mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Preserve OpenCode session across command completion, control SessionStart emission (#14866)
* Preserve OpenCode session across command completion - Add session start events and launch token tracking to establish session boundaries - Defer retiring launch authority until OpenCode process actually exits, not just when a command finishes - Fence previous tokens after restarts to prevent status updates from stale sessions - Maps SessionStart as a session boundary for proper turn/state management * Emit SessionStart only from OpenCode, not mimo-code Restrict SessionStart lifecycle events to OpenCode exclusively. Mimo-code no longer emits SessionStart, as it should rely on OpenCode for session boundary signals. This prevents duplicate lifecycle events that could interfere with pane authority tracking and session state management. Also tighten foreground process result validation to reject stale results after title observation changes, fixing a race where a delayed foreground read from a previous cycle would incorrectly retire authority.
This commit is contained in:
@@ -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<string, unknown>,
|
||||
launchToken: string,
|
||||
paneKey?: string
|
||||
) => 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) =>
|
||||
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' }
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -633,6 +633,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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } : {}
|
||||
|
||||
@@ -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',
|
||||
@@ -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" &&',
|
||||
|
||||
@@ -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<string, string> } | 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<string | null>((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, {
|
||||
|
||||
@@ -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<string | null> {
|
||||
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
|
||||
if (!ptyId) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<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
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user