diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404..c650c4521c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -185,7 +185,8 @@ const layer = Layer.effect( const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { const match = yield* readToolCall(toolCallID) - if (!match || match.part.state.status !== "running") return false + if (!match || (match.part.state.status !== "pending" && match.part.state.status !== "running")) return false + const start = match.part.state.status === "running" ? match.part.state.time.start : Date.now() yield* session.updatePart({ ...match.part, state: { @@ -193,8 +194,8 @@ const layer = Layer.effect( input: match.part.state.input, error: errorMessage(error), // Keep metadata streamed while running so failures retain progress detail (e.g. execute's child calls). - metadata: match.part.state.metadata, - time: { start: match.part.state.time.start, end: Date.now() }, + metadata: match.part.state.status === "running" ? match.part.state.metadata : undefined, + time: { start, end: Date.now() }, }, }) if (error instanceof PermissionV1.RejectedError || error instanceof Question.RejectedError) { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 5287605436..1a4943a93b 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -226,6 +226,22 @@ const fragmentFailureLLM = Layer.succeed( const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]]) const itFragmentFailure = testEffect(fragmentFailureEnv) +const pendingToolErrorLLM = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: () => + Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-pending-error", name: "lookup" }), + LLMEvent.toolError({ id: "call-pending-error", name: "lookup", message: "lookup failed" }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ), + }), +) +const pendingToolErrorEnv = LayerNode.compile(root, [...replacements, [LLM.node, pendingToolErrorLLM]]) +const itPendingToolError = testEffect(pendingToolErrorEnv) + const boot = Effect.fn("test.boot")(function* () { const processors = yield* SessionProcessor.Service const session = yield* Session.Service @@ -834,6 +850,47 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup ), ) +itPendingToolError.live("session.processor settles tool errors received while input is pending", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "pending tool error") + 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 }) + + expect( + 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: "pending tool error" }], + tools: {}, + }), + ).toBe("continue") + + const parts = yield* MessageV2.parts(msg.id) + const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool") + expect(call?.state.status).toBe("error") + if (call?.state.status !== "error") return + expect(call.state.error).toBe("lookup failed") + expect(call.state.metadata?.interrupted).toBeUndefined() + }), + { config: cfg }, + ), +) + it.live("session.processor effect tests record aborted errors and idle state", () => provideTmpdirServer( ({ dir, llm }) =>