mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/unify-agent-session-launch
# Conflicts: # src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 45m">
|
||||
<title>downloads: 45m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 46m">
|
||||
<title>downloads: 46m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">45m</text>
|
||||
<text x="90" y="14">45m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">46m</text>
|
||||
<text x="90" y="14">46m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -100,10 +100,14 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
isPendingFirstAgentMessageRename: () => true
|
||||
})
|
||||
const items: AgentJournalRenderItem[] = []
|
||||
// A real journal's sequence only ever advances, so the feed's projection
|
||||
// cache must miss on every publish here: this test is about the rename.
|
||||
let sequence = 0
|
||||
const journal = {
|
||||
snapshot: () => ({ items }),
|
||||
lastActivityAt: () => 1,
|
||||
isReadOnly: false
|
||||
isReadOnly: false,
|
||||
cursor: () => ({ epoch: 1, sequence: (sequence += 1) })
|
||||
} as unknown as AgentSessionJournal
|
||||
const pending: Promise<void>[] = []
|
||||
const observe = vi.fn((summary, options) => {
|
||||
@@ -175,6 +179,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
const journal = {
|
||||
isReadOnly: false,
|
||||
lastActivityAt: () => 1,
|
||||
cursor: () => ({ epoch: 1, sequence: 1 }),
|
||||
snapshot: () => ({
|
||||
items: [
|
||||
{ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } },
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Field readers for the Claude SDK's background-task lifecycle frames
|
||||
// (task_started / task_updated / task_notification / background_tasks_changed).
|
||||
// Pure and bounded: every reader rejects absent, non-string, or oversized
|
||||
// values so a malformed frame degrades to "field unknown", never to a throw.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState
|
||||
} from '../../shared/agent-session-wire'
|
||||
|
||||
const MAX_TASK_ID_LENGTH = 512
|
||||
const MAX_TASK_TEXT_LENGTH = 512
|
||||
|
||||
export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind']
|
||||
|
||||
export function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
/** The bound every task id shares, wherever it enters. An id the roster stores
|
||||
* becomes a durable entry key, so a provisional one takes the same bound the
|
||||
* announced path applies — an over-long id is rejected, never truncated. */
|
||||
export function isBoundedClaudeTaskId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
|
||||
}
|
||||
|
||||
export function taskId(message: Record<string, unknown>): string | null {
|
||||
const value = message.task_id
|
||||
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
|
||||
}
|
||||
|
||||
function boundedTaskText(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim().replace(/\s+/g, ' ')
|
||||
return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_TEXT_LENGTH) : undefined
|
||||
}
|
||||
|
||||
export function taskDescription(value: unknown): string | undefined {
|
||||
return boundedTaskText(value)
|
||||
}
|
||||
|
||||
/** The provider-reported identity for a task. Subagent frames have carried the
|
||||
* type under both `agent_type` and `subagent_type` across SDK versions. */
|
||||
export function taskName(frame: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
boundedTaskText(frame.name) ??
|
||||
boundedTaskText(frame.agent_type) ??
|
||||
boundedTaskText(frame.subagent_type)
|
||||
)
|
||||
}
|
||||
|
||||
export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind {
|
||||
switch (taskType) {
|
||||
case 'local_agent':
|
||||
return 'agent'
|
||||
case 'local_workflow':
|
||||
return 'workflow'
|
||||
case 'local_bash':
|
||||
return 'command'
|
||||
case 'monitor':
|
||||
return 'monitor'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** Cumulative token usage from a task_progress / task_notification frame. */
|
||||
export function taskUsageTotalTokens(frame: Record<string, unknown>): number | undefined {
|
||||
const usage = record(frame.usage)
|
||||
const total = usage?.total_tokens
|
||||
return typeof total === 'number' && Number.isFinite(total) && total >= 0
|
||||
? Math.floor(total)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Settled state for a terminal status. Null for anything else — an unreadable
|
||||
* status never settles a task by itself. */
|
||||
export function terminalClaudeTaskRunState(
|
||||
status: unknown
|
||||
): AgentSessionBackgroundTaskRunState | null {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'done'
|
||||
case 'failed':
|
||||
return 'blocked'
|
||||
case 'killed':
|
||||
case 'stopped':
|
||||
return 'idle'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Live state for a non-terminal status. Null leaves the tracked state alone. */
|
||||
export function liveClaudeTaskRunState(status: unknown): AgentSessionBackgroundTaskRunState | null {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'running':
|
||||
case 'paused':
|
||||
return 'working'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
const agent = {
|
||||
task_id: 'a962f88aa82feb1c1',
|
||||
task_type: 'local_agent',
|
||||
description: 'Long proof writer'
|
||||
}
|
||||
const shell = { task_id: 'bcl6x3ixf', task_type: 'local_bash', description: 'sleep 150' }
|
||||
const sibling = { task_id: 'sibling', task_type: 'local_agent' }
|
||||
|
||||
function system(subtype: string, fields: Record<string, unknown>) {
|
||||
return { type: 'system', subtype, ...fields }
|
||||
}
|
||||
|
||||
describe('Claude background task pause/resume ownership', () => {
|
||||
it('moves a retained child back to live ownership across eviction, outcome, and auto-resume', () => {
|
||||
let now = 100
|
||||
const tracker = new ClaudeBackgroundTaskTracker(() => now)
|
||||
const roster = (tasks: unknown[]) =>
|
||||
tracker.observe(system('background_tasks_changed', { tasks }))
|
||||
roster([agent, sibling, shell])
|
||||
tracker.observe(
|
||||
system('task_progress', { task_id: agent.task_id, usage: { total_tokens: 18000 } })
|
||||
)
|
||||
tracker.observe({ type: 'result' })
|
||||
expect(tracker.state?.tasks).toHaveLength(3)
|
||||
roster([sibling, shell])
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
tracker.observe(
|
||||
system('task_updated', { task_id: agent.task_id, patch: { status: 'completed' } })
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_notification', {
|
||||
task_id: agent.task_id,
|
||||
status: 'completed',
|
||||
usage: { total_tokens: 19003 }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.settledTasks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: agent.task_id,
|
||||
state: 'done',
|
||||
startedAt: 100,
|
||||
totalTokens: 19003
|
||||
})
|
||||
])
|
||||
now = 150000
|
||||
roster([agent, sibling, shell])
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: agent.task_id,
|
||||
state: 'working',
|
||||
startedAt: 100,
|
||||
totalTokens: 19003
|
||||
}),
|
||||
expect.objectContaining({ id: sibling.task_id }),
|
||||
expect.objectContaining({ id: shell.task_id })
|
||||
])
|
||||
expect(tracker.stoppableTaskIds).toEqual([agent.task_id, sibling.task_id, shell.task_id])
|
||||
roster([sibling, shell])
|
||||
tracker.observe(
|
||||
system('task_notification', {
|
||||
task_id: agent.task_id,
|
||||
status: 'completed',
|
||||
usage: { total_tokens: 21000 }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.settledTasks).toEqual([
|
||||
expect.objectContaining({ id: agent.task_id, startedAt: 100, totalTokens: 21000 })
|
||||
])
|
||||
roster([])
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('reconciles an edge-only resume without keeping its earlier settled copy', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker(() => 100)
|
||||
for (const task of [agent, sibling]) {
|
||||
tracker.observe(system('task_started', { ...task, is_backgrounded: true }))
|
||||
}
|
||||
tracker.observe(system('task_notification', { task_id: agent.task_id, status: 'completed' }))
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: agent.task_id,
|
||||
patch: { status: 'running', is_backgrounded: true }
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.tasks).toHaveLength(2)
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,11 @@ function aggregate(tasks: unknown[]): Record<string, unknown> {
|
||||
return system('background_tasks_changed', { tasks })
|
||||
}
|
||||
|
||||
function trackerAt(times: number[]): ClaudeBackgroundTaskTracker {
|
||||
let index = 0
|
||||
return new ClaudeBackgroundTaskTracker(() => times[Math.min(index++, times.length - 1)])
|
||||
}
|
||||
|
||||
describe('ClaudeBackgroundTaskTracker', () => {
|
||||
it('classifies SDK task types without inferring them from descriptions', () => {
|
||||
expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent')
|
||||
@@ -25,27 +30,30 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
expect(classifyClaudeBackgroundTaskKind('future_task')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('waits for the foreground turn to settle before monitoring a background task', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
it('publishes a backgrounded task while the foreground turn is still running', () => {
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
expect(tracker.observe(result())).toBe(true)
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'agent' }]
|
||||
tasks: [{ id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }]
|
||||
})
|
||||
|
||||
// The turn settling changes nothing the strip renders.
|
||||
expect(tracker.observe(result())).toBe(false)
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses an explicit background update for a foreground task and ignores progress alone', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
@@ -63,12 +71,12 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }))
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'command' }]
|
||||
tasks: [{ id: 'task-1', kind: 'command', state: 'working', startedAt: 100 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes bounded display details when a running task description changes', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
@@ -81,7 +89,15 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }]
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
kind: 'command',
|
||||
description: 'run the build',
|
||||
state: 'working',
|
||||
startedAt: 100
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -103,8 +119,193 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('carries provider-reported names and re-derives classification per transition', () => {
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'future_task',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.state?.tasks?.[0]).toMatchObject({ kind: 'unknown' })
|
||||
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: 'task-1',
|
||||
patch: { task_type: 'local_agent', agent_type: 'deep_review' }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state?.tasks?.[0]).toMatchObject({
|
||||
kind: 'agent',
|
||||
name: 'deep_review',
|
||||
state: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('retains settled siblings beside live work and exits with the last live task', () => {
|
||||
const tracker = trackerAt([100, 200])
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'task-a', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'task-b', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
|
||||
expect(
|
||||
tracker.observe(system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } }))
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-b', kind: 'agent', state: 'working', startedAt: 200 }],
|
||||
settledTasks: [{ id: 'task-a', kind: 'agent', state: 'done', startedAt: 100 }]
|
||||
})
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-b'])
|
||||
|
||||
expect(
|
||||
tracker.observe(system('task_updated', { task_id: 'task-b', patch: { status: 'killed' } }))
|
||||
).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('settles a sibling from the captured producer order: aggregate eviction, then the outcome', () => {
|
||||
// Verbatim sequence from a real SDK capture (2026-09-07): the aggregate
|
||||
// roster arrives FIRST, already missing the finished task, and the
|
||||
// terminal edges trail in the same tick.
|
||||
const tracker = trackerAt([100, 200])
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'bh4zn8der',
|
||||
tool_use_id: 'toolu_01M',
|
||||
description: 'Sleep for 5 seconds',
|
||||
is_backgrounded: true,
|
||||
task_type: 'local_bash'
|
||||
})
|
||||
)
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'bh4zn8der', task_type: 'local_bash', description: 'Sleep for 5 seconds' },
|
||||
{ task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' }
|
||||
])
|
||||
)
|
||||
|
||||
// The settling child is evicted by the aggregate before any outcome frame.
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' }
|
||||
])
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: 'bh4zn8der',
|
||||
patch: { status: 'completed', end_time: 1788804376515 }
|
||||
})
|
||||
)
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_notification', {
|
||||
task_id: 'bh4zn8der',
|
||||
tool_use_id: 'toolu_01M',
|
||||
status: 'completed',
|
||||
summary: 'Background command "Sleep for 5 seconds" completed (exit code 0)',
|
||||
usage: { total_tokens: 18130, tool_uses: 1, duration_ms: 10772 }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{
|
||||
id: 'bprosaiim',
|
||||
kind: 'command',
|
||||
description: 'Sleep for 25 seconds',
|
||||
state: 'working',
|
||||
startedAt: 200
|
||||
}
|
||||
],
|
||||
settledTasks: [
|
||||
{
|
||||
id: 'bh4zn8der',
|
||||
kind: 'command',
|
||||
description: 'Sleep for 5 seconds',
|
||||
state: 'done',
|
||||
startedAt: 100,
|
||||
totalTokens: 18130
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Last task killed, same captured order: the strip exits.
|
||||
tracker.observe(aggregate([]))
|
||||
tracker.observe(system('task_updated', { task_id: 'bprosaiim', patch: { status: 'killed' } }))
|
||||
tracker.observe(system('task_notification', { task_id: 'bprosaiim', status: 'stopped' }))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('carries task_progress usage into a live row without clobbering its name', () => {
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'agent-1',
|
||||
task_type: 'local_agent',
|
||||
subagent_type: 'general-purpose',
|
||||
description: 'Sleep 6 seconds test',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_progress', {
|
||||
task_id: 'agent-1',
|
||||
description: 'Running Sleep for 6 seconds',
|
||||
subagent_type: 'general-purpose',
|
||||
usage: { total_tokens: 14866, tool_uses: 1, duration_ms: 2818 },
|
||||
last_tool_name: 'Bash'
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state?.tasks?.[0]).toEqual({
|
||||
id: 'agent-1',
|
||||
kind: 'agent',
|
||||
// Progress descriptions are transient activity, never the task's name.
|
||||
description: 'Sleep 6 seconds test',
|
||||
name: 'general-purpose',
|
||||
state: 'working',
|
||||
startedAt: 100,
|
||||
totalTokens: 14866
|
||||
})
|
||||
})
|
||||
|
||||
it('maps terminal statuses onto settled states', () => {
|
||||
const tracker = trackerAt([100, 200])
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'live', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'failed', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
tracker.observe(system('task_notification', { task_id: 'failed', status: 'failed' }))
|
||||
expect(tracker.state?.settledTasks).toEqual([
|
||||
{ id: 'failed', kind: 'agent', state: 'blocked', startedAt: 200 }
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a task open when a patch cannot be read', () => {
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
expect(tracker.observe(system('task_updated', { task_id: 'task-1', patch: 'garbage' }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
expect(
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
@@ -117,8 +318,8 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{ id: 'task-agent', kind: 'agent', description: 'agent' },
|
||||
{ id: 'task-bash', kind: 'command', description: 'bash' }
|
||||
{ id: 'task-agent', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 },
|
||||
{ id: 'task-bash', kind: 'command', description: 'bash', state: 'working', startedAt: 100 }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -128,18 +329,31 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-next'])
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-next', kind: 'workflow', description: 'workflow' }]
|
||||
})
|
||||
|
||||
expect(tracker.observe(aggregate([]))).toBe(true)
|
||||
expect(tracker.stoppableTaskIds).toEqual([])
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves first-seen timestamps across aggregate roster replacement', () => {
|
||||
const tracker = trackerAt([100, 200])
|
||||
tracker.observe(
|
||||
system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true })
|
||||
)
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'task-1', task_type: 'local_agent' },
|
||||
{ task_id: 'task-2', task_type: 'local_bash' }
|
||||
])
|
||||
)
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
{ id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 },
|
||||
{ id: 'task-2', kind: 'command', state: 'working', startedAt: 200 }
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes ambient aggregate tasks', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'ambient', task_type: 'monitor', description: 'watcher', ambient: true },
|
||||
@@ -151,7 +365,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
})
|
||||
|
||||
it('does not let late edge frames revive tasks cleared by an aggregate roster', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-late', task_type: 'local_agent', description: 'agent' }])
|
||||
)
|
||||
@@ -173,7 +387,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
})
|
||||
|
||||
it('lets an authoritative aggregate roster replace earlier terminal-edge evidence', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(system('task_notification', { task_id: 'task-live', status: 'completed' }))
|
||||
|
||||
tracker.observe(
|
||||
@@ -183,12 +397,28 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-live'])
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'agent', description: 'agent' }]
|
||||
tasks: [
|
||||
{ id: 'task-live', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('retracts a settled copy when an authoritative roster reports the task live again', () => {
|
||||
const tracker = trackerAt([100, 200, 300])
|
||||
const tasks = [
|
||||
{ task_id: 'agent', task_type: 'local_agent', description: 'Review sample' },
|
||||
{ task_id: 'shell', task_type: 'local_bash' }
|
||||
]
|
||||
tracker.observe(aggregate(tasks))
|
||||
tracker.observe(system('task_notification', { task_id: 'agent', status: 'completed' }))
|
||||
expect(tracker.state?.settledTasks).toHaveLength(1)
|
||||
tracker.observe(aggregate(tasks))
|
||||
expect(tracker.state?.tasks?.map((task) => task.id)).toEqual(['agent', 'shell'])
|
||||
expect(tracker.state?.settledTasks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps terminal edges authoritative on either side of aggregate replacement', () => {
|
||||
const terminalFirst = new ClaudeBackgroundTaskTracker()
|
||||
const terminalFirst = trackerAt([100])
|
||||
terminalFirst.observe(
|
||||
system('task_notification', { task_id: 'task-first', status: 'completed' })
|
||||
)
|
||||
@@ -202,7 +432,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
)
|
||||
expect(terminalFirst.state).toBeNull()
|
||||
|
||||
const terminalLast = new ClaudeBackgroundTaskTracker()
|
||||
const terminalLast = trackerAt([100])
|
||||
terminalLast.observe(
|
||||
aggregate([{ task_id: 'task-last', task_type: 'local_agent', description: 'agent' }])
|
||||
)
|
||||
@@ -218,7 +448,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
})
|
||||
|
||||
it('keeps terminal evidence authoritative across duplicates and out-of-order starts', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
const terminal = system('task_notification', { task_id: 'task-late', status: 'completed' })
|
||||
tracker.observe(terminal)
|
||||
tracker.observe(terminal)
|
||||
@@ -239,7 +469,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'monitor' }]
|
||||
tasks: [{ id: 'task-live', kind: 'monitor', state: 'monitoring', startedAt: 100 }]
|
||||
})
|
||||
expect(
|
||||
tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } }))
|
||||
@@ -249,17 +479,24 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
|
||||
it('recognizes task types that are registered only as background work', () => {
|
||||
for (const taskType of ['local_workflow', 'monitor']) {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(system('task_started', { task_id: taskType, task_type: taskType }))
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: taskType, kind: taskType === 'local_workflow' ? 'workflow' : 'monitor' }]
|
||||
tasks: [
|
||||
{
|
||||
id: taskType,
|
||||
kind: taskType === 'local_workflow' ? 'workflow' : 'monitor',
|
||||
state: taskType === 'local_workflow' ? 'working' : 'monitoring',
|
||||
startedAt: 100
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('admits unknown background updates conservatively and bounds edge-only fallback ids', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } })
|
||||
)
|
||||
@@ -278,7 +515,7 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
})
|
||||
|
||||
it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
aggregate(
|
||||
Array.from({ length: 400 }, (_, index) => ({
|
||||
@@ -301,23 +538,30 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
expect(tracker.stoppableTaskIds).toEqual(['edge-after-reset'])
|
||||
})
|
||||
|
||||
it('gates aggregate monitoring behind foreground turn completion', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
it('publishes an aggregate roster observed mid-turn', () => {
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }])
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
expect(tracker.observe(result())).toBe(true)
|
||||
expect(
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }])
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'command', description: 'command' }]
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-live',
|
||||
kind: 'command',
|
||||
description: 'command',
|
||||
state: 'working',
|
||||
startedAt: 100
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores ambient SDK tasks and clears all liveness when the session ends', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const tracker = trackerAt([100])
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'ambient',
|
||||
|
||||
@@ -1,78 +1,54 @@
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState,
|
||||
AgentSessionBackgroundTaskState
|
||||
} from '../../shared/agent-session-wire'
|
||||
import {
|
||||
classifyClaudeBackgroundTaskKind,
|
||||
liveClaudeTaskRunState,
|
||||
record,
|
||||
taskDescription,
|
||||
taskId,
|
||||
taskName,
|
||||
taskUsageTotalTokens,
|
||||
terminalClaudeTaskRunState
|
||||
} from './claude-background-task-frames'
|
||||
import {
|
||||
ClaudeSettledBackgroundTasks,
|
||||
claudeBackgroundTaskDetail,
|
||||
type TrackedClaudeBackgroundTask
|
||||
} from './claude-settled-background-tasks'
|
||||
|
||||
// `claude-subagent-*` reads this channel through these names; the readers themselves
|
||||
// live in the frames module so both consumers share one definition.
|
||||
export {
|
||||
classifyClaudeBackgroundTaskKind,
|
||||
isBoundedClaudeTaskId,
|
||||
taskDescription as claudeTaskDescription,
|
||||
taskId as claudeTaskId
|
||||
} from './claude-background-task-frames'
|
||||
export type { ClaudeBackgroundTaskKind } from './claude-background-task-frames'
|
||||
|
||||
const MAX_TRACKED_TASKS = 256
|
||||
const MAX_TASK_ID_LENGTH = 512
|
||||
const MAX_TASK_DESCRIPTION_LENGTH = 512
|
||||
const TERMINAL_TASK_STATES = new Set(['completed', 'failed', 'killed', 'stopped'])
|
||||
|
||||
export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind']
|
||||
|
||||
type TrackedTask = {
|
||||
backgrounded: boolean
|
||||
kind: ClaudeBackgroundTaskKind
|
||||
description?: string
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
/** The bound every task id shares, wherever it enters. An id the roster stores
|
||||
* becomes a durable entry key, so a provisional one takes the same bound the
|
||||
* announced path applies — an over-long id is rejected, never truncated. */
|
||||
export function isBoundedClaudeTaskId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
|
||||
}
|
||||
|
||||
/** The task's canonical, resume-stable id. Shared with the subagent roster so
|
||||
* both readers of this channel agree on what identifies a task. */
|
||||
export function claudeTaskId(message: Record<string, unknown>): string | null {
|
||||
const value = message.task_id
|
||||
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
|
||||
}
|
||||
|
||||
/** A task's human label, collapsed and bounded. */
|
||||
export function claudeTaskDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim().replace(/\s+/g, ' ')
|
||||
return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_DESCRIPTION_LENGTH) : undefined
|
||||
}
|
||||
|
||||
export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind {
|
||||
switch (taskType) {
|
||||
case 'local_agent':
|
||||
return 'agent'
|
||||
case 'local_workflow':
|
||||
return 'workflow'
|
||||
case 'local_bash':
|
||||
return 'command'
|
||||
case 'monitor':
|
||||
return 'monitor'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeBackgroundTaskTracker {
|
||||
private readonly tasks = new Map<string, TrackedTask>()
|
||||
private readonly tasks = new Map<string, TrackedClaudeBackgroundTask>()
|
||||
private readonly retention = new ClaudeSettledBackgroundTasks()
|
||||
private readonly terminalTaskIds = new Set<string>()
|
||||
private aggregateRosterObserved = false
|
||||
private foregroundTurnActive = false
|
||||
private monitoring = false
|
||||
private publishedTasksFingerprint = ''
|
||||
|
||||
constructor(private readonly now: () => number = () => Date.now()) {}
|
||||
|
||||
get state(): AgentSessionBackgroundTaskState | null {
|
||||
if (!this.monitoring) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
state: 'monitoring',
|
||||
tasks: this.backgroundTaskDetails()
|
||||
tasks: this.backgroundTaskDetails(),
|
||||
...(this.retention.hasSettled ? { settledTasks: this.retention.settledDetails() } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,16 +63,14 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
|
||||
observe(message: Record<string, unknown>, startsTurn = false): boolean {
|
||||
if (startsTurn) {
|
||||
this.foregroundTurnActive = true
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
this.foregroundTurnActive = false
|
||||
} else if (message.type === 'system') {
|
||||
// Background work publishes through a foreground turn: the strip stays
|
||||
// honest mid-fan-out and the client alone decides when the idle-only
|
||||
// monitoring label may speak.
|
||||
if (message.type === 'system') {
|
||||
if (!this.observeSystemFrame(message) && !startsTurn) {
|
||||
return false
|
||||
}
|
||||
} else if (!startsTurn) {
|
||||
} else if (!startsTurn && message.type !== 'result') {
|
||||
return false
|
||||
}
|
||||
return this.refreshMonitoring()
|
||||
@@ -104,47 +78,51 @@ export class ClaudeBackgroundTaskTracker {
|
||||
|
||||
clear(): boolean {
|
||||
this.tasks.clear()
|
||||
this.retention.clear()
|
||||
this.terminalTaskIds.clear()
|
||||
this.aggregateRosterObserved = false
|
||||
this.foregroundTurnActive = false
|
||||
return this.refreshMonitoring()
|
||||
}
|
||||
|
||||
private settle(
|
||||
id: string,
|
||||
state: AgentSessionBackgroundTaskRunState,
|
||||
outcome: { totalTokens?: number } = {}
|
||||
): void {
|
||||
this.retention.settle(id, state, outcome, this.tasks.get(id))
|
||||
this.finish(id)
|
||||
}
|
||||
|
||||
private observeSystemFrame(message: Record<string, unknown>): boolean {
|
||||
if (message.subtype === 'background_tasks_changed') {
|
||||
this.replaceAggregateRoster(message.tasks)
|
||||
return true
|
||||
}
|
||||
const id = claudeTaskId(message)
|
||||
const id = taskId(message)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
if (message.subtype === 'task_notification') {
|
||||
this.finish(id)
|
||||
// The notification is affirmative terminal evidence even when its status
|
||||
// field is unreadable — matching the liveness semantics this edge always had.
|
||||
this.settle(id, terminalClaudeTaskRunState(message.status) ?? 'done', {
|
||||
totalTokens: taskUsageTotalTokens(message)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (message.subtype === 'task_progress') {
|
||||
// Progress `description` is the current activity ("Running <tool>"), not
|
||||
// the task's name — only usage (and a missing identity) may update.
|
||||
const existing = this.tasks.get(id)
|
||||
const totalTokens = taskUsageTotalTokens(message)
|
||||
if (!existing?.backgrounded || totalTokens === undefined) {
|
||||
return false
|
||||
}
|
||||
this.tasks.set(id, { ...existing, totalTokens, name: existing.name ?? taskName(message) })
|
||||
return true
|
||||
}
|
||||
if (message.subtype === 'task_updated') {
|
||||
const patch = record(message.patch)
|
||||
if (!patch) {
|
||||
return false
|
||||
}
|
||||
if (TERMINAL_TASK_STATES.has(String(patch.status))) {
|
||||
this.finish(id)
|
||||
return true
|
||||
}
|
||||
const existing = this.tasks.get(id)
|
||||
if (
|
||||
(patch.is_backgrounded === true || claudeTaskDescription(patch.description)) &&
|
||||
(!this.aggregateRosterObserved || existing)
|
||||
) {
|
||||
this.upsert(id, {
|
||||
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
|
||||
kind: existing?.kind ?? 'unknown',
|
||||
description: claudeTaskDescription(patch.description) ?? existing?.description
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return this.observeTaskUpdated(id, message)
|
||||
}
|
||||
if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) {
|
||||
return false
|
||||
@@ -160,15 +138,55 @@ export class ClaudeBackgroundTaskTracker {
|
||||
this.upsert(id, {
|
||||
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
|
||||
kind,
|
||||
description: claudeTaskDescription(message.description)
|
||||
description: taskDescription(message.description),
|
||||
name: taskName(message),
|
||||
state: liveClaudeTaskRunState(message.status) ?? undefined,
|
||||
startedAt: this.now()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private observeTaskUpdated(id: string, message: Record<string, unknown>): boolean {
|
||||
const patch = record(message.patch)
|
||||
if (!patch) {
|
||||
return false
|
||||
}
|
||||
const settledState = terminalClaudeTaskRunState(patch.status)
|
||||
if (settledState) {
|
||||
this.settle(id, settledState)
|
||||
return true
|
||||
}
|
||||
const existing = this.tasks.get(id)
|
||||
// Classification is re-derived per transition: a later frame that reveals a
|
||||
// real type moves the task between buckets instead of pinning first-seen.
|
||||
const patchKind =
|
||||
'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : undefined
|
||||
const liveState = liveClaudeTaskRunState(patch.status)
|
||||
const hasContent =
|
||||
patch.is_backgrounded === true ||
|
||||
taskDescription(patch.description) !== undefined ||
|
||||
taskName(patch) !== undefined ||
|
||||
liveState !== null ||
|
||||
(patchKind !== undefined && patchKind !== 'unknown')
|
||||
if (hasContent && (!this.aggregateRosterObserved || existing)) {
|
||||
this.upsert(id, {
|
||||
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
|
||||
kind: patchKind ?? existing?.kind ?? 'unknown',
|
||||
description: taskDescription(patch.description),
|
||||
name: taskName(patch),
|
||||
state: liveState ?? undefined,
|
||||
startedAt: this.now()
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private replaceAggregateRoster(value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
const prior = new Map(this.tasks)
|
||||
this.aggregateRosterObserved = true
|
||||
this.tasks.clear()
|
||||
this.terminalTaskIds.clear()
|
||||
@@ -180,29 +198,33 @@ export class ClaudeBackgroundTaskTracker {
|
||||
if (!task || task.ambient === true) {
|
||||
continue
|
||||
}
|
||||
const id = claudeTaskId(task)
|
||||
const id = taskId(task)
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
// An authoritative live roster supersedes an earlier terminal edge.
|
||||
const retained = this.retention.resume(id)
|
||||
const existing = prior.get(id) ?? retained
|
||||
const kind = classifyClaudeBackgroundTaskKind(task.task_type)
|
||||
this.tasks.set(id, {
|
||||
backgrounded: true,
|
||||
kind: classifyClaudeBackgroundTaskKind(task.task_type),
|
||||
description: claudeTaskDescription(task.description)
|
||||
kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'),
|
||||
description: taskDescription(task.description) ?? existing?.description,
|
||||
name: taskName(task) ?? existing?.name,
|
||||
state: liveClaudeTaskRunState(task.status) ?? existing?.state,
|
||||
startedAt: existing?.startedAt ?? this.now(),
|
||||
totalTokens: existing?.totalTokens
|
||||
})
|
||||
}
|
||||
for (const [id, task] of prior) {
|
||||
if (task.backgrounded && !this.tasks.has(id)) {
|
||||
this.retention.rememberRemoved(id, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private upsert(id: string, task: TrackedTask): void {
|
||||
const existing = this.tasks.get(id)
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
kind: existing.kind === 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
private upsert(id: string, task: TrackedClaudeBackgroundTask): void {
|
||||
if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
let foregroundId: string | undefined
|
||||
for (const [candidateId, candidate] of this.tasks) {
|
||||
if (!candidate.backgrounded) {
|
||||
@@ -215,6 +237,20 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
this.tasks.delete(foregroundId)
|
||||
}
|
||||
const existing = this.tasks.get(id) ?? this.retention.resume(id)
|
||||
this.terminalTaskIds.delete(id)
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description,
|
||||
name: task.name ?? existing.name,
|
||||
state: task.state ?? existing.state,
|
||||
startedAt: existing.startedAt,
|
||||
totalTokens: existing.totalTokens
|
||||
})
|
||||
return
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
}
|
||||
|
||||
@@ -231,9 +267,12 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
|
||||
private refreshMonitoring(): boolean {
|
||||
const details = this.foregroundTurnActive ? [] : this.backgroundTaskDetails()
|
||||
const details = this.backgroundTaskDetails()
|
||||
if (details.length === 0 && this.retention.hasSettled) {
|
||||
this.retention.flushSettled()
|
||||
}
|
||||
const next = details.length > 0
|
||||
const fingerprint = next ? JSON.stringify(details) : ''
|
||||
const fingerprint = next ? JSON.stringify([details, this.retention.settledDetails()]) : ''
|
||||
if (next === this.monitoring && fingerprint === this.publishedTasksFingerprint) {
|
||||
return false
|
||||
}
|
||||
@@ -248,11 +287,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
if (!task.backgrounded) {
|
||||
continue
|
||||
}
|
||||
details.push({
|
||||
id,
|
||||
kind: task.kind,
|
||||
...(task.description ? { description: task.description } : {})
|
||||
})
|
||||
details.push(claudeBackgroundTaskDetail(id, task))
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Retention state for background tasks that have reached a terminal edge.
|
||||
//
|
||||
// The real producer settles a task in two steps inside one tick:
|
||||
// `background_tasks_changed` arrives FIRST with the task already absent, then
|
||||
// `task_updated` / `task_notification` carry the outcome. So the terminal edge
|
||||
// must be able to settle a task the live roster no longer holds — that is what
|
||||
// `rememberRemoved` preserves. A removal whose outcome frame never arrives
|
||||
// simply vanishes: removed tasks are never rendered and never guessed into a
|
||||
// finished state.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState
|
||||
} from '../../shared/agent-session-wire'
|
||||
|
||||
const MAX_RETAINED_TASKS = 256
|
||||
|
||||
export type TrackedClaudeBackgroundTask = {
|
||||
backgrounded: boolean
|
||||
kind: AgentSessionBackgroundTask['kind']
|
||||
description?: string
|
||||
name?: string
|
||||
state?: AgentSessionBackgroundTaskRunState
|
||||
/** First-observed epoch ms; preserved across updates and roster replacement
|
||||
* so clients can render elapsed and keep a stable first-seen sort. */
|
||||
startedAt: number
|
||||
totalTokens?: number
|
||||
}
|
||||
|
||||
export function claudeBackgroundTaskDetail(
|
||||
id: string,
|
||||
task: TrackedClaudeBackgroundTask
|
||||
): AgentSessionBackgroundTask {
|
||||
return {
|
||||
id,
|
||||
kind: task.kind,
|
||||
...(task.description ? { description: task.description } : {}),
|
||||
...(task.name ? { name: task.name } : {}),
|
||||
state: task.state ?? (task.kind === 'monitor' ? 'monitoring' : 'working'),
|
||||
startedAt: task.startedAt,
|
||||
...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
|
||||
map.delete(key)
|
||||
map.set(key, value)
|
||||
if (map.size > MAX_RETAINED_TASKS) {
|
||||
const oldest = map.keys().next()
|
||||
if (!oldest.done) {
|
||||
map.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeSettledBackgroundTasks {
|
||||
private readonly settled = new Map<string, AgentSessionBackgroundTask>()
|
||||
private readonly recentlyRemoved = new Map<string, TrackedClaudeBackgroundTask>()
|
||||
|
||||
/** An aggregate roster evicted a still-live backgrounded task; hold its
|
||||
* details so the outcome frame trailing in the same tick can settle it. */
|
||||
rememberRemoved(id: string, task: TrackedClaudeBackgroundTask): void {
|
||||
setBounded(this.recentlyRemoved, id, task)
|
||||
}
|
||||
|
||||
/** Terminal edge for `id`. `liveSource` is the live roster's entry when it
|
||||
* still has one; otherwise the recently-removed copy is consumed. A second
|
||||
* edge (updated, then notification) re-derives the settled state and can
|
||||
* add the final usage the first edge lacked. */
|
||||
settle(
|
||||
id: string,
|
||||
state: AgentSessionBackgroundTaskRunState,
|
||||
outcome: { totalTokens?: number },
|
||||
liveSource: TrackedClaudeBackgroundTask | undefined
|
||||
): void {
|
||||
const source = liveSource ?? this.recentlyRemoved.get(id)
|
||||
const already = this.settled.get(id)
|
||||
if (source?.backgrounded) {
|
||||
setBounded(this.settled, id, {
|
||||
...claudeBackgroundTaskDetail(id, {
|
||||
...source,
|
||||
totalTokens: outcome.totalTokens ?? source.totalTokens
|
||||
}),
|
||||
state
|
||||
})
|
||||
} else if (already) {
|
||||
this.settled.set(id, {
|
||||
...already,
|
||||
state,
|
||||
...(outcome.totalTokens !== undefined ? { totalTokens: outcome.totalTokens } : {})
|
||||
})
|
||||
}
|
||||
this.recentlyRemoved.delete(id)
|
||||
}
|
||||
|
||||
/** Positive live evidence transfers identity back to the tracker, never the old outcome. */
|
||||
resume(id: string): TrackedClaudeBackgroundTask | undefined {
|
||||
const settled = this.settled.get(id)
|
||||
const removed = this.recentlyRemoved.get(id)
|
||||
this.settled.delete(id)
|
||||
this.recentlyRemoved.delete(id)
|
||||
const source = settled ?? removed
|
||||
if (!source || source.startedAt === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return { ...source, backgrounded: true, state: undefined, startedAt: source.startedAt }
|
||||
}
|
||||
|
||||
get hasSettled(): boolean {
|
||||
return this.settled.size > 0
|
||||
}
|
||||
|
||||
settledDetails(): AgentSessionBackgroundTask[] {
|
||||
return [...this.settled.values()]
|
||||
}
|
||||
|
||||
/** Settled context only makes sense beside live work; the strip exits at the
|
||||
* same instant it always has — when the last live task ends. */
|
||||
flushSettled(): void {
|
||||
this.settled.clear()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.settled.clear()
|
||||
this.recentlyRemoved.clear()
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,9 @@ describe('Claude published session close lifecycle', () => {
|
||||
expect(backgroundStates).toEqual([
|
||||
{
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'background-1', kind: 'agent' }],
|
||||
tasks: [
|
||||
{ id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) }
|
||||
],
|
||||
supportsTaskStop: true
|
||||
}
|
||||
])
|
||||
@@ -74,7 +76,9 @@ describe('Claude published session close lifecycle', () => {
|
||||
expect(backgroundStates).toEqual([
|
||||
{
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'background-1', kind: 'agent' }],
|
||||
tasks: [
|
||||
{ id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) }
|
||||
],
|
||||
supportsTaskStop: true
|
||||
},
|
||||
null
|
||||
|
||||
+5
-1
@@ -21,7 +21,10 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession,
|
||||
private readonly handoffStatus: (
|
||||
sessionId: string
|
||||
) => Parameters<AgentSessionSubscribers['open']>[0]['handoff']
|
||||
) => Parameters<AgentSessionSubscribers['open']>[0]['handoff'],
|
||||
/** Task edges change the status summary too; the feed's equality check
|
||||
* keeps a no-op re-projection from reaching subscribers. */
|
||||
private readonly onPublished: (sessionId: string) => void
|
||||
) {}
|
||||
|
||||
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
|
||||
@@ -53,6 +56,7 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
const state = publishedState !== undefined ? publishedState : this.state(sessionId)
|
||||
if (session && state !== undefined) {
|
||||
this.subscribers.backgroundTasks(sessionId, state, session.fence)
|
||||
this.onPublished(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ import type {
|
||||
StructuredAgentSessionHostSession,
|
||||
StructuredAgentSessionReveal
|
||||
} from './structured-agent-session-host-types'
|
||||
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
|
||||
import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-status-feed'
|
||||
import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed'
|
||||
import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery'
|
||||
import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel'
|
||||
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
@@ -64,11 +65,10 @@ export class StructuredAgentSessionHost {
|
||||
this
|
||||
)
|
||||
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
|
||||
private readonly statusFeed = new StructuredAgentSessionStatusFeed({
|
||||
private readonly statusFeed = createStructuredAgentSessionHostStatusFeed({
|
||||
sessions: this.sessions,
|
||||
getRecord: (sessionId) => this.deps.store.getRecord(sessionId),
|
||||
now: () => this.now(),
|
||||
onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options)
|
||||
deps: () => this.deps
|
||||
})
|
||||
private readonly subscribers = new AgentSessionSubscribers({
|
||||
readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId),
|
||||
@@ -91,7 +91,8 @@ export class StructuredAgentSessionHost {
|
||||
this.sessions,
|
||||
this.subscribers,
|
||||
(sessionId) => this.requireSession(sessionId),
|
||||
(sessionId) => this.handoffs.status(sessionId)
|
||||
(sessionId) => this.handoffs.status(sessionId),
|
||||
(sessionId) => this.statusFeed.publish(sessionId)
|
||||
)
|
||||
this.runtimeState = new StructuredAgentSessionHostRuntimeState(
|
||||
deps,
|
||||
@@ -345,7 +346,7 @@ export class StructuredAgentSessionHost {
|
||||
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
|
||||
|
||||
/** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */
|
||||
subscribeStatus: StructuredAgentSessionStatusFeed['subscribe'] = (subscriber) =>
|
||||
subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) =>
|
||||
this.statusFeed.subscribe(subscriber)
|
||||
|
||||
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
|
||||
|
||||
+149
-3
@@ -1,9 +1,12 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionStatusEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation'
|
||||
import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
@@ -74,11 +77,13 @@ function feedFor(
|
||||
{ journal: Awaited<ReturnType<typeof openJournal>>; hasProviderChild?: boolean; fence?: number }
|
||||
>,
|
||||
record: Partial<AgentSessionRecord> | null = null,
|
||||
onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
|
||||
onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'],
|
||||
readBackgroundTasks?: StructuredAgentSessionStatusFeedDeps['readBackgroundTasks']
|
||||
) {
|
||||
let now = 1_000
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
...(onStatusChanged ? { onStatusChanged } : {}),
|
||||
...(readBackgroundTasks ? { readBackgroundTasks } : {}),
|
||||
sessions: {
|
||||
get: (sessionId: string) => {
|
||||
const session = sessions.get(sessionId)
|
||||
@@ -621,6 +626,147 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the journal projection across task progress and invalidates on journal changes', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
await journal.appendItem(
|
||||
TURN_IDENTITY,
|
||||
{ kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
|
||||
{ fence: 1 }
|
||||
)
|
||||
const snapshot = vi.spyOn(journal, 'snapshot')
|
||||
let taskState: 'working' | 'waiting' = 'working'
|
||||
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'child', kind: 'agent', state: taskState }]
|
||||
}))
|
||||
for (let tick = 1; tick <= 100; tick++) {
|
||||
taskState = tick % 2 === 1 ? 'waiting' : 'working'
|
||||
feed.publish(SESSION)
|
||||
}
|
||||
expect(events).toHaveLength(101)
|
||||
expect(snapshot).toHaveBeenCalledTimes(1)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'status',
|
||||
session: { status: 'working', backgroundTasks: [{ state: 'working' }] }
|
||||
})
|
||||
await journal.appendTombstone(TURN_IDENTITY, { fence: 1 })
|
||||
feed.publish(SESSION)
|
||||
expect(snapshot).toHaveBeenCalledTimes(2)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } })
|
||||
})
|
||||
|
||||
it('invalidates cached status on unreadability and keeps record metadata live', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
const record = { options: { model: 'first-model' }, providerHandleChain: [] }
|
||||
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record)
|
||||
record.options.model = 'second-model'
|
||||
feed.publish(SESSION)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'status',
|
||||
session: { status: 'idle', model: 'second-model' }
|
||||
})
|
||||
const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true)
|
||||
feed.publish(SESSION)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } })
|
||||
readOnly.mockRestore()
|
||||
feed.publish(SESSION)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } })
|
||||
})
|
||||
|
||||
it('projects live background tasks and republishes a task-only state change', async () => {
|
||||
const journal = await openJournal()
|
||||
let tasks = [
|
||||
{ id: 'task-1', kind: 'agent' as const, name: 'deep_review', state: 'working' as const }
|
||||
]
|
||||
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({
|
||||
state: 'monitoring',
|
||||
tasks
|
||||
}))
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({
|
||||
backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }]
|
||||
})
|
||||
})
|
||||
|
||||
// No journal change: only the task state moved.
|
||||
tasks = [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting' as never }]
|
||||
const before = events.length
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events).toHaveLength(before + 1)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({
|
||||
backgroundTasks: [expect.objectContaining({ state: 'waiting' })]
|
||||
})
|
||||
})
|
||||
|
||||
// An identical projection is suppressed.
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events).toHaveLength(before + 1)
|
||||
})
|
||||
|
||||
it('omits task usage so a progress tick never re-broadcasts the summary', async () => {
|
||||
const journal = await openJournal()
|
||||
let tasks: AgentSessionBackgroundTask[] = [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 10 }
|
||||
]
|
||||
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({
|
||||
state: 'monitoring',
|
||||
tasks
|
||||
}))
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
feed.publish(SESSION, journal)
|
||||
const before = events.length
|
||||
|
||||
// A `task_progress` frame moves only usage, which no status-summary reader renders;
|
||||
// re-broadcasting the whole summary per frame would cost every remote subscriber.
|
||||
tasks = [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 4_200 }
|
||||
]
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events).toHaveLength(before)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({
|
||||
backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }]
|
||||
})
|
||||
})
|
||||
|
||||
// A state change on the same task still reaches subscribers.
|
||||
tasks = [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', totalTokens: 4_200 }
|
||||
]
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events).toHaveLength(before + 1)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({
|
||||
backgroundTasks: [expect.objectContaining({ state: 'waiting' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,11 @@ import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization'
|
||||
import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionStatusSummary
|
||||
import {
|
||||
agentSessionBackgroundTasksEqual,
|
||||
type AgentSessionBackgroundTaskState,
|
||||
type AgentSessionStatusEvent,
|
||||
type AgentSessionStatusSummary
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
@@ -41,6 +43,9 @@ export type StructuredAgentSessionStatusFeedDeps = {
|
||||
/** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection
|
||||
* of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */
|
||||
onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void
|
||||
/** Live provider-owned background tasks for the summary, so session lists can
|
||||
* render subagent children. Optional: a provider without the hook projects none. */
|
||||
readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined
|
||||
}
|
||||
|
||||
function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean {
|
||||
@@ -57,13 +62,50 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
|
||||
a.toolName === b.toolName &&
|
||||
a.toolInput === b.toolInput &&
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) &&
|
||||
agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession)
|
||||
)
|
||||
}
|
||||
|
||||
/** Wire the host's own deps into a feed; keeps the host at one call site.
|
||||
* `deps` is a thunk because the host builds the feed in a field initializer,
|
||||
* before its constructor parameters are assigned. */
|
||||
export function createStructuredAgentSessionHostStatusFeed(args: {
|
||||
sessions: StructuredAgentSessionStatusFeedDeps['sessions']
|
||||
now: () => number
|
||||
deps: () => {
|
||||
store: { getRecord: (sessionId: string) => AgentSessionRecord | null }
|
||||
adapter: {
|
||||
backgroundTaskState?: (
|
||||
sessionId: string
|
||||
) => AgentSessionBackgroundTaskState | null | undefined
|
||||
}
|
||||
onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
|
||||
}
|
||||
}): StructuredAgentSessionStatusFeed {
|
||||
return new StructuredAgentSessionStatusFeed({
|
||||
sessions: args.sessions,
|
||||
getRecord: (sessionId) => args.deps().store.getRecord(sessionId),
|
||||
now: args.now,
|
||||
onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options),
|
||||
readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
export class StructuredAgentSessionStatusFeed {
|
||||
private readonly subscribers = new Map<string, StructuredAgentSessionStatusSubscriber>()
|
||||
private readonly published = new Map<string, AgentSessionStatusSummary>()
|
||||
// Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup.
|
||||
private readonly journalProjections = new WeakMap<
|
||||
AgentSessionJournal,
|
||||
{
|
||||
epoch: string
|
||||
sequence: number
|
||||
readOnly: boolean
|
||||
fence: number | undefined
|
||||
summary: ReturnType<typeof projectStructuredAgentSessionStatusSummary>
|
||||
}
|
||||
>()
|
||||
|
||||
constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {}
|
||||
|
||||
@@ -154,24 +196,54 @@ export class StructuredAgentSessionStatusFeed {
|
||||
journal: AgentSessionJournal
|
||||
): AgentSessionStatusSummary {
|
||||
// An unreadable journal projects as "no turn": the chat itself shows the reset.
|
||||
const snapshot = journal.isReadOnly ? null : journal.snapshot()
|
||||
const items = snapshot?.items ?? []
|
||||
const submissions = snapshot?.submissions ?? []
|
||||
const cursor = journal.cursor()
|
||||
const readOnly = journal.isReadOnly
|
||||
const fence = session.fence
|
||||
let projection = this.journalProjections.get(journal)
|
||||
if (
|
||||
!projection ||
|
||||
projection.epoch !== cursor.epoch ||
|
||||
projection.sequence !== cursor.sequence ||
|
||||
projection.readOnly !== readOnly ||
|
||||
projection.fence !== fence
|
||||
) {
|
||||
// A journalled submission bumps `lastSequence`, so the send-time working
|
||||
// signal reaches the cache; the lease fence does not, hence the extra key.
|
||||
const snapshot = readOnly ? null : journal.snapshot()
|
||||
projection = {
|
||||
...cursor,
|
||||
readOnly,
|
||||
fence,
|
||||
summary: projectStructuredAgentSessionStatusSummary(
|
||||
snapshot?.items ?? [],
|
||||
snapshot?.submissions ?? [],
|
||||
fence
|
||||
)
|
||||
}
|
||||
this.journalProjections.set(journal, projection)
|
||||
}
|
||||
const record = this.deps.getRecord(sessionId)
|
||||
const providerSession = structuredAgentSessionProviderSessionMetadata(record)
|
||||
// The journal has no model: the record's acknowledged options are where an owner
|
||||
// handoff or a mid-session switch lands, so the row follows whichever is in force.
|
||||
const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH)
|
||||
// Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the
|
||||
// equality check and re-broadcast a full summary to every remote subscriber for a
|
||||
// number no session list renders. Tokens stay live on the background-task channel.
|
||||
const backgroundTasks = this.deps
|
||||
.readBackgroundTasks?.(sessionId)
|
||||
?.tasks?.map(({ totalTokens: _totalTokens, ...task }) => task)
|
||||
return {
|
||||
sessionId,
|
||||
workspaceId: session.params.location.workspaceId,
|
||||
agent: session.params.provider,
|
||||
...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}),
|
||||
...projectStructuredAgentSessionStatusSummary(items, submissions, session.fence),
|
||||
...projection.summary,
|
||||
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
|
||||
? { rewindBlockedReason: 'outcome-unknown' as const }
|
||||
: {}),
|
||||
...(model ? { model } : {}),
|
||||
...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}),
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
updatedAt: journal.lastActivityAt() || this.deps.now()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createLocalFileSink,
|
||||
DROPPED_RECORD_TYPE,
|
||||
type LocalFileSink
|
||||
} from './local-file-sink'
|
||||
import { createLocalFileSink, DROPPED_RECORD_TYPE, type LocalFileSink } from './local-file-sink'
|
||||
|
||||
function parseLine(raw: string): Record<string, unknown> {
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
|
||||
@@ -53,6 +53,8 @@ function journalWith(prompt: string): AgentSessionJournal {
|
||||
return {
|
||||
isReadOnly: false,
|
||||
lastActivityAt: () => OBSERVED_AT,
|
||||
// This journal never changes, so a real one would hold its cursor steady.
|
||||
cursor: () => ({ epoch: 1, sequence: 1 }),
|
||||
snapshot: () => ({ items: runningTurn(prompt) })
|
||||
} as unknown as AgentSessionJournal
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed {
|
||||
{
|
||||
journal: {
|
||||
isReadOnly: false,
|
||||
cursor: () => ({ epoch: 'epoch-status', sequence: 2 }),
|
||||
lastActivityAt: () => 2,
|
||||
snapshot: () => ({ items: STATUS_ITEMS })
|
||||
} as unknown as AgentSessionJournal,
|
||||
|
||||
@@ -13,6 +13,7 @@ function autocomplete(
|
||||
query: '',
|
||||
triggerKey: '/:0',
|
||||
prefix: '/',
|
||||
dispatchable: true,
|
||||
grouped: true,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: true,
|
||||
@@ -21,6 +22,7 @@ function autocomplete(
|
||||
kind: 'command',
|
||||
id: 'command:clear',
|
||||
name: 'clear',
|
||||
token: '/clear',
|
||||
description: 'Clear history',
|
||||
skillCollision: false
|
||||
},
|
||||
@@ -28,6 +30,7 @@ function autocomplete(
|
||||
kind: 'skill',
|
||||
id: 'skill:browser',
|
||||
name: 'browser',
|
||||
token: '/browser',
|
||||
description: 'Use a browser',
|
||||
sources: [{ sourceKind: 'repo', skillFilePath: '/repo/browser/SKILL.md' }]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
onChoose,
|
||||
onRetry
|
||||
}: {
|
||||
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' | 'skill' }>
|
||||
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' }>
|
||||
activeIndex: number
|
||||
listboxId: string
|
||||
onChoose: (item: NativeChatPickerItem) => void
|
||||
@@ -54,7 +54,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
<PickerOption
|
||||
key={item.id}
|
||||
item={item}
|
||||
prefix={autocomplete.prefix}
|
||||
index={index}
|
||||
activeIndex={activeIndex}
|
||||
listboxId={listboxId}
|
||||
@@ -102,7 +101,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
<PickerOption
|
||||
key={item.id}
|
||||
item={item}
|
||||
prefix={autocomplete.prefix}
|
||||
index={index}
|
||||
activeIndex={activeIndex}
|
||||
listboxId={listboxId}
|
||||
@@ -140,9 +138,9 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
|
||||
})
|
||||
|
||||
function getPickerEmptyText(
|
||||
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' | 'skill' }>
|
||||
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' }>
|
||||
): string {
|
||||
if (autocomplete.mode === 'skill' || !autocomplete.commandsEnabled) {
|
||||
if (!autocomplete.commandsEnabled) {
|
||||
return translate('components.native-chat.composer.noSkills', 'No matching skills')
|
||||
}
|
||||
if (autocomplete.skillsEnabled) {
|
||||
@@ -174,7 +172,6 @@ function PickerStatus({ children }: { children: React.ReactNode }): React.JSX.El
|
||||
|
||||
function PickerOption({
|
||||
item,
|
||||
prefix,
|
||||
index,
|
||||
activeIndex,
|
||||
listboxId,
|
||||
@@ -182,7 +179,6 @@ function PickerOption({
|
||||
onChoose
|
||||
}: {
|
||||
item: NativeChatPickerItem
|
||||
prefix: '/' | '$'
|
||||
index: number
|
||||
activeIndex: number
|
||||
listboxId: string
|
||||
@@ -213,7 +209,7 @@ function PickerOption({
|
||||
<Package className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono font-medium">{prefix + item.name}</span>
|
||||
<span className="block truncate font-mono font-medium">{item.token}</span>
|
||||
{item.description ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
) : null}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { Profiler } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
|
||||
import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const TASKS: AgentSessionBackgroundTask[] = [
|
||||
{ id: 'codex-agent:child-1', kind: 'agent', description: 'count_a' },
|
||||
@@ -20,7 +24,10 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea
|
||||
const onStop = vi.fn()
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible
|
||||
tasks={TASKS}
|
||||
settledTasks={[]}
|
||||
indicatorActive
|
||||
supportsTaskStop={props.supportsTaskStop}
|
||||
supportsStopAll={props.supportsStopAll}
|
||||
stoppingTaskIds={new Set()}
|
||||
@@ -53,3 +60,231 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => {
|
||||
expect(screen.getByText('sleep 90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-tasks strip header', () => {
|
||||
function renderHeader(tasks: AgentSessionBackgroundTask[]): HTMLElement {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible
|
||||
tasks={tasks}
|
||||
settledTasks={[]}
|
||||
indicatorActive
|
||||
supportsTaskStop={false}
|
||||
supportsStopAll={false}
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
)
|
||||
return screen.getByRole('button', { expanded: false })
|
||||
}
|
||||
|
||||
it('leads each kind segment with that kind icon and keeps the counts in the accessible name', () => {
|
||||
const header = renderHeader([
|
||||
{ id: 'a1', kind: 'agent' },
|
||||
{ id: 'a2', kind: 'agent' },
|
||||
{ id: 'a3', kind: 'agent' },
|
||||
{ id: 'm1', kind: 'monitor' }
|
||||
])
|
||||
expect(header).toHaveAttribute('aria-label', '3 agents · 1 monitor')
|
||||
expect(header.querySelector('.lucide-bot')).toBeInTheDocument()
|
||||
// Heartbeat, the same glyph the agent sidebar shows for monitoring.
|
||||
expect(header.querySelector('.lucide-activity')).toBeInTheDocument()
|
||||
// Two kind icons and the chevron: the aggregate state dot is gone.
|
||||
expect(header.querySelectorAll('svg')).toHaveLength(3)
|
||||
for (const icon of header.querySelectorAll('svg')) {
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the monitor heartbeat the sidebar amber and leaves other kinds neutral', () => {
|
||||
const header = renderHeader([
|
||||
{ id: 'a1', kind: 'agent' },
|
||||
{ id: 'm1', kind: 'monitor' }
|
||||
])
|
||||
// Same glyph AND same colour as AgentStateDot/StatusIndicator, or a monitor
|
||||
// here does not read as the monitor there.
|
||||
expect(header.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500')
|
||||
expect(header.querySelector('.lucide-bot')?.classList).toContain('text-muted-foreground')
|
||||
expect(header.querySelector('.lucide-bot')?.classList).not.toContain('text-yellow-500')
|
||||
})
|
||||
|
||||
it('dims the monitor amber while a turn owns the voice', () => {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible
|
||||
tasks={[{ id: 'm1', kind: 'monitor' }]}
|
||||
settledTasks={[]}
|
||||
indicatorActive={false}
|
||||
supportsTaskStop={false}
|
||||
supportsStopAll={false}
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
)
|
||||
const header = screen.getByRole('button', { expanded: false })
|
||||
expect(header.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500/40')
|
||||
})
|
||||
|
||||
it('carries the monitor amber on the expanded row too', () => {
|
||||
const header = renderHeader([
|
||||
{ id: 'm1', kind: 'monitor', description: 'watcher' },
|
||||
{ id: 'c1', kind: 'command', description: 'sleep 90' }
|
||||
])
|
||||
fireEvent.click(header)
|
||||
// Each kind group is its own labelled list, so scope to the monitor one.
|
||||
const monitors = screen.getByRole('list', { name: 'Monitors' })
|
||||
expect(monitors.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500')
|
||||
const shell = screen.getByRole('list', { name: 'Shell' })
|
||||
expect(shell.querySelector('.lucide-square-terminal')?.classList).toContain(
|
||||
'text-muted-foreground'
|
||||
)
|
||||
})
|
||||
|
||||
it('draws the segment separator in a visible text tone, not the divider token', () => {
|
||||
const header = renderHeader([
|
||||
{ id: 'a1', kind: 'agent' },
|
||||
{ id: 'c1', kind: 'command' }
|
||||
])
|
||||
const separators = [...header.querySelectorAll('span')].filter(
|
||||
(element) => element.textContent === ' · '
|
||||
)
|
||||
expect(separators).toHaveLength(1)
|
||||
// `--border` is a divider line (7% white in dark), an order of magnitude
|
||||
// fainter than the counts it sits between.
|
||||
expect(separators[0].classList).not.toContain('text-border')
|
||||
expect(separators[0].classList).toContain('text-muted-foreground')
|
||||
// One space either side; the icon's own margin is the icon-to-label gap.
|
||||
expect(header.textContent).toBe('1 agent · 1 shell')
|
||||
})
|
||||
|
||||
it('carries no icon on a collapsed total, which spans kinds', () => {
|
||||
const header = renderHeader([
|
||||
{ id: 'a1', kind: 'agent' },
|
||||
{ id: 'c1', kind: 'command' },
|
||||
{ id: 'm1', kind: 'monitor' },
|
||||
{ id: 'w1', kind: 'workflow' }
|
||||
])
|
||||
expect(header).toHaveAttribute('aria-label', '4 background tasks')
|
||||
expect(header.querySelectorAll('svg')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settled rows beside their live siblings', () => {
|
||||
// Retention is the PR's headline: a finished child stays visible, keeps the
|
||||
// usage it ended on, and stops claiming a clock or a stop control.
|
||||
it('keeps a settled row with its final usage, no clock and no stop', () => {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible
|
||||
tasks={[
|
||||
{
|
||||
id: 'agent-live',
|
||||
kind: 'agent',
|
||||
description: 'live child',
|
||||
startedAt: 1_000,
|
||||
totalTokens: 4_100
|
||||
}
|
||||
]}
|
||||
settledTasks={[
|
||||
{
|
||||
id: 'agent-settled',
|
||||
kind: 'agent',
|
||||
description: 'settled child',
|
||||
state: 'done',
|
||||
startedAt: 500,
|
||||
totalTokens: 18_130
|
||||
}
|
||||
]}
|
||||
indicatorActive
|
||||
supportsTaskStop
|
||||
supportsStopAll
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const agents = screen.getByRole('list', { name: 'Agents' })
|
||||
const rows = within(agents).getAllByRole('listitem')
|
||||
expect(rows).toHaveLength(2)
|
||||
// First seen first: the settled sibling started earlier.
|
||||
expect(rows[0].textContent).toBe('settled child18.1k')
|
||||
expect(rows[1].textContent).toMatch(/^live child4\.1k · .+Stop$/)
|
||||
expect(within(rows[1]).getByRole('button', { name: 'Stop live child' })).toBeInTheDocument()
|
||||
expect(within(rows[0]).queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-task row reasons', () => {
|
||||
function expandedRows(tasks: AgentSessionBackgroundTask[]): HTMLElement[] {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible
|
||||
tasks={tasks}
|
||||
settledTasks={[]}
|
||||
indicatorActive
|
||||
supportsTaskStop={false}
|
||||
supportsStopAll={false}
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
return screen.getAllByRole('listitem')
|
||||
}
|
||||
|
||||
// `unverifiable` is the SSH verdict for "no contact"; a row that hides it reads
|
||||
// like a working child. `blocked` is the same class of loss.
|
||||
it('names the reason on every attention state, not only on waiting', () => {
|
||||
const rows = expandedRows([
|
||||
{ id: 'a1', kind: 'agent', description: 'ssh child', state: 'unverifiable' },
|
||||
{ id: 'a2', kind: 'agent', description: 'flaky child', state: 'blocked' },
|
||||
{ id: 'a3', kind: 'agent', description: 'approval child', state: 'waiting' },
|
||||
{ id: 'a4', kind: 'agent', description: 'busy child', state: 'working' }
|
||||
])
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows[0].textContent).toContain('ssh child · no contact')
|
||||
expect(rows[1].textContent).toContain('flaky child · failed')
|
||||
expect(rows[2].textContent).toContain('approval child · needs approval')
|
||||
// A running row has nothing to explain.
|
||||
expect(rows[3].textContent).not.toContain('·')
|
||||
})
|
||||
})
|
||||
|
||||
it('stops elapsed renders in a hidden pane and catches up on reveal', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(100_000)
|
||||
const committed = vi.fn()
|
||||
const view = (isVisible: boolean) => (
|
||||
<Profiler id="strip" onRender={committed}>
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible={isVisible}
|
||||
tasks={[{ id: 'shell', kind: 'command', startedAt: 1_000 }]}
|
||||
settledTasks={[]}
|
||||
indicatorActive
|
||||
supportsTaskStop={false}
|
||||
supportsStopAll={false}
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</Profiler>
|
||||
)
|
||||
const { rerender, unmount } = render(view(true))
|
||||
committed.mockClear()
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(committed).toHaveBeenCalled()
|
||||
rerender(view(false))
|
||||
committed.mockClear()
|
||||
act(() => vi.advanceTimersByTime(10_000))
|
||||
expect(committed).not.toHaveBeenCalled()
|
||||
rerender(view(true))
|
||||
committed.mockClear()
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(committed).toHaveBeenCalled()
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -1,62 +1,207 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react'
|
||||
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
|
||||
import { AgentStateDot } from '@/components/AgentStateDot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { backgroundTasksHeaderContent } from './background-task-header-content'
|
||||
import {
|
||||
backgroundTaskElapsedLabel,
|
||||
backgroundTaskGroupLabel,
|
||||
backgroundTaskStateReason,
|
||||
buildBackgroundTaskGroups,
|
||||
formatBackgroundTaskTokens,
|
||||
type BackgroundRosterTask
|
||||
} from './background-task-roster'
|
||||
|
||||
function backgroundTaskLabel(task: AgentSessionBackgroundTask): string {
|
||||
if (task.description) {
|
||||
return task.description
|
||||
}
|
||||
switch (task.kind) {
|
||||
case 'agent':
|
||||
return translate('components.native-chat.backgroundTasks.agent', 'Background agent')
|
||||
case 'workflow':
|
||||
return translate('components.native-chat.backgroundTasks.workflow', 'Background workflow')
|
||||
case 'command':
|
||||
return translate('components.native-chat.backgroundTasks.command', 'Background command')
|
||||
case 'monitor':
|
||||
return translate('components.native-chat.backgroundTasks.monitor', 'Background monitor')
|
||||
case 'unknown':
|
||||
return translate('components.native-chat.backgroundTasks.task', 'Background task')
|
||||
}
|
||||
/** Below this strip width (border-box, live root font size) the header drops
|
||||
* its per-kind breakdown for an honest total. A narrow split pane on a wide
|
||||
* monitor must behave like a narrow window, so no viewport media query. */
|
||||
const NARROW_STRIP_REM = 24
|
||||
|
||||
function rootFontSizePx(): number {
|
||||
const parsed = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 16
|
||||
}
|
||||
|
||||
/** Observe the strip's own border-box width; the viewport is only the
|
||||
* pre-measurement stand-in before the first observer callback. */
|
||||
function useNarrowStrip(ref: React.RefObject<HTMLDivElement | null>): boolean {
|
||||
const [narrow, setNarrow] = useState(() => window.innerWidth < NARROW_STRIP_REM * 16)
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver((observerEntries) => {
|
||||
const width =
|
||||
observerEntries[0]?.borderBoxSize?.[0]?.inlineSize ?? element.getBoundingClientRect().width
|
||||
setNarrow(width < NARROW_STRIP_REM * rootFontSizePx())
|
||||
})
|
||||
observer.observe(element, { box: 'border-box' })
|
||||
return () => observer.disconnect()
|
||||
}, [ref])
|
||||
return narrow
|
||||
}
|
||||
|
||||
const KIND_ICONS = {
|
||||
agent: Bot,
|
||||
command: SquareTerminal,
|
||||
monitor: Activity,
|
||||
workflow: Workflow,
|
||||
unknown: CircleHelp
|
||||
} as const
|
||||
|
||||
/** Monitoring is a STATE the app colours the same on every surface — the agent
|
||||
* sidebar and `AgentStateDot` both draw an amber heartbeat — so the strip must
|
||||
* match it or the two stop reading as the same thing. The other four are plain
|
||||
* kind markers and stay neutral. `dimmed` is the running-turn treatment. */
|
||||
function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean): string {
|
||||
const tone = kind === 'monitor' ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
return dimmed ? `${tone}/40` : tone
|
||||
}
|
||||
|
||||
function BackgroundTaskRow(props: {
|
||||
entry: BackgroundRosterTask
|
||||
now: number
|
||||
supportsTaskStop: boolean
|
||||
stopping: boolean
|
||||
onStop: (taskId: string) => void
|
||||
}): React.JSX.Element {
|
||||
const { entry, now } = props
|
||||
const Icon = KIND_ICONS[entry.task.kind]
|
||||
// Every attention state states its reason on the row, the same ones the collapsed
|
||||
// header names; `unverifiable` ("no contact") must never be silently dropped.
|
||||
const reason = backgroundTaskStateReason(entry.state)
|
||||
// Settled rows keep their final usage but no elapsed — a still-growing clock
|
||||
// on finished work would lie.
|
||||
const meta = [
|
||||
entry.task.totalTokens !== undefined
|
||||
? formatBackgroundTaskTokens(entry.task.totalTokens)
|
||||
: null,
|
||||
entry.settled ? null : backgroundTaskElapsedLabel(entry.task, now)
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ')
|
||||
return (
|
||||
<li className="flex h-6 min-w-0 items-center gap-2 text-foreground/80">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={`size-3.5 shrink-0 ${kindIconTone(entry.task.kind, false)}`}
|
||||
/>
|
||||
<AgentStateDot state={entry.state} size="sm" title={null} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium text-foreground">{entry.name}</span>
|
||||
{reason ? <span className="text-muted-foreground"> · {reason}</span> : null}
|
||||
</span>
|
||||
{meta ? (
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
{!entry.settled && props.supportsTaskStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={translate(
|
||||
'components.native-chat.backgroundTasks.stopTask',
|
||||
'Stop {{value0}}',
|
||||
{
|
||||
value0: entry.name
|
||||
}
|
||||
)}
|
||||
disabled={props.stopping}
|
||||
onClick={() => props.onStop(entry.task.id)}
|
||||
>
|
||||
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatBackgroundTasksStatus(props: {
|
||||
tasks: readonly AgentSessionBackgroundTask[]
|
||||
settledTasks: readonly AgentSessionBackgroundTask[]
|
||||
supportsTaskStop: boolean
|
||||
/** False when the provider exposes no honest stop at all; the fallback
|
||||
* control is hidden rather than offering a button that cannot act. */
|
||||
supportsStopAll: boolean
|
||||
stoppingTaskIds: ReadonlySet<string>
|
||||
stoppingAll: boolean
|
||||
/** True while the session is idle: only then may the strip speak as the
|
||||
* animated monitoring indicator. A running turn owns the voice. */
|
||||
indicatorActive: boolean
|
||||
isVisible: boolean
|
||||
onStop: (taskId?: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const taskListId = useId()
|
||||
const stripRef = useRef<HTMLDivElement>(null)
|
||||
const narrow = useNarrowStrip(stripRef)
|
||||
// The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster.
|
||||
const groups = useMemo(
|
||||
() => buildBackgroundTaskGroups(props.tasks, props.settledTasks),
|
||||
[props.tasks, props.settledTasks]
|
||||
)
|
||||
const singleLiveCommand =
|
||||
groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1
|
||||
const hasElapsed = groups.some((group) =>
|
||||
group.tasks.some((entry) => !entry.settled && (entry.task.startedAt ?? 0) > 0)
|
||||
)
|
||||
const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand))
|
||||
const header = backgroundTasksHeaderContent(groups, { narrow, now })
|
||||
const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}`
|
||||
return (
|
||||
<div
|
||||
data-native-chat-background-tasks="true"
|
||||
className="shrink-0 bg-background px-3 pt-2 sm:px-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs">
|
||||
<div
|
||||
ref={stripRef}
|
||||
className="mx-auto w-full max-w-4xl overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs"
|
||||
>
|
||||
<div className="flex h-8 items-center px-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={taskListId}
|
||||
aria-label={headerText}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<AgentStateDot state="monitoring" size="md" title={null} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{translate(
|
||||
'components.native-chat.backgroundTasks.monitoring',
|
||||
'Monitoring background tasks'
|
||||
)}
|
||||
{header.segments.map((segment, index) => {
|
||||
// A collapsed total spans kinds, so no single icon can stand for it.
|
||||
const kind = segment.kind
|
||||
const Icon = kind ? KIND_ICONS[kind] : null
|
||||
return (
|
||||
<span key={segment.kind ?? 'total'}>
|
||||
{/* A text token, not `--border`: that one is a divider line
|
||||
(7% white in dark) and reads as invisible at this size. */}
|
||||
{index > 0 ? <span className="text-muted-foreground"> · </span> : null}
|
||||
{Icon && kind ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
// The turn owns the voice: same icons, dimmed until it ends.
|
||||
className={`mr-1 inline size-3 align-[-0.125em] ${kindIconTone(
|
||||
kind,
|
||||
!props.indicatorActive
|
||||
)}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="font-medium text-foreground">{segment.text}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{header.detail ? (
|
||||
<span>
|
||||
{header.segments.length > 0 ? ' — ' : null}
|
||||
{header.detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
@@ -69,47 +214,33 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
id={taskListId}
|
||||
className="scrollbar-sleek max-h-40 overflow-y-auto border-t border-border px-3 py-2"
|
||||
>
|
||||
{props.tasks.length > 0 ? (
|
||||
<ul
|
||||
role="list"
|
||||
aria-label={translate(
|
||||
'components.native-chat.backgroundTasks.runningList',
|
||||
'Running background tasks'
|
||||
)}
|
||||
className="space-y-1.5"
|
||||
>
|
||||
{props.tasks.map((task) => {
|
||||
const label = backgroundTaskLabel(task)
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex min-w-0 items-center gap-2 text-foreground/80"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-1.5 shrink-0 rounded-full bg-primary"
|
||||
{groups.length > 0 ? (
|
||||
groups.map((group, index) => (
|
||||
<div
|
||||
key={group.kind}
|
||||
className={index > 0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''}
|
||||
>
|
||||
<p className="px-0.5 pb-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{backgroundTaskGroupLabel(group.kind)}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
aria-label={backgroundTaskGroupLabel(group.kind)}
|
||||
className="space-y-0.5"
|
||||
>
|
||||
{group.tasks.map((entry) => (
|
||||
<BackgroundTaskRow
|
||||
key={entry.task.id}
|
||||
entry={entry}
|
||||
now={now}
|
||||
supportsTaskStop={props.supportsTaskStop}
|
||||
stopping={props.stoppingTaskIds.has(entry.task.id)}
|
||||
onStop={props.onStop}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 break-words">{label}</span>
|
||||
{props.supportsTaskStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={translate(
|
||||
'components.native-chat.backgroundTasks.stopTask',
|
||||
'Stop {{value0}}',
|
||||
{ value0: label }
|
||||
)}
|
||||
disabled={props.stoppingTaskIds.has(task.id)}
|
||||
onClick={() => props.onStop(task.id)}
|
||||
>
|
||||
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>
|
||||
{translate(
|
||||
@@ -119,7 +250,7 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
</p>
|
||||
)}
|
||||
{!props.supportsTaskStop && props.supportsStopAll ? (
|
||||
<div className={props.tasks.length > 0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>
|
||||
<div className={groups.length > 0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -166,7 +166,7 @@ export function NativeChatComposerField({
|
||||
{/* Extra bottom padding keeps the input box off the window rim. */}
|
||||
<div className="px-3 pt-2 pb-4 sm:px-4">
|
||||
<div className="relative mx-auto w-full max-w-4xl">
|
||||
{autocomplete.mode === 'slash' || autocomplete.mode === 'skill' ? (
|
||||
{autocomplete.mode === 'slash' ? (
|
||||
<NativeChatPickerMenu
|
||||
autocomplete={autocomplete}
|
||||
activeIndex={activeSuggestion}
|
||||
@@ -239,15 +239,10 @@ export function NativeChatComposerField({
|
||||
}}
|
||||
onPasteCapture={onPaste}
|
||||
onSelect={onTextareaSelect}
|
||||
aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'}
|
||||
aria-controls={
|
||||
autocomplete.mode === 'slash' || autocomplete.mode === 'skill'
|
||||
? pickerListboxId
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={autocomplete.mode === 'slash'}
|
||||
aria-controls={autocomplete.mode === 'slash' ? pickerListboxId : undefined}
|
||||
aria-activedescendant={
|
||||
(autocomplete.mode === 'slash' || autocomplete.mode === 'skill') &&
|
||||
autocomplete.items.length > 0
|
||||
autocomplete.mode === 'slash' && autocomplete.items.length > 0
|
||||
? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
+18
-6
@@ -38,9 +38,13 @@ export function createStructuredSessionMocks() {
|
||||
pasteFromClipboard: vi.fn() as StructuredSessionSpy,
|
||||
submissions: [] as unknown[],
|
||||
monitoringBackgroundTasks: false,
|
||||
showBackgroundTasks: false,
|
||||
isWorking: false,
|
||||
turnId: null as string | null,
|
||||
supportsBackgroundTaskStop: false,
|
||||
supportsBackgroundTaskStopAll: true,
|
||||
backgroundTasks: [] as AgentSessionBackgroundTask[],
|
||||
settledBackgroundTasks: [] as AgentSessionBackgroundTask[],
|
||||
stopBackgroundTask: vi.fn() as StructuredSessionSpy
|
||||
}
|
||||
|
||||
@@ -91,12 +95,16 @@ export function createStructuredSessionMocks() {
|
||||
blockedClientMessageId: outbox.blockedClientMessageId,
|
||||
send: outbox.send,
|
||||
retry: outbox.retry,
|
||||
isWorking: false,
|
||||
isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks,
|
||||
supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop,
|
||||
supportsBackgroundTaskStopAll: mocks.supportsBackgroundTaskStopAll,
|
||||
backgroundTasks: mocks.backgroundTasks,
|
||||
turnId: null,
|
||||
isWorking: mocks.isWorking,
|
||||
backgroundTasks: {
|
||||
show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks,
|
||||
isMonitoring: mocks.monitoringBackgroundTasks,
|
||||
tasks: mocks.backgroundTasks,
|
||||
settledTasks: mocks.settledBackgroundTasks,
|
||||
supportsStop: mocks.supportsBackgroundTaskStop,
|
||||
supportsStopAll: mocks.supportsBackgroundTaskStopAll
|
||||
},
|
||||
turnId: mocks.turnId,
|
||||
cancel: vi.fn() as StructuredSessionSpy,
|
||||
stopBackgroundTask: (taskId?: string) =>
|
||||
mocks.stopBackgroundTask(props.sessionId, taskId),
|
||||
@@ -181,10 +189,14 @@ export function createStructuredSessionMocks() {
|
||||
mocks.pasteFromClipboard.mockReset()
|
||||
mocks.submissions = []
|
||||
mocks.monitoringBackgroundTasks = false
|
||||
mocks.showBackgroundTasks = false
|
||||
mocks.isWorking = false
|
||||
mocks.turnId = null
|
||||
mocks.supportsBackgroundTaskStop = false
|
||||
mocks.supportsBackgroundTaskStopAll = true
|
||||
mocks.stopBackgroundTask.mockReset()
|
||||
mocks.backgroundTasks = []
|
||||
mocks.settledBackgroundTasks = []
|
||||
}
|
||||
|
||||
return { mocks, moduleFactories, resetStructuredSessionMocks }
|
||||
|
||||
@@ -145,6 +145,17 @@ describe('NativeChatStructuredSession', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Every background-task test mounts the same local Claude session; only the ids differ.
|
||||
const claudeSessionView = (tabId: string, sessionId: string) => (
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId={tabId}
|
||||
sessionId={sessionId}
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
)
|
||||
|
||||
it('places background monitoring above the usable composer and stops without an active turn', async () => {
|
||||
mocks.monitoringBackgroundTasks = true
|
||||
mocks.supportsBackgroundTaskStop = true
|
||||
@@ -154,33 +165,24 @@ describe('NativeChatStructuredSession', () => {
|
||||
]
|
||||
mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true })
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-background"
|
||||
sessionId="session-background"
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
)
|
||||
render(claudeSessionView('structured-tab-background', 'session-background'))
|
||||
|
||||
const status = screen
|
||||
.getByText('Monitoring background tasks')
|
||||
.closest('[data-native-chat-background-tasks="true"]')
|
||||
const disclosure = screen.getByRole('button', { name: '1 agent · 1 shell' })
|
||||
const status = disclosure.closest('[data-native-chat-background-tasks="true"]')
|
||||
const composer = screen.getByTestId('structured-composer')
|
||||
if (!status) {
|
||||
throw new Error('background task status was not rendered')
|
||||
}
|
||||
expect(status.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(mocks.composerProps?.isWorking).toBe(false)
|
||||
expect(screen.queryByRole('list', { name: 'Running background tasks' })).toBeNull()
|
||||
expect(screen.queryByRole('list', { name: 'Agents' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /^Stop / })).toBeNull()
|
||||
|
||||
const disclosure = screen.getByRole('button', { name: 'Monitoring background tasks' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('list', { name: 'Running background tasks' })).toBeTruthy()
|
||||
expect(screen.getByRole('list', { name: 'Agents' })).toBeTruthy()
|
||||
expect(screen.getByRole('list', { name: 'Shell' })).toBeTruthy()
|
||||
expect(screen.getByText('sleep 180')).toBeTruthy()
|
||||
expect(screen.getByText('Background agent')).toBeTruthy()
|
||||
|
||||
@@ -190,6 +192,28 @@ describe('NativeChatStructuredSession', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the strip mounted through a running turn, with the turn owning the voice', () => {
|
||||
// The strip stands for work that OUTLIVES a turn, so `show` is true while
|
||||
// `isMonitoring` is false: mounted, but not speaking as the live indicator.
|
||||
mocks.showBackgroundTasks = true
|
||||
mocks.monitoringBackgroundTasks = false
|
||||
mocks.isWorking = true
|
||||
mocks.turnId = 'turn-midturn'
|
||||
mocks.backgroundTasks = [{ id: 'task-monitor', kind: 'monitor', description: 'watcher' }]
|
||||
|
||||
render(claudeSessionView('structured-tab-midturn', 'session-midturn'))
|
||||
|
||||
const status = document.querySelector('[data-native-chat-background-tasks="true"]')
|
||||
if (!status) {
|
||||
throw new Error('background task status was not rendered during a running turn')
|
||||
}
|
||||
expect(mocks.composerProps?.isWorking).toBe(true)
|
||||
// Dimmed monitor amber is the turn-owns-the-voice treatment.
|
||||
expect(status.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500/40')
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 monitor — monitoring' }))
|
||||
expect(screen.getByText('watcher')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('tracks concurrent task stops independently and clears each pending result', async () => {
|
||||
mocks.monitoringBackgroundTasks = true
|
||||
mocks.supportsBackgroundTaskStop = true
|
||||
@@ -211,15 +235,9 @@ describe('NativeChatStructuredSession', () => {
|
||||
)
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-concurrent-background"
|
||||
sessionId="session-concurrent-background"
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
claudeSessionView('structured-tab-concurrent-background', 'session-concurrent-background')
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 shells — 2 working' }))
|
||||
const firstStop = screen.getByRole('button', { name: 'Stop First task' })
|
||||
const secondStop = screen.getByRole('button', { name: 'Stop Second task' })
|
||||
|
||||
@@ -252,27 +270,11 @@ describe('NativeChatStructuredSession', () => {
|
||||
}
|
||||
})
|
||||
)
|
||||
const { rerender } = render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-stale-background"
|
||||
sessionId="session-old"
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' }))
|
||||
const { rerender } = render(claudeSessionView('structured-tab-stale-background', 'session-old'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 shell command — working' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop Shared task' }))
|
||||
|
||||
rerender(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-stale-background"
|
||||
sessionId="session-current"
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
)
|
||||
rerender(claudeSessionView('structured-tab-stale-background', 'session-current'))
|
||||
const currentStop = screen.getByRole('button', { name: 'Stop Shared task' })
|
||||
expect((currentStop as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.click(currentStop)
|
||||
@@ -288,15 +290,7 @@ describe('NativeChatStructuredSession', () => {
|
||||
mocks.monitoringBackgroundTasks = true
|
||||
mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true })
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-taskless-background"
|
||||
sessionId="session-taskless-background"
|
||||
target={{ kind: 'local' }}
|
||||
agent="claude"
|
||||
/>
|
||||
)
|
||||
render(claudeSessionView('structured-tab-taskless-background', 'session-taskless-background'))
|
||||
expect(screen.queryByRole('button', { name: 'Stop background tasks' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' }))
|
||||
expect(screen.getByText('Task details are unavailable for this session.')).toBeTruthy()
|
||||
|
||||
@@ -313,11 +313,14 @@ export function NativeChatStructuredSession(
|
||||
{controller.error ?? composerError}
|
||||
</p>
|
||||
) : null}
|
||||
{controller.isMonitoringBackgroundTasks ? (
|
||||
{controller.backgroundTasks.show ? (
|
||||
<NativeChatBackgroundTasksStatus
|
||||
tasks={controller.backgroundTasks}
|
||||
supportsTaskStop={controller.supportsBackgroundTaskStop}
|
||||
supportsStopAll={controller.supportsBackgroundTaskStopAll}
|
||||
isVisible={props.isVisible}
|
||||
tasks={controller.backgroundTasks.tasks}
|
||||
settledTasks={controller.backgroundTasks.settledTasks}
|
||||
indicatorActive={controller.backgroundTasks.isMonitoring}
|
||||
supportsTaskStop={controller.backgroundTasks.supportsStop}
|
||||
supportsStopAll={controller.backgroundTasks.supportsStopAll}
|
||||
stoppingTaskIds={activeStoppingBackgroundTasks?.taskIds ?? NO_STOPPING_TASKS}
|
||||
stoppingAll={activeStoppingBackgroundTasks?.all ?? false}
|
||||
onStop={(taskId) => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// The background-tasks strip HEADER: the one line that speaks for the whole
|
||||
// roster while the strip is collapsed. Counting and phrasing only — grouping,
|
||||
// row state and the state vocabulary live in `background-task-roster.ts`.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
backgroundTaskElapsedLabel,
|
||||
backgroundTaskStateReason,
|
||||
backgroundTaskStateWord,
|
||||
type BackgroundTaskGroup
|
||||
} from './background-task-roster'
|
||||
|
||||
type TaskKind = AgentSessionBackgroundTask['kind']
|
||||
type RunState = AgentSessionBackgroundTaskRunState
|
||||
|
||||
function kindCountLabel(kind: TaskKind, count: number): string {
|
||||
const value = { value0: count }
|
||||
switch (kind) {
|
||||
case 'agent':
|
||||
return count === 1
|
||||
? translate('components.native-chat.backgroundTasks.countAgentsOne', '1 agent')
|
||||
: translate(
|
||||
'components.native-chat.backgroundTasks.countAgentsMany',
|
||||
'{{value0}} agents',
|
||||
value
|
||||
)
|
||||
case 'command':
|
||||
return count === 1
|
||||
? translate('components.native-chat.backgroundTasks.countShellOne', '1 shell')
|
||||
: translate(
|
||||
'components.native-chat.backgroundTasks.countShellMany',
|
||||
'{{value0}} shells',
|
||||
value
|
||||
)
|
||||
case 'monitor':
|
||||
return count === 1
|
||||
? translate('components.native-chat.backgroundTasks.countMonitorsOne', '1 monitor')
|
||||
: translate(
|
||||
'components.native-chat.backgroundTasks.countMonitorsMany',
|
||||
'{{value0}} monitors',
|
||||
value
|
||||
)
|
||||
case 'workflow':
|
||||
return count === 1
|
||||
? translate('components.native-chat.backgroundTasks.countWorkflowsOne', '1 workflow')
|
||||
: translate(
|
||||
'components.native-chat.backgroundTasks.countWorkflowsMany',
|
||||
'{{value0}} workflows',
|
||||
value
|
||||
)
|
||||
case 'unknown':
|
||||
return count === 1
|
||||
? translate('components.native-chat.backgroundTasks.countTasksOne', '1 task')
|
||||
: translate(
|
||||
'components.native-chat.backgroundTasks.countTasksMany',
|
||||
'{{value0}} tasks',
|
||||
value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How many kind segments the header may enumerate before an honest total
|
||||
* replaces the breakdown entirely — never a partial enumeration. */
|
||||
const HEADER_SEGMENT_CAP = 3
|
||||
|
||||
/** Done comes last but must be present: the headline counts settled rows too,
|
||||
* so omitting it made the breakdown contradict its own count. */
|
||||
const HEADER_STATE_ORDER: readonly RunState[] = [
|
||||
'working',
|
||||
'monitoring',
|
||||
'waiting',
|
||||
'blocked',
|
||||
'unverifiable',
|
||||
'idle',
|
||||
'done'
|
||||
]
|
||||
|
||||
const ATTENTION_STATES: ReadonlySet<RunState> = new Set(['waiting', 'unverifiable', 'blocked'])
|
||||
|
||||
export type BackgroundTasksHeaderSegment = {
|
||||
text: string
|
||||
/** The kind this segment counts, so the renderer can lead it with that kind's
|
||||
* icon. Null when the segment spans kinds (the collapsed total), which no
|
||||
* single icon can stand for. */
|
||||
kind: TaskKind | null
|
||||
}
|
||||
|
||||
export type BackgroundTasksHeaderContent = {
|
||||
/** Emphasised segments, joined with a muted separator by the renderer. */
|
||||
segments: BackgroundTasksHeaderSegment[]
|
||||
/** Muted " — …" tail; null when the segments say everything. */
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
/** Every variant in the signed-off mock, plus the overflow and narrow forms.
|
||||
* Any lossy form (fallback or total) leaves the detail reachable — the strip
|
||||
* stays expandable regardless of task count. */
|
||||
export function backgroundTasksHeaderContent(
|
||||
groups: readonly BackgroundTaskGroup[],
|
||||
options: { narrow: boolean; now: number }
|
||||
): BackgroundTasksHeaderContent {
|
||||
const all = groups.flatMap((group) => group.tasks)
|
||||
if (all.length === 0) {
|
||||
return {
|
||||
segments: [],
|
||||
detail: translate(
|
||||
'components.native-chat.backgroundTasks.monitoring',
|
||||
'Monitoring background tasks'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (groups.length > HEADER_SEGMENT_CAP || (options.narrow && all.length > 1)) {
|
||||
return {
|
||||
segments: [
|
||||
{
|
||||
text: translate(
|
||||
'components.native-chat.backgroundTasks.headerTotal',
|
||||
'{{value0}} background tasks',
|
||||
{
|
||||
value0: all.length
|
||||
}
|
||||
),
|
||||
kind: null
|
||||
}
|
||||
],
|
||||
detail: null
|
||||
}
|
||||
}
|
||||
if (groups.length > 1) {
|
||||
return {
|
||||
segments: groups.map((group) => ({
|
||||
text: kindCountLabel(group.kind, group.tasks.length),
|
||||
kind: group.kind
|
||||
})),
|
||||
detail: null
|
||||
}
|
||||
}
|
||||
const group = groups[0]
|
||||
const count = group.tasks.length
|
||||
const uniformState = group.tasks.every((entry) => entry.state === group.tasks[0].state)
|
||||
? group.tasks[0].state
|
||||
: null
|
||||
if (uniformState && ATTENTION_STATES.has(uniformState)) {
|
||||
return {
|
||||
segments: [
|
||||
{
|
||||
text: `${kindCountLabel(group.kind, count)} ${backgroundTaskStateWord(uniformState)}`,
|
||||
kind: group.kind
|
||||
}
|
||||
],
|
||||
detail: backgroundTaskStateReason(uniformState)
|
||||
}
|
||||
}
|
||||
if (count === 1) {
|
||||
const entry = group.tasks[0]
|
||||
const subject =
|
||||
group.kind === 'command'
|
||||
? translate(
|
||||
'components.native-chat.backgroundTasks.countShellCommandOne',
|
||||
'1 shell command'
|
||||
)
|
||||
: kindCountLabel(group.kind, 1)
|
||||
// A still-growing clock on finished work would lie, exactly as on the row.
|
||||
const elapsed =
|
||||
group.kind === 'command' && !entry.settled
|
||||
? backgroundTaskElapsedLabel(entry.task, options.now)
|
||||
: null
|
||||
return {
|
||||
segments: [{ text: subject, kind: group.kind }],
|
||||
detail: elapsed ?? backgroundTaskStateWord(entry.state)
|
||||
}
|
||||
}
|
||||
const stateCounts = HEADER_STATE_ORDER.map((state) => ({
|
||||
state,
|
||||
count: group.tasks.filter((entry) => entry.state === state).length
|
||||
})).filter((entry) => entry.count > 0)
|
||||
return {
|
||||
segments: [{ text: kindCountLabel(group.kind, count), kind: group.kind }],
|
||||
// Done is accounted for in the muted detail but never earns its own emphasised
|
||||
// segment: a finished sibling claims no colour above the composer.
|
||||
detail:
|
||||
stateCounts.length > 0
|
||||
? stateCounts
|
||||
.map((entry) => `${entry.count} ${backgroundTaskStateWord(entry.state)}`)
|
||||
.join(', ')
|
||||
: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
|
||||
import { backgroundTasksHeaderContent } from './background-task-header-content'
|
||||
import {
|
||||
buildBackgroundTaskGroups,
|
||||
formatBackgroundTaskTokens,
|
||||
resolveBackgroundTaskName
|
||||
} from './background-task-roster'
|
||||
|
||||
const NOW = 1_000_000
|
||||
|
||||
function agent(
|
||||
id: string,
|
||||
overrides: Partial<AgentSessionBackgroundTask> = {}
|
||||
): AgentSessionBackgroundTask {
|
||||
return { id, kind: 'agent', state: 'working', startedAt: NOW - 60_000, ...overrides }
|
||||
}
|
||||
|
||||
function header(
|
||||
tasks: AgentSessionBackgroundTask[],
|
||||
settled: AgentSessionBackgroundTask[] = [],
|
||||
narrow = false
|
||||
) {
|
||||
return backgroundTasksHeaderContent(buildBackgroundTaskGroups(tasks, settled), {
|
||||
narrow,
|
||||
now: NOW
|
||||
})
|
||||
}
|
||||
|
||||
describe('backgroundTasksHeaderContent', () => {
|
||||
it('lists all states for a single-kind fan-out (agents only)', () => {
|
||||
expect(header([agent('a'), agent('b'), agent('c', { state: 'waiting' })])).toEqual({
|
||||
segments: [{ text: '3 agents', kind: 'agent' }],
|
||||
detail: '2 working, 1 waiting'
|
||||
})
|
||||
})
|
||||
|
||||
it('names a single working agent', () => {
|
||||
expect(header([agent('a')])).toEqual({
|
||||
segments: [{ text: '1 agent', kind: 'agent' }],
|
||||
detail: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('counts by kind for a mixed roster without a partial state breakdown', () => {
|
||||
expect(
|
||||
header([
|
||||
agent('a'),
|
||||
agent('b'),
|
||||
{ id: 's', kind: 'command', state: 'working', startedAt: NOW },
|
||||
{ id: 'm', kind: 'monitor', state: 'monitoring', startedAt: NOW }
|
||||
])
|
||||
).toEqual({
|
||||
segments: [
|
||||
{ text: '2 agents', kind: 'agent' },
|
||||
{ text: '1 shell', kind: 'command' },
|
||||
{ text: '1 monitor', kind: 'monitor' }
|
||||
],
|
||||
detail: null
|
||||
})
|
||||
})
|
||||
|
||||
it('shows elapsed for a single shell command', () => {
|
||||
expect(
|
||||
header([{ id: 's', kind: 'command', state: 'working', startedAt: NOW - 72_000 }])
|
||||
).toEqual({ segments: [{ text: '1 shell command', kind: 'command' }], detail: '1m 12s' })
|
||||
})
|
||||
|
||||
it('leads with the attention state when a single agent needs the user', () => {
|
||||
expect(header([agent('a', { state: 'waiting' })])).toEqual({
|
||||
segments: [{ text: '1 agent waiting', kind: 'agent' }],
|
||||
detail: 'needs approval'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports lost contact above running work', () => {
|
||||
expect(
|
||||
header([agent('a', { state: 'unverifiable' }), agent('b', { state: 'unverifiable' })])
|
||||
).toEqual({
|
||||
segments: [{ text: '2 agents unverifiable', kind: 'agent' }],
|
||||
detail: 'no contact'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the existing copy for a host that sends state without a task list', () => {
|
||||
expect(header([])).toEqual({ segments: [], detail: 'Monitoring background tasks' })
|
||||
})
|
||||
|
||||
it('drops the breakdown for an honest total past the segment cap', () => {
|
||||
expect(
|
||||
header([
|
||||
agent('a'),
|
||||
{ id: 'b', kind: 'command', state: 'working', startedAt: NOW },
|
||||
{ id: 'c', kind: 'monitor', state: 'monitoring', startedAt: NOW },
|
||||
{ id: 'd', kind: 'workflow', state: 'working', startedAt: NOW },
|
||||
agent('e'),
|
||||
{ id: 'f', kind: 'command', state: 'working', startedAt: NOW },
|
||||
{ id: 'g', kind: 'unknown', startedAt: NOW }
|
||||
])
|
||||
).toEqual({ segments: [{ text: '7 background tasks', kind: null }], detail: null })
|
||||
})
|
||||
|
||||
it('falls back to the total on a narrow strip', () => {
|
||||
expect(
|
||||
header([agent('a'), { id: 's', kind: 'command', state: 'working', startedAt: NOW }], [], true)
|
||||
).toEqual({ segments: [{ text: '2 background tasks', kind: null }], detail: null })
|
||||
// A single task stays named: the short form fits.
|
||||
expect(header([agent('a')], [], true)).toEqual({
|
||||
segments: [{ text: '1 agent', kind: 'agent' }],
|
||||
detail: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('says how many of the counted rows are done when every task has settled', () => {
|
||||
expect(
|
||||
header(
|
||||
[],
|
||||
[
|
||||
agent('a', { state: 'done' }),
|
||||
agent('b', { state: 'done' }),
|
||||
agent('c', { state: 'done' })
|
||||
]
|
||||
)
|
||||
).toEqual({ segments: [{ text: '3 agents', kind: 'agent' }], detail: '3 done' })
|
||||
})
|
||||
|
||||
it('accounts for settled siblings so the breakdown sums to the count', () => {
|
||||
const content = header(
|
||||
[agent('live')],
|
||||
[
|
||||
agent('s1', { state: 'done' }),
|
||||
agent('s2', { state: 'done' }),
|
||||
agent('s3', { state: 'done' }),
|
||||
agent('s4', { state: 'done' })
|
||||
]
|
||||
)
|
||||
expect(content).toEqual({
|
||||
segments: [{ text: '5 agents', kind: 'agent' }],
|
||||
detail: '1 working, 4 done'
|
||||
})
|
||||
// The headline count and its own breakdown must never contradict each other.
|
||||
const headline = Number(content.segments[0].text.split(' ')[0])
|
||||
const counted = (content.detail ?? '')
|
||||
.split(', ')
|
||||
.reduce((sum, part) => sum + Number(part.split(' ')[0]), 0)
|
||||
expect(counted).toBe(headline)
|
||||
})
|
||||
|
||||
it('drops the elapsed clock from a settled shell command', () => {
|
||||
// The row already refuses a still-growing clock on finished work; so must the header.
|
||||
expect(
|
||||
header([], [{ id: 's', kind: 'command', state: 'done', startedAt: NOW - 72_000 }])
|
||||
).toEqual({ segments: [{ text: '1 shell command', kind: 'command' }], detail: 'done' })
|
||||
})
|
||||
|
||||
it('counts unknown tasks instead of hiding them', () => {
|
||||
expect(header([{ id: 'u', kind: 'unknown', startedAt: NOW }])).toEqual({
|
||||
segments: [{ text: '1 task', kind: 'unknown' }],
|
||||
detail: 'working'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBackgroundTaskGroups', () => {
|
||||
it('groups by kind in fixed order, keeping first-seen order inside a group', () => {
|
||||
const built = buildBackgroundTaskGroups(
|
||||
[
|
||||
{ id: 'm', kind: 'monitor', startedAt: 3 },
|
||||
agent('late', { startedAt: 2 }),
|
||||
agent('early', { startedAt: 1 })
|
||||
],
|
||||
[agent('settled', { state: 'done', startedAt: 0 })]
|
||||
)
|
||||
expect(built.map((group) => group.kind)).toEqual(['agent', 'monitor'])
|
||||
expect(built[0].tasks.map((entry) => entry.task.id)).toEqual(['settled', 'early', 'late'])
|
||||
expect(built[0].tasks[0].settled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults the state slot so a stateless row still reads as work', () => {
|
||||
const built = buildBackgroundTaskGroups([{ id: 'a', kind: 'agent' }], [])
|
||||
expect(built[0].tasks[0].state).toBe('working')
|
||||
const monitor = buildBackgroundTaskGroups([{ id: 'm', kind: 'monitor' }], [])
|
||||
expect(monitor[0].tasks[0].state).toBe('monitoring')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBackgroundTaskTokens', () => {
|
||||
it('renders compact token counts like the mock', () => {
|
||||
expect(formatBackgroundTaskTokens(950)).toBe('950')
|
||||
expect(formatBackgroundTaskTokens(18_130)).toBe('18.1k')
|
||||
expect(formatBackgroundTaskTokens(4_100)).toBe('4.1k')
|
||||
expect(formatBackgroundTaskTokens(2_000)).toBe('2k')
|
||||
expect(formatBackgroundTaskTokens(1_450_000)).toBe('1.5m')
|
||||
// Rounding first would promote this to "1000k".
|
||||
expect(formatBackgroundTaskTokens(999_950)).toBe('1m')
|
||||
expect(formatBackgroundTaskTokens(999_949)).toBe('999.9k')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBackgroundTaskName', () => {
|
||||
it('prefers description, then name, then the kind label', () => {
|
||||
expect(
|
||||
resolveBackgroundTaskName({ id: 'a', kind: 'agent', description: 'review PR', name: 'deep' })
|
||||
).toBe('review PR')
|
||||
expect(resolveBackgroundTaskName({ id: 'a', kind: 'agent', name: 'deep_review' })).toBe(
|
||||
'deep_review'
|
||||
)
|
||||
expect(resolveBackgroundTaskName({ id: 'a', kind: 'agent' })).toBe('Background agent')
|
||||
})
|
||||
|
||||
it('rejects empty-after-trim and placeholder names', () => {
|
||||
expect(resolveBackgroundTaskName({ id: 'a', kind: 'agent', description: ' ' })).toBe(
|
||||
'Background agent'
|
||||
)
|
||||
expect(
|
||||
resolveBackgroundTaskName({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
description: 'Unknown',
|
||||
name: ' task '
|
||||
})
|
||||
).toBe('Background command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumed tasks from mixed-version hosts', () => {
|
||||
it('renders one live owner per id and counts only the two dispatched agents', () => {
|
||||
const live = agent('resumed', { totalTokens: 20000 })
|
||||
const settled = agent('resumed', { state: 'done', totalTokens: 19003 })
|
||||
const shells = Array.from({ length: 4 }, (_, index) =>
|
||||
agent(`shell-${index}`, { kind: 'command' })
|
||||
)
|
||||
const groups = buildBackgroundTaskGroups(
|
||||
[live, ...shells],
|
||||
[settled, agent('sibling', { state: 'done' })]
|
||||
)
|
||||
expect(
|
||||
groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id)
|
||||
).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }])
|
||||
expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([
|
||||
{ text: '2 agents', kind: 'agent' },
|
||||
{ text: '4 shells', kind: 'command' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
// Grouping, naming, and header derivation for the background-tasks strip.
|
||||
// Pure functions over the wire roster so every header variant is unit-testable
|
||||
// without mounting the strip.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type TaskKind = AgentSessionBackgroundTask['kind']
|
||||
type RunState = AgentSessionBackgroundTaskRunState
|
||||
|
||||
export type BackgroundRosterTask = {
|
||||
task: AgentSessionBackgroundTask
|
||||
settled: boolean
|
||||
state: RunState
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[] }
|
||||
|
||||
/** Fixed presentation order; groups render only when non-empty. */
|
||||
const KIND_ORDER: readonly TaskKind[] = ['agent', 'command', 'monitor', 'workflow', 'unknown']
|
||||
|
||||
/** Provider strings that carry no identity; a row falls through to its kind label. */
|
||||
const PLACEHOLDER_NAMES = new Set(['unknown', 'untitled', 'task', 'subagent'])
|
||||
|
||||
function usableTaskText(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed || PLACEHOLDER_NAMES.has(trimmed.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function backgroundTaskKindLabel(kind: TaskKind): string {
|
||||
switch (kind) {
|
||||
case 'agent':
|
||||
return translate('components.native-chat.backgroundTasks.agent', 'Background agent')
|
||||
case 'workflow':
|
||||
return translate('components.native-chat.backgroundTasks.workflow', 'Background workflow')
|
||||
case 'command':
|
||||
return translate('components.native-chat.backgroundTasks.command', 'Background command')
|
||||
case 'monitor':
|
||||
return translate('components.native-chat.backgroundTasks.monitor', 'Background monitor')
|
||||
case 'unknown':
|
||||
return translate('components.native-chat.backgroundTasks.task', 'Background task')
|
||||
}
|
||||
}
|
||||
|
||||
/** Display name: description → name → kind label. Empty-after-trim and
|
||||
* placeholder values fall through, so a row always renders something. */
|
||||
export function resolveBackgroundTaskName(task: AgentSessionBackgroundTask): string {
|
||||
return (
|
||||
usableTaskText(task.description) ??
|
||||
usableTaskText(task.name) ??
|
||||
backgroundTaskKindLabel(task.kind)
|
||||
)
|
||||
}
|
||||
|
||||
function effectiveState(task: AgentSessionBackgroundTask, settled: boolean): RunState {
|
||||
if (task.state) {
|
||||
return task.state
|
||||
}
|
||||
if (settled) {
|
||||
return 'done'
|
||||
}
|
||||
return task.kind === 'monitor' ? 'monitoring' : 'working'
|
||||
}
|
||||
|
||||
/** Merge live and settled tasks into kind groups, stable-sorted first-seen
|
||||
* (startedAt) then id, so a live update never reshuffles surviving rows. */
|
||||
export function buildBackgroundTaskGroups(
|
||||
tasks: readonly AgentSessionBackgroundTask[],
|
||||
settledTasks: readonly AgentSessionBackgroundTask[]
|
||||
): BackgroundTaskGroup[] {
|
||||
// Older hosts can retain a previous turn beside its resumed live task.
|
||||
const owners = new Map<string, BackgroundRosterTask>()
|
||||
for (const [roster, settled] of [
|
||||
[settledTasks, true],
|
||||
[tasks, false]
|
||||
] as const) {
|
||||
for (const task of roster) {
|
||||
owners.set(task.id, {
|
||||
task,
|
||||
settled,
|
||||
state: effectiveState(task, settled),
|
||||
name: resolveBackgroundTaskName(task)
|
||||
})
|
||||
}
|
||||
}
|
||||
const entries = [...owners.values()]
|
||||
entries.sort((left, right) => {
|
||||
const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0)
|
||||
return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1
|
||||
})
|
||||
return KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
tasks: entries.filter((entry) => entry.task.kind === kind)
|
||||
})).filter((group) => group.tasks.length > 0)
|
||||
}
|
||||
|
||||
export function backgroundTaskStateWord(state: RunState): string {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return translate('components.native-chat.backgroundTasks.stateWorking', 'working')
|
||||
case 'monitoring':
|
||||
return translate('components.native-chat.backgroundTasks.stateMonitoring', 'monitoring')
|
||||
case 'waiting':
|
||||
return translate('components.native-chat.backgroundTasks.stateWaiting', 'waiting')
|
||||
case 'blocked':
|
||||
return translate('components.native-chat.backgroundTasks.stateBlocked', 'blocked')
|
||||
case 'done':
|
||||
return translate('components.native-chat.backgroundTasks.stateDone', 'done')
|
||||
case 'idle':
|
||||
return translate('components.native-chat.backgroundTasks.stateIdle', 'stopped')
|
||||
case 'unverifiable':
|
||||
return translate('components.native-chat.backgroundTasks.stateUnverifiable', 'unverifiable')
|
||||
}
|
||||
}
|
||||
|
||||
/** The reason line for an attention state, per the signed-off mock. */
|
||||
export function backgroundTaskStateReason(state: RunState): string | null {
|
||||
switch (state) {
|
||||
case 'waiting':
|
||||
return translate('components.native-chat.backgroundTasks.reasonWaiting', 'needs approval')
|
||||
case 'unverifiable':
|
||||
return translate('components.native-chat.backgroundTasks.reasonUnverifiable', 'no contact')
|
||||
case 'blocked':
|
||||
return translate('components.native-chat.backgroundTasks.reasonBlocked', 'failed')
|
||||
case 'working':
|
||||
case 'monitoring':
|
||||
case 'done':
|
||||
case 'idle':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function tokenScaleText(value: number): string {
|
||||
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)
|
||||
}
|
||||
|
||||
/** Compact token meta per the mock ("18.2k"). Locale-neutral on purpose:
|
||||
* it sits in a mono meta slot beside elapsed, like other technical literals. */
|
||||
export function formatBackgroundTaskTokens(totalTokens: number): string {
|
||||
if (totalTokens < 1_000) {
|
||||
return String(totalTokens)
|
||||
}
|
||||
// Round before picking the unit, or 999_950 renders as "1000k" instead of "1m".
|
||||
const thousands = Math.round(totalTokens / 100) / 10
|
||||
return thousands < 1_000
|
||||
? `${tokenScaleText(thousands)}k`
|
||||
: `${tokenScaleText(Math.round(totalTokens / 100_000) / 10)}m`
|
||||
}
|
||||
|
||||
export function backgroundTaskElapsedLabel(
|
||||
task: AgentSessionBackgroundTask,
|
||||
now: number
|
||||
): string | null {
|
||||
if (task.startedAt === undefined || task.startedAt <= 0) {
|
||||
return null
|
||||
}
|
||||
return formatNativeChatDuration((now - task.startedAt) / 1000)
|
||||
}
|
||||
|
||||
export function backgroundTaskGroupLabel(kind: TaskKind): string {
|
||||
switch (kind) {
|
||||
case 'agent':
|
||||
return translate('components.native-chat.backgroundTasks.groupAgents', 'Agents')
|
||||
case 'command':
|
||||
return translate('components.native-chat.backgroundTasks.groupShell', 'Shell')
|
||||
case 'monitor':
|
||||
return translate('components.native-chat.backgroundTasks.groupMonitors', 'Monitors')
|
||||
case 'workflow':
|
||||
return translate('components.native-chat.backgroundTasks.groupWorkflows', 'Workflows')
|
||||
case 'unknown':
|
||||
return translate('components.native-chat.backgroundTasks.groupTasks', 'Tasks')
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
editReplacesTriggerToken,
|
||||
EMPTY_HISTORY,
|
||||
filterSlashCommands,
|
||||
isSkillPickerTriggered,
|
||||
isSlashCommandDraft,
|
||||
pushHistory,
|
||||
recallNext,
|
||||
@@ -96,28 +97,30 @@ describe('deriveComposerAutocomplete — mention', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveComposerAutocomplete — skill', () => {
|
||||
describe('deriveComposerAutocomplete — one grammar for every agent', () => {
|
||||
const skills = [
|
||||
skill({ name: 'typescript' }),
|
||||
skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' })
|
||||
]
|
||||
const codex = getNativeChatAgentProfile('codex')
|
||||
|
||||
it('enters skill mode with the query after `$`', () => {
|
||||
const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills)
|
||||
expect(result.mode).toBe('skill')
|
||||
if (result.mode !== 'skill') {
|
||||
it('offers Codex skills under `/`, tokenised as the form Codex invokes', () => {
|
||||
const result = deriveComposerAutocomplete('use /type', 9, COMMANDS, skills, codex)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(result.query).toBe('type')
|
||||
expect(result.items.map((entry) => entry.name)).toEqual(['typescript'])
|
||||
expect(result.items.map((entry) => entry.token)).toEqual(['$typescript'])
|
||||
})
|
||||
|
||||
it('fires at the start of input too', () => {
|
||||
expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill')
|
||||
it('no longer treats `$` as a composer trigger', () => {
|
||||
expect(deriveComposerAutocomplete('use $type', 9, COMMANDS, skills, codex).mode).toBe('none')
|
||||
expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills, codex).mode).toBe('none')
|
||||
})
|
||||
|
||||
it('does not fire inside shell-style text', () => {
|
||||
expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none')
|
||||
expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills, codex).mode).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,31 +197,151 @@ describe('apply suggestions', () => {
|
||||
expect(result.caret).toBe('open @src/app.ts '.length)
|
||||
})
|
||||
|
||||
it('applyPickerSuggestion replaces the active $token at the caret', () => {
|
||||
const result = applyPickerSuggestion(
|
||||
'use $typ now',
|
||||
8,
|
||||
{ kind: 'skill', id: 'skill:typescript', name: 'typescript', description: null, sources: [] },
|
||||
'$'
|
||||
)
|
||||
it('applyPickerSuggestion swaps the typed /token for the agent-native token', () => {
|
||||
const result = applyPickerSuggestion('use /typ now', 8, {
|
||||
kind: 'skill',
|
||||
id: 'skill:typescript',
|
||||
name: 'typescript',
|
||||
token: '$typescript',
|
||||
description: null,
|
||||
sources: []
|
||||
})
|
||||
expect(result.draft).toBe('use $typescript now')
|
||||
expect(result.caret).toBe('use $typescript '.length)
|
||||
expect(result.insertedToken).toBe('$typescript')
|
||||
})
|
||||
})
|
||||
|
||||
describe('native skill and command picker', () => {
|
||||
it('keeps Codex commands under slash and skills under dollar', () => {
|
||||
const profile = getNativeChatAgentProfile('codex')
|
||||
const slash = deriveComposerAutocomplete('/', 1, COMMANDS, [skill({})], profile)
|
||||
it('puts Codex commands and skills in one `/` menu, each with its own token', () => {
|
||||
const slash = deriveComposerAutocomplete(
|
||||
'/',
|
||||
1,
|
||||
COMMANDS,
|
||||
[skill({ name: 'browser' })],
|
||||
getNativeChatAgentProfile('codex')
|
||||
)
|
||||
expect(slash.mode).toBe('slash')
|
||||
if (slash.mode === 'slash') {
|
||||
expect(slash.items.every((item) => item.kind === 'command')).toBe(true)
|
||||
if (slash.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
const dollar = deriveComposerAutocomplete('$', 1, COMMANDS, [skill({})], profile)
|
||||
expect(dollar.mode).toBe('skill')
|
||||
if (dollar.mode === 'skill') {
|
||||
expect(dollar.items.every((item) => item.kind === 'skill')).toBe(true)
|
||||
expect(slash.grouped).toBe(true)
|
||||
expect(slash.items.filter((item) => item.kind === 'command').map((item) => item.token)).toEqual(
|
||||
['/clear', '/compact', '/help']
|
||||
)
|
||||
expect(slash.items.filter((item) => item.kind === 'skill').map((item) => item.token)).toEqual([
|
||||
'$browser'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a Codex command and a same-named skill as separate rows', () => {
|
||||
const result = deriveComposerAutocomplete(
|
||||
'/clear',
|
||||
6,
|
||||
COMMANDS,
|
||||
[skill({ name: 'clear' })],
|
||||
getNativeChatAgentProfile('codex')
|
||||
)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(result.items.map((item) => item.token)).toEqual(['/clear', '$clear'])
|
||||
expect(result.items.find((item) => item.kind === 'command')?.skillCollision).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the same commands and skills for a `/` typed mid-prompt as for a leading one', () => {
|
||||
const args = [
|
||||
COMMANDS,
|
||||
[skill({ name: 'electron' })],
|
||||
getNativeChatAgentProfile('claude')
|
||||
] as const
|
||||
const leading = deriveComposerAutocomplete('/', 1, ...args)
|
||||
const midPrompt = deriveComposerAutocomplete('validate it with /', 18, ...args)
|
||||
expect(midPrompt.mode).toBe('slash')
|
||||
if (midPrompt.mode !== 'slash' || leading.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(midPrompt.items).toEqual(leading.items)
|
||||
expect(midPrompt.items.map((item) => item.kind)).toContain('command')
|
||||
expect(midPrompt.items.map((item) => item.kind)).toContain('skill')
|
||||
expect(midPrompt.grouped).toBe(leading.grouped)
|
||||
})
|
||||
|
||||
it('filters the mid-prompt `/` menu by the typed token', () => {
|
||||
const result = deriveComposerAutocomplete(
|
||||
'validate it with /elec',
|
||||
22,
|
||||
COMMANDS,
|
||||
[skill({ name: 'electron' })],
|
||||
getNativeChatAgentProfile('claude')
|
||||
)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode === 'slash') {
|
||||
expect(result.prefix).toBe('/')
|
||||
expect(result.items.map((item) => item.name)).toEqual(['electron'])
|
||||
}
|
||||
})
|
||||
|
||||
it('marks only a draft-leading `/command` dispatchable', () => {
|
||||
const profile = getNativeChatAgentProfile('claude')
|
||||
const leading = deriveComposerAutocomplete('/comp', 5, COMMANDS, [], profile)
|
||||
const midPrompt = deriveComposerAutocomplete('then /comp', 10, COMMANDS, [], profile)
|
||||
expect(leading.mode === 'slash' && leading.dispatchable).toBe(true)
|
||||
expect(midPrompt.mode === 'slash' && midPrompt.dispatchable).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a mid-prompt path alone', () => {
|
||||
expect(
|
||||
deriveComposerAutocomplete(
|
||||
'open /Users/me/notes',
|
||||
20,
|
||||
COMMANDS,
|
||||
[skill({ name: 'electron' })],
|
||||
getNativeChatAgentProfile('claude')
|
||||
).mode
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('opens the mid-prompt `/` menu for Codex too, tokenised for Codex', () => {
|
||||
const result = deriveComposerAutocomplete(
|
||||
'validate it with /elec',
|
||||
22,
|
||||
COMMANDS,
|
||||
[skill({ name: 'electron' })],
|
||||
getNativeChatAgentProfile('codex')
|
||||
)
|
||||
expect(result.mode).toBe('slash')
|
||||
if (result.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
expect(result.dispatchable).toBe(false)
|
||||
expect(result.items.map((item) => item.token)).toEqual(['$electron'])
|
||||
})
|
||||
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'loads the skill catalog for both `/` trigger positions on %s',
|
||||
(agent) => {
|
||||
const profile = getNativeChatAgentProfile(agent)
|
||||
expect(isSkillPickerTriggered('/elec', profile)).toBe(true)
|
||||
expect(isSkillPickerTriggered('validate it with /elec', profile)).toBe(true)
|
||||
expect(isSkillPickerTriggered('open /Users/me', profile)).toBe(false)
|
||||
// Without a catalog fetch the menu would sit on a permanent loading row.
|
||||
expect(isSkillPickerTriggered('use $elec', profile)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('applyPickerSuggestion replaces a mid-prompt /token at the caret', () => {
|
||||
const result = applyPickerSuggestion('validate it with /elec now', 22, {
|
||||
kind: 'skill',
|
||||
id: 'skill:electron',
|
||||
name: 'electron',
|
||||
token: '/electron',
|
||||
description: null,
|
||||
sources: []
|
||||
})
|
||||
expect(result.draft).toBe('validate it with /electron now')
|
||||
expect(result.caret).toBe('validate it with /electron '.length)
|
||||
})
|
||||
|
||||
it('groups Claude commands and skills under slash', () => {
|
||||
@@ -327,7 +450,7 @@ describe('native skill and command picker', () => {
|
||||
'$'
|
||||
)
|
||||
expect(items.map((item) => item.name)).toEqual([longName])
|
||||
const applied = applyPickerSuggestion('$sk', 3, items[0], '$')
|
||||
const applied = applyPickerSuggestion('/sk', 3, items[0])
|
||||
expect(applied.draft).toBe(`$${longName} `)
|
||||
})
|
||||
|
||||
@@ -364,12 +487,14 @@ describe('native skill and command picker', () => {
|
||||
})
|
||||
|
||||
it('replaces only the active slash token and preserves text after the caret', () => {
|
||||
const result = applyPickerSuggestion(
|
||||
'/bro trailing',
|
||||
4,
|
||||
{ kind: 'skill', id: 'skill:browser', name: 'browser', description: null, sources: [] },
|
||||
'/'
|
||||
)
|
||||
const result = applyPickerSuggestion('/bro trailing', 4, {
|
||||
kind: 'skill',
|
||||
id: 'skill:browser',
|
||||
name: 'browser',
|
||||
token: '/browser',
|
||||
description: null,
|
||||
sources: []
|
||||
})
|
||||
expect(result.draft).toBe('/browser trailing')
|
||||
expect(result.caret).toBe('/browser '.length)
|
||||
})
|
||||
@@ -396,29 +521,29 @@ describe('native skill and command picker', () => {
|
||||
|
||||
it('treats a one-edit token swap as a new trigger occurrence', () => {
|
||||
expect(editReplacesTriggerToken('/foo', '/bar', '/:0')).toBe(true)
|
||||
expect(editReplacesTriggerToken('use $foo', 'use $bar', '$:4')).toBe(true)
|
||||
expect(editReplacesTriggerToken('use /foo', 'use /bar', '/:4')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps suppression while typing or deleting inside the dismissed token', () => {
|
||||
expect(editReplacesTriggerToken('/foo', '/food', '/:0')).toBe(false)
|
||||
expect(editReplacesTriggerToken('/food', '/foo', '/:0')).toBe(false)
|
||||
expect(editReplacesTriggerToken('use $foo now', 'ran $foo now', '$:4')).toBe(false)
|
||||
expect(editReplacesTriggerToken('use /foo now', 'ran /foo now', '/:4')).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses only the dismissed trigger occurrence', () => {
|
||||
const profile = getNativeChatAgentProfile('codex')
|
||||
expect(deriveComposerAutocomplete('use $bro', 8, COMMANDS, [skill({})], profile).mode).toBe(
|
||||
'skill'
|
||||
expect(deriveComposerAutocomplete('use /bro', 8, COMMANDS, [skill({})], profile).mode).toBe(
|
||||
'slash'
|
||||
)
|
||||
expect(
|
||||
deriveComposerAutocomplete(
|
||||
'use $bro',
|
||||
'use /bro',
|
||||
8,
|
||||
COMMANDS,
|
||||
[skill({})],
|
||||
profile,
|
||||
{ status: 'ready', skills: [skill({})] },
|
||||
'$:4'
|
||||
'/:4'
|
||||
).mode
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../../../shared/native-chat-slash-commands'
|
||||
import {
|
||||
buildNativeChatPickerItems,
|
||||
LEADING_SLASH_TRIGGER,
|
||||
MID_PROMPT_SLASH_TRIGGER,
|
||||
type NativeChatPickerItem,
|
||||
type NativeChatSkillDiscoverySnapshot
|
||||
} from './native-chat-picker-items'
|
||||
@@ -28,7 +30,9 @@ type PickerAutocomplete = {
|
||||
query: string
|
||||
items: NativeChatPickerItem[]
|
||||
triggerKey: string
|
||||
prefix: '/' | '$'
|
||||
prefix: '/'
|
||||
/** Only a draft-leading `/command` reaches the agent as a command. */
|
||||
dispatchable: boolean
|
||||
grouped: boolean
|
||||
commandsEnabled: boolean
|
||||
skillsEnabled: boolean
|
||||
@@ -40,10 +44,20 @@ export type ComposerAutocomplete =
|
||||
| { mode: 'none' }
|
||||
| ({ mode: 'slash' } & PickerAutocomplete)
|
||||
| { mode: 'mention'; query: string }
|
||||
| ({ mode: 'skill' } & PickerAutocomplete)
|
||||
|
||||
const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] }
|
||||
|
||||
/** Whether the caret sits in a token that needs the skill catalog loaded. */
|
||||
export function isSkillPickerTriggered(
|
||||
before: string,
|
||||
profile: NativeChatAgentProfile | null
|
||||
): boolean {
|
||||
if (!profile) {
|
||||
return false
|
||||
}
|
||||
return LEADING_SLASH_TRIGGER.test(before) || MID_PROMPT_SLASH_TRIGGER.test(before)
|
||||
}
|
||||
|
||||
export function deriveComposerAutocomplete(
|
||||
draft: string,
|
||||
caret: number,
|
||||
@@ -55,9 +69,11 @@ export function deriveComposerAutocomplete(
|
||||
sessionSkillNames?: readonly string[]
|
||||
): ComposerAutocomplete {
|
||||
const before = draft.slice(0, caret)
|
||||
if (before.startsWith('/') && !/\s/.test(before)) {
|
||||
const leadingMatch = before.match(LEADING_SLASH_TRIGGER)
|
||||
if (leadingMatch) {
|
||||
return deriveSlashAutocomplete(
|
||||
before,
|
||||
leadingMatch[1],
|
||||
0,
|
||||
agentCommands,
|
||||
profile,
|
||||
discovery,
|
||||
@@ -69,70 +85,64 @@ export function deriveComposerAutocomplete(
|
||||
if (mentionMatch) {
|
||||
return { mode: 'mention', query: mentionMatch[1] }
|
||||
}
|
||||
const skillMatch =
|
||||
profile?.skillPrefix === '$' || (!profile && skills.length > 0)
|
||||
? before.match(/(?:^|\s)\$(\S*)$/)
|
||||
: null
|
||||
if (!skillMatch) {
|
||||
// Why: `/` is the whole composer grammar, so a mid-prompt token opens the same
|
||||
// menu a leading one does — it just cannot dispatch.
|
||||
const midPromptMatch = profile ? before.match(MID_PROMPT_SLASH_TRIGGER) : null
|
||||
if (!midPromptMatch) {
|
||||
return { mode: 'none' }
|
||||
}
|
||||
const triggerKey = `$:${before.length - skillMatch[1].length - 1}`
|
||||
if (dismissedTriggerKey === triggerKey) {
|
||||
return { mode: 'none' }
|
||||
}
|
||||
const query = skillMatch[1]
|
||||
return {
|
||||
mode: 'skill',
|
||||
const query = midPromptMatch[1]
|
||||
return deriveSlashAutocomplete(
|
||||
query,
|
||||
triggerKey,
|
||||
prefix: '$',
|
||||
grouped: false,
|
||||
commandsEnabled: false,
|
||||
skillsEnabled: true,
|
||||
items: buildNativeChatPickerItems([], discovery.skills, query, '$', sessionSkillNames),
|
||||
skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status,
|
||||
...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {})
|
||||
}
|
||||
before.length - query.length - 1,
|
||||
agentCommands,
|
||||
profile,
|
||||
discovery,
|
||||
dismissedTriggerKey,
|
||||
sessionSkillNames
|
||||
)
|
||||
}
|
||||
|
||||
function deriveSlashAutocomplete(
|
||||
before: string,
|
||||
query: string,
|
||||
triggerPosition: number,
|
||||
agentCommands: readonly SlashCommandSuggestion[],
|
||||
profile: NativeChatAgentProfile | null,
|
||||
discovery: NativeChatSkillDiscoverySnapshot,
|
||||
dismissedTriggerKey: string | null,
|
||||
sessionSkillNames: readonly string[] | undefined
|
||||
): ComposerAutocomplete {
|
||||
const triggerKey = '/:0'
|
||||
const triggerKey = `/:${triggerPosition}`
|
||||
if (dismissedTriggerKey === triggerKey) {
|
||||
return { mode: 'none' }
|
||||
}
|
||||
const query = before.slice(1)
|
||||
const hasSlashSkills = profile?.skillPrefix === '/'
|
||||
// Why: the caller owns catalog policy (e.g. Grok ships skills-only until a
|
||||
// verified catalog lands); this derivation must not re-gate per agent.
|
||||
// Every agent with a known grammar offers skills here; only the token a pick
|
||||
// inserts differs. The caller owns catalog policy (e.g. Grok ships skills-only
|
||||
// until a verified catalog lands), so this derivation must not re-gate per agent.
|
||||
const skillsEnabled = profile !== null
|
||||
const items = buildNativeChatPickerItems(
|
||||
agentCommands,
|
||||
hasSlashSkills ? discovery.skills : [],
|
||||
skillsEnabled ? discovery.skills : [],
|
||||
query,
|
||||
'/',
|
||||
hasSlashSkills ? sessionSkillNames : []
|
||||
profile?.skillPrefix ?? '/',
|
||||
skillsEnabled ? sessionSkillNames : []
|
||||
)
|
||||
return {
|
||||
mode: 'slash',
|
||||
query,
|
||||
triggerKey,
|
||||
prefix: '/',
|
||||
grouped: profile?.groupedSlash === true,
|
||||
dispatchable: triggerPosition === 0,
|
||||
grouped: skillsEnabled,
|
||||
commandsEnabled: agentCommands.length > 0,
|
||||
skillsEnabled: hasSlashSkills,
|
||||
skillsEnabled,
|
||||
items,
|
||||
skillStatus: hasSlashSkills
|
||||
skillStatus: skillsEnabled
|
||||
? discovery.status === 'idle'
|
||||
? 'loading'
|
||||
: discovery.status
|
||||
: 'ready',
|
||||
...(hasSlashSkills && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {})
|
||||
...(skillsEnabled && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export type NativeChatPickerItem =
|
||||
kind: 'command'
|
||||
id: string
|
||||
name: string
|
||||
/** Exactly what a pick inserts — the form the agent invokes. */
|
||||
token: string
|
||||
description?: string
|
||||
skillCollision: boolean
|
||||
}
|
||||
@@ -25,6 +27,7 @@ export type NativeChatPickerItem =
|
||||
kind: 'skill'
|
||||
id: string
|
||||
name: string
|
||||
token: string
|
||||
description: string | null
|
||||
sources: { sourceKind: SkillSourceKind; skillFilePath: string }[]
|
||||
}
|
||||
@@ -47,16 +50,24 @@ export function buildNativeChatPickerItems(
|
||||
commands: readonly SlashCommandSuggestion[],
|
||||
skills: readonly DiscoveredSkill[],
|
||||
query: string,
|
||||
prefix: '/' | '$',
|
||||
skillSigil: '/' | '$',
|
||||
sessionSkillNames?: readonly string[]
|
||||
): NativeChatPickerItem[] {
|
||||
// A name can only collide when both kinds invoke through the same sigil;
|
||||
// where skills carry their own, `/review` and `$review` are distinct entries.
|
||||
const sharedSigil = skillSigil === '/'
|
||||
const unclassifiedNames = new Set(
|
||||
commands.filter((command) => command.kindUnspecified).map((command) => command.name)
|
||||
)
|
||||
const mergedSkills = mergeNativeChatSkills(skills, sessionSkillNames, unclassifiedNames)
|
||||
const mergedSkills = mergeNativeChatSkills(
|
||||
skills,
|
||||
sessionSkillNames,
|
||||
unclassifiedNames,
|
||||
skillSigil
|
||||
)
|
||||
const skillNames = new Set(mergedSkills.map((skill) => skill.name))
|
||||
const resolvedCommands = commands.filter(
|
||||
(command) => !(command.kindUnspecified && skillNames.has(command.name))
|
||||
(command) => !(sharedSigil && command.kindUnspecified && skillNames.has(command.name))
|
||||
)
|
||||
const commandNames = new Set(resolvedCommands.map((command) => command.name))
|
||||
const commandItems = rankItems(
|
||||
@@ -67,8 +78,9 @@ export function buildNativeChatPickerItems(
|
||||
// it is inserted verbatim; only untrusted skill text gets sanitized.
|
||||
id: `command:${command.name}`,
|
||||
name: command.name,
|
||||
token: `/${command.name}`,
|
||||
description: command.description ? sanitizePickerText(command.description, 240) : undefined,
|
||||
skillCollision: prefix === '/' && skillNames.has(command.name)
|
||||
skillCollision: sharedSigil && skillNames.has(command.name)
|
||||
},
|
||||
stableOrder: index
|
||||
})),
|
||||
@@ -76,7 +88,7 @@ export function buildNativeChatPickerItems(
|
||||
)
|
||||
const skillItems = rankItems(
|
||||
mergedSkills
|
||||
.filter((skill) => !(prefix === '/' && commandNames.has(skill.name)))
|
||||
.filter((skill) => !(sharedSigil && commandNames.has(skill.name)))
|
||||
.map((item, index) => ({ item, stableOrder: index })),
|
||||
query
|
||||
)
|
||||
@@ -89,7 +101,8 @@ export function buildNativeChatPickerItems(
|
||||
function mergeNativeChatSkills(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
sessionSkillNames: readonly string[] | undefined,
|
||||
unclassifiedNames: ReadonlySet<string>
|
||||
unclassifiedNames: ReadonlySet<string>,
|
||||
skillSigil: '/' | '$'
|
||||
): Extract<NativeChatPickerItem, { kind: 'skill' }>[] {
|
||||
const exactPaths = new Map<string, DiscoveredSkill>()
|
||||
for (const skill of skills) {
|
||||
@@ -106,7 +119,10 @@ function mergeNativeChatSkills(
|
||||
byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }])
|
||||
}
|
||||
const discovered = new Map(
|
||||
[...byName.entries()].map(([name, namedSkills]) => [name, pickerSkill(name, namedSkills)])
|
||||
[...byName.entries()].map(([name, namedSkills]) => [
|
||||
name,
|
||||
pickerSkill(name, namedSkills, skillSigil)
|
||||
])
|
||||
)
|
||||
// Why: when the running session reports its own skills, that report is the
|
||||
// authority on which ones exist — a disk scan cannot see what the session
|
||||
@@ -121,19 +137,21 @@ function mergeNativeChatSkills(
|
||||
]
|
||||
: [...discovered.keys()]
|
||||
return [...new Set(names)]
|
||||
.map((name) => discovered.get(name) ?? pickerSkill(name, []))
|
||||
.map((name) => discovered.get(name) ?? pickerSkill(name, [], skillSigil))
|
||||
.sort(comparePickerSkills)
|
||||
}
|
||||
|
||||
function pickerSkill(
|
||||
name: string,
|
||||
namedSkills: readonly DiscoveredSkill[]
|
||||
namedSkills: readonly DiscoveredSkill[],
|
||||
skillSigil: '/' | '$'
|
||||
): Extract<NativeChatPickerItem, { kind: 'skill' }> {
|
||||
const sorted = [...namedSkills].sort(compareDiscoveredSkills)
|
||||
return {
|
||||
kind: 'skill' as const,
|
||||
id: `skill:${name}`,
|
||||
name,
|
||||
token: `${skillSigil}${name}`,
|
||||
description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null,
|
||||
sources: sorted.map((skill) => ({
|
||||
sourceKind: skill.sourceKind,
|
||||
@@ -245,21 +263,27 @@ function comparePickerSkills(
|
||||
)
|
||||
}
|
||||
|
||||
// `/` is the composer's only trigger, for every agent. A draft-leading slash is
|
||||
// the one that can dispatch; elsewhere the token starts after whitespace and its
|
||||
// query stops at the next `/` so file paths stay prose.
|
||||
export const LEADING_SLASH_TRIGGER = /^\/(\S*)$/
|
||||
export const MID_PROMPT_SLASH_TRIGGER = /\s\/([^\s/]*)$/
|
||||
|
||||
/** Replaces the typed `/token` with the item's own token, which for a skill is
|
||||
* the agent-native form even though every agent is typed the same way. */
|
||||
export function applyPickerSuggestion(
|
||||
draft: string,
|
||||
caret: number,
|
||||
item: NativeChatPickerItem,
|
||||
prefix: '/' | '$'
|
||||
item: NativeChatPickerItem
|
||||
): { draft: string; caret: number; insertedToken: string } {
|
||||
const before = draft.slice(0, caret)
|
||||
const after = draft.slice(caret)
|
||||
const match = prefix === '/' ? before.match(/^\/(\S*)$/) : before.match(/(^|\s)\$(\S*)$/)
|
||||
const match = before.match(LEADING_SLASH_TRIGGER) ?? before.match(MID_PROMPT_SLASH_TRIGGER)
|
||||
if (!match) {
|
||||
return { draft, caret, insertedToken: '' }
|
||||
}
|
||||
const query = match.at(-1) ?? ''
|
||||
const tokenStart = before.length - query.length - 1
|
||||
const insertedToken = `${prefix}${item.name}`
|
||||
const nextBefore = `${before.slice(0, tokenStart)}${insertedToken} `
|
||||
return { draft: nextBefore + after, caret: nextBefore.length, insertedToken }
|
||||
const nextBefore = `${before.slice(0, tokenStart)}${item.token} `
|
||||
return { draft: nextBefore + after, caret: nextBefore.length, insertedToken: item.token }
|
||||
}
|
||||
|
||||
+20
-12
@@ -1,8 +1,8 @@
|
||||
// The background-tasks strip's view of one session's wire state.
|
||||
//
|
||||
// The strip stands for work that OUTLIVED a turn, not work in flight: a running
|
||||
// turn already has the working status, the turn activity line, and its own
|
||||
// durable rows, so the strip is suppressed while one is open.
|
||||
// The strip stands for work that OUTLIVES a turn. It stays mounted through a
|
||||
// running turn — a fan-out's children keep reporting long after the parent
|
||||
// settles — but only an idle session lets it animate or speak for itself.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
@@ -10,22 +10,30 @@ import type {
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
|
||||
export type StructuredSessionBackgroundTasksView = {
|
||||
isMonitoringBackgroundTasks: boolean
|
||||
backgroundTasks: readonly AgentSessionBackgroundTask[]
|
||||
supportsBackgroundTaskStop: boolean
|
||||
supportsBackgroundTaskStopAll: boolean
|
||||
/** The strip renders whenever the host reports monitoring — mid-turn included. */
|
||||
show: boolean
|
||||
/** Idle-only: gates the animated monitoring indicator and conversation
|
||||
* commands, never the strip itself. A running turn owns the voice. */
|
||||
isMonitoring: boolean
|
||||
tasks: AgentSessionBackgroundTask[]
|
||||
settledTasks: AgentSessionBackgroundTask[]
|
||||
supportsStop: boolean
|
||||
supportsStopAll: boolean
|
||||
}
|
||||
|
||||
export function structuredSessionBackgroundTasksView(
|
||||
state: AgentSessionBackgroundTaskState | null | undefined,
|
||||
backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
|
||||
turnId: string | null
|
||||
): StructuredSessionBackgroundTasksView {
|
||||
const monitoring = backgroundTasks?.state === 'monitoring'
|
||||
return {
|
||||
isMonitoringBackgroundTasks: turnId === null && state?.state === 'monitoring',
|
||||
backgroundTasks: state?.tasks ?? [],
|
||||
supportsBackgroundTaskStop: state?.supportsTaskStop === true,
|
||||
show: monitoring,
|
||||
isMonitoring: turnId === null && monitoring,
|
||||
tasks: backgroundTasks?.tasks ?? [],
|
||||
settledTasks: backgroundTasks?.settledTasks ?? [],
|
||||
supportsStop: backgroundTasks?.supportsTaskStop === true,
|
||||
// Absent means the host predates the field and does accept an untargeted
|
||||
// stop; only a host that says `false` has none to offer.
|
||||
supportsBackgroundTaskStopAll: state?.supportsStopAll !== false
|
||||
supportsStopAll: backgroundTasks?.supportsStopAll !== false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co
|
||||
items,
|
||||
triggerKey: '/',
|
||||
prefix: '/',
|
||||
dispatchable: true,
|
||||
grouped: true,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: true,
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state'
|
||||
import {
|
||||
applyPickerSuggestion,
|
||||
deriveComposerAutocomplete,
|
||||
EMPTY_HISTORY,
|
||||
type ComposerAutocomplete
|
||||
} from './native-chat-composer-state'
|
||||
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
|
||||
|
||||
const COMMAND = {
|
||||
kind: 'command' as const,
|
||||
id: 'command:clear',
|
||||
name: 'clear',
|
||||
token: '/clear',
|
||||
description: 'Clear history',
|
||||
skillCollision: false
|
||||
}
|
||||
@@ -20,6 +27,7 @@ function picker(items = [COMMAND]): Extract<ComposerAutocomplete, { mode: 'slash
|
||||
items,
|
||||
triggerKey: '/:0',
|
||||
prefix: '/',
|
||||
dispatchable: true,
|
||||
grouped: false,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: false,
|
||||
@@ -27,7 +35,7 @@ function picker(items = [COMMAND]): Extract<ComposerAutocomplete, { mode: 'slash
|
||||
}
|
||||
}
|
||||
|
||||
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false) {
|
||||
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false, draft = '/') {
|
||||
const callbacks = {
|
||||
completePickerItem: vi.fn(),
|
||||
dispatchPickerCommand: vi.fn(),
|
||||
@@ -43,7 +51,7 @@ function setup(autocomplete: ComposerAutocomplete = picker(), composing = false)
|
||||
useNativeChatComposerKeyDown({
|
||||
autocomplete,
|
||||
activeSuggestion: 0,
|
||||
draft: '/',
|
||||
draft,
|
||||
history: EMPTY_HISTORY,
|
||||
isComposing: () => composing,
|
||||
...callbacks
|
||||
@@ -81,6 +89,36 @@ describe('useNativeChatComposerKeyDown', () => {
|
||||
expect(callbacks.send).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['claude', 'openclaude', 'codex', 'grok'] as const)(
|
||||
'completes mid-prompt command Enter without dispatching or losing prose for %s',
|
||||
(agent) => {
|
||||
const draft = 'Explain /cle before continuing'
|
||||
const caret = 'Explain /cle'.length
|
||||
const autocomplete = deriveComposerAutocomplete(
|
||||
draft,
|
||||
caret,
|
||||
[COMMAND],
|
||||
[],
|
||||
getNativeChatAgentProfile(agent)
|
||||
)
|
||||
expect(autocomplete.mode).toBe('slash')
|
||||
const { handler, callbacks } = setup(autocomplete, false, draft)
|
||||
const event = keyEvent('Enter')
|
||||
handler(event as never)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled()
|
||||
expect(callbacks.send).not.toHaveBeenCalled()
|
||||
expect(callbacks.completePickerItem).toHaveBeenCalledOnce()
|
||||
const [item] = callbacks.completePickerItem.mock.calls[0]
|
||||
expect(applyPickerSuggestion(draft, caret, item)).toEqual({
|
||||
draft: 'Explain /clear before continuing',
|
||||
caret: 'Explain /clear '.length,
|
||||
insertedToken: '/clear'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('dismisses Escape without interrupting the agent', () => {
|
||||
const { handler, callbacks } = setup()
|
||||
handler(keyEvent('Escape') as never)
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useNativeChatComposerKeyDown({
|
||||
return
|
||||
}
|
||||
|
||||
if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') {
|
||||
if (autocomplete.mode === 'slash') {
|
||||
const items = autocomplete.items
|
||||
if (event.key === 'ArrowDown' && items.length > 0) {
|
||||
event.preventDefault()
|
||||
@@ -66,7 +66,9 @@ export function useNativeChatComposerKeyDown({
|
||||
if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) {
|
||||
event.preventDefault()
|
||||
const item = items[activeSuggestion] ?? items[0]
|
||||
if (event.key === 'Enter' && item.kind === 'command') {
|
||||
// A mid-prompt command is part of the sentence being written, so Enter
|
||||
// completes the token instead of sending the command on its own.
|
||||
if (event.key === 'Enter' && item.kind === 'command' && autocomplete.dispatchable) {
|
||||
dispatchPickerCommand(item)
|
||||
} else {
|
||||
completePickerItem(item)
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ const COMMAND = {
|
||||
kind: 'command' as const,
|
||||
id: 'command:status',
|
||||
name: 'status',
|
||||
token: '/status',
|
||||
description: 'Show status',
|
||||
skillCollision: false
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
classifyNativeChatSend,
|
||||
deriveComposerAutocomplete,
|
||||
editReplacesTriggerToken,
|
||||
isSkillPickerTriggered,
|
||||
type ComposerAutocomplete,
|
||||
type NativeChatPickerItem,
|
||||
type NativeChatSendClassification
|
||||
@@ -68,13 +69,7 @@ export function useNativeChatPickerState(args: {
|
||||
setActiveSuggestion
|
||||
} = args
|
||||
const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent])
|
||||
const beforeCaret = draft.slice(0, caret)
|
||||
const skillPickerTriggered =
|
||||
profile?.skillPrefix === '$'
|
||||
? /(?:^|\s)\$\S*$/.test(beforeCaret)
|
||||
: profile?.skillPrefix === '/'
|
||||
? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret)
|
||||
: false
|
||||
const skillPickerTriggered = isSkillPickerTriggered(draft.slice(0, caret), profile)
|
||||
const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered)
|
||||
const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}`
|
||||
const dismissalContext = `${draftScopeKey}:${agent}`
|
||||
@@ -114,7 +109,7 @@ export function useNativeChatPickerState(args: {
|
||||
}, [dismissalContext])
|
||||
|
||||
useEffect(() => {
|
||||
if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') {
|
||||
if (autocomplete.mode !== 'slash') {
|
||||
lastOpenKeyRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -127,10 +122,10 @@ export function useNativeChatPickerState(args: {
|
||||
|
||||
const completeItem = useCallback(
|
||||
(item: NativeChatPickerItem) => {
|
||||
if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') {
|
||||
if (autocomplete.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix)
|
||||
const result = applyPickerSuggestion(draft, caret, item)
|
||||
if (item.kind === 'skill' && textareaRef.current?.insertSkill) {
|
||||
const from = result.caret - result.insertedToken.length - 1
|
||||
textareaRef.current.insertSkill(from, caret, result.insertedToken)
|
||||
@@ -174,10 +169,7 @@ export function useNativeChatPickerState(args: {
|
||||
null,
|
||||
sessionSkillNames
|
||||
)
|
||||
if (
|
||||
(next.mode !== 'slash' && next.mode !== 'skill') ||
|
||||
next.triggerKey !== dismissed.triggerKey
|
||||
) {
|
||||
if (next.mode !== 'slash' || next.triggerKey !== dismissed.triggerKey) {
|
||||
setDismissed(null)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatSkillDiscovery } from './use-native-chat-skills'
|
||||
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { isSkillPickerTriggered } from './native-chat-composer-state'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
@@ -56,6 +58,10 @@ function Probe({ enabled }: { enabled: boolean }): null {
|
||||
return null
|
||||
}
|
||||
|
||||
function DraftProbe({ draft }: { draft: string }): React.JSX.Element {
|
||||
return <Probe enabled={isSkillPickerTriggered(draft, getNativeChatAgentProfile('codex'))} />
|
||||
}
|
||||
|
||||
describe('useNativeChatSkills', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = stateForHost('local')
|
||||
@@ -132,6 +138,24 @@ describe('useNativeChatSkills', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses one discovery while typing and reopening leading and mid-prompt slash tokens', async () => {
|
||||
const view = render(<DraftProbe draft="Explain" />)
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(<DraftProbe draft="Explain /" />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
|
||||
for (const draft of ['Explain /b', 'Explain /br', 'Explain /bro']) {
|
||||
view.rerender(<DraftProbe draft={draft} />)
|
||||
expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser'])
|
||||
}
|
||||
view.rerender(<DraftProbe draft="Explain $browser " />)
|
||||
expect(mocks.snapshots.at(-1)?.status).toBe('idle')
|
||||
view.rerender(<DraftProbe draft="/" />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
|
||||
view.rerender(<DraftProbe draft="/bro" />)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces discovery failure instead of remaining loading', async () => {
|
||||
mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed'))
|
||||
render(<Probe enabled />)
|
||||
|
||||
@@ -91,7 +91,7 @@ export function useStructuredAgentSession(args: {
|
||||
() => selectStructuredAgentTurnActivity(state.items, turnId, state.activity),
|
||||
[state.activity, state.items, turnId]
|
||||
)
|
||||
const backgroundTasksView = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId)
|
||||
const backgroundTasks = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !optionCatalog) {
|
||||
@@ -191,12 +191,7 @@ export function useStructuredAgentSession(args: {
|
||||
conversationCommands.sendStructuredConversationCommand({
|
||||
command,
|
||||
pending: commandPending,
|
||||
blocked: Boolean(
|
||||
turnId ||
|
||||
prompts.length ||
|
||||
backgroundTasksView.isMonitoringBackgroundTasks ||
|
||||
outbox.length
|
||||
),
|
||||
blocked: Boolean(turnId || prompts.length || backgroundTasks.isMonitoring || outbox.length),
|
||||
send: (command) =>
|
||||
mutate<AgentSessionConversationCommandResult>(
|
||||
'agentSession.conversationCommand',
|
||||
@@ -219,7 +214,7 @@ export function useStructuredAgentSession(args: {
|
||||
retry: outboxController.retry,
|
||||
isWorking,
|
||||
turnActivity,
|
||||
...backgroundTasksView,
|
||||
backgroundTasks,
|
||||
turnId,
|
||||
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
|
||||
stopBackgroundTask: (taskId?: string) =>
|
||||
|
||||
@@ -17138,8 +17138,34 @@
|
||||
"command": "Background command",
|
||||
"monitor": "Background monitor",
|
||||
"task": "Background task",
|
||||
"runningList": "Running background tasks",
|
||||
"detailsUnavailable": "Task details are unavailable for this session."
|
||||
"detailsUnavailable": "Task details are unavailable for this session.",
|
||||
"countAgentsOne": "1 agent",
|
||||
"countAgentsMany": "{{value0}} agents",
|
||||
"countShellOne": "1 shell",
|
||||
"countShellMany": "{{value0}} shells",
|
||||
"countShellCommandOne": "1 shell command",
|
||||
"countMonitorsOne": "1 monitor",
|
||||
"countMonitorsMany": "{{value0}} monitors",
|
||||
"countWorkflowsOne": "1 workflow",
|
||||
"countWorkflowsMany": "{{value0}} workflows",
|
||||
"countTasksOne": "1 task",
|
||||
"countTasksMany": "{{value0}} tasks",
|
||||
"headerTotal": "{{value0}} background tasks",
|
||||
"stateWorking": "working",
|
||||
"stateMonitoring": "monitoring",
|
||||
"stateWaiting": "waiting",
|
||||
"stateBlocked": "blocked",
|
||||
"stateDone": "done",
|
||||
"stateIdle": "stopped",
|
||||
"stateUnverifiable": "unverifiable",
|
||||
"reasonWaiting": "needs approval",
|
||||
"reasonUnverifiable": "no contact",
|
||||
"reasonBlocked": "failed",
|
||||
"groupAgents": "Agents",
|
||||
"groupShell": "Shell",
|
||||
"groupMonitors": "Monitors",
|
||||
"groupWorkflows": "Workflows",
|
||||
"groupTasks": "Tasks"
|
||||
},
|
||||
"jumpToLatest": "Jump to latest",
|
||||
"toggle": {
|
||||
|
||||
@@ -56,16 +56,39 @@ export type AgentSessionHandoffRequest = {
|
||||
|
||||
export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus }
|
||||
|
||||
/** Per-task run state, reusing the agent-state vocabulary the dashboard already
|
||||
* renders. Optional on the wire: an old host sends none and clients fall back
|
||||
* to kind-derived defaults. */
|
||||
export type AgentSessionBackgroundTaskRunState =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'waiting'
|
||||
| 'blocked'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'unverifiable'
|
||||
|
||||
export type AgentSessionBackgroundTask = {
|
||||
id: string
|
||||
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
|
||||
description?: string
|
||||
/** Provider-reported identity (e.g. a subagent type). `description` stays the
|
||||
* display name; this is the fallback when the description is absent. */
|
||||
name?: string
|
||||
state?: AgentSessionBackgroundTaskRunState
|
||||
/** Host epoch ms when the task was first observed, so clients render elapsed. */
|
||||
startedAt?: number
|
||||
/** Cumulative provider-reported token usage, where the provider supplies it. */
|
||||
totalTokens?: number
|
||||
}
|
||||
|
||||
export type AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring'
|
||||
/** Optional so mixed-version clients can consume state-only hosts. */
|
||||
tasks?: AgentSessionBackgroundTask[]
|
||||
/** Terminal-state siblings of a still-live roster, kept apart from `tasks`
|
||||
* so old clients keep rendering exactly the live set they render today. */
|
||||
settledTasks?: AgentSessionBackgroundTask[]
|
||||
/** Optional so clients only send targeted stops to hosts that accept them. */
|
||||
supportsTaskStop?: boolean
|
||||
/** Whether an untargeted "stop everything" is available at all. Absent means
|
||||
@@ -75,6 +98,37 @@ export type AgentSessionBackgroundTaskState = {
|
||||
supportsStopAll?: boolean
|
||||
}
|
||||
|
||||
function backgroundTaskFieldsEqual(
|
||||
left: AgentSessionBackgroundTask,
|
||||
right: AgentSessionBackgroundTask
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id &&
|
||||
left.kind === right.kind &&
|
||||
left.description === right.description &&
|
||||
left.name === right.name &&
|
||||
left.state === right.state &&
|
||||
left.startedAt === right.startedAt &&
|
||||
left.totalTokens === right.totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
/** Field equality for task lists, shared by the client reducer and the host
|
||||
* status feed so a publish whose only change is one task's state is never
|
||||
* judged equal and dropped. */
|
||||
export function agentSessionBackgroundTasksEqual(
|
||||
left: AgentSessionBackgroundTask[] | undefined,
|
||||
right: AgentSessionBackgroundTask[] | undefined
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((task, index) => backgroundTaskFieldsEqual(task, right[index]))
|
||||
}
|
||||
|
||||
export type AgentSessionTurnActivity = {
|
||||
turnId: string
|
||||
text: string
|
||||
@@ -211,6 +265,10 @@ export type AgentSessionStatusSummary = {
|
||||
toolInput?: string
|
||||
/** Preview of the newest assistant prose, so a settled row says what the agent said. */
|
||||
lastAssistantMessage?: string
|
||||
/** Live provider-owned background tasks, so session lists can render
|
||||
* subagent children without holding a journal reader open. Optional for
|
||||
* mixed-version hosts. */
|
||||
backgroundTasks?: AgentSessionBackgroundTask[]
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -2,24 +2,23 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getNativeChatAgentProfile } from './native-chat-agent-profiles'
|
||||
|
||||
describe('native chat agent picker profiles', () => {
|
||||
it('keeps Codex dollar skills separate from slash commands', () => {
|
||||
// The composer types the same `/` for every agent; skillPrefix is only the
|
||||
// form a picked skill is written as.
|
||||
it('keeps Codex skills invocable as dollar tokens', () => {
|
||||
expect(getNativeChatAgentProfile('codex')).toMatchObject({
|
||||
skillPrefix: '$',
|
||||
groupedSlash: false,
|
||||
skillSourceOwner: 'codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('groups Claude-family and Grok skills under slash', () => {
|
||||
it('writes Claude-family and Grok skills as slash tokens', () => {
|
||||
expect(getNativeChatAgentProfile('claude')).toMatchObject({
|
||||
skillPrefix: '/',
|
||||
groupedSlash: true,
|
||||
skillSourceOwner: 'claude'
|
||||
})
|
||||
expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' })
|
||||
expect(getNativeChatAgentProfile('grok')).toMatchObject({
|
||||
skillPrefix: '/',
|
||||
groupedSlash: true,
|
||||
skillSourceOwner: 'grok'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-cha
|
||||
|
||||
export type NativeChatAgentProfile = {
|
||||
skillPrefix: '$' | '/'
|
||||
groupedSlash: boolean
|
||||
/** OpenClaude reads Claude-owned roots, so this can differ from the agent. */
|
||||
skillSourceOwner: AgentType
|
||||
}
|
||||
@@ -11,22 +10,18 @@ export type NativeChatAgentProfile = {
|
||||
const NATIVE_CHAT_AGENT_PROFILES: Partial<Record<AgentType, NativeChatAgentProfile>> = {
|
||||
codex: {
|
||||
skillPrefix: '$',
|
||||
groupedSlash: false,
|
||||
skillSourceOwner: 'codex'
|
||||
},
|
||||
claude: {
|
||||
skillPrefix: '/',
|
||||
groupedSlash: true,
|
||||
skillSourceOwner: 'claude'
|
||||
},
|
||||
openclaude: {
|
||||
skillPrefix: '/',
|
||||
groupedSlash: true,
|
||||
skillSourceOwner: 'claude'
|
||||
},
|
||||
grok: {
|
||||
skillPrefix: '/',
|
||||
groupedSlash: true,
|
||||
skillSourceOwner: 'grok'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +385,77 @@ describe('structured agent session reducer', () => {
|
||||
expect(changed.items).toBe(monitoring.items)
|
||||
})
|
||||
|
||||
it('applies a publication whose only change is one task state or settled roster', () => {
|
||||
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: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
const batch = (backgroundTasks: NonNullable<typeof monitoring.backgroundTasks>) =>
|
||||
reduceStructuredAgentSession(monitoring, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'batch',
|
||||
sessionId: 'session-a',
|
||||
batch: { cursor: monitoring.cursor!, items: [], removedItemIds: [], submissions: [] },
|
||||
fence: 1,
|
||||
backgroundTasks
|
||||
}
|
||||
})
|
||||
|
||||
const stateOnly = batch({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', startedAt: 100 }
|
||||
]
|
||||
})
|
||||
expect(stateOnly).not.toBe(monitoring)
|
||||
expect(stateOnly.backgroundTasks?.tasks?.[0]?.state).toBe('waiting')
|
||||
|
||||
const settledOnly = batch({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
|
||||
],
|
||||
settledTasks: [{ id: 'task-2', kind: 'agent', state: 'done', startedAt: 50 }]
|
||||
})
|
||||
expect(settledOnly).not.toBe(monitoring)
|
||||
expect(settledOnly.backgroundTasks?.settledTasks).toHaveLength(1)
|
||||
|
||||
const tokensOnly = batch({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
kind: 'agent',
|
||||
name: 'deep_review',
|
||||
state: 'working',
|
||||
startedAt: 100,
|
||||
totalTokens: 18_130
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(tokensOnly.backgroundTasks?.tasks?.[0]?.totalTokens).toBe(18_130)
|
||||
|
||||
const unchanged = batch({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
|
||||
]
|
||||
})
|
||||
expect(unchanged).toBe(monitoring)
|
||||
})
|
||||
|
||||
it('clears additive background state when a replacement snapshot omits the field', () => {
|
||||
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'event',
|
||||
|
||||
@@ -3,13 +3,14 @@ import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from './agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionBackgroundTaskState,
|
||||
AgentSessionSlashCommand,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryPage,
|
||||
AgentSessionSubscribeEvent,
|
||||
AgentSessionTurnActivity
|
||||
import {
|
||||
agentSessionBackgroundTasksEqual,
|
||||
type AgentSessionBackgroundTaskState,
|
||||
type AgentSessionSlashCommand,
|
||||
type AgentSessionHandoffStatus,
|
||||
type AgentSessionHistoryPage,
|
||||
type AgentSessionSubscribeEvent,
|
||||
type AgentSessionTurnActivity
|
||||
} from './agent-session-wire'
|
||||
|
||||
export type StructuredAgentSessionState = {
|
||||
@@ -70,17 +71,9 @@ function backgroundTaskStatesEqual(
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (left.tasks === right.tasks) {
|
||||
return true
|
||||
}
|
||||
if (!left.tasks || !right.tasks || left.tasks.length !== right.tasks.length) {
|
||||
return false
|
||||
}
|
||||
return left.tasks.every(
|
||||
(task, index) =>
|
||||
task.id === right.tasks?.[index]?.id &&
|
||||
task.kind === right.tasks[index]?.kind &&
|
||||
task.description === right.tasks[index]?.description
|
||||
return (
|
||||
agentSessionBackgroundTasksEqual(left.tasks, right.tasks) &&
|
||||
agentSessionBackgroundTasksEqual(left.settledTasks, right.settledTasks)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user