From 829d9448f17ce0dbc40d27016e2296c66beaf3ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:27:10 -0700 Subject: [PATCH] test(wsl): exercise native chat on real WSL2 hosts (#28973) * test(wsl): exercise structured chat on real guest hosts * test(wsl): keep chat contract within the module size limit * test(wsl): use braced conditions in chat lane * test(wsl): configure the fixture through host launch settings * test(wsl): keep cancellation assertion at the RPC boundary * test(wsl): avoid assuming cancelled turn publication timing * ci(wsl): run guest chat coverage for Claude environment changes * test(ci): match the WSL lane's merged unit-test exclusion * ci(wsl): drop lane trigger paths that no longer exist * ci(wsl): run the WSL lane when the shared process-host package changes --- .github/workflows/wsl-orcad-e2e.yml | 63 +++- .../scripts/orcad-e2e-change-scope.test.mjs | 6 +- .../claude-agent-sdk-scripted-cli.mjs | 16 +- .../wsl/wsl-orcad-chat-provider-fixture.ts | 78 +++++ src/main/wsl/wsl-orcad-chat-scenario.ts | 41 +++ .../wsl/wsl-orcad-real-distro-lane.test.ts | 21 +- .../wsl/wsl-orcad-structured-chat-contract.ts | 292 ++++++++++++++++++ 7 files changed, 499 insertions(+), 18 deletions(-) create mode 100644 src/main/wsl/wsl-orcad-chat-provider-fixture.ts create mode 100644 src/main/wsl/wsl-orcad-chat-scenario.ts create mode 100644 src/main/wsl/wsl-orcad-structured-chat-contract.ts diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml index e33250d06e4..5d06bc26025 100644 --- a/.github/workflows/wsl-orcad-e2e.yml +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -3,24 +3,38 @@ name: WSL orcad E2E # WSL via orcad (step C10) on real WSL2 distros: a GitHub-hosted windows-2025 runner imports a # pinned Ubuntu 24.04 rootfs twice (systemd off and on) and runs # src/main/wsl/wsl-orcad-real-distro-lane.test.ts against both: deploy and launch, the wsl.exe -# stdio bridge, runtime RPC, liveness recovery, holder survival and teardown. Not a required check. +# stdio bridge, runtime RPC, liveness recovery, holder survival, structured chat and teardown. Not a required check. # Hosted WSL2 was proven in #27783: WSL 2.7.14, import 13-18 s, cold boot 8-14 s. on: pull_request: types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] paths: - 'src/main/wsl/**' - - '!src/main/wsl/**/*.test.ts' - - 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts' - # This lane executes only the real distro test above. + - 'src/main/runtime/**' + - 'src/main/native-chat/**' + - 'src/main/claude/**' + - 'src/main/claude-accounts/**' + - 'src/main/codex/**' + - 'src/main/acp/**' + - 'src/main/pi/**' + - 'src/main/orcad/**' + - 'src/main/server/**' + - 'src/main/persistence/**' + - 'mobile/src/**' + - 'src/main/ipc/*wsl*' + - 'src/shared/**' + - 'src/renderer/src/components/native-chat/**' + - 'src/renderer/src/lib/*agent-session*' + - 'config/scripts/*orcad*' + - 'package.json' + - 'pnpm-lock.yaml' - 'src/main/ssh/orcad-*' - 'src/main/managed-server/**' - - '!src/main/ssh/orcad-*.test.ts' - - '!src/main/managed-server/**/*.test.ts' - - 'src/main/managed-server/install/remote-install-transport.ts' - # orcad-entry runs this on every boot of the orcad the lane launches in the distro. - - 'src/main/orcad/orcad-wsl-*' - - '!src/main/orcad/orcad-wsl-*.test.ts' + - 'src/packages/process-host/**' + # Unit tests never run in this lane; only the real distro test below does. + - '!**/*.test.ts' + - '!**/*.test.tsx' + - 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts' - '.github/workflows/wsl-orcad-e2e.yml' workflow_dispatch: @@ -175,6 +189,7 @@ jobs: # A non-root default user, as on a real install; git for the lane's repo. $setup = "set -e; id orca >/dev/null 2>&1 || useradd -m -s /bin/bash orca; " + "printf '[boot]\nsystemd=$($d.Systemd)\n[user]\ndefault=orca\n' > /etc/wsl.conf; " + + "printf 'export ORCA_BACKGROUND_LAUNCH=1\nexport USERPROFILE=`$HOME\nexport APPDATA=`$HOME/.config\nexport LOCALAPPDATA=`$HOME/.local/share\nexport XDG_CONFIG_HOME=`$HOME/.config\nexport XDG_DATA_HOME=`$HOME/.local/share\nexport XDG_CACHE_HOME=`$HOME/.cache\nexport XDG_STATE_HOME=`$HOME/.local/state\nexport CLAUDE_CONFIG_DIR=`$HOME/.claude\nexport CODEX_HOME=`$HOME/.codex\n' >> /home/orca/.profile; " + "command -v git >/dev/null || (apt-get update -qq && apt-get install -y -qq git)" wsl.exe -d $d.Name -u root --exec sh -c $setup if ($LASTEXITCODE -ne 0) { throw "provisioning $($d.Name) failed: $LASTEXITCODE" } @@ -201,6 +216,20 @@ jobs: with: path: ${{ runner.temp }}/wsl-rootfs key: wsl-orcad-rootfs-${{ env.ROOTFS_SHA256 }} + - name: Isolate the desktop test home + run: | + New-Item -ItemType Directory -Force $env:ORCA_WSL_ORCAD_LANE_RECEIPTS | Out-Null + $files = @('.claude/settings.json', '.claude.json', '.codex/hooks.json', '.codex/config.toml') | ForEach-Object { Join-Path $env:USERPROFILE $_ } + $hashes = @($files | ForEach-Object { + @{ Path = $_; Hash = $(if (Test-Path $_) { (Get-FileHash $_ -Algorithm SHA256).Hash } else { 'absent' }) } + }) + $hashes | ConvertTo-Json | Set-Content (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'real-home-hashes.json') + $homeDir = Join-Path $env:RUNNER_TEMP 'wsl-orcad-desktop-home' + foreach ($name in @('HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME')) { + $path = Join-Path $homeDir $name + New-Item -ItemType Directory -Force $path | Out-Null + "$name=$path" >> $env:GITHUB_ENV + } - name: 'Phase 1: deploy, RPC, kill orcad, terminate distro' timeout-minutes: 20 env: @@ -212,8 +241,8 @@ jobs: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'launch.log') if ($global:LASTEXITCODE -ne 0) { throw "launch phase failed: $global:LASTEXITCODE" } # A new process: the holders must have kept both distros Running since phase 1 exited. - - name: 'Phase 2: holders survive, reconnect, teardown' - timeout-minutes: 10 + - name: 'Phase 2: holders survive, chat, reconnect, teardown' + timeout-minutes: 15 env: ORCA_RUN_WSL_ORCAD_LANE: '1' ORCA_WSL_ORCAD_LANE_PHASE: survive @@ -221,6 +250,16 @@ jobs: run: | node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'survive.log') if ($global:LASTEXITCODE -ne 0) { throw "survive phase failed: $global:LASTEXITCODE" } + - name: Real desktop agent configuration is unchanged + if: always() + run: | + $baseline = Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'real-home-hashes.json' + if (Test-Path $baseline) { + foreach ($file in (Get-Content $baseline -Raw | ConvertFrom-Json)) { + $hash = if (Test-Path $file.Path) { (Get-FileHash $file.Path -Algorithm SHA256).Hash } else { 'absent' } + if ($hash -ne $file.Hash) { throw "Real agent configuration changed: $($file.Path)" } + } + } # Belt and braces for the runner: a missing VM-wide interop entry is restored, then fails. - name: WSLInterop is intact if: ${{ !cancelled() && steps.import.outcome == 'success' }} diff --git a/config/scripts/orcad-e2e-change-scope.test.mjs b/config/scripts/orcad-e2e-change-scope.test.mjs index 3bc4963b7b9..76e0ede6a06 100644 --- a/config/scripts/orcad-e2e-change-scope.test.mjs +++ b/config/scripts/orcad-e2e-change-scope.test.mjs @@ -64,7 +64,8 @@ const cases = { 'src/main/wsl/wsl-orcad-deploy.test.ts', 'src/main/wsl/nested/wsl-orcad-deploy.test.ts', 'src/main/ssh/orcad-activation-outcome-log.test.ts', - 'src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts' + 'src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts', + 'src/packages/process-host/src/run-process.test.ts' ], retained: [ 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts', @@ -76,6 +77,7 @@ const cases = { 'src/main/ssh/orcad-launch-test-harness.ts', 'src/main/ssh/orcad-launch.test-fixture.ts', 'src/main/managed-server/install/remote-install-transport.ts', + 'src/packages/process-host/src/run-process.ts', '.github/workflows/wsl-orcad-e2e.yml' ], product: 'src/main/wsl/wsl-orcad-deploy.ts' @@ -148,7 +150,7 @@ it.each([ it('re-includes the real WSL distro root after excluding unit tests', () => { const paths = workflows.wsl.on.pull_request.paths - const exclude = paths.indexOf('!src/main/wsl/**/*.test.ts') + const exclude = paths.indexOf('!**/*.test.ts') expect(exclude).toBeGreaterThan(paths.indexOf('src/main/wsl/**')) expect(paths.indexOf('src/main/wsl/wsl-orcad-real-distro-lane.test.ts')).toBeGreaterThan(exclude) const steps = workflows.wsl.jobs.wsl2.steps diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs index 310dbb822c7..49d328fa6c5 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -8,7 +8,7 @@ // { steps: Step[], controlResponses?: { [subtype]: }, // controlResponseLeadingFrames?: { [subtype]: [] } } where a Step is // { emit: } | { awaitUserMessage: true } | { stderr: } | -// { awaitControlResponse: } | { awaitFile: { path, timeoutMs } } | +// { awaitControlRequest: } | { awaitControlResponse: } | { awaitFile: { path, timeoutMs } } | // { delayMs: } | { exit: } // ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written // ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close @@ -86,6 +86,12 @@ const waitFor = (kind, requestId) => { if (kind === 'user' && report.userMessages.length > 0) { return Promise.resolve() } + if ( + kind === 'control_request' && + report.controlRequests.some((frame) => frame.request?.subtype === requestId) + ) { + return Promise.resolve() + } if ( kind === 'control_response' && report.controlResponses.some((frame) => frame.response?.request_id === requestId) @@ -104,6 +110,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { } if (frame.type === 'control_request') { report.controlRequests.push(frame) + settle('control_request', frame.request?.subtype) writeReport() if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) { return @@ -139,7 +146,10 @@ createInterface({ input: process.stdin }).on('line', (line) => { // Never outlive a wedged test: the readline subscription would otherwise hold // this process open forever if the SDK side stops driving the scenario. -setTimeout(() => process.exit(3), 20_000).unref() +setTimeout( + () => process.exit(3), + Number(process.env.ORCA_SDK_CONTRACT_TIMEOUT_MS ?? 20_000) +).unref() for (const step of scenario.steps) { if (step.emit) { @@ -148,6 +158,8 @@ for (const step of scenario.steps) { process.stderr.write(step.stderr) } else if (step.awaitUserMessage) { await waitFor('user') + } else if (step.awaitControlRequest !== undefined) { + await waitFor('control_request', step.awaitControlRequest) } else if (step.awaitControlResponse !== undefined) { await waitFor('control_response', step.awaitControlResponse) } else if (step.awaitFile) { diff --git a/src/main/wsl/wsl-orcad-chat-provider-fixture.ts b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts new file mode 100644 index 00000000000..24f0f04f47f --- /dev/null +++ b/src/main/wsl/wsl-orcad-chat-provider-fixture.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { shellEscape } from '../ssh/ssh-connection-utils' + +export type WslChatHost = { + rpc(method: string, params?: unknown): Promise + guest(command: string): Promise + runtimePid: number +} + +export async function writeChatFixtureFile( + host: WslChatHost, + path: string, + content: string +): Promise { + await host.guest( + `printf %s ${shellEscape(Buffer.from(content).toString('base64'))} | base64 -d > ${shellEscape(path)}` + ) +} + +/** Install the existing SDK wire fixture with the guest's packaged runtime, never a Windows CLI. */ +export async function installWslChatFixture(lane: WslChatHost) { + const home = (await lane.guest('printf %s "$HOME"')).trim() + const root = `${home}/wsl chat fixture` + await lane.guest(`mkdir -p ${shellEscape(root)}`) + const runtime = (await lane.guest(`readlink -f /proc/${lane.runtimePid}/exe`)).trim() + const script = `${root}/scripted.mjs` + const cli = `${root}/claude` + await writeChatFixtureFile( + lane, + script, + readFileSync(resolve('src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs'), 'utf8') + ) + await writeChatFixtureFile( + lane, + cli, + `#!/bin/sh\nexec ${shellEscape(runtime)} ${shellEscape(script)} "$@"\n` + ) + await lane.guest(`chmod 700 ${shellEscape(cli)}`) + await lane.rpc('settings.mutateAgentLaunch', { + type: 'command', + agent: 'claude', + value: shellEscape(cli) + }) + await lane.rpc('settings.update', { + nativeChatPermissionMode: 'ask', + agentDefaultEnv: { + claude: { + ORCA_SDK_CONTRACT_SCENARIO_PATH: `${root}/scenario.json`, + ORCA_SDK_CONTRACT_REPORT_PATH: `${root}/report.json`, + ORCA_SDK_CONTRACT_TIMEOUT_MS: '120000' + } + } + }) + const group = await lane.rpc<{ group: { id: string } }>('projectGroup.create', { + name: 'WSL chat lane' + }) + const folderPath = `${root}/folder project` + await lane.guest(`mkdir -p ${shellEscape(folderPath)}`) + const folder = await lane.rpc<{ folderWorkspace: { id: string } }>('folderWorkspace.create', { + projectGroupId: group.group.id, + folderPath, + name: 'WSL folder chat' + }) + return { home, root, folderPath, folderWorktree: folderWorkspaceKey(folder.folderWorkspace.id) } +} + +export const WslChatProviderReport = z.object({ + execPath: z.string(), + cwd: z.string(), + argv: z.array(z.string()), + env: z.record(z.string(), z.string()), + userMessages: z.array(z.object({ message: z.object({ content: z.unknown() }) })), + controlRequests: z.array(z.object({ request: z.object({ subtype: z.string() }) })), + controlResponses: z.array(z.object({ response: z.object({ response: z.unknown().optional() }) })) +}) diff --git a/src/main/wsl/wsl-orcad-chat-scenario.ts b/src/main/wsl/wsl-orcad-chat-scenario.ts new file mode 100644 index 00000000000..52e30073965 --- /dev/null +++ b/src/main/wsl/wsl-orcad-chat-scenario.ts @@ -0,0 +1,41 @@ +import { packagedClaudeChatScenario } from '../orcad/orcad-structured-chat-scenario' + +/** The normal streamed reply stays open for an approval and the user's Stop. */ +export function wslClaudeChatScenario(providerSessionId: string, partialReplyAckPath: string) { + const scenario = packagedClaudeChatScenario(providerSessionId, partialReplyAckPath) + return { + ...scenario, + steps: [ + ...scenario.steps + .slice(0, -2) + .map((step) => + step.awaitFile ? { awaitFile: { ...step.awaitFile, timeoutMs: 120_000 } } : step + ), + { + emit: { + type: 'control_request', + request_id: 'wsl-permission', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'pwd' }, + tool_use_id: 'wsl-tool' + } + } + }, + { awaitControlResponse: 'wsl-permission' }, + { awaitControlRequest: 'interrupt' }, + { + emit: { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + session_id: providerSessionId, + uuid: 'wsl-interrupted', + errors: ['Interrupted by the user'] + } + }, + { delayMs: 250 } + ] + } +} diff --git a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts index a2028e911bf..b4b9222199d 100644 --- a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts +++ b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts @@ -5,7 +5,7 @@ // launch - deploy, RPC, kill orcad => relaunch + census, `wsl --terminate` => terminals exited; // then an app update (E1): deferred over a live terminal, forced past it with the // terminal kept, silent with none, and silent again after a reboot -// survive - >= 60 s after launch exited: distros still Running, holders adopted live, reconnect, +// survive - >= 60 s after launch exited: distros still Running, holders adopted live, reconnect, structured chat in git/folder projects, // teardown leaves no bridge or holder processes, WSLInterop intact // .github/workflows/wsl-orcad-e2e.yml imports the distros and runs both phases. // @@ -35,6 +35,7 @@ import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' import { readWindowsProcessTableFresh } from '../windows/windows-process-table' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' +import { proveWslStructuredChat } from './wsl-orcad-structured-chat-contract' import { WSL_DISTRO_HOLDER_MARKER } from './wsl-distro-holder-command' import { resolveWslExecutablePath } from './wsl-executable-path' import { wslDistroHolderKey } from './wsl-distro-holder-record' @@ -554,10 +555,26 @@ async function survivePhase(): Promise { expect(adopted).toContainEqual({ distro: name, state: 'live' }) } for (const { name } of DISTROS) { - const connection = await manager.ensure(name) + let connection = await manager.ensure(name) await rpc(connection, 'status.get') receipt[`${name}.detachedSession`] = await proveDetachedSession(connection, name) receipt[`${name}.census`] = await requestOrcadTerminalCensus(pairedConnection(connection), 0) + if (connection.pid === null) { + throw new Error('Missing guest runtime PID') + } + receipt[`${name}.chat`] = await proveWslStructuredChat({ + rpc: (method, params) => rpc(connection, method, params), + guest: (command) => guest(name, command), + pairing: () => pairedConnection(connection), + runtimePid: connection.pid, + reconnect: async (whileDisconnected) => { + const { localPort, pid } = connection + await connection.close() + await whileDisconnected() + connection = await manager.ensure(name, { localPort }) + expect(connection.pid, 'bridge loss must preserve the guest runtime').toBe(pid) + } + }) } // Teardown: bridges closed, holders released, distros terminated. diff --git a/src/main/wsl/wsl-orcad-structured-chat-contract.ts b/src/main/wsl/wsl-orcad-structured-chat-contract.ts new file mode 100644 index 00000000000..89d4066406f --- /dev/null +++ b/src/main/wsl/wsl-orcad-structured-chat-contract.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'node:crypto' +import { expect, vi } from 'vitest' +import { z } from 'zod' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../shared/agent-session-wire' +import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription +} from '../../shared/remote-runtime-client' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-session-id' +import { packagedClaudeChatScenario } from '../orcad/orcad-structured-chat-scenario' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { wslClaudeChatScenario } from './wsl-orcad-chat-scenario' +import { + installWslChatFixture, + WslChatProviderReport, + writeChatFixtureFile, + type WslChatHost +} from './wsl-orcad-chat-provider-fixture' + +type ChatLane = WslChatHost & { + pairing(): PairedRuntimeConnection + reconnect(whileDisconnected: () => Promise): Promise +} + +function envelope( + sessionId: string, + method: string, + fields: Record, + fence: number | null +) { + return { + sessionId, + expectedRuntimeFence: fence, + clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ sessionId, method, fields }) + } +} + +function assistantText(row: AgentJournalRenderItem): string { + return row.body?.kind === 'message' && row.body.role === 'assistant' + ? row.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +async function exerciseChat(lane: ChatLane, root: string, worktree: string, cwd: string) { + const sessionId = randomUUID() + const providerId = claudeSessionIdForOrcaSession(sessionId) + const ackPath = `${root}/partial-observed` + const scenarioPath = `${root}/scenario.json` + const reportPath = `${root}/report.json` + await lane.guest(`rm -f ${shellEscape(ackPath)}`) + await writeChatFixtureFile( + lane, + scenarioPath, + JSON.stringify(wslClaudeChatScenario(providerId, ackPath)) + ) + const fields = { worktree, agent: 'claude' } + const created = await lane.rpc('agentSession.create', { + ...fields, + envelope: envelope(sessionId, 'agentSession.create', fields, null) + }) + const { + value: { fence } + } = z.object({ ok: z.literal(true), value: z.object({ fence: z.number() }) }).parse(created) + const rows = new Map() + const text: string[] = [] + const errors: Error[] = [] + let dropping = false + let subscription: RemoteRuntimeSubscription | undefined + const subscribe = async () => + subscribeRemoteRuntimeRequest( + lane.pairing(), + 'agentSession.subscribe', + { sessionId }, + 30_000, + { + onResponse(response) { + if (!response.ok) { + errors.push(new Error(JSON.stringify(response.error))) + return + } + const event = response.result + const items = + event.type === 'batch' + ? event.batch.items + : event.type === 'snapshot' || event.type === 'reset' + ? event.page.items + : [] + for (const row of items) { + rows.set(row.itemId, row) + text.push(assistantText(row)) + } + }, + onError(error) { + if (!dropping) { + errors.push(error) + } + } + }, + { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } + ) + try { + subscription = await subscribe() + const png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=' + const upload = await lane.rpc<{ uploadId: string }>('agentSessionAttachment.uploadStart', { + sessionId, + name: 'wsl image.png', + byteLength: Buffer.from(png, 'base64').length + }) + await lane.rpc('agentSessionAttachment.uploadAppend', { + ...upload, + offset: 0, + contentBase64: png + }) + const attachment = await lane.rpc<{ path: string }>( + 'agentSessionAttachment.uploadCommit', + upload + ) + expect(attachment.path).toMatch(/^\//u) + expect(await lane.guest(`base64 -w0 ${shellEscape(attachment.path)}`)).toBe(png) + const body = { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'Inspect this image, then wait for Stop.' }, + { type: 'image-ref', path: attachment.path } + ] + } + expect( + await lane.rpc('agentSession.send', { + body, + envelope: envelope(sessionId, 'agentSession.send', { body }, fence) + }) + ).toMatchObject({ ok: true }) + await vi.waitFor( + () => { + expect(errors).toEqual([]) + expect(text).toContain('Packaged server ') + }, + { timeout: 30_000 } + ) + dropping = true + // Close the actual WSL bridge, not just the chat subscription; the guest owns the turn. + await lane.reconnect(async () => { + await lane.guest(`touch ${shellEscape(ackPath)}`) + }) + subscription.close() + rows.clear() + text.length = 0 + dropping = false + subscription = await subscribe() + await vi.waitFor( + () => { + expect(errors).toEqual([]) + expect(text).toContain('Packaged server reply.') + expect([...rows.values()].some((row) => row.body?.kind === 'approval')).toBe(true) + }, + { timeout: 30_000 } + ) + const approval = [...rows.values()].find((row) => row.body?.kind === 'approval') + if (!approval) { + throw new Error('Missing streamed approval') + } + const answer = { + itemId: approval.itemId, + expectedRevision: approval.revision, + optionId: 'allow' + } + expect( + await lane.rpc('agentSession.respondToApproval', { + ...answer, + envelope: envelope(sessionId, 'agentSession.respondTo:approval', answer, fence) + }) + ).toMatchObject({ ok: true }) + expect( + await lane.rpc('agentSession.cancel', { + envelope: envelope(sessionId, 'agentSession.cancel', {}, fence) + }) + ).toMatchObject({ ok: true, value: { cancelled: true } }) + const report = WslChatProviderReport.parse( + JSON.parse(await lane.guest(`cat ${shellEscape(reportPath)}`)) + ) + expect(report.cwd).toBe(cwd) + expect(report.execPath).toMatch(/^\//u) + expect(report.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1' + }) + expect(report.controlRequests.some((entry) => entry.request.subtype === 'interrupt')).toBe(true) + expect(report.controlResponses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + response: expect.objectContaining({ + response: expect.objectContaining({ behavior: 'allow' }) + }) + }) + ]) + ) + expect(report.userMessages).toHaveLength(1) + expect(report.userMessages[0]?.message.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'image', source: expect.objectContaining({ data: png }) }) + ]) + ) + // Close releases the provider; the saved host record must resume the same conversation. + subscription.close() + await lane.rpc('agentSession.close', { sessionId }) + await writeChatFixtureFile( + lane, + scenarioPath, + JSON.stringify(packagedClaudeChatScenario(providerId, ackPath)) + ) + rows.clear() + const history = await lane.rpc('agentSession.history', { + sessionId, + direction: 'tail', + limit: 100 + }) + const resumedBody = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Continue after Stop.' }] + } + subscription = await subscribe() + if (history.page.fence === undefined) { + throw new Error('Missing host fence') + } + expect( + await lane.rpc('agentSession.send', { + body: resumedBody, + envelope: envelope( + sessionId, + 'agentSession.send', + { body: resumedBody }, + history.page.fence + ) + }) + ).toMatchObject({ ok: true }) + await vi.waitFor( + () => { + expect(errors).toEqual([]) + expect( + [...rows.values()].some( + (row) => + row.body?.kind === 'turn' && + row.body.state === 'completed' && + row.body.outcome === 'success' && + !history.page.items.some((old) => old.itemId === row.itemId) + ) + ).toBe(true) + }, + { timeout: 30_000 } + ) + const resumed = WslChatProviderReport.parse( + JSON.parse(await lane.guest(`cat ${shellEscape(reportPath)}`)) + ) + expect(resumed.cwd).toBe(cwd) + expect(resumed.argv.join(' ')).toMatch(new RegExp(`--resume(?:=| )${providerId}`)) + expect(resumed.userMessages).toHaveLength(1) + return { + sessionId, + providerId, + cwd, + attachment: attachment.path + } + } finally { + subscription?.close() + await lane.rpc('agentSession.close', { sessionId }) + } +} + +/** Same cross-process contract for git and folder projects, in each imported distro. */ +export async function proveWslStructuredChat(lane: ChatLane) { + const { home, root, folderPath, folderWorktree } = await installWslChatFixture(lane) + return { + git: await exerciseChat( + lane, + root, + `path:${home}/wsl-orcad-lane-repo`, + `${home}/wsl-orcad-lane-repo` + ), + folder: await exerciseChat(lane, root, folderWorktree, folderPath) + } +}