mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
* feat(agents): pi session resume support
* fix(pi): require persisted session files for resume
* test(sleeping-agent): use non-resumable sentinel in malformed-record fixture
The 'drops malformed sleeping agent resume records' test used agent:'pi' as
its example of an unknown/non-resumable agent, expecting the record to be
dropped. This PR added 'pi' to RESUMABLE_TUI_AGENTS, making that fixture
valid and retained, so the toBeUndefined assertion broke. Switch the
malformed-case fixture to a genuinely non-resumable sentinel
('definitely-not-an-agent') so the drop-malformed path is still exercised;
no other assertions changed.
* Add durable resume identity for Pi sessions without fabricating turn sta
Pi's `session_start` hook now carries the session file needed to resume
a sleeping pane, but until now Orca either discarded it or treated it
as a fake status transition. Thread a `providerSessionOnly` envelope
through the hook listener, relay, main-process server, and renderer
store so resume identity (and its session-file-scoped equality/claim
key) can be persisted and replayed without emitting prompt telemetry
or a visible working/done row.
* Add durable resume identity for completed Pi sessions
Pi's agent_end hook marks a turn done, but the underlying TUI session
stays alive and resumable. Previously a `done` status wiped sleeping
records and launch config as if the session ended, so hibernation,
manual worktree sleep, and quit-capture all lost Pi's resume identity.
- Track a "live recovery" record for done-but-still-resumable Pi
sessions, exempting it from the usual done-state cleanup paths in
agent-status.ts and agent-hibernation-planner.ts
- Gate providerSessionOnly rows and sleeping-agent schema records on
actual resumability (getAgentResumeArgv) instead of trusting the
presence of a provider session
- Wait for Pi to persist its session file before advertising resume
metadata, and treat `/reload` as a non-terminal event so it doesn't
clobber visible status
- Extend SSH relay envelopes to carry providerSessionOnly so remote
hosts get the same behavior
* Add explicit periodic/quit mode to sleeping-agent session capture
Split captureAllSleepingAgentSessions into 'periodic' and 'quit' modes
so a background checkpoint can no longer downgrade a confirmed-quit
record or promote a completed Pi session without an authoritative
transcript path. Updates all call sites and tests accordingly.
* Use normalizeAgentStatusPayload for default pi status
Remove unnecessary JSON.stringify wrapper and call the appropriate normalization function directly.
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
124 lines
5.5 KiB
TypeScript
124 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
agentProviderSessionsEqual,
|
|
extractAgentProviderSession,
|
|
getAgentResumeArgv,
|
|
isResumableTuiAgent,
|
|
normalizeAgentProviderSession
|
|
} from './agent-session-resume'
|
|
|
|
describe('agent session resume metadata', () => {
|
|
it('treats devin as a resumable TUI agent', () => {
|
|
expect(isResumableTuiAgent('devin')).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }],
|
|
['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }],
|
|
['gemini', { session_id: 'gemini-session' }, { key: 'session_id', id: 'gemini-session' }],
|
|
[
|
|
'antigravity',
|
|
{ conversationId: 'agy-conversation' },
|
|
{ key: 'conversation_id', id: 'agy-conversation' }
|
|
],
|
|
['opencode', { sessionID: 'opencode-session' }, { key: 'session_id', id: 'opencode-session' }],
|
|
[
|
|
'pi',
|
|
{ session_id: 'pi-session', session_file: '/tmp/pi-session.jsonl' },
|
|
{ key: 'session_id', id: 'pi-session', transcriptPath: '/tmp/pi-session.jsonl' }
|
|
],
|
|
['mimo-code', { sessionID: 'mimo-session' }, { key: 'session_id', id: 'mimo-session' }],
|
|
['droid', { session_id: 'droid-session' }, { key: 'session_id', id: 'droid-session' }],
|
|
['grok', { sessionId: 'grok-session' }, { key: 'session_id', id: 'grok-session' }],
|
|
['devin', { session_id: 'devin-session' }, { key: 'session_id', id: 'devin-session' }]
|
|
] as const)('extracts %s provider session ids', (source, payload, expected) => {
|
|
expect(extractAgentProviderSession(source, payload)).toEqual(expected)
|
|
})
|
|
|
|
it.each([
|
|
['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']],
|
|
['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']],
|
|
['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']],
|
|
['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']],
|
|
['opencode', { key: 'session_id', id: 's1' }, ['opencode', '--session', 's1']],
|
|
[
|
|
'pi',
|
|
{ key: 'session_id', id: 's1', transcriptPath: '/tmp/pi-session.jsonl' },
|
|
['pi', '--session', '/tmp/pi-session.jsonl']
|
|
],
|
|
['mimo-code', { key: 'session_id', id: 's1' }, ['mimo', '--session', 's1']],
|
|
['droid', { key: 'session_id', id: 's1' }, ['droid', '--resume', 's1']],
|
|
['grok', { key: 'session_id', id: 's1' }, ['grok', '--resume', 's1']],
|
|
['devin', { key: 'session_id', id: 'abc12345' }, ['devin', '--resume', 'abc12345']]
|
|
] as const)('builds %s resume argv', (agent, providerSession, expected) => {
|
|
expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected)
|
|
})
|
|
|
|
it('rejects unsupported sources and unsafe ids', () => {
|
|
expect(extractAgentProviderSession('omp', { session_id: 'omp-session' })).toBeNull()
|
|
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'bad\nid' })).toBeNull()
|
|
expect(normalizeAgentProviderSession({ key: 'session_id', id: '--last' })).toBeNull()
|
|
expect(extractAgentProviderSession('codex', { session_id: '--last' })).toBeNull()
|
|
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok' })).toEqual({
|
|
key: 'session_id',
|
|
id: 'ok'
|
|
})
|
|
})
|
|
|
|
it('does not capture ephemeral Pi sessions without a session file', () => {
|
|
expect(extractAgentProviderSession('pi', { session_id: 'pi-session' })).toBeNull()
|
|
expect(
|
|
extractAgentProviderSession('pi', { session_id: 'pi-session', session_file: '' })
|
|
).toBeNull()
|
|
expect(extractAgentProviderSession('pi', { session_file: '/tmp/pi-session.jsonl' })).toBeNull()
|
|
expect(getAgentResumeArgv('pi', { key: 'session_id', id: 'pi-session' })).toBeNull()
|
|
})
|
|
|
|
it('compares the actual provider resume locator for each agent', () => {
|
|
const first = { key: 'session_id' as const, id: 'session-1', transcriptPath: '/tmp/first' }
|
|
const second = { key: 'session_id' as const, id: 'session-1', transcriptPath: '/tmp/second' }
|
|
|
|
expect(agentProviderSessionsEqual('pi', first, second)).toBe(false)
|
|
expect(agentProviderSessionsEqual('claude', first, second)).toBe(true)
|
|
})
|
|
|
|
it('rejects devin resume when provider session key is not session_id', () => {
|
|
expect(getAgentResumeArgv('devin', { key: 'conversation_id', id: 'x' })).toBeNull()
|
|
})
|
|
|
|
it('captures the hook transcript_path for native-chat agents (claude/codex)', () => {
|
|
expect(
|
|
extractAgentProviderSession('claude', {
|
|
session_id: 'cs',
|
|
transcript_path: '/home/u/.claude/projects/slug/real.jsonl'
|
|
})
|
|
).toEqual({
|
|
key: 'session_id',
|
|
id: 'cs',
|
|
transcriptPath: '/home/u/.claude/projects/slug/real.jsonl'
|
|
})
|
|
expect(
|
|
extractAgentProviderSession('codex', { session_id: 'xs', transcriptPath: '/x/r.jsonl' })
|
|
).toEqual({ key: 'session_id', id: 'xs', transcriptPath: '/x/r.jsonl' })
|
|
})
|
|
|
|
it('does not attach transcript_path for non-native-chat agents', () => {
|
|
expect(
|
|
extractAgentProviderSession('gemini', { session_id: 'gs', transcript_path: '/x/r.jsonl' })
|
|
).toEqual({ key: 'session_id', id: 'gs' })
|
|
})
|
|
|
|
it('round-trips transcriptPath through normalizeAgentProviderSession', () => {
|
|
expect(
|
|
normalizeAgentProviderSession({ key: 'session_id', id: 'ok', transcriptPath: '/x/r.jsonl' })
|
|
).toEqual({ key: 'session_id', id: 'ok', transcriptPath: '/x/r.jsonl' })
|
|
expect(
|
|
normalizeAgentProviderSession({
|
|
key: 'session_id',
|
|
id: 'ok',
|
|
transcriptPath: '/tmp/bad\npath.jsonl'
|
|
})
|
|
).toEqual({ key: 'session_id', id: 'ok' })
|
|
})
|
|
})
|