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

This commit is contained in:
hh
2026-09-04 20:23:33 +02:00
parent 2afd3bc3c1
commit 8019c76e53
15 changed files with 608 additions and 91 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
onMount(() => {
const timer = setInterval(() => {
if (model.running) {
if (model.live.length > 0) {
now = Date.now();
}
}, TICK_MS);
+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",
};
+6 -4
View File
@@ -7,7 +7,7 @@
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte";
@@ -48,7 +48,7 @@
const tail = $derived(
model.turns
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
.filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
.reverse()
);
const tailSize = $derived(
@@ -150,14 +150,16 @@
$effect(() => {
const timer = setInterval(() => {
if (model.running) {
if (model.live.length > 0) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
// Text the gateway or the CLI put in the user's seat: an inject header,
// an envelope, a subagent's report.
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set());
+1 -1
View File
@@ -53,7 +53,7 @@
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
{toolLabel(node.name)}
{subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
</span>
{#if summary}
<span class="truncate text-muted-foreground">{summary}</span>
+19 -8
View File
@@ -9,7 +9,7 @@
fmtTokens,
} from "$lib/format";
import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte";
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte";
@@ -18,10 +18,14 @@
const duration = $derived(
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
);
const origin = $derived(ORIGIN_LABELS[turn.origin] ?? turn.origin);
const originLabel = $derived(
turn.itemOrigin && turn.itemOrigin !== turn.origin
? `${turn.origin} · ${turn.itemOrigin}`
: turn.origin
? `${origin} · ${turn.itemOrigin}`
: origin
);
const pill = $derived(
turn.status !== "running" && isLive(turn) ? "running" : turn.status
);
const toolCount = $derived(Object.keys(turn.nodes).length);
</script>
@@ -29,15 +33,20 @@
<article
class={cn(
"flex flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]"
pill === "running" && "bg-signal/[0.03]"
)}
>
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
<StatusPill status={turn.status} />
{#if turn.origin !== "agent"}
<StatusPill status={pill} />
{/if}
<span
class={cn(
"font-medium",
turn.origin === "inject" ? "text-note" : "text-foreground"
turn.origin === "inject" && "text-note",
turn.origin === "agent" || turn.origin === "task"
? "text-kind-deep"
: "text-foreground"
)}
>
{originLabel}
@@ -65,7 +74,9 @@
</span>
{/if}
</header>
{#if turn.userText}
{#if turn.userText && turn.origin === "task"}
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
{:else if turn.userText}
<Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
text={turn.userText}
@@ -88,7 +99,7 @@
{/each}
{#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
{:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model…
</p>