Files
orca/src/main/daemon/client.test.ts
T
Jinjing ca5a821600 Stop relaunching creation-time agents on workspace activation (#10647)
* fix(activation): stop relaunching the creation-time agent on workspace activation

Activating a workspace with zero renderable tabs launched the agent it was
created with, unprompted and in approval-bypass mode. Navigation is not consent
to start a process: the same fallback fired from post-delete focus handoff, the
jump palette, keyboard cycling, CLI/relay activation, and notification clicks.

The mechanism was superseded. #1814 added it when relaunching the created agent
*was* the resume feature; #4706 later added real provider-session resume six
lines above and left the fallback in place. What remained fired whenever a
workspace had no renderable tabs -- including when nothing had ever slept -- and
reported itself as `request_kind: 'resume'` while resuming nothing, discarding
any resumable session a plain tab close had already purged.

No caller depends on it. All seven intent-carrying callers pass an explicit
`startup` on the branch where they intend a launch, and every no-startup branch
either declined an agent, already has one running (host `didSpawnStartup`), or
is this same defect arriving over IPC.

Drops the now-orphaned imports, retargets the stale comment in
launch-work-item-direct that cited reopen-relaunch as the reason to persist
`createdWithAgent`, and moves the WSL default-args quoting assertion to
launch-agent-in-new-tab, whose launch path still resolves those args.

Regression tests are revert-sensitive -- all four fail if the fallback returns.

* test(activation): name the relaunch regression tests after what they reach

Three tests were named after scenarios they never invoked, which is the
failure mode that lets a coverage gap read as closed.

- The "host-originated" test's `notifyHostRuntime: false` is inert here: both
  gates resolve through `isWebRuntimeSessionActive`, false with no runtime
  environment seeded, so it was byte-identical to the plain reopen test. It no
  longer claims to cover the host `didSpawnStartup` leg, which lives in main and
  is unreachable from this layer.
- The "post-delete focus handoff" test never deleted anything and never touched
  `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in
  active-worktree-focus-after-delete.test.ts, which locks out any opts.
- The activate/close loop resets state instead of calling `closeTab`, so it does
  not exercise the sleeping-record purge its comment claimed.

Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the
fixture extracted for exactly that state, which its inline copy had drifted from
by hardcoding a POSIX repo path.

`preflight` is dropped from the launch-work-item-direct comment: the trust
preflight reads the create-time argument (worktree-remote.ts), not the persisted
meta. Removal safety and ownership do read the field and remain accurate.

Renames the ported quoting test to what it pins. Under vitest's node
environment `navigator.userAgent` carries no "Windows", so platform resolution
bails before the WSL branch and the WSL preference is inert — the real coverage
is single-quote escaping of user-configured agentDefaultArgs.

* transfer large terminal history seeds across bounded protocol messages

- Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations
- Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit
- Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery
- NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors

* fix(daemon): drop held output when teardown checkpoint fails to serializ

When a final snapshot checkpoint fails to serialize (returns retryable), the
pending output records must not be appended later—doing so would splice them
over the seq gap left by the failed snapshot, defeating gap detection. Drop
the records and retry the checkpoint instead.

* Bump daemon protocol version to 30

* Bump daemon protocol version to 30
2026-07-28 15:12:44 -07:00

615 lines
20 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createServer, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
import { getDaemonSocketPath } from './daemon-spawner'
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'daemon-client-test-'))
}
function splitInsideUtf8Sequence(payload: string, needle: string): [Buffer, Buffer] {
const encoded = Buffer.from(payload, 'utf8')
const encodedNeedle = Buffer.from(needle, 'utf8')
const offset = encoded.indexOf(encodedNeedle)
if (offset === -1 || encodedNeedle.length < 2) {
throw new Error(`Unable to split payload inside ${needle}`)
}
return [encoded.subarray(0, offset + 1), encoded.subarray(offset + 1)]
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor timed out')
}
await new Promise((r) => setTimeout(r, 10))
}
}
describe('DaemonClient', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: Server
let client: DaemonClient
beforeEach(() => {
dir = createTestDir()
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'test.token')
writeFileSync(tokenPath, 'test-token-123')
})
afterEach(async () => {
vi.useRealTimers()
client?.disconnect()
await new Promise<void>((resolve) => {
if (server?.listening) {
server.close(() => resolve())
} else {
resolve()
}
})
rmSync(dir, { recursive: true, force: true })
})
function startMockDaemon(opts?: {
closeOnConnect?: boolean
closeOnHello?: boolean
onControlMessage?: (msg: unknown) => string | null
onHello?: (msg: HelloMessage) => void
onStreamHello?: (msg: HelloMessage) => void
rejectVersion?: boolean
suppressHelloResponse?: boolean
omitHelloIdentity?: boolean
helloIdentity?: (role: 'control' | 'stream') => {
pid: number
startedAtMs: number
launchNonce: string
}
}): Promise<void> {
return new Promise((resolve) => {
server = createServer((socket) => {
if (opts?.closeOnConnect) {
socket.destroy()
return
}
let buffer = ''
socket.on('data', (chunk) => {
buffer += chunk.toString()
let newlineIdx: number
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 1)
if (!line) {
continue
}
const msg = JSON.parse(line) as HelloMessage | DaemonRequest
if (msg.type === 'hello') {
const hello = msg as HelloMessage
opts?.onHello?.(hello)
if (opts?.closeOnHello) {
socket.destroy()
return
}
if (opts?.suppressHelloResponse) {
return
}
if (opts?.rejectVersion) {
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' }))
return
}
socket.write(
encodeNdjson({
type: 'hello',
ok: true,
...(!opts?.omitHelloIdentity
? {
daemonIdentity: opts?.helloIdentity
? opts.helloIdentity(hello.role)
: { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' }
}
: {})
})
)
if (hello.role === 'stream') {
opts?.onStreamHello?.(hello)
}
} else if (opts?.onControlMessage) {
const response = opts.onControlMessage(msg)
if (response) {
socket.write(response)
}
}
}
})
})
server.listen(socketPath, () => resolve())
})
}
describe('connect', () => {
it('establishes connection with hello handshake', async () => {
const hellos: HelloMessage[] = []
await startMockDaemon({
onStreamHello: (msg) => hellos.push(msg)
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
// Both control and stream sockets should have sent hello
await waitFor(() => hellos.length > 0)
})
it('captures one matching endpoint identity from both authenticated sockets', async () => {
const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' }
await startMockDaemon({ helloIdentity: () => identity })
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(client.getDaemonIdentity()).toEqual(identity)
})
it('rejects a v24 daemon that omits endpoint identity', async () => {
await startMockDaemon({ omitHelloIdentity: true })
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity')
})
it('allows a legacy v23 daemon to omit endpoint identity', async () => {
await startMockDaemon({ omitHelloIdentity: true })
client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 23 })
await expect(client.ensureConnected()).resolves.toBeUndefined()
expect(client.getDaemonIdentity()).toBeNull()
})
it('rejects when control and stream sockets report different daemon identities', async () => {
await startMockDaemon({
helloIdentity: (role) => ({
pid: role === 'control' ? 123 : 124,
startedAtMs: 456,
launchNonce: 'launch-a'
})
})
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow(
'Daemon identity changed during connection'
)
expect(client.getDaemonIdentity()).toBeNull()
})
it('removes socket startup listeners after connecting', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const connectedClient = client as unknown as {
controlSocket: Socket | null
streamSocket: Socket | null
}
for (const socket of [connectedClient.controlSocket, connectedClient.streamSocket]) {
expect(socket?.listenerCount('connect')).toBe(0)
// One live error listener remains: the disconnect handler installed
// after the daemon hello handshake succeeds.
expect(socket?.listenerCount('error')).toBe(1)
}
})
it('rejects on version mismatch', async () => {
await startMockDaemon({ rejectVersion: true })
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow()
})
it('times out when the daemon never answers hello', async () => {
let resolveHello: () => void = () => {}
const helloReceived = new Promise<void>((resolve) => {
resolveHello = resolve
})
await startMockDaemon({
suppressHelloResponse: true,
onHello: resolveHello
})
vi.useFakeTimers()
try {
client = new DaemonClient({ socketPath, tokenPath })
const outcomePromise = client
.ensureConnected()
.then(() => 'connected')
.catch((error: Error) => error.message)
await helloReceived
await vi.advanceTimersByTimeAsync(5000)
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('Hello response timed out')
} finally {
vi.useRealTimers()
}
})
it('bounds a waiter on an existing connection attempt and prevents socket resurrection', async () => {
let resolveHello: () => void = () => {}
const helloReceived = new Promise<void>((resolve) => {
resolveHello = resolve
})
await startMockDaemon({
suppressHelloResponse: true,
onHello: resolveHello
})
client = new DaemonClient({ socketPath, tokenPath })
const ownerAttempt = client.ensureConnected().catch((error: Error) => error)
await helloReceived
await expect(client.ensureConnectedWithin(25)).rejects.toThrow(
'Connection attempt wait timed out'
)
client.disconnect()
await expect(ownerAttempt).resolves.toBeInstanceOf(Error)
await new Promise((resolve) => setTimeout(resolve, 25))
const disconnected = client as unknown as {
controlSocket: Socket | null
streamSocket: Socket | null
}
expect(client.isConnected()).toBe(false)
expect(disconnected.controlSocket).toBeNull()
expect(disconnected.streamSocket).toBeNull()
})
it('removes hello startup listeners after timeout', async () => {
vi.useFakeTimers()
client = new DaemonClient({ socketPath, tokenPath })
const write = vi.fn(() => true)
const destroy = vi.fn()
const socket = new EventEmitter() as Socket
socket.write = write as unknown as Socket['write']
socket.destroy = destroy as unknown as Socket['destroy']
const sendHello = (
client as unknown as {
sendHello(
socket: Socket,
token: string,
role: 'control' | 'stream',
timeoutMs: number
): Promise<void>
}
).sendHello.bind(client)
const promise = sendHello(socket, 'test-token-123', 'control', 5000)
const rejection = expect(promise).rejects.toThrow('Hello response timed out')
await vi.advanceTimersByTimeAsync(5000)
await rejection
expect(write).toHaveBeenCalledOnce()
expect(destroy).toHaveBeenCalledOnce()
expect(socket.listenerCount('data')).toBe(0)
expect(socket.listenerCount('error')).toBe(0)
expect(socket.listenerCount('close')).toBe(0)
})
it('rejects when the daemon closes before hello completes', async () => {
await startMockDaemon({ closeOnHello: true })
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow(
'Connection closed before hello response'
)
})
it('rejects when the daemon closes immediately after connect', async () => {
await startMockDaemon({ closeOnConnect: true })
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow()
})
})
describe('RPC', () => {
it('rejects an oversized request before installing a timer or writing', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as {
controlSocket: Socket
pendingRequests: Map<string, unknown>
}
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
const timerSpy = vi.spyOn(globalThis, 'setTimeout')
try {
await expect(
client.request('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
).rejects.toBeInstanceOf(NdjsonLineTooLongError)
expect(timerSpy).not.toHaveBeenCalled()
expect(writeSpy).not.toHaveBeenCalled()
expect(internals.pendingRequests.size).toBe(0)
} finally {
timerSpy.mockRestore()
writeSpy.mockRestore()
}
})
it('sends request and receives response', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
const req = msg as { id: string; type: string }
if (req.type === 'listSessions') {
return encodeNdjson({
id: req.id,
ok: true,
payload: { sessions: [] }
})
}
return null
}
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const result = await client.request('listSessions', undefined)
expect(result).toEqual({ sessions: [] })
})
it('rejects on error response', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
const req = msg as { id: string; type: string }
return encodeNdjson({
id: req.id,
ok: false,
error: 'Something went wrong'
})
}
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await expect(client.request('listSessions', undefined)).rejects.toThrow(
'Something went wrong'
)
})
it('adds recovery hints to node-pty daemon diagnostics', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
const req = msg as { id: string; type: string }
return encodeNdjson({
id: req.id,
ok: false,
error:
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
})
}
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await expect(client.request('listSessions', undefined)).rejects.toThrow(
"Daemon's node-pty install is gone (worktree deleted?). Restart Orca. node-pty: posix_spawn failed: ENOENT"
)
})
})
describe('events', () => {
it('receives stream events', async () => {
let streamSocket: Socket | null = null
await startMockDaemon({
onStreamHello: () => {
// We need to capture the stream socket to send events on it
}
})
// Capture stream socket from server
const origListener = server.listeners('connection')[0] as (s: Socket) => void
server.removeAllListeners('connection')
let socketCount = 0
server.on('connection', (socket) => {
socketCount++
if (socketCount === 2) {
streamSocket = socket
}
origListener(socket)
})
const events: DaemonEvent[] = []
client = new DaemonClient({ socketPath, tokenPath })
client.onEvent((event) => events.push(event as DaemonEvent))
await client.ensureConnected()
await waitFor(() => streamSocket !== null)
// Send a data event on the stream socket
const event: DaemonEvent = {
type: 'event',
event: 'data',
sessionId: 'session-1',
payload: { data: 'hello from daemon' }
}
streamSocket!.write(encodeNdjson(event))
await waitFor(() => events.length > 0)
expect(events[0]).toMatchObject({
type: 'event',
event: 'data',
sessionId: 'session-1'
})
})
it('preserves UTF-8 stream events split inside multibyte characters', async () => {
let streamSocket: Socket | null = null
await startMockDaemon()
const origListener = server.listeners('connection')[0] as (s: Socket) => void
server.removeAllListeners('connection')
let socketCount = 0
server.on('connection', (socket) => {
socketCount++
if (socketCount === 2) {
streamSocket = socket
}
origListener(socket)
})
const events: DaemonEvent[] = []
client = new DaemonClient({ socketPath, tokenPath })
client.onEvent((event) => events.push(event as DaemonEvent))
await client.ensureConnected()
await waitFor(() => streamSocket !== null)
const tableRow = '│OpenCode│🧩│┼────────┤'
const event: DaemonEvent = {
type: 'event',
event: 'data',
sessionId: 'session-1',
payload: { data: tableRow }
}
const [first, second] = splitInsideUtf8Sequence(encodeNdjson(event), '🧩')
streamSocket!.write(first)
streamSocket!.write(second)
await waitFor(() => events.length > 0)
expect(events[0]).toMatchObject({
type: 'event',
event: 'data',
sessionId: 'session-1',
payload: { data: tableRow }
})
expect(JSON.stringify(events[0])).not.toContain('\ufffd')
})
})
describe('disconnect', () => {
it('removes socket listeners when disconnecting', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const connectedClient = client as unknown as {
controlSocket: Socket | null
streamSocket: Socket | null
}
const sockets = [connectedClient.controlSocket, connectedClient.streamSocket]
for (const socket of sockets) {
expect(socket?.listenerCount('data')).toBe(1)
expect(socket?.listenerCount('close')).toBe(1)
expect(socket?.listenerCount('error')).toBe(1)
}
client.disconnect()
for (const socket of sockets) {
expect(socket?.listenerCount('data')).toBe(0)
expect(socket?.listenerCount('close')).toBe(0)
expect(socket?.listenerCount('error')).toBe(0)
}
})
it('emits disconnected when server destroys sockets', async () => {
const serverSockets: Socket[] = []
await startMockDaemon()
server.on('connection', (socket) => serverSockets.push(socket))
client = new DaemonClient({ socketPath, tokenPath })
const disconnected = vi.fn()
client.onDisconnected(disconnected)
await client.ensureConnected()
// Wait for both sockets to be tracked
await waitFor(() => serverSockets.length >= 2)
// Destroy all server-side sockets to simulate daemon crash
for (const socket of serverSockets) {
socket.destroy()
}
await waitFor(() => disconnected.mock.calls.length > 0, 3000)
expect(client.isConnected()).toBe(false)
})
it('disconnect() can be called safely when not connected', () => {
client = new DaemonClient({ socketPath, tokenPath })
expect(() => client.disconnect()).not.toThrow()
})
})
describe('notify (fire-and-forget)', () => {
it('sends request with notify_ prefix without expecting response', async () => {
const received: unknown[] = []
await startMockDaemon({
onControlMessage: (msg) => {
received.push(msg)
return null // no response
}
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const delivered = client.notify('write', { sessionId: 'session-1', data: 'hello' })
expect(delivered).toBe(true)
await waitFor(() => received.length > 0)
const msg = received[0] as { id: string; type: string }
expect(msg.id).toMatch(/^notify_/)
expect(msg.type).toBe('write')
})
it('reports a dropped delivery when not connected', () => {
// Why: STA-2373 relies on this false to detect a write silently swallowed by a dead socket.
client = new DaemonClient({ socketPath, tokenPath })
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
})
it('reports a dropped delivery for an oversized payload without writing', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as { controlSocket: Socket }
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
expect(client.notify('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })).toBe(false)
expect(writeSpy).not.toHaveBeenCalled()
})
it('reports a dropped delivery when the socket write throws', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as { controlSocket: Socket }
vi.spyOn(internals.controlSocket, 'write').mockImplementation(() => {
throw new Error('EPIPE')
})
// Swallowed, not rethrown: a dead socket must not tear down the caller.
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
})
})
})