mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 167 low-value shared agent tests (#37791)
This commit is contained in:
@@ -11711,30 +11711,21 @@
|
||||
"src/shared/agent-status-transport-envelope.test.ts": 192,
|
||||
"src/shared/agent-status-types.test.ts": 132,
|
||||
"src/shared/agent-status-worktree-attribution.test.ts": 198,
|
||||
"src/shared/agent-terminal-status-equivalence.test.ts": 46,
|
||||
"src/shared/agent-title-agy-gemini-collision.test.ts": 83,
|
||||
"src/shared/agent-title-decoration.test.ts": 43,
|
||||
"src/shared/agent-title-evidence.test.ts": 166,
|
||||
"src/shared/agent-title-identity-characterization.test.ts": 77,
|
||||
"src/shared/agent-transcript-poll-scheduler.test.ts": 113,
|
||||
"src/shared/agent-transitions.test.ts": 105,
|
||||
"src/shared/agent-tui-input-clear.test.ts": 57,
|
||||
"src/shared/ai-vault-resume-command.test.ts": 70,
|
||||
"src/shared/ai-vault-resume-preparation.test.ts": 39,
|
||||
"src/shared/ai-vault-resume-target.test.ts": 85,
|
||||
"src/shared/ai-vault-scan-error-message.test.ts": 54,
|
||||
"src/shared/ai-vault-scope-paths.test.ts": 73,
|
||||
"src/shared/ai-vault-search-agent-history.test.ts": 168,
|
||||
"src/shared/ai-vault-search-contract.test.ts": 148,
|
||||
"src/shared/ai-vault-search-query-operators.test.ts": 82,
|
||||
"src/shared/ai-vault-search-scope-contract.test.ts": 130,
|
||||
"src/shared/ai-vault-search-settings.test.ts": 86,
|
||||
"src/shared/ai-vault-search-status-fallback.test.ts": 175,
|
||||
"src/shared/ai-vault-session-cwd-candidates.test.ts": 113,
|
||||
"src/shared/ai-vault-session-depth.test.ts": 78,
|
||||
"src/shared/ai-vault-session-filters.test.ts": 176,
|
||||
"src/shared/ai-vault-ssh-resume-target.test.ts": 84,
|
||||
"src/shared/ai-vault-types.test.ts": 44,
|
||||
"src/shared/ansi-escape-sequences-consumers.test.ts": 140,
|
||||
"src/shared/ansi-escape-sequences.test.ts": 289,
|
||||
"src/shared/antigravity-generation-plan.test.ts": 122,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { terminalStatusPayloadMatchesHook } from './agent-terminal-status-equivalence'
|
||||
|
||||
describe('terminalStatusPayloadMatchesHook', () => {
|
||||
const hook = {
|
||||
state: 'working' as const,
|
||||
workingMode: 'monitoring' as const,
|
||||
prompt: 'watch tests',
|
||||
agentType: 'claude'
|
||||
}
|
||||
|
||||
it('accepts an otherwise equivalent terminal payload that omits hook-owned mode', () => {
|
||||
expect(
|
||||
terminalStatusPayloadMatchesHook(hook, {
|
||||
state: 'working',
|
||||
prompt: 'watch tests',
|
||||
agentType: 'claude'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a terminal payload from a different turn', () => {
|
||||
expect(
|
||||
terminalStatusPayloadMatchesHook(hook, {
|
||||
state: 'working',
|
||||
prompt: 'fix tests',
|
||||
agentType: 'claude'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentLabel } from './agent-title-identity'
|
||||
import './agent-title-identity'
|
||||
import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type'
|
||||
import { GEMINI_IDLE, GEMINI_WORKING } from './agent-title-core'
|
||||
import './agent-title-core'
|
||||
|
||||
// agy model names include Gemini; the earlier Gemini token detector once mistook model metadata for pane identity.
|
||||
describe('agy titles carrying a Gemini model name', () => {
|
||||
@@ -11,30 +11,7 @@ describe('agy titles carrying a Gemini model name', () => {
|
||||
'agy · Gemini 3.5 Flash (High)'
|
||||
]
|
||||
|
||||
it.each(AGY_TITLES)('labels %s as Antigravity', (title) => {
|
||||
expect(getAgentLabel(title)).toBe('Antigravity')
|
||||
})
|
||||
|
||||
// Exercise both sidebar and tab detector chains so fixing one cannot leave the surfaces disagreeing.
|
||||
it.each(AGY_TITLES)('resolves %s as antigravity on the tab path too', (title) => {
|
||||
expect(resolveExplicitTerminalTitleAgentType(title)).toBe('antigravity')
|
||||
})
|
||||
})
|
||||
|
||||
describe('titles that must keep their existing label', () => {
|
||||
// Recorded Grok task text mentions Antigravity; narrowing the Gemini token avoids changing this ordered-chain result.
|
||||
it('keeps a grok pane whose task text names Antigravity', () => {
|
||||
expect(getAgentLabel('STA-4011 Linux Antigravity Commit Messages - grok')).toBe('Grok')
|
||||
})
|
||||
|
||||
// Why: the four Gemini OSC glyphs are decisive and stay so; agy emits none of them.
|
||||
it('still labels a glyph-bearing Gemini CLI title', () => {
|
||||
expect(getAgentLabel(`${GEMINI_WORKING} Gemini CLI`)).toBe('Gemini CLI')
|
||||
expect(getAgentLabel(`${GEMINI_IDLE} Gemini CLI`)).toBe('Gemini CLI')
|
||||
})
|
||||
|
||||
// Why: a Gemini CLI title with no agy token must be unaffected by the narrowing.
|
||||
it('still labels a bare gemini token title', () => {
|
||||
expect(getAgentLabel('gemini ready')).toBe('Gemini CLI')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
stripLeadingAgentTitleDecoration,
|
||||
stripLeadingAgentTitleDecorationOrEmpty
|
||||
} from './agent-title-decoration'
|
||||
|
||||
describe('stripLeadingAgentTitleDecoration', () => {
|
||||
it('strips Qoder permission decoration without stripping triangles inside a title', () => {
|
||||
expect(stripLeadingAgentTitleDecoration('▲ Qoder CLI')).toBe('Qoder CLI')
|
||||
expect(stripLeadingAgentTitleDecoration('deploy ▲ complete')).toBe('deploy ▲ complete')
|
||||
})
|
||||
|
||||
it("strips Claude's ✳ idle glyph", () => {
|
||||
expect(stripLeadingAgentTitleDecoration('✳ Claude Code')).toBe('Claude Code')
|
||||
})
|
||||
|
||||
it("strips Claude's working/idle text prefixes", () => {
|
||||
expect(stripLeadingAgentTitleDecoration('. working on the fix')).toBe('working on the fix')
|
||||
expect(stripLeadingAgentTitleDecoration('* Claude Code')).toBe('Claude Code')
|
||||
})
|
||||
|
||||
it('strips a leading braille spinner', () => {
|
||||
expect(stripLeadingAgentTitleDecoration('⠋ Pi')).toBe('Pi')
|
||||
})
|
||||
|
||||
it('leaves an undecorated title untouched', () => {
|
||||
expect(stripLeadingAgentTitleDecoration('Dolphin-2')).toBe('Dolphin-2')
|
||||
expect(stripLeadingAgentTitleDecoration('npm run dev')).toBe('npm run dev')
|
||||
})
|
||||
|
||||
it('keeps the original when the title is only a status glyph', () => {
|
||||
expect(stripLeadingAgentTitleDecoration('✳')).toBe('✳')
|
||||
expect(stripLeadingAgentTitleDecoration('✳ ')).toBe('✳ ')
|
||||
})
|
||||
|
||||
it('can strip to empty when the caller supplies its own fallback label', () => {
|
||||
expect(stripLeadingAgentTitleDecorationOrEmpty('✳')).toBe('')
|
||||
expect(stripLeadingAgentTitleDecorationOrEmpty('✳ ')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
GEMINI_IDLE,
|
||||
GEMINI_PERMISSION,
|
||||
GEMINI_SILENT_WORKING,
|
||||
GEMINI_WORKING
|
||||
} from './agent-title-core'
|
||||
import './agent-title-core'
|
||||
import {
|
||||
collectAgentTitleEvidence,
|
||||
collectExplicitAgentTitleEvidence
|
||||
@@ -13,7 +8,6 @@ import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter
|
||||
import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity'
|
||||
|
||||
const agentFor = (title: string) => collectAgentTitleEvidence(title).agent
|
||||
const reasonFor = (title: string) => collectAgentTitleEvidence(title).reason
|
||||
|
||||
describe('collectAgentTitleEvidence', () => {
|
||||
it.each([
|
||||
@@ -49,19 +43,6 @@ describe('collectAgentTitleEvidence', () => {
|
||||
])('resolves %j to the suffix owner', (title) => {
|
||||
expect(agentFor(title)).toBe('grok')
|
||||
})
|
||||
|
||||
it('does not read a hyphenated worktree name as an owner suffix', () => {
|
||||
// Require whitespace before the owner-suffix dash so directory names such as review-14600-codex do not claim ownership.
|
||||
expect(agentFor('review-14600-codex')).toBeNull()
|
||||
expect(agentFor('codex-split-core')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['pi', 'omp', 'claude-agent-teams', 'qwen-code'] as const)(
|
||||
'recognizes the reserved owner id %s',
|
||||
(agent) => {
|
||||
expect(agentFor(`Review another agent… - ${agent}`)).toBe(agent)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('order independence', () => {
|
||||
@@ -81,113 +62,18 @@ describe('collectAgentTitleEvidence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', 'claude'],
|
||||
['openclaude', 'openclaude'],
|
||||
['codex', 'codex'],
|
||||
['copilot', 'copilot'],
|
||||
['cursor', 'cursor'],
|
||||
['gemini', 'gemini'],
|
||||
['antigravity', 'antigravity'],
|
||||
['opencode', 'opencode'],
|
||||
['mimo', 'mimo-code'],
|
||||
['openclaw', 'openclaw'],
|
||||
['aider', 'aider'],
|
||||
['grok', 'grok'],
|
||||
['devin', 'devin']
|
||||
] as const)('collects the free-text token %s without claiming identity', (token, agent) => {
|
||||
expect(collectAgentTitleEvidence(`review the ${token} integration`)).toMatchObject({
|
||||
agent: null,
|
||||
reason: 'free-text-only',
|
||||
freeTextNames: [agent]
|
||||
})
|
||||
})
|
||||
|
||||
describe('a vendor marker is evidence the agent emitted, not text a human typed', () => {
|
||||
it('keeps a Claude pane Claude when its task text names another agent', () => {
|
||||
// 13 recorded titles have this shape. The sigil is emitted by Claude; the name is typed.
|
||||
expect(agentFor('✳ Fix Codex false attention notifications on Windows')).toBe('claude')
|
||||
expect(reasonFor('✳ Consolidate Codex subagent sidebar rows')).toBe('vendor-marker')
|
||||
})
|
||||
|
||||
it('keeps the Claude marker when the remaining bare name is unanchored', () => {
|
||||
expect(agentFor('✳ agy')).toBe('claude')
|
||||
expect(reasonFor('✳ agy')).toBe('vendor-marker')
|
||||
expect(agentFor('✳ codex')).toBe('claude')
|
||||
expect(reasonFor('✳ codex')).toBe('vendor-marker')
|
||||
})
|
||||
|
||||
it('keeps an OpenCode envelope OpenCode when its session text names another agent', () => {
|
||||
expect(agentFor('OC | QA PR #14582 Cursor sidecar SSH arms')).toBe('opencode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Antigravity model names are metadata, not identity', () => {
|
||||
it('reads an identity segment plus a model name as Antigravity', () => {
|
||||
expect(agentFor('agy · Gemini 3.7 Flash')).toBe('antigravity')
|
||||
expect(agentFor('Antigravity — Gemini 3.7 Flash')).toBe('antigravity')
|
||||
})
|
||||
|
||||
it('declines a bare model name rather than guessing Gemini CLI', () => {
|
||||
// Without identity grammar/vendor evidence, free-text agy/Gemini names are ambiguous.
|
||||
expect(agentFor('Gemini 3.7 Flash · high')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([GEMINI_WORKING, GEMINI_SILENT_WORKING, GEMINI_IDLE, GEMINI_PERMISSION])(
|
||||
'still resolves the real Gemini marker %s',
|
||||
(marker) => {
|
||||
expect(agentFor(`${marker} Refactor the parser`)).toBe('gemini')
|
||||
}
|
||||
)
|
||||
|
||||
it('does not promote model names in task text', () => {
|
||||
expect(agentFor('Compare Antigravity with Gemini 3.7 Flash')).toBeNull()
|
||||
expect(agentFor('Compare Antigravity with Gemini 3.7 Flash… - grok')).toBe('grok')
|
||||
})
|
||||
|
||||
it('does not treat a Gemini glyph inside task text as a vendor marker', () => {
|
||||
const evidence = collectAgentTitleEvidence('Explain the ✦ marker… - grok')
|
||||
expect(evidence.agent).toBe('grok')
|
||||
expect(evidence.vendorMarkers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a name in free text alone is never identity', () => {
|
||||
it.each([
|
||||
'◐ DaemonConnectionLostError with 70 Codex agents',
|
||||
'Fix the grok hook',
|
||||
'Debug the cursor sidecar',
|
||||
'grok',
|
||||
'⠋ grok',
|
||||
'⠋ codex'
|
||||
])('declines %j', (title) => {
|
||||
expect(agentFor(title)).toBeNull()
|
||||
expect(reasonFor(title)).toBe('free-text-only')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Claude Code', 'claude'],
|
||||
['Gemini CLI', 'gemini'],
|
||||
['Claude Agent Teams', 'claude-agent-teams'],
|
||||
['MiMo Code', 'mimo-code'],
|
||||
['Prime Agent', 'prime-agent'],
|
||||
['Command Code', 'command-code'],
|
||||
['GitHub Copilot', 'copilot'],
|
||||
['Agent Teams', 'claude-agent-teams']
|
||||
] as const)('recognizes the emitted whole-title alias %s', (title, agent) => {
|
||||
expect(agentFor(title)).toBe(agent)
|
||||
expect(reasonFor(title)).toBe('anchored')
|
||||
})
|
||||
|
||||
it.each(['Continue', 'Charm', 'Goose', 'Amp'])(
|
||||
'does not treat the UI-only display label %s as identity',
|
||||
(title) => {
|
||||
expect(agentFor(title)).toBeNull()
|
||||
expect(reasonFor(title)).toBe('no-evidence')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'~/codex',
|
||||
'/grok',
|
||||
@@ -200,74 +86,11 @@ describe('collectAgentTitleEvidence', () => {
|
||||
expect(agentFor(title)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not duplicate an anchored token as free text', () => {
|
||||
expect(collectAgentTitleEvidence('codex.exe').freeTextNames).toEqual([])
|
||||
expect(collectAgentTitleEvidence('Claude Agent Teams').freeTextNames).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Codex ready', 'codex'],
|
||||
['Codex - action required', 'codex'],
|
||||
['Cursor ready', 'cursor'],
|
||||
['Droid - action required', 'droid'],
|
||||
['Hermes ready', 'hermes'],
|
||||
['Devin - action required', 'devin'],
|
||||
['Pi ready', 'pi'],
|
||||
['OMP - action required', 'omp']
|
||||
] as const)('recognizes Orca-controlled synthetic title %s', (title, agent) => {
|
||||
expect(agentFor(title)).toBe(agent)
|
||||
expect(reasonFor(title)).toBe('anchored')
|
||||
})
|
||||
|
||||
it.each(['Droid', 'Hermes', 'Devin'])(
|
||||
'does not treat a bare working label as synthetic identity: %s',
|
||||
(title) => {
|
||||
expect(agentFor(title)).toBeNull()
|
||||
expect(reasonFor(title)).toBe('free-text-only')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'Claude Code ready',
|
||||
'Claude thinking',
|
||||
'. Claude Code working',
|
||||
'zsh | ⠋ Claude Code - action required'
|
||||
])('recognizes the explicit Claude identity frame %s', (title) => {
|
||||
expect(agentFor(title)).toBe('claude')
|
||||
expect(reasonFor(title)).toBe('anchored')
|
||||
})
|
||||
|
||||
it('does not promote Claude status words in task text', () => {
|
||||
expect(agentFor('Fix the Claude Code ready-state parser')).toBeNull()
|
||||
expect(agentFor('Fix Claude Code ready behavior… - grok')).toBe('grok')
|
||||
})
|
||||
|
||||
it.each(['. Review the parser', '* Waiting for input'])(
|
||||
'recognizes the established Claude status prefix in %s',
|
||||
(title) => {
|
||||
expect(agentFor(title)).toBe('claude')
|
||||
expect(reasonFor(title)).toBe('vendor-marker')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['⠋ Cursor Agent', 'cursor'],
|
||||
['⠋ Pi idle', 'pi'],
|
||||
['⠋ OMP done', 'omp'],
|
||||
['⠋ Droid', 'droid'],
|
||||
['⠋ Hermes', 'hermes'],
|
||||
['⠋ Devin', 'devin']
|
||||
] as const)('recognizes the decorated identity frame %s', (title, agent) => {
|
||||
expect(agentFor(title)).toBe(agent)
|
||||
expect(reasonFor(title)).toBe('anchored')
|
||||
})
|
||||
|
||||
it('does not invent synthetic titles for an opted-out profile', () => {
|
||||
expect(agentFor('OpenCode ready')).toBeNull()
|
||||
expect(agentFor('⠋ OpenCode')).toBeNull()
|
||||
expect(reasonFor('OpenCode ready')).toBe('free-text-only')
|
||||
})
|
||||
|
||||
it('reads identity from the innermost wrapper segment', () => {
|
||||
expect(agentFor('zsh | ⠋ Claude Code')).toBe('claude')
|
||||
expect(agentFor('ssh | tmux | Cursor Agent')).toBe('cursor')
|
||||
@@ -281,63 +104,6 @@ describe('collectAgentTitleEvidence', () => {
|
||||
expect(collectExplicitAgentTitleEvidence(title).agent).toBe('opencode')
|
||||
})
|
||||
|
||||
it('bounds wrapper inspection while preserving innermost identity', () => {
|
||||
const wrappers = Array.from({ length: 200 }, (_, index) => `wrapper-${index}`).join(' | ')
|
||||
expect(agentFor(`${wrappers} | ⠋ Cursor Agent`)).toBe('cursor')
|
||||
expect(agentFor(`${wrappers} | OC | review the parser`)).toBe('opencode')
|
||||
expect(agentFor(`outer-a | outer-b | OC | ${wrappers} | Cursor Agent`)).toBe('cursor')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['codex.exe', 'codex'],
|
||||
['openclaude.cmd', 'openclaude'],
|
||||
['gemini.ps1', 'gemini'],
|
||||
['droid.cmd', 'droid'],
|
||||
['hermes.exe', 'hermes'],
|
||||
['agy.bat', 'antigravity'],
|
||||
['CODEX.EXE', 'codex'],
|
||||
['DROID.CMD', 'droid']
|
||||
] as const)('recognizes the bare Windows launcher %s', (title, agent) => {
|
||||
expect(agentFor(title)).toBe(agent)
|
||||
expect(reasonFor(title)).toBe('anchored')
|
||||
})
|
||||
|
||||
it('produces no name evidence for an agent outside the token set', () => {
|
||||
// Keep the evidence token set narrow: short names such as omp would classify ordinary shell text.
|
||||
expect(reasonFor('Review PR for OMP transcript rendering')).toBe('no-evidence')
|
||||
})
|
||||
|
||||
describe('activity is not identity', () => {
|
||||
it.each(['◐ Rebase PR #14624 onto main', '⠂ Fix SSH fallback', '⠋ Thinking'])(
|
||||
'declines the spinner-only title %j',
|
||||
(title) => {
|
||||
// Shared braille/quarter-circle spinners prove activity only; identity requires other evidence.
|
||||
expect(agentFor(title)).toBeNull()
|
||||
expect(reasonFor(title)).toBe('no-evidence')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat an embedded Claude sigil as a vendor marker', () => {
|
||||
expect(collectAgentTitleEvidence('task text ✳ decoration')).toEqual({
|
||||
vendorMarkers: [],
|
||||
anchoredNames: [],
|
||||
freeTextNames: [],
|
||||
agent: null,
|
||||
reason: 'no-evidence'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a bare Claude sigil as a vendor marker', () => {
|
||||
expect(collectAgentTitleEvidence('✳')).toEqual({
|
||||
vendorMarkers: ['claude'],
|
||||
anchoredNames: [],
|
||||
freeTextNames: [],
|
||||
agent: 'claude',
|
||||
reason: 'vendor-marker'
|
||||
})
|
||||
})
|
||||
|
||||
describe('conflicting evidence of the same class resolves to nothing', () => {
|
||||
it('declines two anchored names', () => {
|
||||
const evidence = collectAgentTitleEvidence('OC | something… - grok')
|
||||
@@ -372,11 +138,6 @@ describe('collectAgentTitleEvidence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires whitespace before the owner suffix dash', () => {
|
||||
expect(agentFor('task- codex')).toBeNull()
|
||||
expect(reasonFor('task- codex')).toBe('free-text-only')
|
||||
})
|
||||
|
||||
it('terminates when a wrapper title starts with a separator', () => {
|
||||
expect(agentFor(' | ')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentLabel } from './agent-title-identity'
|
||||
|
||||
/**
|
||||
* Characterization of `getAgentLabel` before the identity refactor.
|
||||
*
|
||||
* `getAgentLabel` is an ordered first-match-wins scan of title predicates. Detector precedence
|
||||
* and targeted exceptions — not a unified evidence model — decide identity. These tests pin
|
||||
* the current answers, including the wrong ones, so the resolver change shows up as a
|
||||
* reviewable diff of assertions rather than as silent behavior drift.
|
||||
*
|
||||
* Cases marked DEFECT are minimized from real recorded pane titles
|
||||
* (`terminal-history/<id>/checkpoint.json` -> `lastTitle`). At the time of writing the live
|
||||
* corpus held 2,846 checkpoints / 1,084 populated titles / 747 distinct, of which 5 real titles
|
||||
* resolved to the wrong agent. Task text is minimized here; the corpus stays local.
|
||||
*/
|
||||
|
||||
/** Orca's own owner suffix: the agent that owns the pane is named after the final `- `. */
|
||||
const ownerSuffix = (task: string, agent: string): string => `${task}… - ${agent}`
|
||||
|
||||
describe('getAgentLabel — characterization (pre-refactor)', () => {
|
||||
describe('the owner suffix loses to a foreign name in task text', () => {
|
||||
// DEFECT. In each case the pane owner is Grok, named by Orca's own `- grok` suffix
|
||||
// grammar, while the competing agent appears only inside free-form task text. Codex is
|
||||
// checked before Grok, so the weaker evidence wins.
|
||||
it.each([
|
||||
['Switch Claude and Codex off the load balancer', 'Codex'],
|
||||
['Codex structured chat revalidation', 'Codex'],
|
||||
['Swap Codex off the load balancer', 'Codex']
|
||||
])('reads %j as %s instead of Grok', (task, current) => {
|
||||
expect(getAgentLabel(ownerSuffix(task, 'grok'))).toBe(current)
|
||||
})
|
||||
|
||||
it('reads a spinner-prefixed Grok pane as Codex', () => {
|
||||
// DEFECT. Real shape: a status spinner and phase precede the task text.
|
||||
expect(getAgentLabel(`⠸ - Thinking - ${ownerSuffix('Codex native-chat work', 'grok')}`)).toBe(
|
||||
'Codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a Grok pane naming two other agents, but only by a targeted exception', () => {
|
||||
// Fixed by #15535, which teaches the Gemini token branch to decline when an Antigravity
|
||||
// name is present. Correct here only because that exception happens to clear the path to
|
||||
// Grok — drop the word "Antigravity" and it reads as Gemini CLI again (next case).
|
||||
expect(
|
||||
getAgentLabel(ownerSuffix('Electron QA: Antigravity tab vs Gemini label', 'grok'))
|
||||
).toBe('Grok')
|
||||
})
|
||||
|
||||
it('still reads a Grok pane as Gemini CLI when no exception covers the pair', () => {
|
||||
// DEFECT, and the general form of the case above: one narrowing fixes one collision.
|
||||
expect(getAgentLabel(ownerSuffix('Electron QA: check the Gemini label', 'grok'))).toBe(
|
||||
'Gemini CLI'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the owner suffix correctly only when no earlier agent is named', () => {
|
||||
// Why this passes today: nothing earlier in the chain matches, so position never comes up.
|
||||
expect(getAgentLabel(ownerSuffix('Fix the sidebar row', 'grok'))).toBe('Grok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a hyphenated worktree name is correctly not identity', () => {
|
||||
// Not a defect — pinned so the resolver does not start claiming these.
|
||||
it.each(['review-14600-codex', 'sta4779-review-codex', 'codex-split-core'])(
|
||||
'declines %j',
|
||||
(title) => {
|
||||
expect(getAgentLabel(title)).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('title order does not decide between two names', () => {
|
||||
// Both orderings of each pair agree: detector precedence and its targeted exceptions,
|
||||
// rather than name order within the title, choose the label.
|
||||
it.each([
|
||||
['codex', 'grok', 'Codex'],
|
||||
['grok', 'codex', 'Codex'],
|
||||
['gemini', 'antigravity', 'Antigravity'],
|
||||
['antigravity', 'gemini', 'Antigravity'],
|
||||
['copilot', 'devin', 'GitHub Copilot'],
|
||||
['devin', 'copilot', 'GitHub Copilot']
|
||||
])('%s + %s both resolve to %s', (first, second, winner) => {
|
||||
expect(getAgentLabel(`${first} and ${second}`)).toBe(winner)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a vendor glyph is claimed by whichever check sits earliest', () => {
|
||||
it('claims a Claude glyph even when the task text names another agent', () => {
|
||||
// Correct today (the pane really is Claude) but for the wrong reason: the glyph is not
|
||||
// consulted as vendor evidence, the `✳ ` prefix branch simply sits first.
|
||||
expect(getAgentLabel('✳ Fix Codex false attention notifications on Windows')).toBe(
|
||||
'Claude Code'
|
||||
)
|
||||
})
|
||||
|
||||
it('gives a bare agent name no way to outrank an earlier glyph check', () => {
|
||||
// DEFECT. `agy` is the entire undecorated remainder — the strongest possible name
|
||||
// evidence — and still loses to Claude's prefix glyph.
|
||||
expect(getAgentLabel('✳ agy')).toBe('Claude Code')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Antigravity model names', () => {
|
||||
it('reads a bare Antigravity model title as Gemini CLI', () => {
|
||||
// DEFECT. Antigravity models are named `Gemini <n.n> <Name>`, so an agy pane's own title
|
||||
// carries a whole `gemini` token, and Gemini CLI is checked first.
|
||||
expect(getAgentLabel('Gemini 3.7 Flash · high')).toBe('Gemini CLI')
|
||||
})
|
||||
|
||||
it('resolves once the Antigravity name is also present', () => {
|
||||
// Fixed by #15535. The resolver reaches the same answer structurally — the agy segment is
|
||||
// the anchored identity and `Gemini <n.n> <Name>` is model metadata — rather than by
|
||||
// teaching one detector about one competitor.
|
||||
expect(getAgentLabel('agy · Gemini 3.7 Flash')).toBe('Antigravity')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentPhaseTransition,
|
||||
hookRowAgentPhase,
|
||||
hookRowTransition,
|
||||
nextAgentPhase,
|
||||
titleAgentPhase,
|
||||
type AgentPhase
|
||||
} from './agent-transitions'
|
||||
import { AGENT_TRANSITION_SCENARIOS } from './agent-transitions.test-fixture'
|
||||
|
||||
const PHASES: readonly AgentPhase[] = ['rest', 'working', 'needs-you', 'done']
|
||||
|
||||
describe('hookRowAgentPhase', () => {
|
||||
it('maps hook states to phases, and a stopped turn to Rest', () => {
|
||||
expect(hookRowAgentPhase({ state: 'working' })).toBe('working')
|
||||
expect(hookRowAgentPhase({ state: 'blocked' })).toBe('needs-you')
|
||||
expect(hookRowAgentPhase({ state: 'waiting' })).toBe('needs-you')
|
||||
expect(hookRowAgentPhase({ state: 'done' })).toBe('done')
|
||||
expect(hookRowAgentPhase({ state: 'done', interrupted: true })).toBe('rest')
|
||||
expect(
|
||||
hookRowAgentPhase({ state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } })
|
||||
).toBe('rest')
|
||||
// An error is news: it ranks as Done, like a finish.
|
||||
expect(
|
||||
hookRowAgentPhase({ state: 'done', mainAgent: { state: 'done', outcome: 'failure' } })
|
||||
).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('titleAgentPhase', () => {
|
||||
it('reads the title heuristic Smart uses', () => {
|
||||
expect(titleAgentPhase('⠋ Codex working')).toBe('working')
|
||||
expect(titleAgentPhase('zsh')).toBe('rest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPhaseTransition', () => {
|
||||
it('names the news in every phase change', () => {
|
||||
const table = Object.fromEntries(
|
||||
PHASES.flatMap((from) =>
|
||||
PHASES.map((to) => [`${from} -> ${to}`, agentPhaseTransition(from, to)])
|
||||
)
|
||||
)
|
||||
expect(table).toEqual({
|
||||
'rest -> rest': null,
|
||||
'rest -> working': 'prompt-submitted',
|
||||
'rest -> needs-you': 'needs-input',
|
||||
'rest -> done': 'turn-finished',
|
||||
'working -> rest': null,
|
||||
'working -> working': null,
|
||||
'working -> needs-you': 'needs-input',
|
||||
'working -> done': 'turn-finished',
|
||||
'needs-you -> rest': null,
|
||||
'needs-you -> working': null,
|
||||
'needs-you -> needs-you': null,
|
||||
'needs-you -> done': 'turn-finished',
|
||||
'done -> rest': null,
|
||||
'done -> working': 'prompt-submitted',
|
||||
'done -> needs-you': 'needs-input',
|
||||
'done -> done': null
|
||||
})
|
||||
})
|
||||
|
||||
it('tells a failed turn from a finish only by its row', () => {
|
||||
const failed = {
|
||||
state: 'done' as const,
|
||||
mainAgent: { state: 'done' as const, outcome: 'failure' as const }
|
||||
}
|
||||
expect(hookRowTransition('working', 'done', failed)).toBe('errored')
|
||||
expect(hookRowTransition('working', 'done', { state: 'done' })).toBe('turn-finished')
|
||||
expect(hookRowTransition('done', 'done', failed)).toBeNull()
|
||||
})
|
||||
|
||||
it('names no news for a session boundary, whose phase still reads done', () => {
|
||||
const boundary = { state: 'done' as const, sessionBoundary: true }
|
||||
expect(hookRowAgentPhase(boundary)).toBe('done')
|
||||
|
||||
@@ -1,88 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_FORWARD,
|
||||
AGENT_TUI_CLEAR_INPUT_LINE,
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
AGENT_TUI_CLEAR_LINE_SLACK,
|
||||
AGENT_TUI_CLEAR_MAX_LINES,
|
||||
buildAgentTuiClearInput,
|
||||
buildAgentTuiClearInputForText,
|
||||
countAgentTuiInputLines
|
||||
} from './agent-tui-input-clear'
|
||||
|
||||
const countCtrlU = (bytes: string): number =>
|
||||
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length
|
||||
const countCtrlK = (bytes: string): number =>
|
||||
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length
|
||||
import { AGENT_TUI_CLEAR_INPUT_MAX } from './agent-tui-input-clear'
|
||||
|
||||
describe('buildAgentTuiClearInput', () => {
|
||||
// Pin N kills + N-1 joins for every small N; constant bursts under-clear multiline drafts and glue residue onto the next message.
|
||||
it.each([
|
||||
[1, 1],
|
||||
[2, 3],
|
||||
[3, 5],
|
||||
[4, 7],
|
||||
[10, 19]
|
||||
])('clears %i logical lines with %i Ctrl+U', (lines, expected) => {
|
||||
const clearInput = buildAgentTuiClearInput(lines)
|
||||
expect(countCtrlU(clearInput)).toBe(expected)
|
||||
expect(countCtrlK(clearInput)).toBe(expected)
|
||||
})
|
||||
|
||||
it('clears before the cursor before clearing the suffix after it', () => {
|
||||
expect(buildAgentTuiClearInput(4)).toBe('\x15'.repeat(7) + '\x0b'.repeat(7))
|
||||
})
|
||||
|
||||
it('still clears one line for a zero or negative count', () => {
|
||||
expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1)
|
||||
expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1)
|
||||
})
|
||||
|
||||
it('caps the burst so a pathological draft cannot emit an unbounded write', () => {
|
||||
expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1)
|
||||
expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES))
|
||||
})
|
||||
|
||||
it('keeps the widest burst under the 64-byte chunk Claude Code reads as a paste', () => {
|
||||
// Claude Code 2.1.314 dropped a 64-byte burst and kept a 63-byte one.
|
||||
expect(AGENT_TUI_CLEAR_INPUT_MAX.length).toBeLessThan(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAgentTuiInputLines', () => {
|
||||
it.each([
|
||||
['one line', 1],
|
||||
['a\nb', 2],
|
||||
['a\r\nb\r\nc', 3],
|
||||
['a\rb', 2],
|
||||
['trailing\n', 2]
|
||||
])('counts %j as %i logical lines', (text, expected) => {
|
||||
expect(countAgentTuiInputLines(text)).toBe(expected)
|
||||
})
|
||||
|
||||
it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => {
|
||||
expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAgentTuiClearInputForText', () => {
|
||||
it('sizes the burst from the text plus slack for TUI-side edits', () => {
|
||||
// The injected text is a LOWER bound: the user can type into the TUI line too.
|
||||
expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe(
|
||||
2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1
|
||||
)
|
||||
})
|
||||
|
||||
it('clears strictly more than the draft needs, never less', () => {
|
||||
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n'
|
||||
expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan(
|
||||
2 * countAgentTuiInputLines(draft) - 1
|
||||
)
|
||||
})
|
||||
|
||||
it('a long wrapped single line does not inflate the burst', () => {
|
||||
expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe(
|
||||
buildAgentTuiClearInputForText('y')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,201 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildAiVaultResumeCommand,
|
||||
buildAiVaultResumeShellCommand
|
||||
} from './ai-vault-resume-command'
|
||||
|
||||
describe('buildAiVaultResumeCommand', () => {
|
||||
it('uses Antigravity conversation ids instead of Gemini resume flags', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/repo/app' && agy --conversation 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'")
|
||||
})
|
||||
|
||||
it('builds a self-contained cmd wrapper when no live shell is known', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32'
|
||||
})
|
||||
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
|
||||
})
|
||||
|
||||
it('resumes CodeBuddy sessions with the Claude-compatible --resume flag', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codebuddy',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/repo/app' && codebuddy --resume 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'")
|
||||
})
|
||||
|
||||
it('builds a direct queued command for a live cmd shell', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'omp',
|
||||
sessionId: 'session-one',
|
||||
resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\sessions\\A&B session one.jsonl',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\A&B repo',
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
).toBe(
|
||||
'cd /d "C:\\Users\\Ada Lovelace\\A&B repo" && omp --resume "C:\\Users\\Ada Lovelace\\.omp\\sessions\\A&B session one.jsonl"'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits no CODEX_HOME stamp for real-home canonical sessions', () => {
|
||||
// Backfills dedupe to real-home rows: resume from ~/.codex, never a frozen managed home whose auth stops refreshing.
|
||||
const command = buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin',
|
||||
codexHome: null
|
||||
})
|
||||
expect(command).toBe("cd '/repo/app' && codex resume 'session-1'")
|
||||
expect(command).not.toContain('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('carries non-default Codex homes in copied resume commands', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin',
|
||||
codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
|
||||
})
|
||||
).toBe(
|
||||
"cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
|
||||
)
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32',
|
||||
codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
|
||||
})
|
||||
).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes OMP by absolute transcript path so it resolves across session-dir roots', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'omp',
|
||||
sessionId: '019f27cd-4268-7000-96e7-62f42a55c144',
|
||||
resumeFilePath:
|
||||
'/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl',
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe(
|
||||
"cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl'"
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes queued OMP resume paths for the provided Windows shell', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'omp',
|
||||
sessionId: '019f27cd-4268-7000-96e7-62f42a55c144',
|
||||
resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32',
|
||||
shell: 'powershell'
|
||||
})
|
||||
).toBe(
|
||||
"Set-Location -LiteralPath 'C:\\Users\\Ada Lovelace\\repo'; omp --resume 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl'"
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the session id when no OMP transcript path is known', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'omp',
|
||||
sessionId: '019f27cd-4268-7000-96e7-62f42a55c144',
|
||||
resumeFilePath: null,
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'")
|
||||
})
|
||||
|
||||
it('resumes Prime Agent by absolute transcript path like OMP', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'prime-agent',
|
||||
sessionId: 'dddddddd-eeee-4fff-8aaa-111111111111',
|
||||
resumeFilePath:
|
||||
'/Users/ada/.prime/agent/sessions/dddddddd-eeee-4fff-8aaa-111111111111.jsonl',
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe(
|
||||
"cd '/Users/ada/repo' && prime-agent --resume '/Users/ada/.prime/agent/sessions/dddddddd-eeee-4fff-8aaa-111111111111.jsonl'"
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the session id when no Prime Agent transcript path is known', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'prime-agent',
|
||||
sessionId: 'dddddddd-eeee-4fff-8aaa-111111111111',
|
||||
resumeFilePath: null,
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && prime-agent --resume 'dddddddd-eeee-4fff-8aaa-111111111111'")
|
||||
})
|
||||
|
||||
it('resumes Rovo Dev through its acli launch command', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'rovo',
|
||||
sessionId: 'rovo-session',
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && acli rovodev run --restore 'rovo-session'")
|
||||
})
|
||||
|
||||
it('appends the Rovo Dev restore flag to a full-command settings override', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'rovo',
|
||||
sessionId: 'rovo-session',
|
||||
cwd: null,
|
||||
platform: 'darwin',
|
||||
commandOverride: '/opt/acli/acli rovodev run'
|
||||
})
|
||||
).toBe("/opt/acli/acli rovodev run --restore 'rovo-session'")
|
||||
})
|
||||
|
||||
it('resumes Muse by session id in the session cwd', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'muse',
|
||||
sessionId: 'eeeeeeee-ffff-4000-baaa-222222222222',
|
||||
cwd: '/Users/ada/repo',
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && muse resume 'eeeeeeee-ffff-4000-baaa-222222222222'")
|
||||
})
|
||||
})
|
||||
import { buildAiVaultResumeShellCommand } from './ai-vault-resume-command'
|
||||
|
||||
describe('buildAiVaultResumeShellCommand env removal', () => {
|
||||
const base = {
|
||||
@@ -204,12 +9,6 @@ describe('buildAiVaultResumeShellCommand env removal', () => {
|
||||
clearEnvNames: ['CODEX_HOME', 'ORCA_CODEX_HOME']
|
||||
}
|
||||
|
||||
it('carries the removal on the agent under a POSIX shell', () => {
|
||||
expect(buildAiVaultResumeShellCommand({ ...base, platform: 'darwin' })).toBe(
|
||||
"cd '/repo' && env -u CODEX_HOME -u ORCA_CODEX_HOME codex 'resume' 'sid'"
|
||||
)
|
||||
})
|
||||
|
||||
// Filter assigned variables from env -u or the pinned home silently becomes real-home resume.
|
||||
it('keeps a pinned CODEX_HOME authoritative instead of stripping it', () => {
|
||||
const command = buildAiVaultResumeShellCommand({
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAiVaultPrepareSessionResumeUnavailableError } from './ai-vault-resume-preparation'
|
||||
|
||||
describe('isAiVaultPrepareSessionResumeUnavailableError', () => {
|
||||
it('recognizes deterministic old-host responses', () => {
|
||||
expect(
|
||||
isAiVaultPrepareSessionResumeUnavailableError({
|
||||
code: 'forbidden',
|
||||
message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients"
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isAiVaultPrepareSessionResumeUnavailableError({
|
||||
code: 'method_not_found',
|
||||
message: 'Unknown method: aiVault.prepareSessionResume'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'forbidden',
|
||||
message: "Method 'aiVault.listSessions' is not available to mobile clients"
|
||||
},
|
||||
{ code: 'forbidden', message: 'Session resume is forbidden' },
|
||||
{ code: 'internal_error', message: 'Method not found while preparing session' }
|
||||
])('rejects a non-capability error: $code / $message', (error) => {
|
||||
expect(isAiVaultPrepareSessionResumeUnavailableError(error)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,36 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import { canResumeAiVaultSessionOnTarget } from './ai-vault-resume-target'
|
||||
|
||||
describe('ai vault session storage compatibility', () => {
|
||||
const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl'
|
||||
const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl'
|
||||
const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl'
|
||||
const wslLocalhostSessionFile =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl'
|
||||
|
||||
it('allows host-stored sessions on local targets only', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: windowsHostSessionFile,
|
||||
targetStatus: 'ssh'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows WSL-stored sessions on local and SSH targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Native Windows and WSL are both local, but only the matching namespace contains the agent/session (#24408).
|
||||
it('resumes a local session only in the namespace that stores it', () => {
|
||||
const local = { sessionExecutionHostId: 'local', targetExecutionHostId: 'local' } as const
|
||||
@@ -87,17 +62,6 @@ describe('ai vault session storage compatibility', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: wslSessionFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: 'ssh:dev-box'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows runtime sessions only on the matching runtime target', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
@@ -123,10 +87,4 @@ describe('ai vault session storage compatibility', () => {
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never allows unknown targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeAiVaultScanError } from './ai-vault-scan-error-message'
|
||||
|
||||
describe('describeAiVaultScanError', () => {
|
||||
it('replaces the restart-circuit wording with an actionable line', () => {
|
||||
expect(describeAiVaultScanError('AI Vault service restart circuit is open.')).toBe(
|
||||
'Session scanning paused after repeated failures. Refresh to try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the scan timeout in seconds', () => {
|
||||
expect(describeAiVaultScanError('AI Vault service timed out after 130000ms.')).toBe(
|
||||
'Session scan timed out after 130s — the machine may be busy. Refresh to try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the SSH host in the timeout copy', () => {
|
||||
expect(
|
||||
describeAiVaultScanError(
|
||||
'Agent Session History scan timed out after 130000ms on this SSH host.'
|
||||
)
|
||||
).toBe('Session scan timed out after 130s on this SSH host. Refresh to try again.')
|
||||
})
|
||||
|
||||
it('humanizes the relay-hosted variant of the same fault', () => {
|
||||
expect(describeAiVaultScanError('Relay AI Vault service restart circuit is open.')).toBe(
|
||||
'Session scanning paused after repeated failures. Refresh to try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the Electron invoke wrapper before matching', () => {
|
||||
expect(
|
||||
describeAiVaultScanError(
|
||||
"Error invoking remote method 'aiVault:listSessions': Error: AI Vault service did not become ready."
|
||||
)
|
||||
).toBe('The session scanner stopped unexpectedly. Refresh to try again.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['AI Vault service disconnected.', 'The session scanner stopped unexpectedly.'],
|
||||
['AI Vault service exited (null).', 'The session scanner stopped unexpectedly.'],
|
||||
['AI Vault scanner worker exited with code 1.', 'The session scanner stopped unexpectedly.'],
|
||||
['AI Vault service sent a malformed message.', 'The session scanner stopped unexpectedly.']
|
||||
])('covers the supervision fault %s', (raw, expected) => {
|
||||
expect(describeAiVaultScanError(raw)).toBe(`${expected} Refresh to try again.`)
|
||||
})
|
||||
|
||||
it('routes a missing scanner entry to install guidance, not a retry', () => {
|
||||
const described = describeAiVaultScanError('AI Vault service entry not found: /a/b.js')
|
||||
expect(described).toBe(
|
||||
'The session scanner is missing from this Orca install. Reinstalling Orca restores it.'
|
||||
)
|
||||
expect(described).not.toContain('Refresh')
|
||||
})
|
||||
|
||||
it('never leaks internal vocabulary a user cannot act on', () => {
|
||||
for (const raw of [
|
||||
'AI Vault service restart circuit is open.',
|
||||
'AI Vault service timed out after 130000ms.',
|
||||
'AI Vault service queue is full.',
|
||||
'AI Vault service cache invalidation timed out.',
|
||||
'AI Vault service did not cancel within 2000ms.',
|
||||
'AI Vault service client was disposed.'
|
||||
]) {
|
||||
const described = describeAiVaultScanError(raw)
|
||||
expect(described).not.toMatch(/circuit|ms\.|AI Vault service|scanner worker/)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes scanner-authored messages through untouched', () => {
|
||||
const authored = 'Could not read /Users/x/.codex/sessions: EACCES'
|
||||
expect(describeAiVaultScanError(authored)).toBe(authored)
|
||||
expect(describeAiVaultScanError('SSH relay is not ready')).toBe('SSH relay is not ready')
|
||||
})
|
||||
|
||||
it('removes transport wrappers before passing through an unknown message', () => {
|
||||
expect(
|
||||
describeAiVaultScanError(
|
||||
"Error invoking remote method 'aiVault:listSessions': Error: SSH relay is not ready"
|
||||
)
|
||||
).toBe('SSH relay is not ready')
|
||||
expect(describeAiVaultScanError('Relay AI Vault scanner is not ready')).toBe(
|
||||
'AI Vault scanner is not ready'
|
||||
)
|
||||
expect(describeAiVaultScanError('Relay client is not connected')).toBe(
|
||||
'Relay client is not connected'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from './worktree/types'
|
||||
import {
|
||||
deriveAiVaultScopeSessionPaths,
|
||||
deriveAiVaultWorkspaceScopePaths
|
||||
} from './ai-vault-scope-paths'
|
||||
import { deriveAiVaultWorkspaceScopePaths } from './ai-vault-scope-paths'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
@@ -29,19 +26,6 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
}
|
||||
|
||||
describe('deriveAiVaultWorkspaceScopePaths', () => {
|
||||
it('includes prior paths so renamed workspaces keep their transcripts', () => {
|
||||
const active = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-renamed',
|
||||
path: '/repo/orca-renamed',
|
||||
priorWorktreeIds: ['repo-1::/repo/orca']
|
||||
})
|
||||
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([
|
||||
'/repo/orca-renamed',
|
||||
'/repo/orca'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a claimed prior path regardless of where the claimant sits in the list', () => {
|
||||
// Exclude the active workspace before indexing owners so duplicates cannot hide later claimants.
|
||||
const active = makeWorktree({
|
||||
@@ -64,52 +48,4 @@ describe('deriveAiVaultWorkspaceScopePaths', () => {
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores prior ids belonging to another repo', () => {
|
||||
const active = makeWorktree({ priorWorktreeIds: ['repo-2::/repo/other'] })
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca'])
|
||||
})
|
||||
|
||||
it('skips relative and blank paths', () => {
|
||||
const active = makeWorktree({ path: 'relative/path' })
|
||||
expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([])
|
||||
expect(deriveAiVaultWorkspaceScopePaths(makeWorktree({ path: ' ' }), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveAiVaultScopeSessionPaths', () => {
|
||||
it('covers the active workspace plus the rest of its repo', () => {
|
||||
const active = makeWorktree()
|
||||
const sibling = makeWorktree({ id: 'repo-1::/repo/feature', path: '/repo/feature' })
|
||||
const otherRepo = makeWorktree({
|
||||
id: 'repo-2::/repo/other',
|
||||
repoId: 'repo-2',
|
||||
path: '/repo/other'
|
||||
})
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, sibling, otherRepo])).toEqual([
|
||||
'/repo/orca',
|
||||
'/repo/feature'
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates paths that differ only by separators or trailing slash', () => {
|
||||
// The dedupe compares normalized keys; the first spelling is what ships.
|
||||
const active = makeWorktree()
|
||||
const trailing = makeWorktree({ id: 'repo-1::a', path: '/repo/orca/' })
|
||||
const doubled = makeWorktree({ id: 'repo-1::b', path: '/repo//orca' })
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, trailing, doubled])).toEqual([
|
||||
'/repo/orca'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats NFD and NFC spellings of one path as the same scope entry', () => {
|
||||
// macOS yields NFD on disk while agents record NFC cwds (#10832).
|
||||
const nfc = '/repo/프로젝트'
|
||||
const active = makeWorktree({ id: `repo-1::${nfc}`, path: nfc })
|
||||
const nfd = makeWorktree({ id: 'repo-1::nfd', path: nfc.normalize('NFD') })
|
||||
|
||||
expect(deriveAiVaultScopeSessionPaths(active, [active, nfd])).toEqual([nfc])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,6 @@ import { AI_VAULT_AGENTS } from './ai-vault-types'
|
||||
|
||||
describe.each(['qoder', 'jcode'] as const)('%s search negotiation', (agent) => {
|
||||
const supportField = agent === 'qoder' ? 'supportsQoderHistory' : 'supportsJcodeHistory'
|
||||
it('keeps the frozen old parser closed to the requested agent', () => {
|
||||
expect(
|
||||
LegacyRequestSchema.safeParse({ query: 'q', filters: { agents: [agent] } }).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['runtime', 'relay'] as const)(
|
||||
'sends no search request for an unsupported agent over %s',
|
||||
@@ -97,43 +92,6 @@ describe.each(['qoder', 'jcode'] as const)('%s search negotiation', (agent) => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not infer support from the other agent capability', async () => {
|
||||
const otherField = agent === 'qoder' ? 'supportsJcodeHistory' : 'supportsQoderHistory'
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? { ...unavailableSessionSearchStatus(), [otherField]: true }
|
||||
: searchResults()
|
||||
)
|
||||
await createSessionSearchClient(call, 'relay').searchSessions({
|
||||
query: 'q',
|
||||
filters: { agents: [agent, 'codex'] }
|
||||
})
|
||||
expect(call).toHaveBeenLastCalledWith('aiVault.searchSessions', {
|
||||
query: 'q',
|
||||
limit: 20,
|
||||
filters: { agents: ['codex'] },
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
supportsQoderHistory: true,
|
||||
supportsJcodeHistory: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate local IPC before the per-host aggregator negotiates', async () => {
|
||||
const call = vi.fn(async () => searchResults())
|
||||
await createSessionSearchClient(call, 'ipc').searchSessions({
|
||||
query: 'q',
|
||||
filters: { agents: [agent] }
|
||||
})
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', {
|
||||
query: 'q',
|
||||
limit: 20,
|
||||
filters: { agents: [agent] },
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
supportsQoderHistory: true,
|
||||
supportsJcodeHistory: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not dispatch a search when the capability probe loses host contact', async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw new Error('host disconnected')
|
||||
@@ -149,23 +107,6 @@ describe.each(['qoder', 'jcode'] as const)('%s search negotiation', (agent) => {
|
||||
})
|
||||
|
||||
describe('explicit search host vocabulary', () => {
|
||||
it.each(['runtime', 'relay'] as const)(
|
||||
'does not send sole Jcode to a host that only attests Qoder over %s',
|
||||
async (transport) => {
|
||||
const call = vi.fn(async () => ({
|
||||
...unavailableSessionSearchStatus(),
|
||||
supportsQoderHistory: true
|
||||
}))
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'q',
|
||||
filters: { agents: ['jcode'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['runtime', 'relay'] as const)(
|
||||
'retains current Jcode support even with no indexed sessions over %s',
|
||||
async (transport) => {
|
||||
|
||||
@@ -1,84 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AiVaultSearchRequestSchema,
|
||||
AiVaultSearchResponseSchema,
|
||||
AiVaultSearchStatusSchema
|
||||
} from './ai-vault-search-contract'
|
||||
import { AiVaultSearchResponseSchema, AiVaultSearchStatusSchema } from './ai-vault-search-contract'
|
||||
import { searchHit, searchResults } from './ai-vault-search-test-fixture'
|
||||
import { redactForTransport, redactStatusForTransport } from './ai-vault-search-transport'
|
||||
import { unavailableSessionSearchStatus } from './ai-vault-search-client'
|
||||
|
||||
describe('session search public contract', () => {
|
||||
it('drops legacy fields without letting them override scope or freshness', () => {
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.parse({ query: 'needle', tier: 'conversation', refresh: true })
|
||||
).toEqual({ query: 'needle', limit: 20 })
|
||||
})
|
||||
it.each([
|
||||
[0, 1],
|
||||
[-4, 1],
|
||||
[200, 100],
|
||||
[2.5, 20],
|
||||
[undefined, 20]
|
||||
])('clamps %s to %s', (limit, expected) => {
|
||||
expect(AiVaultSearchRequestSchema.parse({ query: 'needle', limit }).limit).toBe(expected)
|
||||
})
|
||||
it.each(['2026-10-12T00:00:00Z', '2026-10-12T08:00:00+08:00', '2026-10-11T16:00:00-08:00'])(
|
||||
'reads since %s as the stored UTC instant',
|
||||
(since) => {
|
||||
expect(AiVaultSearchRequestSchema.parse({ query: 'q', filters: { since } }).filters).toEqual({
|
||||
since: '2026-10-12T00:00:00.000Z'
|
||||
})
|
||||
}
|
||||
)
|
||||
it('preserves long query text in the request schema', () => {
|
||||
const query = 'needle '.repeat(100)
|
||||
expect(AiVaultSearchRequestSchema.parse({ query }).query).toBe(query)
|
||||
})
|
||||
it('validates every response variant and separate status', () => {
|
||||
for (const response of [
|
||||
searchResults(),
|
||||
{ kind: 'malformed-cursor' },
|
||||
{ kind: 'stale-cursor', generation: 2, expectedGeneration: 1 },
|
||||
...['disabled', 'not-ready', 'no-service'].map((reason) => ({ kind: 'unavailable', reason }))
|
||||
]) {
|
||||
expect(AiVaultSearchResponseSchema.parse(response)).toEqual(response)
|
||||
}
|
||||
expect(AiVaultSearchStatusSchema.parse(unavailableSessionSearchStatus())).toEqual(
|
||||
unavailableSessionSearchStatus()
|
||||
)
|
||||
expect(AiVaultSearchResponseSchema.safeParse({ kind: 'results', hits: [] }).success).toBe(false)
|
||||
})
|
||||
it('keeps host attribution optional in both wire directions', () => {
|
||||
const legacy = searchResults()
|
||||
expect(AiVaultSearchResponseSchema.parse(legacy)).toEqual(legacy)
|
||||
expect(legacy.hits[0]).not.toHaveProperty('executionHostId')
|
||||
const attributed = {
|
||||
...searchResults(),
|
||||
hits: [{ ...searchHit(), executionHostId: 'runtime:env-1' }]
|
||||
}
|
||||
expect(AiVaultSearchResponseSchema.parse(attributed)).toEqual(attributed)
|
||||
expect(
|
||||
AiVaultSearchResponseSchema.safeParse({
|
||||
...searchResults(),
|
||||
hits: [{ ...searchHit(), executionHostId: '' }]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
it('accepts future agent names only in optional capability lists', () => {
|
||||
const supportedAgents = ['codex', 'jcode', 'future-agent']
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.parse({ query: 'q', supportedAgents }).supportedAgents
|
||||
).toEqual(supportedAgents)
|
||||
expect(
|
||||
AiVaultSearchStatusSchema.parse({ ...unavailableSessionSearchStatus(), supportedAgents })
|
||||
.supportedAgents
|
||||
).toEqual(supportedAgents)
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.safeParse({ query: 'q', filters: { agents: ['future-agent'] } })
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
it('never accepts resume commands for an unverified or missing source', () => {
|
||||
for (const presence of ['unverifiable', 'missing'] as const) {
|
||||
const response = searchResults()
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseVaultQuery } from './ai-vault-session-filters'
|
||||
import {
|
||||
hasAiVaultSearchQueryOperators,
|
||||
splitAiVaultSearchQuery
|
||||
} from './ai-vault-search-query-operators'
|
||||
|
||||
describe('what counts as an operator', () => {
|
||||
it('splits repo: and path: out of the free text', () => {
|
||||
const split = splitAiVaultSearchQuery('relay capacity repo:orca path:/work/app')
|
||||
expect(split.text).toBe('relay capacity')
|
||||
expect(split.terms).toEqual(['relay', 'capacity'])
|
||||
expect(split.repoTerms).toEqual(['orca'])
|
||||
expect(split.pathTerms).toEqual(['/work/app'])
|
||||
expect(hasAiVaultSearchQueryOperators(split)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a value that only looks like an operator as ordinary text', () => {
|
||||
const split = splitAiVaultSearchQuery('myrepo:x https://host/path:y')
|
||||
expect(split.repoTerms).toEqual([])
|
||||
expect(split.pathTerms).toEqual([])
|
||||
expect(split.text).toBe('myrepo:x https://host/path:y')
|
||||
})
|
||||
|
||||
it('reads a quoted operator value whole, including its spaces', () => {
|
||||
expect(splitAiVaultSearchQuery('path:"/Users/ada/My Project" needle').pathTerms).toEqual([
|
||||
'/Users/ada/My Project'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let an apostrophe in prose swallow the operator between quotes', () => {
|
||||
const split = splitAiVaultSearchQuery("it's a repo:orca thing's")
|
||||
expect(split.repoTerms).toEqual(['orca'])
|
||||
})
|
||||
|
||||
it('preserves operator case, which the panel folds and the index must not', () => {
|
||||
// Keep execution-host case in cwd_key so distinct POSIX directories remain searchable.
|
||||
expect(splitAiVaultSearchQuery('path:/Work/App').pathTerms).toEqual(['/Work/App'])
|
||||
expect(parseVaultQuery('path:/Work/App').pathTerms).toEqual(['/work/app'])
|
||||
})
|
||||
|
||||
it('has no operators when the query is plain text', () => {
|
||||
expect(hasAiVaultSearchQueryOperators(splitAiVaultSearchQuery('relay capacity'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Pin intentional differences from the former panel tokenizer; the shared parser now guarantees panel/index agreement.
|
||||
describe('the shapes where the panel parser used to answer differently', () => {
|
||||
it.each([
|
||||
['repo:"" x', 'repoTerms'],
|
||||
['path:"" x', 'pathTerms']
|
||||
] as const)('drops the empty operator value in %s instead of filtering on `""`', (query, key) => {
|
||||
// Drop empty operators; filtering on a quoted empty label once silently emptied the list.
|
||||
expect(splitAiVaultSearchQuery(query)[key]).toEqual([])
|
||||
expect(parseVaultQuery(query)[key]).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['repo:" " x', 'repoTerms'],
|
||||
['path:" " x', 'pathTerms']
|
||||
] as const)('drops the whitespace-only operator value in %s too', (query, key) => {
|
||||
// Whitespace-only operator values also narrow nothing; retaining spaces once emptied results.
|
||||
expect(splitAiVaultSearchQuery(query)[key]).toEqual([])
|
||||
expect(parseVaultQuery(query)[key]).toEqual([])
|
||||
})
|
||||
|
||||
it('trims a quoted operator value rather than searching for the spaces', () => {
|
||||
expect(splitAiVaultSearchQuery('repo:" session-search "').repoTerms).toEqual(['session-search'])
|
||||
})
|
||||
|
||||
it.each(['"" empty', "'' empty", '" " empty'])(
|
||||
'reads the empty quotes in %s as an empty term',
|
||||
(query) => {
|
||||
// Empty free-text terms match everything instead of searching literal quotes.
|
||||
expect(parseVaultQuery(query).terms).toEqual(['', 'empty'])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['"foo"bar', { terms: ['foo', 'bar'], repoTerms: [], pathTerms: [] }],
|
||||
['"a b"c', { terms: ['a b', 'c'], repoTerms: [], pathTerms: [] }],
|
||||
['repo:"a"b', { terms: ['b'], repoTerms: ['a'], pathTerms: [] }],
|
||||
['path:"a"b', { terms: ['b'], repoTerms: [], pathTerms: ['a'] }],
|
||||
['repo:"a b"c d', { terms: ['c', 'd'], repoTerms: ['a b'], pathTerms: [] }]
|
||||
])('reads %s exactly as the panel always has', (query, expected) => {
|
||||
// Closing quotes may end mid-word; token-start rules protect apostrophes without retaining literal quotes in search terms.
|
||||
expect(parseVaultQuery(query)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agrees with the sessions panel parser on operator recognition', () => {
|
||||
it.each([
|
||||
'relay capacity',
|
||||
'repo:orca needle',
|
||||
'path:/work/app needle',
|
||||
'myrepo:x',
|
||||
'needle repo:orca path:/work/app',
|
||||
'path:"/Users/ada/My Project"',
|
||||
'https://host/path:y'
|
||||
])('reads the same operators out of %s', (query) => {
|
||||
const split = splitAiVaultSearchQuery(query)
|
||||
const parsed = parseVaultQuery(query)
|
||||
const fold = (values: readonly string[]): string[] => values.map((v) => v.toLowerCase()).sort()
|
||||
expect(fold(split.repoTerms)).toEqual(fold(parsed.repoTerms))
|
||||
expect(fold(split.pathTerms)).toEqual(fold(parsed.pathTerms))
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AiVaultSearchRequestSchema, AiVaultSearchResponseSchema } from './ai-vault-search-contract'
|
||||
import { searchResults } from './ai-vault-search-test-fixture'
|
||||
import { AiVaultSearchRequestSchema } from './ai-vault-search-contract'
|
||||
import './ai-vault-search-test-fixture'
|
||||
|
||||
describe('scope identity on the search request', () => {
|
||||
it('accepts a workspace identity', () => {
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.parse({
|
||||
query: 'needle',
|
||||
within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' }
|
||||
}).within
|
||||
).toEqual({ kind: 'workspace', worktreeId: 'repo-1::/work/app' })
|
||||
})
|
||||
|
||||
it('accepts a project identity under either key spelling', () => {
|
||||
for (const projectKey of ['repo:repo-1', 'project:proj-1']) {
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.parse({
|
||||
query: 'needle',
|
||||
within: { kind: 'project', projectKey }
|
||||
}).within
|
||||
).toEqual({ kind: 'project', projectKey })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses an identity sent alongside explicit scope paths', () => {
|
||||
expect(() =>
|
||||
AiVaultSearchRequestSchema.parse({
|
||||
@@ -33,33 +13,9 @@ describe('scope identity on the search request', () => {
|
||||
).toThrow(/either a scope identity or explicit scope paths/)
|
||||
})
|
||||
|
||||
it('still accepts explicit scope paths with no identity, which is what --path sends', () => {
|
||||
expect(
|
||||
AiVaultSearchRequestSchema.parse({ query: 'needle', filters: { scopePaths: ['/work/app'] } })
|
||||
.filters
|
||||
).toEqual({ scopePaths: ['/work/app'] })
|
||||
})
|
||||
|
||||
it('still accepts the old shape that carries neither', () => {
|
||||
expect(AiVaultSearchRequestSchema.parse({ query: 'needle' })).toEqual({
|
||||
query: 'needle',
|
||||
limit: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an unknown identity kind rather than dropping the narrowing', () => {
|
||||
expect(() =>
|
||||
AiVaultSearchRequestSchema.parse({ query: 'needle', within: { kind: 'repo', repoId: 'r' } })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('carries the scope-unknown answer and per-host outcome on the response', () => {
|
||||
expect(
|
||||
AiVaultSearchResponseSchema.parse({ kind: 'unavailable', reason: 'scope-unknown' })
|
||||
).toEqual({ kind: 'unavailable', reason: 'scope-unknown' })
|
||||
const hosts = [{ executionHostId: 'local', outcome: 'scope-unknown' }]
|
||||
expect(AiVaultSearchResponseSchema.parse({ ...searchResults(), hosts })).toMatchObject({
|
||||
hosts
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
AiVaultSearchSettingsSchema,
|
||||
resolveAiVaultSearchSettings,
|
||||
sameAiVaultSearchSettings
|
||||
} from './ai-vault-search-settings'
|
||||
|
||||
// Default off: indexing reads all machine transcripts and requires recorded opt-in.
|
||||
it('reads anything that is not an explicit opt-in as off', () => {
|
||||
expect(resolveAiVaultSearchSettings(undefined)).toEqual({ enabled: false, historyDays: null })
|
||||
expect(resolveAiVaultSearchSettings({})).toEqual({ enabled: false, historyDays: null })
|
||||
expect(resolveAiVaultSearchSettings({ aiVaultSearch: null })).toEqual({
|
||||
enabled: false,
|
||||
historyDays: null
|
||||
})
|
||||
expect(resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: 'yes' } })).toEqual({
|
||||
enabled: false,
|
||||
historyDays: null
|
||||
})
|
||||
expect(resolveAiVaultSearchSettings({ aiVaultSearch: 'on' })).toEqual({
|
||||
enabled: false,
|
||||
historyDays: null
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a history bound and drops anything that is not one', () => {
|
||||
expect(
|
||||
resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays: 30.7 } })
|
||||
).toEqual({ enabled: true, historyDays: 30 })
|
||||
// Fractional days floor to zero, otherwise disagreeing as all-history versus cutoff-now.
|
||||
for (const historyDays of [0.4, 0, -30, Number.NaN] as const) {
|
||||
expect(resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays } })).toEqual(
|
||||
{ enabled: true, historyDays: null }
|
||||
)
|
||||
}
|
||||
expect(
|
||||
resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays: 999_999 } })
|
||||
).toEqual({ enabled: true, historyDays: 3_650 })
|
||||
})
|
||||
|
||||
// No paused lifecycle: immutable indexers would otherwise need a second store/queue/sweep lifetime.
|
||||
it('keeps only the two fields the indexer is constructed from', () => {
|
||||
expect(
|
||||
resolveAiVaultSearchSettings({
|
||||
aiVaultSearch: { enabled: true, historyDays: 90, paused: true }
|
||||
})
|
||||
).toEqual({ enabled: true, historyDays: 90 })
|
||||
})
|
||||
|
||||
it('accepts what it produces and refuses what it does not', () => {
|
||||
expect(AiVaultSearchSettingsSchema.parse({ enabled: true, historyDays: 90 })).toEqual({
|
||||
enabled: true,
|
||||
historyDays: 90
|
||||
})
|
||||
expect(AiVaultSearchSettingsSchema.safeParse({ enabled: true, historyDays: 0 }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(AiVaultSearchSettingsSchema.safeParse({ historyDays: null }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('compares both enabled state and history bounds', () => {
|
||||
expect(
|
||||
sameAiVaultSearchSettings(
|
||||
{ enabled: true, historyDays: 30 },
|
||||
{ enabled: true, historyDays: 30 }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
sameAiVaultSearchSettings(
|
||||
{ enabled: true, historyDays: 30 },
|
||||
{ enabled: true, historyDays: 90 }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
sameAiVaultSearchSettings(
|
||||
{ enabled: true, historyDays: null },
|
||||
{ enabled: false, historyDays: null }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSessionSearchClient, unavailableSessionSearchStatus } from './ai-vault-search-client'
|
||||
import { createSessionSearchClient } from './ai-vault-search-client'
|
||||
import { AiVaultSearchRequestSchema as LegacyRequestSchema } from './__fixtures__/pre-qoder-search-request'
|
||||
import { searchResults } from './ai-vault-search-test-fixture'
|
||||
|
||||
@@ -71,24 +71,6 @@ describe.each(['runtime', 'relay'] as const)('missing search status over %s', (t
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
})
|
||||
|
||||
it.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])(
|
||||
'honors explicit narrow host vocabulary $supportedAgents',
|
||||
async ({ supportedAgents }) => {
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? { ...unavailableSessionSearchStatus(), supportedAgents }
|
||||
: searchResults()
|
||||
)
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: ['claude'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ code: 'unauthorized', message: 'Authentication required' },
|
||||
{ code: 'connection_closed', message: 'Host disconnected' },
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { matchAiVaultSessionCwdCandidates } from './ai-vault-session-cwd-candidates'
|
||||
import { createNormalizedPathInsideOrEqualMatcher } from './cross-platform-path'
|
||||
|
||||
it('keeps alias matching linear in candidate roots at the maximum host alias count', () => {
|
||||
const session = {
|
||||
filePath: '/session.jsonl',
|
||||
cwdAliases: Array.from({ length: 64 }, (_, i) => `/alias-${i}/child`)
|
||||
}
|
||||
for (const size of [100, 1000]) {
|
||||
const owns = vi.fn(createNormalizedPathInsideOrEqualMatcher('/alias-63'))
|
||||
const candidates = Array.from({ length: size }, () => ({
|
||||
normalizedPath: '/alias-63',
|
||||
ownsNormalizedCwd: owns
|
||||
}))
|
||||
expect(
|
||||
matchAiVaultSessionCwdCandidates(session, '/physical/child', candidates, () => 0)
|
||||
).toHaveLength(size)
|
||||
expect(owns).toHaveBeenCalledTimes(size * 65)
|
||||
}
|
||||
})
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from './ai-vault-types'
|
||||
import {
|
||||
aiVaultScanLimit,
|
||||
aiVaultSessionDepthCovers,
|
||||
requestedAiVaultSessionDepth,
|
||||
truncateAiVaultListResult
|
||||
} from './ai-vault-session-depth'
|
||||
import { truncateAiVaultListResult } from './ai-vault-session-depth'
|
||||
|
||||
function session(id: string, cwd: string, index: number): AiVaultSession {
|
||||
const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString()
|
||||
@@ -38,32 +33,6 @@ function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
}
|
||||
|
||||
describe('Agent Session History depth', () => {
|
||||
it('recognizes which loaded depths cover a request', () => {
|
||||
expect(aiVaultSessionDepthCovers(250, 250)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers(1000, 250)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers(250, 500)).toBe(false)
|
||||
expect(aiVaultSessionDepthCovers(1000, 'unlimited')).toBe(false)
|
||||
expect(aiVaultSessionDepthCovers('unlimited', 1000)).toBe(true)
|
||||
expect(aiVaultSessionDepthCovers('unlimited', 'unlimited')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes default, finite, and unlimited requests', () => {
|
||||
expect(requestedAiVaultSessionDepth()).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500 })).toBe(500)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500.75 })).toBe(500)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 0 })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: -1 })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: Number.NaN })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
|
||||
expect(requestedAiVaultSessionDepth({ limit: 500, unlimited: true })).toBe('unlimited')
|
||||
})
|
||||
|
||||
it('resolves scan limits to a numeric bound', () => {
|
||||
expect(aiVaultScanLimit({ limit: 500 })).toBe(500)
|
||||
expect(aiVaultScanLimit({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
|
||||
expect(aiVaultScanLimit({ unlimited: true })).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
it('keeps every scoped session and the newest out-of-scope ones when truncating', () => {
|
||||
const loaded = result([
|
||||
session('global-1', '/other', 6),
|
||||
@@ -87,59 +56,6 @@ describe('Agent Session History depth', () => {
|
||||
])
|
||||
expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded)
|
||||
})
|
||||
|
||||
// Repeat opens served from a cache used to drop to `depth` scoped rows (341 -> 250 in QA).
|
||||
it('returns a scan result with more scoped rows than the depth unchanged', () => {
|
||||
const scanned = result([
|
||||
...Array.from({ length: 3 }, (_, i) => session(`global-${i}`, '/other', 100 - i)),
|
||||
...Array.from({ length: 5 }, (_, i) => session(`codex-${i}`, '/repo', 50 - i)),
|
||||
...Array.from({ length: 4 }, (_, i) => session(`claude-${i}`, '/repo/app', 40 - i))
|
||||
])
|
||||
|
||||
const cachedHit = truncateAiVaultListResult(scanned, 3, ['/repo'])
|
||||
expect(cachedHit.sessions).toEqual(scanned.sessions)
|
||||
expect(truncateAiVaultListResult(cachedHit, 3, ['/repo']).sessions).toEqual(scanned.sessions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Session History scope truncation', () => {
|
||||
it('normalizes scope roots and candidate cwd once per truncation pass', () => {
|
||||
const loaded = result(
|
||||
Array.from({ length: 1000 }, (_, i) => session(`id-${i}`, `/other/${i}`, i))
|
||||
)
|
||||
const scopes = Array.from({ length: 100 }, (_, i) => `/repo/${i}`)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
let selected: AiVaultListResult
|
||||
try {
|
||||
selected = truncateAiVaultListResult(loaded, 10, scopes)
|
||||
expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
expect(selected.sessions).toEqual(loaded.sessions.slice(0, 10))
|
||||
})
|
||||
|
||||
it('selects scoped sessions across host path spellings', () => {
|
||||
const cases: [scope: string, cwd: string, kept: boolean][] = [
|
||||
['C:\\Users\\Ada\\repo', 'c:/users/ada/repo/app', true],
|
||||
['C:/Users/Ada/repo/', 'C:\\Users\\Ada\\repo', true],
|
||||
['C:\\Users\\Ada\\repo', 'C:\\Users\\Ada\\repo-other', false],
|
||||
['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/Ada/Repo/App', true],
|
||||
['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl.localhost/Debian/home/ada/repo', false],
|
||||
['/Users/ada/repo', '/Users/ada/repo//app/', true],
|
||||
['/Users/ada/repo', '/Users/ada/repository', false],
|
||||
['/', '/anywhere', true]
|
||||
]
|
||||
for (const [scope, cwd, expected] of cases) {
|
||||
const loaded = result([session('scoped', cwd!, 0)])
|
||||
const selected = truncateAiVaultListResult(loaded, 0, [scope!])
|
||||
expect({ scope, cwd, kept: selected.sessions.length === 1 }).toEqual({
|
||||
scope,
|
||||
cwd,
|
||||
kept: expected
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateAiVaultListResult symlinked scope', () => {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from './ai-vault-types'
|
||||
import {
|
||||
agentLabel,
|
||||
filterAiVaultSessions,
|
||||
folderGroupKey,
|
||||
folderLabel,
|
||||
groupAiVaultSessions,
|
||||
parseVaultQuery
|
||||
} from './ai-vault-session-filters'
|
||||
import { sessionPreviewSearchText } from './ai-vault-session-display'
|
||||
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
|
||||
import './ai-vault-session-display'
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'claude:1',
|
||||
@@ -78,95 +71,6 @@ describe('/shared ai-vault-session-filters (lifted core)', () => {
|
||||
).toEqual(['claude:1'])
|
||||
})
|
||||
|
||||
it('hides empty sessions by default and keeps non-empty ones', () => {
|
||||
// Keep preview/recovery evidence even without counted turns; only genuinely empty sessions disappear.
|
||||
const empty: AiVaultSession = {
|
||||
...baseSession,
|
||||
id: 'claude:empty',
|
||||
messageCount: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0
|
||||
}
|
||||
expect(
|
||||
filterAiVaultSessions([baseSession, empty], {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: true
|
||||
}).map((session) => session.id)
|
||||
).toEqual(['claude:1'])
|
||||
})
|
||||
|
||||
it('groups by folder', () => {
|
||||
const groups = groupAiVaultSessions([baseSession, otherSession], 'folder')
|
||||
expect(groups.map((group) => group.label).sort()).toEqual(['packages/ui', 'repo/app'])
|
||||
})
|
||||
|
||||
it('folds trailing-slash and repeated-slash cwd spellings into one folder group', () => {
|
||||
const groups = groupAiVaultSessions(
|
||||
[
|
||||
{ ...baseSession, cwd: '/Users/ada/repo/app' },
|
||||
{ ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' },
|
||||
{ ...baseSession, id: 'claude:3', cwd: '/Users/ada//repo/app//' }
|
||||
],
|
||||
'folder'
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].sessions).toHaveLength(3)
|
||||
expect(groups[0].label).toBe('repo/app')
|
||||
})
|
||||
|
||||
it('folds NFD and NFC cwd spellings into one folder group with an NFC label', () => {
|
||||
const groups = groupAiVaultSessions(
|
||||
[
|
||||
{ ...baseSession, cwd: '/Users/ada/Café/app'.normalize('NFD') },
|
||||
{ ...baseSession, id: 'claude:2', cwd: '/Users/ada/Café/app'.normalize('NFC') }
|
||||
],
|
||||
'folder'
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].sessions).toHaveLength(2)
|
||||
expect(groups[0].label).toBe('Café/app'.normalize('NFC'))
|
||||
})
|
||||
|
||||
it('folds separator and case variants of one Windows folder', () => {
|
||||
const groups = groupAiVaultSessions(
|
||||
[
|
||||
{ ...baseSession, cwd: 'C:\\Users\\Ada\\repo\\app' },
|
||||
{ ...baseSession, id: 'claude:2', cwd: 'c:/users/ada/repo/app/' }
|
||||
],
|
||||
'folder'
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('folds the two WSL UNC aliases of one folder into a single group', () => {
|
||||
const groups = groupAiVaultSessions(
|
||||
[
|
||||
{ ...baseSession, cwd: '//wsl.localhost/Ubuntu/home/ada/repo/app' },
|
||||
{ ...baseSession, id: 'claude:2', cwd: '//wsl$/Ubuntu/home/ada/repo/app' }
|
||||
],
|
||||
'folder'
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].sessions.map((session) => session.id)).toEqual(['claude:1', 'claude:2'])
|
||||
})
|
||||
|
||||
it('keeps case-distinct POSIX folders in separate groups', () => {
|
||||
const groups = groupAiVaultSessions(
|
||||
[
|
||||
{ ...baseSession, cwd: '/home/ada/Foo' },
|
||||
{ ...baseSession, id: 'claude:2', cwd: '/home/ada/foo' }
|
||||
],
|
||||
'folder'
|
||||
)
|
||||
expect(groups).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('folds the project-grouping fallback onto the resolved folder project key', () => {
|
||||
const resolved = { ...baseSession, cwd: '/Users/ada/repo/app' }
|
||||
const unresolved = { ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' }
|
||||
@@ -182,64 +86,9 @@ describe('/shared ai-vault-session-filters (lifted core)', () => {
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keys unknown cwd separately from real folders', () => {
|
||||
expect(folderGroupKey(null)).toBe('unknown')
|
||||
expect(folderGroupKey('/Users/ada/repo/app')).toBe('folder:/Users/ada/repo/app')
|
||||
})
|
||||
|
||||
it('parses repo: and path: operators from the query', () => {
|
||||
expect(parseVaultQuery('hello repo:orca path:/tmp world')).toEqual({
|
||||
terms: ['hello', 'world'],
|
||||
repoTerms: ['orca'],
|
||||
pathTerms: ['/tmp']
|
||||
})
|
||||
})
|
||||
|
||||
it('parses quoted repo:/path: operator values containing spaces', () => {
|
||||
expect(parseVaultQuery('repo:"my repo" path:"/Users/ada/My Project"')).toEqual({
|
||||
terms: [],
|
||||
repoTerms: ['my repo'],
|
||||
pathTerms: ['/users/ada/my project']
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes a stable agent label and folder label', () => {
|
||||
expect(agentLabel('claude')).toBe('Claude')
|
||||
expect(folderLabel('/Users/ada/repo/app')).toBe('repo/app')
|
||||
})
|
||||
|
||||
it('builds preview search text from conversation turns', () => {
|
||||
expect(sessionPreviewSearchText(baseSession)).toContain('scope tabs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', () => {
|
||||
it('prepares workspace path matching once for a large session filter pass', () => {
|
||||
const sessions = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
cwd: `/other/${i}`
|
||||
}))
|
||||
const activeWorktreePaths = Array.from({ length: 100 }, (_, i) => `/repo/${i}`)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
try {
|
||||
expect(
|
||||
filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'workspace',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths,
|
||||
hideEmptySessions: false
|
||||
})
|
||||
).toEqual([])
|
||||
expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not read transcript previews for empty or field-only queries', () => {
|
||||
let reads = 0
|
||||
const sessions = Array.from({ length: 1000 }, (_, i) => ({
|
||||
@@ -276,40 +125,6 @@ describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', ()
|
||||
expect(reads).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('parses each sort timestamp once with original ordering, including invalid dates', () => {
|
||||
const sessions = Array.from({ length: 2000 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
updatedAt:
|
||||
i % 131 === 0
|
||||
? 'invalid'
|
||||
: new Date(1700000000000 + ((i * 173) % 1999) * 1000).toISOString()
|
||||
}))
|
||||
const parse = vi.spyOn(Date, 'parse')
|
||||
let actual: AiVaultSession[]
|
||||
let expected: AiVaultSession[]
|
||||
try {
|
||||
expected = [...sessions].sort(
|
||||
(a, b) => Date.parse(b.updatedAt ?? b.modifiedAt) - Date.parse(a.updatedAt ?? a.modifiedAt)
|
||||
)
|
||||
expect(parse.mock.calls.length).toBeGreaterThan(10_000)
|
||||
parse.mockClear()
|
||||
actual = filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
expect(parse).toHaveBeenCalledTimes(2000)
|
||||
} finally {
|
||||
parse.mockRestore()
|
||||
}
|
||||
expect(actual).toHaveLength(expected.length)
|
||||
actual.forEach((session, i) => expect(session).toBe(expected[i]))
|
||||
})
|
||||
|
||||
it('matches workspace scope across host path spellings', () => {
|
||||
// Verify native containment across Windows folding, WSL aliases/mounts, trailing separators and near-miss siblings.
|
||||
const cases: [workspace: string, cwd: string, matched: boolean][] = [
|
||||
@@ -344,28 +159,6 @@ describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', ()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('orders by creation time identically to the per-comparison parse', () => {
|
||||
const sessions = Array.from({ length: 500 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
createdAt:
|
||||
i % 37 === 0 ? 'invalid' : new Date(1700000000000 + ((i * 91) % 499) * 1000).toISOString()
|
||||
}))
|
||||
const expected = [...sessions].sort(
|
||||
(a, b) => Date.parse(b.createdAt ?? b.modifiedAt) - Date.parse(a.createdAt ?? a.modifiedAt)
|
||||
)
|
||||
const actual = filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'created',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
expect(actual).toHaveLength(expected.length)
|
||||
actual.forEach((session, i) => expect(session).toBe(expected[i]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterAiVaultSessions symlinked workspace', () => {
|
||||
|
||||
@@ -1,52 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canResumeAiVaultSessionOnSshHost } from './ai-vault-ssh-resume-target'
|
||||
|
||||
const WSL_FILE = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\app\\s.jsonl'
|
||||
const HOST_FILE = '/Users/ada/.claude/projects/app/s.jsonl'
|
||||
|
||||
describe('canResumeAiVaultSessionOnSshHost', () => {
|
||||
it('accepts a session scanned on the target SSH host', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnSshHost({
|
||||
sessionFilePath: '/home/ada/.claude/projects/app/s.jsonl',
|
||||
sessionExecutionHostId: 'ssh:builder',
|
||||
targetExecutionHostId: 'ssh:builder'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a session scanned on another SSH host', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnSshHost({
|
||||
sessionFilePath: WSL_FILE,
|
||||
sessionExecutionHostId: 'ssh:other',
|
||||
targetExecutionHostId: 'ssh:builder'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a local WSL-stored transcript, with or without a stamped host', () => {
|
||||
for (const sessionExecutionHostId of ['local' as const, null]) {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnSshHost({
|
||||
sessionFilePath: WSL_FILE,
|
||||
sessionExecutionHostId,
|
||||
targetExecutionHostId: 'ssh:builder'
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a transcript stored on this machine outside WSL', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnSshHost({
|
||||
sessionFilePath: HOST_FILE,
|
||||
sessionExecutionHostId: 'local',
|
||||
targetExecutionHostId: 'ssh:builder'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a transcript-referencing Antigravity resume on the transcript host', () => {
|
||||
const wslReference =
|
||||
'//wsl.localhost/Ubuntu/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
|
||||
@@ -74,14 +29,4 @@ describe('canResumeAiVaultSessionOnSshHost', () => {
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a runtime-scanned session', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnSshHost({
|
||||
sessionFilePath: WSL_FILE,
|
||||
sessionExecutionHostId: 'runtime:devbox',
|
||||
targetExecutionHostId: 'ssh:builder'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
aiVaultSessionRecoverableSignalCount,
|
||||
isAiVaultSessionRecoverableEmpty,
|
||||
isAiVaultSessionResumableContent,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from './ai-vault-types'
|
||||
|
||||
type SignalFields = {
|
||||
messageCount: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
}
|
||||
|
||||
function signal(overrides: Partial<SignalFields> = {}): SignalFields {
|
||||
return {
|
||||
messageCount: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function preview(role: AiVaultSessionPreviewMessage['role']): AiVaultSessionPreviewMessage {
|
||||
return { role, text: 'preview text', timestamp: null }
|
||||
}
|
||||
|
||||
describe('isAiVaultSessionResumableContent', () => {
|
||||
it('is true when the transcript holds conversation turns', () => {
|
||||
expect(isAiVaultSessionResumableContent(signal({ messageCount: 2 }))).toBe(true)
|
||||
expect(isAiVaultSessionResumableContent(signal())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts conversation previews when the turn count is zero', () => {
|
||||
// Metadata turn counts may be absent while Grok/OpenCode fallback previews contain real turns.
|
||||
expect(isAiVaultSessionResumableContent(signal({ previewMessages: [preview('user')] }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isAiVaultSessionResumableContent(signal({ previewMessages: [preview('assistant')] }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-conversation previews', () => {
|
||||
expect(isAiVaultSessionResumableContent(signal({ previewMessages: [preview('system')] }))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAiVaultSessionRecoverableEmpty', () => {
|
||||
it('is true for a zero-turn session with queued or subagent signal', () => {
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal({ queuedMessageCount: 3 }))).toBe(true)
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal({ subagentTranscriptCount: 1 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a plain empty session or one with resumable content', () => {
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal())).toBe(false)
|
||||
expect(
|
||||
isAiVaultSessionRecoverableEmpty(signal({ messageCount: 2, queuedMessageCount: 4 }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
isAiVaultSessionRecoverableEmpty(
|
||||
signal({ previewMessages: [preview('user')], queuedMessageCount: 4 })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultSessionRecoverableSignalCount', () => {
|
||||
it('sums queued messages and subagent transcripts, clamping negatives', () => {
|
||||
expect(
|
||||
aiVaultSessionRecoverableSignalCount({
|
||||
queuedMessageCount: 4,
|
||||
subagentTranscriptCount: 2
|
||||
})
|
||||
).toBe(6)
|
||||
expect(
|
||||
aiVaultSessionRecoverableSignalCount({
|
||||
queuedMessageCount: -1,
|
||||
subagentTranscriptCount: 3
|
||||
})
|
||||
).toBe(3)
|
||||
expect(
|
||||
aiVaultSessionRecoverableSignalCount({
|
||||
queuedMessageCount: 4,
|
||||
subagentTranscriptCount: -2
|
||||
})
|
||||
).toBe(4)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user