fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn

This commit is contained in:
hh committed 2026-09-04 20:23:33 +02:00
1 parent 2afd3bc3c1
commit 8019c76e53
15 files changed
+608 -91

No files matched your search

+82 -5
View File
@@ -27,6 +27,9 @@ export interface Turn {
itemOrigin: string | null;
nodes: Record<string, ToolNode>;
origin: string;
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
resultSubtype: string | null;
roots: string[];
says: string[];
@@ -51,6 +54,20 @@ function str(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
export function joinText(head: string, tail: string, paused: boolean): string {
if (paused && head.trim() && !head.endsWith("\n")) {
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
}
return head + tail;
}
export function isLive(turn: Turn): boolean {
return (
turn.status === "running" ||
Object.values(turn.nodes).some((node) => node.status === "running")
);
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done";
@@ -65,6 +82,9 @@ export class ActivityModel {
turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null);
// A subagent still working after its turn ended: nothing on this page
// knows the turn, so its tool calls hang under the launching Agent node
// of a turn named after that node.
private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c),
@@ -89,6 +109,12 @@ export class ActivityModel {
return this.turns.find((turn) => turn.status === "running") ?? null;
}
// A turn is live while it runs or while a subagent it launched still
// works: the CLI reports subagent tool calls after the turn's own end.
get live(): Turn[] {
return this.turns.filter((turn) => isLive(turn));
}
setConversation(info: ConversationInfo): void {
this.conversation = info;
this.question = info.question;
@@ -151,6 +177,42 @@ export class ActivityModel {
turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText;
if (turn.origin === "task") {
this.settleAgents(ts);
}
}
// The report is in: whatever Agent node still runs has finished.
private settleAgents(ts: string): void {
for (const turn of this.turns) {
if (turn.origin === "agent" && turn.status === "running") {
turn.status = "done";
turn.endedAt = ts;
}
for (const node of Object.values(turn.nodes)) {
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
if (node.status === "running" && node.parent === null && agent) {
node.status = "done";
node.endedAt = ts;
}
}
}
}
// Subagent traffic belongs to the turn that launched the subagent, whatever
// turn the gateway was in when it arrived.
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
if (parent) {
const owner = this.turns.find((turn) => parent in turn.nodes);
if (owner) {
return owner;
}
const current = this.turns.find((turn) => turn.id === turnId);
if (current?.status !== "running") {
return this.ensureTurn(`agent:${parent}`, ts, "agent");
}
}
return this.ensureTurn(turnId, ts, null);
}
private onStream({ event, ts, turnId, parent }: Cursor): void {
@@ -158,7 +220,7 @@ export class ActivityModel {
if (!(turnId && raw && typeof raw === "object")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
@@ -172,7 +234,8 @@ export class ActivityModel {
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text += delta.text;
turn.text = joinText(turn.text, delta.text, turn.paused);
turn.paused = false;
}
}
}
@@ -181,7 +244,10 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
if (parent === null) {
turn.paused = true;
}
const node = this.ensureNode(
turn,
event.tool_use_id,
@@ -196,7 +262,7 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done";
node.endedAt = ts;
@@ -266,11 +332,13 @@ export class ActivityModel {
};
}
// The turn's own tool calls die with it; a subagent's keep going until
// their own results arrive.
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status;
turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) {
if (node.status === "running") {
if (node.status === "running" && node.parent === null) {
node.status = "aborted";
node.endedAt = ts;
}
@@ -291,6 +359,7 @@ export class ActivityModel {
itemOrigin: null,
nodes: {},
origin: origin ?? "?",
paused: false,
resultSubtype: null,
roots: [],
says: [],
@@ -394,3 +463,11 @@ export function summarizeInput(name: string, input: unknown): string {
export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
}
// How a turn was started, as a word for the header.
export const ORIGIN_LABELS: Record<string, string> = {
agent: "subagent at work",
inject: "inject",
task: "subagent report",
user: "user",
};