Fill OpenCode subagent info: task, type, model, and tokens (#2909)
* feat(opencode): fill subagent descriptor with task, type, model, and tokens OpenCode subagent rows rendered with no information: the initial upsert carried only the child session title and status, so the track showed a bare title with an empty second line and the pane tab fell back to "Subagent". Fill the same provider-agnostic descriptor fields Claude populates since #2498, from data OpenCode already emits: - description <- the task tool call's description input (fallback: the child session title), so the task names the row - title <- subagent_type from the task call, else the session's agent - toolCallId <- the parent task tool call id - subtitle <- "agent · model · Variant · Nk tokens", folded from the child's own session record and assistant frames, deduped per child Facts are never inherited from the parent; absent facts render as nothing. Presentation upserts omit status so a late subtitle update cannot revert a finished child (the store's sticky contract). Both task-link orderings are handled, and the hydration path derives the same facts for historical children so restored sessions match live. Server-side only; no protocol or app changes. * fix(opencode): emit subagent type as descriptor title from assistant frames * feat(app): show subagent info line in the provider subagent pane * polish: address subagent presentation review nits * fix(opencode): link subagents from task metadata Current OpenCode task results identify their child session in tool metadata rather than the legacy textual output. Preserve that boundary field so parallel task calls retain the correct description, type, and tool-call association. --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
e1b4058b77
commit
5465d66edd
@@ -15,6 +15,8 @@ interface ProviderSubagentCase {
|
|||||||
provider: RewindFlowProvider;
|
provider: RewindFlowProvider;
|
||||||
sentinel: string;
|
sentinel: string;
|
||||||
expectedName: string;
|
expectedName: string;
|
||||||
|
expectedSubtitle?: RegExp;
|
||||||
|
expectsUserMessage?: boolean;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||||
}
|
}
|
||||||
@@ -39,9 +41,12 @@ const cases: ProviderSubagentCase[] = [
|
|||||||
{
|
{
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||||
expectedName: "Explore",
|
expectedName: "Verify OpenCode descriptor",
|
||||||
|
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||||
|
expectsUserMessage: false,
|
||||||
|
providerConfig: { model: "openai/gpt-5.4" },
|
||||||
prompt:
|
prompt:
|
||||||
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ test.describe("real provider subagent timelines", () => {
|
|||||||
for (const scenario of cases) {
|
for (const scenario of cases) {
|
||||||
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const cwd = realpathSync(
|
const cwd = realpathSync(
|
||||||
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
||||||
);
|
);
|
||||||
@@ -71,13 +76,52 @@ test.describe("real provider subagent timelines", () => {
|
|||||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||||
await expect(rows.first()).toContainText(scenario.expectedName);
|
await expect(rows.first()).toContainText(scenario.expectedName);
|
||||||
|
if (scenario.expectedSubtitle) {
|
||||||
|
await expect(rows.first()).toContainText(scenario.expectedSubtitle);
|
||||||
|
const desktopTrackScreenshot = testInfo.outputPath(
|
||||||
|
`${scenario.provider}-subagent-track-desktop.png`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: desktopTrackScreenshot });
|
||||||
|
await testInfo.attach(`${scenario.provider} subagent track desktop`, {
|
||||||
|
path: desktopTrackScreenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
await rows.first().click();
|
await rows.first().click();
|
||||||
|
|
||||||
const panel = page.getByTestId("provider-subagent-panel");
|
const panel = page.getByTestId("provider-subagent-panel");
|
||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(
|
if (scenario.expectedSubtitle) {
|
||||||
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
|
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
|
||||||
).toBeVisible({ timeout: 30_000 });
|
scenario.expectedSubtitle,
|
||||||
|
);
|
||||||
|
const desktopScreenshot = testInfo.outputPath(
|
||||||
|
`${scenario.provider}-subagent-desktop.png`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: desktopScreenshot });
|
||||||
|
await testInfo.attach(`${scenario.provider} subagent desktop`, {
|
||||||
|
path: desktopScreenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 494, height: 862 });
|
||||||
|
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
|
||||||
|
scenario.expectedSubtitle,
|
||||||
|
);
|
||||||
|
const compactScreenshot = testInfo.outputPath(
|
||||||
|
`${scenario.provider}-subagent-compact.png`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: compactScreenshot });
|
||||||
|
await testInfo.attach(`${scenario.provider} subagent compact`, {
|
||||||
|
path: compactScreenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
}
|
||||||
|
if (scenario.expectsUserMessage !== false) {
|
||||||
|
await expect(
|
||||||
|
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
await expect(
|
await expect(
|
||||||
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ function ProviderSubagentPanel() {
|
|||||||
const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||||
const progressKey =
|
const progressKey =
|
||||||
timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null;
|
timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null;
|
||||||
|
const subtitle = descriptor?.subtitle?.trim();
|
||||||
|
|
||||||
const streamContext = useMemo<AgentScreenAgent>(
|
const streamContext = useMemo<AgentScreenAgent>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -183,6 +184,17 @@ function ProviderSubagentPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="provider-subagent-panel">
|
<View style={styles.container} testID="provider-subagent-panel">
|
||||||
|
{subtitle ? (
|
||||||
|
<View style={styles.subtitleHeader}>
|
||||||
|
<Text
|
||||||
|
style={styles.subtitleText}
|
||||||
|
numberOfLines={1}
|
||||||
|
testID="provider-subagent-pane-subtitle"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<AgentStreamView
|
<AgentStreamView
|
||||||
agentId={streamId}
|
agentId={streamId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -201,7 +213,17 @@ function ProviderSubagentPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: { flex: 1 },
|
container: { flex: 1, minHeight: 0 },
|
||||||
|
subtitleHeader: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
subtitleText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3249,7 +3249,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: "ses_child_registry",
|
id: "ses_child_registry",
|
||||||
title: "Live child",
|
description: "Live child",
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -4046,7 +4046,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: "ses_child_background",
|
id: "ses_child_background",
|
||||||
title: "Plugin child",
|
description: "Plugin child",
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -4094,13 +4094,539 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: "ses_child_plugin",
|
id: "ses_child_plugin",
|
||||||
title: "Background plugin child",
|
description: "Background plugin child",
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("folds child assistant facts into deduped presentation upserts without status", async () => {
|
||||||
|
const releaseEvents = createTestDeferred<void>();
|
||||||
|
const eventsConsumed = createTestDeferred<void>();
|
||||||
|
const fakeClient = {
|
||||||
|
global: {
|
||||||
|
event: vi.fn().mockResolvedValue({
|
||||||
|
stream: (async function* () {
|
||||||
|
yield { type: "server.connected", properties: {} };
|
||||||
|
await releaseEvents.promise;
|
||||||
|
yield {
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: { id: "ses_child_facts", parentID: "ses_parent", title: "Fact child" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Streaming frame: model facts, no completion yet.
|
||||||
|
yield {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_child_facts",
|
||||||
|
sessionID: "ses_child_facts",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "general",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-5",
|
||||||
|
variant: "high",
|
||||||
|
time: { created: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Same facts again: must not re-emit an identical subtitle upsert.
|
||||||
|
yield {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_child_facts",
|
||||||
|
sessionID: "ses_child_facts",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "general",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-5",
|
||||||
|
variant: "high",
|
||||||
|
time: { created: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Completion carries the token sums.
|
||||||
|
yield {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_child_facts",
|
||||||
|
sessionID: "ses_child_facts",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "general",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-5",
|
||||||
|
variant: "high",
|
||||||
|
time: { created: 1, completed: 2 },
|
||||||
|
tokens: {
|
||||||
|
input: 10_000,
|
||||||
|
output: 5_000,
|
||||||
|
reasoning: 1_000,
|
||||||
|
cache: { read: 400, write: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield { type: "session.idle", properties: { sessionID: "ses_child_facts" } };
|
||||||
|
eventsConsumed.resolve();
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||||
|
update: vi.fn().mockResolvedValue({ error: null }),
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||||
|
{ provider: "opencode", cwd: "/tmp/test" },
|
||||||
|
fakeClient,
|
||||||
|
"ses_parent",
|
||||||
|
createTestLogger(),
|
||||||
|
);
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
releaseEvents.resolve();
|
||||||
|
await eventsConsumed.promise;
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
const subtitleUpserts = events.flatMap((event) =>
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "upsert" &&
|
||||||
|
event.event.subtitle !== undefined
|
||||||
|
? [event.event]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(subtitleUpserts).toEqual([
|
||||||
|
{
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_facts",
|
||||||
|
title: "general",
|
||||||
|
subtitle: "general · claude-sonnet-5 · High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_facts",
|
||||||
|
subtitle: "general · claude-sonnet-5 · High · 16.5k tokens",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Presentation upserts must not carry status: they can never revert a finished child.
|
||||||
|
for (const upsert of subtitleUpserts) {
|
||||||
|
expect(upsert).not.toHaveProperty("status");
|
||||||
|
}
|
||||||
|
expect(subtitleUpserts.filter((upsert) => upsert.title !== undefined)).toHaveLength(1);
|
||||||
|
expect(events.at(-1)).toEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: { type: "upsert", id: "ses_child_facts", status: "completed" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not overwrite a link-set title with the assistant-frame agent", async () => {
|
||||||
|
const { parent, openCode } = await createParentSession("ses_parent_link_title");
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
parent.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
openCode.emitEvent({
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_link_title_task",
|
||||||
|
sessionID: "ses_parent_link_title",
|
||||||
|
messageID: "msg_parent_link_title",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: "call_link_title",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { subagent_type: "explore", description: "Inspect title precedence" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
openCode.emitEvent({
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "ses_child_link_title",
|
||||||
|
parentID: "ses_parent_link_title",
|
||||||
|
title: "Inspect title precedence",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: expect.objectContaining({
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_link_title",
|
||||||
|
title: "explore",
|
||||||
|
toolCallId: "call_link_title",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
openCode.emitEvent({
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_child_link_title",
|
||||||
|
sessionID: "ses_child_link_title",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "general",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-5",
|
||||||
|
time: { created: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_link_title",
|
||||||
|
subtitle: "general · claude-sonnet-5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const childTitles = events.flatMap((event) =>
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "upsert" &&
|
||||||
|
event.event.id === "ses_child_link_title" &&
|
||||||
|
event.event.title !== undefined
|
||||||
|
? [event.event.title]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(childTitles).toEqual(["explore"]);
|
||||||
|
await parent.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps child detection facts onto title, description, and subtitle", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
|
||||||
|
const events = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "ses_child_rich",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
title: "Investigate flaky test",
|
||||||
|
agent: "explore",
|
||||||
|
model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "high" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_rich",
|
||||||
|
title: "explore",
|
||||||
|
description: "Investigate flaky test",
|
||||||
|
status: "running",
|
||||||
|
subtitle: "explore · claude-sonnet-5 · High",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves title and description unset when the child session carries no facts", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
|
||||||
|
const events = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: { id: "ses_child_bare", parentID: "ses_parent" },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: { type: "upsert", id: "ses_child_bare", status: "running" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("links a waiting task tool call to a child detected afterwards", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
|
events.push(
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_parent_task",
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
messageID: "msg_parent",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: "call_task",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { subagent_type: "explore", description: "Inspect repo" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: { id: "ses_child_linked", parentID: "ses_parent", title: "Inspect repo" },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_linked",
|
||||||
|
toolCallId: "call_task",
|
||||||
|
title: "explore",
|
||||||
|
description: "Inspect repo",
|
||||||
|
subtitle: "explore",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("links a task tool call whose metadata resolves the child session id after detection", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
|
events.push(
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: { id: "ses_childlatelink", parentID: "ses_parent", title: "Late link" },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
// No task call was waiting at detection time; the link arrives via the tool output.
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_parent_task_late",
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
messageID: "msg_parent",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: "call_task_late",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { subagent_type: "plan", description: "Draft the plan" },
|
||||||
|
output: '<task id="ses_childlatelink">Done.</task>',
|
||||||
|
metadata: { sessionId: "ses_childlatelink" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_childlatelink",
|
||||||
|
toolCallId: "call_task_late",
|
||||||
|
title: "plan",
|
||||||
|
description: "Draft the plan",
|
||||||
|
subtitle: "plan",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("links parallel task calls from their canonical child session metadata", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
|
for (const task of [
|
||||||
|
{ callId: "call_alpha", description: "Inspect alpha" },
|
||||||
|
{ callId: "call_beta", description: "Inspect beta" },
|
||||||
|
]) {
|
||||||
|
events.push(
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: `prt_${task.callId}`,
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
messageID: "msg_parent",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: task.callId,
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { subagent_type: "explore", description: task.description },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const childSessionId of ["ses_child_alpha", "ses_child_beta"]) {
|
||||||
|
events.push(
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: { id: childSessionId, parentID: "ses_parent", title: "Child session" },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of [
|
||||||
|
{ callId: "call_alpha", childSessionId: "ses_child_alpha", description: "Inspect alpha" },
|
||||||
|
{ callId: "call_beta", childSessionId: "ses_child_beta", description: "Inspect beta" },
|
||||||
|
]) {
|
||||||
|
events.push(
|
||||||
|
...translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: `prt_${task.callId}`,
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
messageID: "msg_parent",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: task.callId,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { subagent_type: "explore", description: task.description },
|
||||||
|
output: `<task id="${task.childSessionId}">Done.</task>`,
|
||||||
|
metadata: { sessionId: task.childSessionId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_alpha",
|
||||||
|
toolCallId: "call_alpha",
|
||||||
|
title: "explore",
|
||||||
|
description: "Inspect alpha",
|
||||||
|
subtitle: "explore",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_beta",
|
||||||
|
toolCallId: "call_beta",
|
||||||
|
title: "explore",
|
||||||
|
description: "Inspect beta",
|
||||||
|
subtitle: "explore",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linking after detection keeps the task description over the session title", () => {
|
||||||
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
|
|
||||||
|
const detection = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_parent_task",
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
messageID: "msg_parent",
|
||||||
|
type: "tool",
|
||||||
|
tool: "task",
|
||||||
|
callID: "call_task",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { subagent_type: "explore", description: "Audit configs" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
const linked = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "ses_child_dup",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
title: "OpenCode session title",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const upserts = [...detection, ...linked].flatMap((event) =>
|
||||||
|
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||||
|
);
|
||||||
|
// Detection precedes the link inside the same translation batch; the link's description
|
||||||
|
// (task input) lands last, so the sticky store keeps the task as the row label.
|
||||||
|
expect(upserts.at(-1)).toMatchObject({
|
||||||
|
id: "ses_child_dup",
|
||||||
|
toolCallId: "call_task",
|
||||||
|
title: "explore",
|
||||||
|
description: "Audit configs",
|
||||||
|
});
|
||||||
|
for (const upsert of upserts) {
|
||||||
|
expect(upsert.title).not.toBe("OpenCode session title");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("translates provider deletion of a known child session", () => {
|
test("translates provider deletion of a known child session", () => {
|
||||||
const state = createOpenCodeTranslationState("ses_parent");
|
const state = createOpenCodeTranslationState("ses_parent");
|
||||||
state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task");
|
state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task");
|
||||||
@@ -4159,12 +4685,12 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
{
|
{
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
event: { type: "upsert", id: "ses_child_a", title: "Child A", status: "completed" },
|
event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
event: { type: "upsert", id: "ses_child_b", title: "Child B", status: "completed" },
|
event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
@@ -4172,7 +4698,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: "ses_grandchild_a",
|
id: "ses_grandchild_a",
|
||||||
title: "Grandchild A",
|
description: "Grandchild A",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4234,7 +4760,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: "ses_child_with_history",
|
id: "ses_child_with_history",
|
||||||
title: "Historical child",
|
description: "Historical child",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
cwd: "/workspace/child",
|
cwd: "/workspace/child",
|
||||||
},
|
},
|
||||||
@@ -4261,6 +4787,86 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("derives hydrated child presentation facts from the session record and last assistant message", async () => {
|
||||||
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
const openCodeClient = new TestOpenCodeClient();
|
||||||
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_facts" } };
|
||||||
|
openCodeClient.sessionChildrenResponses = [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "ses_child_hydrated_facts",
|
||||||
|
parentID: "ses_parent_facts",
|
||||||
|
title: "Chase the regression",
|
||||||
|
model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "max" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ data: [] },
|
||||||
|
];
|
||||||
|
openCodeClient.sessionMessagesResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_child_hydrated",
|
||||||
|
sessionID: "ses_child_hydrated_facts",
|
||||||
|
role: "assistant",
|
||||||
|
agent: "explore",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-5",
|
||||||
|
variant: "max",
|
||||||
|
time: { created: 2, completed: 3 },
|
||||||
|
tokens: { input: 800, output: 150, reasoning: 30, cache: { read: 15, write: 5 } },
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "prt_child_hydrated",
|
||||||
|
sessionID: "ses_child_hydrated_facts",
|
||||||
|
messageID: "msg_child_hydrated",
|
||||||
|
type: "text",
|
||||||
|
text: "Found it.",
|
||||||
|
time: { start: 2, end: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
runtime.enqueueClient(openCodeClient);
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2));
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_hydrated_facts",
|
||||||
|
description: "Chase the regression",
|
||||||
|
status: "completed",
|
||||||
|
subtitle: "claude-sonnet-5 · Max",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: "ses_child_hydrated_facts",
|
||||||
|
title: "explore",
|
||||||
|
subtitle: "explore · claude-sonnet-5 · Max · 1k tokens",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("preserves child events that arrive while existing children hydrate", async () => {
|
test("preserves child events that arrive while existing children hydrate", async () => {
|
||||||
const hydration = createTestDeferred<{
|
const hydration = createTestDeferred<{
|
||||||
data: Array<{ id: string; parentID: string; title: string }>;
|
data: Array<{ id: string; parentID: string; title: string }>;
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
|||||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||||
|
import {
|
||||||
|
claimOpenCodeSubagentFallbackTitle,
|
||||||
|
foldOpenCodeSubagentPresentation,
|
||||||
|
type OpenCodeSubagentPresentationFacts,
|
||||||
|
type OpenCodeSubagentPresentationState,
|
||||||
|
} from "./opencode/subagent-presentation.js";
|
||||||
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
|
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
|
||||||
|
|
||||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -312,6 +318,7 @@ const OpencodeToolStateSchema = z
|
|||||||
input: z.unknown().optional(),
|
input: z.unknown().optional(),
|
||||||
output: z.unknown().optional(),
|
output: z.unknown().optional(),
|
||||||
error: z.unknown().optional(),
|
error: z.unknown().optional(),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -332,6 +339,7 @@ const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
|
|||||||
input: part.state?.input,
|
input: part.state?.input,
|
||||||
output: part.state?.output,
|
output: part.state?.output,
|
||||||
error: part.state?.error,
|
error: part.state?.error,
|
||||||
|
metadata: part.state?.metadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
|
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
|
||||||
@@ -344,6 +352,7 @@ const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
|
|||||||
input: part.state?.input,
|
input: part.state?.input,
|
||||||
output: part.state?.output,
|
output: part.state?.output,
|
||||||
error: part.state?.error,
|
error: part.state?.error,
|
||||||
|
metadata: part.state?.metadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
|
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
|
||||||
@@ -356,6 +365,7 @@ const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
|
|||||||
input: part.state?.input,
|
input: part.state?.input,
|
||||||
output: part.state?.output,
|
output: part.state?.output,
|
||||||
error: part.state?.error,
|
error: part.state?.error,
|
||||||
|
metadata: part.state?.metadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OpencodeToolPartSchema = z.union([
|
const OpencodeToolPartSchema = z.union([
|
||||||
@@ -371,6 +381,7 @@ const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform(
|
|||||||
input: part.input,
|
input: part.input,
|
||||||
output: part.output,
|
output: part.output,
|
||||||
error: part.error,
|
error: part.error,
|
||||||
|
metadata: part.metadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform(
|
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform(
|
||||||
@@ -382,6 +393,7 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
|
|||||||
input: part.input,
|
input: part.input,
|
||||||
output: part.output,
|
output: part.output,
|
||||||
error: part.error,
|
error: part.error,
|
||||||
|
metadata: part.metadata,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1724,6 +1736,7 @@ export interface OpenCodeEventTranslationState {
|
|||||||
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
|
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
|
||||||
subAgentCallIdByChildSessionId?: Map<string, string>;
|
subAgentCallIdByChildSessionId?: Map<string, string>;
|
||||||
knownChildSessionIds?: Set<string>;
|
knownChildSessionIds?: Set<string>;
|
||||||
|
subagentPresentationByChildId?: Map<string, OpenCodeSubagentPresentationState>;
|
||||||
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
||||||
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||||
}
|
}
|
||||||
@@ -1761,6 +1774,8 @@ interface OpenCodeChildSessionInfo {
|
|||||||
title?: string;
|
title?: string;
|
||||||
directory?: string;
|
directory?: string;
|
||||||
revert?: OpenCodePersistedSession["revert"];
|
revert?: OpenCodePersistedSession["revert"];
|
||||||
|
agent?: string;
|
||||||
|
model?: { id: string; variant?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenCodeSubAgentActivityState {
|
interface OpenCodeSubAgentActivityState {
|
||||||
@@ -2102,6 +2117,62 @@ function getOpenCodeKnownChildSessionIds(state: OpenCodeEventTranslationState):
|
|||||||
return state.knownChildSessionIds;
|
return state.knownChildSessionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOpenCodeSubagentPresentationState(
|
||||||
|
childSessionId: string,
|
||||||
|
state: OpenCodeEventTranslationState,
|
||||||
|
): OpenCodeSubagentPresentationState {
|
||||||
|
state.subagentPresentationByChildId ??= new Map();
|
||||||
|
const existing = state.subagentPresentationByChildId.get(childSessionId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const created: OpenCodeSubagentPresentationState = { facts: {} };
|
||||||
|
state.subagentPresentationByChildId.set(childSessionId, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumOpenCodeAssistantMessageTokens(
|
||||||
|
tokens: OpenCodeAssistantMessage["tokens"] | undefined,
|
||||||
|
): number {
|
||||||
|
if (!tokens) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(readPositiveFiniteNumber(tokens.input) ?? 0) +
|
||||||
|
(readPositiveFiniteNumber(tokens.output) ?? 0) +
|
||||||
|
(readPositiveFiniteNumber(tokens.reasoning) ?? 0) +
|
||||||
|
(readPositiveFiniteNumber(tokens.cache?.read) ?? 0) +
|
||||||
|
(readPositiveFiniteNumber(tokens.cache?.write) ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presentation facts observable on a child assistant message. Token sums only count once the
|
||||||
|
* message completes, so partial frames don't publish a shrinking total. */
|
||||||
|
function readOpenCodeAssistantPresentationFacts(
|
||||||
|
info: OpenCodeAssistantMessage,
|
||||||
|
): OpenCodeSubagentPresentationFacts | null {
|
||||||
|
const facts: OpenCodeSubagentPresentationFacts = {};
|
||||||
|
const agentName = readNonEmptyString(info.agent);
|
||||||
|
if (agentName) {
|
||||||
|
facts.agentName = agentName;
|
||||||
|
}
|
||||||
|
const modelId = readNonEmptyString(info.modelID);
|
||||||
|
if (modelId) {
|
||||||
|
facts.modelId = modelId;
|
||||||
|
}
|
||||||
|
const variant = readNonEmptyString(info.variant);
|
||||||
|
if (variant) {
|
||||||
|
facts.variant = variant;
|
||||||
|
}
|
||||||
|
if (info.time?.completed !== undefined) {
|
||||||
|
const totalTokens = sumOpenCodeAssistantMessageTokens(info.tokens);
|
||||||
|
if (totalTokens > 0) {
|
||||||
|
facts.totalTokens = totalTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(facts).length > 0 ? facts : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isOpenCodeSessionTrackedByParent(
|
function isOpenCodeSessionTrackedByParent(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
@@ -2127,20 +2198,34 @@ function appendOpenCodeChildSessionDetected(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const knownChildSessionIds = getOpenCodeKnownChildSessionIds(state);
|
const knownChildSessionIds = getOpenCodeKnownChildSessionIds(state);
|
||||||
|
// Known limitation: detection runs once per child, so a session record that gains `agent`
|
||||||
|
// only in a later session.updated is not refreshed here. Assistant-frame facts recover the
|
||||||
|
// descriptor title and subtitle via appendChildAssistantPresentationUpsert.
|
||||||
if (knownChildSessionIds.has(child.id)) {
|
if (knownChildSessionIds.has(child.id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
knownChildSessionIds.add(child.id);
|
knownChildSessionIds.add(child.id);
|
||||||
|
const presentation = getOpenCodeSubagentPresentationState(child.id, state);
|
||||||
|
const subtitle = foldOpenCodeSubagentPresentation(presentation, {
|
||||||
|
...(child.agent ? { agentName: child.agent } : {}),
|
||||||
|
...(child.model?.id ? { modelId: child.model.id } : {}),
|
||||||
|
...(child.model?.variant ? { variant: child.model.variant } : {}),
|
||||||
|
});
|
||||||
|
const title = claimOpenCodeSubagentFallbackTitle(presentation, child.agent);
|
||||||
|
// The row label contract: `description` carries the task (session title fallback), `title`
|
||||||
|
// carries the subagent type. Neither gets a placeholder — absent facts render as nothing.
|
||||||
events.push({
|
events.push({
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: child.id,
|
id: child.id,
|
||||||
title: child.title ?? "OpenCode subagent",
|
...(title ? { title } : {}),
|
||||||
|
...(child.title && !presentation.descriptionFromLink ? { description: child.title } : {}),
|
||||||
status,
|
status,
|
||||||
...(child.directory ? { cwd: child.directory } : {}),
|
...(child.directory ? { cwd: child.directory } : {}),
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -2169,10 +2254,59 @@ function linkOpenCodeSubAgentChildSession(
|
|||||||
activity: OpenCodeSubAgentActivityState,
|
activity: OpenCodeSubAgentActivityState,
|
||||||
childSessionId: string,
|
childSessionId: string,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
|
events: AgentStreamEvent[],
|
||||||
): void {
|
): void {
|
||||||
activity.childSessionId = childSessionId;
|
activity.childSessionId = childSessionId;
|
||||||
const maps = getOpenCodeSubAgentMaps(state);
|
const maps = getOpenCodeSubAgentMaps(state);
|
||||||
maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId);
|
maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId);
|
||||||
|
appendOpenCodeSubAgentLinkPresentation(activity, childSessionId, state, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a child session ties to a parent `task` tool call, publish the task's identity onto the
|
||||||
|
* descriptor: `description` (task input), `title` (subagent type), `toolCallId`. Presentation
|
||||||
|
* only — no `status`, so it can never revert a finished child.
|
||||||
|
*/
|
||||||
|
function appendOpenCodeSubAgentLinkPresentation(
|
||||||
|
activity: OpenCodeSubAgentActivityState,
|
||||||
|
childSessionId: string,
|
||||||
|
state: OpenCodeEventTranslationState,
|
||||||
|
events: AgentStreamEvent[],
|
||||||
|
): void {
|
||||||
|
const detail = activity.toolCall.detail;
|
||||||
|
if (detail.type !== "sub_agent") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = getOpenCodeSubagentPresentationState(childSessionId, state);
|
||||||
|
if (presentation.linkedToolCallId === activity.toolCall.callId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presentation.linkedToolCallId = activity.toolCall.callId;
|
||||||
|
const subAgentType = readNonEmptyString(detail.subAgentType);
|
||||||
|
const description = readNonEmptyString(detail.description);
|
||||||
|
if (subAgentType) {
|
||||||
|
presentation.titleFromLink = true;
|
||||||
|
presentation.titleEmitted = true;
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
presentation.descriptionFromLink = true;
|
||||||
|
}
|
||||||
|
const subtitle = foldOpenCodeSubagentPresentation(
|
||||||
|
presentation,
|
||||||
|
subAgentType ? { agentName: subAgentType } : {},
|
||||||
|
);
|
||||||
|
events.push({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: childSessionId,
|
||||||
|
toolCallId: activity.toolCall.callId,
|
||||||
|
...(subAgentType ? { title: subAgentType } : {}),
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenCodeSubAgentTimelineItem(
|
function buildOpenCodeSubAgentTimelineItem(
|
||||||
@@ -2195,13 +2329,14 @@ function buildOpenCodeSubAgentTimelineItem(
|
|||||||
function registerOpenCodeSubAgentToolCall(
|
function registerOpenCodeSubAgentToolCall(
|
||||||
item: ToolCallTimelineItem,
|
item: ToolCallTimelineItem,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
|
events: AgentStreamEvent[],
|
||||||
): ToolCallTimelineItem {
|
): ToolCallTimelineItem {
|
||||||
if (item.detail.type !== "sub_agent") {
|
if (item.detail.type !== "sub_agent") {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
const activity = getOpenCodeSubAgentState(item.callId, state, item);
|
const activity = getOpenCodeSubAgentState(item.callId, state, item);
|
||||||
if (item.detail.childSessionId) {
|
if (item.detail.childSessionId) {
|
||||||
linkOpenCodeSubAgentChildSession(activity, item.detail.childSessionId, state);
|
linkOpenCodeSubAgentChildSession(activity, item.detail.childSessionId, state, events);
|
||||||
}
|
}
|
||||||
return buildOpenCodeSubAgentTimelineItem(activity);
|
return buildOpenCodeSubAgentTimelineItem(activity);
|
||||||
}
|
}
|
||||||
@@ -2224,7 +2359,7 @@ function appendOpenCodeToolCallTimelineItem(
|
|||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
): void {
|
): void {
|
||||||
const timelineItem = registerOpenCodeSubAgentToolCall(item, state);
|
const timelineItem = registerOpenCodeSubAgentToolCall(item, state, events);
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -2241,7 +2376,7 @@ function appendOpenCodeSubAgentChildSessionLinked(
|
|||||||
if (!activity) {
|
if (!activity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
linkOpenCodeSubAgentChildSession(activity, childSessionId, state);
|
linkOpenCodeSubAgentChildSession(activity, childSessionId, state, events);
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -2271,20 +2406,14 @@ function appendOpenCodeSessionCreatedOrUpdated(
|
|||||||
|
|
||||||
const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId);
|
const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId);
|
||||||
if (parentSessionId) {
|
if (parentSessionId) {
|
||||||
appendOpenCodeChildSessionDetected(
|
const child = readOpenCodeChildSessionInfo({
|
||||||
{
|
...info,
|
||||||
id: event.properties.info.id,
|
id: event.properties.info.id,
|
||||||
parentSessionId,
|
parentID: parentSessionId,
|
||||||
...(readNonEmptyString(info?.title)
|
});
|
||||||
? { title: readNonEmptyString(info?.title) ?? undefined }
|
if (child) {
|
||||||
: {}),
|
appendOpenCodeChildSessionDetected(child, state, events);
|
||||||
...(readNonEmptyString(info?.directory)
|
}
|
||||||
? { directory: readNonEmptyString(info?.directory) ?? undefined }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (parentSessionId === state.sessionId) {
|
if (parentSessionId === state.sessionId) {
|
||||||
appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events);
|
appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events);
|
||||||
@@ -2302,6 +2431,7 @@ function appendOpenCodeSessionDeleted(
|
|||||||
}
|
}
|
||||||
state.knownChildSessionIds?.delete(sessionId);
|
state.knownChildSessionIds?.delete(sessionId);
|
||||||
state.subAgentCallIdByChildSessionId?.delete(sessionId);
|
state.subAgentCallIdByChildSessionId?.delete(sessionId);
|
||||||
|
state.subagentPresentationByChildId?.delete(sessionId);
|
||||||
events.push({
|
events.push({
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -2857,12 +2987,29 @@ function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo
|
|||||||
const title = readNonEmptyString(record.title);
|
const title = readNonEmptyString(record.title);
|
||||||
const directory = readNonEmptyString(record.directory);
|
const directory = readNonEmptyString(record.directory);
|
||||||
const revert = readOpenCodeRecord(record.revert) as OpenCodePersistedSession["revert"] | null;
|
const revert = readOpenCodeRecord(record.revert) as OpenCodePersistedSession["revert"] | null;
|
||||||
|
const agent = readNonEmptyString(record.agent);
|
||||||
|
const model = readOpenCodeChildSessionModel(record.model);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
parentSessionId,
|
parentSessionId,
|
||||||
...(title ? { title } : {}),
|
...(title ? { title } : {}),
|
||||||
...(directory ? { directory } : {}),
|
...(directory ? { directory } : {}),
|
||||||
...(revert ? { revert } : {}),
|
...(revert ? { revert } : {}),
|
||||||
|
...(agent ? { agent } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpenCodeChildSessionModel(value: unknown): OpenCodeChildSessionInfo["model"] | null {
|
||||||
|
const record = readOpenCodeRecord(value);
|
||||||
|
const id = readNonEmptyString(record?.id);
|
||||||
|
if (!record || !id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const variant = readNonEmptyString(record.variant);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2956,6 +3103,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private subAgentsByCallId = new Map<string, OpenCodeSubAgentActivityState>();
|
private subAgentsByCallId = new Map<string, OpenCodeSubAgentActivityState>();
|
||||||
private subAgentCallIdByChildSessionId = new Map<string, string>();
|
private subAgentCallIdByChildSessionId = new Map<string, string>();
|
||||||
private knownChildSessionIds = new Set<string>();
|
private knownChildSessionIds = new Set<string>();
|
||||||
|
private readonly subagentPresentationByChildId = new Map<
|
||||||
|
string,
|
||||||
|
OpenCodeSubagentPresentationState
|
||||||
|
>();
|
||||||
private readonly childTranslationStates = new Map<string, OpenCodeEventTranslationState>();
|
private readonly childTranslationStates = new Map<string, OpenCodeEventTranslationState>();
|
||||||
private readonly childSessionCwds = new Map<string, string>();
|
private readonly childSessionCwds = new Map<string, string>();
|
||||||
private readonly pendingPermissionDirectories = new Map<string, string>();
|
private readonly pendingPermissionDirectories = new Map<string, string>();
|
||||||
@@ -3544,6 +3695,50 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
translationState.hydratedPartFingerprints?.set(part.id, JSON.stringify(part));
|
translationState.hydratedPartFingerprints?.set(part.id, JSON.stringify(part));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.emitHydratedChildPresentation(child, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After replaying a historical child, derive presentation facts from the last assistant
|
||||||
|
* message (the session record's agent/model were already folded at detection) and publish
|
||||||
|
* any missing title plus the updated subtitle once. Presentation-only: no `status`.
|
||||||
|
*/
|
||||||
|
private emitHydratedChildPresentation(
|
||||||
|
child: OpenCodeChildSessionInfo,
|
||||||
|
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
||||||
|
): void {
|
||||||
|
const lastAssistant = messages.findLast(
|
||||||
|
(message): message is OpenCodeSessionMessage & { info: OpenCodeAssistantMessage } =>
|
||||||
|
message.info.role === "assistant",
|
||||||
|
);
|
||||||
|
if (!lastAssistant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const facts = readOpenCodeAssistantPresentationFacts(lastAssistant.info);
|
||||||
|
if (!facts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = getOpenCodeSubagentPresentationState(
|
||||||
|
child.id,
|
||||||
|
this.getChildTranslationState(child.id),
|
||||||
|
);
|
||||||
|
const subtitle = foldOpenCodeSubagentPresentation(presentation, facts);
|
||||||
|
const title = claimOpenCodeSubagentFallbackTitle(presentation, facts.agentName);
|
||||||
|
if (!subtitle && !title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event: AgentStreamEvent = {
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: child.id,
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.recordProviderInternalEvent(event);
|
||||||
|
this.notifySubscribers(event, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordProviderInternalEvent(event: AgentStreamEvent): void {
|
private recordProviderInternalEvent(event: AgentStreamEvent): void {
|
||||||
@@ -3562,6 +3757,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
unregisterOpenCodeChildSessionServerUrl(event.event.id);
|
unregisterOpenCodeChildSessionServerUrl(event.event.id);
|
||||||
this.childTranslationStates.delete(event.event.id);
|
this.childTranslationStates.delete(event.event.id);
|
||||||
this.childSessionCwds.delete(event.event.id);
|
this.childSessionCwds.delete(event.event.id);
|
||||||
|
this.subagentPresentationByChildId.delete(event.event.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4335,6 +4531,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
subAgentsByCallId: this.subAgentsByCallId,
|
subAgentsByCallId: this.subAgentsByCallId,
|
||||||
subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId,
|
subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId,
|
||||||
knownChildSessionIds: this.knownChildSessionIds,
|
knownChildSessionIds: this.knownChildSessionIds,
|
||||||
|
subagentPresentationByChildId: this.subagentPresentationByChildId,
|
||||||
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
||||||
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
|
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
|
||||||
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
|
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||||
@@ -4367,6 +4564,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
subAgentsByCallId: new Map(),
|
subAgentsByCallId: new Map(),
|
||||||
subAgentCallIdByChildSessionId: new Map(),
|
subAgentCallIdByChildSessionId: new Map(),
|
||||||
knownChildSessionIds: new Set(),
|
knownChildSessionIds: new Set(),
|
||||||
|
subagentPresentationByChildId: this.subagentPresentationByChildId,
|
||||||
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
||||||
};
|
};
|
||||||
this.childTranslationStates.set(sessionId, state);
|
this.childTranslationStates.set(sessionId, state);
|
||||||
@@ -4401,6 +4599,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
if (event.type === "session.status" && event.properties.status.type === "busy") {
|
if (event.type === "session.status" && event.properties.status.type === "busy") {
|
||||||
markRunning();
|
markRunning();
|
||||||
}
|
}
|
||||||
|
this.appendChildAssistantPresentationUpsert(sessionId, event, events);
|
||||||
for (const childEvent of translated) {
|
for (const childEvent of translated) {
|
||||||
if (childEvent.type === "timeline") {
|
if (childEvent.type === "timeline") {
|
||||||
markRunning();
|
markRunning();
|
||||||
@@ -4444,6 +4643,48 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold presentation facts (agent, model, variant, completed-message tokens) off a child
|
||||||
|
* assistant `message.updated` frame and emit the missing title and/or changed subtitle.
|
||||||
|
* Never carries `status`: a presentation upsert must not revert a finished child.
|
||||||
|
*/
|
||||||
|
private appendChildAssistantPresentationUpsert(
|
||||||
|
sessionId: string,
|
||||||
|
event: OpenCodeEvent,
|
||||||
|
events: AgentStreamEvent[],
|
||||||
|
): void {
|
||||||
|
if (event.type !== "message.updated") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const info = event.properties.info;
|
||||||
|
if (info.sessionID !== sessionId || info.role !== "assistant") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const facts = readOpenCodeAssistantPresentationFacts(info);
|
||||||
|
if (!facts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const presentation = getOpenCodeSubagentPresentationState(
|
||||||
|
sessionId,
|
||||||
|
this.getChildTranslationState(sessionId),
|
||||||
|
);
|
||||||
|
const subtitle = foldOpenCodeSubagentPresentation(presentation, facts);
|
||||||
|
const title = claimOpenCodeSubagentFallbackTitle(presentation, facts.agentName);
|
||||||
|
if (!subtitle && !title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "opencode",
|
||||||
|
event: {
|
||||||
|
type: "upsert",
|
||||||
|
id: sessionId,
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> {
|
private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> {
|
||||||
const eventSessionId = getOpenCodeEventSessionId(event);
|
const eventSessionId = getOpenCodeEventSessionId(event);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOpenCodeSubagentSubtitle,
|
||||||
|
claimOpenCodeSubagentFallbackTitle,
|
||||||
|
foldOpenCodeSubagentPresentation,
|
||||||
|
type OpenCodeSubagentPresentationState,
|
||||||
|
} from "./subagent-presentation.js";
|
||||||
|
|
||||||
|
describe("claimOpenCodeSubagentFallbackTitle", () => {
|
||||||
|
it("claims the fallback title once", () => {
|
||||||
|
const state: OpenCodeSubagentPresentationState = { facts: {} };
|
||||||
|
|
||||||
|
expect(claimOpenCodeSubagentFallbackTitle(state, "explore")).toBe("explore");
|
||||||
|
expect(claimOpenCodeSubagentFallbackTitle(state, "general")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects a title set by the tool-call link", () => {
|
||||||
|
const state: OpenCodeSubagentPresentationState = { facts: {}, titleFromLink: true };
|
||||||
|
|
||||||
|
expect(claimOpenCodeSubagentFallbackTitle(state, "general")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects blank agent names", () => {
|
||||||
|
const state: OpenCodeSubagentPresentationState = { facts: {} };
|
||||||
|
|
||||||
|
expect(claimOpenCodeSubagentFallbackTitle(state, " ")).toBeUndefined();
|
||||||
|
expect(state.titleEmitted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildOpenCodeSubagentSubtitle", () => {
|
||||||
|
it("formats OpenCode facts into one compact provider-owned label", () => {
|
||||||
|
expect(
|
||||||
|
buildOpenCodeSubagentSubtitle({
|
||||||
|
agentName: "general",
|
||||||
|
modelId: "claude-sonnet-5",
|
||||||
|
variant: "high",
|
||||||
|
totalTokens: 16_484,
|
||||||
|
}),
|
||||||
|
).toBe("general · claude-sonnet-5 · High · 16.5k tokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps raw model ids visible without a label table", () => {
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ modelId: "big-pickle" })).toBe("big-pickle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capitalizes multi-word variants and maps xhigh", () => {
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ variant: "ultra-think" })).toBe("Ultra Think");
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ variant: "xhigh" })).toBe("Extra High");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats token counts below one thousand without abbreviation", () => {
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ totalTokens: 999 })).toBe("999 tokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits facts that were not observed", () => {
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ agentName: "explore", totalTokens: 0 })).toBe("explore");
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({})).toBeUndefined();
|
||||||
|
expect(buildOpenCodeSubagentSubtitle({ agentName: " " })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("foldOpenCodeSubagentPresentation", () => {
|
||||||
|
it("returns the subtitle only when it changes", () => {
|
||||||
|
const state: OpenCodeSubagentPresentationState = { facts: {} };
|
||||||
|
expect(foldOpenCodeSubagentPresentation(state, { agentName: "general" })).toBe("general");
|
||||||
|
expect(foldOpenCodeSubagentPresentation(state, { agentName: "general" })).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
foldOpenCodeSubagentPresentation(state, { modelId: "claude-sonnet-5", totalTokens: 1_200 }),
|
||||||
|
).toBe("general · claude-sonnet-5 · 1.2k tokens");
|
||||||
|
expect(foldOpenCodeSubagentPresentation(state, { totalTokens: 1_200 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined while no facts are known", () => {
|
||||||
|
const state: OpenCodeSubagentPresentationState = { facts: {} };
|
||||||
|
expect(foldOpenCodeSubagentPresentation(state, {})).toBeUndefined();
|
||||||
|
expect(state.lastSubtitle).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export interface OpenCodeSubagentPresentationFacts {
|
||||||
|
/** OpenCode agent name running the child, e.g. "general", "explore". */
|
||||||
|
agentName?: string;
|
||||||
|
/** Raw model id, e.g. "claude-sonnet-5". OpenCode has no label table at translation time. */
|
||||||
|
modelId?: string;
|
||||||
|
/** Thinking level, e.g. "high", "max". */
|
||||||
|
variant?: string;
|
||||||
|
/** Latest assistant message token sum (input+output+reasoning+cache). */
|
||||||
|
totalTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-child presentation tracking: raw facts plus the last subtitle emitted, so
|
||||||
|
* repeated folds of the same facts never re-emit an identical upsert.
|
||||||
|
*/
|
||||||
|
export interface OpenCodeSubagentPresentationState {
|
||||||
|
facts: OpenCodeSubagentPresentationFacts;
|
||||||
|
lastSubtitle?: string;
|
||||||
|
/** A descriptor title has been emitted from detection, linking, or assistant facts. */
|
||||||
|
titleEmitted?: boolean;
|
||||||
|
/** Set once the parent `task` tool call has been linked to this child. */
|
||||||
|
linkedToolCallId?: string;
|
||||||
|
/** The link upsert carried a title (subAgentType); detection must not overwrite it. */
|
||||||
|
titleFromLink?: boolean;
|
||||||
|
/** The link upsert carried a description (task input); detection must not overwrite it. */
|
||||||
|
descriptionFromLink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claim the assistant/session agent as the descriptor title when no stronger title exists. */
|
||||||
|
export function claimOpenCodeSubagentFallbackTitle(
|
||||||
|
state: OpenCodeSubagentPresentationState,
|
||||||
|
agentName: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const title = readPart(agentName);
|
||||||
|
if (!title || state.titleFromLink || state.titleEmitted) return undefined;
|
||||||
|
// Title means subagent type, so claim it once rather than refreshing on a mid-session handoff.
|
||||||
|
state.titleEmitted = true;
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the complete compact subtitle OpenCode exposes to provider-neutral clients. */
|
||||||
|
export function buildOpenCodeSubagentSubtitle(
|
||||||
|
facts: OpenCodeSubagentPresentationFacts,
|
||||||
|
): string | undefined {
|
||||||
|
const parts = [
|
||||||
|
readPart(facts.agentName),
|
||||||
|
readPart(facts.modelId),
|
||||||
|
formatVariant(facts.variant),
|
||||||
|
formatTokens(facts.totalTokens),
|
||||||
|
].filter((part): part is string => part !== undefined);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge observed facts into the tracked state and return the folded subtitle only
|
||||||
|
* when it differs from the last one emitted; otherwise return undefined.
|
||||||
|
*/
|
||||||
|
export function foldOpenCodeSubagentPresentation(
|
||||||
|
state: OpenCodeSubagentPresentationState,
|
||||||
|
patch: OpenCodeSubagentPresentationFacts,
|
||||||
|
): string | undefined {
|
||||||
|
if (patch.agentName !== undefined) state.facts.agentName = patch.agentName;
|
||||||
|
if (patch.modelId !== undefined) state.facts.modelId = patch.modelId;
|
||||||
|
if (patch.variant !== undefined) state.facts.variant = patch.variant;
|
||||||
|
if (patch.totalTokens !== undefined) state.facts.totalTokens = patch.totalTokens;
|
||||||
|
const subtitle = buildOpenCodeSubagentSubtitle(state.facts);
|
||||||
|
if (!subtitle || subtitle === state.lastSubtitle) return undefined;
|
||||||
|
state.lastSubtitle = subtitle;
|
||||||
|
return subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPart(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVariant(variant: string | undefined): string | undefined {
|
||||||
|
const normalized = readPart(variant);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (normalized === "xhigh") return "Extra High";
|
||||||
|
return normalized
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(totalTokens: number | undefined): string | undefined {
|
||||||
|
if (typeof totalTokens !== "number" || totalTokens <= 0) return undefined;
|
||||||
|
if (totalTokens < 1000) return `${Math.round(totalTokens)} tokens`;
|
||||||
|
return `${Math.round(totalTokens / 100) / 10}k tokens`;
|
||||||
|
}
|
||||||
@@ -196,6 +196,7 @@ function deriveOpencodeTaskDetail(
|
|||||||
input: unknown,
|
input: unknown,
|
||||||
output: unknown,
|
output: unknown,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
|
metadata: Record<string, unknown> | undefined,
|
||||||
): ToolCallDetail | null {
|
): ToolCallDetail | null {
|
||||||
if (!isRecord(input)) {
|
if (!isRecord(input)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -208,7 +209,8 @@ function deriveOpencodeTaskDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
|
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
|
||||||
const childSessionId = extractOpenCodeTaskSessionId(output);
|
const childSessionId =
|
||||||
|
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
|
||||||
return {
|
return {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
...(subAgentType ? { subAgentType } : {}),
|
...(subAgentType ? { subAgentType } : {}),
|
||||||
@@ -359,9 +361,10 @@ export function deriveOpencodeToolDetail(
|
|||||||
input: unknown,
|
input: unknown,
|
||||||
output: unknown,
|
output: unknown,
|
||||||
error: unknown = null,
|
error: unknown = null,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
): ToolCallDetail {
|
): ToolCallDetail {
|
||||||
if (toolName.trim().toLowerCase() === "task") {
|
if (toolName.trim().toLowerCase() === "task") {
|
||||||
const taskDetail = deriveOpencodeTaskDetail(input, output, error);
|
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
|
||||||
if (taskDetail) {
|
if (taskDetail) {
|
||||||
return taskDetail;
|
return taskDetail;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ describe("opencode tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts child session ids from completed task output", () => {
|
it("extracts child session ids from current task metadata", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapOpencodeToolCall({
|
mapOpencodeToolCall({
|
||||||
toolName: "task",
|
toolName: "task",
|
||||||
@@ -467,7 +467,8 @@ describe("opencode tool-call mapper", () => {
|
|||||||
subagent_type: "explore",
|
subagent_type: "explore",
|
||||||
description: "Explore current directory",
|
description: "Explore current directory",
|
||||||
},
|
},
|
||||||
output: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\n<task_result>done</task_result>",
|
output: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
|
||||||
|
metadata: { sessionId: "ses_2268db431ffe299vL1bbot8R7Z" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -476,7 +477,7 @@ describe("opencode tool-call mapper", () => {
|
|||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Explore current directory",
|
description: "Explore current directory",
|
||||||
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
||||||
log: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\n<task_result>done</task_result>",
|
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
|
|||||||
const error = raw.error ?? null;
|
const error = raw.error ?? null;
|
||||||
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
||||||
const status = normalizeToolCallStatus(rawStatus, error, output);
|
const status = normalizeToolCallStatus(rawStatus, error, output);
|
||||||
const detail = deriveOpencodeToolDetail(name, input, output, error);
|
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
|
||||||
|
|
||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user