fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (model.running) {
|
||||
if (model.live.length > 0) {
|
||||
now = Date.now();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user