diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b509f3cb7459..bdaf16e9c8ae 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { LLMClient, LLMError, LLMEvent, + InvalidProviderOutputReason, Message, SystemPart, isContextOverflowFailure, @@ -296,7 +297,24 @@ const layer = Layer.effect( ) return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) - const llmFailure = failure instanceof LLMError ? failure : undefined + const recordedFinish = publisher.stepSettlement()?.finish + // A successful stream with an explicit provider error is still a failed assistant turn. + // `length` is intentionally excluded: it is a valid truncation outcome and may contain + // usable partial output, so it is outside this subagent failure fix. + const invalidFinish = + stream._tag === "Success" && (recordedFinish === "unknown" || recordedFinish === "error") + ? new LLMError({ + module: "SessionRunner", + method: "run", + reason: new InvalidProviderOutputReason({ + message: + recordedFinish === "error" + ? "The provider response ended with an error finish reason." + : "The provider response ended with an unknown finish reason.", + }), + }) + : undefined + const llmFailure = failure instanceof LLMError ? failure : invalidFinish if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) @@ -323,7 +341,7 @@ const layer = Layer.effect( yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) } const stepSettlement = publisher.stepSettlement() - if (stepSettlement && !publisher.hasProviderError()) { + if (stepSettlement && !publisher.hasProviderError() && !publisher.hasAssistantFailure()) { const endSnapshot = yield* snapshots.capture() const files = startSnapshot && endSnapshot @@ -346,12 +364,15 @@ const layer = Layer.effect( } if (publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) - if (stream._tag === "Success" && !publisher.hasProviderError()) + if (stream._tag === "Success" && !publisher.hasProviderError() && !publisher.hasAssistantFailure()) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) return yield* Effect.failCause(settled.cause) - return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } + return { + needsContinuation: !publisher.hasProviderError() && !publisher.hasAssistantFailure() && needsContinuation, + step: currentStep, + } }), ) }, Effect.scoped) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 33652a618c93..dedddbf47e1e 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -415,6 +415,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) failUnsettledTools, hasActiveAssistant: () => assistantActive, hasAssistantStarted: () => assistantMessageID !== undefined, + hasAssistantFailure: () => assistantFailed, hasProviderError: () => providerFailed, stepSettlement: () => stepSettlement, startAssistant, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d2a34bf65ce7..9c9d33d15fc0 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3183,6 +3183,35 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("projects an error finish as a terminal assistant step failure", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish with an error" }), resume: false }) + + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "error-finish-text" }), + LLMEvent.textDelta({ id: "error-finish-text", text: "Partial output" }), + LLMEvent.textEnd({ id: "error-finish-text" }), + LLMEvent.stepFinish({ index: 0, reason: "error" }), + LLMEvent.finish({ reason: "error" }), + ] + + yield* session.resume(sessionID) + + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish with an error" }, + { + type: "assistant", + finish: "error", + error: { type: "unknown", message: "The provider response ended with an error finish reason." }, + content: [{ type: "text", text: "Partial output" }], + }, + ]) + }), + ) + it.effect("projects provider errors emitted before assistant step start", () => Effect.gen(function* () { yield* setup diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d8ca640cfba9..0dc6abf9f7a8 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -210,11 +210,12 @@ export const TaskTool = Tool.define( agent: next.name, parts, }) - if (result.info.role === "assistant" && result.info.error) { - const message = - "message" in result.info.error.data && typeof result.info.error.data.message === "string" + if (result.info.role === "assistant" && (result.info.error || result.info.finish === "error")) { + const message = result.info.error + ? "message" in result.info.error.data && typeof result.info.error.data.message === "string" ? result.info.error.data.message : result.info.error.name + : `finished with error${result.info.finish ? ` (finish: ${result.info.finish})` : ""}` return yield* Effect.fail(new Error(`Subagent failed (task_id: ${nextSession.id}): ${message}`)) } const failed = result.parts.findLast((item) => item.type === "tool" && item.state.status === "error") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 42f46fd35d7e..fe62e9185360 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -101,6 +101,7 @@ function stubOps(opts?: { text?: string error?: NonNullable toolError?: string + finish?: string }): TaskPromptOps { return { cancel: () => Effect.void, @@ -108,7 +109,7 @@ function stubOps(opts?: { prompt: (input) => Effect.sync(() => { opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done", opts?.error, opts?.toolError) + return reply(input, opts?.text ?? "done", opts?.error, opts?.toolError, opts?.finish) }), } } @@ -118,6 +119,7 @@ function reply( text: string, error?: NonNullable, toolError?: string, + finish?: string, ): SessionV1.WithParts { const id = MessageID.ascending() return { @@ -134,7 +136,7 @@ function reply( modelID: input.model?.modelID ?? ref.modelID, providerID: input.model?.providerID ?? ref.providerID, time: { created: Date.now() }, - finish: "stop", + finish: finish ?? "stop", error, }, parts: [ @@ -327,6 +329,95 @@ describe("tool.task", () => { }), ) + it.instance("execute fails when subagent finishes with error and no error object", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: stubOps({ + text: "", + finish: "error", + }), + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) throw new Error("expected task failure") + const child = (yield* sessions.children(chat.id))[0] + expect(child).toBeDefined() + const failure = Cause.squash(exit.cause) + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error("expected Error defect") + expect(failure.message).toBe(`Subagent failed (task_id: ${child?.id}): finished with error (finish: error)`) + }), + ) + + background.instance("background delivery reports a subagent finish error", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const notified = defer() + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: stubOps({ + finish: "error", + onPrompt: (input) => { + if (input.sessionID === chat.id) notified.resolve(input) + }, + }), + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 }) + expect(waited.info?.status).toBe("error") + expect(waited.info?.error).toBe( + `Subagent failed (task_id: ${result.metadata.sessionId}): finished with error (finish: error)`, + ) + const notification = yield* Effect.promise(() => notified.promise) + const part = notification.parts[0] + expect(part?.type).toBe("text") + if (part?.type === "text") expect(part.text).toContain('state="error"') + }), + ) + it.instance("execute surfaces terminal child tool errors with a resumable task_id", () => Effect.gen(function* () { const sessions = yield* Session.Service