Files
orca/tests/e2e/omp-child-session-recovery.unit.test.ts
latteandNeil bb2afe1792 fix(ai-vault): discover and parse Devin sessions on Windows (#21337)
* fix(ai-vault): discover and parse Devin sessions on Windows, restore workspace mapping

Devin sessions never appeared in the AI Vault on Windows, and parsed
nearly empty elsewhere:

- The transcripts root hardcoded the XDG layout
  (~/.local/share/devin/cli/transcripts), but Devin CLI writes under
  %APPDATA%/devin/cli/transcripts on Windows. The root is now
  platform-aware (APPDATA on win32, XDG_DATA_HOME elsewhere) for both
  local scans and win32 remote hosts, and APPDATA joins the scanner
  child's env allowlist so relocated AppData resolves.
- The parser read metadata.is_user_input / created_at / metrics, which
  real ATIF-v1.7 transcripts don't carry. It now also accepts the ATIF
  step shape (source, timestamp, step-level metrics/model_name,
  plain-string message) while keeping the legacy shape.
- ATIF transcripts carry no working_directory, so sessions couldn't
  group under a workspace. The sibling sessions.db index is now merged
  through the existing sidecar seam: it fills cwd/title/model/
  timestamps, honors the db's hidden flag, and re-merges on db-only
  changes without re-reading transcripts.

* fix(ai-vault): inline Devin transcripts root, harden parser/db edge cases

- Resolve the platform-aware Devin cli dir in agent-sources instead of
  importing the shared devin-cli-data-dir module, which is not part of
  this change (broke typecheck).
- Exclude source:'system' steps unconditionally, even when legacy
  metadata fields would classify them as user/assistant messages.
- Guard unix-seconds conversion against out-of-range values so a single
  bad sessions.db row cannot mark the whole index unreadable.

* fix(ai-vault): watch sessions.db-wal so live Devin metadata cannot go stale

In WAL mode, committed rows sit in sessions.db-wal while sessions.db
keeps its stat until checkpoint, so keying the dependency on the db
alone could serve a stale index. The dependency now observes the wal
when one exists; the reader still opens sessions.db itself.

* fix(ai-vault): probe sessions.db-wal through the WSL-gated stat

existsSync bypasses wslGatedStat and can hang a scan on a stalled 9P
mount; the fs-import guard forbids it in session-scanner modules. The
dependency path resolution is now async and probes through the gate.

* fix(ai-vault): honor zero metrics and array messages in Devin steps

- firstDevinMetricValue skipped explicit numeric zeros, letting a
  lower-priority positive metric win and overstating token totals.
- ATIF allows step.message as an array of content parts; route it
  through extractContentText so those steps still feed title/preview.

* test(ai-vault): cover array-valued ATIF message extraction

The extractDevinStepText fallback that routes an array-valued
step.message through extractContentText shipped without a fixture that
produces that shape, so a future refactor could silently drop the
branch. Pin that an array of text parts feeds the step's title and
preview.

* fix(ai-vault): invalidate old Devin caches and bound database retries

* Discover current Devin ATIF exports alongside legacy transcripts

* Recognize drawn geometry in the browser markup contract test

* Deduplicate Devin exports across transcript roots

* Account for the workspace sleep-state reader in scan budget

* Align OMP integration tests with recorded-path resume

* fix: update scan benchmarks and await relay environment test

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-19 03:32:12 -07:00

126 lines
4.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { createAgentStatusExtensionHarness } from '../../src/main/pi/agent-status-extension-test-harness'
import { normalizeHookPayload } from '../../src/shared/agent-hook-listener'
import { createHookListenerState } from '../../src/shared/agent-hook-listener/listener-state'
import { getAgentResumeArgv } from '../../src/shared/agent-session-resume'
import { buildAgentResumeStartupPlan } from '../../src/shared/tui-agent-resume-startup'
import { sleepingAgentSessionsByPaneKeySchema } from '../../src/shared/workspace-session-sleeping-agents'
import { createTestStore, makeTab } from '../../src/renderer/src/store/slices/store-test-helpers'
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
const ROOT_SESSION = '22222222-2222-4222-8222-222222222222'
const ROOT_TRANSCRIPT_PATH = '/sessions/root.jsonl'
const CHILD_SESSION = '33333333-3333-4333-8333-333333333333'
async function rootWithActiveChild(): Promise<ReturnType<typeof createTestStore>> {
const store = createTestStore()
store.setState({ tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } })
const listener = createHookListenerState()
const harness = createAgentStatusExtensionHarness({
kind: 'omp',
env: {
ORCA_PANE_KEY: PANE,
ORCA_WORKTREE_ID: 'wt-1',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_AGENT_HOOK_ENV: 'production'
},
fetchImpl: async (_url, init) => {
const event = normalizeHookPayload(
listener,
'omp',
JSON.parse(String(init?.body)),
'production'
)
if (!event) {
throw new Error('Expected a normalized OMP hook')
}
store
.getState()
.setAgentStatus(
PANE,
event.payload,
'OMP',
{ updatedAt: Date.now(), stateStartedAt: Date.now() },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: event.providerSession, launchToken: event.launchToken }
)
return { ok: true }
}
})
const emit = async (
name: string,
event: unknown,
sessionManager: {
getSessionId: () => string
getSessionFile: () => string
}
): Promise<void> => {
await harness.callHook(name, event, { sessionManager })
for (let i = 0; i < 10; i++) {
await Promise.resolve()
}
}
const root = { getSessionId: () => ROOT_SESSION, getSessionFile: () => ROOT_TRANSCRIPT_PATH }
await emit('session_start', {}, root)
await emit('before_agent_start', { prompt: 'ROOT distinctive user request' }, root)
await emit(
'message_end',
{ message: { role: 'assistant', content: 'ROOT assistant preview' } },
root
)
expect(store.getState().agentStatusByPaneKey[PANE]?.providerSession?.id).toBe(ROOT_SESSION)
// OMP binds each task's extension factory to a different SessionManager in the same process.
harness.reload()
const child = {
getSessionId: () => CHILD_SESSION,
getSessionFile: () => '/sessions/root/worker.jsonl'
}
await emit('session_start', {}, child)
await emit('before_agent_start', { prompt: 'CHILD delegated bootstrap' }, child)
await emit(
'message_end',
{ message: { role: 'assistant', content: 'CHILD assistant preview' } },
child
)
return store
}
describe('OMP child lifecycle recovery boundaries', () => {
it('retains the normalized root prompt and assistant preview while its child works (#9348)', async () => {
const store = await rootWithActiveChild()
expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject({
prompt: 'ROOT distinctive user request',
lastAssistantMessage: 'ROOT assistant preview',
state: 'working',
providerSession: { id: ROOT_SESSION }
})
})
it('captures and hydrates the root identity into resume startup after child hooks (#16353)', async () => {
const store = await rootWithActiveChild()
store.getState().captureAllSleepingAgentSessions('quit')
const serialized = JSON.stringify(store.getState().sleepingAgentSessionsByPaneKey)
const hydrated = sleepingAgentSessionsByPaneKeySchema.parse(JSON.parse(serialized))
const record = hydrated?.[PANE]
if (!record) {
throw new Error('Expected the root sleeping record to survive hydration')
}
expect(record).toMatchObject({ origin: 'quit', providerSession: { id: ROOT_SESSION } })
expect(getAgentResumeArgv(record.agent, record.providerSession)).toEqual([
'omp',
'--resume',
ROOT_TRANSCRIPT_PATH
])
const startup = buildAgentResumeStartupPlan({
agent: record.agent,
providerSession: record.providerSession,
cmdOverrides: {},
platform: 'linux',
...record.launchConfig
})
expect(startup?.launchCommand).toBe(`omp '--resume' '${ROOT_TRANSCRIPT_PATH}'`)
expect(serialized).not.toContain(CHILD_SESSION)
})
})