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
This commit is contained in:
Brennan Benson
2026-10-18 21:27:10 -07:00
committed by GitHub
parent b117a33c1b
commit 829d9448f1
7 changed files with 499 additions and 18 deletions
+51 -12
View File
@@ -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' }}
@@ -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
@@ -8,7 +8,7 @@
// { steps: Step[], controlResponses?: { [subtype]: <response> },
// controlResponseLeadingFrames?: { [subtype]: <frame>[] } } where a Step is
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
// { awaitControlResponse: <request_id> } | { awaitFile: { path, timeoutMs } } |
// { awaitControlRequest: <subtype> } | { awaitControlResponse: <request_id> } | { awaitFile: { path, timeoutMs } } |
// { delayMs: <n> } | { exit: <code> }
// 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) {
@@ -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<T>(method: string, params?: unknown): Promise<T>
guest(command: string): Promise<string>
runtimePid: number
}
export async function writeChatFixtureFile(
host: WslChatHost,
path: string,
content: string
): Promise<void> {
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() }) }))
})
+41
View File
@@ -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 }
]
}
}
@@ -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<void> {
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.
@@ -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<void>): Promise<void>
}
function envelope(
sessionId: string,
method: string,
fields: Record<string, unknown>,
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<string, AgentJournalRenderItem>()
const text: string[] = []
const errors: Error[] = []
let dropping = false
let subscription: RemoteRuntimeSubscription | undefined
const subscribe = async () =>
subscribeRemoteRuntimeRequest<AgentSessionSubscribeEvent>(
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<AgentSessionHistoryResult>('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)
}
}