Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"../src/main/codex/codex-hook-local-install.ts",
"../src/main/codex/codex-hook-local-maintenance.ts",
"../src/main/codex/codex-hook-remote-install.ts",
"../src/main/codex/codex-hook-remote-user-trust-moves.ts",
"../src/main/codex/codex-hook-script.ts",
"../src/main/codex/codex-hook-service-implementation.ts",
"../src/main/codex/codex-hook-status.ts",
Expand Down
28 changes: 20 additions & 8 deletions src/main/agent-hooks/server-codex-normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ describe('Codex hook normalization', () => {
expect(result?.payload.toolInput).toBeUndefined()
})

it('SessionStart clears cached tool state from a prior session', () => {
// Seed a Stop snapshot with an assistant message.
it('SessionStart clears cached tool state without reporting working', () => {
// Why: SessionStart is an idle TUI/resume boundary, not an active turn.
_internals.normalizeHookPayload(
'codex',
buildBody({
Expand All @@ -205,11 +205,18 @@ describe('Codex hook normalization', () => {
)
const result = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SessionStart' }),
buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-next' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.lastAssistantMessage).toBeUndefined()
const prompted = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'next turn' }),
'production'
)
expect(result).toBeNull()
expect(prompted?.payload.state).toBe('working')
expect(prompted?.payload.lastAssistantMessage).toBeUndefined()
expect(prompted?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-next' })
})

it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => {
Expand All @@ -223,10 +230,15 @@ describe('Codex hook normalization', () => {
)
const result = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SessionStart' }),
buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-fresh' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('')
const prompted = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'fresh prompt' }),
'production'
)
expect(result).toBeNull()
expect(prompted?.payload.prompt).toBe('fresh prompt')
})
})
142 changes: 142 additions & 0 deletions src/main/agent-hooks/server-codex-session-identity-review.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import {
createHookListenerState,
seedLegacyAgentStatusForTests
} from '../../shared/agent-hook-listener/listener-state'
import { PANE_KEY } from '../../shared/agent-hook-listener-test-harness'
import { AgentHookServer } from './server'

vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))

describe('review: Codex root identity isolation', () => {
it('keeps the parent status and session when a child emits SessionStart', () => {
const state = createHookListenerState()
const parent = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: 'parent work',
session_id: 'parent-session'
}
},
'production'
)
if (!parent) {
throw new Error('missing parent fixture')
}
seedLegacyAgentStatusForTests(state, parent)
normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'SessionStart',
agent_id: 'child-agent',
session_id: 'child-session'
}
},
'production'
)
expect.soft(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(parent)
expect.soft(state.lastProviderSessionByPaneKey.get(PANE_KEY)?.id).toBe('parent-session')
const next = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'pwd' }
}
},
'production'
)
expect.soft(next?.providerSession?.id).toBe('parent-session')
})

it('does not replace the current connection session with a delayed foreign SessionStart', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE_KEY,
source: 'codex',
hookEventName: 'SessionStart',
providerSession: { key: 'session_id', id: 'current-session' },
payload: { agentType: 'codex', state: 'working', prompt: '' }
},
'current-connection'
)
server.ingestRemote(
{
paneKey: PANE_KEY,
source: 'codex',
hookEventName: 'UserPromptSubmit',
providerSession: { key: 'session_id', id: 'current-session' },
payload: { agentType: 'codex', state: 'working', prompt: 'current work' }
},
'current-connection'
)
server.ingestRemote(
{
paneKey: PANE_KEY,
source: 'codex',
hookEventName: 'SessionStart',
providerSession: { key: 'session_id', id: 'stale-session' },
payload: { agentType: 'codex', state: 'working', prompt: '' }
},
'stale-connection'
)
expect
.soft(server.getStatusSnapshot())
.toEqual([
expect.objectContaining({ connectionId: 'current-connection', prompt: 'current work' })
])
expect
.soft(server._getStateForTests().lastProviderSessionByPaneKey.get(PANE_KEY)?.id)
.toBe('current-session')
})

it('ignores session identity from an unrecognized hook', () => {
const state = createHookListenerState()
normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: 'real work',
session_id: 'real-session'
}
},
'production'
)
const ignored = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: { hook_event_name: 'UnknownEvent', session_id: 'ignored-session' }
},
'production'
)
expect(ignored).toBeNull()
const next = normalizeHookPayload(
state,
'codex',
{
paneKey: PANE_KEY,
payload: { hook_event_name: 'PostToolUse', tool_name: 'Bash' }
},
'production'
)
expect(next?.providerSession?.id).toBe('real-session')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
it('accepts the first real tool event after an idle SessionStart revives a retired pane', () => {
const server = new AgentHookServer()
server.retirePaneAuthority(PANE)
server.ingestRemote(
{
paneKey: PANE,
source: 'codex',
hookEventName: 'SessionStart',
providerSession: { key: 'session_id', id: 'new-session' },
payload: { state: 'working', prompt: '', agentType: 'codex' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([])
server.ingestRemote(
{
paneKey: PANE,
source: 'codex',
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: '',
agentType: 'codex',
toolName: 'Bash',
toolInput: 'pwd'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'codex' })
])
})
Loading