mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Revert "Preserve OpenCode session across command completion, control SessionS…" (#14943)
This reverts commit 1da1bdc01c.
This commit is contained in:
@@ -1,159 +0,0 @@
|
||||
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,7 +633,6 @@ 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
|
||||
@@ -997,13 +996,7 @@ export class AgentHookServer {
|
||||
|
||||
private getAgentStatusDisposition(
|
||||
paneKey: string,
|
||||
event?: {
|
||||
hookEventName?: string
|
||||
isReplay?: boolean
|
||||
source?: AgentHookSource
|
||||
hasExplicitPrompt?: boolean
|
||||
launchToken?: string
|
||||
}
|
||||
event?: { hookEventName?: string; isReplay?: boolean }
|
||||
): 'accept' | 'restart' | 'suppress' {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneRetired =
|
||||
@@ -1014,38 +1007,17 @@ 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: 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
|
||||
// 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).
|
||||
if (
|
||||
(event?.hookEventName === 'UserPromptSubmit' ||
|
||||
event?.hookEventName === 'SessionStart' ||
|
||||
freshOpenCodePrompt) &&
|
||||
event?.isReplay !== true
|
||||
(event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') &&
|
||||
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'
|
||||
@@ -1619,13 +1591,6 @@ 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)
|
||||
@@ -1678,7 +1643,6 @@ 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)
|
||||
@@ -1989,10 +1953,7 @@ export class AgentHookServer {
|
||||
: undefined
|
||||
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
|
||||
hookEventName,
|
||||
isReplay: envelope.isReplay === true,
|
||||
source,
|
||||
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
|
||||
launchToken: envelope.launchToken
|
||||
isReplay: envelope.isReplay === true
|
||||
})
|
||||
if (statusDisposition === 'suppress') {
|
||||
return
|
||||
@@ -2196,10 +2157,7 @@ export class AgentHookServer {
|
||||
const statusDisposition = normalized.event
|
||||
? this.getAgentStatusDisposition(normalized.event.paneKey, {
|
||||
hookEventName: normalized.event.hookEventName,
|
||||
isReplay: normalized.event.isReplay,
|
||||
source: normalized.event.source,
|
||||
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
|
||||
launchToken: normalized.event.launchToken
|
||||
isReplay: normalized.event.isReplay
|
||||
})
|
||||
: 'suppress'
|
||||
if (normalized.event && statusDisposition !== 'suppress') {
|
||||
@@ -2282,7 +2240,6 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.clear()
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
@@ -2457,7 +2414,6 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
@@ -2478,7 +2434,6 @@ 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) {
|
||||
@@ -2488,7 +2443,6 @@ export class AgentHookServer {
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ describe('MimoCodeHookService buildPtyEnv', () => {
|
||||
|
||||
const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js')
|
||||
expect(existsSync(orcaPlugin)).toBe(true)
|
||||
const pluginSource = readFileSync(orcaPlugin, 'utf8')
|
||||
expect(pluginSource).toContain('/hook/mimo-code')
|
||||
expect(pluginSource).not.toContain('post("SessionStart"')
|
||||
expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code')
|
||||
|
||||
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', { emitSessionStart: false })
|
||||
getOpenCodeFamilyPluginSource('/hook/mimo-code')
|
||||
)
|
||||
} catch {
|
||||
return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {}
|
||||
|
||||
@@ -131,22 +131,6 @@ 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,13 +36,10 @@ function toSafeDirName(id: string): string {
|
||||
}
|
||||
|
||||
export function getOpenCodePluginSource(): string {
|
||||
return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true })
|
||||
return getOpenCodeFamilyPluginSource('/hook/opencode')
|
||||
}
|
||||
|
||||
export function getOpenCodeFamilyPluginSource(
|
||||
hookPathname: string,
|
||||
options: { emitSessionStart: boolean }
|
||||
): string {
|
||||
export function getOpenCodeFamilyPluginSource(hookPathname: string): 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',
|
||||
@@ -838,20 +835,6 @@ export function getOpenCodeFamilyPluginSource(
|
||||
'',
|
||||
' 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,110 +13092,6 @@ 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, {
|
||||
|
||||
@@ -10325,7 +10325,7 @@ export class OrcaRuntimeService {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
|
||||
return
|
||||
case 'command-finished':
|
||||
this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId)
|
||||
this.retirePtyAgentLaunchAuthority(ptyId)
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'command-finished',
|
||||
exitCode: fact.exitCode
|
||||
@@ -10614,7 +10614,7 @@ export class OrcaRuntimeService {
|
||||
this.confirmPtyAgentExit(ptyId)
|
||||
},
|
||||
onCommandFinished: (exitCode: number | null) => {
|
||||
this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId)
|
||||
this.retirePtyAgentLaunchAuthority(ptyId)
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
|
||||
},
|
||||
onBell: () => {
|
||||
@@ -12742,36 +12742,6 @@ 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,15 +216,6 @@ 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',
|
||||
@@ -235,7 +226,6 @@ 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,7 +2437,6 @@ 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':
|
||||
@@ -3774,19 +3773,14 @@ 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'
|
||||
: source === 'opencode' && eventName === 'SessionStart'
|
||||
? 'done'
|
||||
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
|
||||
? 'waiting'
|
||||
: null
|
||||
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
|
||||
? 'waiting'
|
||||
: null
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
@@ -3796,20 +3790,19 @@ function normalizeOpenCodeFamilyEvent(
|
||||
state,
|
||||
paneKey,
|
||||
extractToolFields(source, eventName, hookPayload),
|
||||
{ resetOnNewTurn: resetsTurn }
|
||||
{ resetOnNewTurn: isNewTurnEvent(source, eventName) }
|
||||
)
|
||||
|
||||
return normalizeAgentStatusPayload({
|
||||
state: stateName,
|
||||
prompt: resolvePrompt(state, paneKey, promptText, {
|
||||
resetOnNewTurn: resetsTurn
|
||||
resetOnNewTurn: isNewTurnEvent(source, eventName)
|
||||
}),
|
||||
agentType: source,
|
||||
toolName: snapshot.toolName,
|
||||
toolInput: snapshot.toolInput,
|
||||
interactivePrompt: snapshot.interactivePrompt,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage,
|
||||
sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user