Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/OpencodeHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/channels/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion src/dispatcher/EventDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -303,6 +322,7 @@ export class EventDispatcher {
ts: Date.now(),
})
return
}

case 'session.diff':
this.screen.publishSystem({
Expand Down
32 changes: 31 additions & 1 deletion src/dispatcher/sessionError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
const semantic = new SemanticChannel()
const errors: Array<Record<string, unknown>> = []
semantic.on('api_error', event => errors.push(event as Record<string, unknown>))
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' })])
})
7 changes: 7 additions & 0 deletions testing/fixtures/session-error/instance-skill-parse-1.18.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
5 changes: 5 additions & 0 deletions testing/fixtures/session-error/user-abort-1.18.31.json
Original file line number Diff line number Diff line change
@@ -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" } }
}
Loading