From 90b0fdbaa844838a35ada39e4c652f2b8a991448 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:07:21 -0700 Subject: [PATCH 1/4] fix: preserve module URLs in the headless server bundle --- config/scripts/build-orcad.mjs | 18 ++++- config/scripts/orcad-entry-build.mjs | 21 ++++- config/scripts/orcad-entry-build.test.mjs | 79 +++++++++++++++++++ src/main/orcad/main.ts | 7 +- .../orcad/orcad-structured-provider-smoke.ts | 22 ++++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 config/scripts/orcad-entry-build.test.mjs create mode 100644 src/main/orcad/orcad-structured-provider-smoke.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 96a46677626f..7a9b511f8ec3 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -7,7 +7,8 @@ import { buildOrcadLauncher, externalNativeAddons, ORCAD_EXTERNAL_MODULES, - ORCAD_CHILD_ENTRY_POINTS + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' import { @@ -47,6 +48,7 @@ import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR @@ -105,6 +107,7 @@ async function stageParcelWatcher(target) { const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js') mkdirSync(WATCHER_MODULE_DIR, { recursive: true }) await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, stdin: { contents: `const {createWrapper}=require(${JSON.stringify(wrapperSource)});` + @@ -202,6 +205,7 @@ cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-cha * their runtime resolvers look for them. */ function buildForkedChild(entryPoint, outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [entryPoint], bundle: true, platform: 'node', @@ -214,6 +218,7 @@ function buildForkedChild(entryPoint, outfile) { minify: true, sourcemap: false, define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, 'process.env.NODE_ENV': '"production"' }, logLevel: 'error' @@ -317,6 +322,17 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + const providerSmoke = runProcessSync({ + program: process.execPath, + args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'], + timeoutMs: 60_000 + }) + if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) { + console.error( + `[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}` + ) + process.exitCode = 1 + } // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every // top-level import, and calling its exported argv parser proves the entry's own code is // there rather than a graph that merely resolved. Booting one would need a socket, a diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index a746ac4bb5b2..0c37406b2397 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -19,6 +19,15 @@ export const ORCAD_CHILD_ENTRY_POINTS = { export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] +// Bundled ESM resolves from the deployed bundle, like CommonJS __filename. +export const ORCAD_COMMONJS_MODULE_OPTIONS = { + banner: { + js: 'var __orcadModuleUrl = require("node:url").pathToFileURL(__filename).href;' + }, + define: { 'import.meta.url': '__orcadModuleUrl' }, + logOverride: { 'empty-import-meta': 'error' } +} + // Native binaries are staged separately from every JavaScript entry. export const externalNativeAddons = { name: 'external-native-addons', @@ -39,6 +48,7 @@ const jsoncParserEsm = { export function buildOrcadEntry(outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', @@ -50,7 +60,10 @@ export function buildOrcadEntry(outfile) { metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + 'process.env.NODE_ENV': '"production"' + }, logLevel: 'error' }) } @@ -60,6 +73,7 @@ export function buildOrcadLauncher(outfile) { .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) .digest('hex') return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], bundle: true, platform: 'node', @@ -69,7 +83,10 @@ export function buildOrcadLauncher(outfile) { metafile: true, minify: true, sourcemap: false, - define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) + }, logLevel: 'error' }) } diff --git a/config/scripts/orcad-entry-build.test.mjs b/config/scripts/orcad-entry-build.test.mjs new file mode 100644 index 000000000000..458c1a058148 --- /dev/null +++ b/config/scripts/orcad-entry-build.test.mjs @@ -0,0 +1,79 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { buildOrcadEntry, ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const directory = mkdtempSync(join(tmpdir(), 'orca-structured-provider-build-')) +const bundle = join(directory, 'orcad-server.js') + +beforeAll(async () => { + await buildOrcadEntry(bundle) +}, 60_000) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it('loads the structured Claude SDK through the packaged server under plain Node', () => { + const loaded = runProcessSync({ + program: process.execPath, + args: [bundle, '--orcad-structured-provider-load-check'], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr.slice(-2_000)).toBe(0) +}, 40_000) + +it('resolves bundle-relative requires after deployment to a path with URL-special characters', async () => { + const buildDirectory = join(directory, 'build') + mkdirSync(buildDirectory) + const outfile = join(buildDirectory, 'entry.cjs') + await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { + contents: `import { createRequire } from 'node:module'; + console.log(createRequire(import.meta.url)('./asset.cjs'));`, + resolveDir: directory + }, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + minify: true, + logLevel: 'error' + }) + writeFileSync(join(buildDirectory, 'asset.cjs'), 'module.exports = "deployed asset"') + const deployedDirectory = join(directory, 'deployed # 100%') + renameSync(buildDirectory, deployedDirectory) + const loaded = runProcessSync({ + program: process.execPath, + args: [join(deployedDirectory, 'entry.cjs')], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr).toBe(0) + expect(loaded.stdout.trim()).toBe('deployed asset') +}) + +it.each(['import.meta', 'import.meta.dirname', 'import.meta.filename', 'import.meta.resolve'])( + 'rejects unsupported %s instead of silently replacing it with an empty object', + async (expression) => { + await expect( + build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { contents: `console.log(${expression})`, resolveDir: directory }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + ).rejects.toThrow('"import.meta" is not available') + } +) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 49490ffedf37..9d2dc046d486 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -45,7 +45,12 @@ function failStartup(error: unknown): void { } // One-shot launch and stop commands must skip server startup and preflights. -if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { +if (process.argv[2] === '--orcad-structured-provider-load-check') { + void import('./orcad-structured-provider-smoke') + .then(({ smokeLoadOrcadStructuredProviders }) => smokeLoadOrcadStructuredProviders()) + .then(() => process.exit(0)) + .catch(failStartup) +} else if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || diff --git a/src/main/orcad/orcad-structured-provider-smoke.ts b/src/main/orcad/orcad-structured-provider-smoke.ts new file mode 100644 index 000000000000..7e64c62f38a9 --- /dev/null +++ b/src/main/orcad/orcad-structured-provider-smoke.ts @@ -0,0 +1,22 @@ +import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' + +export async function smokeLoadOrcadStructuredProviders(): Promise { + const loaded = new Error('structured provider module loaded') + try { + await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'unused', cwd: process.cwd(), options: {} }, + {}, + undefined, + () => { + // The production connection loads the SDK before calling the query implementation. + throw loaded + } + ) + } catch (error) { + if (error === loaded) { + return + } + throw error + } + throw new Error('structured provider load check did not reach the SDK boundary') +} From bba4aa64725a889bc51dae612780db48bd87d518 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:17:11 -0700 Subject: [PATCH 2/4] test: exercise structured chat on the packaged Node server --- .github/workflows/node-server-tests.yml | 3 + config/scripts/node-server-change-scope.mjs | 3 + .../scripts/node-server-change-scope.test.mjs | 21 ++ .../claude-agent-sdk-scripted-cli.mjs | 12 + ...ckaged-structured-chat.integration.test.ts | 330 ++++++++++++++++++ .../orcad/orcad-structured-chat-scenario.ts | 70 ++++ 6 files changed, 439 insertions(+) create mode 100644 src/main/orcad/orcad-packaged-structured-chat.integration.test.ts create mode 100644 src/main/orcad/orcad-structured-chat-scenario.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 90c3fc68473d..039ca2e895bf 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -198,6 +198,9 @@ jobs: env: ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} + - name: Verify a structured chat against the packaged plain-Node server + if: runner.os == 'Linux' + run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. - name: Build the Windows process-table addons for the desktop template diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 0499796eab17..17d42ceff423 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -45,6 +45,8 @@ const ALWAYS_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', + 'src/main/runtime/', + 'src/main/claude/', 'src/main/daemon/pty-subprocess/', 'src/main/providers/', 'config/patches/', @@ -119,6 +121,7 @@ export async function classifyNodeServerChanges( (file) => ALWAYS_FILES.has(file) || ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) || + /^config\/scripts\/[^/]*orcad[^/]*$/.test(file) || selectors.some((selector) => file.includes(selector)) ) if (forced) { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 38b56f0d6000..19ffe0b26942 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -91,6 +91,11 @@ it.each([ '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', + 'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts', + 'src/main/runtime/new-launch-path.ts', + 'src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs', + 'config/scripts/orcad-entry-build.mjs', + 'config/scripts/future-orcad-package.mjs', 'src/main/persistence/profile-state/new-worker.ts' ])('always selects build, native and dynamically opened inputs: %s', async (file) => { expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) @@ -293,6 +298,22 @@ it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (d ) }) +it('runs a real packaged structured chat on Linux after the package is built', () => { + const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + const steps = workflow.jobs.persistence.steps + const chat = steps.find((step) => + step.run?.includes('orcad-packaged-structured-chat.integration.test.ts') + ) + expect(chat.if).toBe("runner.os == 'Linux'") + expect(chat.run).toBe( + 'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts' + ) + expect(steps.indexOf(chat)).toBeGreaterThan( + steps.findIndex((step) => step.run === 'pnpm build:orcad') + ) + expect(chat['continue-on-error']).toBeUndefined() +}) + it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') 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 799b64546242..7dc112547efb 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -17,12 +17,24 @@ import { spawn } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { createInterface } from 'node:readline' +if (process.argv.includes('--version')) { + process.stdout.write('2.1.258 (Claude Code)\n') + process.exit(0) +} + const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH const report = { argv: process.argv.slice(1), execPath: process.execPath, + cwd: process.cwd(), + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + key.startsWith('ORCA_') || ['HOME', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].includes(key) + ) + ), controlRequests: [], controlResponses: [], userMessages: [], diff --git a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts new file mode 100644 index 000000000000..d711f0c473ec --- /dev/null +++ b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts @@ -0,0 +1,330 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { getDefaultPersistedState } from '../../shared/constants' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { spawnProcess } from '../../shared/child-process/run-process' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { parsePairingCode, type PairingOffer } from '../../shared/pairing' +import { + AGENT_SESSION_TURN_ITEM_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription +} from '../../shared/remote-runtime-client' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-session-id' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { + installPackagedOrcadSlotForTests, + locatePinnedNodeForTests, + skipForMissingInputs +} from './orcad-node-slot-fixture' +import { + killChildAndWait, + killProfileDaemons, + removeTestRoot +} from './orcad-daemon-teardown-fixture' +import { packagedClaudeChatScenario } from './orcad-structured-chat-scenario' + +const pinnedNode = locatePinnedNodeForTests() +const missing = [ + ...(!pinnedNode ? ['a pinned Node runtime'] : []), + ...(!existsSync(resolve('out/orcad/orcad.js')) ? ['out/orcad/orcad.js'] : []) +] +const skip = skipForMissingInputs('artifact', missing) +const capabilities = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY +] +const Ready = z.object({ + pairing: z.object({ available: z.literal(true), url: z.string() }) +}) +const ChildReport = z.object({ + argv: z.array(z.string()), + execPath: z.string(), + cwd: z.string(), + env: z.record(z.string(), z.string()), + userMessages: z.array(z.object({ message: z.object({ content: z.unknown() }) })) +}) + +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) { + await killProfileDaemons(join(root, 'data')) + await removeTestRoot(root) + } +}) + +function launch(slotDir: string, runtime: string, userData: string, home: string) { + const child = spawnProcess({ + program: runtime, + args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + cwd: home, + env: { + HOME: home, + CODEX_HOME: join(home, '.codex'), + CLAUDE_CONFIG_DIR: join(home, '.claude'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + PATH: [dirname(userData), dirname(runtime), '/usr/bin', '/bin'].join(delimiter), + SHELL: '/bin/sh', + LANG: 'en_US.UTF-8', + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA: userData + }, + timeoutMs: null + }) + let output = '' + let stderr = '' + child.stderr.on('data', (data: Buffer) => (stderr += data.toString())) + const ready = new Promise((resolveReady, reject) => { + const timer = setTimeout(() => reject(new Error(`No server readiness: ${stderr}`)), 30_000) + child.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`Server exited ${code}: ${stderr}`)) + }) + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + if (!output.includes('\n')) { + return + } + clearTimeout(timer) + try { + const parsed = Ready.parse(JSON.parse(output.split('\n')[0]!)) + const pairing = parsePairingCode(parsed.pairing.url) + if (!pairing) { + throw new Error('Invalid server pairing offer') + } + resolveReady(pairing) + } catch (error) { + reject(error) + } + }) + }) + return { child, ready, stderr: () => stderr } +} + +async function call(pairing: PairingOffer, method: string, params: unknown): Promise { + const response = await sendRemoteRuntimeRequest( + pairing, + method, + params, + 20_000, + undefined, + undefined, + capabilities + ) + if (!response.ok) { + throw new Error(`${method}: ${JSON.stringify(response.error)}`) + } + return response.result +} + +function envelope( + sessionId: string, + method: string, + fields: Record, + fence: number | null +) { + return { + sessionId, + clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) + } +} + +describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node structured chat', () => { + it('creates, streams and settles a Claude turn through the real paired RPC', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned Node runtime') + } + const root = realpathSync( + mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'orca-chat-')) + ) + roots.push(root) + const home = join(root, 'home') + const userData = join(root, 'data') + mkdirSync(home) + mkdirSync(userData) + const { slotDir, runtime } = installPackagedOrcadSlotForTests(root, pinnedNode) + const script = join(root, 'claude-scripted.mjs') + const fakeCli = join(root, 'claude') + writeFileSync( + script, + readFileSync( + resolve('src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs'), + 'utf8' + ) + ) + writeFileSync(fakeCli, `#!/bin/sh\nexec ${shellEscape(runtime)} ${shellEscape(script)} "$@"\n`) + chmodSync(fakeCli, 0o700) + const sessionId = randomUUID() + const scenarioPath = join(root, 'scenario.json') + const reportPath = join(root, 'provider-report.json') + writeFileSync( + scenarioPath, + JSON.stringify(packagedClaudeChatScenario(claudeSessionIdForOrcaSession(sessionId))) + ) + const state = getDefaultPersistedState(home) + state.settings = { + ...state.settings, + agentStatusHooksEnabled: false, + nativeChatInheritShellEnvironment: false, + agentCmdOverrides: { claude: fakeCli }, + agentDefaultEnv: { + claude: { + CLAUDE_CONFIG_DIR: join(home, 'claude-config'), + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + } + } + } + writeFileSync(join(userData, 'orca-data.json'), JSON.stringify(state)) + const server = launch(slotDir, runtime, userData, home) + let subscription: RemoteRuntimeSubscription | undefined + try { + const pairing = await server.ready + const group = await call<{ group: { id: string } }>(pairing, 'projectGroup.create', { + name: 'Test folder' + }) + const folder = await call<{ folderWorkspace: { id: string } }>( + pairing, + 'folderWorkspace.create', + { + projectGroupId: group.group.id, + folderPath: home, + name: 'Chat test' + } + ) + const fields = { worktree: folderWorkspaceKey(folder.folderWorkspace.id), agent: 'claude' } + const created = await call(pairing, 'agentSession.create', { + ...fields, + envelope: envelope(sessionId, 'agentSession.create', fields, null) + }) + expect(created, server.stderr()).toMatchObject({ ok: true }) + const { + value: { fence } + } = z.object({ ok: z.literal(true), value: z.object({ fence: z.number() }) }).parse(created) + const items = new Map() + const observedText: string[] = [] + const errors: Error[] = [] + subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'agentSession.subscribe', + { sessionId }, + 20_000, + { + onResponse(response) { + if (!response.ok) { + errors.push(new Error(JSON.stringify(response.error))) + return + } + const event = response.result + const rows = + event.type === 'batch' + ? event.batch.items + : event.type === 'snapshot' || event.type === 'reset' + ? event.page.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + if (row.body?.kind === 'message' && row.body.role === 'assistant') { + observedText.push( + row.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + ) + } + } + }, + onError: (error) => errors.push(error) + }, + { clientCapabilities: capabilities } + ) + const body = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Hello packaged server' }] + } + expect( + await call(pairing, 'agentSession.send', { + body, + envelope: envelope(sessionId, 'agentSession.send', { body }, fence) + }), + server.stderr() + ).toMatchObject({ ok: true }) + await vi.waitFor( + () => { + expect(errors).toEqual([]) + expect(observedText).toContain('Packaged server ') + expect(observedText).toContain('Packaged server reply.') + expect([...items.values()].find((row) => row.body?.kind === 'turn')?.body).toMatchObject({ + state: 'completed', + outcome: 'success' + }) + }, + { timeout: 15_000 } + ) + const report = ChildReport.parse(JSON.parse(readFileSync(reportPath, 'utf8'))) + expect(report.argv[0]).toBe(script) + expect(report.argv).toEqual( + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + '--include-partial-messages', + '--replay-user-messages', + '--permission-prompt-tool', + 'stdio', + `--session-id=${claudeSessionIdForOrcaSession(sessionId)}`, + '--setting-sources=user,project,local', + '--dangerously-skip-permissions' + ]) + ) + expect(report.execPath).toBe(runtime) + expect(report.cwd).toBe(home) + expect(report.env).toMatchObject({ + HOME: home, + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1', + ORCA_USER_DATA_PATH: userData, + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + }) + expect(report.env.CLAUDE_CONFIG_DIR).toBe(join(home, 'claude-config')) + expect(report.env).not.toHaveProperty('ORCA_PANE_KEY') + expect(report.userMessages).toHaveLength(1) + expect(report.userMessages[0]?.message.content).toEqual([ + { type: 'text', text: 'Hello packaged server' } + ]) + // Follow-up #26464: assert ORCA_CLI_COMMAND executes the packaged host CLI. + } finally { + subscription?.close() + await killChildAndWait(server.child) + } + }, 90_000) +}) diff --git a/src/main/orcad/orcad-structured-chat-scenario.ts b/src/main/orcad/orcad-structured-chat-scenario.ts new file mode 100644 index 000000000000..e44624d21892 --- /dev/null +++ b/src/main/orcad/orcad-structured-chat-scenario.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' + +/** A credential-free reply in the real CLI's partial-message cadence. */ +export function packagedClaudeChatScenario(providerSessionId: string) { + const frame = (fields: Record) => ({ + uuid: randomUUID(), + session_id: providerSessionId, + parent_tool_use_id: null, + ...fields + }) + const event = (fields: Record) => frame({ type: 'stream_event', event: fields }) + const message = { + id: 'packaged-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Packaged server reply.' }] + } + return { + controlResponses: { + initialize: { + commands: [], + models: [{ value: 'sonnet', displayName: 'Sonnet', description: 'Test model' }] + } + }, + steps: [ + { awaitUserMessage: true }, + { emit: event({ type: 'message_start', message: { ...message, content: [] } }) }, + { + emit: event({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }) + }, + { + emit: event({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Packaged server ' } + }) + }, + // Let the host publish a partial reply before the final block arrives. + { delayMs: 150 }, + { + emit: event({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'reply.' } + }) + }, + { emit: frame({ type: 'assistant', message }) }, + { emit: event({ type: 'content_block_stop', index: 0 }) }, + { emit: event({ type: 'message_stop' }) }, + { + emit: frame({ + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 150, + duration_api_ms: 1, + num_turns: 1, + result: 'Packaged server reply.', + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 4 } + }) + }, + // Context usage is a control request issued after the result. + { delayMs: 250 } + ] + } +} From 391c9d5b4cfcbd74b0c8549e4dd81b1b53bf7e50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:09:34 -0700 Subject: [PATCH 3/4] test(orcad): run the packaged chat check from the desktop's server template The chat check loaded the server bundle directly, so a desktop build missing its server template still passed. Materialize the slot from the built template the way the desktop does, and replace the fixed streaming pause with a test-owned acknowledgement so the partial-reply assertion can't race. --- .github/workflows/node-server-tests.yml | 7 ++++-- .../scripts/node-server-change-scope.test.mjs | 13 ++++++++--- .../claude-agent-sdk-scripted-cli.mjs | 14 +++++++++-- src/main/orcad/orcad-node-slot-fixture.ts | 7 +++--- ...ckaged-structured-chat.integration.test.ts | 23 ++++++++++++++++--- .../orcad/orcad-structured-chat-scenario.ts | 5 ++-- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 039ca2e895bf..2f2535b9b968 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -198,9 +198,12 @@ jobs: env: ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} - - name: Verify a structured chat against the packaged plain-Node server + - name: Build the host-target desktop server template if: runner.os == 'Linux' - run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts + run: node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc + - name: Verify a structured chat from the desktop server template + if: runner.os == 'Linux' + run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2 # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. - name: Build the Windows process-table addons for the desktop template diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 19ffe0b26942..ff1e46ed28de 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -298,7 +298,7 @@ it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (d ) }) -it('runs a real packaged structured chat on Linux after the package is built', () => { +it('runs a real structured chat on Linux from the built desktop template', () => { const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) const steps = workflow.jobs.persistence.steps const chat = steps.find((step) => @@ -306,11 +306,18 @@ it('runs a real packaged structured chat on Linux after the package is built', ( ) expect(chat.if).toBe("runner.os == 'Linux'") expect(chat.run).toBe( - 'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts' + 'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2' ) - expect(steps.indexOf(chat)).toBeGreaterThan( + const template = steps.find((step) => step.run?.includes('build-orcad-template.mjs --targets')) + expect(template.if).toBe("runner.os == 'Linux'") + expect(template.run).toBe( + "node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc" + ) + expect(steps.indexOf(template)).toBeGreaterThan( steps.findIndex((step) => step.run === 'pnpm build:orcad') ) + expect(steps.indexOf(chat)).toBeGreaterThan(steps.indexOf(template)) + expect(template['continue-on-error']).toBeUndefined() expect(chat['continue-on-error']).toBeUndefined() }) 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 7dc112547efb..310dbb822c7a 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -8,14 +8,16 @@ // { steps: Step[], controlResponses?: { [subtype]: }, // controlResponseLeadingFrames?: { [subtype]: [] } } where a Step is // { emit: } | { awaitUserMessage: true } | { stderr: } | -// { awaitControlResponse: } | { delayMs: } | { exit: } +// { 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 // ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer // ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid import { spawn } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { createInterface } from 'node:readline' +import { setTimeout as delay } from 'node:timers/promises' if (process.argv.includes('--version')) { process.stdout.write('2.1.258 (Claude Code)\n') @@ -148,6 +150,14 @@ for (const step of scenario.steps) { await waitFor('user') } else if (step.awaitControlResponse !== undefined) { await waitFor('control_response', step.awaitControlResponse) + } else if (step.awaitFile) { + const deadline = Date.now() + step.awaitFile.timeoutMs + while (!existsSync(step.awaitFile.path)) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for test acknowledgement: ${step.awaitFile.path}`) + } + await delay(10) + } } else if (step.delayMs) { await new Promise((resolve) => setTimeout(resolve, step.delayMs)) } else if (step.exit !== undefined) { diff --git a/src/main/orcad/orcad-node-slot-fixture.ts b/src/main/orcad/orcad-node-slot-fixture.ts index 73fa3c9c944a..b771ca01ef90 100644 --- a/src/main/orcad/orcad-node-slot-fixture.ts +++ b/src/main/orcad/orcad-node-slot-fixture.ts @@ -73,10 +73,11 @@ export async function writeNodeSlotFixture( return { slotDir, runtime } } -/** Hard-links the built `out/orcad` slot under `/orcad-test` with its pinned runtime beside it. */ +/** Installs a built or materialized slot under `/orcad-test` with its pinned runtime beside it. */ export function installPackagedOrcadSlotForTests( root: string, - pinnedNode: string + pinnedNode: string, + sourceDir = resolve('out/orcad') ): { slotDir: string; runtime: string } { const linkOrCopy = (from: string, to: string): void => { try { @@ -96,7 +97,7 @@ export function installPackagedOrcadSlotForTests( } } const slotDir = join(root, 'orcad-test') - installTree(resolve('out/orcad'), slotDir) + installTree(sourceDir, slotDir) const target = hostServerTarget() const runtime = resolve( slotDir, diff --git a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts index d711f0c473ec..7a327e8d6f19 100644 --- a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts +++ b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts @@ -32,7 +32,9 @@ import { } from '../../shared/remote-runtime-client' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-session-id' import { shellEscape } from '../ssh/ssh-connection-utils' +import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' import { + hostServerTarget, installPackagedOrcadSlotForTests, locatePinnedNodeForTests, skipForMissingInputs @@ -45,9 +47,10 @@ import { import { packagedClaudeChatScenario } from './orcad-structured-chat-scenario' const pinnedNode = locatePinnedNodeForTests() +const templateDir = process.env.ORCA_ORCAD_TEMPLATE_PATH ?? resolve('out/orcad-template') const missing = [ ...(!pinnedNode ? ['a pinned Node runtime'] : []), - ...(!existsSync(resolve('out/orcad/orcad.js')) ? ['out/orcad/orcad.js'] : []) + ...(!existsSync(templateDir) ? [templateDir] : []) ] const skip = skipForMissingInputs('artifact', missing) const capabilities = [ @@ -172,7 +175,15 @@ describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node struc const userData = join(root, 'data') mkdirSync(home) mkdirSync(userData) - const { slotDir, runtime } = installPackagedOrcadSlotForTests(root, pinnedNode) + const materializedSlot = await materializeOrcadArtifact(hostServerTarget(), { + templateDir, + cacheRoot: join(root, 'artifacts') + }) + const { slotDir, runtime } = installPackagedOrcadSlotForTests( + root, + pinnedNode, + materializedSlot + ) const script = join(root, 'claude-scripted.mjs') const fakeCli = join(root, 'claude') writeFileSync( @@ -187,9 +198,12 @@ describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node struc const sessionId = randomUUID() const scenarioPath = join(root, 'scenario.json') const reportPath = join(root, 'provider-report.json') + const partialReplyAckPath = join(root, 'partial-reply-observed') writeFileSync( scenarioPath, - JSON.stringify(packagedClaudeChatScenario(claudeSessionIdForOrcaSession(sessionId))) + JSON.stringify( + packagedClaudeChatScenario(claudeSessionIdForOrcaSession(sessionId), partialReplyAckPath) + ) ) const state = getDefaultPersistedState(home) state.settings = { @@ -258,6 +272,9 @@ describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node struc observedText.push( row.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') ) + if (observedText.at(-1) === 'Packaged server ') { + writeFileSync(partialReplyAckPath, '') + } } } }, diff --git a/src/main/orcad/orcad-structured-chat-scenario.ts b/src/main/orcad/orcad-structured-chat-scenario.ts index e44624d21892..26699b873928 100644 --- a/src/main/orcad/orcad-structured-chat-scenario.ts +++ b/src/main/orcad/orcad-structured-chat-scenario.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' /** A credential-free reply in the real CLI's partial-message cadence. */ -export function packagedClaudeChatScenario(providerSessionId: string) { +export function packagedClaudeChatScenario(providerSessionId: string, partialReplyAckPath: string) { const frame = (fields: Record) => ({ uuid: randomUUID(), session_id: providerSessionId, @@ -38,8 +38,7 @@ export function packagedClaudeChatScenario(providerSessionId: string) { delta: { type: 'text_delta', text: 'Packaged server ' } }) }, - // Let the host publish a partial reply before the final block arrives. - { delayMs: 150 }, + { awaitFile: { path: partialReplyAckPath, timeoutMs: 10_000 } }, { emit: event({ type: 'content_block_delta', From c2d3c6748ec05380c04cbb3dcc58e3e50d20c131 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:59:35 -0700 Subject: [PATCH 4/4] Connect the packaged chat test through a paired connection Main now sends remote runtime requests over a paired connection rather than a pairing offer, so the test converts the server's offer once when it is ready. --- ...-packaged-structured-chat.integration.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts index 87ca072764b5..6c24a299f411 100644 --- a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts +++ b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts @@ -18,7 +18,11 @@ import { spawnProcess } from '@orca/process-host' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { parsePairingCode, type PairingOffer } from '../../shared/pairing' +import { parsePairingCode } from '../../shared/pairing' +import { + pairedRuntimeConnectionFromOffer, + type PairedRuntimeConnection +} from '../../shared/paired-runtime-connection' import { AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -101,7 +105,7 @@ function launch(slotDir: string, runtime: string, userData: string, home: string let output = '' let stderr = '' child.stderr.on('data', (data: Buffer) => (stderr += data.toString())) - const ready = new Promise((resolveReady, reject) => { + const ready = new Promise((resolveReady, reject) => { const timer = setTimeout(() => reject(new Error(`No server readiness: ${stderr}`)), 30_000) child.once('error', (error) => { clearTimeout(timer) @@ -123,7 +127,7 @@ function launch(slotDir: string, runtime: string, userData: string, home: string if (!pairing) { throw new Error('Invalid server pairing offer') } - resolveReady(pairing) + resolveReady(pairedRuntimeConnectionFromOffer(pairing)) } catch (error) { reject(error) } @@ -132,7 +136,11 @@ function launch(slotDir: string, runtime: string, userData: string, home: string return { child, ready, stderr: () => stderr } } -async function call(pairing: PairingOffer, method: string, params: unknown): Promise { +async function call( + pairing: PairedRuntimeConnection, + method: string, + params: unknown +): Promise { const response = await sendRemoteRuntimeRequest( pairing, method,