mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(agent-hooks): give resumed Claude sessions a sidebar row at SessionStart (STA-3386)
Claude's hook set never registered SessionStart and normalizeClaudeEvent dropped it at ingest, so a resumed session that idled produced zero hook traffic and earned no sidebar agent row until the first prompt. - Register SessionStart in CLAUDE_EVENTS (local + remote installs). - Map lead SessionStart (startup/resume/clear) to an idle 'done' row, resetting stale roster/task/cron/tool/prompt state like the Codex path; compact restarts and child-attributed SessionStart stay dropped. - Thread hookEventName through the agent-status IPC payload so the completion coordinator can tell a session connect from a turn result; a SessionStart 'done' no longer raises agent-task-complete.
This commit is contained in:
@@ -394,6 +394,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
|
||||
connectionId: entry.connectionId,
|
||||
receivedAt: entry.receivedAt,
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
...(entry.hookEventName ? { hookEventName: entry.hookEventName } : {}),
|
||||
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
|
||||
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
|
||||
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
|
||||
|
||||
@@ -378,6 +378,9 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
for (const event of [
|
||||
// Why: SessionStart is the only signal a resumed/idle session ever emits;
|
||||
// without it the sidebar row waits for the first prompt (STA-3386).
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'StopFailure',
|
||||
|
||||
@@ -28,6 +28,9 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
}
|
||||
|
||||
export const CLAUDE_EVENTS = [
|
||||
// Why: SessionStart is the only event a resumed/idle session emits before the
|
||||
// first prompt; without it the sidebar row can't exist until the user types (STA-3386).
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
// Why: OpenClaude skips normal Stop hooks after API/model errors and emits
|
||||
|
||||
@@ -1426,6 +1426,7 @@ function openMainWindow(): BrowserWindow {
|
||||
providerSessionOnly,
|
||||
promptInteractionKey,
|
||||
restoredUnconfirmed,
|
||||
hookEventName,
|
||||
isReplay
|
||||
}) => {
|
||||
if (mainWindow?.isDestroyed()) {
|
||||
@@ -1460,6 +1461,7 @@ function openMainWindow(): BrowserWindow {
|
||||
connectionId,
|
||||
receivedAt,
|
||||
stateStartedAt,
|
||||
...(hookEventName ? { hookEventName } : {}),
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
...(promptInteractionKey ? { promptInteractionKey } : {}),
|
||||
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
||||
|
||||
@@ -421,6 +421,43 @@ describe('agent hook completion notifications', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fire a completion notification for a SessionStart idle row', async () => {
|
||||
const { observeAgentHookCompletionForNotification } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
|
||||
// Why: Claude SessionStart lands as 'done' so a resumed session gets its sidebar
|
||||
// row while idle (STA-3386) — connecting to a session is not completing a turn.
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId: 'wt-1',
|
||||
hookEventName: 'SessionStart',
|
||||
payload: {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 1_700_000_000_000
|
||||
}
|
||||
})
|
||||
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
// Why: the resumed session's next real turn must still notify normally.
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { ...hookStatus('working'), agentType: 'claude' }
|
||||
})
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { ...hookStatus('done'), agentType: 'claude', stateStartedAt: 1_700_000_020_000 }
|
||||
})
|
||||
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not notify twice when the same done hook snapshot replays after activation', async () => {
|
||||
const { observeAgentHookCompletionForNotification } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
|
||||
@@ -272,12 +272,19 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion
|
||||
export function observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId,
|
||||
payload
|
||||
payload,
|
||||
hookEventName
|
||||
}: {
|
||||
paneKey: string
|
||||
worktreeId: string
|
||||
payload: AgentCompletionStatusSnapshot
|
||||
hookEventName?: string
|
||||
}): void {
|
||||
if (hookEventName === 'SessionStart' && payload.state === 'done') {
|
||||
// Why: a SessionStart that lands 'done' is an idle session connecting (resume/launch,
|
||||
// STA-3386) — not a completed turn, so it must not raise agent-task-complete.
|
||||
return
|
||||
}
|
||||
pruneClosedPaneCoordinators()
|
||||
if (!paneCanReceiveHookCompletion(paneKey)) {
|
||||
return
|
||||
|
||||
@@ -3286,7 +3286,8 @@ export function useIpcEvents(): void {
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey,
|
||||
worktreeId: statusWorktreeId,
|
||||
payload: notificationPayload
|
||||
payload: notificationPayload,
|
||||
...(data.hookEventName ? { hookEventName: data.hookEventName } : {})
|
||||
})
|
||||
}
|
||||
return 'applied'
|
||||
|
||||
@@ -1369,6 +1369,82 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(event?.payload.lastAssistantMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps Claude SessionStart to an idle done row so a resumed session earns its sidebar row before the first prompt', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'resume',
|
||||
session_id: '44444444-4444-4444-8444-444444444444'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
// Why: 'working' would show a phantom spinner on an idle TUI (Devin precedent);
|
||||
// 'done' renders the row idle, which is the truth at SessionStart.
|
||||
expect(event?.payload).toMatchObject({ state: 'done', prompt: '', agentType: 'claude' })
|
||||
expect(event?.payload.interrupted).toBeUndefined()
|
||||
expect(event?.hookEventName).toBe('SessionStart')
|
||||
// Why: SessionStart carries resume identity, so in-app resume works before any prompt.
|
||||
expect(event?.providerSession).toMatchObject({
|
||||
key: 'session_id',
|
||||
id: '44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
})
|
||||
|
||||
it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => {
|
||||
normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' })
|
||||
normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' }
|
||||
})
|
||||
normalizeAndAccept(state, 'claude', { hook_event_name: 'SubagentStart', agent_id: 'agent-1' })
|
||||
|
||||
const event = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'startup'
|
||||
})
|
||||
|
||||
// Why: a new process owns the pane; stale prompt/tool/children must not survive
|
||||
// into the fresh session's idle row or gate it back up to 'working'.
|
||||
expect(event?.payload.state).toBe('done')
|
||||
expect(event?.payload.prompt).toBe('')
|
||||
expect(event?.payload.toolName).toBeUndefined()
|
||||
expect(event?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the running Claude turn when SessionStart comes from a compact restart or a child session', () => {
|
||||
normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' })
|
||||
|
||||
const compacted = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{ paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } },
|
||||
'production'
|
||||
)
|
||||
const child = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'SessionStart', source: 'startup', agent_id: 'agent-7' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
const stopped = normalizeAndAccept(state, 'claude', { hook_event_name: 'Stop' })
|
||||
|
||||
// Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a
|
||||
// child-attributed SessionStart must not flip the lead's live turn to an idle row.
|
||||
expect(compacted).toBeNull()
|
||||
expect(child).toBeNull()
|
||||
expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' })
|
||||
})
|
||||
|
||||
it('normalizes Devin documented lifecycle events', () => {
|
||||
const started = normalizeHookPayload(
|
||||
state,
|
||||
|
||||
@@ -2405,9 +2405,11 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
||||
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
// Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too.
|
||||
// falls through
|
||||
// Why: SessionStart lands an idle row (STA-3386) and must also drop stale
|
||||
// tool/prompt caches left by the pane's previous session.
|
||||
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
|
||||
case 'kimi':
|
||||
// Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too.
|
||||
return eventName === 'UserPromptSubmit'
|
||||
case 'codex':
|
||||
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
|
||||
@@ -2739,6 +2741,26 @@ function normalizeClaudeEvent(
|
||||
) {
|
||||
return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
|
||||
}
|
||||
if (eventName === 'SessionStart') {
|
||||
// Why: SessionStart fires while the TUI is idle (startup/resume/clear), so land the row
|
||||
// 'done' — 'working' would show a phantom spinner before any prompt (Devin precedent).
|
||||
// This is the only signal a resumed session emits before its first prompt (STA-3386).
|
||||
if (eventAgentId !== undefined || hookPayload['source'] === 'compact') {
|
||||
// Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle), and a
|
||||
// child-attributed SessionStart must not flip the lead's live turn to an idle row.
|
||||
return null
|
||||
}
|
||||
// Why: a new process owns the pane; stale children/tasks/crons must not gate the
|
||||
// fresh session's idle row back up to 'working' (mirrors the Codex SessionStart reset).
|
||||
state.claudeSubagentRosterByPaneKey.delete(paneKey)
|
||||
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
|
||||
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
|
||||
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' })
|
||||
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
|
||||
stateName: 'done',
|
||||
updateToolSnapshot: true
|
||||
})
|
||||
}
|
||||
const previousLead = state.claudeLeadStateByPaneKey.get(paneKey)
|
||||
// Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it.
|
||||
const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure'
|
||||
|
||||
@@ -219,6 +219,10 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
|
||||
/** Identifies the SSH connection the event arrived on, or null for local.
|
||||
* Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */
|
||||
connectionId: string | null
|
||||
/** Hook event that produced this status, when it came from a native hook.
|
||||
* Why: lets receivers tell a session boundary from a turn result — a SessionStart
|
||||
* 'done' is an idle connect (STA-3386), not a completion to notify about. */
|
||||
hookEventName?: string
|
||||
/** Timestamp (ms) when the hook server received this latest status event. */
|
||||
receivedAt: number
|
||||
/** Timestamp (ms) when the current state first appeared for this pane. */
|
||||
|
||||
Reference in New Issue
Block a user