From b83e76f01e66fa9d2467a2fb2fa12b5e1b7be62e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:36:49 -0700 Subject: [PATCH] Keep Hermes and Antigravity helper IDs out of terminal recovery (#36349) * fix(copilot): keep helper IDs out of pane recovery * test(copilot): use recorded root hook shape and fixed clocks * test(copilot): fail on rejected hooks before ingesting them * fix(hermes): bind terminal recovery only from root hooks * fix(antigravity): derive terminal recovery from conversation origin * Preserve hook event typing and verify bounded transcript reads * fix: retain accepted hook session binding through reentry * test: use controlled time for hook binding fixtures * fix: preserve hook binding type and test boundaries --- ...server-antigravity-resume-identity.test.ts | 215 +++++++++ ...erver-antigravity-status-admission.test.ts | 98 ++++ .../server-hermes-resume-identity.test.ts | 216 +++++++++ .../server-hook-session-binding.test.ts | 431 ++++++++++++++++++ .../server/server-ingest-terminal.ts | 3 +- .../server/server-status-update.ts | 66 +-- src/main/agent-hooks/server/server-types.ts | 1 + ...k-session-binding-renderer.test-fixture.ts | 70 +++ .../agent-hook-listener-antigravity.test.ts | 2 +- ...t-hook-listener-hermes-codex-droid.test.ts | 2 +- src/shared/agent-hook-listener.ts | 60 +-- .../agent-hook-listener/hook-envelope.ts | 9 + .../agent-hook-listener/listener-event.ts | 2 + .../pane-provider-session.ts | 134 +++++- .../providers/hermes-events.ts | 10 +- .../transcript-reader.test.ts | 63 ++- .../agent-hook-listener/transcript-reader.ts | 34 ++ src/shared/agent-hook-presence-transition.ts | 108 ++++- ...agent-hook-session-binding.test-fixture.ts | 12 + src/shared/agent-process-presence.ts | 12 +- src/wsl-guest/agent-hook-envelope-build.ts | 8 +- src/wsl-guest/agent-hook-event-admission.ts | 8 +- .../agent-hook-provider-normalization.ts | 5 +- 23 files changed, 1466 insertions(+), 103 deletions(-) create mode 100644 src/main/agent-hooks/server-antigravity-resume-identity.test.ts create mode 100644 src/main/agent-hooks/server-antigravity-status-admission.test.ts create mode 100644 src/main/agent-hooks/server-hermes-resume-identity.test.ts create mode 100644 src/main/agent-hooks/server-hook-session-binding.test.ts create mode 100644 src/renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture.ts create mode 100644 src/shared/agent-hook-session-binding.test-fixture.ts diff --git a/src/main/agent-hooks/server-antigravity-resume-identity.test.ts b/src/main/agent-hooks/server-antigravity-resume-identity.test.ts new file mode 100644 index 00000000000..5996e15d32f --- /dev/null +++ b/src/main/agent-hooks/server-antigravity-resume-identity.test.ts @@ -0,0 +1,215 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { resumeRecordFromStatus } from '../../shared/agent-resume-record-from-status' +import { getAgentResumeArgv } from '../../shared/agent-session-resume' +import { TRANSCRIPT_MAX_SCAN_BYTES } from '../../shared/agent-hook-listener/transcript-reader' +import { AgentHookServer } from './server' +import { buildBody, PANE } from './server.test-fixtures' + +// Exact origin metadata and hook shape captured from agy 1.4.11; paths/IDs are test-local. +const ROOT = '6bba92d4-47e1-440a-9571-b12bb2d474bc' +const CHILD = '2e290489-f9b1-432b-807d-f5f2acdf9421' +const ROOT_ORIGIN = { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + content: 'Reply with OK.' +} +const CHILD_ORIGIN = { + step_index: 0, + source: 'SYSTEM', + type: 'SYSTEM_MESSAGE', + content: 'sender=0ec29085-37fc-4105-b0a9-9afb536af06c' +} +const CHILD_HOOKS = [ + { hook_event_name: 'PreInvocation', initialNumSteps: 0, invocationNum: 0 }, + { hook_event_name: 'PreToolUse', toolCall: { name: 'read_file', args: {} } }, + { hook_event_name: 'PostInvocation', invocationNum: 0 }, + { hook_event_name: 'Stop', fullyIdle: true, terminationReason: 'NO_TOOL_CALL', error: '' } +] + +function jsonl(...records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +function normalized(payload: Record, state = createHookListenerState()) { + const event = normalizeHookPayload(state, 'antigravity', buildBody(payload), 'production') + if (!event) { + throw new Error('Missing Antigravity event') + } + return event +} + +function hook(transcriptPath: string, conversationId = ROOT, extra: Record = {}) { + return { + hook_event_name: 'PreInvocation', + conversationId, + transcriptPath, + lastUserInput: 'Reply with OK.', + modelName: 'gemini-3.8-flash-high', + ...extra + } +} + +function expectResume(server: AgentHookServer, id = ROOT) { + const status = server.getStatusSnapshot()[0] + if (!status) { + throw new Error('Missing status') + } + expect(status).not.toHaveProperty('statusAdmissionSession') + const record = resumeRecordFromStatus({ + entry: { ...status, updatedAt: status.receivedAt }, + worktreeId: 'wt-1', + capturedAt: status.receivedAt + }) + expect(record?.providerSession.id).toBe(id) + if (!record) { + throw new Error('Missing resume record') + } + expect(getAgentResumeArgv('antigravity', record.providerSession)).toEqual([ + 'agy', + '--conversation', + id + ]) +} + +it.each(CHILD_HOOKS.map((_, index) => index))( + 'keeps persisted root across cold child suffix %s', + async (start) => { + const dir = mkdtempSync(join(tmpdir(), 'agy-resume-')) + const rootPath = join(dir, 'root.jsonl') + const childPath = join(dir, 'child.jsonl') + writeFileSync(rootPath, jsonl(ROOT_ORIGIN)) + writeFileSync(childPath, jsonl(CHILD_ORIGIN, ROOT_ORIGIN)) + let server = new AgentHookServer() + try { + await server.start({ env: 'production', userDataPath: dir }) + server.ingestRemote(normalized(hook(rootPath)), 'host-1') + server.stop() + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + for (const suffix of CHILD_HOOKS.slice(start)) { + const event = normalized(hook(childPath, CHILD, { lastUserInput: '', ...suffix })) + expect(event.providerSession).toBeUndefined() + server.ingestRemote(event, 'host-1') + expectResume(server) + } + server.stop() + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + expectResume(server) + server.ingestRemote(normalized(hook(rootPath, 'later-root')), 'host-1') + expectResume(server, 'later-root') + server.ingestRemote( + { ...normalized(hook(childPath, CHILD)), worktreeId: 'other-worktree' }, + 'host-1' + ) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + server.ingestRemote(normalized(hook(childPath, CHILD)), 'host-2') + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + server.ingestRemote(normalized(hook(childPath, CHILD)), 'host-1') + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } +) + +it('derives root identity from bounded first-record provenance, independently of latest input', () => { + const dir = mkdtempSync(join(tmpdir(), 'agy-origin-')) + const transcriptPath = join(dir, 'transcript.jsonl') + const server = new AgentHookServer() + try { + const accepts = (first: unknown, lastUserInput?: unknown) => { + writeFileSync(transcriptPath, jsonl(first, ROOT_ORIGIN)) + return normalized(hook(transcriptPath, ROOT, { lastUserInput })) + } + for (const first of [CHILD_ORIGIN, { source: 'USER', type: 'REQUEST' }, {}, null]) { + const event = accepts(first, 'nonempty input grants no authority') + expect(event.providerSession).toBeUndefined() + server.ingestRemote(event, null) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + } + for (const lastUserInput of ['Reply with OK.', '', undefined, null]) { + expect(accepts(ROOT_ORIGIN, lastUserInput).providerSession?.id).toBe(ROOT) + } + const state = createHookListenerState() + for (const text of [ + '', + '{"source":"USER_EXPLICIT"', + `malformed\n${JSON.stringify(ROOT_ORIGIN)}`, + jsonl({ ...ROOT_ORIGIN, content: 'x'.repeat(TRANSCRIPT_MAX_SCAN_BYTES) }) + ]) { + writeFileSync(transcriptPath, text) + expect(normalized(hook(transcriptPath), state).providerSession).toBeUndefined() + } + rmSync(transcriptPath) + expect(normalized(hook(transcriptPath), state).providerSession).toBeUndefined() + writeFileSync(transcriptPath, jsonl(ROOT_ORIGIN)) + expect(normalized(hook(dir), state).providerSession).toBeUndefined() + const root = normalized(hook(transcriptPath), state) + server.ingestRemote(root, null) + expectResume(server) + rmSync(transcriptPath) + server.ingestRemote( + normalized(hook(transcriptPath, ROOT, { hook_event_name: 'Stop', fullyIdle: true }), state), + null + ) + expectResume(server) + server.ingestTerminalStatus({ + paneKey: PANE, + worktreeId: 'wt-1', + origin: 'process', + payload: { state: 'working', prompt: '', agentType: 'antigravity' } + }) + server.ingestRemote(normalized(hook(transcriptPath, CHILD)), null) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +it('keeps warm-child dropping and helper-held completion status unchanged', () => { + const dir = mkdtempSync(join(tmpdir(), 'agy-status-')) + const rootPath = join(dir, 'root.jsonl') + const childPath = join(dir, 'child.jsonl') + writeFileSync(rootPath, jsonl(ROOT_ORIGIN)) + writeFileSync(childPath, jsonl(CHILD_ORIGIN)) + const state = createHookListenerState() + try { + normalized( + hook(rootPath, ROOT, { + hook_event_name: 'PreToolUse', + toolCall: { name: 'invoke_subagent', args: {} } + }), + state + ) + expect( + normalizeHookPayload( + state, + 'antigravity', + buildBody(hook(childPath, CHILD, { lastUserInput: '' })), + 'production' + ) + ).toBeNull() + const held = normalized( + hook(rootPath, ROOT, { hook_event_name: 'Stop', fullyIdle: false }), + state + ) + expect(held.payload).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(held.providerSession?.id).toBe(ROOT) + const cold = normalized( + hook(childPath, CHILD, { lastUserInput: '', hook_event_name: 'Stop', fullyIdle: true }) + ) + expect(cold.payload.state).toBe('done') + expect(cold.providerSession).toBeUndefined() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/main/agent-hooks/server-antigravity-status-admission.test.ts b/src/main/agent-hooks/server-antigravity-status-admission.test.ts new file mode 100644 index 00000000000..93459a5d5ae --- /dev/null +++ b/src/main/agent-hooks/server-antigravity-status-admission.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' +import { AgentHookServer } from './server' +import { buildBody, postHookEvent } from './server.test-fixtures' + +it.each(['local', 'relay'] as const)( + 'retains Antigravity status admission without promoting a raw locator over %s', + async (transport) => { + const dir = mkdtempSync(join(tmpdir(), 'agy-admission-')) + const transcriptPath = join(dir, 'transcript.jsonl') + const root = '6bba92d4-47e1-440a-9571-b12bb2d474bc' + writeFileSync( + transcriptPath, + `${JSON.stringify({ source: 'USER_EXPLICIT', type: 'USER_INPUT', content: 'Reply with OK.' })}\n` + ) + const server = new AgentHookServer() + const forwarded: AgentHookRelayEnvelope[] = [] + const relay = new RelayAgentHookServer({ + endpointDir: dir, + forward: (event) => { + forwarded.push(event) + server.ingestRemote(event, 'host-1') + } + }) + try { + await server.start({ env: 'production' }) + if (transport === 'relay') { + await relay.start() + } + const post = async (source: 'antigravity' | 'codex', payload: Record) => { + const body = buildBody(payload) + const response = + transport === 'local' + ? await postHookEvent(server, body, `/hook/${source}`) + : await fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': relay.getCoordinates().token + }, + body: JSON.stringify(body) + }) + expect(response.status).toBe(204) + } + const agy = (conversationId: string, extra: Record = {}) => ({ + hook_event_name: 'PreInvocation', + conversationId, + transcriptPath, + lastUserInput: 'Reply with OK.', + ...extra + }) + await post('antigravity', agy(root)) + await post('antigravity', agy(root, { hook_event_name: 'Stop', fullyIdle: true })) + rmSync(transcriptPath) + await post('antigravity', agy('unknown-child', { lastUserInput: '' })) + const delayedUnknown = forwarded.at(-1) + await post('antigravity', agy(root, { hook_event_name: 'Stop', fullyIdle: true })) + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'nested', + prompt: 'review' + }) + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + if (transport === 'relay') { + if (!delayedUnknown) { + throw new Error('Missing child relay event') + } + expect(delayedUnknown.agentPresence).toEqual({ + agent: 'antigravity', + session: 'unknown-child' + }) + server.ingestRemote(delayedUnknown, 'host-1') + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + } + await post('antigravity', agy('unknown-child', { lastUserInput: '' })) + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + await post('antigravity', agy(root)) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'antigravity', + state: 'working' + }) + expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ + key: 'conversation_id', + id: root + }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('statusAdmissionSession') + expect(JSON.stringify(forwarded)).not.toContain('statusAdmissionSession') + } finally { + server.stop() + relay.stop() + rmSync(dir, { recursive: true, force: true }) + } + } +) diff --git a/src/main/agent-hooks/server-hermes-resume-identity.test.ts b/src/main/agent-hooks/server-hermes-resume-identity.test.ts new file mode 100644 index 00000000000..5370a6ecdf1 --- /dev/null +++ b/src/main/agent-hooks/server-hermes-resume-identity.test.ts @@ -0,0 +1,216 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { resumeRecordFromStatus } from '../../shared/agent-resume-record-from-status' +import { getAgentResumeArgv } from '../../shared/agent-session-resume' +import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +const ROOT = 'local-parent' +const CHILD = '20261024_child' +// Order captured from a real Hermes delegate_task run; tools/end omit platform. +const HOOKS = [ + { hook_event_name: 'on_session_start', session_id: CHILD, platform: 'subagent' }, + { hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'subagent' }, + { hook_event_name: 'pre_tool_call', session_id: CHILD, tool_name: 'read_file' }, + { hook_event_name: 'post_llm_call', session_id: CHILD, platform: 'subagent' }, + { hook_event_name: 'on_session_end', session_id: CHILD } +] + +function normalize(payload: Record) { + const event = normalizeHookPayload( + createHookListenerState(), + 'hermes', + buildBody(payload), + 'production' + ) + if (!event) { + throw new Error('Hermes hook was rejected') + } + return event +} + +function root(id = ROOT, platform = 'cli', hook_event_name = 'pre_llm_call') { + return normalize({ hook_event_name, session_id: id, platform, user_message: 'delegate it' }) +} + +function expectResume(server: AgentHookServer, id = ROOT) { + const status = server.getStatusSnapshot()[0] + if (!status) { + throw new Error('Missing Hermes status') + } + expect(status).not.toHaveProperty('statusAdmissionSession') + const record = resumeRecordFromStatus({ + entry: { ...status, updatedAt: status.receivedAt }, + worktreeId: 'wt-1', + capturedAt: status.receivedAt + }) + expect(record?.providerSession.id).toBe(id) + if (!record) { + throw new Error('Missing Hermes resume record') + } + expect(getAgentResumeArgv('hermes', record.providerSession)).toEqual(['hermes', '--resume', id]) +} + +it.each(HOOKS.map((_, index) => index))( + 'keeps the persisted root after restart before child hook %s', + async (start) => { + const dir = mkdtempSync(join(tmpdir(), 'hermes-resume-identity-')) + let server = new AgentHookServer() + try { + await server.start({ env: 'production', userDataPath: dir }) + server.ingestRemote(root(), 'host-1') + server.stop() + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + for (const hook of HOOKS.slice(start)) { + const event = normalize(hook) + expect(event.providerSession).toBeUndefined() + server.ingestRemote(event, 'host-1') + expectResume(server) + } + server.stop() + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + expectResume(server) + server.ingestRemote(root('selected-root', 'tui', 'on_session_reset'), 'host-1') + expectResume(server, 'selected-root') + server.ingestRemote(normalize(HOOKS[2]), 'host-2') + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } +) + +it('requires root authority, never creates an unmarked tool locator, and respects host reset', () => { + const server = new AgentHookServer() + server.ingestRemote(normalize(HOOKS[2]), null) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + for (const platform of ['cli', 'tui', 'desktop', 'acp']) { + server.ingestRemote(root(platform, platform), null) + expectResume(server, platform) + } + for (const platform of ['', 'subagent', 'telegram', undefined]) { + const event = normalize({ hook_event_name: 'pre_llm_call', session_id: CHILD, platform }) + expect(event.providerSession).toBeUndefined() + server.ingestRemote(event, null) + expectResume(server, 'acp') + } + // An older normalized relay still exposes the non-authoritative event kind. + server.ingestRemote({ ...root(CHILD), hookEventName: 'pre_tool_call' }, null) + expectResume(server, 'acp') + server.ingestRemote(normalize({ hook_event_name: 'on_session_end', session_id: 'acp' }), null) + server.ingestTerminalStatus({ + paneKey: PANE, + worktreeId: 'wt-1', + origin: 'process', + payload: { state: 'working', prompt: '', agentType: 'hermes' } + }) + server.ingestRemote(normalize(HOOKS[2]), null) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + server.stop() +}) + +it.each(['local', 'relay'] as const)( + 'preserves root status re-entry without tool-derived restore identity over %s', + async (transport) => { + const dir = mkdtempSync(join(tmpdir(), 'hermes-status-admission-')) + const server = new AgentHookServer() + const forwarded: AgentHookRelayEnvelope[] = [] + const relay = new RelayAgentHookServer({ + endpointDir: dir, + forward: (event) => { + forwarded.push(event) + server.ingestRemote(event, 'host-1') + } + }) + try { + await server.start({ env: 'production' }) + if (transport === 'relay') { + await relay.start() + } + const post = async (source: 'hermes' | 'codex', payload: Record) => { + const body = buildBody(payload) + const response = + transport === 'local' + ? await postHookEvent(server, body, `/hook/${source}`) + : await fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': relay.getCoordinates().token + }, + body: JSON.stringify(body) + }) + expect(response.status).toBe(204) + } + await post('hermes', { hook_event_name: 'pre_llm_call', session_id: ROOT, platform: 'cli' }) + await post('hermes', { + hook_event_name: 'pre_llm_call', + session_id: CHILD, + platform: 'subagent' + }) + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) + await post('hermes', { + hook_event_name: 'post_tool_call', + session_id: 'unknown-child', + tool_name: 'read_file' + }) + const delayedUnknown = forwarded.at(-1) + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'nested', + prompt: 'review' + }) + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + if (transport === 'relay') { + if (!delayedUnknown) { + throw new Error('Missing relay child event') + } + expect(delayedUnknown.agentPresence).toEqual({ agent: 'hermes', session: 'unknown-child' }) + server.ingestRemote(delayedUnknown, 'host-1') + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + } + await post('hermes', { + hook_event_name: 'post_tool_call', + session_id: 'unknown-child', + tool_name: 'read_file' + }) + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + await post('hermes', { + hook_event_name: 'post_tool_call', + session_id: ROOT, + tool_name: 'delegate_task' + }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType: 'hermes', state: 'working' }) + expectResume(server) + // Existing warm-child status admission uses the same remembered parent, without binding its ID. + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) + expect(server.getStatusSnapshot()[0]?.state).toBe('done') + await post('hermes', { hook_event_name: 'pre_llm_call', session_id: ROOT, platform: 'cli' }) + expectResume(server) + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'nested', + prompt: 'review' + }) + // Positive authority must replace a formerly substituted child before either admission gate. + await post('hermes', { hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'cli' }) + expectResume(server, CHILD) + expect(JSON.stringify(forwarded)).not.toContain('statusAdmissionSession') + } finally { + server.stop() + relay.stop() + rmSync(dir, { recursive: true, force: true }) + } + } +) diff --git a/src/main/agent-hooks/server-hook-session-binding.test.ts b/src/main/agent-hooks/server-hook-session-binding.test.ts new file mode 100644 index 00000000000..57a5426b70f --- /dev/null +++ b/src/main/agent-hooks/server-hook-session-binding.test.ts @@ -0,0 +1,431 @@ +import type { BrowserWindow } from 'electron' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { HookSessionBindingRendererFixture } from '../../shared/agent-hook-session-binding.test-fixture' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + createHookListenerState, + admitLegacyAgentStatus +} from '../../shared/agent-hook-listener/listener-state' +import { readAgentProcessPresence } from '../../shared/agent-process-presence' +import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../shared/agent-status-legacy-adapter' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { buildRelayHookEnvelope } from '../../wsl-guest/agent-hook-envelope-build' +import { RelayProviderHookNormalization } from '../../wsl-guest/agent-hook-provider-normalization' +import { hookRowPaneResumeRecord } from '../runtime/session-lifecycle/projection/hook-row-pane-resume-record' +import { + hookStatusResumeCommand, + quitResumeRecords, + resumeStatusFromHookRow +} from '../runtime/terminal/stop/hook-status-resume-records' +import { + installMainWindowAgentStatusListeners, + clearMainWindowAgentStatusListeners +} from '../startup/main-window-agent-status' +import { agentHookServer as server } from './server' +import { buildBody, PANE, GOOD_PANE, postHookEvent } from './server.test-fixtures' + +const bridge = vi.hoisted(() => ({ + send: vi.fn<(channel: string, data: AgentStatusIpcPayload) => void>() +})) +vi.mock('../startup/main-process-state', () => ({ + mainProcessState: { mainWindow: { isDestroyed: () => false, webContents: bridge }, runtime: null } +})) +vi.mock('../window/dashboard-popout-window', () => ({ getDashboardPopoutWindow: () => null })) +vi.mock('../startup/synthetic-title-runtime', () => ({ stopSyntheticTitleSpinnerTimer: () => {} })) + +const rendererFixture: unknown = await vi.importActual( + '../../renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture' +) +function isRendererFixture(value: unknown): value is HookSessionBindingRendererFixture { + return ( + typeof value === 'object' && + value !== null && + 'expectReturnedRenderer' in value && + typeof value.expectReturnedRenderer === 'function' + ) +} +if (!isRendererFixture(rendererFixture)) { + throw new Error('Missing renderer hook binding assertion fixture') +} +const { expectReturnedRenderer } = rendererFixture + +const ROOT = 'ordinary-root-B' +const CHILD = 'delegated-child-A' +let dir: string + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'hook-session-binding-')) + bridge.send.mockClear() + await restart() +}) +afterEach(() => { + vi.restoreAllMocks() + clearMainWindowAgentStatusListeners() + server.stop() + rmSync(dir, { recursive: true, force: true }) +}) + +async function restart() { + await server.start({ env: 'production', userDataPath: dir }) + installMainWindowAgentStatusListeners({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only mocked WebContents is used; window is an unused option. + window: {} as BrowserWindow, + onRecordAgentState: () => {} + }) +} +async function post(source: 'hermes' | 'codex' | 'antigravity', payload: Record) { + expect((await postHookEvent(server, buildBody(payload), `/hook/${source}`)).status).toBe(204) +} +function frame() { + const call = bridge.send.mock.calls.findLast(([channel]) => channel === 'agentStatus:set') + if (!call) { + throw new Error('No main-window status publication') + } + return call[1] +} +async function positive(id = ROOT) { + await post('hermes', { + hook_event_name: 'pre_llm_call', + session_id: id, + platform: 'cli', + user_message: 'continue' + }) +} +async function childDone() { + await post('hermes', { hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'subagent' }) + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) +} +async function nested() { + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'nested-codex', + prompt: 'review' + }) + expect(frame()).toMatchObject({ + agentType: 'codex', + state: 'working', + providerSession: { id: 'nested-codex' } + }) +} +async function tool(id = ROOT) { + await post('hermes', { + hook_event_name: 'post_tool_call', + session_id: id, + tool_name: 'delegate_task' + }) +} +function expectReturned(id = ROOT, agent: 'hermes' | 'antigravity' = 'hermes') { + const data = frame() + expect(data).toMatchObject({ + agentType: agent, + state: 'working', + providerSession: { id }, + paneOwner: { agent, session: id } + }) + expect(server.getStatusSnapshot()[0]?.providerSession?.id).toBe(id) + const tab = expectReturnedRenderer(data, id, agent, PANE, 'wt-1') + const status = resumeStatusFromHookRow(data, undefined) + if (!status) { + throw new Error('No host resume status') + } + const session = { ...getDefaultWorkspaceSession(), tabsByWorktree: { 'wt-1': [tab] } } + const checkpoint = hookStatusResumeCommand({ + status, + session, + worktreeId: 'wt-1', + launchConfigOf: () => undefined + }) + expect(checkpoint?.type === 'sleep' && checkpoint.records[0]?.providerSession.id).toBe(id) + expect( + quitResumeRecords({ + statuses: [status], + session, + worktreeIdOf: () => 'wt-1', + launchConfigOf: () => undefined, + capturedAt: data.receivedAt + })[0]?.providerSession.id + ).toBe(id) + expect( + hookRowPaneResumeRecord({ rows: [data], worktreeId: 'wt-1', tab })?.providerSession.id + ).toBe(id) +} + +it.each(['sessionless', 'replacement', 'positive-after-foreign'] as const)( + 'settles the complete HTTP parent iteration after %s', + async (mode) => { + await post('hermes', { + hook_event_name: 'pre_tool_call', + session_id: ROOT, + tool_name: 'read_file' + }) + expect(frame().paneOwner).toBeUndefined() + const id = mode === 'sessionless' ? ROOT : mode === 'replacement' ? CHILD : 'successor-C' + if (mode === 'replacement') { + await positive(ROOT) + await childDone() + await nested() + } + if (mode === 'positive-after-foreign') { + await post('hermes', { hook_event_name: 'on_session_end', session_id: ROOT }) + await nested() + } + await positive(id) + expect(frame().paneOwner?.session).toBe(id) + // A different child avoids completing the renewed root when it was formerly a child itself. + await post('hermes', { + hook_event_name: 'pre_llm_call', + session_id: 'new-helper', + platform: 'subagent' + }) + await post('hermes', { hook_event_name: 'on_session_end', session_id: 'new-helper' }) + expect(frame().state).toBe('done') + await nested() + await tool('unrecognized-child') + expect(frame().agentType).toBe('codex') + await tool(id) + expectReturned(id) + if (mode === 'sessionless') { + await childDone() + await nested() + } + server.stop() + await restart() + await tool(id) + expectReturned(id) + } +) + +it('keeps root-own completion terminal even when its binding matches', async () => { + await positive() + await post('hermes', { hook_event_name: 'post_llm_call', session_id: ROOT, platform: 'cli' }) + await nested() + await tool() + expect(frame().agentType).toBe('codex') +}) + +it('retires the binding on an actual host clear, including through a later foreign row', async () => { + await positive() + await childDone() + server.ingestTerminalStatus({ + paneKey: PANE, + worktreeId: 'wt-1', + origin: 'process', + payload: { agentType: 'hermes', state: 'working', prompt: '' } + }) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + expect(frame().paneOwner).toBeUndefined() + await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD }) + await nested() + await tool() + expect(frame().agentType).toBe('codex') + server.stop() + await restart() + await tool() + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + await positive('fresh-C') + await childDone() + await nested() + await tool('fresh-C') + expectReturned('fresh-C') + server.recordLaunchResumeIdentity({ + paneKey: PANE, + worktreeId: 'wt-1', + agentType: 'hermes', + providerSession: { key: 'session_id', id: 'explicit-seed' }, + launchedAt: frame().receivedAt + 1 + }) + await tool('explicit-seed') + expectReturned('explicit-seed') +}) + +it.each([257, 512])( + 'matches only the actual hook against a %s-character unproven legacy tuple after hydration', + async (length) => { + const id = 'l'.repeat(length) + await positive('b'.repeat(length)) + expect(frame().providerSession?.id).toHaveLength(length) + await childDone() + await nested() + server.stop() + // Exact legacy representation: a helper ID with no positive observation of its own. + const path = join(dir, 'agent-hooks', 'last-status.json') + const saved = JSON.parse(readFileSync(path, 'utf8')) + saved.entries[PANE].agentPresence.session = id + writeFileSync(path, JSON.stringify(saved)) + await restart() + await tool('different-session') + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + await tool(id) + expectReturned(id) + expect(readAgentProcessPresence({ agent: 'hermes', session: id })?.session).toBe(id) + expect(readAgentProcessPresence({ agent: 'antigravity', session: id })?.session).toBe(id) + expect(readAgentProcessPresence({ agent: 'codex', session: id })?.session).toBeUndefined() + } +) + +it('reenters Antigravity from retained first-record authority when that file is temporarily unreadable', async () => { + const transcriptPath = join(dir, 'agy.jsonl') + const origin = { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + content: 'Reply with OK.' + } + const hook = (id: string, extra: Record = {}) => ({ + hook_event_name: 'PreInvocation', + conversationId: id, + transcriptPath, + lastUserInput: 'Reply with OK.', + ...extra + }) + writeFileSync(transcriptPath, `${JSON.stringify(origin)}\n`) + await post('antigravity', hook(ROOT)) + await post( + 'antigravity', + hook(ROOT, { + hook_event_name: 'Stop', + fullyIdle: true, + terminationReason: 'NO_TOOL_CALL', + error: '' + }) + ) + await nested() + const beforeRejectedRoot = server.getStatusSnapshot() + await post('antigravity', hook('rejected-C', { hook_event_name: 'SessionStart' })) + expect(server.getStatusSnapshot()).toEqual(beforeRejectedRoot) + rmSync(transcriptPath) + await post('antigravity', hook('child-A')) + expect(frame().agentType).toBe('codex') + await post('antigravity', hook(ROOT)) + expectReturned(ROOT, 'antigravity') + writeFileSync( + transcriptPath, + `${JSON.stringify({ step_index: 0, source: 'SYSTEM', type: 'SYSTEM_MESSAGE', content: 'sender=parent' })}\n` + ) + const cold = normalizeHookPayload( + createHookListenerState(), + 'antigravity', + buildBody(hook('child-A')), + 'production' + ) + expect(cold?.providerSession).toBeUndefined() +}) + +it('forwards a cold host comparison to the retained canonical binding without creating one', async () => { + await positive() + await childDone() + await nested() + const build = (id: string) => { + const canonical = createHookListenerState() + const event = new RelayProviderHookNormalization().normalize( + canonical, + 'hermes', + buildBody({ hook_event_name: 'post_tool_call', session_id: id, tool_name: 'delegate_task' }), + 'production' + ) + if (!event) { + throw new Error('No cold execution-host tool event') + } + return buildRelayHookEnvelope(event, 'hermes', 'production') + } + for (const id of ['child-A', ROOT]) { + const envelope = build(id) + expect(envelope.agentPresence).toEqual({ agent: 'hermes', session: id }) + expect(envelope.providerSession).toBeUndefined() + server.ingestRemote(envelope, null) + expect(frame().agentType).toBe(id === ROOT ? 'hermes' : 'codex') + } + expectReturned() + server.clearPaneState(PANE) + server.ingestRemote(build('unknown-child'), null) + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + expect(frame().paneOwner).toBeUndefined() +}) + +it('rejects before mutating provider folds and validates scoped IDs with the locator parser', () => { + const now = 1_700_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(now) + const state = createHookListenerState() + const previous = { + paneKey: PANE, + worktreeId: 'wt-1', + connectionId: null, + hostEvidenceObservedAt: now, + agentPresence: { agent: 'hermes', session: ROOT }, + payload: { agentType: 'codex', state: 'working' as const, prompt: 'review' } + } + admitLegacyAgentStatus( + state, + 'main-status-update', + previous, + AGENT_STATUS_2A_CURRENT_PRODUCER_MODE + ) + const body = buildBody({ + hook_event_name: 'pre_tool_call', + session_id: 'mismatch', + tool_name: 'write_file', + user_message: 'poison' + }) + expect(normalizeHookPayload(state, 'hermes', body, 'production')).toBeNull() + expect(state.lastPromptByPaneKey.size).toBe(0) + expect(state.lastToolByPaneKey.size).toBe(0) + expect(state.hermesTurnByPaneKey.size).toBe(0) + for (const session of ['x'.repeat(513), '-option', 'bad\nidentity']) { + expect(readAgentProcessPresence({ agent: 'hermes', session })?.session).toBeUndefined() + expect(readAgentProcessPresence({ agent: 'antigravity', session })?.session).toBeUndefined() + } +}) + +it.each([ + { connectionId: 'other-host', worktreeId: 'wt-1', paneKey: PANE, tabId: 'tab-1' }, + { connectionId: null, worktreeId: 'other-worktree', paneKey: PANE, tabId: 'tab-1' }, + { connectionId: null, worktreeId: 'wt-1', paneKey: GOOD_PANE, tabId: 'tab-good' } +])('does not project a binding across terminal scope %j', async (scope) => { + await positive() + await childDone() + await nested() + const event = normalizeHookPayload( + createHookListenerState(), + 'hermes', + buildBody({ hook_event_name: 'post_tool_call', session_id: ROOT, tool_name: 'delegate_task' }), + 'production' + ) + if (!event) { + throw new Error('Missing scope test tool') + } + server.ingestRemote({ ...buildRelayHookEnvelope(event, 'hermes'), ...scope }, scope.connectionId) + const rows = server.getStatusSnapshotForPane(scope.paneKey) + expect(rows[0]?.providerSession).toBeUndefined() +}) + +it('does not let positive authority bypass a different proven process owner', async () => { + const event = normalizeHookPayload( + createHookListenerState(), + 'claude', + buildBody({ hook_event_name: 'UserPromptSubmit', session_id: 'claude-owner', prompt: 'work' }), + 'production' + ) + if (!event) { + throw new Error('Missing Claude owner event') + } + server.ingestRemote( + { + ...event, + agentPresence: { + agent: 'claude', + sessionOwner: true, + process: { pid: 99, platform: 'linux', startTime: 'start' } + } + }, + null + ) + await positive('cannot-steal') + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'claude', + providerSession: { id: 'claude-owner' } + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 6859fa3ee26..43a3fda816f 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -315,7 +315,8 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.origin ?? 'osc', undefined, mutationBefore, - newRun + newRun, + !preservedProviderSession ) } } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index af1531b4153..c0b1bc7a09e 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,5 +1,6 @@ +import { retainHookRootContext } from '../../../shared/agent-hook-listener/pane-provider-session' import { deriveCliWriterTopology } from './cli-writer-observation' -import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' +import { previewHookPresence } from '../../../shared/agent-hook-presence-transition' import { reconcileCodexStatusEvent, markCodexLeadTurnInterrupted @@ -50,24 +51,22 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload, - newRun = false + newRun = false, + retireBinding = false ): EnrichedAgentHookEventPayload | undefined { - // Older relays can still forward a child's own session as the pane's resume identity. - const rootIdentityIncoming = - origin === 'hook' && incoming.payload.agentType === 'copilot' && incoming.toolAgentId - ? { ...incoming, providerSession: undefined } - : incoming - const transitioned = transitionHookPresence( - rootIdentityIncoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey) + const rootContext = previewHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey), + origin === 'hook' || origin === 'launch', + retireBinding ) + const { event: rootIdentityIncoming, statusAdmissionSession } = rootContext + const transitioned = rootContext.transitioned if (!transitioned) { return undefined } - const { authorityRestartId, codexRosterAuthority, ...payload } = { - ...rootIdentityIncoming, - ...transitioned - } + const enrichedTransition = { ...rootIdentityIncoming, ...transitioned } + const { authorityRestartId, codexRosterAuthority, ...payload } = enrichedTransition if (!this.canWriteLegacyStatusRow(payload)) { return undefined } @@ -123,13 +122,13 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA state: previous.payload.state, updatedAt: previous.receivedAt, restoredUnconfirmed: previous.restoredUnconfirmed, - agentPresence: previous.providerSessionOnly ? undefined : previous.agentPresence + agentPresence: previous.providerSessionOnly ? undefined : rootContext.owner } : undefined, incoming: terminalOwnedPayload.payload.agentType, - // Relay presence is the execution host's owner verdict, not the sender's identity. + // Relay process presence is the host's owner verdict; scoped session bytes only compare this hook. incomingProcess: payload.connectionId ? undefined : incoming.agentPresence?.process, - incomingSession: terminalOwnedPayload.providerSession?.id, + incomingSession: statusAdmissionSession, now }) if ( @@ -178,33 +177,14 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA previous?.payload, codexRosterAuthority ) - const previousRoot = - previous && - previous.payload.agentType === stateReconciledPayload.payload.agentType && - ((stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId) || - (origin === 'hook' && - stateReconciledPayload.payload.agentType === 'copilot' && - this.sameTerminalOwner(previous, stateReconciledPayload))) - ? previous - : undefined - const preservedProviderSession = - !stateReconciledPayload.providerSession && previousRoot?.providerSession - const preservedRootModel = - stateReconciledPayload.payload.agentType === 'codex' && !stateReconciledPayload.payload.model - ? previousRoot?.payload.model - : undefined // Relay restart loses root context; child hooks must not erase persisted resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload + const rootContextPreservingPayload = retainHookRootContext( + stateReconciledPayload, + previous, + origin === 'hook', + !!previous && this.sameTerminalOwner(previous, stateReconciledPayload), + rootContext.returningProviderSession + ) const identityResolvedPayload = identity.agentType === rootContextPreservingPayload.payload.agentType ? rootContextPreservingPayload diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 9e3773657ae..0b2836727a5 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -42,6 +42,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' + | 'statusAdmissionSession' | 'launchToken' | 'hostTurnRevision' | 'promptInteractionKey' diff --git a/src/renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture.ts b/src/renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture.ts new file mode 100644 index 00000000000..307e2b739bb --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture.ts @@ -0,0 +1,70 @@ +import { expect } from 'vitest' +import type { HookSessionBindingRendererFixture } from '../../../../../shared/agent-hook-session-binding.test-fixture' +import { getAgentResumeArgv } from '../../../../../shared/agent-session-resume' +import { resumeRecordFromStatus } from '../../../../../shared/agent-resume-record-from-status' +import { createTestStore, makeTab } from '../store-test-helpers' +import { isInheritedFromActivePane } from '../../../hooks/ipc-events/agent-status-routing' +import { sleepingRecordFromEntry } from './agent-status-sleeping-records' + +export const expectReturnedRenderer: HookSessionBindingRendererFixture['expectReturnedRenderer'] = ( + data, + id, + agent, + paneKey, + worktreeId +) => { + // Both renderer admission paths consume the actual main-window publication over a foreign live row. + const store = createTestStore() + const tab = makeTab({ id: 'tab-1', worktreeId: worktreeId }) + store.setState({ tabsByWorktree: { [worktreeId]: [tab] } }) + store + .getState() + .setAgentStatus( + paneKey, + { agentType: 'codex', state: 'working', prompt: 'review' }, + undefined, + { + updatedAt: data.receivedAt - 1 + } + ) + expect( + isInheritedFromActivePane(store.getState().agentStatusByPaneKey[paneKey], data, agent, { + connectionId: data.connectionId, + worktreeId: data.worktreeId + }) + ).toBe(false) + store + .getState() + .setAgentStatus( + paneKey, + data, + undefined, + { updatedAt: data.receivedAt }, + { connectionId: data.connectionId, worktreeId: data.worktreeId }, + { providerSession: data.providerSession, paneOwner: data.paneOwner } + ) + const entry = store.getState().agentStatusByPaneKey[paneKey] + expect(entry).toMatchObject({ agentType: agent, providerSession: { id } }) + const record = resumeRecordFromStatus({ + entry, + worktreeId: worktreeId, + capturedAt: data.receivedAt + }) + expect(record?.providerSession.id).toBe(id) + if (!record) { + throw new Error('No actual renderer resume record') + } + expect(getAgentResumeArgv(agent, record.providerSession)).toEqual( + agent === 'hermes' ? ['hermes', '--resume', id] : ['agy', '--conversation', id] + ) + expect(store.getState().sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id).toBe(id) + expect( + sleepingRecordFromEntry({ + state: store.getState(), + entry, + worktreeId: worktreeId, + capturedAt: data.receivedAt + })?.providerSession.id + ).toBe(id) + return tab +} diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index fa1ec17efbb..15f62b9a928 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -483,7 +483,7 @@ describe('shared agent-hook-listener', () => { expect(lateTool).toBeNull() }) - // Event order captured from agy 1.4.7 running an in-process invoke_subagent (#17650). + // Event order captured from agy 1.4.7 (#17650); transcript contents below are synthetic, not origin evidence. it('keeps an Antigravity parent working until its own idle Stop when a subagent finishes', () => { const tmpDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-subagent-')) const writeTranscript = (name: string, request: string, reply: string): string => { diff --git a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts index 506dd687687..69d91f8a3b8 100644 --- a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts +++ b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts @@ -340,6 +340,6 @@ describe('shared agent-hook-listener', () => { 'production' )?.providerSession?.id ?? null ) - expect(sessions).toEqual(Array(10).fill('local-parent')) + expect(sessions).toEqual(['local-parent', 'local-parent', ...Array(8).fill(null)]) }) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 2836d384bf0..ba39126391f 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1,6 +1,5 @@ import { readHookAgentProcess } from './agent-process-presence' -import { resolveAgentStatusIdentity } from './agent-status-identity' -import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner' +import { isInheritedHookStatus } from './agent-hook-presence-transition' import { normalizeAgentStatusPayload, type AgentMainAgentStatus } from './agent-status-types' import type { AgentHookSource } from './agent-hook-relay' import { @@ -28,7 +27,10 @@ import { } from './agent-hook-listener/opencode-session-registry' import { claudeRowHasUnlistedLiveWork } from './agent-hook-listener/providers/claude-pane-hold-evidence' import { readString } from './agent-hook-listener/tool-input-preview' -import { readPaneProviderSession } from './agent-hook-listener/pane-provider-session' +import { + hasSeparateHookSessionAdmission, + readPaneSessionContext +} from './agent-hook-listener/pane-provider-session' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ const CLAUDE_EXIT_SESSION_END_REASONS = new Set([ 'prompt_input_exit', @@ -45,6 +47,7 @@ export function normalizeHookPayload( options: { deferCompactOwnershipToClient?: boolean previousOpenCodeMainAgent?: AgentMainAgentStatus + bindingStatusByPaneKey?: ReadonlyMap admitOpenCodeTui?: ( identity: Pick< AgentHookEventPayload, @@ -59,6 +62,7 @@ export function normalizeHookPayload( } const { record, + eventName, paneKey: stampedPaneKey, hookPayloadRecord, tabId: stampedTabId, @@ -68,13 +72,8 @@ export function normalizeHookPayload( if (source === 'claude') { state.claudeUnconfirmedRestoredStatusPaneKeys.delete(stampedPaneKey) } - const eventName = - readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? - hookPayloadRecord.hook_event_name ?? - hookPayloadRecord.hookEventName ?? - // Only jcode posts lifecycle under event; other providers may use that field for unrelated data. - (source === 'jcode' ? hookPayloadRecord.event : undefined) - const providerSession = readPaneProviderSession(state, source, stampedPaneKey, hookPayloadRecord) + const sessions = readPaneSessionContext(state, source, envelope, eventName) + const { providerSession, statusAdmissionSession } = sessions if (source === 'opencode' && record.opencodeTui === 1 && !providerSession) { return null } @@ -143,33 +142,23 @@ export function normalizeHookPayload( } const previousStatus = state.lastStatusByPaneKey.get(paneKey) const agentProcess = readHookAgentProcess(source, record.agentProcess) - const previousReceivedAt = - previousStatus && - 'receivedAt' in previousStatus && - typeof previousStatus.receivedAt === 'number' - ? previousStatus.receivedAt - : previousStatus?.hostEvidenceObservedAt - // Reject before provider folds mutate the pane's prompt, tool and child caches. + const admissionStatus = + hasSeparateHookSessionAdmission(source) && options.bindingStatusByPaneKey + ? options.bindingStatusByPaneKey.get(paneKey) + : previousStatus if ( - previousStatus && - previousReceivedAt !== undefined && - sameAgentStatusTerminalOwner( - { connectionId: previousStatus.connectionId ?? null, worktreeId: previousStatus.worktreeId }, - { connectionId: null, worktreeId } - ) && - resolveAgentStatusIdentity({ - existing: { - agentType: previousStatus.payload.agentType, - state: previousStatus.payload.state, - updatedAt: previousReceivedAt, - restoredUnconfirmed: previousStatus.restoredUnconfirmed, - agentPresence: previousStatus.providerSessionOnly ? undefined : previousStatus.agentPresence + isInheritedHookStatus( + { + paneKey, + worktreeId, + connectionId: null, + providerSession: providerSession ?? undefined, + statusAdmissionSession, + agentPresence: agentProcess ? { agent: source, process: agentProcess } : undefined, + payload: { agentType: source, state: 'working', prompt: '' } }, - incoming: source, - incomingProcess: agentProcess, - incomingSession: providerSession?.id, - now: Date.now() - }).inheritedFromActivePane + admissionStatus + ) ) { return null } @@ -308,6 +297,7 @@ export function normalizeHookPayload( } : {}), ...(providerSession ? { providerSession } : {}), + ...(statusAdmissionSession ? { statusAdmissionSession } : {}), ...(providerSessionOnly ? { providerSessionOnly: true } : {}), ...dispatched.hostTurnSignals, payload: transportPayload diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts index caadb3d50ac..f79039d9728 100644 --- a/src/shared/agent-hook-listener/hook-envelope.ts +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -6,6 +6,7 @@ import { parsePaneKey } from '../stable-pane-id' import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' import type { HookListenerState } from './listener-state' import { parseAgentHookJson } from './request-body' +import { readFirstString } from './interactive-tool' function readHookHeader(headers: IncomingHttpHeaders, name: string): string | undefined { const value = headers[name] @@ -100,6 +101,7 @@ export type ParsedHookEnvelope = { record: Record paneKey: string hookPayloadRecord: Record + eventName: unknown tabId?: string worktreeId?: string launchToken?: string @@ -152,9 +154,16 @@ export function parseHookEnvelope( if (tabId && tabId !== parsedPaneKey.tabId) { return null } + const eventName = + readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? + ('hook_event_name' in hookPayload ? hookPayload.hook_event_name : undefined) ?? + ('hookEventName' in hookPayload ? hookPayload.hookEventName : undefined) ?? + // Only jcode uses event for lifecycle; other providers may use it for unrelated data. + (source === 'jcode' && 'event' in hookPayload ? hookPayload.event : undefined) return { record, paneKey, + eventName, hookPayloadRecord: hookPayload as Record, tabId, worktreeId: readEnvelopeString(record, 'worktreeId'), diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 180a60ec7a3..1309c1562a1 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -51,6 +51,8 @@ export type AgentHookEventPayload = { toolAgentType?: string /** Provider-owned conversation/session id needed to resume a sleeping agent. */ providerSession?: AgentProviderSessionMetadata + /** In-process status correlation only; never a new locator, persisted field or relay field. */ + statusAdmissionSession?: string /** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */ providerSessionOnly?: boolean /** True when this event is a relay cache replay rather than a live hook. */ diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 2c57d669366..7c38b8674ae 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -7,16 +7,45 @@ import { } from '../agent-session-resume' import { readFirstString } from './interactive-tool' import type { HookListenerState } from './listener-state' +import type { AgentHookEventPayload } from './listener-event' import { isGrokSubagentEvent } from './providers/grok-tool-fields' -import { withHermesPaneSessionId } from './providers/hermes-events' +import { isHermesRootSessionEvent, withHermesPaneSessionId } from './providers/hermes-events' import { readString } from './tool-input-preview' +import { readFirstTranscriptRecord } from './transcript-reader' + +export function hasSeparateHookSessionAdmission( + source: string | undefined +): source is 'hermes' | 'antigravity' { + return source === 'hermes' || source === 'antigravity' +} + +export function readPaneSessionContext( + state: HookListenerState, + source: AgentHookSource, + envelope: { paneKey: string; hookPayloadRecord: Record }, + eventName: unknown +) { + const { paneKey, hookPayloadRecord } = envelope + return { + providerSession: readPaneProviderSession(state, source, paneKey, hookPayloadRecord, eventName), + statusAdmissionSession: hasSeparateHookSessionAdmission(source) + ? extractAgentProviderSession( + source, + source === 'hermes' + ? withHermesPaneSessionId(state, paneKey, hookPayloadRecord) + : hookPayloadRecord + )?.id + : undefined + } +} /** The provider session a hook names for its pane; child agents' hooks never name their own. */ -export function readPaneProviderSession( +function readPaneProviderSession( state: HookListenerState, source: AgentHookSource, paneKey: string, - hookPayload: Record + hookPayload: Record, + eventName: unknown ): AgentProviderSessionMetadata | null { if ( source === 'copilot' && @@ -32,13 +61,32 @@ export function readPaneProviderSession( const previous = state.lastStatusByPaneKey.get(paneKey) return previous?.source === 'grok' ? (previous.providerSession ?? null) : null } - const providerSession = extractAgentProviderSession( - source, - source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload - ) + if ( + source === 'hermes' && + (!isHermesRootSessionEvent(eventName) || + typeof hookPayload.platform !== 'string' || + !['cli', 'tui', 'desktop', 'acp'].includes(hookPayload.platform)) + ) { + return null + } + const providerSession = extractAgentProviderSession(source, hookPayload) if (!providerSession) { return null } + if (source === 'antigravity') { + const transcriptPath = readPathFromKeys(hookPayload, ['transcriptPath', 'transcript_path']) + const origin = transcriptPath ? readFirstTranscriptRecord(transcriptPath) : undefined + if ( + typeof origin !== 'object' || + origin === null || + !('source' in origin) || + origin.source !== 'USER_EXPLICIT' || + !('type' in origin) || + origin.type !== 'USER_INPUT' + ) { + return null + } + } const continued = continuedProviderSession( state, source, @@ -105,3 +153,75 @@ function readClaudeEffort( : undefined return reported ?? continued?.effort } + +/** A status correlation may reuse an established owner without publishing a new restore locator. */ +function readHookStatusSession(incoming: AgentHookEventPayload): string | undefined { + return ( + incoming.statusAdmissionSession ?? + incoming.providerSession?.id ?? + (hasSeparateHookSessionAdmission(incoming.agentPresence?.agent) && + incoming.agentPresence?.agent === incoming.payload.agentType && + !incoming.agentPresence?.ended + ? incoming.agentPresence?.session + : undefined) + ) +} + +/** Older normalized relays retain some event kinds even after losing the raw ancestry fields. */ +export function readHookSessionContext( + incoming: T, + fromHook: boolean +) { + const { statusAdmissionSession: _statusAdmissionSession, ...event } = incoming + // Relays use scoped session bytes only to compare this hook, never to initialize ownership. + const ownershipEvent = + fromHook && event.agentPresence && hasSeparateHookSessionAdmission(event.agentPresence.agent) + ? { ...event, agentPresence: { ...event.agentPresence, session: undefined } } + : event + const rootEvent = + fromHook && + ((event.payload.agentType === 'copilot' && event.toolAgentId) || + (event.payload.agentType === 'hermes' && + event.hookEventName !== undefined && + !isHermesRootSessionEvent(event.hookEventName))) + ? { ...ownershipEvent, providerSession: undefined } + : ownershipEvent + return { + event: rootEvent, + statusAdmissionSession: + fromHook && hasSeparateHookSessionAdmission(incoming.payload.agentType) + ? readHookStatusSession(incoming) + : rootEvent.providerSession?.id + } +} + +export function retainHookRootContext( + incoming: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined, + fromHook: boolean, + sameTerminalOwner: boolean, + returningProviderSession: AgentProviderSessionMetadata | undefined +): AgentHookEventPayload { + const previousRoot = + previous && + previous.payload.agentType === incoming.payload.agentType && + ((incoming.payload.agentType === 'codex' && incoming.toolAgentId) || + (fromHook && + ['copilot', 'hermes', 'antigravity'].includes(incoming.payload.agentType ?? '') && + sameTerminalOwner)) + ? previous + : undefined + const providerSession = + !incoming.providerSession && (previousRoot?.providerSession ?? returningProviderSession) + const model = + incoming.payload.agentType === 'codex' && !incoming.payload.model + ? previousRoot?.payload.model + : undefined + return providerSession || model + ? { + ...incoming, + ...(providerSession ? { providerSession } : {}), + payload: model ? { ...incoming.payload, model } : incoming.payload + } + : incoming +} diff --git a/src/shared/agent-hook-listener/providers/hermes-events.ts b/src/shared/agent-hook-listener/providers/hermes-events.ts index efe003fc34b..8bbbc3d0bbc 100644 --- a/src/shared/agent-hook-listener/providers/hermes-events.ts +++ b/src/shared/agent-hook-listener/providers/hermes-events.ts @@ -20,6 +20,14 @@ const HERMES_EVENT_STATES: Record = { post_approval_response: 'working' } +export function isHermesRootSessionEvent(eventName: unknown): boolean { + return ( + eventName === 'on_session_start' || + eventName === 'on_session_reset' || + eventName === 'pre_llm_call' + ) +} + // Why: Hermes' post-turn memory/skill review fires tool hooks with no turn of its own. function admitsHermesEvent( state: HookListenerState, @@ -52,7 +60,7 @@ function admitsHermesEvent( return !(isToolEvent && turn?.ended) } -/** The hook payload with a `delegate_task` child's session id replaced by its parent pane's. */ +/** Status admission compares warm children with their parent; this never supplies restore identity. */ export function withHermesPaneSessionId( state: HookListenerState, paneKey: string, diff --git a/src/shared/agent-hook-listener/transcript-reader.test.ts b/src/shared/agent-hook-listener/transcript-reader.test.ts index 83780b08388..3777c1af9c6 100644 --- a/src/shared/agent-hook-listener/transcript-reader.test.ts +++ b/src/shared/agent-hook-listener/transcript-reader.test.ts @@ -1,7 +1,66 @@ -import { describe, expect, it } from 'vitest' -import { findLastExtractedTranscriptLineText } from './transcript-reader' +import { + closeSync, + fstatSync, + mkdtempSync, + openSync, + readSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeFs from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + findLastExtractedTranscriptLineText, + readFirstTranscriptRecord, + TRANSCRIPT_CHUNK_BYTES +} from './transcript-reader' import { extractAssistantTextFromLine } from './transcript-entry-text' +vi.mock('node:fs', async (importOriginal) => { + const fs = await importOriginal() + return { + ...fs, + openSync: vi.fn(fs.openSync), + readSync: vi.fn(fs.readSync), + closeSync: vi.fn(fs.closeSync) + } +}) + +it('reads the same bounded first-record prefix with N and 10N later records and closes the file', () => { + const dir = mkdtempSync(join(tmpdir(), 'transcript-first-record-')) + const transcriptPath = join(dir, 'transcript.jsonl') + const first = { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + content: 'x'.repeat(TRANSCRIPT_CHUNK_BYTES) + } + const later = `${JSON.stringify({ source: 'SYSTEM', content: 'y'.repeat(512) })}\n` + try { + for (const records of [1000, 10_000]) { + writeFileSync(transcriptPath, `${JSON.stringify(first)}\n${later.repeat(records)}`) + vi.mocked(openSync).mockClear() + vi.mocked(readSync).mockClear() + vi.mocked(closeSync).mockClear() + + // Call only the origin reader, excluding the unrelated reverse/prompt reader's IO. + expect(readFirstTranscriptRecord(transcriptPath)).toEqual(first) + + expect(openSync).toHaveBeenCalledExactlyOnceWith(transcriptPath, 'r') + expect(vi.mocked(readSync).mock.results.map((result) => result.value)).toEqual([ + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_CHUNK_BYTES + ]) + const fd = vi.mocked(openSync).mock.results[0].value + expect(closeSync).toHaveBeenCalledExactlyOnceWith(fd) + expect(() => fstatSync(fd)).toThrow(expect.objectContaining({ code: 'EBADF' })) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + function expectedLines(text: string): string[] { return text .split('\n') diff --git a/src/shared/agent-hook-listener/transcript-reader.ts b/src/shared/agent-hook-listener/transcript-reader.ts index 4051f1eff8e..3ae407777b6 100644 --- a/src/shared/agent-hook-listener/transcript-reader.ts +++ b/src/shared/agent-hook-listener/transcript-reader.ts @@ -1,8 +1,42 @@ +import { closeSync, openSync, readSync } from 'node:fs' +import { parseAgentHookJson } from './request-body' import { extractAssistantTextFromLine } from './transcript-entry-text' import { scanFileRegionsBackward } from './reverse-file-region-scan' export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 export const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 +/** Read origin evidence from the first record only; later user records cannot establish root ownership. */ +export function readFirstTranscriptRecord(transcriptPath: string): unknown { + try { + const fd = openSync(transcriptPath, 'r') + try { + const chunks: Buffer[] = [] + let bytesRead = 0 + while (bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const buffer = Buffer.alloc( + Math.min(TRANSCRIPT_CHUNK_BYTES, TRANSCRIPT_MAX_SCAN_BYTES - bytesRead) + ) + const count = readSync(fd, buffer, 0, buffer.length, bytesRead) + if (count === 0) { + return parseAgentHookJson(Buffer.concat(chunks).toString('utf8')) + } + bytesRead += count + const chunk = buffer.subarray(0, count) + const newline = chunk.indexOf(10) + chunks.push(newline === -1 ? chunk : chunk.subarray(0, newline)) + if (newline !== -1) { + return parseAgentHookJson(Buffer.concat(chunks).toString('utf8')) + } + } + } finally { + closeSync(fd) + } + } catch { + // Missing, partial or unreadable origin is re-observed on the next ordinary hook. + } + return undefined +} + export function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) } diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 037cf035aca..f12c3b6af88 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,17 +1,111 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { isSameAgentProcess } from './agent-process-presence' import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner' +import { + hasSeparateHookSessionAdmission, + readHookSessionContext +} from './agent-hook-listener/pane-provider-session' +import { extractAgentProviderSession } from './agent-session-resume' +import { resolveAgentStatusIdentity } from './agent-status-identity' -/** A pane has one owning agent; only the owner's own proven process can end it. */ -export function transitionHookPresence( +/** Refuse before provider folds can mutate prompt, tool or child state. */ +export function isInheritedHookStatus( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined -): AgentHookEventPayload | undefined { +): boolean { + const receivedAt = + previous && 'receivedAt' in previous && typeof previous.receivedAt === 'number' + ? previous.receivedAt + : previous?.hostEvidenceObservedAt + if (!previous || receivedAt === undefined || !sameAgentStatusTerminalOwner(previous, incoming)) { + return false + } + const preview = previewHookPresence(incoming, previous) + return resolveAgentStatusIdentity({ + existing: { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed, + agentPresence: previous.providerSessionOnly ? undefined : preview.owner + }, + incoming: incoming.payload.agentType, + incomingProcess: incoming.agentPresence?.process, + incomingSession: preview.statusAdmissionSession, + now: Date.now() + }).inheritedFromActivePane +} + +/** Preview an existing association before admission; only the accepted event commits this result. */ +export function previewHookPresence( + incoming: T, + previous: AgentHookEventPayload | undefined, + fromHook = true, + retireBinding = false +) { + const context = readHookSessionContext(incoming, fromHook) if (previous && !sameAgentStatusTerminalOwner(previous, incoming)) { previous = undefined } const recorded = previous?.agentPresence - const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined + const recordedAgent = recorded?.agent + const scopedSource = hasSeparateHookSessionAdmission(recordedAgent) ? recordedAgent : undefined + let owner = recorded + let statusAdmissionSession = context.statusAdmissionSession + if (scopedSource && retireBinding && recorded) { + owner = { ...recorded, session: undefined } + } else if ( + fromHook && + scopedSource && + recorded && + !recorded.ended && + !recorded.process && + recorded.agent === incoming.payload.agentType && + context.event.providerSession + ) { + owner = { ...recorded, session: context.event.providerSession.id } + statusAdmissionSession = context.event.providerSession.id + } + const previousBinding = + previous && owner !== recorded ? { ...previous, agentPresence: owner } : previous + const matched = Boolean( + fromHook && + scopedSource && + owner?.session && + !owner.ended && + owner.agent === incoming.payload.agentType && + owner.session === statusAdmissionSession && + (!owner.process || + (incoming.agentPresence?.process && + isSameAgentProcess(owner.process, incoming.agentPresence.process))) + ) + return { + ...context, + owner, + statusAdmissionSession, + transitioned: transitionHookPresence(context.event, previousBinding), + returningProviderSession: + matched && owner && scopedSource + ? (extractAgentProviderSession(scopedSource, { + session_id: owner.session, + conversationId: owner.session + }) ?? undefined) + : undefined + } +} + +/** A pane has one owning agent; only the owner's own proven process can end it. */ +function transitionHookPresence( + incoming: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload | undefined { + const recorded = previous?.agentPresence + const owner = + recorded && + !recorded.ended && + (!previous?.providerSessionOnly || hasSeparateHookSessionAdmission(recorded.agent)) + ? recorded + : undefined const sender = incoming.agentPresence?.process // Only admitted process-ending SessionEnd or host-proved exits mark ended; other SessionEnd events update status. const exit = incoming.agentPresence?.ended === true @@ -50,7 +144,11 @@ export function transitionHookPresence( if (!agent || agent === 'unknown') { return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming } - const session = incoming.agentPresence?.session ?? incoming.providerSession?.id + const session = hasSeparateHookSessionAdmission(agent) + ? agent === incoming.payload.agentType + ? incoming.providerSession?.id + : undefined + : (incoming.agentPresence?.session ?? incoming.providerSession?.id) return { ...incoming, agentPresence: { diff --git a/src/shared/agent-hook-session-binding.test-fixture.ts b/src/shared/agent-hook-session-binding.test-fixture.ts new file mode 100644 index 00000000000..cacbdb2f0a7 --- /dev/null +++ b/src/shared/agent-hook-session-binding.test-fixture.ts @@ -0,0 +1,12 @@ +import type { AgentStatusIpcPayload } from './agent-status-types' +import type { TerminalTab } from './terminal-tab-types' + +export type HookSessionBindingRendererFixture = { + expectReturnedRenderer: ( + data: AgentStatusIpcPayload, + id: string, + agent: 'hermes' | 'antigravity', + paneKey: string, + worktreeId: string + ) => TerminalTab +} diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index 6577b6ede21..bda63d2c9e3 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,5 +1,6 @@ import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' import type { AgentType } from './agent-status-types' +import { extractAgentProviderSession } from './agent-session-resume' export type AgentProcessIdentity = { pid: number @@ -73,9 +74,14 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | } const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined const session = - 'session' in value && typeof value.session === 'string' && value.session.length <= 256 - ? value.session - : undefined + value.agent === 'hermes' || value.agent === 'antigravity' + ? extractAgentProviderSession(value.agent, { + session_id: 'session' in value ? value.session : undefined, + conversationId: 'session' in value ? value.session : undefined + })?.id + : 'session' in value && typeof value.session === 'string' && value.session.length <= 256 + ? value.session + : undefined return { agent: value.agent, ...(process ? { process } : {}), diff --git a/src/wsl-guest/agent-hook-envelope-build.ts b/src/wsl-guest/agent-hook-envelope-build.ts index 9d01ef75629..422bf767b33 100644 --- a/src/wsl-guest/agent-hook-envelope-build.ts +++ b/src/wsl-guest/agent-hook-envelope-build.ts @@ -1,6 +1,7 @@ // Build SSH hook envelopes from parsed events/raw bodies; publication owns shedding and redelivery. import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' +import { hasSeparateHookSessionAdmission } from '../shared/agent-hook-listener/pane-provider-session' import { pickHostTurnEnd } from '../shared/agent-status-types' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. @@ -18,7 +19,12 @@ export function buildRelayHookEnvelope( ...(event.hostEvidenceObservedAt !== undefined ? { evidenceAgeMs: Math.max(0, Date.now() - event.hostEvidenceObservedAt) } : {}), - agentPresence: event.agentPresence, + agentPresence: hasSeparateHookSessionAdmission(source) + ? { + agent: source, + session: event.statusAdmissionSession + } + : event.agentPresence, paneKey: event.paneKey, ...(event.hostTurnRevision ? { hostTurnRevision: event.hostTurnRevision } : {}), ...(event.launchToken ? { launchToken: event.launchToken } : {}), diff --git a/src/wsl-guest/agent-hook-event-admission.ts b/src/wsl-guest/agent-hook-event-admission.ts index 3390b95f5c9..0c7d575c7ac 100644 --- a/src/wsl-guest/agent-hook-event-admission.ts +++ b/src/wsl-guest/agent-hook-event-admission.ts @@ -1,4 +1,4 @@ -import { transitionHookPresence } from '../shared/agent-hook-presence-transition' +import { previewHookPresence } from '../shared/agent-hook-presence-transition' import { hasOtherAgentProcessOwner, isSameAgentProcess } from '../shared/agent-process-presence' import { sameAgentStatusTerminalOwner } from '../shared/agent-status-terminal-owner' import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' @@ -53,7 +53,11 @@ export function applyRelayHookEvent( if (cancellation.hold) { return previous } - const transitioned = transitionHookPresence(cancellation.event, previous) + const preview = previewHookPresence(cancellation.event, previous) + const transitioned = preview.transitioned && { + ...preview.transitioned, + statusAdmissionSession: incoming.statusAdmissionSession + } if (!transitioned) { return undefined } diff --git a/src/wsl-guest/agent-hook-provider-normalization.ts b/src/wsl-guest/agent-hook-provider-normalization.ts index e366a2ba214..3a861c56ad8 100644 --- a/src/wsl-guest/agent-hook-provider-normalization.ts +++ b/src/wsl-guest/agent-hook-provider-normalization.ts @@ -39,7 +39,10 @@ export class RelayProviderHookNormalization { lastToolByPaneKey: state.lastToolByPaneKey, lastStatusByPaneKey: state.lastStatusByPaneKey } - const event = normalizeHookPayload(view, source, body, env, options) + const event = normalizeHookPayload(view, source, body, env, { + ...options, + bindingStatusByPaneKey: canonical.lastStatusByPaneKey + }) if (event) { const providerState = state cacheRelayLegacyAgentStatus(state, event, MAX_CACHED_PANES, (paneKey) =>