Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ openpi web /path/to/repo # 指定初始工作区

Web 可以在选择工作区之前预选可用模型。选择仅保留在当前页面,创建会话后确认模型生效再发送第一条消息;模型不可用时会提示并阻止发送,不会自动换成默认模型。打开已有会话时使用该会话的模型。

Web 的 Subagent 活动条可打开当前 Web Session 的只读详情:状态和完成/中断结果来自原有 Subagent manager,模型、轮次、近期工具与输出只以有界文本展示。该面板不控制或接管子代理,不代表完整子 Session transcript;终端中启动的另一 Pi Session 不会自动出现在当前 Web Session 中。

Pi 当前只原生分派 `install`、`remove`、`update`、`list`、`config` 和 `auth` 等固定子命令,package 不能注册新的顶层子命令。因此 Web 入口是独立 CLI 的 `openpi web`,不是会被 Pi 当成初始 Prompt 的 `pi open`。Web 进程仍沿用 Pi 的 Provider、模型、凭据、Settings、Trust、Session 格式和 extension 资源加载,不引入第二套 Provider 或 Session 存储。

### 命令速查
Expand Down
117 changes: 116 additions & 1 deletion extensions/shared/web-observer-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,37 @@ export interface WebBackgroundTerminalDetail {
readonly truncated: boolean;
}

export type WebCapabilityDetail = WebBackgroundTerminalDetail;
export interface WebSubagentDetail {
readonly kind: "subagents";
readonly id: string;
readonly title: string;
readonly cwd: string;
readonly origin: "model" | "btw";
readonly status: WebSubagentActivity["status"];
readonly outcome?: WebSubagentActivity["outcome"];
readonly createdAt: number;
readonly settledAt?: number;
readonly modelLabel?: string;
readonly turns: number;
readonly transcriptItems: number;
readonly latestOutput: {
readonly text: string;
readonly omittedBytes: number;
};
readonly errorText?: string;
readonly liveTools: readonly {
readonly name: string;
readonly done: boolean;
readonly isError?: boolean;
readonly outputPreview?: string;
}[];
readonly toolsOmitted: number;
readonly truncated: boolean;
}

export type WebCapabilityDetail =
| WebBackgroundTerminalDetail
| WebSubagentDetail;

export type WebCapabilityDetailReceipt =
| { readonly status: "found"; readonly detail: WebCapabilityDetail }
Expand Down Expand Up @@ -148,6 +178,91 @@ function boundedUtf8Tail(value: string, maxBytes: number): BoundedUtf8Text {
};
}

export function projectSubagentDetail(source: {
readonly id: string;
readonly title: string;
readonly cwd: string;
readonly origin: WebSubagentDetail["origin"];
readonly status: WebSubagentDetail["status"];
readonly outcome?: WebSubagentDetail["outcome"];
readonly createdAt: number;
readonly settledAt?: number;
readonly meta: { readonly modelLabel?: string };
readonly turns: number;
readonly transcript: readonly unknown[];
readonly finalText: string;
readonly liveAssistant?: { readonly text: string };
readonly errorText?: string;
readonly liveTools: readonly {
readonly name: string;
readonly done?: boolean;
readonly isError?: boolean;
readonly outputPreview?: string;
}[];
}): WebSubagentDetail {
const title = boundedActivityText(source.title);
const cwd = boundedUtf8Tail(source.cwd, 2 * 1024);
const model = source.meta.modelLabel
? boundedActivityText(source.meta.modelLabel)
: undefined;
const latestOutput =
source.status === "running"
? (source.liveAssistant?.text.trim() ?? "")
: source.finalText;
const output = boundedUtf8Tail(latestOutput, 8 * 1024);
const error = source.errorText
? boundedUtf8Tail(source.errorText, 2 * 1024)
: undefined;
const selectedTools = source.liveTools.slice(-8);
const tools = selectedTools.map((tool) => {
const name = boundedActivityText(tool.name);
const preview = tool.outputPreview
? boundedUtf8Tail(tool.outputPreview, 1024)
: undefined;
return {
item: {
name: name.value,
done: tool.done === true,
...(tool.isError !== undefined ? { isError: tool.isError } : {}),
...(preview ? { outputPreview: preview.value } : {}),
},
truncated: name.truncated || preview?.truncated === true,
};
});
const toolsOmitted = source.liveTools.length - selectedTools.length;
return {
kind: "subagents",
id: source.id,
title: title.value,
cwd: cwd.value,
origin: source.origin,
status: source.status,
...(source.outcome ? { outcome: source.outcome } : {}),
createdAt: source.createdAt,
...(source.settledAt !== undefined ? { settledAt: source.settledAt } : {}),
...(model ? { modelLabel: model.value } : {}),
turns: source.turns,
transcriptItems: source.transcript.length,
latestOutput: {
text: output.value,
omittedBytes: output.truncated
? new TextEncoder().encode(latestOutput).byteLength - output.bytes
: 0,
},
...(error ? { errorText: error.value } : {}),
liveTools: tools.map(({ item }) => item),
toolsOmitted,
truncated:
title.truncated ||
cwd.truncated ||
model?.truncated === true ||
output.truncated ||
error?.truncated === true ||
toolsOmitted > 0 ||
tools.some(({ truncated }) => truncated),
};
}

function projectTerminalOutput(
source: {
readonly modelSafeText: string;
Expand Down
5 changes: 5 additions & 0 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
} from "../shared/tool-surface.ts";
import {
projectSubagentCapability,
projectSubagentDetail,
registerWebCapability,
} from "../shared/web-observer-registry.ts";
import {
Expand Down Expand Up @@ -575,6 +576,10 @@ export default function (
? registerWebCapability(scope, {
kind: "subagents",
snapshot: () => projectSubagentCapability(manager.view.list()),
detail: (id) => {
const agent = manager.view.get(id);
return agent ? projectSubagentDetail(agent) : undefined;
},
subscribe: (listener) => manager.view.subscribe(listener),
})
: undefined;
Expand Down
22 changes: 22 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,28 @@ it("keeps background terminal activity and omission receipts visible", () => {
expect(screen.getByText("+3")).toBeTruthy();
});

it("opens Subagent activity by exact id without treating it as a terminal", () => {
const snapshot = activeSnapshot();
snapshot.runtime.capabilities = {
subagents: {
items: [
{
id: "child-1",
title: "Investigate",
status: "running",
createdAt: 1,
},
],
omitted: 0,
truncated: false,
},
};
const inspect = vi.fn();
render(createElement(ActivityBar, { snapshot, onInspectSubagent: inspect }));
fireEvent.click(screen.getByRole("button", { name: /Investigate/u }));
expect(inspect).toHaveBeenCalledExactlyOnceWith("child-1");
});

it("resolves canonical system theme changes and explicit overrides", () => {
const initial = webStore.getState().snapshot;
const media = new EventTarget();
Expand Down
50 changes: 50 additions & 0 deletions tests/web/inspection-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,56 @@ it("rejects another Session's same-id terminal and renders output as plain text"
).toBeTruthy();
});

it("shows exact Subagent outcome and bounded text without granting control", async () => {
const detail = {
kind: "subagents",
id: "child-1",
title: "Investigate",
cwd: "/ws",
origin: "model",
status: "error",
outcome: "interrupted",
createdAt: 1,
settledAt: 2,
modelLabel: "provider/model",
turns: 2,
transcriptItems: 12,
latestOutput: {
text: '<img src="https://external.example/x" onerror="alert(1)">',
omittedBytes: 20,
},
errorText: "Stopped after cancellation",
liveTools: [{ name: "read", done: true, outputPreview: "partial result" }],
toolsOmitted: 3,
truncated: true,
};
const fetcher = vi
.fn()
.mockResolvedValueOnce(reply({ sessionId: "wrong", detail }))
.mockResolvedValueOnce(reply({ sessionId: "session-a", detail }));
vi.stubGlobal("fetch", fetcher);
const view = show({ ...target, subagentId: "child-1" });
expect(
await screen.findByText("The active session changed. Reopen this panel."),
).toBeTruthy();
expect(screen.queryByText("Stopped after cancellation")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Refresh status" }));
expect(await screen.findByText("Stopped after cancellation")).toBeTruthy();
expect(screen.getByText("Error · Interrupted")).toBeTruthy();
expect(screen.getByText("2 turns · 12 records")).toBeTruthy();
expect(screen.getByText("3 earlier tools omitted.")).toBeTruthy();
expect(
screen.getByText("20 bytes omitted from this output view."),
).toBeTruthy();
expect(screen.getByText(detail.latestOutput.text)).toBeTruthy();
expect(view.container.querySelector(".terminal-evidence img")).toBeNull();
expect(fetcher).toHaveBeenCalledWith(
"/api/capabilities/detail?kind=subagents&id=child-1&sessionId=session-a",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(fetcher.mock.calls[0]?.[1]).not.toHaveProperty("method");
});

function thinkingFetcher(thinking: unknown) {
return vi.fn(async (url: string) => {
if (url.startsWith("/api/thinking")) return reply(thinking);
Expand Down
77 changes: 77 additions & 0 deletions tests/web/observer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
projectBackgroundTerminalCapability,
projectBackgroundTerminalDetail,
projectSubagentCapability,
projectSubagentDetail,
projectWorkflowCapability,
registerWebCapability,
subscribeWebCapabilities,
Expand Down Expand Up @@ -332,3 +333,79 @@ test("detail lookup is Session-scoped, exact, and fail-closed", () => {
unregister();
}
});

test("Subagent detail bounds owner evidence and retains exact outcome", () => {
const detail = projectSubagentDetail({
id: "child-1",
title: "Investigate",
cwd: "/repo",
origin: "model",
status: "error",
outcome: "interrupted",
createdAt: 1,
settledAt: 2,
meta: { modelLabel: "provider/model" },
turns: 3,
transcript: Array.from({ length: 100 }, () => ({})),
finalText: "🙂".repeat(6000),
errorText: "failure".repeat(1000),
liveTools: Array.from({ length: 12 }, (_, index) => ({
name: `tool-${index}`,
done: index < 10,
outputPreview: "x".repeat(3000),
})),
});
assert.equal(detail.outcome, "interrupted");
assert.equal(detail.transcriptItems, 100);
assert.equal(detail.liveTools.length, 8);
assert.equal(detail.toolsOmitted, 4);
assert.ok(Buffer.byteLength(detail.latestOutput.text) <= 8 * 1024);
assert.ok(detail.latestOutput.omittedBytes > 0);
assert.ok(Buffer.byteLength(detail.errorText ?? "") <= 2 * 1024);
assert.ok(
detail.liveTools.every(
(tool) => Buffer.byteLength(tool.outputPreview ?? "") <= 1024,
),
);
assert.equal(detail.truncated, true);

const scope = sessionScope();
const unregister = registerWebCapability(scope, {
kind: "subagents",
snapshot: () => ({ items: [], omitted: 0, truncated: false }),
detail: (id) => (id === detail.id ? detail : undefined),
});
try {
assert.deepEqual(webCapabilityDetail(scope, "subagents", "child-1"), {
status: "found",
detail,
});
assert.deepEqual(webCapabilityDetail(scope, "subagents", "child-2"), {
status: "missing",
});
assert.deepEqual(
webCapabilityDetail(sessionScope(), "subagents", "child-1"),
{ status: "unavailable" },
);
} finally {
unregister();
}
});

test("a restarted running Subagent does not present the previous run's final text as live output", () => {
const detail = projectSubagentDetail({
id: "child-2",
title: "Continue",
cwd: "/repo",
origin: "model",
status: "running",
createdAt: 1,
meta: {},
turns: 1,
transcript: [],
finalText: "previous run",
liveTools: [],
});
assert.equal(detail.latestOutput.text, "");
assert.equal(detail.truncated, false);
});
Loading
Loading