mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* feat(chat): read one saved row per subagent Adds the singular `subagent` journal block (one row per child, revised in place, with a plain-text twin for older clients) and teaches every reader to draw it: desktop and phone fold a turn's sibling rows into the existing "Ran N subagents" row, the client and off-page rosters name children from their own rows, the reopen pass settles a row a dead host left working, and the phone and worker-transcript wires bound it. No producer writes the block yet; that switch lands in the follow-up. * feat(chat): host writes one saved row per subagent Claude, Codex and Grok now only report child-work evidence. The host writes each subagent's row from its child record, so the chat row, strip and sidebar read one lifecycle. Deletes the shared subagent tracker, the per-agent rosters and their caps. An accepted Stop settles only the run it was sent to, provisionally, until that run reports its own ending. Work the host store has no room to record keeps the running-tasks strip and its Stop instead of reading as idle. * fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows - background is an open boolean so a later build's value never fails a chat load - a helper's own helpers fold under a groupId of their own, so the phone opens them apart - each subagent's own row rides along with the page window instead of counting as conversation * fix(chat): subagent row writer reads the live turn once, stop acks survive a failed batch - the row writer reads the journal's live turn at most once per change and only for a subagent with no saved, queued or in-flight row; a revision keeps its first scope - a background stop names each accepted task as its answer lands, so a stop-all batch that times out or fails part way still ends the runs it was answered for (Claude and Grok) - the strip's untargeted Stop rule lives in the shared view (offersStopAll); both strips inline it again; incomplete tracking no longer holds commands or rewind - clients admit a frame whose only change is incomplete tracking - drop the Claude stop's dead publish; the real-CLI stop test asserts the run's own ending - list the writer test with the real SQLite tests * fix(chat): a late Grok report never revives an ended helper; Codex keeps announced helpers - Grok: once a helper's run ended, a later progress or repeated spawn sends no live evidence, so its record and saved row stay ended after the next send retires the record; the test rig now retires finished records on each turn open, as the host does - Codex: past the bound, only a child that ran and ended is evicted, so an announced helper that has not started keeps its record, row, Stop and parent - cut: Codex parentTurnId (written, never read), the Claude identities' turn-end forwarding (call the corrections directly), the duplicate Grok outcome map - update stale comments that described the deleted producers * test(chat): Claude frames to saved row, kept Codex path and usage rules, agent ingestion exemption - a captured background Claude helper keeps its saved row working past its spawn result and the parent turn's end, through the real adapter, host and child records, until its own end - restore focused tests for the Codex root path, label trim and placeholder, usage before announcement and parent usage - the per-session live limit test uses tasks: subagents are exempt from it * fix(chat): incomplete tracking follows the refused children, and the strip's Stop reaches them - the host remembers which children the store refused for room, by handle; each leaves when a later report of it is admitted or it reports its own end, so incomplete clears on a truthful signal; the provider session's end still clears all, and past 512 a flag holds until then - a parent row the store refused counts every live child it reports as refused - with work no record lists, an untargeted strip Stop while no turn runs ends the agent through the idle sweep's stop; under a running turn it leaves the agent alone - the background Stop moves into its own module * fix(chat): the incomplete Stop is offered for every agent and decides inside its step - the strip offers the untargeted Stop for work no row lists whenever no turn runs, whatever untargeted stop the provider has (ACP agents, Codex before it proves terminal stops), since the host reaches it by ending the agent; never under a running turn - the Stop reads whether work is unlisted and whether a turn runs inside its own step, after the listed stops land, so a wake-up turn opened meanwhile is never ended - Codex executions past the bound evict the oldest ended child, else the oldest never started - test the writer's unnamed-subagent label; qualify the writer's retry comment * refactor(chat): the subagent row writer joins the views with their records Drops the shared host-only row projection and its hook-server read: the writer's wiring reads the views every surface reads and the records they come from, and takes residency and the ending's basis from the record. * refactor(chat): Grok subagent evidence reads its ending through the one outcome map * refactor(chat): a subagent's group entry as one object * fix(chat): a background Stop with nothing listed asks the agent for no task stops So an agent with no stop route (an ACP agent the probe found none for) never refuses before the Stop ends the work no row lists. Tests: the unlisted case is untargeted and reads unlisted work inside the step; the repeat-press rig lists the task its Stops reach; reflow the writer header. * test(chat): a nested helper's saved parent; describe how incomplete tracking clears * fix(chat): a closed session claims no Stop; a helper's row follows its own running work - a session close Orca proves is loss of contact, not a Stop the CLI accepted: Claude no longer ends what still ran as stopped, so the host settles it unknown and its row reads unverifiable, as before this stack; stop-acknowledged comes only from a Stop the CLI accepted - a helper that finished while work it started still runs is written working, by the strip's own rule; once that work ends, or the session ends, it reads its own ending again - the CLI starting a completed helper again under the same spawn call is the helper waking when its work ends: the decoder reports it as a restart, so its one row revises in place and ends with its real duration; after any other ending such a start is still read as late - tests from a captured 2.1.280 frame order; item 4 Stops pin their stop-acknowledged basis * refactor(chat): drop the unused Stop basis on a child's own ending Only the acknowledged-Stop edge writes that basis now; reflow the decoder header. * fix(chat): an older history page keeps a turn's subagent ordinals and row A page now reaches back to the first sibling of each subagent group it holds part of, at most one more page of conversation, so loading the older page no longer renumbers same-named helpers or moves (and closes) their folded row.
205 lines
8.7 KiB
TypeScript
205 lines
8.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentSessionBackgroundTaskState } from './agent-session-wire'
|
|
import type { AgentChildWorkView } from './agent-status-child-work-view'
|
|
import {
|
|
structuredSessionBackgroundTasksView,
|
|
structuredSessionConfirmedStopsStillListed,
|
|
structuredSessionListedTaskRun
|
|
} from './structured-session-background-tasks-view'
|
|
|
|
function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = 'agent') {
|
|
return {
|
|
id: `${kind}-${membership}`,
|
|
kind,
|
|
state: membership === 'live' ? 'working' : 'done',
|
|
membership,
|
|
...(membership === 'settled' ? { outcome: 'succeeded', settledAt: 2 } : {}),
|
|
firstObservedAt: 1,
|
|
observedAt: 2,
|
|
stoppable: false,
|
|
invocation: { invocationId: 'run-1', generation: 1 }
|
|
} satisfies AgentChildWorkView
|
|
}
|
|
|
|
describe('structuredSessionBackgroundTasksView', () => {
|
|
it('does not mount the strip for a foreground command from an older host', () => {
|
|
const id = 'codex-command:primary:pwd'
|
|
const foreground = new Set([id])
|
|
expect(
|
|
structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks: [{ id, kind: 'command' }] },
|
|
'turn-1',
|
|
foreground
|
|
)
|
|
).toMatchObject({ show: false, isMonitoring: false, tasks: [] })
|
|
})
|
|
|
|
it('removes the foreground command from child views and legacy tasks together', () => {
|
|
const id = 'codex-command:primary:pwd'
|
|
const shell = { ...view('live', 'command'), providerId: id }
|
|
const agent = view('live')
|
|
const tasks = [
|
|
{ id, kind: 'command' as const },
|
|
{ id: 'child-agent', kind: 'agent' as const }
|
|
]
|
|
const shown = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks, children: [shell, agent] },
|
|
'turn-1',
|
|
new Set([id])
|
|
)
|
|
expect(shown).toMatchObject({ show: true, tasks: [tasks[1]], children: [agent] })
|
|
})
|
|
|
|
it('keeps unrelated rosters and commands from earlier turns during the current turn', () => {
|
|
const tasks = [
|
|
{ id: 'codex-command:primary:server', kind: 'command' as const },
|
|
{ id: 'claude-shell', kind: 'command' as const },
|
|
{ id: 'codex-command:thread:child:pwd', kind: 'command' as const }
|
|
]
|
|
const shown = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks },
|
|
'turn-2',
|
|
new Set(['codex-command:primary:pwd'])
|
|
)
|
|
expect(shown.show).toBe(true)
|
|
expect(shown.tasks).toBe(tasks)
|
|
})
|
|
|
|
it('shows finished children until the next turn, but they hold nothing open', () => {
|
|
const finished = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', children: [view('settled')] },
|
|
null
|
|
)
|
|
expect(finished).toMatchObject({ show: true, isMonitoring: false, children: [view('settled')] })
|
|
})
|
|
|
|
it('hands the strip the decoded roster it was given, so its grouped rows stay memoized', () => {
|
|
const roster = { state: 'monitoring' as const, children: [view('live')] }
|
|
expect(structuredSessionBackgroundTasksView(roster, null).children).toBe(roster.children)
|
|
expect(structuredSessionBackgroundTasksView(roster, 'turn-1').children).toBe(roster.children)
|
|
})
|
|
|
|
it('keeps the same empty lists for a roster that omits one, so the strip memo holds', () => {
|
|
for (const roster of [
|
|
{ state: 'monitoring' as const, tasks: [{ id: 'task-1', kind: 'agent' as const }] },
|
|
{ state: 'monitoring' as const, settledTasks: [{ id: 'task-2', kind: 'agent' as const }] },
|
|
null
|
|
]) {
|
|
const first = structuredSessionBackgroundTasksView(roster, null)
|
|
const again = structuredSessionBackgroundTasksView(roster, null)
|
|
expect(again.tasks).toBe(first.tasks)
|
|
expect(again.settledTasks).toBe(first.settledTasks)
|
|
}
|
|
})
|
|
|
|
// Absence of a row never reads as idle when the host says some work went unrecorded; commands
|
|
// and rewind still follow the records, as the host's own admission does.
|
|
it('keeps the strip and its Stop when the host could not record some work', () => {
|
|
expect(
|
|
structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', children: [], supportsTaskStop: true, incomplete: true },
|
|
null
|
|
)
|
|
).toMatchObject({ show: true, isMonitoring: false, incomplete: true, offersStopAll: true })
|
|
})
|
|
|
|
it('offers the untargeted Stop only where no row stop reaches the work, and the host has one', () => {
|
|
const offers = (
|
|
state: Partial<AgentSessionBackgroundTaskState>,
|
|
turnId: string | null = null
|
|
) =>
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', ...state }, turnId).offersStopAll
|
|
expect(offers({ supportsTaskStop: true })).toBe(false)
|
|
expect(offers({ supportsTaskStop: false })).toBe(true)
|
|
expect(offers({ supportsTaskStop: false }, 'turn-1')).toBe(true)
|
|
expect(offers({ supportsTaskStop: false, supportsStopAll: false })).toBe(false)
|
|
expect(offers({ supportsTaskStop: true, incomplete: true })).toBe(true)
|
|
// Unlisted work is reached by ending the agent: no untargeted provider stop is needed, as on an
|
|
// ACP agent, and none is offered under a running turn the agent would end with it.
|
|
const acp = { supportsTaskStop: true, supportsStopAll: false, incomplete: true } as const
|
|
expect(offers(acp)).toBe(true)
|
|
expect(offers(acp, 'turn-1')).toBe(false)
|
|
expect(offers({ ...acp, supportsTaskStop: false })).toBe(true)
|
|
})
|
|
|
|
it('reads live work beneath an idle session as monitoring, and a running turn as its own', () => {
|
|
const roster = {
|
|
state: 'monitoring' as const,
|
|
children: [view('settled'), view('live', 'command')]
|
|
}
|
|
expect(structuredSessionBackgroundTasksView(roster, null).isMonitoring).toBe(true)
|
|
expect(structuredSessionBackgroundTasksView(roster, 'turn-1').isMonitoring).toBe(false)
|
|
})
|
|
|
|
it("shows an older host's running tasks only, and nothing once only finished ones are left", () => {
|
|
const tasks = [{ id: 'task-1', kind: 'agent' as const }]
|
|
const settledTasks = [{ id: 'task-2', kind: 'agent' as const }]
|
|
expect(
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', tasks, settledTasks }, null)
|
|
).toMatchObject({ show: true, isMonitoring: true, tasks, settledTasks: [] })
|
|
expect(
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', settledTasks }, null)
|
|
).toMatchObject({ show: false, isMonitoring: false })
|
|
// A host that reports state only still says something runs.
|
|
expect(structuredSessionBackgroundTasksView({ state: 'monitoring' }, null).show).toBe(true)
|
|
})
|
|
|
|
it('reads an older host that publishes no views exactly as before', () => {
|
|
const tasks = [{ id: 'task-1', kind: 'agent' as const }]
|
|
expect(structuredSessionBackgroundTasksView({ state: 'monitoring', tasks }, null)).toEqual({
|
|
show: true,
|
|
isMonitoring: true,
|
|
tasks,
|
|
settledTasks: [],
|
|
supportsStop: false,
|
|
offersStopAll: true,
|
|
incomplete: false
|
|
})
|
|
expect(structuredSessionBackgroundTasksView(null, null)).toMatchObject({
|
|
show: false,
|
|
isMonitoring: false
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('a confirmed Stop held per run', () => {
|
|
const helper = (invocationId: string, generation: number): AgentChildWorkView => ({
|
|
...view('live'),
|
|
providerId: 'thread-helper',
|
|
invocation: { invocationId, generation }
|
|
})
|
|
const withChildren = (children: AgentChildWorkView[]) =>
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', children }, null)
|
|
|
|
it("keys a child's run by its invocation, so the next run under the same id is another", () => {
|
|
const first = withChildren([helper('turn-1', 1)])
|
|
const run = structuredSessionListedTaskRun(first, 'thread-helper')
|
|
expect(run).not.toBeNull()
|
|
const confirmed = new Map([['thread-helper', run!]])
|
|
|
|
expect(structuredSessionConfirmedStopsStillListed(first, confirmed)).toBe(confirmed)
|
|
const next = withChildren([helper('turn-2', 2)])
|
|
expect(structuredSessionListedTaskRun(next, 'thread-helper')).not.toBe(run)
|
|
expect(structuredSessionConfirmedStopsStillListed(next, confirmed)).toEqual(new Map())
|
|
expect(structuredSessionConfirmedStopsStillListed(withChildren([]), confirmed)).toEqual(
|
|
new Map()
|
|
)
|
|
})
|
|
|
|
it("keys an older host's roster row by its id, which it has no run marker beside", () => {
|
|
const legacy = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] },
|
|
null
|
|
)
|
|
expect(structuredSessionListedTaskRun(legacy, 'task-1')).toBe('task-1')
|
|
expect(structuredSessionListedTaskRun(legacy, 'task-2')).toBeNull()
|
|
const confirmed = new Map([['task-1', 'task-1']])
|
|
expect(structuredSessionConfirmedStopsStillListed(legacy, confirmed)).toBe(confirmed)
|
|
})
|
|
|
|
it('lists nothing while the strip is hidden', () => {
|
|
const hidden = structuredSessionBackgroundTasksView(null, null)
|
|
expect(structuredSessionListedTaskRun(hidden, 'task-1')).toBeNull()
|
|
})
|
|
})
|