mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Show live agent icons for terminal helper processes
This commit is contained in:
@@ -352,6 +352,42 @@ describe('createPtySubprocess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serves Unix agent foreground when node-pty reports an agent child process', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'uv'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
let resolveForeground!: (processName: string) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('uv')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'uv',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
resolveForeground('claude')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('claude'))
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let stale shell enrichment clear a newer direct agent foreground cache', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'powershell.exe'
|
||||
|
||||
@@ -732,7 +732,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
}
|
||||
const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean =>
|
||||
fallbackProcess !== null &&
|
||||
(isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess))
|
||||
(isShellProcess(fallbackProcess) ||
|
||||
isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
// Why: agent-spawned helper processes can become the PTY foreground
|
||||
// child; the Unix process tree can still identify the parent agent.
|
||||
process.platform !== 'win32')
|
||||
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
|
||||
if (dead || !proc.pid) {
|
||||
return
|
||||
@@ -756,7 +760,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
foregroundRefreshInFlight = true
|
||||
lastForegroundRefreshStartedAt = now
|
||||
// Why: daemon foreground reads are sync and run on the IPC hot path.
|
||||
// Refresh shell/wrapper-derived identities (powershell/node -> codex/etc.)
|
||||
// Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.)
|
||||
// in the background and serve them from a short cache on later reads.
|
||||
void resolveAgentForegroundProcess(proc.pid, fallbackProcess, {
|
||||
contextPaths: agentForegroundContextPaths
|
||||
|
||||
@@ -591,6 +591,85 @@ describe('useTabAgent', () => {
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
|
||||
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry unknown helper foreground without launch intent', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess.mockResolvedValue('uv')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: undefined })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('claude')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
|
||||
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250 + 1250 + 3500)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(4)
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(750)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(5)
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses completed local hook status as launch lifecycle evidence after remount', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
getForegroundProcess.mockResolvedValueOnce('zsh')
|
||||
|
||||
@@ -34,6 +34,8 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
|
||||
Pi: 'pi'
|
||||
}
|
||||
|
||||
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
|
||||
|
||||
function agentFromTitle(title: string): TuiAgent | null {
|
||||
const label = getAgentLabel(title)
|
||||
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
|
||||
@@ -234,42 +236,69 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
if (!ptyId || isRemoteLike) {
|
||||
return
|
||||
}
|
||||
const localPtyId = ptyId
|
||||
let cancelled = false
|
||||
const helperForegroundRetryTimers: number[] = []
|
||||
// Why: re-runs when ptyId or tab.title changes — a title change is the event
|
||||
// signalling a possible foreground transition (agent start, exit, or turn).
|
||||
// One RPC per transition, not a timer; cancellation coalesces rapid churn.
|
||||
window.api.pty
|
||||
.getForegroundProcess(ptyId)
|
||||
.then((process) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
function readForeground(retryIndex = 0): void {
|
||||
window.api.pty
|
||||
.getForegroundProcess(localPtyId)
|
||||
.then((process) => {
|
||||
applyForegroundProcess(process, retryIndex)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setForeground(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
function scheduleHelperForegroundRetry(retryIndex: number): void {
|
||||
const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex]
|
||||
if (delay === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: the daemon resolves shell/helper -> agent ancestry asynchronously
|
||||
// after the first foreground read, so give its short cache a bounded re-read.
|
||||
const timer = window.setTimeout(() => {
|
||||
readForeground(retryIndex + 1)
|
||||
}, delay)
|
||||
helperForegroundRetryTimers.push(timer)
|
||||
}
|
||||
function applyForegroundProcess(process: string | null, retryIndex: number): void {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const recognized = recognizeAgentProcess(process)
|
||||
if (recognized) {
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
setForeground(recognized.agent)
|
||||
} else if (process && isShellProcess(process)) {
|
||||
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
|
||||
setForeground(null)
|
||||
if (tab.launchAgent && !hasObservedAgentSignalRef.current) {
|
||||
scheduleHelperForegroundRetry(retryIndex)
|
||||
}
|
||||
const recognized = recognizeAgentProcess(process)
|
||||
if (recognized) {
|
||||
} else {
|
||||
if (process && tab.launchAgent) {
|
||||
// Why: for Orca-owned launches, an unrecognized non-shell process
|
||||
// is enough lifecycle evidence to clear launch intent when the pane
|
||||
// later returns to a shell, without using title text as identity.
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
setForeground(recognized.agent)
|
||||
} else if (process && isShellProcess(process)) {
|
||||
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
|
||||
setForeground(null)
|
||||
} else {
|
||||
if (process && tab.launchAgent) {
|
||||
// Why: for Orca-owned launches, an unrecognized non-shell process
|
||||
// is enough lifecycle evidence to clear launch intent when the pane
|
||||
// later returns to a shell, without using title text as identity.
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
setForeground(undefined)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setForeground(undefined)
|
||||
setForeground(undefined)
|
||||
if (process && tab.launchAgent) {
|
||||
scheduleHelperForegroundRetry(retryIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
readForeground()
|
||||
return () => {
|
||||
cancelled = true
|
||||
helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer))
|
||||
}
|
||||
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user