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:
Brennan Benson
2026-08-23 15:24:25 -07:00
committed by GitHub
parent fe6f942d1f
commit ab3b1d07cd
11 changed files with 729 additions and 27 deletions
@@ -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)
})
+64 -3
View File
@@ -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
}
}
+3 -1
View File
@@ -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')
+1 -1
View File
@@ -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) => {
+19 -2
View File
@@ -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', () => {
+17 -9
View File
@@ -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')