fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn
This commit is contained in:
1 parent
2afd3bc3c1
commit
8019c76e53
15 files changed
+608
-91
No files matched your search
@@ -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",
|
||||
};
|
||||
Reference in new issue
Block a user