Skip to content

Commit eb71403

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 6d82489 commit eb71403

4 files changed

Lines changed: 157 additions & 8 deletions

File tree

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
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect } from "bun:test"
2+
import { Effect, Fiber } from "effect"
3+
import z from "zod"
4+
import { ProviderID, ModelID } from "../../src/provider/schema"
5+
import { Session } from "../../src/session"
6+
import { MessageV2 } from "../../src/session/message-v2"
7+
import { SessionPrompt } from "../../src/session/prompt"
8+
import { MessageID, PartID } from "../../src/session/schema"
9+
import { Tool } from "../../src/tool/tool"
10+
import { ToolRegistry } from "../../src/tool/registry"
11+
import { Log } from "../../src/util/log"
12+
import { server, waitRequest, toolResponse, textResponse, deferred } from "../fixture/anthropic"
13+
import { env } from "../fixture/prompt-layers"
14+
import { provideTmpdirInstance } from "../fixture/fixture"
15+
import { testEffect } from "../lib/effect"
16+
17+
Log.init({ print: false })
18+
19+
beforeAll(() => server.start())
20+
beforeEach(() => server.reset())
21+
afterAll(() => server.stop())
22+
23+
const it = testEffect(env)
24+
25+
describe("session.processor.metadata-race", () => {
26+
it.effect(
27+
"ctx.metadata() survives pending→running transition through full prompt pipeline",
28+
() =>
29+
provideTmpdirInstance(
30+
(_dir) =>
31+
Effect.gen(function* () {
32+
const signal = deferred<void>()
33+
const gate = deferred<void>()
34+
35+
// 1. Register custom tool that calls ctx.metadata()
36+
const reg = yield* ToolRegistry.Service
37+
yield* reg.register(
38+
Tool.define("test_metadata", {
39+
description: "Test tool for metadata race",
40+
parameters: z.object({ key: z.string() }),
41+
async execute(_args, ctx) {
42+
ctx.metadata({ title: "test-task", metadata: { sessionId: "sess-123" } })
43+
signal.resolve()
44+
await gate.promise
45+
return { title: "test-task", metadata: {}, output: "done" }
46+
},
47+
}),
48+
)
49+
50+
// 2. Create session with non-default title (suppresses title generation fork)
51+
const sessions = yield* Session.Service
52+
const chat = yield* sessions.create({ title: "Pinned" })
53+
54+
// 3. Create user message with anthropic model ref
55+
const ref = {
56+
providerID: ProviderID.make("anthropic"),
57+
modelID: ModelID.make("claude-3-5-sonnet-20241022"),
58+
}
59+
const parent = yield* sessions.updateMessage({
60+
id: MessageID.ascending(),
61+
role: "user",
62+
sessionID: chat.id,
63+
agent: "build",
64+
model: ref,
65+
time: { created: Date.now() },
66+
})
67+
yield* sessions.updatePart({
68+
id: PartID.ascending(),
69+
messageID: parent.id,
70+
sessionID: chat.id,
71+
type: "text",
72+
text: "call test_metadata",
73+
})
74+
75+
// 4. Queue SSE responses: tool_use then text (for second loop iteration)
76+
waitRequest("/messages", toolResponse("toolu_01", "test_metadata", { key: "value" }))
77+
waitRequest("/messages", textResponse("Done"))
78+
79+
// 5. Fork prompt.loop on background fiber
80+
const prompt = yield* SessionPrompt.Service
81+
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
82+
83+
// 6. Wait for tool execute to call ctx.metadata()
84+
yield* Effect.promise(() => signal.promise)
85+
86+
// 7. Poll DB until tool part reaches running state
87+
const deadline = Date.now() + 5_000
88+
let tp: MessageV2.ToolPart | undefined
89+
while (Date.now() < deadline) {
90+
const msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(chat.id)))
91+
for (const m of msgs) {
92+
if (m.info.role !== "assistant") continue
93+
for (const p of m.parts) {
94+
if (p.type === "tool" && p.tool === "test_metadata" && p.state.status === "running") {
95+
tp = p as MessageV2.ToolPart
96+
}
97+
}
98+
}
99+
if (tp) break
100+
yield* Effect.promise(() => new Promise<void>((r) => setTimeout(r, 10)))
101+
}
102+
103+
// 8. Assert: metadata must survive pending→running transition
104+
expect(tp).toBeDefined()
105+
expect(tp!.state.status).toBe("running")
106+
const running = tp!.state as MessageV2.ToolStateRunning
107+
expect(running.metadata).toBeDefined()
108+
expect(running.metadata?.sessionId).toBe("sess-123")
109+
110+
// 9. Release gate to let tool complete
111+
gate.resolve()
112+
113+
// 10. Wait for prompt.loop to finish
114+
yield* Fiber.join(fiber)
115+
}),
116+
{
117+
git: true,
118+
config: {
119+
provider: {
120+
anthropic: {
121+
options: {
122+
apiKey: "test-key",
123+
baseURL: `${server.origin}/v1`,
124+
},
125+
},
126+
},
127+
},
128+
},
129+
),
130+
60_000,
131+
)
132+
})

0 commit comments

Comments
 (0)