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
12 changes: 12 additions & 0 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,18 @@ export function MessageTimeline(props: {
</TimelineRowFrame>
)
}
case "Warning": {
const warningRow = row as Accessor<TimelineRowByTag<"Warning">>
return (
<TimelineRowFrame row={warningRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="warning" class="error-card">
{warningRow().text}
</Card>
</div>
</TimelineRowFrame>
)
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/pages/session/timeline/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/pages/session/timeline/timeline-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}> {}
Expand All @@ -49,6 +53,7 @@ export namespace TimelineRow {
| Thinking
| DiffSummary
| Error
| Warning
| Retry

export const key = (row: TimelineRow) => {
Expand All @@ -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}`
}
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
})
}),
),
Expand Down
34 changes: 33 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -1343,7 +1345,37 @@ const layer = Layer.effect(
const loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts> = 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<SessionV1.WithParts, Session.BusyError> = Effect.fn(
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
})

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly skipped: () => Effect.Effect<Array<{ file: string; error: unknown }>>
readonly tools: (model: {
providerID: ProviderV2.ID
modelID: ModelV2.ID
Expand Down Expand Up @@ -116,6 +118,7 @@ const layer = Layer.effect(
const state = yield* InstanceState.make<State>(
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
Expand Down Expand Up @@ -244,6 +247,7 @@ const layer = Layer.effect(
],
task: tool.task,
read: tool.read,
skipped,
}
}),
)
Expand All @@ -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)
})
Expand Down Expand Up @@ -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 })
}),
)

Expand Down
17 changes: 16 additions & 1 deletion packages/schema/src/v1/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
export type Assistant = Omit<
Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>,
"error" | "warning"
> & {
error?: AssistantError
warning?: AssistantError
}

export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
Expand Down Expand Up @@ -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,
Expand All @@ -672,5 +686,6 @@ export const Event = {
PartDelta,
Diff,
Error,
Warning,
),
}
12 changes: 7 additions & 5 deletions packages/schema/test/event-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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", () => {
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading