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
22 changes: 22 additions & 0 deletions docs/web-conversation-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,25 @@ than replacing it with transport-disposal errors. Failed opens are rolled back
by their route, so exit callbacks must not race that registry write. During a
live ACP turn, user-message echoes are ignored because prompt() already
appended the message; history replay still consumes user-message chunks.

### Native protocol differences

- OMP emits `agent_end` without Pi's later `agent_settled`; both must settle the
turn and clear the streaming copy. A retrying agent_end must remain busy.
- Pi/OMP can accept a prompt and later emit an assistant with `stopReason:
error` and `errorMessage`. Surface that error, including on history load;
an empty assistant body is not a successful reply.
- Claude stream-json does not replay old messages on --resume. The transport
loads the exact native session file under CLAUDE_CONFIG_DIR (or the native
home default), follows the latest main parent chain, and excludes abandoned
branches and sidechains. It does not create another transcript store.
- OpenCode ACP does not accept --model. Session projection sets its model in
process-local OPENCODE_CONFIG_CONTENT and leaves Web argv free of that flag.
Native-session config selectors can confirm the effective model. ACP also
exposes [session configuration options](https://agentclientprotocol.com/announcements/session-config-options-stabilized).

A default handshake is insufficient acceptance for an adapter: also exercise
an explicit model, a completed turn, errors, and exact-session restoration with
the installed CLI. Keep login/provider/version failures distinct from parser
or lifecycle defects, and never claim every runtime passed from one shared
wire's fake-process fixture.
25 changes: 25 additions & 0 deletions plans/web-conversation-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,28 @@ inherited from the shared primitives.

Delete this file and its [[PLANS.md]] bullet when the live acceptance is
recorded and the PR is accepted.

## Maintainer runtime audit (2026-09-06)

After #1392, exercised all seven installed Web-capable runtimes using their actual Adapter argv
in disposable working directories. Text probes explicitly prohibited tools and
file changes; native credentials remained owned by each CLI.

| Runtime | Installed version | Evidence |
|---|---|---|
| Claude | 2.1.229 | Native startup and login error; real CLI + isolated local Anthropic stub completed a turn and replayed the same native history after the fix. No credentialed Claude claim. |
| Codex | 0.147.0 | Handshake, actual text turn and exact-thread history replay with `gpt-5.6-sol` from its model/list. Global `gpt-6-astra` selection is too new for this installed CLI; global settings were not changed. |
| Cursor | 2026.09.02-c22c1a3 | Actual text turn and same-session history replay. |
| Grok | 1.0.13 | Actual text turn and same-session history replay (also verified in UI Setup in #1392). |
| OpenCode | 1.17.13 | Actual text turn and same-session replay. ACP rejected argv containing --model; process-local model config was verified through returned configOptions.currentValue. |
| OMP | 17.3.4 | Actual text turn and replay; fixed agent_end settlement and stale streaming copy. UI Setup also restored a TUI-created Session in Web and completed a second turn with no duplicate or stuck spinner. |
| Pi | 0.80.6 | Handshake and recorded session reopening; selected provider returned authentication failure. Fixed the swallowed asynchronous error. No successful credentialed Pi turn claimed. |

Corrections in this increment: native OpenCode model configuration for ACP,
OMP agent_end handling, Pi/OMP asynchronous model-error projection, and Claude
native-history replay through its active parent chain. Unit fixtures cover
these observed runtime shapes. Full tool-approval, cancellation and Web/TUI
acceptance remains open as listed above; these narrower probes do not close it.

Verification: full hermetic suite passed (739 files, 6657 tests, 3 skipped),
root typecheck passed, and the final targeted regression run passed (30 tests).
9 changes: 8 additions & 1 deletion src/workspaces/adapters/opencode-runtime-flags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe('opencode runtime flags', () => {
);

expect(projected.interactiveArgs).toEqual(['--model', 'openai/gpt-5.6-sol']);
expect(projected.webArgs).toEqual(['--model', 'openai/gpt-5.6-sol']);
expect(projected.webArgs).toEqual([]);
expect(JSON.parse(projected.env['OPENCODE_CONFIG_CONTENT']!)).toEqual({ model: 'openai/gpt-5.6-sol' });
expect(projected.headlessArgs).toEqual([
'--model', 'openai/gpt-5.6-sol',
'--variant', 'high',
Expand All @@ -44,3 +45,9 @@ describe('opencode runtime flags', () => {
}, 'hello')).toContain('--variant');
});
});

it('preserves process config while selecting the ACP model without unsupported flags', () => {
const projected = opencodeAdapter.sessionRuntime!.project({ cwd: '/workspace', env: { OPENCODE_CONFIG_CONTENT: '{"theme":"system"}' } }, runtime);
expect(JSON.parse(projected.env['OPENCODE_CONFIG_CONTENT']!)).toEqual({ theme: 'system', model: 'openai/gpt-5.6-sol' });
expect(opencodeAdapter.composeWebCommand!([], { cwd: '/workspace', env: projected.env, sessionRuntime: projected })).toEqual(['opencode', 'acp']);
});
16 changes: 13 additions & 3 deletions src/workspaces/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,17 @@ export const opencodeAdapter: CliAdapter = {
...(selectedModel ? { model: selectedModel } : {}),
});
}
return { env, interactiveArgs, headlessArgs, webArgs: interactiveArgs };
// ACP has no --model flag. Its native config selects the default model
// for session/new; keep this process-local and preserve other settings.
if (selectedModel && !env['OPENCODE_CONFIG_CONTENT']) {
const raw = _ctx.env['OPENCODE_CONFIG_CONTENT'];
const inherited: unknown = raw ? JSON.parse(raw) : {};
if (!inherited || typeof inherited !== 'object' || Array.isArray(inherited)) {
throw new Error('OPENCODE_CONFIG_CONTENT must contain a JSON object');
}
env['OPENCODE_CONFIG_CONTENT'] = JSON.stringify({ ...inherited, model: selectedModel });
}
return { env, interactiveArgs, headlessArgs, webArgs: [] };
},
},

Expand All @@ -369,8 +379,8 @@ export const opencodeAdapter: CliAdapter = {
return [...head, '--session', ctx.resume.sessionId];
},

// Web surface: `opencode [--model …] acp`. Model selection stays on the
// top-level flag/env projection; session identity is negotiated over ACP.
// Web surface: `opencode acp`. ACP rejects --model; sessionRuntime
// projects model selection into OPENCODE_CONFIG_CONTENT instead.
composeWebCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] {
if (ctx.resume === 'last') throw new Error('the Web surface requires a concrete opencode session id or a fresh Session');
return [
Expand Down
30 changes: 27 additions & 3 deletions src/workspaces/web-session-host.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function texts(snapshot: WebSessionSnapshot | null): string[] {

// ── pi-rpc ─────────────────────────────────────────────────────────────────

function piRpcProcess(state: Json = {}, options: { rejectPrompt?: string } = {}): FakeProcess {
function piRpcProcess(state: Json = {}, options: { rejectPrompt?: string; omitSettled?: boolean; modelError?: string } = {}): FakeProcess {
let messages: unknown[] = []
const rpcState = { sessionId: 'native-pi', isStreaming: false, isCompacting: false, ...state }
return new FakeProcess((command, self) => {
Expand All @@ -100,13 +100,16 @@ function piRpcProcess(state: Json = {}, options: { rejectPrompt?: string } = {})
}
if (type === 'prompt') {
const user = { role: 'user', content: command['message'] }
const assistant = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }
const assistant = options.modelError
? { role: 'assistant', content: [], stopReason: 'error', errorMessage: options.modelError }
: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }
messages = [...messages, user, assistant]
self.line({ type: 'response', id, command: type, success: true })
self.line({ type: 'agent_start' })
self.line({ type: 'message_update', message: { role: 'assistant', content: [{ type: 'text', text: 'hel' }] } })
self.line({ type: 'message_end', message: assistant })
self.line({ type: 'agent_settled' })
self.line({ type: 'agent_end', messages, willRetry: false })
if (!options.omitSettled) self.line({ type: 'agent_settled' })
}
if (type === 'abort') self.line({ type: 'response', id, command: type, success: true })
})
Expand All @@ -131,6 +134,27 @@ describe('WebSessionHost with the pi-rpc transport', () => {
expect(snapshot?.streamingMessage).toBeNull()
})

it('settles OMP without a Pi-specific agent_settled event', async () => {
const host = new WebSessionHost(logger, {}, () => piRpcProcess({}, { omitSettled: true }) as never)
await host.start(input({ agent: 'omp' }))
await host.prompt('record-1', 'hi')
await settle(80)
expect(host.get('record-1')?.phase).toBe('idle')
expect(host.get('record-1')?.streamingMessage).toBeNull()
expect(texts(host.get('record-1'))).toEqual(['user:hi', 'assistant:hello'])
await host.stopAll()
})

it('surfaces asynchronous model failures instead of an empty successful reply', async () => {
const host = new WebSessionHost(logger, {}, () => piRpcProcess({}, { modelError: '401: invalid API key' }) as never)
await host.start(input({}))
await host.prompt('record-1', 'hi')
await settle(80)
expect(host.get('record-1')?.phase).toBe('idle')
expect(host.get('record-1')?.error).toBe('401: invalid API key')
await host.stopAll()
})

it('binds the runtime-minted session id for a fresh omp session', async () => {
const onNativeSessionId = vi.fn()
const host = new WebSessionHost(logger, { onNativeSessionId }, () => piRpcProcess({ sessionId: 'omp-777' }) as never)
Expand Down
29 changes: 29 additions & 0 deletions src/workspaces/web-session/claude-history.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { readClaudeHistory, selectClaudeHistory } from './claude-history.js'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) })
const entry = (uuid: string, parentUuid: string | null, type: string, extra = {}) => ({ uuid, parentUuid, type, message: { role: type, content: uuid }, ...extra })
it('replays the latest main chain across attachments without abandoned branches or sidechains', () => {
const values = [entry('u', null, 'user'), entry('old', 'u', 'assistant'), entry('attachment', 'u', 'attachment'), entry('new', 'attachment', 'assistant'), entry('side', 'new', 'assistant', { isSidechain: true })]
expect(selectClaudeHistory(values.map((v) => JSON.stringify(v)).join('\n') + '\n{"partial":')).toEqual([values[0], values[3]])
})
it('bounds malformed parent cycles', () => {
expect(selectClaudeHistory([entry('a', 'b', 'user'), entry('b', 'a', 'assistant')].map((v) => JSON.stringify(v)).join('\n'))).toHaveLength(2)
})
it('reads only the requested session under the configured native directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'claude-history-')); roots.push(root)
const cwd = join(root, 'workspace'); await mkdir(cwd)
const config = join(root, 'config')
const { realpath } = await import('node:fs/promises')
const key = (await realpath(cwd)).replaceAll('/', '-').replaceAll('.', '-')
const dir = join(config, 'projects', key); await mkdir(dir, { recursive: true })
const id = '11111111-1111-4111-8111-111111111111'
const record = entry('a', null, 'user')
await writeFile(join(dir, `${id}.jsonl`), JSON.stringify(record))
expect(await readClaudeHistory(cwd, id, { CLAUDE_CONFIG_DIR: config })).toEqual([record])
await expect(readClaudeHistory(cwd, '../other', { CLAUDE_CONFIG_DIR: config })).rejects.toThrow('valid native session id')
await expect(readClaudeHistory(cwd, '22222222-2222-4222-8222-222222222222', { CLAUDE_CONFIG_DIR: config })).rejects.toThrow('not found')
})
44 changes: 44 additions & 0 deletions src/workspaces/web-session/claude-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFile, realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { isJsonObject, type JsonObject } from './model.js'

/** Read the active parent chain from Claude's own session file, never a cache. */
export async function readClaudeHistory(cwd: string, sessionId: string, env: Readonly<Record<string, string>>): Promise<JsonObject[]> {
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(sessionId)) {
throw new Error('Claude requires a valid native session id')
}
const config = env['CLAUDE_CONFIG_DIR'] || join(env['HOME'] || homedir(), '.claude')
const canonical = await realpath(cwd)
for (const directory of new Set([canonical, resolve(cwd)])) {
const key = directory.replaceAll('/', '-').replaceAll('.', '-')
try {
return selectClaudeHistory(await readFile(join(config, 'projects', key, `${sessionId}.jsonl`), 'utf8'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
throw new Error('Claude session history was not found for this workspace. Open the original Session in the terminal to check its location.')
}

export function selectClaudeHistory(raw: string): JsonObject[] {
const records = new Map<string, JsonObject>()
let leaf: string | undefined
for (const line of raw.split('\n')) {
let value: unknown
try { value = JSON.parse(line) } catch { continue } // A writer may leave a partial final line.
if (!isJsonObject(value) || value['isSidechain'] === true || typeof value['uuid'] !== 'string') continue
records.set(value['uuid'], value)
if (value['type'] === 'user' || value['type'] === 'assistant') leaf = value['uuid']
}
const chain: JsonObject[] = []
const visited = new Set<string>()
while (leaf && !visited.has(leaf)) {
visited.add(leaf)
const entry = records.get(leaf)
if (!entry) break
if ((entry['type'] === 'user' || entry['type'] === 'assistant') && isJsonObject(entry['message'])) chain.push(entry)
leaf = typeof entry['parentUuid'] === 'string' ? entry['parentUuid'] : undefined
}
return chain.reverse()
}
26 changes: 26 additions & 0 deletions src/workspaces/web-session/claude-stream-json-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* `control_request` frames (`can_use_tool`) and are answered with
* `control_response`; `interrupt` is a client-initiated control request.
*/
import { readClaudeHistory } from './claude-history.js'
import {
isJsonObject,
stringOrNull,
Expand Down Expand Up @@ -42,6 +43,22 @@ export class ClaudeStreamJsonTransport implements WebSessionTransport {
async start(): Promise<void> {
// Claude prints `system/init` only once the first user message arrives,
// so there is nothing to await here beyond a live process.
if (this.ctx.input.nativeSessionId) {
const history = await readClaudeHistory(this.ctx.input.cwd, this.ctx.input.nativeSessionId, this.ctx.input.env)
for (const entry of history) {
const message = entry['message']
if (!isJsonObject(message)) continue
if (entry['type'] === 'assistant') this.handleAssistant(message)
else if (Array.isArray(message['content']) && message['content'].some((part) => isJsonObject(part) && part['type'] === 'tool_result')) this.handleUser(message)
else {
this.commitBlocks()
this.builder.endTurn()
this.builder.user(partsFromUnknownContent(message['content'] ?? ''))
}
}
this.commitBlocks()
this.builder.endTurn()
}
this.ctx.state.setPhase('idle')
}

Expand Down Expand Up @@ -133,6 +150,14 @@ export class ClaudeStreamJsonTransport implements WebSessionTransport {

private handleStreamEvent(raw: unknown): void {
if (!isJsonObject(raw)) return
if (raw['type'] === 'message_start' && isJsonObject(raw['message'])) {
const id = stringOrNull(raw['message']['id'])
if (id && id !== this.assistantMessageId) {
this.commitBlocks()
this.assistantMessageId = id
}
return
}
if (raw['type'] !== 'content_block_delta' || !isJsonObject(raw['delta'])) return
const delta = raw['delta']
const last = this.partial[this.partial.length - 1]
Expand All @@ -152,6 +177,7 @@ export class ClaudeStreamJsonTransport implements WebSessionTransport {
if (!isJsonObject(message) || !Array.isArray(message['content'])) return
const id = stringOrNull(message['id'])
if (id !== this.assistantMessageId) {
if (this.assistantMessageId !== null) this.commitBlocks()
this.assistantMessageId = id
this.blocks = []
}
Expand Down
22 changes: 21 additions & 1 deletion src/workspaces/web-session/pi-rpc-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export class PiRpcTransport implements WebSessionTransport {
const data = messageResponse['data']
if (isJsonObject(data) && Array.isArray(data['messages'])) {
this.ctx.state.replaceMessages(data['messages'].map(convertPiMessage))
const last = data['messages'].at(-1)
if (isJsonObject(last) && last['role'] === 'assistant' && last['stopReason'] === 'error') {
this.ctx.state.error = stringOrNull(last['errorMessage']) ?? 'The model request failed'
}
}
this.ctx.state.bump()
return nextState
Expand Down Expand Up @@ -135,11 +139,27 @@ export class PiRpcTransport implements WebSessionTransport {
case 'message_update':
state.streamingMessage = isJsonObject(event['message']) ? convertPiMessage(event['message']) : null
break
case 'message_end':
case 'message_end': {
const message = isJsonObject(event['message']) ? event['message'] : null
if (message?.['role'] === 'assistant') {
state.streamingMessage = null
state.error = message['stopReason'] === 'error'
? stringOrNull(message['errorMessage']) ?? 'The model request failed'
: null
}
this.scheduleRefresh(30)
break
}
case 'tool_execution_end':
case 'queue_update':
this.scheduleRefresh(30)
break
case 'agent_end':
// OMP ends here; Pi may follow with agent_settled or retry events.
state.phase = event['willRetry'] === true ? 'retrying' : 'idle'
state.streamingMessage = null
this.scheduleRefresh(0)
break
case 'agent_settled':
state.phase = 'idle'
state.streamingMessage = null
Expand Down