diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 25abdc9e7341..e090f2ac9231 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1271,6 +1271,18 @@ export function MessageTimeline(props: { ) } + case "Warning": { + const warningRow = row as Accessor> + return ( + +
+ + {warningRow().text} + +
+
+ ) + } } } diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 1f9472a0a58b..98dc0f0020fd 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -228,6 +228,19 @@ export namespace Timeline { ) } + const warning = assistantMessages.at(-1)?.warning + if (warning) { + const data = warning.data?.message + rows.push( + new TimelineRow.Warning({ + userMessageID: userMessage.id, + text: unwrapErrorMessage( + typeof data === "string" ? data : data === undefined || data === null ? "" : String(data), + ), + }), + ) + } + return rows } diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b29b5..ce1068c07ec3 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -36,6 +36,10 @@ export namespace TimelineRow { userMessageID: string text: string }> {} + export class Warning extends Data.TaggedClass("Warning")<{ + userMessageID: string + text: string + }> {} export class Retry extends Data.TaggedClass("Retry")<{ userMessageID: string }> {} @@ -49,6 +53,7 @@ export namespace TimelineRow { | Thinking | DiffSummary | Error + | Warning | Retry export const key = (row: TimelineRow) => { @@ -69,6 +74,8 @@ export namespace TimelineRow { return `diff-summary:${row.userMessageID}` case "Error": return `error:${row.userMessageID}` + case "Warning": + return `warning:${row.userMessageID}` case "Retry": return `retry:${row.userMessageID}` } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..4c91c67d2790 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -835,6 +835,7 @@ export const RunCommand = effectCmd({ if (args.attach) return const error = await completed if (error) process.exitCode = 1 + return error } if (args.command) { @@ -847,8 +848,11 @@ export const RunCommand = effectCmd({ variant: args.variant, }) if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 + const loopError = await finish() + if (!loopError && !emit("error", { error: result.error })) { + UI.error(formatRunError(result.error)) + } return } await finish() @@ -864,8 +868,11 @@ export const RunCommand = effectCmd({ parts: [...files, { type: "text", text: message }], }) if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 + const loopError = await finish() + if (!loopError && !emit("error", { error: result.error })) { + UI.error(formatRunError(result.error)) + } return } await finish() diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..43dd8ebdad4b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -16,6 +16,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NamedError } from "@opencode-ai/core/util/error" +import { errorMessage } from "@/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect/instance-state" @@ -316,10 +317,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { + if (cause.reasons.some(Cause.isDieReason)) return yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) yield* events.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), + error: new NamedError.Unknown({ message: errorMessage(Cause.squash(cause)) }).toObject(), }) }), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 22b1d7d99a2a..7e881b0267f2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -42,6 +42,7 @@ import { Truncate } from "@/tool/truncate" import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" +import { errorMessage } from "@/util/error" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" @@ -1238,6 +1239,7 @@ const layer = Layer.effect( Effect.provideService(MCP.Service, mcp), Effect.provideService(Truncate.Service, truncate), Effect.provideService(RuntimeFlags.Service, flags), + Effect.provideService(EventV2Bridge.Service, events), ) if (lastUser.format?.type === "json_schema") { @@ -1343,7 +1345,37 @@ const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( input: LoopInput, ) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) + return yield* state.ensureRunning( + input.sessionID, + lastAssistant(input.sessionID), + runLoop(input.sessionID).pipe( + Effect.catchCause((cause) => { + const alreadySurfaced = cause.reasons + .filter(Cause.isDieReason) + .some( + (reason) => + reason.defect instanceof NamedError || Provider.ModelNotFoundError.isInstance(reason.defect), + ) + if (alreadySurfaced) return Effect.failCause(cause) + if (!cause.reasons.some(Cause.isDieReason)) return Effect.failCause(cause) + return Effect.gen(function* () { + const error = new NamedError.Unknown({ message: errorMessage(Cause.squash(cause)) }).toObject() + const assistant = yield* lastAssistant(input.sessionID) + if (assistant.info.role === "assistant") { + assistant.info.error = error + assistant.info.finish = "error" + yield* sessions.updateMessage(assistant.info) + } + yield* Effect.logError("session run failed with defect", { + "session.id": input.sessionID, + cause, + }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error }) + return yield* Effect.failCause(cause) + }) + }), + ), + ) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index cfe034146c1b..fdf3de150b89 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -326,6 +326,7 @@ export const Event = { Deleted: SessionV1.Event.Deleted, Diff: SessionV1.Event.Diff, Error: SessionV1.Event.Error, + Warning: SessionV1.Event.Warning, } export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..8e02f0c3bf5a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -17,10 +17,12 @@ import { Effect } from "effect" import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionProcessor } from "./processor" +import { EventV2Bridge } from "@/event-v2-bridge" import { PartID } from "./schema" import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { NamedError } from "@opencode-ai/core/util/error" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -489,6 +491,22 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { tools[key] = item } + const skipped = yield* registry.skipped() + if (skipped.length > 0) { + const message = skipped + .map(({ file, error }) => { + const detail = error instanceof Error ? `${error.message}` : String(error) + return `Failed to load tool: ${file} (${detail})` + }) + .join("\n") + input.processor.message.warning = new NamedError.Unknown({ message }).toObject() + const events = yield* EventV2Bridge.Service + yield* events.publish(Session.Event.Warning, { + sessionID: input.session.id, + warning: input.processor.message.warning, + }) + } + return tools }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..29270705d344 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -67,12 +67,14 @@ type State = { builtin: Tool.Def[] task: TaskDef read: ReadDef + skipped: Array<{ file: string; error: unknown }> } export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> + readonly skipped: () => Effect.Effect> readonly tools: (model: { providerID: ProviderV2.ID modelID: ModelV2.ID @@ -116,6 +118,7 @@ const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { const custom: Tool.Def[] = [] + const skipped: Array<{ file: string; error: unknown }> = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { // Plugin tools still expose Zod args publicly; keep that compatibility @@ -244,6 +247,7 @@ const layer = Layer.effect( ], task: tool.task, read: tool.read, + skipped, } }), ) @@ -253,6 +257,11 @@ const layer = Layer.effect( return [...s.builtin, ...s.custom] as Tool.Def[] }) + const skipped: Interface["skipped"] = Effect.fn("ToolRegistry.skipped")(function* () { + const s = yield* InstanceState.get(state) + return s.skipped + }) + const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { return (yield* all()).map((tool) => tool.id) }) @@ -339,7 +348,7 @@ const layer = Layer.effect( return { task: s.task, read: s.read } }) - return Service.of({ ids, all, named, tools }) + return Service.of({ ids, all, named, skipped, tools }) }), ) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..fc6ee66f68f0 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -458,6 +458,7 @@ export const Assistant = Schema.Struct({ completed: Schema.optional(NonNegativeInt), }), error: Schema.optional(AssistantErrorSchema), + warning: Schema.optional(AssistantErrorSchema), parentID: MessageID, modelID: Model.ID, providerID: Provider.ID, @@ -483,8 +484,12 @@ export const Assistant = Schema.Struct({ variant: Schema.optional(Schema.String), finish: Schema.optional(Schema.String), }).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { +export type Assistant = Omit< + Types.DeepMutable>, + "error" | "warning" +> & { error?: AssistantError + warning?: AssistantError } export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) @@ -656,11 +661,20 @@ export const Error = define({ }, }) +export const Warning = define({ + type: "session.warning", + schema: { + sessionID: Schema.optional(SessionID), + warning: Assistant.fields.warning, + }, +}) + export const Event = { ...events, PartDelta, Diff, Error, + Warning, Definitions: inventory( events.Created, events.Updated, @@ -672,5 +686,6 @@ export const Event = { PartDelta, Diff, Error, + Warning, ), } diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5694afdd30df..b9bcf8c5a4d1 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -9,8 +9,8 @@ import { WorkspaceEvent } from "../src/workspace-event" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.length).toBe(55) - expect(EventManifest.Definitions.length).toBe(85) + expect(EventManifest.ServerDefinitions.length).toBe(58) + expect(EventManifest.Definitions.length).toBe(89) expect(SessionV1.Event.Definitions).toEqual([ SessionV1.Event.Created, SessionV1.Event.Updated, @@ -22,9 +22,10 @@ describe("public event manifest", () => { SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error, + SessionV1.Event.Warning, ]) - expect(EventManifest.Latest.size).toBe(85) - expect(EventManifest.Durable.size).toBe(32) + expect(EventManifest.Latest.size).toBe(89) + expect(EventManifest.Durable.size).toBe(35) }) test("uses canonical definitions for current public events", () => { @@ -42,10 +43,11 @@ describe("public event manifest", () => { expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) - expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + expect(EventManifest.Definitions.slice(43, 47)).toEqual([ SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error, + SessionV1.Event.Warning, ]) expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158cc..246432137766 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -347,6 +347,15 @@ export type AssistantMessage = { | ContextOverflowError | ContentFilterError | ApiError + warning?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError parentID: string modelID: string providerID: string