Skip to content

Commit 367f492

Browse files
committed
fix(session): preserve tool metadata across pending→running transition
The AI SDK fires tool execute() as a detached promise before the processor handles the tool-call stream event. When execute() calls ctx.metadata({sessionId}), the processor's tool-call handler overwrites the DB with a fresh running state that has no metadata, making subagent sessions unclickable in the TUI. - Add synchronous toolMetadata side-channel on processor context - setToolMetadata() on Handle writes to the map immediately - tool-call handler reads and merges metadata from the side-channel - prompt.ts metadata callback calls setToolMetadata() before the async DB update, ensuring metadata is captured regardless of event ordering - Add E2E regression test using fake Anthropic HTTP server through the real AI SDK streamText pipeline with gate-based assertion of running-state metadata Closes #20184
1 parent 2cc738f commit 367f492

File tree

4 files changed

+375
-8
lines changed

4 files changed

+375
-8
lines changed

packages/opencode/src/session/processor.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export namespace SessionProcessor {
3030
export interface Handle {
3131
readonly message: MessageV2.Assistant
3232
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
33+
readonly setToolMetadata: (toolCallID: string, val: { title?: string; metadata?: Record<string, any> }) => void
3334
readonly abort: () => Effect.Effect<void>
3435
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
3536
}
@@ -46,6 +47,7 @@ export namespace SessionProcessor {
4647

4748
interface ProcessorContext extends Input {
4849
toolcalls: Record<string, MessageV2.ToolPart>
50+
toolMetadata: Record<string, { title?: string; metadata?: Record<string, any> }>
4951
shouldBreak: boolean
5052
snapshot: string | undefined
5153
blocked: boolean
@@ -89,6 +91,7 @@ export namespace SessionProcessor {
8991
sessionID: input.sessionID,
9092
model: input.model,
9193
toolcalls: {},
94+
toolMetadata: {},
9295
shouldBreak: false,
9396
snapshot: undefined,
9497
blocked: false,
@@ -173,10 +176,18 @@ export namespace SessionProcessor {
173176
}
174177
const match = ctx.toolcalls[value.toolCallId]
175178
if (!match) return
179+
const pending = ctx.toolMetadata[value.toolCallId]
180+
delete ctx.toolMetadata[value.toolCallId]
176181
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
177182
...match,
178183
tool: value.toolName,
179-
state: { status: "running", input: value.input, time: { start: Date.now() } },
184+
state: {
185+
status: "running",
186+
input: value.input,
187+
time: { start: Date.now() },
188+
title: pending?.title,
189+
metadata: pending?.metadata,
190+
},
180191
metadata: value.providerMetadata,
181192
} satisfies MessageV2.ToolPart)
182193

@@ -224,6 +235,7 @@ export namespace SessionProcessor {
224235
},
225236
})
226237
delete ctx.toolcalls[value.toolCallId]
238+
delete ctx.toolMetadata[value.toolCallId]
227239
return
228240
}
229241

@@ -243,6 +255,7 @@ export namespace SessionProcessor {
243255
ctx.blocked = ctx.shouldBreak
244256
}
245257
delete ctx.toolcalls[value.toolCallId]
258+
delete ctx.toolMetadata[value.toolCallId]
246259
return
247260
}
248261

@@ -494,6 +507,9 @@ export namespace SessionProcessor {
494507
partFromToolCall(toolCallID: string) {
495508
return ctx.toolcalls[toolCallID]
496509
},
510+
setToolMetadata(toolCallID: string, val: { title?: string; metadata?: Record<string, any> }) {
511+
ctx.toolMetadata[toolCallID] = val
512+
},
497513
abort,
498514
process,
499515
} satisfies Handle

packages/opencode/src/session/prompt.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
384384
model: Provider.Model
385385
session: Session.Info
386386
tools?: Record<string, boolean>
387-
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
387+
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall" | "setToolMetadata">
388388
bypassAgentCheck: boolean
389389
messages: MessageV2.WithParts[]
390390
}) {
@@ -399,23 +399,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the
399399
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
400400
agent: input.agent.name,
401401
messages: input.messages,
402-
metadata: (val) =>
403-
Effect.runPromise(
402+
metadata: (val) => {
403+
input.processor.setToolMetadata(options.toolCallId, val)
404+
return Effect.runPromise(
404405
Effect.gen(function* () {
405406
const match = input.processor.partFromToolCall(options.toolCallId)
406407
if (!match || match.state.status !== "running") return
407408
yield* sessions.updatePart({
408409
...match,
409410
state: {
411+
...match.state,
410412
title: val.title,
411413
metadata: val.metadata,
412-
status: "running",
413-
input: args,
414-
time: { start: Date.now() },
415414
},
416415
})
417416
}),
418-
),
417+
)
418+
},
419419
ask: (req) =>
420420
Effect.runPromise(
421421
permission.ask({

packages/opencode/test/session/compaction.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function fake(
149149
state: { status: "pending", input: {}, raw: "" },
150150
}
151151
},
152+
setToolMetadata() {},
152153
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
153154
} satisfies SessionProcessorModule.SessionProcessor.Handle
154155
}

0 commit comments

Comments
 (0)