mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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() }) }))
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user