diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index f82bb4d962..d9d23999d2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" function activeAssistant(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) if (index < 0) return const assistant = messages[index] return assistant?.type === "assistant" ? assistant : undefined } function activeCompaction(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "compaction") + const index = messages.findIndex((message) => message.type === "compaction") if (index < 0) return const compaction = messages[index] return compaction?.type === "compaction" ? compaction : undefined } function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) if (index < 0) return const shell = messages[index] return shell?.type === "shell" ? shell : undefined @@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( switch (event.type) { case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "user", text: event.properties.prompt.text, @@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( } case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "synthetic", sessionID: event.properties.sessionID, @@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "shell", callID: event.properties.callID, @@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( update(event.properties.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.push({ + draft.unshift({ id: event.id, type: "assistant", agent: event.properties.agent, @@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } }) break + case "session.next.step.failed": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break case "session.next.text.started": update(event.properties.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "text", text: "" }) @@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( match.time.completed = event.properties.timestamp }) break - case "session.next.tool.error": + case "session.next.tool.failed": update(event.properties.sessionID, (draft) => { const match = latestTool(activeAssistant(draft), event.properties.callID) if (match?.state.status !== "running") return @@ -250,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "compaction", reason: event.properties.reason, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 7270a9c3b7..73e9b9d435 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SyntaxStyle } from "@opentui/core" +import type { BoxRenderable, SyntaxStyle } from "@opentui/core" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) @@ -83,6 +87,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { last={lastAssistant()?.id === message.id} syntax={syntax()} subtleSyntax={subtleSyntax()} + start={lastUserCreated(index())} /> @@ -294,12 +299,13 @@ function AssistantMessage(props: { last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle + start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created + return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" @@ -521,6 +527,7 @@ function InlineTool(props: { part: SessionMessageAssistantTool }) { const { theme } = useTheme() + const [margin, setMargin] = createSignal(0) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const denied = createMemo(() => { const message = error() @@ -532,21 +539,46 @@ function InlineTool(props: { ) }) return ( - - - - {props.children} - - - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - + 1) { + setMargin(1) + return + } + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.height > 1 || previous.id.startsWith("text-")) setMargin(1) + }} + > + + + {props.complete ? props.icon : "~"}} + > + + + + + + {props.children} + + + - {error()} + + + {error()} + ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e2a47f1800..cf1a7e0ae9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -405,7 +405,7 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Error.Sync, { + EventV2.run(SessionEvent.Tool.Failed.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, error: { @@ -650,6 +650,17 @@ export const layer: Layer.Layer< yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: error.name, + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 951e3e874f..88f73acf1a 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -161,6 +161,9 @@ export default [ SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) }), + SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) + }), SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) }), @@ -181,8 +184,8 @@ export default [ SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) }), - SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) }), SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 3af5932f0d..47938dcbed 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -22,6 +22,11 @@ const Base = { sessionID: SessionID, } +const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}) + export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", aggregate: "sessionID", @@ -128,6 +133,16 @@ export namespace Step { }, }) export type Ended = Schema.Schema.Type + + export const Failed = EventV2.define({ + type: "session.next.step.failed", + aggregate: "sessionID", + schema: { + ...Base, + error: Error, + }, + }) + export type Failed = Schema.Schema.Type } export namespace Text { @@ -275,23 +290,20 @@ export namespace Tool { }) export type Success = Schema.Schema.Type - export const Error = EventV2.define({ - type: "session.next.tool.error", + export const Failed = EventV2.define({ + type: "session.next.tool.failed", aggregate: "sessionID", schema: { ...Base, callID: Schema.String, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: Error, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, }) - export type Error = Schema.Schema.Type + export type Failed = Schema.Schema.Type } export const RetryError = Schema.Struct({ @@ -359,6 +371,7 @@ export const All = Schema.Union( Shell.Ended, Step.Started, Step.Ended, + Step.Failed, Text.Started, Text.Delta, Text.Ended, @@ -368,7 +381,7 @@ export const All = Schema.Union( Tool.Called, Tool.Progress, Tool.Success, - Tool.Error, + Tool.Failed, Reasoning.Started, Reasoning.Delta, Reasoning.Ended, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index ad1aa32e70..d5d5aac7b7 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -199,6 +199,17 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, + "session.next.step.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }, "session.next.text.started": () => { if (currentAssistant) { adapter.updateAssistant( @@ -314,7 +325,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, - "session.next.tool.error": (event) => { + "session.next.tool.failed": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 8ec99bc200..94f6b1cac2 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -152,7 +152,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan write: Schema.Finite, }), }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), + error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index caa3d4c767..79ef42d9e1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -58,6 +58,7 @@ export type Event = | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -70,7 +71,7 @@ export type Event = | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -823,6 +824,7 @@ export type GlobalEvent = { | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -835,7 +837,7 @@ export type GlobalEvent = { | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -857,6 +859,7 @@ export type GlobalEvent = { | SyncEventSessionNextShellEnded | SyncEventSessionNextStepStarted | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed | SyncEventSessionNextTextStarted | SyncEventSessionNextTextDelta | SyncEventSessionNextTextEnded @@ -869,7 +872,7 @@ export type GlobalEvent = { | SyncEventSessionNextToolCalled | SyncEventSessionNextToolProgress | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolError + | SyncEventSessionNextToolFailed | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionDelta @@ -1973,6 +1976,22 @@ export type SyncEventSessionNextStepEnded = { } } +export type SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type SyncEventSessionNextTextStarted = { type: "sync" name: "session.next.text.started.1" @@ -2157,9 +2176,9 @@ export type SyncEventSessionNextToolSuccess = { } } -export type SyncEventSessionNextToolError = { +export type SyncEventSessionNextToolFailed = { type: "sync" - name: "session.next.tool.error.1" + name: "session.next.tool.failed.1" id: string seq: number aggregateID: "sessionID" @@ -2710,6 +2729,19 @@ export type EventSessionNextStepEnded = { } } +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type EventSessionNextTextStarted = { id: string type: "session.next.text.started" @@ -2870,9 +2902,9 @@ export type EventSessionNextToolSuccess = { } } -export type EventSessionNextToolError = { +export type EventSessionNextToolFailed = { id: string - type: "session.next.tool.error" + type: "session.next.tool.failed" properties: { timestamp: number sessionID: string @@ -3162,7 +3194,10 @@ export type SessionMessageAssistant = { write: number } } - error?: string + error?: { + type: string + message: string + } } export type SessionMessageCompaction = {