Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 90de5b6fa4 fix: make tool progress live-only 2026-07-21 22:04:50 -04:00
24 changed files with 334 additions and 97 deletions
+2 -2
View File
@@ -198,8 +198,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: current.structured,
content: current.content,
structured: event.data.structured ?? current.structured,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
+2 -2
View File
@@ -401,8 +401,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
structured: event.data.structured ?? current.structured,
content: event.data.content ?? current.content,
error: event.data.error,
result: event.data.result,
},
+4 -2
View File
@@ -214,7 +214,7 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
@@ -251,7 +251,7 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
@@ -265,6 +265,8 @@ describe("acp event behavior", () => {
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
)
+2 -1
View File
@@ -130,7 +130,6 @@ function failedTool(inputID: string): V2Event[] {
id: "evt_failed_tool_progress",
created: 3,
type: "session.tool.progress",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
@@ -149,6 +148,8 @@ function failedTool(inputID: string): V2Event[] {
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
executed: true,
},
},
+35 -35
View File
@@ -1385,24 +1385,6 @@ export type SessionToolCalled = {
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1847,22 +1829,6 @@ export type SessionMessageToolStateError = {
result?: JsonValue
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionToolSuccess = {
id: string
created: number
@@ -1882,6 +1848,41 @@ export type SessionToolSuccess = {
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
structured?: { [x: string]: any }
content?: Array<LLMToolContent>
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
@@ -2219,7 +2220,6 @@ export type SessionEventDurable =
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
+1
View File
@@ -55,5 +55,6 @@ export const migrations = (
import("./migration/20260709190621_session_pending_table"),
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260722011141_delete_tool_progress_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
})
},
} satisfies DatabaseMigration.Migration
+2 -2
View File
@@ -402,8 +402,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
structured: event.data.structured ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
}),
)
-1
View File
@@ -697,7 +697,6 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
+3 -10
View File
@@ -62,6 +62,8 @@ const layer = Layer.effect(
assistantMessageID: message.id,
callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
structured: tool.state.status === "running" ? tool.state.structured : {},
content: tool.state.status === "running" ? tool.state.content : [],
executed: tool.executed === true,
})
}
@@ -163,16 +165,7 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
),
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((settlement) =>
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -54,8 +55,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
called: boolean
settled: boolean
providerExecuted: boolean
progress?: ToolRegistry.Progress
}
>()
const failureState = (tool: { readonly progress?: ToolRegistry.Progress }) => ({
structured: tool.progress?.structured ?? {},
content: tool.progress?.content ?? [],
})
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
@@ -232,6 +238,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
},
...failureState(tool),
executed: false,
})
})
@@ -256,6 +263,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
assistantMessageID: tool.assistantMessageID,
callID,
error,
...failureState(tool),
executed: tool.providerExecuted,
})
}
@@ -415,6 +423,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
...failureState(tool),
result: event.result,
executed,
resultState,
@@ -447,6 +456,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
: { type: "tool.execution", message: event.message },
...failureState(tool),
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
})
@@ -471,8 +481,23 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
}
})
const progress = Effect.fnUntraced(function* (callID: string, update: ToolRegistry.Progress) {
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { structured: { ...update.structured }, content: [...update.content] }
tool.progress = current
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
...current,
})
})
return {
publish,
progress,
flush,
failAssistant,
publishStepFailure,
+12 -3
View File
@@ -256,13 +256,22 @@ export const Plugin = {
}
}
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
Effect.gen(function* () {
if (
previousProgress?.output === capture.output &&
previousProgress.truncated === capture.truncated
)
return
previousProgress = capture
yield* context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
})
}),
),
),
@@ -24,6 +24,7 @@ import renameInstructionsMigration from "@opencode-ai/core/database/migration/20
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@@ -557,6 +558,31 @@ describe("DatabaseMigration", () => {
)
})
test("deletes durable tool progress without changing aggregate sequence watermarks", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 5)`)
yield* db.run(sql`INSERT INTO event VALUES ('evt_success', 'ses_test', 4, 'session.tool.success.1', '{}')`)
yield* db.run(sql`INSERT INTO event VALUES ('evt_progress', 'ses_test', 5, 'session.tool.progress.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [deleteToolProgressEventsMigration])
expect(yield* db.all(sql`SELECT id, seq, type, data FROM event ORDER BY seq`)).toEqual([
{ id: "evt_success", seq: 4, type: "session.tool.success.1", data: "{}" },
])
expect(yield* db.get(sql`SELECT aggregate_id, seq FROM event_sequence`)).toEqual({
aggregate_id: "ses_test",
seq: 5,
})
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
@@ -16,11 +16,11 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = (providerMetadataKey = "anthropic") => {
const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = {
publish: (definition, data) =>
Effect.sync(() => {
publish: (definition, data) => {
const publish = Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
published.push({
type: definition.durable
@@ -29,7 +29,11 @@ const capture = (providerMetadataKey = "anthropic") => {
data,
})
return event
}),
})
return definition.type === SessionEvent.Tool.Progress.type && options?.interruptProgress
? publish.pipe(Effect.andThen(Effect.interrupt))
: publish
},
}
return {
published,
@@ -92,6 +96,24 @@ test("provider-executed success retains its raw provider result", async () => {
expect(success?.data).toHaveProperty("result")
})
test("interrupted progress publication remains in the terminal failure snapshot", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(
publisher.progress(call.id, {
structured: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
}),
)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
structured: { phase: "visible" },
content: [{ type: "text", text: "visible" }],
})
})
test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
+46
View File
@@ -892,6 +892,52 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("persists the final progress snapshot when a tool fails", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
failing_progress: Tool.make({
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
})
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
}, { codemode: false })
yield* admit(session, "Run failing progress")
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Run failing progress" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-failing-progress",
state: {
status: "error",
structured: { phase: "running" },
content: [{ type: "text", text: "before failure" }],
error: { message: "failed after progress" },
},
},
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("executes the tool advertised before a registry reload", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -25,7 +25,7 @@ const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("pr
const content = (text: string) => [{ type: "text" as const, text }]
describe("Tool.Progress", () => {
it.effect("projects durable progress and keeps final settlements durable", () =>
it.effect("keeps progress live-only and terminal settlements replayable", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const service = yield* EventV2.Service
@@ -87,7 +87,7 @@ describe("Tool.Progress", () => {
state: { status: "running", structured: {}, content: [] },
})
yield* service.publish(SessionEvent.Tool.Progress, {
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
@@ -95,7 +95,7 @@ describe("Tool.Progress", () => {
content: content("saved"),
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
state: { status: "running", structured: {}, content: [] },
})
const success = yield* service.publish(SessionEvent.Tool.Success, {
@@ -123,6 +123,8 @@ describe("Tool.Progress", () => {
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
structured: { phase: "checkpoint" },
content: content("before failure"),
executed: false,
})
expect((yield* readAssistant).content[1]).toMatchObject({
@@ -133,6 +135,7 @@ describe("Tool.Progress", () => {
error: { type: "unknown", message: "boom" },
},
})
expect(Schema.is(SessionEvent.Durable)(progress)).toBe(false)
expect(Schema.is(SessionEvent.Durable)(success)).toBe(true)
expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true)
@@ -143,9 +146,10 @@ describe("Tool.Progress", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
}),
)
})
+31
View File
@@ -159,6 +159,9 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
@@ -462,6 +465,34 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live(
"does not repeat unchanged shell progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const updates: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: steadyProgressCommand }, "call-steady-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toEqual([
{
structured: { truncated: false },
content: [{ type: "text", text: "steady" }],
},
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 10_000 },
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+12 -14
View File
@@ -3,7 +3,6 @@ export * as SessionEvent from "./session-event.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { ToolContent } from "./llm.js"
import { FinishReason } from "./llm.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
@@ -410,18 +409,15 @@ export namespace Tool {
})
export type Called = typeof Called.Type
/**
* Replayable bounded running-tool state. Tools should checkpoint semantic
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = Event.durable({
const ToolOutputFields = {
structured: SessionMessage.ToolStateRunning.fields.structured,
content: SessionMessage.ToolStateRunning.fields.content,
}
/** Live replacement snapshot for a running tool. Terminal events own durable output. */
export const Progress = Event.ephemeral({
type: "session.tool.progress",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Unknown),
content: Schema.Array(ToolContent),
},
schema: { ...ToolBase, ...ToolOutputFields },
})
export type Progress = typeof Progress.Type
@@ -430,8 +426,7 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Unknown),
content: Schema.Array(ToolContent),
...ToolOutputFields,
result: Schema.Unknown.pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
@@ -445,6 +440,9 @@ export namespace Tool {
schema: {
...ToolBase,
error: SessionError.Error,
// Optional only for compatibility with existing v1 failure rows.
structured: ToolOutputFields.structured.pipe(optional),
content: ToolOutputFields.content.pipe(optional),
result: Schema.Unknown.pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
+2 -1
View File
@@ -125,7 +125,6 @@ describe("public event manifest", () => {
"session.tool.input.started.1",
"session.tool.input.ended.1",
"session.tool.called.1",
"session.tool.progress.1",
"session.tool.success.1",
"session.tool.failed.1",
"session.reasoning.started.1",
@@ -152,6 +151,8 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral")
expect(EventManifest.Server.get("session.tool.progress")).toBe(SessionEvent.Tool.Progress)
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated)
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
+2 -2
View File
@@ -643,8 +643,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
structured: event.data.structured ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
+9 -7
View File
@@ -838,8 +838,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: part && part.state.status !== "streaming" ? part.state.structured : {},
content: part && part.state.status !== "streaming" ? part.state.content : [],
structured:
event.data.structured ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
error: event.data.error,
result: event.data.result,
}
@@ -958,14 +959,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input)
return
}
if (event.type === "session.tool.failed") {
pendingCalls.delete(sourceKey(event.data.assistantMessageID, event.data.callID))
if (
event.type !== "session.tool.progress" &&
event.type !== "session.tool.success" &&
event.type !== "session.tool.failed"
)
return
}
if (event.type !== "session.tool.progress" && event.type !== "session.tool.success") return
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const pending = pendingCalls.get(key)
if (event.type === "session.tool.success") pendingCalls.delete(key)
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.data.structured))
if (!found) return
const child = admitChild(found.sessionID)
+3 -2
View File
@@ -1067,8 +1067,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
? {
status: "error",
input: part && part.state.status !== "streaming" ? part.state.input : {},
structured: part && part.state.status !== "streaming" ? part.state.structured : {},
content: part && part.state.status !== "streaming" ? part.state.content : [],
structured:
event.data.structured ?? (part && part.state.status !== "streaming" ? part.state.structured : {}),
content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []),
error: event.data.error,
result: event.data.result,
}
-1
View File
@@ -2332,7 +2332,6 @@ test("settles pending tools when a live failure arrives", async () => {
id: "evt_progress_1",
created: 0,
type: "session.tool.progress",
durable: durable("session-1", 5),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
@@ -2015,7 +2015,6 @@ describe("V2 mini transport", () => {
id: "evt_progress",
created: 3,
type: "session.tool.progress",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
@@ -2034,6 +2033,8 @@ describe("V2 mini transport", () => {
assistantMessageID: "msg_progress",
callID: "call_progress",
error: { type: "unknown", message: "boom" },
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial" }],
executed: true,
},
})
@@ -2806,6 +2807,71 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("discovers a subagent from its terminal failure snapshot", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events], messages: { ses_child_failed: [] } })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
events.push({
id: "evt_failed_subagent_input",
created: 1,
type: "session.tool.input.started",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
name: "subagent",
},
})
events.push({
id: "evt_failed_subagent_called",
created: 2,
type: "session.tool.called",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
input: { agent: "explore", description: "Inspect failure", prompt: "inspect" },
executed: true,
},
})
events.push({
id: "evt_failed_subagent",
created: 3,
type: "session.tool.failed",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
error: { type: "unknown", message: "subagent failed" },
structured: { sessionID: "ses_child_failed", status: "running" },
content: [],
executed: true,
},
})
while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed")))
await Bun.sleep(0)
expect(states().at(-1)?.tabs).toMatchObject([
{
sessionID: "ses_child_failed",
label: "Explore",
description: "Inspect failure",
},
])
await transport.close()
})
test("discovers current subagents from progress and reduces descendant tool state", async () => {
const events = feed()
events.push(connected())
@@ -2847,7 +2913,6 @@ describe("V2 mini transport", () => {
id: "evt_subagent_progress",
created: 3,
type: "session.tool.progress",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
@@ -2899,7 +2964,6 @@ describe("V2 mini transport", () => {
id: "evt_child_tool_progress",
created: 6,
type: "session.tool.progress",
durable: durable("ses_child_progress", 2),
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
@@ -2930,6 +2994,8 @@ describe("V2 mini transport", () => {
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
error: { type: "unknown", message: "child boom" },
structured: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
executed: true,
},
})