mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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
This commit is contained in:
@@ -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<string, unknown>, 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<string, unknown> = {}) {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown> = {}) => ({
|
||||
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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) => {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown> = {}) => ({
|
||||
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' }
|
||||
})
|
||||
})
|
||||
@@ -315,7 +315,8 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
|
||||
event.origin ?? 'osc',
|
||||
undefined,
|
||||
mutationBefore,
|
||||
newRun
|
||||
newRun,
|
||||
!preservedProviderSession
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
export type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
| 'authorityRestartId'
|
||||
| 'statusAdmissionSession'
|
||||
| 'launchToken'
|
||||
| 'hostTurnRevision'
|
||||
| 'promptInteractionKey'
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, AgentHookEventPayload>
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>
|
||||
paneKey: string
|
||||
hookPayloadRecord: Record<string, unknown>
|
||||
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<string, unknown>,
|
||||
tabId,
|
||||
worktreeId: readEnvelopeString(record, 'worktreeId'),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, unknown> },
|
||||
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<string, unknown>
|
||||
hookPayload: Record<string, unknown>,
|
||||
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<T extends AgentHookEventPayload>(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ const HERMES_EVENT_STATES: Record<string, 'working' | 'waiting' | 'done'> = {
|
||||
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,
|
||||
|
||||
@@ -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<typeof NodeFs>()
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<T extends AgentHookEventPayload>(
|
||||
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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user