mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
reland(opencode): session continuity without the command-finished deferral (STA-4557) (#15350)
* reland(opencode): session continuity without the command-finished deferral (STA-4557) Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which is what caused the revert. ## Why the original runtime change was wrong `retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority retirement behind an async foreground read, on the premise that OpenCode emits `command-finished` while still in the foreground. Raw PTY capture disproves it: OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one `133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being deferred past only ever fires at exit, which is exactly when authority should be retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`. ## Why the deferral was unsafe `confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but its early return means "don't record an exit" — conservative. The deferral copied that shape into a site where the early return means "don't revoke a secret". Same code, inverted consequence: every guard failed open, so a stale or racing read silently kept a finished session's authority alive, and the pane's persisted `launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority after an app restart. ## Why the deferral's guards could not have worked `ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started in that shell inherits it — both sessions in a reused pane post the same token. A pane-lifetime bearer secret cannot be a session identity baseline, by construction, and `incarnationId` tracks the PTY, not the agent. The only field that separates sessions is the provider `sessionID`. ## What lands - Status/session-boundary work from #14866: opencode emits `SessionStart` for root sessions (mimo-code does not), launch-token fencing, and `SessionStart` as an opencode turn boundary. - The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a tokened `SessionStart`, and restore mimo-code's explicit-prompt restart boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes). #14941's re-poll hunk is dropped along with the code it patched. - Five regression tests in `opencode-finished-session-authority.test.ts`. They pass here and all five go red if the deferral is re-added. * chore: drop incidental reformatting of files unrelated to this PR
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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<string, unknown>,
|
||||
launchToken: string,
|
||||
paneKey?: string,
|
||||
source?: 'opencode' | 'mimo-code'
|
||||
) => Promise<Response>
|
||||
}> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
return {
|
||||
server,
|
||||
post: (payload, launchToken, paneKey = PANE, source = 'opencode') =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, {
|
||||
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('clears the 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')
|
||||
|
||||
// The destination fence rejects the source token until the transfer removes it.
|
||||
await post(
|
||||
{ hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' },
|
||||
'source-token',
|
||||
TARGET_PANE
|
||||
)
|
||||
expect(
|
||||
server.getStatusSnapshot().find((entry) => entry.paneKey === TARGET_PANE)?.providerSession
|
||||
).toEqual({ key: 'session_id', id: 'target-fresh' })
|
||||
|
||||
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' }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('re-fences on a new SessionStart while the pane stays authorized', 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: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
// The stale old-token follow-up stays fenced out.
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
||||
])
|
||||
|
||||
// Why: the runtime defers retirement while an agent stays in the foreground, so a
|
||||
// genuinely new process can start in a still-authorized pane. Its SessionStart must
|
||||
// replace the stale fence — not be swallowed as a stale event (rowless reuse).
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'fresh' } })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the fence when a live pane sees a tokenless 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: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
|
||||
// Why: an untokened boundary cannot prove which process it belongs to, so dropping
|
||||
// the fence for it would reopen the pane to every stale token.
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a stale explicit prompt re-fence a live pane', 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: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
|
||||
// Why: prompts recur mid-session, so honoring one as a process boundary would hand
|
||||
// the pane back to any still-live stale process.
|
||||
await post(
|
||||
{
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'stale prompt',
|
||||
messageID: 'message-stale',
|
||||
sessionID: 'old'
|
||||
},
|
||||
'old-token'
|
||||
)
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' }, prompt: '' })
|
||||
])
|
||||
})
|
||||
|
||||
it('restarts a retired mimo-code pane on an explicit user prompt', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code')
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
// Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart
|
||||
// boundary — excluding it would strand every retired mimo-code pane.
|
||||
await post(
|
||||
{
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'continue the task',
|
||||
messageID: 'message-resumed',
|
||||
sessionID: 'resumed'
|
||||
},
|
||||
'resume-token',
|
||||
PANE,
|
||||
'mimo-code'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,8 @@ beforeEach(() => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
/** Each source's own new-turn boundary, as `isNewTurnEvent` classifies it. `null` means the
|
||||
* provider has no turn boundary at all, so a retired pane there stays retired by design. */
|
||||
* classifier names no boundary for that source. That is not the same as "can never revive":
|
||||
* mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */
|
||||
const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
claude: 'SessionStart',
|
||||
kimi: 'UserPromptSubmit',
|
||||
@@ -36,9 +37,7 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
copilot: 'sessionStart',
|
||||
hermes: 'pre_llm_call',
|
||||
devin: 'UserPromptSubmit',
|
||||
// Why null for opencode today: its plugin emits no SessionStart, so it has no reachable
|
||||
// boundary. A pending change adds one; this entry moves with that change, not before it.
|
||||
opencode: null,
|
||||
opencode: 'SessionStart',
|
||||
'mimo-code': null,
|
||||
'command-code': null
|
||||
}
|
||||
@@ -111,10 +110,9 @@ describe("retired pane un-retires on each provider's own new-turn event", () =>
|
||||
)
|
||||
|
||||
it('leaves the pane retired for a source with no turn boundary', () => {
|
||||
// Why mimo-code and command-code rather than opencode: these two have no boundary event in
|
||||
// any planned state. Opencode is deliberately excluded — its plugin emits no SessionStart on
|
||||
// main (verified: zero occurrences in opencode/hook-service.ts), so asserting either outcome
|
||||
// for it would pin a synthetic event, and a pending change gives it a real one.
|
||||
// Why mimo-code and command-code: neither names a boundary through `isNewTurnEvent`, so
|
||||
// SessionStart must not open the gate for them. Mimo-code still revives on its own
|
||||
// explicit-prompt MessagePart — that path is covered in server-opencode-lifecycle.test.ts.
|
||||
expect(reviveRetiredPane('mimo-code', 'SessionStart')).toBe(false)
|
||||
expect(reviveRetiredPane('command-code', 'SessionStart')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -741,6 +741,7 @@ export class AgentHookServer {
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
private closedAgentStatusPaneKeys = new Set<string>()
|
||||
private restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
private connectionTimestampWatermarkById = new Map<string, number>()
|
||||
// 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
|
||||
@@ -1129,6 +1130,8 @@ export class AgentHookServer {
|
||||
rawSource?: unknown
|
||||
hookEventName?: string
|
||||
isReplay?: boolean
|
||||
hasExplicitPrompt?: boolean
|
||||
launchToken?: string
|
||||
}
|
||||
): 'accept' | 'restart' | 'suppress' {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
@@ -1140,6 +1143,29 @@ export class AgentHookServer {
|
||||
return 'suppress'
|
||||
}
|
||||
if (!paneRetired) {
|
||||
const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey)
|
||||
// Why: deferred retirement lets a new process start in a still-authorized pane, so
|
||||
// its tokened SessionStart re-fences; prompts recur, so a stale process would win.
|
||||
if (
|
||||
event?.hookEventName === 'SessionStart' &&
|
||||
event.isReplay !== true &&
|
||||
tokenFence !== undefined
|
||||
) {
|
||||
const startedLaunchToken = event.launchToken?.trim()
|
||||
if (startedLaunchToken) {
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.set(
|
||||
ownerPaneKey,
|
||||
createHash('sha256').update(startedLaunchToken).digest('hex')
|
||||
)
|
||||
return 'accept'
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1164,9 +1190,28 @@ export class AgentHookServer {
|
||||
// cannot revive a provider whose boundary event is named anything else.
|
||||
event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart'
|
||||
: false
|
||||
if (isNewTurn && event?.isReplay !== true) {
|
||||
// Why in addition to the classifier: the OpenCode family carries its mid-session boundary in
|
||||
// an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no
|
||||
// SessionStart at all, so without this its retired panes never come back.
|
||||
const freshOpenCodeFamilyPrompt =
|
||||
(event?.source === 'opencode' || event?.source === 'mimo-code') &&
|
||||
event.hookEventName === 'MessagePart' &&
|
||||
event.hasExplicitPrompt === true
|
||||
// Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up
|
||||
// status on that launch token stops a stale process reclaiming the pane's row without
|
||||
// restoring retired orchestration authority.
|
||||
if ((isNewTurn || freshOpenCodeFamilyPrompt) && 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'
|
||||
@@ -1824,6 +1869,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 activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey)
|
||||
if (activeTurnCompletedAt !== undefined) {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey)
|
||||
@@ -1884,6 +1936,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)
|
||||
@@ -2274,7 +2327,9 @@ export class AgentHookServer {
|
||||
source,
|
||||
rawSource: envelope.source,
|
||||
hookEventName,
|
||||
isReplay: envelope.isReplay === true
|
||||
isReplay: envelope.isReplay === true,
|
||||
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
|
||||
launchToken: envelope.launchToken
|
||||
})
|
||||
if (statusDisposition === 'suppress') {
|
||||
return
|
||||
@@ -2493,7 +2548,9 @@ export class AgentHookServer {
|
||||
? this.getAgentStatusDisposition(normalized.event.paneKey, {
|
||||
source,
|
||||
hookEventName: normalized.event.hookEventName,
|
||||
isReplay: normalized.event.isReplay
|
||||
isReplay: normalized.event.isReplay,
|
||||
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
|
||||
launchToken: normalized.event.launchToken
|
||||
})
|
||||
: 'suppress'
|
||||
if (normalized.event && statusDisposition !== 'suppress') {
|
||||
@@ -2587,6 +2644,7 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.clear()
|
||||
this.retiredPaneFencesByKey.clear()
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
@@ -2764,6 +2822,7 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
@@ -2785,6 +2844,7 @@ export class AgentHookServer {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(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) {
|
||||
@@ -2795,6 +2855,7 @@ export class AgentHookServer {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,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')
|
||||
|
||||
@@ -72,7 +72,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 } : {}
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
|
||||
@@ -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',
|
||||
@@ -875,6 +878,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" &&',
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../agent-hooks/server'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
|
||||
|
||||
// STA-4557: #14866 deferred command-finished retirement for OpenCode panes behind an
|
||||
// async "is the foreground still OpenCode?" read. These pin the two ways a finished
|
||||
// session then kept or regained orchestration authority (the #14943 revert reason).
|
||||
|
||||
const WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const WORKTREE = {
|
||||
path: WORKTREE_PATH,
|
||||
head: 'abc',
|
||||
branch: 'feature/opencode-authority',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/opencode-authority',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/opencode-authority',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
type LaunchedOpenCodePane = {
|
||||
runtime: OrcaRuntimeService
|
||||
ptyId: string
|
||||
paneKey: string
|
||||
tabId: string
|
||||
launchToken: string
|
||||
evidence: { terminalHandle: string; paneKey: string; launchToken: string }
|
||||
}
|
||||
|
||||
async function launchOpenCodePane(options: {
|
||||
ptyId: string
|
||||
getForegroundProcess: () => Promise<string | null>
|
||||
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
|
||||
attestAgentHookCompatibilityAuthority?: OrcaRuntimeServiceDeps['attestAgentHookCompatibilityAuthority']
|
||||
}): Promise<LaunchedOpenCodePane> {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: options.ptyId, incarnationId: 'incarnation-1' })
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
|
||||
attestAgentHookCompatibilityAuthority:
|
||||
options.attestAgentHookCompatibilityAuthority ??
|
||||
((candidate) => ({ paneKey: candidate.paneKey, source: 'current_hook' as const })),
|
||||
...(options.retireAgentHookCompatibilityAuthority
|
||||
? { retireAgentHookCompatibilityAuthority: options.retireAgentHookCompatibilityAuthority }
|
||||
: {})
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: options.getForegroundProcess
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`path:${WORKTREE.path}`, {
|
||||
command: 'opencode',
|
||||
launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} },
|
||||
launchAgent: 'opencode'
|
||||
})
|
||||
const env = (spawn.mock.calls[0]?.[0] as { env?: Record<string, string> } | undefined)?.env ?? {}
|
||||
const paneKey = env.ORCA_PANE_KEY as string
|
||||
const launchToken = env.ORCA_AGENT_LAUNCH_TOKEN as string
|
||||
expect(paneKey).toBeTruthy()
|
||||
expect(launchToken).toBeTruthy()
|
||||
return {
|
||||
runtime,
|
||||
ptyId: options.ptyId,
|
||||
paneKey,
|
||||
tabId: paneKey.split(':')[0]!,
|
||||
launchToken,
|
||||
evidence: { terminalHandle: terminal.handle, paneKey, launchToken }
|
||||
}
|
||||
}
|
||||
|
||||
type OrcaRuntimeServiceDeps = NonNullable<ConstructorParameters<typeof OrcaRuntimeService>[2]>
|
||||
|
||||
/** Drain the microtask + timer queues the deferred foreground read chains through. */
|
||||
async function settle(ticks = 40): Promise<void> {
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenCode finished-session launch authority (STA-4557)', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs.length = 0
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('retires authority when command-finished proves OpenCode left the foreground, even if a title raced the read', async () => {
|
||||
let resolveForeground: ((process: string | null) => void) | undefined
|
||||
const foreground = new Promise<string | null>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
const getForegroundProcess = vi.fn(() => foreground)
|
||||
const pane = await launchOpenCodePane({
|
||||
ptyId: 'pty-opencode-exit',
|
||||
getForegroundProcess
|
||||
})
|
||||
|
||||
expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).not.toBeNull()
|
||||
|
||||
// OpenCode exits; the shell prints its OSC 133;D and repaints its title while the
|
||||
// foreground read is still in flight, then the read lands proving a plain shell.
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100)
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]0;~/worktree-a\x07', 101)
|
||||
resolveForeground?.('zsh')
|
||||
await settle()
|
||||
|
||||
expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).toBeNull()
|
||||
})
|
||||
|
||||
it('retires authority when every foreground re-poll keeps racing a fresh title', async () => {
|
||||
let titleSequence = 0
|
||||
const getForegroundProcess = vi.fn(
|
||||
() =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
titleSequence += 1
|
||||
pane.runtime.onPtyData(
|
||||
pane.ptyId,
|
||||
`\x1b]0;~/worktree-a (${titleSequence})\x07`,
|
||||
200 + titleSequence
|
||||
)
|
||||
resolve('zsh')
|
||||
}, 0)
|
||||
})
|
||||
)
|
||||
const pane = await launchOpenCodePane({
|
||||
ptyId: 'pty-opencode-title-storm',
|
||||
getForegroundProcess
|
||||
})
|
||||
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100)
|
||||
await settle()
|
||||
|
||||
expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).toBeNull()
|
||||
})
|
||||
|
||||
it('stops attesting the finished session token for the reused pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const pane = await launchOpenCodePane({
|
||||
ptyId: 'pty-opencode-reuse',
|
||||
// OpenCode is a TUI: it is still the foreground process when its command completes.
|
||||
getForegroundProcess: async () => 'opencode',
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey),
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
server.attestCompatibilityAuthority(candidate)
|
||||
})
|
||||
const hookEnv = server.buildPtyEnv()
|
||||
const post = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: pane.paneKey,
|
||||
launchToken: pane.launchToken,
|
||||
tabId: pane.tabId,
|
||||
worktreeId: 'wt-opencode',
|
||||
env: 'production',
|
||||
payload
|
||||
})
|
||||
})
|
||||
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'session-1' })
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'session-1' })
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: pane.paneKey,
|
||||
launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'),
|
||||
connectionId: null,
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).not.toBeNull()
|
||||
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100)
|
||||
await settle()
|
||||
|
||||
// Every later process in this shell inherits ORCA_AGENT_LAUNCH_TOKEN from the PTY env,
|
||||
// so the finished session's token must stop attesting once its command completed.
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: pane.paneKey,
|
||||
launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'),
|
||||
connectionId: null,
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not let a later OpenCode session satisfy the previous pending retirement', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
let resolveForeground: ((process: string | null) => void) | undefined
|
||||
const foreground = new Promise<string | null>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
const pane = await launchOpenCodePane({
|
||||
ptyId: 'pty-opencode-session-boundary',
|
||||
getForegroundProcess: () => foreground,
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey),
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
server.attestCompatibilityAuthority(candidate)
|
||||
})
|
||||
const hookEnv = server.buildPtyEnv()
|
||||
// Both sessions post the same launchToken: it lives in the PTY env, so every
|
||||
// process started in this shell inherits it. Only sessionID separates them.
|
||||
const post = (sessionId: string): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: pane.paneKey,
|
||||
launchToken: pane.launchToken,
|
||||
tabId: pane.tabId,
|
||||
worktreeId: 'wt-opencode',
|
||||
env: 'production',
|
||||
payload: { hook_event_name: 'SessionBusy', sessionID: sessionId }
|
||||
})
|
||||
})
|
||||
const attestCurrent = (): unknown =>
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: pane.paneKey,
|
||||
launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'),
|
||||
connectionId: null,
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
|
||||
await post('session-1')
|
||||
expect(attestCurrent()).not.toBeNull()
|
||||
|
||||
// Session 1 ends. Nothing about the PTY changes across an agent session boundary:
|
||||
// same record, same incarnation, no title write — so every guard on the deferred
|
||||
// read still matches the baseline captured for session 1.
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100)
|
||||
await post('session-2')
|
||||
resolveForeground?.('opencode')
|
||||
await settle()
|
||||
|
||||
expect(attestCurrent()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not rehydrate a finished session token as restored authority after a restart', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sta4557-'))
|
||||
tempDirs.push(userDataPath)
|
||||
const first = new AgentHookServer()
|
||||
servers.push(first)
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
const pane = await launchOpenCodePane({
|
||||
ptyId: 'pty-opencode-restart',
|
||||
getForegroundProcess: async () => 'opencode',
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) => first.retirePaneAuthority(paneKey)
|
||||
})
|
||||
const hookEnv = first.buildPtyEnv()
|
||||
await fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: pane.paneKey,
|
||||
launchToken: pane.launchToken,
|
||||
tabId: pane.tabId,
|
||||
worktreeId: 'wt-opencode',
|
||||
env: 'production',
|
||||
payload: { hook_event_name: 'SessionStart', sessionID: 'session-1' }
|
||||
})
|
||||
})
|
||||
|
||||
pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100)
|
||||
await settle()
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const restarted = new AgentHookServer()
|
||||
servers.push(restarted)
|
||||
await restarted.start({ env: 'production', userDataPath })
|
||||
|
||||
// After a restart the PTY survives with ORCA_AGENT_LAUNCH_TOKEN still in its env and
|
||||
// pty.launchToken gone, so a persisted commitment is the whole proof of authority.
|
||||
expect(
|
||||
restarted.attestCompatibilityAuthority({
|
||||
paneKey: pane.paneKey,
|
||||
launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'),
|
||||
connectionId: null,
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1789,9 +1789,10 @@ function extractAmpToolFields(
|
||||
|
||||
/**
|
||||
* Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that
|
||||
* carries them, and isNewTurnEvent is false for this family, so nothing else ever resets the
|
||||
* cache — without an explicit retire, resolveToolState inherits one answered permission onto
|
||||
* every later frame in the pane and the row reads a resolved command as the live tool.
|
||||
* carries them, and the only isNewTurnEvent boundary this family has is opencode's
|
||||
* SessionStart — which a resumed session never re-emits — so nothing else resets the cache
|
||||
* mid-session. Without an explicit retire, resolveToolState inherits one answered permission
|
||||
* onto every later frame in the pane and the row reads a resolved command as the live tool.
|
||||
*/
|
||||
const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = {
|
||||
hasToolUpdate: true,
|
||||
@@ -2495,6 +2496,7 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo
|
||||
case 'amp':
|
||||
return eventName === 'agent.start'
|
||||
case 'opencode':
|
||||
return eventName === 'SessionStart'
|
||||
case 'mimo-code':
|
||||
return false
|
||||
case 'cursor':
|
||||
@@ -3899,14 +3901,19 @@ function normalizeOpenCodeFamilyEvent(
|
||||
paneKey: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): 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
|
||||
@@ -3916,19 +3923,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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -239,9 +239,9 @@ describe('OpenCode-family permission request status', () => {
|
||||
})
|
||||
|
||||
it.each(SOURCES)('does not carry an answered permission into a later turn for %s', (source) => {
|
||||
// Why: isNewTurnEvent is false for this family, so nothing else ever resets the cached
|
||||
// tool. Without an explicit retire, one permission pins its command to every later
|
||||
// working frame in the pane — the exact stale-tool-line the row gate guards against.
|
||||
// Why: no isNewTurnEvent boundary fires mid-session for this family, so nothing else
|
||||
// resets the cached tool. Without an explicit retire, one permission pins its command to
|
||||
// every later working frame in the pane — the exact stale-tool-line the row gate guards against.
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
lifecycleEvent(source, 'SessionBusy')
|
||||
lifecycleEvent(source, 'SessionIdle')
|
||||
|
||||
Reference in New Issue
Block a user