From 95a3bc18eaad46905ad25b718b3866d2a6e48f88 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 19 Sep 2026 02:27:10 -0700 Subject: [PATCH] fix(dispatcher): name what an api_error is, so a consumer can tell a failure from an abort Agent Code #1018 review. The orchestration lifecycle reads a newer-than- output api_error on an idle session as a failed turn. It could not tell three things apart: - An instance-wide session.error with NO sessionID. OpenCode publishes these when a SKILL.md, agent, command or plugin file fails to parse. The session filter lets them through because there is nothing to compare, so a healthy child read "failed: Failed to parse skill ...". They stay visible, marked errorType 'instance'. - A user's Esc (MessageAbortedError). This is an interruption, not a provider failure. Every session error now carries OpenCode's own error name as errorType. - This package's delta-buffer overflow. The turn continues; it is marked 'part_overflow'. Tests: - The recorded usage-limit row now also pins errorType APIError. - A new recorded abort row (case b of Agent Code's catalog) pins MessageAbortedError. - The instance case uses the payload exactly as OpenCode's source builds it. A live capture against 1.18.31 was attempted and produced no event; the fixture's provenance says so. - All three fail with the dispatcher change reverted. Co-Authored-By: Claude Opus 5 (1M context) --- src/OpencodeHeadless.ts | 5 +++ src/channels/types.ts | 8 +++++ src/dispatcher/EventDispatcher.ts | 22 ++++++++++++- src/dispatcher/sessionError.test.ts | 32 ++++++++++++++++++- .../instance-skill-parse-1.18.json | 7 ++++ .../session-error/user-abort-1.18.31.json | 5 +++ 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 testing/fixtures/session-error/instance-skill-parse-1.18.json create mode 100644 testing/fixtures/session-error/user-abort-1.18.31.json diff --git a/src/OpencodeHeadless.ts b/src/OpencodeHeadless.ts index 83526e8..cff4f36 100644 --- a/src/OpencodeHeadless.ts +++ b/src/OpencodeHeadless.ts @@ -145,6 +145,11 @@ export class OpencodeHeadless extends EventEmitter { this.semantic.publish({ type: 'api_error', turnId: this.semantic.getActiveTurnId(), + // Not a provider failure: the turn keeps streaming, only this part's + // live deltas were cut. Marked so a consumer that asks "did the turn + // fail?" (Agent Code's orchestration lifecycle) can skip it, while a + // feed still shows it. + errorType: 'part_overflow', message: `OpenCode part ${overflow.partID} exceeded the in-memory delta buffer`, error: overflow, source: 'opencode-sse', diff --git a/src/channels/types.ts b/src/channels/types.ts index 1652995..5a73cf8 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -158,6 +158,14 @@ export type SemanticApiErrorEvent = { type: 'api_error' turnId: string | null message: string + /** + * What failed, as a stable name rather than prose. OpenCode's own error + * name for a session error ('APIError', 'MessageAbortedError', …), + * 'instance' for an instance-wide error with no session (a broken skill, + * agent, command or plugin file), or 'part_overflow' for this package's + * delta-buffer cap. Absent when OpenCode sent no name. + */ + errorType?: string error?: unknown source: SemanticSource ts: number diff --git a/src/dispatcher/EventDispatcher.ts b/src/dispatcher/EventDispatcher.ts index 385f9db..89ebeb6 100644 --- a/src/dispatcher/EventDispatcher.ts +++ b/src/dispatcher/EventDispatcher.ts @@ -286,10 +286,29 @@ export class EventDispatcher { }) return - case 'session.error': + case 'session.error': { + // `errorType` tells a consumer WHAT failed without parsing prose + // (Agent Code #1018 review). Two cases matter to it: + // + // 1. A session.error with NO sessionID. OpenCode publishes those for + // instance-wide problems: a SKILL.md, agent, command or plugin file + // that fails to parse (skill/index.ts, config/agent.ts, + // config/command.ts, plugin/index.ts in OpenCode's source; every + // session-scoped publish passes a sessionID). The session filter + // above lets them through because there is no id to compare. They + // are still worth showing, since a broken skill is the user's to fix. + // But they say nothing about this session's turn, and reading them + // as a turn failure made a healthy orchestration child report + // `failed: Failed to parse skill …`. They are marked 'instance'. + // 2. Every other error keeps OpenCode's own error name ('APIError', + // 'ProviderAuthError', 'MessageAbortedError', …). A user's Esc is + // 'MessageAbortedError', which is an interruption and not a provider + // failure; the consumer needs the name to tell the two apart. + const sessionScoped = getString(payload, ['sessionID', 'sessionId', 'session.id']) !== undefined this.semantic.publish({ type: 'api_error', turnId: this.turns.getActiveTurnId(), + errorType: sessionScoped ? getString(payload, ['error.name']) : 'instance', // `error.data.message` first (Agent Code #1018): OpenCode 1.18 // publishes session.error as { sessionID, error: { name, data: { // message, statusCode, ... } } }, the same shape as the assistant @@ -303,6 +322,7 @@ export class EventDispatcher { ts: Date.now(), }) return + } case 'session.diff': this.screen.publishSystem({ diff --git a/src/dispatcher/sessionError.test.ts b/src/dispatcher/sessionError.test.ts index d15e2b6..9aa149b 100644 --- a/src/dispatcher/sessionError.test.ts +++ b/src/dispatcher/sessionError.test.ts @@ -21,5 +21,35 @@ it('reports the provider\'s own text for a session error, not the generic fallba semantic.on('api_error', event => errors.push(event as { message: string })) const dispatcher = new EventDispatcher({ semantic, screen: new ScreenChannel(), committed: new CommittedChannel(), sessionID: recorded.sessionID }) dispatcher.dispatch({ type: 'session.error', properties: { sessionID: recorded.sessionID, error: recorded.error } }) - expect(errors).toEqual([expect.objectContaining({ message: 'Usage limit reached for 5 hour. Your limit will reset at 2026-09-19 14:14:24' })]) + expect(errors).toEqual([expect.objectContaining({ message: 'Usage limit reached for 5 hour. Your limit will reset at 2026-09-19 14:14:24', errorType: 'APIError' })]) +}) + +// Agent Code #1018 review: a consumer asking "did this turn fail?" must be +// able to tell a provider failure from an interruption and from an +// instance-wide problem, without parsing the message. +function dispatchError(properties: Record) { + const semantic = new SemanticChannel() + const errors: Array> = [] + semantic.on('api_error', event => errors.push(event as Record)) + const dispatcher = new EventDispatcher({ semantic, screen: new ScreenChannel(), committed: new CommittedChannel(), sessionID: recorded.sessionID }) + dispatcher.dispatch({ type: 'session.error', properties }) + return errors +} +const fixture = (name: string) => JSON.parse(readFileSync(new URL(`../../testing/fixtures/session-error/${name}.json`, import.meta.url), 'utf8')) as { + sessionID?: string + error: { name: string; data: { message: string } } +} + +it('names a user abort as MessageAbortedError, from the recorded row', () => { + const abort = fixture('user-abort-1.18.31') + // The recorded row belongs to another session; the dispatcher's own session + // is the one this error is published for. + expect(dispatchError({ sessionID: recorded.sessionID, error: abort.error })) + .toEqual([expect.objectContaining({ message: 'Aborted', errorType: 'MessageAbortedError' })]) +}) + +it('still shows an instance-wide error with no sessionID, marked instance rather than as this turn\'s failure', () => { + const instance = fixture('instance-skill-parse-1.18') + expect(dispatchError({ error: instance.error })) + .toEqual([expect.objectContaining({ message: instance.error.data.message, errorType: 'instance' })]) }) diff --git a/testing/fixtures/session-error/instance-skill-parse-1.18.json b/testing/fixtures/session-error/instance-skill-parse-1.18.json new file mode 100644 index 0000000..a728fd9 --- /dev/null +++ b/testing/fixtures/session-error/instance-skill-parse-1.18.json @@ -0,0 +1,7 @@ +{ + "provenance": "Derived from OpenCode's source, not recorded: packages/opencode/src/skill/index.ts publishes Session.Event.Error with { error: new NamedError.Unknown({ message }).toObject() } and NO sessionID when a SKILL.md fails to parse (config/agent.ts, config/command.ts and plugin/index.ts do the same for their files). NamedError.toObject() is { name, data }. A live capture was attempted on 2026-09-19 against the 1.18.31 binary (isolated HOME, a project with a malformed .opencode/skill/*/SKILL.md, /global/event subscribed first) but that server did not scan the project's skills, so no event was produced. The message text is the FrontmatterError form from config/markdown.ts.", + "error": { + "name": "UnknownError", + "data": { "message": "/project/.opencode/skill/broken/SKILL.md: Failed to parse YAML frontmatter: unexpected end of the stream within a flow collection" } + } +} diff --git a/testing/fixtures/session-error/user-abort-1.18.31.json b/testing/fixtures/session-error/user-abort-1.18.31.json new file mode 100644 index 0000000..c962a57 --- /dev/null +++ b/testing/fixtures/session-error/user-abort-1.18.31.json @@ -0,0 +1,5 @@ +{ + "provenance": "Agent Code testing/fixtures/orchestration-api-error/opencode-terminal-nonretryable-and-abort.json, case b (OpenCode 1.18.31 database, 2026-09-18): the assistant row error left by a user pressing Esc mid-turn. session.error publishes {sessionID, error: assistantMessage.error}, so this is the payload's error field verbatim.", + "sessionID": "ses_7e76f12489224b43abc3984fe2e5bda2", + "error": { "name": "MessageAbortedError", "data": { "message": "Aborted" } } +}