Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/opencode/src/session/llm/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,10 @@ export function toLLMEvents(

case "tool-result":
return Effect.sync(() => {
// Names are kept for the life of the stream rather than dropped here:
// providers may re-emit argument deltas for a call that already
// produced its result, and those chunks carry no name of their own.
const name = state.toolNames[event.toolCallId] ?? "unknown"
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolResult({
id: event.toolCallId,
Expand All @@ -252,7 +254,6 @@ export function toLLMEvents(
case "tool-error":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolError({
id: event.toolCallId,
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
// Sessions recorded before phantom tool parts were fixed at the source can
// hold two parts for one call ID. Replaying both emits duplicate tool_use
// blocks, which providers reject or answer with a confused retry loop.
const seenCallIDs = new Set<string>()
for (const part of msg.parts) {
if (part.type === "tool") {
if (seenCallIDs.has(part.callID)) continue
seenCallIDs.add(part.callID)
}
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ type ToolCall = {

interface ProcessorContext extends Input {
toolcalls: Record<string, ToolCall>
// Call IDs whose part already reached a terminal state. Some providers
// re-emit argument deltas for a call that has already produced its result;
// without this, those late events would create a second part for the same
// call ID (see ensureToolCall).
settled: Set<string>
shouldBreak: boolean
snapshot: string | undefined
blocked: boolean
Expand Down Expand Up @@ -105,6 +110,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
model: input.model,
toolcalls: {},
settled: new Set(),
shouldBreak: false,
snapshot: initialSnapshot,
blocked: false,
Expand All @@ -123,6 +129,7 @@ const layer = Layer.effect(
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
const done = ctx.toolcalls[toolCallID]?.done
delete ctx.toolcalls[toolCallID]
ctx.settled.add(toolCallID)
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
})

Expand Down Expand Up @@ -219,6 +226,10 @@ const layer = Layer.effect(
providerExecuted?: boolean
}) {
const existing = yield* readToolCall(input.id)
// A settled call never gets a fresh part. Late events for it carry no
// tool name, so recreating one would persist a phantom `unknown` call
// that shares its call ID with the real, already-completed part.
if (!existing && ctx.settled.has(input.id)) return undefined
if (existing) {
if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
const part = yield* session.updatePart({
Expand Down Expand Up @@ -606,6 +617,7 @@ const layer = Layer.effect(
})
}
ctx.toolcalls = {}
ctx.settled.clear()
ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage)
})
Expand Down
81 changes: 81 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,87 @@ describe("session.message-v2.toModelMessage", () => {
])
})

test("replays only the first part recorded for a duplicated tool call id", async () => {
const userID = "m-user"
const assistantID = "m-assistant"

const input: SessionV1.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1"),
type: "text",
text: "run tool",
},
] as SessionV1.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "read",
state: {
status: "completed",
input: { filePath: "x.json" },
output: "contents",
title: "read",
metadata: {},
time: { start: 0, end: 1 },
},
},
// Phantom part recorded by older builds: same call ID, no tool name.
{
...basePart(assistantID, "a2"),
type: "tool",
callID: "call-1",
tool: "unknown",
state: {
status: "error",
input: {},
error: "Tool execution aborted",
time: { start: 0, end: 1 },
metadata: { interrupted: true },
},
},
] as SessionV1.Part[],
},
]

expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "read",
input: { filePath: "x.json" },
providerExecuted: undefined,
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "read",
output: { type: "text", value: "contents" },
},
],
},
])
})

test("forwards partial bash output for aborted tool calls", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,34 @@ const providerErrorLLM = Layer.succeed(
const providerErrorEnv = LayerNode.compile(root, [...replacements, [LLM.node, providerErrorLLM]])
const itProviderError = testEffect(providerErrorEnv)

// Regression: providers (Qwen via OpenRouter) can re-emit argument deltas for a
// tool call that already produced its result. Those late events carry no tool
// name, so the adapter falls back to "unknown".
const lateToolInputLLM = Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: () =>
Stream.make(
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-1", name: "lookup" }),
LLMEvent.toolInputEnd({ id: "call-1", name: "lookup" }),
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {}, providerExecuted: true }),
LLMEvent.toolResult({
id: "call-1",
name: "lookup",
result: { type: "json", value: { output: "ok", title: "lookup", metadata: {} } },
providerExecuted: true,
}),
LLMEvent.toolInputDelta({ id: "call-1", name: "unknown", text: "{}" }),
LLMEvent.toolInputEnd({ id: "call-1", name: "unknown" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
),
}),
)
const lateToolInputEnv = LayerNode.compile(root, [...replacements, [LLM.node, lateToolInputLLM]])
const itLateToolInput = testEffect(lateToolInputEnv)

const fragmentFailureLLM = Layer.succeed(
LLM.Service,
LLM.Service.of({
Expand Down Expand Up @@ -1169,3 +1197,43 @@ itFragmentFailure.live("session.processor effect tests retain partial legacy par
{ config: cfg },
),
)

itLateToolInput.live("session.processor effect tests ignore tool input events after a call settles", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()

const chat = yield* session.create({})
const parent = yield* user(chat.id, "late tool input")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })

yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "late tool input" }],
tools: {},
})

const calls = (yield* MessageV2.parts(msg.id)).filter(
(part): part is SessionV1.ToolPart => part.type === "tool",
)
expect(calls.map((part) => ({ tool: part.tool, status: part.state.status }))).toEqual([
{ tool: "lookup", status: "completed" },
])
}),
{ config: cfg },
),
)
Loading