mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
* fix(native-chat): list the slash commands and skills a structured Claude session actually loaded The chat composer's `/` menu was built from a curated five-command catalog plus a host disk scan of skill roots. Neither is what the running session can do: the session reports its own `/` surface, which carries this repo's `.claude/commands`, the skills that only reach it through plugin roots, and a hide-list of commands that mean nothing outside a terminal UI. On one local session the menu offered 6 commands and 17 skills where the session reported 62 commands and 33 skills. Read that surface per session and let it drive the picker: - A per-session catalog seeded from the frame that proves the session and kept current by every later report, exposed over a new `agentSession.commands` read. - The report is the authority on WHICH skills exist; the disk scan stays the source of scope and description for the names both know about, so a skill the session never loaded is no longer offered and one it loaded from a root the scan cannot see now is. - A host that predates the read answers `method_not_found` and the composer keeps its curated catalog, so mixed versions and the PTY lane are unchanged. * test: register agentSession.commands on the three surface ratchets The structured method count, the mobile allowlist, and the cross-version call table each enumerate the agentSession surface on purpose, so an additive method has to be declared in all three rather than counted around. * fix: preserve session catalog authority and publish live updates * fix(native-chat): publish authoritative command catalogs on session updates * fix: seed Claude slash catalog before the first prompt * test: verify unclassified catalogs survive session publication * test: complete structured rename journal fixtures --------- Co-authored-by: Merge Sim <sim@local>
506 lines
15 KiB
TypeScript
506 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
|
import type { AgentSessionHistoryPage } from './agent-session-wire'
|
|
import {
|
|
EMPTY_STRUCTURED_AGENT_SESSION,
|
|
reduceStructuredAgentSession
|
|
} from './structured-agent-session-reducer'
|
|
|
|
function item(id: string, sequence: number): AgentJournalRenderItem {
|
|
return {
|
|
itemId: id,
|
|
revision: 1,
|
|
sequence,
|
|
observedAt: sequence,
|
|
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }
|
|
}
|
|
}
|
|
|
|
function submission(index: number) {
|
|
return {
|
|
clientMessageId: `client-${index}`,
|
|
fence: 1,
|
|
payloadFingerprint: `fingerprint-${index}`,
|
|
dispatchState: 'accepted' as const,
|
|
providerItemId: `provider-${index}`,
|
|
reason: null,
|
|
submittedAt: index,
|
|
resolvedAt: index
|
|
}
|
|
}
|
|
|
|
function hydrationPage(
|
|
items: AgentJournalRenderItem[],
|
|
submissions: AgentJournalSubmission[] = []
|
|
): AgentSessionHistoryPage {
|
|
const oldest = items[0]?.sequence ?? 0
|
|
const newest = items.at(-1)?.sequence ?? 0
|
|
return {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items,
|
|
removedItemIds: [],
|
|
submissions,
|
|
window: {
|
|
oldest: items[0] ? { epoch: 'epoch-a', sequence: oldest } : null,
|
|
newest: items.at(-1) ? { epoch: 'epoch-a', sequence: newest } : null,
|
|
nextCursor: { epoch: 'epoch-a', sequence: oldest }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: newest },
|
|
hasOlder: false,
|
|
hasNewer: false
|
|
}
|
|
}
|
|
|
|
describe('structured agent session reducer', () => {
|
|
it('applies an additive targeted-stop capability update without journal churn', () => {
|
|
const backgroundTasks = {
|
|
state: 'monitoring' as const,
|
|
tasks: [{ id: 'task-1', kind: 'agent' as const }]
|
|
}
|
|
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: { ...hydrationPage([]), backgroundTasks }
|
|
}
|
|
})
|
|
const updated = reduceStructuredAgentSession(initial, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: 0 },
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
backgroundTasks: { ...backgroundTasks, supportsTaskStop: true }
|
|
}
|
|
})
|
|
|
|
expect(updated.backgroundTasks).toEqual({ ...backgroundTasks, supportsTaskStop: true })
|
|
expect(updated.items).toBe(initial.items)
|
|
})
|
|
|
|
it('uses the bounded hydration page pagination boundary', () => {
|
|
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage(
|
|
Array.from({ length: 84 }, (_, index) => item(`item-${index}`, index + 1))
|
|
)
|
|
}
|
|
})
|
|
|
|
expect(restored.items).toHaveLength(84)
|
|
expect(restored.hasOlder).toBe(false)
|
|
})
|
|
|
|
it('does not let a stale focus refresh replace newer streamed state', () => {
|
|
const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('streamed', 50)])
|
|
}
|
|
})
|
|
const afterRefresh = reduceStructuredAgentSession(streamed, {
|
|
type: 'tail-page',
|
|
page: {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items: [item('stale', 40)],
|
|
removedItemIds: [],
|
|
submissions: [],
|
|
window: {
|
|
oldest: { epoch: 'epoch-a', sequence: 40 },
|
|
newest: { epoch: 'epoch-a', sequence: 40 },
|
|
nextCursor: { epoch: 'epoch-a', sequence: 40 }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: 40 },
|
|
hasOlder: true,
|
|
hasNewer: false
|
|
}
|
|
})
|
|
|
|
expect(afterRefresh).toBe(streamed)
|
|
})
|
|
|
|
it('keeps paged-in older items when a focus refresh carries nothing new', () => {
|
|
const snapshot = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('newest', 50)])
|
|
}
|
|
})
|
|
const withOlder = reduceStructuredAgentSession(snapshot, {
|
|
type: 'older-page',
|
|
requestedEpoch: 'epoch-a',
|
|
page: {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'before',
|
|
items: [item('older', 10)],
|
|
removedItemIds: [],
|
|
submissions: [],
|
|
window: {
|
|
oldest: { epoch: 'epoch-a', sequence: 10 },
|
|
newest: { epoch: 'epoch-a', sequence: 10 },
|
|
nextCursor: { epoch: 'epoch-a', sequence: 10 }
|
|
},
|
|
hasOlder: false,
|
|
hasNewer: true
|
|
}
|
|
})
|
|
const afterRefresh = reduceStructuredAgentSession(withOlder, {
|
|
type: 'tail-page',
|
|
page: {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items: [item('newest', 50)],
|
|
removedItemIds: [],
|
|
submissions: [],
|
|
window: {
|
|
oldest: { epoch: 'epoch-a', sequence: 50 },
|
|
newest: { epoch: 'epoch-a', sequence: 50 },
|
|
nextCursor: { epoch: 'epoch-a', sequence: 50 }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: 50 },
|
|
hasOlder: true,
|
|
hasNewer: false
|
|
}
|
|
})
|
|
|
|
expect(afterRefresh).toBe(withOlder)
|
|
expect(afterRefresh.items.map((entry) => entry.itemId)).toEqual(['older', 'newest'])
|
|
})
|
|
|
|
it('accepts a newer fence from an equal-cursor tail refresh', () => {
|
|
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('newest', 50)])
|
|
}
|
|
})
|
|
const page = { ...hydrationPage([item('newest', 50)]), fence: 2 }
|
|
|
|
const refreshed = reduceStructuredAgentSession(initial, { type: 'tail-page', page })
|
|
|
|
expect(refreshed.fence).toBe(2)
|
|
expect(refreshed.items).toBe(initial.items)
|
|
})
|
|
|
|
it('keeps rapid-send submissions when a newer tail refresh contains only the last one', () => {
|
|
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage(
|
|
[item('first', 10)],
|
|
Array.from({ length: 8 }, (_, index) => submission(index))
|
|
)
|
|
}
|
|
})
|
|
const refreshed = reduceStructuredAgentSession(initial, {
|
|
type: 'tail-page',
|
|
page: {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items: [item('latest', 11)],
|
|
removedItemIds: [],
|
|
submissions: [submission(7)],
|
|
window: {
|
|
oldest: { epoch: 'epoch-a', sequence: 11 },
|
|
newest: { epoch: 'epoch-a', sequence: 11 },
|
|
nextCursor: { epoch: 'epoch-a', sequence: 11 }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: 11 },
|
|
hasOlder: true,
|
|
hasNewer: false
|
|
}
|
|
})
|
|
|
|
expect(refreshed.submissions.map((entry) => entry.clientMessageId)).toEqual(
|
|
Array.from({ length: 8 }, (_, index) => `client-${index}`)
|
|
)
|
|
})
|
|
|
|
it('bounds retained submission identities across repeated tail refreshes', () => {
|
|
let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('first', 1)])
|
|
}
|
|
})
|
|
|
|
for (let index = 0; index < 300; index += 1) {
|
|
state = reduceStructuredAgentSession(state, {
|
|
type: 'tail-page',
|
|
page: {
|
|
sessionId: 'session-a',
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items: [item(`item-${index}`, index + 2)],
|
|
removedItemIds: [],
|
|
submissions: [submission(index)],
|
|
window: {
|
|
oldest: { epoch: 'epoch-a', sequence: index + 2 },
|
|
newest: { epoch: 'epoch-a', sequence: index + 2 },
|
|
nextCursor: { epoch: 'epoch-a', sequence: index + 2 }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: index + 2 },
|
|
hasOlder: true,
|
|
hasNewer: false
|
|
}
|
|
})
|
|
}
|
|
|
|
expect(state.submissions).toHaveLength(256)
|
|
expect(state.submissions[0]?.clientMessageId).toBe('client-44')
|
|
expect(state.submissions.at(-1)?.clientMessageId).toBe('client-299')
|
|
})
|
|
|
|
it('projects additive background task state without changing transcript identity', () => {
|
|
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('message', 1)]),
|
|
backgroundTasks: null
|
|
}
|
|
})
|
|
const monitoring = reduceStructuredAgentSession(initial, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: initial.cursor!,
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
fence: 1,
|
|
backgroundTasks: { state: 'monitoring' }
|
|
}
|
|
})
|
|
|
|
expect(monitoring.backgroundTasks).toEqual({ state: 'monitoring' })
|
|
expect(monitoring.items).toBe(initial.items)
|
|
})
|
|
|
|
it('returns the same state for duplicate background task publications', () => {
|
|
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('message', 1)]),
|
|
backgroundTasks: { state: 'monitoring' }
|
|
}
|
|
})
|
|
const duplicate = reduceStructuredAgentSession(monitoring, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: monitoring.cursor!,
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
fence: 1,
|
|
backgroundTasks: { state: 'monitoring' }
|
|
}
|
|
})
|
|
|
|
expect(duplicate).toBe(monitoring)
|
|
})
|
|
|
|
it('applies background task roster changes without a journal update', () => {
|
|
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('message', 1)]),
|
|
backgroundTasks: {
|
|
state: 'monitoring',
|
|
tasks: [{ id: 'task-1', kind: 'command', description: 'first command' }]
|
|
}
|
|
}
|
|
})
|
|
const changed = reduceStructuredAgentSession(monitoring, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: monitoring.cursor!,
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
fence: 1,
|
|
backgroundTasks: {
|
|
state: 'monitoring',
|
|
tasks: [{ id: 'task-1', kind: 'agent', description: 'review the change' }]
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(changed).not.toBe(monitoring)
|
|
expect(changed.backgroundTasks?.tasks).toEqual([
|
|
{ id: 'task-1', kind: 'agent', description: 'review the change' }
|
|
])
|
|
expect(changed.items).toBe(monitoring.items)
|
|
})
|
|
|
|
it('clears additive background state when a replacement snapshot omits the field', () => {
|
|
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('message', 1)]),
|
|
backgroundTasks: { state: 'monitoring' }
|
|
}
|
|
})
|
|
const withoutCapability = reduceStructuredAgentSession(monitoring, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 2,
|
|
page: hydrationPage([item('message', 1)])
|
|
}
|
|
})
|
|
|
|
expect(withoutCapability.backgroundTasks).toBeUndefined()
|
|
})
|
|
|
|
it('projects ephemeral activity without changing transcript identity and clears it', () => {
|
|
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('message', 1)])
|
|
}
|
|
})
|
|
const active = reduceStructuredAgentSession(initial, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: initial.cursor!,
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
activity: { turnId: 'turn-1', text: 'Checking the renderer' }
|
|
}
|
|
})
|
|
|
|
expect(active.activity).toEqual({ turnId: 'turn-1', text: 'Checking the renderer' })
|
|
expect(active.items).toBe(initial.items)
|
|
|
|
const cleared = reduceStructuredAgentSession(active, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: active.cursor!,
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
activity: null
|
|
}
|
|
})
|
|
|
|
expect(cleared.activity).toBeNull()
|
|
expect(cleared.items).toBe(active.items)
|
|
})
|
|
|
|
it('retains same-epoch activity across a newer journal tail refresh', () => {
|
|
const active = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('first', 1)]),
|
|
activity: { turnId: 'turn-1', text: 'Checking the renderer' }
|
|
}
|
|
})
|
|
const refreshed = reduceStructuredAgentSession(active, {
|
|
type: 'tail-page',
|
|
page: hydrationPage([item('latest', 2)])
|
|
})
|
|
|
|
expect(refreshed.activity).toEqual({ turnId: 'turn-1', text: 'Checking the renderer' })
|
|
})
|
|
})
|
|
|
|
it('applies catalog-only checkpoints without replacing transcript or submission state', () => {
|
|
const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: hydrationPage([item('one', 1)], [submission(1)]),
|
|
commands: []
|
|
}
|
|
})
|
|
const event = {
|
|
type: 'batch' as const,
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
commands: [{ name: 'loaded', kind: 'skill' as const }],
|
|
batch: { cursor: state.cursor!, items: [], removedItemIds: [], submissions: [] }
|
|
}
|
|
const updated = reduceStructuredAgentSession(state, { type: 'event', event })
|
|
expect(updated.commands).toEqual(event.commands)
|
|
expect(updated.items).toBe(state.items)
|
|
expect(updated.submissions).toBe(state.submissions)
|
|
expect(updated.cursor).toBe(state.cursor)
|
|
expect(reduceStructuredAgentSession(updated, { type: 'event', event })).toBe(updated)
|
|
const { commands: _commands, ...oldEvent } = event
|
|
expect(reduceStructuredAgentSession(updated, { type: 'event', event: oldEvent })).toBe(updated)
|
|
})
|