mini migrate to v2 (#35526)
This commit is contained in:
parent
fb75ea2cf6
commit
32cf36de9d
93 changed files with 2244 additions and 2333 deletions
|
|
@ -1,739 +0,0 @@
|
|||
// Current-native subagent (child Session) tracking for the mini transport.
|
||||
//
|
||||
// Discovers child Sessions of the active parent from four current sources:
|
||||
// 1. projected subagent tool output (`structured.sessionID`) during hydration
|
||||
// 2. the current session list filtered by `parentID` during hydration
|
||||
// 3. the process-local active-session map during hydration
|
||||
// 4. live events from unknown sessions whose `parentID` matches the parent
|
||||
//
|
||||
// Tracks one footer tab per child and a detail transcript for the selected
|
||||
// child, reduced from the same current live event stream the parent uses.
|
||||
// Detail transcripts rebuild from projected messages on discovery, selection,
|
||||
// and reconnect, then continue from live deltas using the same
|
||||
// projected-prefix dedup the parent transport uses.
|
||||
//
|
||||
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
|
||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
||||
import type {
|
||||
OpencodeClient,
|
||||
SessionMessage,
|
||||
SessionMessageAssistantTool,
|
||||
ToolPart,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
|
||||
const CHILD_MESSAGE_LIMIT = 80
|
||||
const CHILD_FRAME_LIMIT = 80
|
||||
const CHILD_EVENT_BUFFER_LIMIT = 64
|
||||
const FAMILY_LIST_LIMIT = 100
|
||||
const FALLBACK_LABEL = "Subagent"
|
||||
|
||||
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
|
||||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||
}
|
||||
|
||||
export function legacyTool(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
name: string
|
||||
state: SessionMessageAssistantTool["state"]
|
||||
time: SessionMessageAssistantTool["time"]
|
||||
provider?: SessionMessageAssistantTool["provider"]
|
||||
}): ToolPart {
|
||||
const base = {
|
||||
id: `prt_${input.callID}`,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
type: "tool" as const,
|
||||
callID: input.callID,
|
||||
tool: input.name,
|
||||
}
|
||||
if (input.state.status === "pending") {
|
||||
return {
|
||||
...base,
|
||||
state: { status: "pending", input: {}, raw: input.state.input },
|
||||
}
|
||||
}
|
||||
if (input.state.status === "running") {
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "running",
|
||||
input: input.state.input,
|
||||
title: input.name,
|
||||
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
|
||||
time: { start: input.time.ran ?? input.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (input.state.status === "completed") {
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: input.state.input,
|
||||
output: outputText(input.state.content),
|
||||
title: input.name,
|
||||
metadata: {
|
||||
structured: input.state.structured,
|
||||
content: input.state.content,
|
||||
outputPaths: input.state.outputPaths,
|
||||
result: input.state.result,
|
||||
providerCall: input.provider,
|
||||
},
|
||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: input.state.input,
|
||||
error: input.state.error.message,
|
||||
metadata: {
|
||||
structured: input.state.structured,
|
||||
content: input.state.content,
|
||||
result: input.state.result,
|
||||
providerCall: input.provider,
|
||||
},
|
||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
const status = part.state.status
|
||||
const text =
|
||||
status === "running"
|
||||
? part.tool === "task"
|
||||
? "running task"
|
||||
: `running ${part.tool}`
|
||||
: status === "completed"
|
||||
? part.state.output
|
||||
: status === "error"
|
||||
? part.state.error
|
||||
: ""
|
||||
return {
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
text,
|
||||
phase,
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running",
|
||||
toolError: status === "error" ? part.state.error : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
key: string
|
||||
commit: StreamCommit
|
||||
}
|
||||
|
||||
type ToolTrack = {
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
}
|
||||
|
||||
type ChildState = {
|
||||
sessionID: string
|
||||
label: string
|
||||
description: string
|
||||
status: FooterSubagentTab["status"]
|
||||
background: boolean
|
||||
title?: string
|
||||
callIDs: Set<string>
|
||||
lastUpdatedAt: number
|
||||
frames: Frame[]
|
||||
text: Map<string, string>
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
tools: Map<string, ToolTrack>
|
||||
finishedTools: Set<string>
|
||||
messageIDs: Set<string>
|
||||
prompts: Map<string, string>
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
export type SubagentTrackerInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
emit: () => void
|
||||
}
|
||||
|
||||
export type SubagentTracker = {
|
||||
main(event: V2Event): void
|
||||
foreign(sessionID: string, event: V2Event): void
|
||||
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
|
||||
select(sessionID: string | undefined): void
|
||||
snapshot(): FooterSubagentState
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
|
||||
return undefined
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined
|
||||
const next = value.trim()
|
||||
return next || undefined
|
||||
}
|
||||
|
||||
function childSessionID(structured: Record<string, unknown> | undefined) {
|
||||
const sessionID = text(structured?.sessionID)
|
||||
if (!sessionID || !sessionID.startsWith("ses")) return undefined
|
||||
const status = structured?.status
|
||||
if (status !== "running" && status !== "completed") return undefined
|
||||
return { sessionID, running: status === "running" }
|
||||
}
|
||||
|
||||
function tab(child: ChildState): FooterSubagentTab {
|
||||
return {
|
||||
sessionID: child.sessionID,
|
||||
partID: `subagent:${child.sessionID}`,
|
||||
callID: `subagent:${child.sessionID}`,
|
||||
label: child.label,
|
||||
description: child.description || child.title || "",
|
||||
status: child.status,
|
||||
background: child.background ? true : undefined,
|
||||
title: child.title,
|
||||
toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined,
|
||||
lastUpdatedAt: child.lastUpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
|
||||
const children = new Map<string, ChildState>()
|
||||
// Live subagent tool calls in the parent, so tool.success structured output
|
||||
// can be joined with the call's input metadata.
|
||||
const pendingCalls = new Map<string, Record<string, unknown>>()
|
||||
// Foreign sessions already resolved through session.get. Non-children stay
|
||||
// cached so unrelated concurrent sessions are checked at most once.
|
||||
const checked = new Set<string>()
|
||||
// Foreign events buffered while a session.get discovery is in flight, so a
|
||||
// fast child (including its settled event) is not lost mid-discovery.
|
||||
const pendingEvents = new Map<string, V2Event[]>()
|
||||
const hydrationEvents = new Map<string, V2Event[]>()
|
||||
const hydrationOverflow = new Set<string>()
|
||||
const hydrations = new Map<string, Promise<void>>()
|
||||
let selected: string | undefined
|
||||
|
||||
const ensureChild = (sessionID: string): ChildState => {
|
||||
const existing = children.get(sessionID)
|
||||
const child: ChildState = existing ?? {
|
||||
sessionID,
|
||||
label: FALLBACK_LABEL,
|
||||
description: "",
|
||||
status: "running",
|
||||
background: false,
|
||||
callIDs: new Set(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
frames: [],
|
||||
text: new Map(),
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
messageIDs: new Set(),
|
||||
prompts: new Map(),
|
||||
hydrated: false,
|
||||
}
|
||||
if (!existing) children.set(sessionID, child)
|
||||
// Adopting a child while its session.get discovery is still in flight:
|
||||
// drain the buffered events now. They arrived before whatever the caller
|
||||
// applies next, so replaying them first preserves bus order, and the
|
||||
// resolved discovery can no longer replay stale events (e.g. step.started)
|
||||
// after a terminal settled event was applied directly.
|
||||
const buffered = pendingEvents.get(sessionID)
|
||||
if (buffered) {
|
||||
pendingEvents.delete(sessionID)
|
||||
for (const event of buffered) reduce(child, event)
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
const touch = (child: ChildState, timestamp?: number) => {
|
||||
child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now())
|
||||
}
|
||||
|
||||
const notifyDetail = (child: ChildState) => {
|
||||
if (child.sessionID === selected) input.emit()
|
||||
}
|
||||
|
||||
const setFrame = (child: ChildState, key: string, commit: StreamCommit) => {
|
||||
const index = child.frames.findIndex((item) => item.key === key)
|
||||
if (index === -1) {
|
||||
child.frames.push({ key, commit })
|
||||
if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT)
|
||||
return
|
||||
}
|
||||
child.frames[index] = { key, commit }
|
||||
}
|
||||
|
||||
const applyMeta = (child: ChildState, meta: Record<string, unknown> | undefined) => {
|
||||
if (!meta) return
|
||||
const agent = text(meta.agent)
|
||||
if (agent) child.label = Locale.titlecase(agent)
|
||||
const description = text(meta.description)
|
||||
if (description) child.description = description
|
||||
if (meta.background === true) child.background = true
|
||||
}
|
||||
|
||||
const userFrame = (child: ChildState, messageID: string, value: string) => {
|
||||
if (child.messageIDs.has(messageID)) return false
|
||||
child.messageIDs.add(messageID)
|
||||
setFrame(child, `user:${messageID}`, {
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: value,
|
||||
phase: "start",
|
||||
messageID,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
|
||||
const part = legacyTool({
|
||||
sessionID: child.sessionID,
|
||||
messageID,
|
||||
callID: item.id,
|
||||
name: item.name,
|
||||
state: item.state,
|
||||
time: item.time,
|
||||
provider: item.provider,
|
||||
})
|
||||
if (item.state.status === "pending") return
|
||||
child.callIDs.add(item.id)
|
||||
if (item.state.status === "running") {
|
||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
|
||||
return
|
||||
}
|
||||
child.finishedTools.add(item.id)
|
||||
child.tools.delete(item.id)
|
||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
|
||||
}
|
||||
|
||||
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
|
||||
child.frames = []
|
||||
child.text.clear()
|
||||
child.projectedText.clear()
|
||||
child.reasoning.clear()
|
||||
child.projectedReasoning.clear()
|
||||
child.finishedTools.clear()
|
||||
child.messageIDs.clear()
|
||||
child.callIDs.clear()
|
||||
for (const message of messages) {
|
||||
if (message.type === "user") {
|
||||
child.prompts.delete(message.id)
|
||||
userFrame(child, message.id, message.text)
|
||||
continue
|
||||
}
|
||||
if (message.type !== "assistant") continue
|
||||
child.messageIDs.add(message.id)
|
||||
for (const item of message.content) {
|
||||
if (item.type === "text") {
|
||||
child.text.set(item.id, item.text)
|
||||
child.projectedText.set(item.id, item.text)
|
||||
setFrame(child, `text:${item.id}`, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: item.text,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (item.type === "reasoning") {
|
||||
child.reasoning.set(item.id, item.text)
|
||||
child.projectedReasoning.set(item.id, item.text)
|
||||
if (input.thinking)
|
||||
setFrame(child, `reasoning:${item.id}`, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${item.text}`,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
childTool(child, item, message.id)
|
||||
}
|
||||
if (message.error) {
|
||||
setFrame(child, `error:${message.id}`, {
|
||||
kind: "error",
|
||||
source: "system",
|
||||
text: message.error.message,
|
||||
phase: "start",
|
||||
messageID: message.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateChild = (child: ChildState): Promise<void> => {
|
||||
const existing = hydrations.get(child.sessionID)
|
||||
if (existing) return existing
|
||||
const pendingPrompts = new Map(child.prompts)
|
||||
const pendingTools = new Map(child.tools)
|
||||
let retry = false
|
||||
const task = input.sdk.v2.session
|
||||
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
|
||||
.then((response) => {
|
||||
const buffered = hydrationEvents.get(child.sessionID) ?? []
|
||||
hydrationEvents.delete(child.sessionID)
|
||||
if (hydrationOverflow.delete(child.sessionID)) {
|
||||
child.hydrated = false
|
||||
retry = true
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
for (const [id, prompt] of pendingPrompts) {
|
||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
||||
}
|
||||
rebuild(child, response.data.data.toReversed())
|
||||
for (const [id, tool] of pendingTools) {
|
||||
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
|
||||
}
|
||||
for (const event of buffered) reduce(child, event)
|
||||
child.hydrated = true
|
||||
notifyDetail(child)
|
||||
})
|
||||
.catch(() => {
|
||||
hydrationEvents.delete(child.sessionID)
|
||||
hydrationOverflow.delete(child.sessionID)
|
||||
})
|
||||
.finally(() => {
|
||||
hydrations.delete(child.sessionID)
|
||||
if (retry) queueMicrotask(() => void hydrateChild(child))
|
||||
})
|
||||
hydrations.set(child.sessionID, task)
|
||||
return task
|
||||
}
|
||||
|
||||
const discover = (sessionID: string) => {
|
||||
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
|
||||
checked.add(sessionID)
|
||||
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
|
||||
void input.sdk.v2.session
|
||||
.get({ sessionID }, { throwOnError: true })
|
||||
.then((response) => {
|
||||
const session = response.data.data
|
||||
const buffered = pendingEvents.get(sessionID) ?? []
|
||||
pendingEvents.delete(sessionID)
|
||||
if (session.parentID !== input.sessionID) return
|
||||
const child = ensureChild(sessionID)
|
||||
if (session.agent) child.label = Locale.titlecase(session.agent)
|
||||
child.title = session.title
|
||||
for (const event of buffered) reduce(child, event)
|
||||
touch(child)
|
||||
input.emit()
|
||||
void hydrateChild(child)
|
||||
})
|
||||
.catch(() => {
|
||||
// Allow a later event to retry discovery after transient failures.
|
||||
pendingEvents.delete(sessionID)
|
||||
checked.delete(sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
const reduce = (child: ChildState, event: V2Event) => {
|
||||
if (event.type === "session.prompt.admitted") {
|
||||
child.prompts.set(event.data.inputID, event.data.prompt.text)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
const prompt = child.prompts.get(event.data.inputID) ?? ""
|
||||
child.prompts.delete(event.data.inputID)
|
||||
if (userFrame(child, event.data.inputID, prompt)) {
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
touch(child, event.created)
|
||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||
if (child.status !== "running") child.status = "running"
|
||||
input.emit()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const projected = child.projectedText.get(event.data.textID)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
|
||||
child.text.set(event.data.textID, next)
|
||||
setFrame(child, `text:${event.data.textID}`, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: next,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
child.text.set(event.data.textID, event.data.text)
|
||||
child.projectedText.delete(event.data.textID)
|
||||
setFrame(child, `text:${event.data.textID}`, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: event.data.text,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
|
||||
child.reasoning.set(event.data.reasoningID, next)
|
||||
if (!input.thinking) return
|
||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${next}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
child.reasoning.set(event.data.reasoningID, event.data.text)
|
||||
child.projectedReasoning.delete(event.data.reasoningID)
|
||||
if (!input.thinking) return
|
||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${event.data.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
const current = child.tools.get(event.data.callID)
|
||||
child.tools.set(event.data.callID, {
|
||||
name: event.data.tool,
|
||||
input: event.data.input,
|
||||
started: current?.started ?? event.created,
|
||||
})
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.tool,
|
||||
provider: event.data.provider,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { created: current?.started ?? event.created, ran: event.created },
|
||||
},
|
||||
event.data.assistantMessageID,
|
||||
)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
const current = child.tools.get(event.data.callID)
|
||||
const failed = event.type === "session.tool.failed"
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: current?.name ?? "tool",
|
||||
provider: event.data.provider,
|
||||
state: failed
|
||||
? {
|
||||
status: "error",
|
||||
input: current?.input ?? {},
|
||||
structured: {},
|
||||
content: [],
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
}
|
||||
: {
|
||||
status: "completed",
|
||||
input: current?.input ?? {},
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: {
|
||||
created: current?.started ?? event.created,
|
||||
ran: current?.started,
|
||||
completed: event.created,
|
||||
},
|
||||
},
|
||||
event.data.assistantMessageID,
|
||||
)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
||||
kind: "error",
|
||||
source: "system",
|
||||
text: event.data.error.message,
|
||||
phase: "start",
|
||||
messageID: event.data.assistantMessageID,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.settled") {
|
||||
child.status =
|
||||
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
}
|
||||
}
|
||||
|
||||
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
|
||||
if (item.name !== "subagent" || item.state.status !== "completed") return
|
||||
const found = childSessionID(record(item.state.structured))
|
||||
if (!found) return
|
||||
const child = ensureChild(found.sessionID)
|
||||
applyMeta(child, record(item.state.input))
|
||||
if (found.running) child.background = true
|
||||
if (child.status === "running") {
|
||||
const running = found.running && (!active || found.sessionID in active)
|
||||
child.status = running ? "running" : "completed"
|
||||
}
|
||||
touch(child, item.time.completed ?? item.time.created)
|
||||
}
|
||||
|
||||
return {
|
||||
main(event) {
|
||||
if (event.type === "session.tool.called") {
|
||||
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
pendingCalls.delete(event.data.callID)
|
||||
return
|
||||
}
|
||||
if (event.type !== "session.tool.success") return
|
||||
const pending = pendingCalls.get(event.data.callID)
|
||||
pendingCalls.delete(event.data.callID)
|
||||
const found = childSessionID(record(event.data.structured))
|
||||
if (!found) return
|
||||
const child = ensureChild(found.sessionID)
|
||||
applyMeta(child, pending)
|
||||
if (found.running) {
|
||||
child.background = true
|
||||
child.status = "running"
|
||||
}
|
||||
if (!found.running && child.status === "running") child.status = "completed"
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
if (!child.hydrated) void hydrateChild(child)
|
||||
},
|
||||
foreign(sessionID, event) {
|
||||
const child = children.get(sessionID)
|
||||
if (child) {
|
||||
if (hydrations.has(sessionID)) {
|
||||
const buffered = hydrationEvents.get(sessionID) ?? []
|
||||
if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
||||
else hydrationOverflow.add(sessionID)
|
||||
hydrationEvents.set(sessionID, buffered)
|
||||
}
|
||||
reduce(child, event)
|
||||
return
|
||||
}
|
||||
discover(sessionID)
|
||||
const buffered = pendingEvents.get(sessionID)
|
||||
if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
||||
},
|
||||
async hydrate(next) {
|
||||
for (const message of next.messages) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const item of message.content) {
|
||||
if (item.type === "tool") mainTool(item, next.active)
|
||||
}
|
||||
}
|
||||
// Family index: adopt children directly from the current session list so
|
||||
// historical subagents beyond the projected message window still get tabs.
|
||||
const family = await input.sdk.v2.session
|
||||
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
|
||||
.then((response) => response.data.data)
|
||||
.catch(() => [])
|
||||
for (const session of family) {
|
||||
const child = ensureChild(session.id)
|
||||
if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent)
|
||||
if (!child.title) child.title = session.title
|
||||
touch(child, session.time.updated)
|
||||
}
|
||||
for (const sessionID of Object.keys(next.active)) discover(sessionID)
|
||||
for (const child of children.values()) {
|
||||
// Reconnect can miss a child's settled event; the active map is the
|
||||
// authoritative live signal for still-running children.
|
||||
if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed"
|
||||
}
|
||||
const current = selected ? children.get(selected) : undefined
|
||||
if (current) await hydrateChild(current)
|
||||
if (children.size > 0) input.emit()
|
||||
},
|
||||
select(sessionID) {
|
||||
selected = sessionID
|
||||
const child = sessionID ? children.get(sessionID) : undefined
|
||||
if (child && !child.hydrated) void hydrateChild(child)
|
||||
input.emit()
|
||||
},
|
||||
snapshot() {
|
||||
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
|
||||
const active = Number(b.status === "running") - Number(a.status === "running")
|
||||
if (active !== 0) return active
|
||||
return b.lastUpdatedAt - a.lastUpdatedAt
|
||||
})
|
||||
const child = selected ? children.get(selected) : undefined
|
||||
const details: Record<string, FooterSubagentDetail> = child
|
||||
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
|
||||
: {}
|
||||
return { tabs, details, permissions: [], questions: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue