From 53197500c1bad81f90517126c2f23e3c4e8a30ec Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:03:57 +0800 Subject: [PATCH] fix(workspaces): align web sessions with native runtime protocols --- docs/web-conversation-surface.md | 22 ++++++++++ plans/web-conversation-surface.md | 25 +++++++++++ .../adapters/opencode-runtime-flags.spec.ts | 9 +++- src/workspaces/adapters/opencode.ts | 16 +++++-- src/workspaces/web-session-host.spec.ts | 30 +++++++++++-- .../web-session/claude-history.spec.ts | 29 ++++++++++++ src/workspaces/web-session/claude-history.ts | 44 +++++++++++++++++++ .../claude-stream-json-transport.ts | 26 +++++++++++ .../web-session/pi-rpc-transport.ts | 22 +++++++++- 9 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 src/workspaces/web-session/claude-history.spec.ts create mode 100644 src/workspaces/web-session/claude-history.ts diff --git a/docs/web-conversation-surface.md b/docs/web-conversation-surface.md index 2405bdfdc..3dcfdcd54 100644 --- a/docs/web-conversation-surface.md +++ b/docs/web-conversation-surface.md @@ -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. diff --git a/plans/web-conversation-surface.md b/plans/web-conversation-surface.md index d929f3b99..5bea314e1 100644 --- a/plans/web-conversation-surface.md +++ b/plans/web-conversation-surface.md @@ -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). diff --git a/src/workspaces/adapters/opencode-runtime-flags.spec.ts b/src/workspaces/adapters/opencode-runtime-flags.spec.ts index 099cf6af2..7d095de07 100644 --- a/src/workspaces/adapters/opencode-runtime-flags.spec.ts +++ b/src/workspaces/adapters/opencode-runtime-flags.spec.ts @@ -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', @@ -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']); +}); diff --git a/src/workspaces/adapters/opencode.ts b/src/workspaces/adapters/opencode.ts index 4d37ca050..93343fe4b 100644 --- a/src/workspaces/adapters/opencode.ts +++ b/src/workspaces/adapters/opencode.ts @@ -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: [] }; }, }, @@ -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 [ diff --git a/src/workspaces/web-session-host.spec.ts b/src/workspaces/web-session-host.spec.ts index 3d7849f5a..d08b76ae9 100644 --- a/src/workspaces/web-session-host.spec.ts +++ b/src/workspaces/web-session-host.spec.ts @@ -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) => { @@ -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 }) }) @@ -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) diff --git a/src/workspaces/web-session/claude-history.spec.ts b/src/workspaces/web-session/claude-history.spec.ts new file mode 100644 index 000000000..6dc5a20de --- /dev/null +++ b/src/workspaces/web-session/claude-history.spec.ts @@ -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') +}) diff --git a/src/workspaces/web-session/claude-history.ts b/src/workspaces/web-session/claude-history.ts new file mode 100644 index 000000000..605bced61 --- /dev/null +++ b/src/workspaces/web-session/claude-history.ts @@ -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>): Promise { + 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() + 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() + 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() +} diff --git a/src/workspaces/web-session/claude-stream-json-transport.ts b/src/workspaces/web-session/claude-stream-json-transport.ts index b3370c666..11c120f8f 100644 --- a/src/workspaces/web-session/claude-stream-json-transport.ts +++ b/src/workspaces/web-session/claude-stream-json-transport.ts @@ -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, @@ -42,6 +43,22 @@ export class ClaudeStreamJsonTransport implements WebSessionTransport { async start(): Promise { // 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') } @@ -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] @@ -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 = [] } diff --git a/src/workspaces/web-session/pi-rpc-transport.ts b/src/workspaces/web-session/pi-rpc-transport.ts index 40aa922f3..bc716fcda 100644 --- a/src/workspaces/web-session/pi-rpc-transport.ts +++ b/src/workspaces/web-session/pi-rpc-transport.ts @@ -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 @@ -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