mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(pi): scope status completion to the run that ended (#20840)
A sibling extension can queue a follow-up run from inside its own `agent_settled` handler (a memory reminder does exactly that). Pi dispatches extension handlers in registration order and starts that run synchronously, so the status extension sees the follow-up's `agent_start` before its own `agent_settled` for the run that just ended. The completion post was deduped by one `agentEndReported` boolean that `agent_start` reset, so the older settlement consumed the latch and the follow-up run's own settlement was swallowed. The host kept the follow-up's last `message_end` (working) as its last word about the pane, with nothing left to correct it until the freshness window expired. Key the dedupe on the generation of the run that ENDED instead: the older settlement still reports its own completion (that run did finish), and the follow-up run now reports its own end exactly once. Fixes #20838
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
|
||||
|
||||
function postedHookNames(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map((call) => {
|
||||
const body: { payload?: { hook_event_name?: unknown } } = JSON.parse(String(call[1]?.body))
|
||||
return typeof body.payload?.hook_event_name === 'string' ? body.payload.hook_event_name : ''
|
||||
})
|
||||
}
|
||||
|
||||
async function flushPosts(): Promise<void> {
|
||||
// Each delivery has a bounded promise chain; no wall-clock sleeps in the harness.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(text: string): { message: Record<string, unknown> } {
|
||||
return { message: { role: 'assistant', content: [{ type: 'text', text }] } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A sibling extension (the Basic Memory reminder is one) can queue a follow-up run
|
||||
* from inside its own `agent_settled` handler. Pi dispatches handlers in registration
|
||||
* order and starts the follow-up run synchronously, so this extension sees the NEXT
|
||||
* run's `agent_start` before its own `agent_settled` for the run that just ended.
|
||||
*
|
||||
* Completion belongs to the run that ended, not to a "last run posted" latch: a
|
||||
* boolean latch reset on `agent_start` is consumed by the older run's settlement and
|
||||
* then swallows the follow-up run's own completion, leaving the host on that run's
|
||||
* last working event.
|
||||
*/
|
||||
describe('a follow-up run started from another extension settlement', () => {
|
||||
it('still reports the follow-up run completion', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
const context = { isIdle: vi.fn(() => true) }
|
||||
|
||||
// First turn establishes that this runtime settles runs.
|
||||
await harness.callHook('agent_start', undefined, context)
|
||||
await harness.callHook('agent_end', undefined, context)
|
||||
await harness.callHook('agent_settled', undefined, context)
|
||||
await flushPosts()
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
|
||||
// Turn 1 ends; a sibling extension's settle handler starts turn 2.
|
||||
await harness.callHook('agent_start', undefined, context)
|
||||
await harness.callHook('message_end', assistantMessage('all done'), context)
|
||||
await harness.callHook('agent_end', undefined, context)
|
||||
await harness.callHook('agent_start', undefined, context)
|
||||
// Turn 1's settlement reaches this extension only now, after turn 2 started.
|
||||
await harness.callHook('agent_settled', undefined, context)
|
||||
await harness.callHook('message_end', assistantMessage('nothing durable to save'), context)
|
||||
await harness.callHook('agent_end', undefined, context)
|
||||
await harness.callHook('agent_settled', undefined, context)
|
||||
await flushPosts()
|
||||
|
||||
// The host's last word about the pane must be the follow-up run's completion, not
|
||||
// the follow-up's last working frame.
|
||||
const posted = postedHookNames(harness.fetchMock)
|
||||
expect(posted.at(-1), `posted: ${posted.join(' -> ')}`).toBe('agent_end')
|
||||
})
|
||||
})
|
||||
@@ -141,7 +141,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
` onStatus('agent_start', (${bareCtxParams}) => {`,
|
||||
...captureSessionMetadata,
|
||||
' clearPendingAgentEndCheck()',
|
||||
' agentEndReported = false',
|
||||
' runGeneration += 1',
|
||||
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
|
||||
// boundary that can recover a modal whose close never arrived.
|
||||
...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []),
|
||||
@@ -192,7 +192,15 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' const AGENT_END_IDLE_RECHECK_MS = 25',
|
||||
' const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
|
||||
' let agentSettledSupported = false',
|
||||
' let agentEndReported = false',
|
||||
// Why: completion is a per-RUN fact. A sibling extension (the memory reminder is one)
|
||||
// can start the next run from inside its own agent_settled handler, and Pi dispatches
|
||||
// handlers in registration order, so this extension sees that run's agent_start
|
||||
// BEFORE its own agent_settled for the run that just ended. A boolean "already
|
||||
// posted" latch reset on agent_start then eats the newer run's completion and leaves
|
||||
// the host stuck on that run's last working event.
|
||||
' let runGeneration = 0',
|
||||
' let endedRunGeneration = 0',
|
||||
' let completionPostedGeneration = -1',
|
||||
' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS',
|
||||
' let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null',
|
||||
' let pendingAgentEndContext: { isIdle: () => boolean } | null = null',
|
||||
@@ -204,12 +212,13 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' }',
|
||||
'',
|
||||
' // Why: isIdle flips before agent_settled handlers run, so both paths',
|
||||
' // share a per-run guard instead of racing duplicate completion posts.',
|
||||
' // share a guard instead of racing duplicate completion posts — one keyed on the',
|
||||
' // generation of the run that ENDED, so a later run still reports its own end.',
|
||||
' function postAgentEndOnce(): void {',
|
||||
' if (agentEndReported) return',
|
||||
' agentEndReported = true',
|
||||
// Why: distinct from agentEndReported, which also dedupes the completion post and so
|
||||
// starts false on a pane that has not run a turn yet — that pane is idle, not busy.
|
||||
' if (completionPostedGeneration === endedRunGeneration) return',
|
||||
' completionPostedGeneration = endedRunGeneration',
|
||||
// Why: distinct from the completion guard, which holds the generation of the posted run
|
||||
// and so starts clean on a pane that has not run a turn yet — that pane is idle, not busy.
|
||||
...(kind === 'pi' ? [' piTurnInFlight = false'] : []),
|
||||
" post('agent_end')",
|
||||
' }',
|
||||
@@ -217,7 +226,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' function checkPendingAgentEnd(): void {',
|
||||
' pendingAgentEndCheck = null',
|
||||
' const ctx = pendingAgentEndContext',
|
||||
' if (!ctx || agentSettledSupported || agentEndReported) {',
|
||||
' if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {',
|
||||
' pendingAgentEndContext = null',
|
||||
' return',
|
||||
' }',
|
||||
@@ -249,6 +258,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' clearPendingAgentEndCheck()',
|
||||
' return',
|
||||
' }',
|
||||
' endedRunGeneration = runGeneration',
|
||||
' if (isOmpRuntime()) {',
|
||||
' postAgentEndOnce()',
|
||||
' return',
|
||||
|
||||
Reference in New Issue
Block a user