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
39 changes: 39 additions & 0 deletions docs/plans/2026-09-19-permission-question-subject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Permission and question subjects on OpenCode 1.18.30: plan

**Issue:** Juliusolsson05/agent-code#878 (bug, release blocker: OpenCode is now
bundled, so this is on the default path).
**Branch:** `fix/permission-question-subject` from `main` @ `4f2ef5d`.

## Problem (from recordings, not assumed)

On 1.18.30 the permission modal shows no subject, so the user approves
`bash: ls -1` blind, and the question modal shows no question. Two separate
parsers read these payloads, and both drifted:
- the dispatcher walked `['title','tool','action','permission.action']` first
non-null, so the now-object `tool` stopped it;
- `permissionRequestFromEvent` walked `['title','tool','action']` and
matched nothing.

The question text moved to `questions[].question`.

## Change

- One shared parser, `src/permissions/subject.ts` (`permissionSubject`,
`questionText`, `permissionRequestFromEvent`), used by BOTH the dispatcher
and OpencodeHeadless.
- The 1.18.30 shape comes first. The old keys stay as string-only fallbacks.

## Test (fail-first, real recordings)

`src/permissions/subject.test.ts` replays the recorded 1.18.30 streams
(`testing/fixtures/live-1.18.30`, copied unchanged from
opencode-terminal-headless's Stage 0 probe) through the real `EventDispatcher`
and channels. It asserts the subject `bash: ls -1`, the question text, and the
same subject on the pending request. Before the fix: 3/3 subject assertions
failed with `undefined`.

## Out of scope

- A cross-package shared parser with opencode-terminal-headless's
`LiveStateProjector`. That package already parses the shape correctly, and
sharing would need a third package for two small functions.
18 changes: 3 additions & 15 deletions src/OpencodeHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type { CommittedEvent, ScreenEvent, SemanticEvent } from './channels/type
import { EventDispatcher, type OpenCodeBusEvent } from './dispatcher/EventDispatcher.js'
import { PartAccumulator } from './dispatcher/partAccumulator.js'
import { PermissionService, type OpenCodePermissionRequest } from './permissions/PermissionService.js'
// Shared with the dispatcher so the modal and the pending request can never
// disagree about a permission's subject again (agent-code#878).
import { permissionRequestFromEvent } from './permissions/subject.js'
import { HistoryClient } from './transcript/HistoryClient.js'
import { SpawnedServer } from './transport/SpawnedServer.js'
import { SseClient, type SseMessage } from './transport/SseClient.js'
Expand Down Expand Up @@ -374,21 +377,6 @@ function extractSessionIDFromEvent(event: OpenCodeBusEvent): string | null {
return firstString(props, ['sessionID', 'sessionId']) ?? extractID(props.session)
}

function permissionRequestFromEvent(event: OpenCodeBusEvent): OpenCodePermissionRequest | null {
const payload =
event.properties && typeof event.properties === 'object'
? (event.properties as Record<string, unknown>)
: event
const requestID = firstString(payload, ['requestID', 'permissionID', 'id'])
if (!requestID) return null
return {
requestID,
sessionID: firstString(payload, ['sessionID', 'sessionId']),
title: firstString(payload, ['title', 'tool', 'action']),
metadata: payload,
}
}

function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = obj[key]
Expand Down
9 changes: 7 additions & 2 deletions src/dispatcher/EventDispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CommittedChannel } from '../channels/CommittedChannel.js'
import { permissionSubject, questionText } from '../permissions/subject.js'
import { ScreenChannel } from '../channels/ScreenChannel.js'
import { SemanticChannel } from '../channels/SemanticChannel.js'
import type { SemanticBlockKind } from '../channels/types.js'
Expand Down Expand Up @@ -954,7 +955,10 @@ export class EventDispatcher {
visible: true,
requestID,
sessionID: this.eventSessionID(payload),
title: getString(payload, ['title', 'tool', 'action', 'permission.action']),
// The shared parser, not a first-non-null key walk: on OpenCode 1.18.30
// `tool` is an object that used to stop the walk and blank the subject
// (agent-code#878). See permissions/subject.ts.
title: permissionSubject(payload),
metadata: payload,
})
}
Expand All @@ -964,7 +968,8 @@ export class EventDispatcher {
visible: true,
questionID: getString(payload, ['questionID', 'id']) ?? undefined,
sessionID: this.eventSessionID(payload),
text: getString(payload, ['text', 'question', 'prompt']) ?? undefined,
// 1.18.30 nests the text in questions[].question (agent-code#878).
text: questionText(payload),
metadata: payload,
})
}
Expand Down
109 changes: 109 additions & 0 deletions src/permissions/subject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'

import { CommittedChannel } from '../channels/CommittedChannel.js'
import { ScreenChannel } from '../channels/ScreenChannel.js'
import { SemanticChannel } from '../channels/SemanticChannel.js'
import type { ScreenPermissionEvent, ScreenQuestionEvent } from '../channels/types.js'
import { EventDispatcher } from '../dispatcher/EventDispatcher.js'
import type { OpenCodeBusEvent } from '../dispatcher/EventDispatcher.js'
import { permissionRequestFromEvent } from './subject.js'

// agent-code#878. On OpenCode 1.18.30, the structured runtime's permission
// modal read "OpenCode is requesting permission." with no subject, so users
// approved `bash: ls -1` blind. The question modal showed a generic line
// instead of the question.
//
// WHY the inputs are RECORDINGS, not literals: the bug is precisely that
// someone's idea of the payload shape (`title` / `tool` / `action`) stopped
// matching what the server actually sends. These streams are the real
// OpenCode 1.18.30 SSE bus, recorded by opencode-terminal-headless's Stage 0
// live probe (testing/fixtures/live-1.18.30/README.md has the provenance).
// Every event is replayed in its recorded order through the REAL
// EventDispatcher and the REAL channels. That is the same path the app
// consumes, so the test sees whatever the app would see.

type Recording = {
opencodeVersion: string
sessionID: string
sse: { t: number, event: OpenCodeBusEvent }[]
}

function recording(name: string): Recording {
return JSON.parse(readFileSync(new URL(`../../testing/fixtures/live-1.18.30/${name}.json`, import.meta.url), 'utf8'))
}

function replay(rec: Recording) {
const screen = new ScreenChannel()
const permissions: ScreenPermissionEvent['state'][] = []
const questions: ScreenQuestionEvent['state'][] = []
screen.on('permission', (event: ScreenPermissionEvent) => permissions.push(event.state))
screen.on('question', (event: ScreenQuestionEvent) => questions.push(event.state))
const dispatcher = new EventDispatcher({
semantic: new SemanticChannel(),
screen,
committed: new CommittedChannel(),
sessionID: rec.sessionID,
})
for (const { event } of rec.sse) dispatcher.dispatch(event)
return { permissions, questions }
}

describe('permission and question subjects on recorded OpenCode 1.18.30 streams', () => {
it('the recordings are the 1.18.30 shape this fix targets', () => {
// Guards the premise: if the fixtures are ever re-recorded on a server
// that changed shape again, this fails first and says so, instead of the
// assertions below failing with a confusing subject mismatch.
expect(recording('permission-once').opencodeVersion).toBe('1.18.30')
const asked = recording('permission-once').sse.find(({ event }) => event.type === 'permission.asked')!.event
expect(asked.properties).toMatchObject({ permission: 'bash', patterns: ['ls -1'] })
expect((asked.properties as Record<string, unknown>).tool).toBeTypeOf('object')
})

it('the permission modal names what is being asked for', () => {
const shown = replay(recording('permission-once')).permissions.filter(state => state.visible)
expect(shown.length).toBeGreaterThan(0)
expect(shown[0]!.title).toBe('bash: ls -1')
expect(shown[0]!.requestID).toMatch(/^per_/)
})

it('the question modal shows the question the agent asked', () => {
const shown = replay(recording('question-reject')).questions.filter(state => state.visible)
expect(shown.length).toBeGreaterThan(0)
expect(shown[0]!.text).toBe('Do you prefer the color red or blue?')
expect(shown[0]!.questionID).toMatch(/^que_/)
})

it('a compound bash command shows the WHOLE command, not just the patterns OpenCode derived from it', () => {
// Review of #14: `patterns` holds only the command nodes OpenCode
// collects. It skips cd/pushd and declarations like `export`, so a
// subject built from it can hide part of what will actually run.
// OpenCode's own permission UI shows `$ ${metadata.command}`.
// DERIVED from the recorded event: only metadata.command and patterns
// change, to what 1.18.31's ShellTool.collect produces for
// `cd packages/app && npm test`. Everything else is the recording.
const asked = recording('permission-once').sse.find(({ event }) => event.type === 'permission.asked')!.event
const props = asked.properties as Record<string, unknown>
const compound = { ...asked, properties: { ...props, patterns: ['npm test'], metadata: { command: 'cd packages/app && npm test' } } }
expect(permissionRequestFromEvent(compound)!.title).toBe('bash: cd packages/app && npm test')
})

it('does not present a wildcard-only pattern as the subject (MCP and todowrite asks send ["*"])', () => {
const asked = recording('permission-once').sse.find(({ event }) => event.type === 'permission.asked')!.event
const props = asked.properties as Record<string, unknown>
// DERIVED: the recorded event reshaped the way 1.18.31 asks for an MCP
// tool, `{ permission: <tool key>, patterns: ['*'], metadata: {} }`.
const mcp = { ...asked, properties: { ...props, permission: 'github_create_issue', patterns: ['*'], metadata: {} } }
expect(permissionRequestFromEvent(mcp)!.title).toBe('github_create_issue')
})

it('the pending-permission request (the path that answers the prompt) carries the same subject', () => {
// OpencodeHeadless builds this request for every permission.asked and
// keeps it for the reply. It used a second, separately drifted parser;
// both paths now share one.
const asked = recording('permission-once').sse.find(({ event }) => event.type === 'permission.asked')!.event
const request = permissionRequestFromEvent(asked)
expect(request).toMatchObject({ requestID: expect.stringMatching(/^per_/), title: 'bash: ls -1' })
expect(request!.sessionID).toBe(recording('permission-once').sessionID)
})
})
113 changes: 113 additions & 0 deletions src/permissions/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// What a permission or question event is ABOUT. This is the one parser that
// both consumers share: the dispatcher (the modal the user sees) and
// OpencodeHeadless (the pending request that answers the prompt).
//
// WHY one parser (agent-code#878): each path read the subject its own way,
// and neither matched the payload OpenCode actually sends. That shape is
// present since at least 1.14 (vendored source) and recorded on 1.18.30:
// - `permission.asked`:
// `{ id, sessionID, permission: "bash", patterns: ["ls -1"], metadata: { command }, always, tool: { messageID, callID } }`
// - `question.asked`:
// `{ id, sessionID, questions: [{ question, header, options }], tool }`
// The dispatcher walked `['title','tool','action',…]` first-non-null. `tool`
// is an OBJECT, so the walk stopped there and rejected it. The pending-request
// builder only accepted strings, but it had no key that exists in this shape.
// Both produced an undefined subject, and users approved `bash: ls -1` blind.
//
// Source of truth for the new shape: the recorded 1.18.30 bus in
// testing/fixtures/live-1.18.30 (see its README). It is the same shape
// opencode-terminal-headless's LiveStateProjector already parses. The older
// keys stay as fallbacks, each read ONLY when it is a string, so a server
// that still sends a string `title` keeps working and an object can never
// shadow a later key again.

import type { OpenCodePermissionRequest } from './PermissionService.js'

type Payload = Record<string, unknown>

function asRecord(value: unknown): Payload | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Payload : undefined
}

function stringAt(payload: Payload, path: string): string | undefined {
let cursor: unknown = payload
for (const key of path.split('.')) {
const record = asRecord(cursor)
if (!record) return undefined
cursor = record[key]
}
return typeof cursor === 'string' && cursor ? cursor : undefined
}

/** The first key whose value is a non-empty STRING. This deliberately differs
* from a first-non-null lookup: an object at an earlier key (the 1.18.30
* `tool`) must not hide a string at a later one. */
function firstString(payload: Payload, paths: string[]): string | undefined {
for (const path of paths) {
const value = stringAt(payload, path)
if (value) return value
}
return undefined
}

/** Bus events arrive as `{ type, properties }`. Some call sites hand over the
* properties object directly. */
export function eventPayload(event: unknown): Payload {
const record = asRecord(event) ?? {}
return asRecord(record.properties) ?? record
}

/** What the permission is for, e.g. `bash: ls -1` or `edit: src/a.ts`.
* Falls back to the legacy string title for older servers, or undefined if
* neither exists. */
export function permissionSubject(payload: unknown): string | undefined {
const record = asRecord(payload) ?? {}
const permission = typeof record.permission === 'string' ? record.permission : undefined
if (permission) {
// For shell commands, show the WHOLE command, as OpenCode's own
// permission UI does (`$ ${metadata.command}`). `patterns` holds only the
// command nodes OpenCode collects: it skips cd/pushd and declarations
// like `export`. A subject built from it turned
// `export NODE_OPTIONS=--require=/tmp/x.js && git status` into
// `bash: git status`, which is the very blind approval this parser exists
// to prevent (#14 review).
const command = stringAt(record, 'metadata.command')
if (permission === 'bash' && command) return `bash: ${command}`
const patterns = Array.isArray(record.patterns)
? record.patterns.filter((pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0)
: []
// `["*"]` is how MCP, todowrite and lsp asks say "this tool, no narrower
// scope". Rendered as `github_create_issue: *`, it read like a request
// for everything, so the tool name alone is the honest subject.
const meaningful = patterns.length === 1 && patterns[0] === '*' ? [] : patterns
return meaningful.length > 0 ? `${permission}: ${meaningful.join(', ')}` : permission
}
return firstString(record, ['title', 'action', 'permission.action', 'tool'])
}

/** The question text. On 1.18.30 it lives in `questions[].question`; when
* several questions come in one prompt, each gets its own line. Older servers
* put a flat `text`, `question` or `prompt` string on the payload. */
export function questionText(payload: unknown): string | undefined {
const record = asRecord(payload) ?? {}
if (Array.isArray(record.questions)) {
const texts = record.questions
.map(entry => asRecord(entry)?.question)
.filter((text): text is string => typeof text === 'string' && text.length > 0)
if (texts.length > 0) return texts.join('\n')
}
return firstString(record, ['text', 'question', 'prompt'])
}

/** The request OpencodeHeadless keeps until the permission is answered. */
export function permissionRequestFromEvent(event: unknown): OpenCodePermissionRequest | null {
const payload = eventPayload(event)
const requestID = firstString(payload, ['requestID', 'permissionID', 'id'])
if (!requestID) return null
return {
requestID,
sessionID: firstString(payload, ['sessionID', 'sessionId']),
title: permissionSubject(payload),
metadata: payload,
}
}
25 changes: 25 additions & 0 deletions testing/fixtures/live-1.18.30/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# live-1.18.30 fixtures

These are real OpenCode 1.18.30 SSE bus recordings, copied unchanged from
`opencode-terminal-headless/testing/fixtures/live/` (commit `a83130f`, "stamp
the fixtures, floor the coverage, watch for drift"). There they were recorded
by that package's Stage 0 live probe, `scripts/probe-live.mts`, against the
real OpenCode 1.18.30 TUI server.

Recording conditions:
- a throwaway HOME/XDG and project;
- synthetic prompts;
- the free `opencode/big-pickle` model;
- sandbox paths and ports normalised.

The `meta` block inside each file records this, plus a schema hash.

| File | Scenario | What it pins here |
|---|---|---|
| `permission-once.json` | an agent runs `ls -1` and the user allows it once | the 1.18.30 `permission.asked` shape: `permission: "bash"`, `patterns: ["ls -1"]`, and `tool` as an object |
| `question-reject.json` | the agent asks "red or blue?" and the user rejects | the 1.18.30 `question.asked` shape: `questions[].question` |

`src/permissions/subject.test.ts` replays each stream, in its recorded order,
through the real `EventDispatcher` and channels (agent-code#878). Do not edit
these files by hand. If OpenCode changes the bus again, record new ones with
the probe and keep these as the 1.18.30 baseline.
Loading
Loading