mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
* feat(native-chat): name, group, and state the background-tasks strip
The strip above the composer described five different kinds of background
work as "Monitoring background tasks", with identical flat-dot rows. Now:
- Wire: additive optional `name`, `state`, `startedAt` on
AgentSessionBackgroundTask, plus `settledTasks` on the state object so
terminal siblings of a live fan-out stay visible without changing what
old clients render (they keep exactly the live `tasks` list).
- Reducer equality learns the new fields, so a publish whose only change
is a task's state is no longer judged equal and dropped.
- Header counts by kind and lists states within a kind; past three kind
segments (or on a narrow strip, measured by its own border-box against
the live root font size) it falls back to an honest total, never a
partial enumeration, and the strip stays expandable whenever the header
is lossy.
- Rows group by kind (Agents / Shell / Monitors / Workflows / Tasks),
stable-sorted first-seen-then-id, each with a kind icon, its own state
dot, a resolved name (description -> name -> kind label), and elapsed.
- Claude producer: task frames now carry name (agent_type/subagent_type),
a run state mapped from patch status, and first-seen startedAt. Terminal
statuses settle a task (completed->done, failed->blocked,
killed/stopped->idle) instead of deleting it; settled tasks render only
beside still-live work and flush when the last live task ends, so the
strip exits exactly when it does today. An unreadable patch leaves a
task open, never settled.
- Turn gating moves off the strip: the tracker no longer zeroes its
roster during a foreground turn, and the client renders the strip
whenever it has contents while the idle-only flag now gates just the
animated monitoring indicator and conversation commands.
* feat(sidebar): indent native-chat subagents under their session row
buildSubagentChildRows() has always rendered indented children from
parentEntry.subagents, and the structured-session status bridge has
always published an AgentStatusEntry for native chat — it just never
populated subagents. Connect them:
- Wire: additive optional `backgroundTasks` on AgentSessionStatusSummary
(live tasks only), projected by the host status feed from the
provider's backgroundTaskState hook and republished on task edges via
the background-task channel, with the shared task equality suppressing
no-op re-projections.
- Bridge: maps agent-kind tasks onto the sidebar's own
AgentSubagentState (working/waiting/blocked, terminal -> idle) — kinds
stay distinct, so a backgrounded shell never lands in a subagent
count — and extends its pre-write equality with the existing
agentSubagentsEqual.
- parentIsFresh for a bridge entry means "the host feed reported a
change inside the sidebar's ordinary evidence window": every publish
restamps evidenceObservedAt, and a dead feed stops restamping, so
children decay to idle on lost contact instead of pinning 'working'.
* fix(native-chat): settle tasks the aggregate roster evicted first; carry usage
Real-agent QA showed settledTasks never rendered. A frame capture from the
SDK (probe against claude 2.1.261) explains it: when a backgrounded child
finishes, the producer emits `background_tasks_changed` FIRST — with the
task already absent — and only then `task_updated`/`task_notification`
with the outcome, in the same tick. The tracker's settle path looked the
task up in the live roster the aggregate had just evicted, so retention
lost the race 100% of the time.
Fix: aggregate eviction of a live backgrounded task now parks its details
in a bounded recently-removed map (new claude-settled-background-tasks.ts,
which also owns the settled roster), and the trailing terminal edge
consumes it. A removal whose outcome frame never arrives still vanishes —
nothing is guessed into a finished state. A second terminal edge for the
same task re-derives the settled state and can add final usage. The
captured sequence is replayed verbatim as a tracker test, including the
kill-at-exit tail proving the strip still exits with the last live task.
The same capture disproved the PR's earlier claim that Claude task frames
carry no usage: task_progress and task_notification both carry
usage.total_tokens. Additive optional `totalTokens` on the wire task,
covered by the shared equality; the tracker takes usage (never the
transient "Running <tool>" description) from task_progress, and rows
render the mock's "18.1k · 2m" meta — settled rows keep final usage with
no still-growing clock.
* chore(i18n): sync runtime-required catalog for backgroundTasks.runningList
* fix(native-chat): preserve background task lifecycle and bound update work
* fix(native-chat): transfer resumed background tasks to one live owner
* fix(native-chat): bring structured session host under the line cap and restore subscribe fixture
* fix(native-chat): complete journal stubs and stop notifying on feed teardown
The status feed's projection cache calls journal.cursor(); the rename test's
stubs are cast through unknown, so the missing method only surfaced at runtime.
Teardown runs only once nothing is activated, so there is no mounted reader to
notify - clearing confirmed sessions is what prevents a stale live on reactivation.
* feat(native-chat): lead each strip header count with its kind icon
The header carried one aggregate state dot, so a fan-out of agents and a
monitor looked alike. Each count segment now leads with its own kind glyph;
a collapsed total spans kinds and takes none.
Monitor is the heartbeat AgentStateDot already draws for monitoring, so the
strip and the agent sidebar speak one vocabulary.
* feat(native-chat): give the strip's monitor heartbeat the sidebar amber
The glyph matched AgentStateDot but the colour did not, so a monitor in the
strip did not read as the monitor in the agent sidebar. One shared tone helper
now serves the header segment and the expanded row, so they cannot diverge.
Monitoring is a state the app already colours; the other four kinds are plain
markers and stay neutral. A running turn still dims the whole set.
* fix(native-chat): draw the strip header separator in a visible tone
The separator used `text-border`, a divider-line token that is 7% white in
dark mode - an order of magnitude fainter than the counts on either side, so
the dot between them read as absent. main.css already records that token as
too faint for a visible mark.
* fix(native-chat): give the worktree-ps journal stub a cursor
The status feed's projection cache calls journal.cursor(); this stub is cast
through unknown, so the missing method only surfaced at runtime. Its journal
never changes, so a real one would hold the cursor steady.
* refactor(native-chat): split the sidebar subagent rows out of this PR
The strip stands alone: the sidebar mapping, its observation plumbing and the
AgentStatusEntry.subagents wiring move to a stacked follow-up. No wire field
here is sidebar-only - the strip's rows read name, state, elapsed and tokens.
* perf(native-chat): keep task usage out of the session status summary
A `task_progress` frame ticks a background task's `totalTokens`, which
failed the status feed's equality check and re-broadcast a full summary to
every `agentSession.subscribeStatus` subscriber — paired-web and SSH/relay
clients included — for a number no session list renders. The projection now
drops usage; tokens keep flowing on the background-task channel the strip
reads.
* fix(native-chat): correct token unit rounding and drop the unused dot state
`formatBackgroundTaskTokens` rounded before choosing the unit, so 999_950
rendered as "1000k" instead of "1m"; pick the unit from the rounded value.
`backgroundTasksDotState` has no caller on this branch or the stacked
sidebar PR, and its multi-kind branch would report 'monitoring' over an
attention state. Delete it rather than leave it to be wired up.
* fix(i18n): drop the orphaned backgroundTasks.runningList key
The strip rewrite removed its only call site, and an unreferenced key gets
promoted into the eagerly parsed boot catalog. Delete it from en.json and
regenerate en-runtime-required.json.
* fix(native-chat): show the reason on every attention row
The row guarded the reason line on 'waiting', so an 'unverifiable' child
("no contact") and a 'blocked' one ("failed") rendered bare while the
collapsed header named exactly those reasons. `backgroundTaskStateReason`
already returns null for the non-attention states, so the guard was only
lossy — the SSH boundary requires the unverifiable verdict stay legible.
Also keys the header segments off their kind discriminant instead of the
translated display text.
* fix(native-chat): make the strip header agree with its own count
The headline counts live AND settled rows, but the state breakdown omitted
'done', so one working agent beside four settled ones read "5 agents — 1
working": the count said five, the breakdown accounted for one. Done now
appears in the muted detail (never as an emphasised segment) so the two
agree.
The single-command header also drew an elapsed clock on a settled task,
which the row already refuses as a lie about finished work.
* perf(native-chat): memoize the background-task roster grouping
The 1 Hz elapsed tick re-rendered the strip, and the render body regrouped,
re-sorted and re-translated every task each time only `now` had changed.
The header still derives from `now` on purpose.
* test(native-chat): cover settled rows and the mid-turn mounted strip
Neither headline behaviour had component coverage: every strip render passed
`settledTasks={[]}`, and the `showBackgroundTasks` seam was never set true,
so the strip staying mounted through a running turn was exercised nowhere.
Adds a settled-beside-live row test (final usage kept, no clock, no stop) and
a mid-turn mount test (strip present, turn owns the voice). The background-task
tests share one session-element helper so the file stays under its line cap.
* refactor(claude): keep MAX_TASK_ID_LENGTH module-private
Nothing outside claude-background-task-frames.ts references it; the export
was residue from this PR's split.
* test(native-chat): give the mid-turn strip test a real turn
main now gates the composer's stop button on a provider-minted turnId rather
than the send-time working signal, so a test claiming a running turn has to
supply one. The controller mock hardcoded turnId null.
---------
Co-authored-by: Merge Sim <sim@local>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import type {
|
|
AgentJournalCursor,
|
|
AgentJournalRenderItem,
|
|
AgentJournalSubmission
|
|
} from './agent-session-journal-types'
|
|
import {
|
|
agentSessionBackgroundTasksEqual,
|
|
type AgentSessionBackgroundTaskState,
|
|
type AgentSessionSlashCommand,
|
|
type AgentSessionHandoffStatus,
|
|
type AgentSessionHistoryPage,
|
|
type AgentSessionSubscribeEvent,
|
|
type AgentSessionTurnActivity
|
|
} from './agent-session-wire'
|
|
|
|
export type StructuredAgentSessionState = {
|
|
epoch: string | null
|
|
cursor: AgentJournalCursor | null
|
|
fence: number | null
|
|
items: AgentJournalRenderItem[]
|
|
submissions: AgentJournalSubmission[]
|
|
/** Head-trim floor for `items`; paging back raises it so a live batch cannot undo the page. */
|
|
retainedItemLimit: number
|
|
hasOlder: boolean
|
|
status: 'idle' | 'loading' | 'ready' | 'error'
|
|
error?: string
|
|
handoff: AgentSessionHandoffStatus | null
|
|
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
|
commands?: AgentSessionSlashCommand[] | null
|
|
activity?: AgentSessionTurnActivity | null
|
|
}
|
|
|
|
export type StructuredAgentSessionAction =
|
|
| { type: 'loading' }
|
|
| { type: 'error'; message: string }
|
|
| { type: 'handoff'; handoff: AgentSessionHandoffStatus }
|
|
| { type: 'event'; event: AgentSessionSubscribeEvent }
|
|
| { type: 'tail-page'; page: AgentSessionHistoryPage }
|
|
| { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage }
|
|
|
|
const MAX_RETAINED_SUBMISSIONS = 256
|
|
// Well above the renderer's initial read window (300) plus a page, so only genuinely
|
|
// long live sessions trim; anything trimmed is still reachable by paging older.
|
|
const MAX_RETAINED_ITEMS = 1024
|
|
|
|
export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
|
|
epoch: null,
|
|
cursor: null,
|
|
fence: null,
|
|
items: [],
|
|
submissions: [],
|
|
retainedItemLimit: MAX_RETAINED_ITEMS,
|
|
hasOlder: false,
|
|
status: 'idle',
|
|
handoff: null
|
|
}
|
|
|
|
function backgroundTaskStatesEqual(
|
|
left: AgentSessionBackgroundTaskState | null | undefined,
|
|
right: AgentSessionBackgroundTaskState | null | undefined
|
|
): boolean {
|
|
if (left === right) {
|
|
return true
|
|
}
|
|
if (
|
|
!left ||
|
|
!right ||
|
|
left.state !== right.state ||
|
|
left.supportsTaskStop !== right.supportsTaskStop ||
|
|
left.supportsStopAll !== right.supportsStopAll
|
|
) {
|
|
return false
|
|
}
|
|
return (
|
|
agentSessionBackgroundTasksEqual(left.tasks, right.tasks) &&
|
|
agentSessionBackgroundTasksEqual(left.settledTasks, right.settledTasks)
|
|
)
|
|
}
|
|
|
|
function replacePage(
|
|
page: AgentSessionHistoryPage,
|
|
fence: number,
|
|
handoff?: AgentSessionHandoffStatus,
|
|
backgroundTasks?: AgentSessionBackgroundTaskState | null,
|
|
activity?: AgentSessionTurnActivity | null
|
|
): StructuredAgentSessionState {
|
|
return {
|
|
epoch: page.epoch,
|
|
cursor: page.liveCursor ?? page.window.nextCursor,
|
|
fence,
|
|
items: [...page.items].sort((left, right) => left.sequence - right.sequence),
|
|
submissions: page.submissions,
|
|
retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length),
|
|
hasOlder: page.hasOlder,
|
|
status: 'ready',
|
|
handoff: handoff ?? null,
|
|
activity: activity ?? null,
|
|
...(backgroundTasks !== undefined
|
|
? { backgroundTasks }
|
|
: page.backgroundTasks !== undefined
|
|
? { backgroundTasks: page.backgroundTasks }
|
|
: {})
|
|
}
|
|
}
|
|
|
|
function mergeItems(
|
|
current: readonly AgentJournalRenderItem[],
|
|
incoming: readonly AgentJournalRenderItem[],
|
|
removedIds: readonly string[]
|
|
): AgentJournalRenderItem[] {
|
|
const removed = new Set(removedIds)
|
|
const byId = new Map(
|
|
current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item])
|
|
)
|
|
for (const item of incoming) {
|
|
const prior = byId.get(item.itemId)
|
|
if (!prior || item.revision >= prior.revision) {
|
|
byId.set(item.itemId, item)
|
|
}
|
|
}
|
|
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
|
|
}
|
|
|
|
function trimRetainedItems(
|
|
items: AgentJournalRenderItem[],
|
|
limit: number
|
|
): AgentJournalRenderItem[] {
|
|
return items.length <= limit ? items : items.slice(items.length - limit)
|
|
}
|
|
|
|
function mergeSubmissions(
|
|
current: readonly AgentJournalSubmission[],
|
|
incoming: readonly AgentJournalSubmission[]
|
|
): AgentJournalSubmission[] {
|
|
const byId = new Map(current.map((submission) => [submission.clientMessageId, submission]))
|
|
for (const submission of incoming) {
|
|
byId.set(submission.clientMessageId, submission)
|
|
}
|
|
return [...byId.values()]
|
|
.sort((left, right) => left.submittedAt - right.submittedAt)
|
|
.slice(-MAX_RETAINED_SUBMISSIONS)
|
|
}
|
|
|
|
export function reduceStructuredAgentSession(
|
|
state: StructuredAgentSessionState,
|
|
action: StructuredAgentSessionAction
|
|
): StructuredAgentSessionState {
|
|
if (action.type === 'loading') {
|
|
// Keep the last transcript visible while a reconnect rehydrates the stream.
|
|
return { ...state, status: 'loading', error: undefined }
|
|
}
|
|
if (action.type === 'error') {
|
|
return { ...state, status: 'error', error: action.message }
|
|
}
|
|
if (action.type === 'handoff') {
|
|
return { ...state, handoff: action.handoff }
|
|
}
|
|
if (action.type === 'tail-page') {
|
|
const pageCursor = action.page.liveCursor ?? action.page.window.newest
|
|
// An equal cursor means the page holds nothing the stream has not already
|
|
// delivered; replacing would throw away paged-in older items mid-scroll.
|
|
if (
|
|
state.epoch === action.page.epoch &&
|
|
state.cursor &&
|
|
(!pageCursor || pageCursor.sequence <= state.cursor.sequence)
|
|
) {
|
|
const backgroundTasksChanged =
|
|
action.page.backgroundTasks !== undefined &&
|
|
!backgroundTaskStatesEqual(action.page.backgroundTasks, state.backgroundTasks)
|
|
if (
|
|
pageCursor?.sequence === state.cursor.sequence &&
|
|
((action.page.fence !== undefined && action.page.fence !== state.fence) ||
|
|
backgroundTasksChanged)
|
|
) {
|
|
return {
|
|
...state,
|
|
...(action.page.fence !== undefined ? { fence: action.page.fence } : {}),
|
|
...(action.page.backgroundTasks !== undefined
|
|
? { backgroundTasks: action.page.backgroundTasks }
|
|
: {}),
|
|
status: 'ready',
|
|
error: undefined
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
const sameEpoch = state.epoch === action.page.epoch
|
|
return {
|
|
epoch: action.page.epoch,
|
|
cursor: action.page.liveCursor ?? null,
|
|
fence: action.page.fence ?? null,
|
|
items: action.page.items,
|
|
submissions: sameEpoch
|
|
? mergeSubmissions(state.submissions, action.page.submissions)
|
|
: action.page.submissions,
|
|
retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, action.page.items.length),
|
|
hasOlder: action.page.hasOlder,
|
|
status: 'ready',
|
|
handoff: state.handoff,
|
|
...(sameEpoch ? { commands: state.commands } : {}),
|
|
...(sameEpoch && state.activity !== undefined ? { activity: state.activity } : {}),
|
|
...(action.page.backgroundTasks !== undefined
|
|
? { backgroundTasks: action.page.backgroundTasks }
|
|
: state.backgroundTasks !== undefined
|
|
? { backgroundTasks: state.backgroundTasks }
|
|
: {})
|
|
}
|
|
}
|
|
if (action.type === 'older-page') {
|
|
if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) {
|
|
return state
|
|
}
|
|
const paged = mergeItems(state.items, action.page.items, action.page.removedItemIds)
|
|
return {
|
|
...state,
|
|
items: paged,
|
|
retainedItemLimit: Math.max(state.retainedItemLimit, paged.length),
|
|
submissions: mergeSubmissions(state.submissions, action.page.submissions),
|
|
hasOlder: action.page.hasOlder
|
|
}
|
|
}
|
|
const event = action.event
|
|
if (event.type === 'end') {
|
|
return state
|
|
}
|
|
if (event.type === 'snapshot' || event.type === 'reset') {
|
|
return {
|
|
...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks, event.activity),
|
|
commands: event.commands
|
|
}
|
|
}
|
|
if (state.epoch !== event.batch.cursor.epoch) {
|
|
return state
|
|
}
|
|
if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) {
|
|
return state
|
|
}
|
|
const backgroundTasks =
|
|
event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks
|
|
const activity = event.activity !== undefined ? event.activity : state.activity
|
|
const journalUnchanged =
|
|
event.batch.items.length === 0 &&
|
|
event.batch.removedItemIds.length === 0 &&
|
|
event.batch.submissions.length === 0
|
|
if (
|
|
event.batch.cursor.sequence === state.cursor?.sequence &&
|
|
journalUnchanged &&
|
|
(event.fence === undefined || event.fence === state.fence) &&
|
|
(event.handoff === undefined || event.handoff === state.handoff) &&
|
|
(event.commands === undefined || event.commands === state.commands) &&
|
|
backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) &&
|
|
activity?.turnId === state.activity?.turnId &&
|
|
activity?.text === state.activity?.text &&
|
|
state.status === 'ready' &&
|
|
state.error === undefined
|
|
) {
|
|
return state
|
|
}
|
|
const merged = journalUnchanged
|
|
? state.items
|
|
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds)
|
|
const items = trimRetainedItems(merged, state.retainedItemLimit)
|
|
return {
|
|
...state,
|
|
cursor: event.batch.cursor,
|
|
fence: event.fence ?? state.fence,
|
|
items,
|
|
// A trim leaves older items behind the cursor, so paging must stay offered.
|
|
hasOlder: items.length < merged.length ? true : state.hasOlder,
|
|
submissions: journalUnchanged
|
|
? state.submissions
|
|
: mergeSubmissions(state.submissions, event.batch.submissions),
|
|
status: 'ready',
|
|
error: undefined,
|
|
handoff: event.handoff ?? state.handoff,
|
|
commands: event.commands !== undefined ? event.commands : state.commands,
|
|
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
|
...(activity !== undefined ? { activity } : {})
|
|
}
|
|
}
|
|
|
|
export function oldestStructuredAgentSessionCursor(
|
|
state: StructuredAgentSessionState
|
|
): AgentJournalCursor | null {
|
|
const oldest = state.items[0]
|
|
return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null
|
|
}
|
|
|
|
export function shouldAdvanceStructuredResumeCursor(
|
|
current: AgentJournalCursor | null,
|
|
incoming: AgentJournalCursor
|
|
): boolean {
|
|
return (
|
|
current === null || (current.epoch === incoming.epoch && incoming.sequence >= current.sequence)
|
|
)
|
|
}
|