diff --git a/src/main/agent-hooks/server-opencode-lifecycle.test.ts b/src/main/agent-hooks/server-opencode-lifecycle.test.ts new file mode 100644 index 00000000000..9d1fb08541b --- /dev/null +++ b/src/main/agent-hooks/server-opencode-lifecycle.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111') +const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222') + +describe('AgentHookServer OpenCode lifecycle', () => { + const servers: AgentHookServer[] = [] + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + }) + + async function setup(): Promise<{ + server: AgentHookServer + post: ( + payload: Record, + launchToken: string, + paneKey?: string + ) => Promise + }> { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + return { + server, + post: (payload, launchToken, paneKey = PANE) => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey, + launchToken, + tabId: 'tab-opencode', + worktreeId: 'wt-opencode', + env: 'production', + payload + }) + }) + } + } + + it('accepts Busy after a retired pane receives a root SessionStart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + sessionBoundary: true, + providerSession: { key: 'session_id', id: 'fresh' } + }) + ]) + + await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' }) + ]) + }) + + it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + + await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token') + expect(server.getStatusSnapshot()).toEqual([]) + + await post( + { + hook_event_name: 'MessagePart', + role: 'user', + text: 'continue the task', + messageID: 'message-resumed', + sessionID: 'resumed' + }, + 'resume-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'continue the task' }) + ]) + }) + + it('maps question.asked attention to Waiting after restart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + + await post( + { hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' }, + 'fresh-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', agentType: 'opencode' }) + ]) + }) + + it('suppresses stale old-token Busy after a fresh restart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') + + await post( + { hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' }, + 'old-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: '' }) + ]) + }) + + it('replaces a destination token fence when pane authority transfers', async () => { + const { server, post } = await setup() + await post( + { hook_event_name: 'SessionBusy', sessionID: 'target-old' }, + 'target-old-token', + TARGET_PANE + ) + server.retirePaneAuthority(TARGET_PANE) + await post( + { hook_event_name: 'SessionStart', sessionID: 'target-fresh' }, + 'target-fresh-token', + TARGET_PANE + ) + await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token') + + server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode') + await post( + { hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' }, + 'source-token', + TARGET_PANE + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: TARGET_PANE, + providerSession: { key: 'session_id', id: 'source-after-transfer' } + }) + ]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f819414ad4b..81452af847d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -633,6 +633,7 @@ export class AgentHookServer { private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() + private restartedStatusLaunchTokenHashByPaneKey = new Map() private connectionTimestampWatermarkById = new Map() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null @@ -996,7 +997,13 @@ export class AgentHookServer { private getAgentStatusDisposition( paneKey: string, - event?: { hookEventName?: string; isReplay?: boolean } + event?: { + hookEventName?: string + isReplay?: boolean + source?: AgentHookSource + hasExplicitPrompt?: boolean + launchToken?: string + } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const paneRetired = @@ -1007,17 +1014,38 @@ export class AgentHookServer { return 'suppress' } if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } return 'accept' } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live SessionStart proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why: a new session boundary or explicit prompt proves a live lifecycle, while its + // token fences follow-up status without restoring retired orchestration authority. + const freshOpenCodePrompt = + event?.source === 'opencode' && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true if ( - (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && - event.isReplay !== true + (event?.hookEventName === 'UserPromptSubmit' || + event?.hookEventName === 'SessionStart' || + freshOpenCodePrompt) && + event?.isReplay !== true ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } return 'restart' } return 'suppress' @@ -1591,6 +1619,13 @@ export class AgentHookServer { if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { this.runtimeObservedStatusPaneKeys.add(toPaneKey) } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -1643,6 +1678,7 @@ export class AgentHookServer { const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) this.clearAssistantMessageRetry(key) this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) @@ -1953,7 +1989,10 @@ export class AgentHookServer { : undefined const statusDisposition = this.getAgentStatusDisposition(paneKey, { hookEventName, - isReplay: envelope.isReplay === true + isReplay: envelope.isReplay === true, + source, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken }) if (statusDisposition === 'suppress') { return @@ -2157,7 +2196,10 @@ export class AgentHookServer { const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay + isReplay: normalized.event.isReplay, + source: normalized.event.source, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken }) : 'suppress' if (normalized.event && statusDisposition !== 'suppress') { @@ -2240,6 +2282,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) @@ -2414,6 +2457,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(paneKey) this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() @@ -2434,6 +2478,7 @@ export class AgentHookServer { clearPaneCacheState(this.state, resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { if (stablePaneKey.stablePaneKey === resolvedPaneKey) { @@ -2443,6 +2488,7 @@ export class AgentHookServer { clearPaneCacheState(this.state, legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) clearedAlias = true } } diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index e30e415ca97..709b3bddae3 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -56,7 +56,9 @@ describe('MimoCodeHookService buildPtyEnv', () => { const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js') expect(existsSync(orcaPlugin)).toBe(true) - expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code') + const pluginSource = readFileSync(orcaPlugin, 'utf8') + expect(pluginSource).toContain('/hook/mimo-code') + expect(pluginSource).not.toContain('post("SessionStart"') expect( readFileSync(join(mimocodeHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 3f879d93063..eb991f59db2 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -68,7 +68,7 @@ export class MimoCodeHookService { mkdirSync(pluginsDir, { recursive: true }) writeFileSync( join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE), - getOpenCodeFamilyPluginSource('/hook/mimo-code') + getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }) ) } catch { return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {} diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index b6684ea38af..5e87a90991a 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -131,6 +131,22 @@ describe('OpenCode plugin lifecycle delivery', () => { }) } + it('maps only root session.created to SessionStart', async () => { + const handler = await loadHandler() + + await handler({ + event: { type: 'session.created', properties: { info: { id: 'root' } } } + }) + await handler({ + event: { + type: 'session.created', + properties: { info: { id: 'child', parentID: 'root' } } + } + }) + + expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }]) + }) + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { let releaseFirstLookup: (() => void) | undefined const firstLookup = new Promise((resolve) => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 706b39151d9..6face9d652b 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -36,10 +36,13 @@ function toSafeDirName(id: string): string { } export function getOpenCodePluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode') + return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true }) } -export function getOpenCodeFamilyPluginSource(hookPathname: string): string { +export function getOpenCodeFamilyPluginSource( + hookPathname: string, + options: { emitSessionStart: boolean } +): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', @@ -835,6 +838,20 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '', ' const sessionID = event.properties?.sessionID;', ' const updatedPart = event.properties?.part;', + ...(options.emitSessionStart + ? [ + ' if (event.type === "session.created") {', + ' const info = event.properties?.info;', + ' if (!info?.id || info.parentID) return;', + ' rememberSessionRoot(info.id, info.id);', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', + ' );', + ' return;', + ' }', + '' + ] + : []), ' if (', ' event.type === "message.part.updated" &&', ' updatedPart?.type === "tool" &&', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4c2dd2fbdd7..e13b56b069c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13092,6 +13092,110 @@ describe('OrcaRuntimeService', () => { ).toBeUndefined() }) + it('keeps OpenCode launch authority while command-finished leaves it in foreground', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-opencode', incarnationId: 'process-1' }) + const retireAuthority = vi.fn() + const getForegroundProcess = vi.fn(async () => 'opencode') + const runtime = new OrcaRuntimeService(store, undefined, { + attestAgentHookCompatibilityAuthority: (candidate) => ({ + paneKey: candidate.paneKey, + source: 'current_hook' + }), + retireAgentHookCompatibilityAuthority: retireAuthority + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-opencode' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const terminal = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'opencode', + launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} }, + launchAgent: 'opencode' + }) + const spawnEnv = + (spawn.mock.calls[0]?.[0] as { env?: Record } | undefined)?.env ?? {} + const evidence = { + terminalHandle: terminal.handle, + paneKey: spawnEnv.ORCA_PANE_KEY, + launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN + } + + runtime.onPtyData('pty-opencode', '\x1b]133;D;0\x07', 100) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled()) + + expect(retireAuthority).not.toHaveBeenCalled() + expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).not.toBeNull() + }) + + it('ignores a stale OpenCode foreground result after a newer title observation', async () => { + let resolveForegroundProcess: ((process: string | null) => void) | undefined + const foregroundProcess = new Promise((resolve) => { + resolveForegroundProcess = resolve + }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-opencode-race', incarnationId: 'process-1' }) + const retireAuthority = vi.fn() + const getForegroundProcess = vi.fn(() => foregroundProcess) + const runtime = new OrcaRuntimeService(store, undefined, { + retireAgentHookCompatibilityAuthority: retireAuthority + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-opencode-race' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'opencode', + launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} }, + launchAgent: 'opencode' + }) + + runtime.onPtyData('pty-opencode-race', '\x1b]133;D;0\x07', 100) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled()) + runtime.onPtyData('pty-opencode-race', '\x1b]0;OpenCode working\x07', 101) + resolveForegroundProcess?.(null) + await foregroundProcess + await Promise.resolve() + + expect(retireAuthority).not.toHaveBeenCalled() + }) + it('retires only receipted restored PTY authority on command completion and exit', () => { const retireAuthority = vi.fn() const runtime = new OrcaRuntimeService(store, undefined, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 69b7c342438..55500aa1008 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11119,7 +11119,7 @@ export class OrcaRuntimeService { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': - this.retirePtyAgentLaunchAuthority(ptyId) + this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode @@ -11408,7 +11408,7 @@ export class OrcaRuntimeService { this.confirmPtyAgentExit(ptyId) }, onCommandFinished: (exitCode: number | null) => { - this.retirePtyAgentLaunchAuthority(ptyId) + this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, onBell: () => { @@ -13536,6 +13536,36 @@ export class OrcaRuntimeService { } } + private retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (pty?.launchAgent !== 'opencode') { + this.retirePtyAgentLaunchAuthority(ptyId) + return + } + const titleObservedAt = pty.lastOscTitleAt ?? null + const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) + if (!foregroundRead) { + this.retirePtyAgentLaunchAuthority(ptyId) + return + } + const incarnationId = pty.incarnationId + void foregroundRead.then((result) => { + const current = this.ptysById.get(ptyId) + if ( + current !== pty || + current.incarnationId !== incarnationId || + current.lastOscTitleAt !== titleObservedAt || + result.controller !== this.ptyController + ) { + return + } + if (result.available && recognizeAgentProcess(result.process)?.agent === 'opencode') { + return + } + this.retirePtyAgentLaunchAuthority(ptyId) + }) + } + async resolveTerminalCwd(handle: string): Promise { const ptyId = this.resolveLeafForHandle(handle)?.ptyId if (!ptyId) { diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index b7e3dc362db..64390067fcf 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -216,6 +216,15 @@ describe('shared agent-hook-listener', () => { }, 'production' ) + const sessionStart = normalizeHookPayload( + state, + 'mimo-code', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', sessionID: 'mimo-session' } + }, + 'production' + ) expect(message?.payload).toMatchObject({ agentType: 'mimo-code', @@ -226,6 +235,7 @@ describe('shared agent-hook-listener', () => { expect(message?.providerSession).toMatchObject({ key: 'session_id', id: 'mimo-session' }) expect(tool?.payload).toMatchObject({ agentType: 'mimo-code', state: 'working' }) expect(idle?.payload).toMatchObject({ agentType: 'mimo-code', state: 'done' }) + expect(sessionStart).toBeNull() }) it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 82c9bfc9fec..bbfd0c459ad 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2437,6 +2437,7 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { case 'amp': return eventName === 'agent.start' case 'opencode': + return eventName === 'SessionStart' case 'mimo-code': return false case 'cursor': @@ -3773,14 +3774,19 @@ function normalizeOpenCodeFamilyEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + const resetsTurn = + isNewTurnEvent(source, eventName) || + (eventName === 'MessagePart' && hookPayload.role === 'user') const stateName = eventName === 'SessionBusy' || eventName === 'MessagePart' ? 'working' : eventName === 'SessionIdle' ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null + : source === 'opencode' && eventName === 'SessionStart' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null if (!stateName) { return null @@ -3790,19 +3796,20 @@ function normalizeOpenCodeFamilyEvent( state, paneKey, extractToolFields(source, eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent(source, eventName) } + { resetOnNewTurn: resetsTurn } ) return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent(source, eventName) + resetOnNewTurn: resetsTurn }), agentType: source, toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined }) }