Merge remote-tracking branch 'origin/main' into brennanb2025/fix-dispatch-admission-settlement

# Conflicts:
#	src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts
#	src/main/native-chat/agent-session-journal/journal-store.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts
This commit is contained in:
Merge Sim
2026-09-10 01:52:25 -07:00
87 changed files with 4039 additions and 861 deletions
+4 -4
View File
@@ -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

+1
View File
@@ -57,6 +57,7 @@
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0",
"react-native-svg": "^15.15.4",
"react-native-uitextview": "2.2.0",
"react-native-web": "^0.21.2",
"react-native-webview": "13.16.2",
"react-native-worklets": "^0.8.3",
+14
View File
@@ -136,6 +136,9 @@ importers:
react-native-svg:
specifier: ^15.15.4
version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
react-native-uitextview:
specifier: 2.2.0
version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
react-native-web:
specifier: ^0.21.2
version: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -6158,6 +6161,12 @@ packages:
react: '*'
react-native: '*'
react-native-uitextview@2.2.0:
resolution: {integrity: sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==}
peerDependencies:
react: '*'
react-native: '*'
react-native-web@0.21.2:
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
peerDependencies:
@@ -14609,6 +14618,11 @@ snapshots:
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
warn-once: 0.1.1
react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@babel/runtime': 7.29.2
+111 -44
View File
@@ -1,5 +1,22 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, Text, View } from 'react-native'
import { MobileSelectableText } from './MobileSelectableText'
import {
Fragment,
createElement,
createContext,
memo,
useContext,
useMemo,
type ComponentType,
type ReactNode
} from 'react'
import {
Linking,
Pressable,
ScrollView,
Text as NativeText,
View,
type TextProps
} from 'react-native'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { styles } from './mobile-markdown-styles'
import {
@@ -19,6 +36,8 @@ import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
type Props = {
content?: string
fallback?: string
/** Enables iOS range selection for native-chat transcript prose. */
rangeSelectable?: boolean
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
@@ -33,6 +52,12 @@ const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
const MarkdownTextContext = createContext<ComponentType<TextProps>>(NativeText)
function MarkdownText(props: TextProps): React.JSX.Element {
const TextComponent = useContext(MarkdownTextContext)
return createElement(TextComponent, props)
}
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
@@ -64,13 +89,13 @@ function renderTextRun(
return segments.map((segment, segmentIndex) => {
if (segment.type === 'file') {
return (
<Text
<MarkdownText
key={`${keyPrefix}:${segmentIndex}`}
style={styles.link}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</Text>
</MarkdownText>
)
}
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
@@ -105,22 +130,34 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(image[2]!, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(image[2]!, onOpenFile)}
>
{image[1] || 'image'}
</Text>
</MarkdownText>
)
} else if (link) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(link[2]!, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(link[2]!, onOpenFile)}
>
{link[1]}
</Text>
</MarkdownText>
)
} else if (/^https?:\/\//i.test(token)) {
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(url, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(url, onOpenFile)}
>
{url}
</Text>
</MarkdownText>
)
if (trailing) {
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
@@ -129,38 +166,38 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
parts.push(
<Text
<MarkdownText
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
>
{code}
</Text>
</MarkdownText>
)
} else {
parts.push(
<Text key={key} style={styles.inlineCode}>
<MarkdownText key={key} style={styles.inlineCode}>
{code}
</Text>
</MarkdownText>
)
}
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
<MarkdownText key={key} style={styles.strike}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
<Text key={key} style={styles.bold}>
<MarkdownText key={key} style={styles.bold}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
} else {
parts.push(
<Text key={key} style={styles.italic}>
<MarkdownText key={key} style={styles.italic}>
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
}
}
@@ -171,7 +208,13 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
return parts
}
function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) {
function MobileMarkdownContent({
content,
fallback = '',
rangeSelectable = false,
textScale = 1,
onOpenFile
}: Props) {
const text = content?.trim() ?? ''
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
@@ -181,30 +224,35 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
const proseScale = scaled(13)
const listScale = scaled(14)
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
return fallback ? (
<MarkdownText selectable={rangeSelectable} style={styles.paragraph}>
{fallback}
</MarkdownText>
) : null
}
const mermaidSourceOccurrences = new Map<string, number>()
// Native-chat range selection is set on each block; nested inline spans inherit it.
return (
<View style={styles.root}>
{blocks.map((block, index) => {
if (block.type === 'heading') {
return (
<Text
<MarkdownText
key={index}
selectable
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text, onOpenFile)}
</Text>
</MarkdownText>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text selectable style={styles.quoteText}>
<MarkdownText selectable style={styles.quoteText}>
{renderInline(block.text, onOpenFile)}
</Text>
</MarkdownText>
</View>
)
}
@@ -225,10 +273,12 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
}
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
<Text selectable style={styles.codeText}>
{block.language ? (
<NativeText style={styles.codeLanguage}>{block.language}</NativeText>
) : null}
<MarkdownText selectable style={styles.codeText}>
{block.text}
</Text>
</MarkdownText>
</View>
)
}
@@ -239,10 +289,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
style={styles.imageFrame}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
>
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
<Text style={styles.imageCaption} numberOfLines={1}>
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
{block.url}
</Text>
</NativeText>
</Pressable>
)
}
@@ -256,26 +306,30 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<View style={styles.table}>
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} selectable style={[styles.tableCell, styles.tableHeader]}>
<MarkdownText
key={cellIndex}
selectable
style={[styles.tableCell, styles.tableHeader]}
>
{renderInline(header, onOpenFile)}
</Text>
</MarkdownText>
))}
</View>
{visibleRows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} selectable style={styles.tableCell}>
<MarkdownText key={cellIndex} selectable style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</Text>
</MarkdownText>
))}
</View>
))}
{hiddenRows > 0 || hiddenColumns > 0 ? (
<Text style={styles.tableTruncated}>
<NativeText style={styles.tableTruncated}>
{hiddenRows > 0 ? `${hiddenRows} more rows` : ''}
{hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''}
{hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''}
</Text>
</NativeText>
) : null}
</View>
</ScrollView>
@@ -286,7 +340,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<View key={index} style={styles.list}>
{block.items.map((item, itemIndex) => (
<View key={itemIndex} style={styles.listItem}>
<Text style={styles.listMarker}>
<NativeText style={styles.listMarker}>
{item.checked == null
? block.ordered
? `${itemIndex + 1}.`
@@ -294,10 +348,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
: item.checked
? '[x]'
: '[ ]'}
</Text>
<Text selectable style={[styles.listText, listScale]}>
</NativeText>
<MarkdownText selectable style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</Text>
</MarkdownText>
</View>
))}
</View>
@@ -307,18 +361,31 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
return <View key={index} style={styles.rule} />
}
return (
<Text key={index} style={[styles.paragraph, proseScale]}>
<MarkdownText
key={index}
selectable={rangeSelectable}
style={[styles.paragraph, proseScale]}
>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line, onOpenFile)}
</Fragment>
))}
</Text>
</MarkdownText>
)
})}
</View>
)
}
function MobileMarkdownInner(props: Props): React.JSX.Element | null {
const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText
return (
<MarkdownTextContext.Provider value={TextComponent}>
<MobileMarkdownContent {...props} />
</MarkdownTextContext.Provider>
)
}
export const MobileMarkdown = memo(MobileMarkdownInner)
@@ -0,0 +1,38 @@
import { Children, Fragment, isValidElement, type ReactNode } from 'react'
import { StyleSheet, Text, UIManager, type TextProps } from 'react-native'
import { UITextView } from 'react-native-uitextview'
// Older development clients can load this bundle before rebuilding their native views.
const hasRangeSelection = UIManager.hasViewManagerConfig('RNUITextView')
function flattenFragments(children: ReactNode): ReactNode[] {
return (
Children.map(children, (child) =>
isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment
? flattenFragments(child.props.children)
: child
) ?? []
)
}
export function MobileSelectableText({ children, style, ...props }: TextProps): React.JSX.Element {
if (!hasRangeSelection) {
return (
<Text {...props} style={style}>
{children}
</Text>
)
}
// The native span adapter otherwise maps numeric bold to semibold.
const textStyle = StyleSheet.flatten(style)
const nativeStyle =
textStyle?.fontWeight === '700' || textStyle?.fontWeight === 700
? { ...textStyle, fontWeight: 'bold' as const }
: style
return (
<UITextView {...props} uiTextView style={nativeStyle}>
{flattenFragments(children)}
</UITextView>
)
}
@@ -0,0 +1 @@
export { Text as MobileSelectableText } from 'react-native'
@@ -0,0 +1,110 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
type TestNode = {
type: string
props: Record<string, unknown>
children: (TestNode | string)[] | null
}
/** Every Text that is not nested inside another Text, paired with the full prose
* it renders. Nested inline spans inherit selection, so only these carry it. */
function outermostTextNodes(
node: TestNode | string,
insideText = false
): { text: string; selectable: boolean }[] {
if (typeof node === 'string') {
return []
}
const children = node.children ?? []
if (node.type === 'Text' && !insideText) {
return [{ text: flattenText(node), selectable: node.props.selectable === true }]
}
return children.flatMap((child) => outermostTextNodes(child, insideText || node.type === 'Text'))
}
function flattenText(node: TestNode | string): string {
if (typeof node === 'string') {
return node
}
return (node.children ?? []).map(flattenText).join('')
}
function renderMarkdown(props: Parameters<typeof MobileMarkdown>[0]): TestNode {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(MobileMarkdown, { rangeSelectable: true, ...props }))
})
const tree = renderer!.toJSON() as unknown as TestNode
act(() => renderer!.unmount())
return tree
}
function selectableFor(tree: TestNode, needle: string): boolean {
const match = outermostTextNodes(tree).find((entry) => entry.text.includes(needle))
if (!match) {
throw new Error(`no Text rendered "${needle}"`)
}
return match.selectable
}
describe('MobileMarkdown selection', () => {
afterEach(() => vi.clearAllMocks())
// Paragraphs are the default block for agent prose, and were the one block
// type left non-selectable when the others gained it.
it.each([
['paragraph', 'Paragraph prose here.'],
['heading', 'Heading prose'],
['quote', 'Quote prose'],
['code', 'const code = 1'],
['list item', 'List item prose'],
['table header', 'Head A'],
['table cell', 'Cell A']
])('makes %s prose selectable', (_label, needle) => {
const content = [
'# Heading prose',
'',
'Paragraph prose here.',
'',
'> Quote prose',
'',
'```ts',
'const code = 1',
'```',
'',
'- List item prose',
'',
'| Head A | Head B |',
'| --- | --- |',
'| Cell A | Cell B |'
].join('\n')
expect(selectableFor(renderMarkdown({ content }), needle)).toBe(true)
})
it('makes the empty-content fallback selectable', () => {
const tree = renderMarkdown({ content: '', fallback: 'Fallback prose' })
expect(selectableFor(tree, 'Fallback prose')).toBe(true)
})
it('keeps inline spans inside their selectable block rather than splitting it', () => {
const tree = renderMarkdown({ content: 'Prose with `code` and **bold** inline.' })
const blocks = outermostTextNodes(tree)
expect(blocks).toHaveLength(1)
expect(blocks[0]!.selectable).toBe(true)
expect(blocks[0]!.text).toBe('Prose with code and bold inline.')
})
})
@@ -0,0 +1,198 @@
import { createElement, Fragment } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => ({ available: true }))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
UIManager: { hasViewManagerConfig: () => native.available },
Linking: { openURL: vi.fn() },
Text: 'Text',
View: 'View',
ScrollView: 'ScrollView',
Pressable: 'Pressable',
StyleSheet: {
create: (styles: unknown) => styles,
flatten: (style: unknown): object =>
Array.isArray(style)
? Object.assign({}, ...style.flat(Infinity).filter(Boolean))
: (style ?? {}),
hairlineWidth: 1
}
}))
vi.mock('react-native/Libraries/Utilities/codegenNativeComponent', () => ({
default: (name: string) => name
}))
// Exercise the dependency's real span conversion without a native runtime.
vi.mock('react-native-uitextview', () => import('react-native-uitextview/src/Text'))
vi.mock('./MobileSelectableText', () => import('./MobileSelectableText.ios'))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
let renderer: ReactTestRenderer | undefined
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
native.available = true
vi.resetModules()
vi.restoreAllMocks()
})
function render(element: React.ReactElement): ReactTestRenderer {
act(() => {
renderer = create(element)
})
return renderer!
}
function nodes(tree: ReactTestRenderer, name: string) {
return tree.root.findAll((node) => node.type === name)
}
describe('iOS selectable text boundary', () => {
it('preserves line-scoped keys when repeated inline spans update or disappear', async () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
const { MobileMarkdown } = await import('./MobileMarkdown')
const onOpenFile = vi.fn()
const line = '**same** [file](src/main.ts)'
const tree = render(
createElement(MobileMarkdown, {
content: `${line}\n${line}`,
rangeSelectable: true,
onOpenFile
})
)
expect(
nodes(tree, 'RNUITextViewChild')
.map((node) => node.props.text)
.join('')
).toBe('same file\nsame file')
act(() =>
tree.update(
createElement(MobileMarkdown, {
content: `${line}\n**changed** [file](src/main.ts)`,
rangeSelectable: true,
onOpenFile
})
)
)
expect(
nodes(tree, 'RNUITextViewChild')
.map((node) => node.props.text)
.join('')
).toBe('same file\nchanged file')
act(() =>
tree.update(
createElement(MobileMarkdown, { content: line, rangeSelectable: true, onOpenFile })
)
)
const spans = nodes(tree, 'RNUITextViewChild')
expect(spans.map((node) => node.props.text).join('')).toBe('same file')
act(() => spans.find((node) => node.props.text === 'file')!.props.onPress())
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('src/main.ts')
expect(errors.mock.calls.filter((args) => String(args[0]).includes('same key'))).toEqual([])
})
it.each([
['500', 'medium'],
['600', 'semibold'],
['700', 'bold']
] as const)('preserves font weight %s', async (fontWeight, expected) => {
const { MobileSelectableText: Text } = await import('./MobileSelectableText.ios')
const tree = render(createElement(Text, { selectable: true, style: { fontWeight } }, 'Weight'))
expect(nodes(tree, 'RNUITextViewChild')[0]!.props.style.fontWeight).toBe(expected)
})
it('keeps fragments, arrays, newlines and nested styles in one native root', async () => {
const { MobileSelectableText: Text } = await import('./MobileSelectableText.ios')
const tree = render(
createElement(
Text,
{ selectable: true, style: { fontSize: 18 } },
createElement(Fragment, null, 'Before ', ['one', '\n']),
createElement(Text, { style: { fontWeight: '700' } }, 'bold'),
createElement(Text, { style: { color: 'blue' } }, 'nested'),
' after'
)
)
expect(nodes(tree, 'RNUITextView')).toHaveLength(1)
expect(nodes(tree, 'Text')).toHaveLength(0)
const spans = nodes(tree, 'RNUITextViewChild')
expect(spans.map((node) => node.props.text).join('')).toBe('Before one\nboldnested after')
expect(spans.find((node) => node.props.text === 'bold')?.props.style).toMatchObject({
fontSize: 18,
fontWeight: 'bold'
})
expect(spans.find((node) => node.props.text === 'nested')?.props.style).toMatchObject({
fontSize: 18,
color: 'blue'
})
})
it('preserves Markdown text, inline styles and file-link callbacks', async () => {
const { MobileMarkdown } = await import('./MobileMarkdown')
const onOpenFile = vi.fn()
const tree = render(
createElement(MobileMarkdown, {
content: 'Hello 😀 [src/main.ts](src/main.ts) and `code`.\nNext line.',
rangeSelectable: true,
onOpenFile
})
)
const spans = nodes(tree, 'RNUITextViewChild')
expect(spans.map((node) => node.props.text).join('')).toBe(
'Hello 😀 src/main.ts and code.\nNext line.'
)
const link = spans.find((node) => node.props.text === 'src/main.ts')!
expect(link.props.style.color).toBeDefined()
act(() => link.props.onPress())
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('src/main.ts')
expect(nodes(tree, 'RNUITextView')).toHaveLength(1)
})
it('keeps ordinary button labels on React Native Text', async () => {
const { MobileSelectableText: Text } = await import('./MobileSelectableText.ios')
const tree = render(createElement(Text, null, 'Submit'))
expect(nodes(tree, 'RNUITextView')).toHaveLength(0)
expect(nodes(tree, 'Text')).toHaveLength(1)
})
it('uses native range selection only when Markdown opts in', async () => {
const { MobileMarkdown } = await import('./MobileMarkdown')
const tree = render(createElement(MobileMarkdown, { content: 'Transcript prose' }))
expect(nodes(tree, 'RNUITextView')).toHaveLength(0)
expect(
nodes(tree, 'Text').find((node) => node.children.includes('Transcript prose'))?.props
.selectable
).toBe(false)
act(() =>
tree.update(
createElement(MobileMarkdown, { content: 'Transcript prose', rangeSelectable: true })
)
)
expect(nodes(tree, 'RNUITextView')).toHaveLength(1)
})
it('keeps code-language labels on styled React Native Text', async () => {
const { MobileMarkdown } = await import('./MobileMarkdown')
const tree = render(
createElement(MobileMarkdown, {
content: '```ts\nconst value = 1\n```',
rangeSelectable: true
})
)
const label = nodes(tree, 'Text').find((node) => node.children.join('') === 'ts')!
expect(label.props.style.textTransform).toBe('uppercase')
expect(nodes(tree, 'RNUITextView')).toHaveLength(1)
})
it('falls back for older clients without the native view', async () => {
native.available = false
const { MobileSelectableText: Text } = await import('./MobileSelectableText.ios')
const tree = render(
createElement(Text, { selectable: true }, 'Old client ', createElement(Text, null, 'inline'))
)
expect(nodes(tree, 'RNUITextView')).toHaveLength(0)
expect(nodes(tree, 'Text')).toHaveLength(2)
expect(nodes(tree, 'Text')[0]!.props.selectable).toBe(true)
})
})
@@ -106,6 +106,21 @@ describe('MobileNativeChatMessage', () => {
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('makes user message text selectable', () => {
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
const text = tree.root
.findAllByType('Text' as never)
.find((node) => String(node.children.join('')) === 'Prompt I typed')
expect(text?.props.selectable).toBe(true)
})
it('routes assistant prose through selectable Markdown', () => {
const tree = render(toolMessage([{ type: 'text', text: 'Agent reply prose' }]))
const markdown = tree.root.findByType('MobileMarkdown' as never)
expect(markdown.props.content).toBe('Agent reply prose')
expect(markdown.props.rangeSelectable).toBe(true)
})
it('labels a tool row with the target path instead of raw input JSON', () => {
const tree = render(
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
+12 -95
View File
@@ -1,7 +1,6 @@
import { memo, useEffect, useRef, useState } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, Copy } from 'lucide-react-native'
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
@@ -10,10 +9,8 @@ import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { colors } from '../theme/mobile-theme'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
function Prose({
block,
@@ -37,7 +34,12 @@ function Prose({
)
}
return (
<MobileMarkdown content={block.text} textScale={1.25 * fontScale} onOpenFile={onOpenFile} />
<MobileMarkdown
content={block.text}
rangeSelectable
textScale={1.25 * fontScale}
onOpenFile={onOpenFile}
/>
)
}
if (isImageRefBlock(block)) {
@@ -55,53 +57,18 @@ function Prose({
)
}
return (
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
<NativeText style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</Text>
</NativeText>
)
}
return null
}
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
* this message's top aligns to the top of the viewport. */
function AgentControls({
onCopy,
onScrollToTop
}: {
onCopy: () => void
onScrollToTop?: () => void
}): React.JSX.Element {
return (
<View style={styles.controls}>
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onCopy}
hitSlop={8}
accessibilityLabel="Copy message"
>
<Copy size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
{onScrollToTop ? (
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onScrollToTop}
hitSlop={8}
accessibilityLabel="Scroll this message to top"
>
<ArrowUp size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
) : null}
</View>
)
}
function MobileNativeChatMessageImpl({
message,
toolsExpanded = false,
fontScale = 1,
messageIndex,
onScrollToMessage,
onOpenFile,
turnStatus,
turnExpanded,
@@ -114,10 +81,6 @@ function MobileNativeChatMessageImpl({
toolsExpanded?: boolean
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
/** This message's index in the list, paired with onScrollToMessage. */
messageIndex?: number
/** Ask the list to align this message's top to the top of the viewport. */
onScrollToMessage?: (index: number) => void
onOpenFile?: (relativePath: string) => void
/** This turn's status row, rendered under a user message (desktop parity). */
turnStatus?: NativeChatTurnStatus | null
@@ -134,18 +97,6 @@ function MobileNativeChatMessageImpl({
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isAgent = !isUser
// Briefly tint the bubble to confirm a copy landed.
const [copied, setCopied] = useState(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
},
[]
)
// Separate the agent's words from its tool activity: prose renders first, the
// tool calls fold into a collapsible run beneath. The user's own messages get
// an inverted (filled accent) bubble so they stand apart from agent prose.
@@ -165,42 +116,11 @@ function MobileNativeChatMessageImpl({
!toolsExpanded
const showToolRun = tools.length > 0 && !settledToolsHidden
const handleCopy = (): void => {
const text = nativeChatMessageText(message.blocks)
if (!text) {
return
}
void Clipboard.setStringAsync(text)
setCopied(true)
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
copyTimer.current = setTimeout(() => setCopied(false), 700)
}
// Copy + scroll-to-top, shown inline with the first tool call (or after the
// prose when there are no tools).
const controls = isAgent ? (
<AgentControls
onCopy={handleCopy}
onScrollToTop={
onScrollToMessage && messageIndex !== undefined
? () => onScrollToMessage(messageIndex)
: undefined
}
/>
) : null
return (
<>
<View style={[styles.row, isUser && styles.rowUser]}>
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
copied && styles.copied
]}
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
>
{prose.map((block, index) => (
<Prose
@@ -220,11 +140,8 @@ function MobileNativeChatMessageImpl({
defaultExpanded={turnExpanded || toolsExpanded}
expandChildren={turnExpanded ? false : toolsExpanded}
activeCall={activeCall}
trailing={controls}
onOpenFile={onOpenFile}
/>
) : controls ? (
<View style={styles.controlsRow}>{controls}</View>
) : null}
</View>
</View>
@@ -71,6 +71,7 @@ export function MobileNativeChatOverlay({
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
streaming={streaming}
onStop={controller.handleNativeChatStop}
@@ -172,7 +172,6 @@ export function ToolRun({
defaultExpanded,
expandChildren,
activeCall,
trailing,
onOpenFile
}: {
blocks: NativeChatBlock[]
@@ -181,7 +180,6 @@ export function ToolRun({
expandChildren: boolean
/** The still-running call, when the turn is live (desktop parity). */
activeCall: ReturnType<typeof selectActiveToolCall>
trailing?: React.ReactNode
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [open, setOpen] = useState(defaultExpanded)
@@ -230,7 +228,6 @@ export function ToolRun({
</Text>
</Pressable>
)}
{trailing}
</View>
{open ? (
<View style={styles.toolRunBody}>
@@ -74,6 +74,7 @@ type Overrides = {
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
agentWorking?: boolean
canStop?: boolean
sendSurfaceId?: string
}
@@ -118,6 +119,18 @@ describe('MobileNativeChatView', () => {
}
/** Ids of the rows the list is currently rendering. */
it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => {
const props = { structuredActivityUi: true, agentWorking: true, canStop: false }
await render(props)
const stops = () =>
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent')
expect(stops()).toHaveLength(0)
await update({ ...props, canStop: true })
expect(stops()).toHaveLength(1)
await update({ agentWorking: true })
expect(stops()).toHaveLength(1)
})
function listIds(): string[] {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return (list.props.data as { id: string }[]).map((row) => row.id)
+6 -29
View File
@@ -49,10 +49,11 @@ type Props = {
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
agentWorking?: boolean
canStop?: boolean
/** Structured lane: per-turn "Working for N" status plus live tool progress,
* replacing the bridge lane's static three-dot working row (desktop parity). */
structuredActivityUi?: boolean
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
/** Interrupt a provider turn. */
onStop?: () => void
/** Live partial assistant text to show as an in-progress bubble, already gated
* by the overlay against the transcript catching up. */
@@ -129,6 +130,7 @@ export function MobileNativeChatView({
error,
agent,
agentWorking,
canStop = agentWorking,
structuredActivityUi = false,
onStop,
streaming,
@@ -251,11 +253,6 @@ export function MobileNativeChatView({
[hasMore, loadingEarlier, onLoadEarlier]
)
// Align a single message's top to the top of the viewport.
const onScrollToMessage = useCallback((index: number) => {
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
}, [])
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
// owns them; the bridge lane keeps its three-dot indicator.
const turns = useMobileNativeChatTurnDisclosure({
@@ -271,15 +268,13 @@ export function MobileNativeChatView({
message={item}
toolsExpanded={toolsExpanded}
fontScale={fontScale}
messageIndex={index}
onScrollToMessage={onScrollToMessage}
onOpenFile={onOpenFile}
structuredActivityUi={structuredActivityUi}
onToggleTurn={turns.onToggleTurn}
{...turns.resolveRow(index, item)}
/>
),
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile, structuredActivityUi, turns]
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
@@ -323,21 +318,6 @@ export function MobileNativeChatView({
listRef.current?.scrollToEnd({ animated: false })
}
}}
// scrollToIndex can fail before an off-screen row is measured —
// fall back to an estimated offset, then retry once it's laid out.
onScrollToIndexFailed={(info) => {
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: true
})
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
viewPosition: 0,
animated: true
})
}, 120)
}}
ListHeaderComponent={
hasMore ? (
<Pressable
@@ -372,8 +352,7 @@ export function MobileNativeChatView({
}
/>
</GestureDetector>
{/* Jump-to-latest control. The scroll-to-top affordance now lives
per-message (the up-arrow in each agent message's controls). */}
{/* Jump-to-latest control. */}
{!atBottom ? (
<Pressable
accessibilityLabel="Scroll to latest"
@@ -396,8 +375,6 @@ export function MobileNativeChatView({
question={question}
onAnswerQuestion={onAnswerQuestion}
/>
{/* Chrome row above the composer: the working indicator and the global
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
{agentWorking && !structuredActivityUi ? <MobileAgentWorkingIndicator /> : null}
@@ -414,7 +391,7 @@ export function MobileNativeChatView({
<Text style={styles.chromeToggleLabel}>{toolsExpanded ? 'Collapse' : 'Tools'}</Text>
</Pressable>
</View>
{agentWorking ? (
{canStop ? (
<Pressable
style={({ pressed }) => [styles.stopButton, pressed && styles.pressed]}
onPress={onStop}
@@ -28,6 +28,7 @@ export type MobileNativeChatController = {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: boolean
nativeChatAgentWorking: boolean
nativeChatCanStop: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
@@ -29,23 +29,6 @@ export const styles = StyleSheet.create({
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
controls: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.xs,
marginBottom: 2,
opacity: 0.7
},
controlButton: {
padding: 3
},
controlPressed: {
opacity: 0.5
},
copied: {
backgroundColor: colors.diffAddedBg,
borderRadius: radii.card
},
reasoning: {
opacity: 0.7
},
@@ -64,10 +47,6 @@ export const styles = StyleSheet.create({
gap: spacing.sm,
paddingVertical: 3
},
controlsRow: {
flexDirection: 'row',
justifyContent: 'flex-end'
},
toolRunCount: {
color: colors.statusGreen,
fontFamily: typography.monoFamily,
@@ -1,31 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import {
clampFontScale,
FONT_SCALE_MAX,
FONT_SCALE_MIN,
nativeChatMessageText
} from './mobile-native-chat-message-text'
describe('nativeChatMessageText', () => {
it('joins text blocks and skips non-text blocks', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'Hello' },
{ type: 'tool-call', name: 'Read', input: {} },
{ type: 'text', text: 'World' }
]
expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld')
})
it('returns an empty string when there is no prose', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }]
expect(nativeChatMessageText(blocks)).toBe('')
})
it('trims surrounding whitespace', () => {
expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi')
})
})
import { clampFontScale, FONT_SCALE_MAX, FONT_SCALE_MIN } from './mobile-native-chat-message-text'
describe('clampFontScale', () => {
it('clamps below the minimum', () => {
@@ -1,15 +1,3 @@
import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
/** Concatenate a message's text blocks into a single copyable string. Tool
* calls/results and image refs are skipped — Copy is for the agent's prose. */
export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string {
return blocks
.filter(isTextBlock)
.map((b) => b.text)
.join('\n\n')
.trim()
}
/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */
export const FONT_SCALE_MIN = 0.8
export const FONT_SCALE_MAX = 1.8
@@ -55,6 +55,7 @@ const structuredQuestion = {
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
}
const structuredActivity = { isWorking: false, turnId: null as string | null }
const structuredSessionState = {
messages: [] as unknown[],
status: 'ready',
@@ -86,8 +87,7 @@ vi.mock('./use-mobile-native-chat-session', () => ({
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({
session: structuredSessionState,
isWorking: false,
turnId: null,
...structuredActivity,
sendWithOutcome: structuredSendWithOutcome,
cancel: structuredCancel,
permission: structuredPermission,
@@ -341,6 +341,37 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(clientStub.sendRequest).not.toHaveBeenCalled()
})
it('separates structured working status from provider cancellation availability', async () => {
const props = {
tab: {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Chat',
sessionId: 'session-structured',
agent: 'codex',
isActive: true
},
activeHandle: null,
inputLeaseReady: false
}
structuredActivity.isWorking = true
try {
await act(async () => {
renderer?.update(createElement(Harness, props))
})
expect(controller?.nativeChatAgentWorking).toBe(true)
expect(controller?.nativeChatCanStop).toBe(false)
structuredActivity.turnId = 'provider-turn'
await act(async () => {
renderer?.update(createElement(Harness, props))
})
expect(controller?.nativeChatCanStop).toBe(true)
} finally {
structuredActivity.isWorking = false
structuredActivity.turnId = null
}
})
it('exposes structured prompt cards and session options on structured tabs', async () => {
await act(async () => {
renderer?.update(
@@ -299,6 +299,9 @@ export function useMobileNativeChatController(args: {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: activeChatStructured,
nativeChatAgentWorking,
nativeChatCanStop: activeChatStructured
? structuredNativeChat.turnId !== null
: nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatStreamLive,
nativeChatStreamScopeKey: streamScopeKey,
@@ -11,7 +11,10 @@ import {
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch
} from '../../../src/shared/structured-agent-session-projection'
import {
pendingStructuredApproval,
pendingStructuredQuestion,
@@ -291,7 +294,10 @@ export function useMobileStructuredAgentSession(args: {
loadingEarlier: loadingOlder,
loadEarlier
},
isWorking: activeStructuredAgentSessionTurnId(state.items) !== null,
// A dispatch the provider has not answered yet is already work — see the desktop hook.
isWorking:
activeStructuredAgentSessionTurnId(state.items) !== null ||
hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence),
turnId: activeStructuredAgentSessionTurnId(state.items),
sendWithOutcome,
cancel,
@@ -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',
+144 -109
View File
@@ -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
@@ -15,6 +15,7 @@ import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection'
import { digestPayload } from './journal-payload-bounds'
import {
reconcileSubmissions,
@@ -110,6 +111,8 @@ describe('crash between provider accept and journal commit', () => {
expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1'])
await restarted.markPendingSubmissionsUnknown(2)
expect(restarted.submissions()[0]?.dispatchState).toBe('unknown')
// Marks the send as outlived by its writer, so no reader reports it as still working.
expect(restarted.submissions()[0]?.recovered).toBe(true)
const [outcome] = reconcileSubmissions({
submissions: restarted.submissions(),
@@ -139,6 +142,30 @@ describe('crash between provider accept and journal commit', () => {
expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity))
})
it('retires an ack timeout on restart without changing its delivery verdict', async () => {
const journal = await open()
await journal.appendSubmission({
clientMessageId: 'cm_timeout',
payloadFingerprint: digestPayload('slow'),
body: userMessage('slow'),
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'cm_timeout',
state: 'unknown',
reason: 'ack timeout',
fence: 1
})
expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions())).toBe(true)
const restarted = await open()
await restarted.markPendingSubmissionsUnknown(2)
expect(restarted.submissions()[0]?.dispatchState).toBe('unknown')
expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false)
const cursor = restarted.cursor()
await restarted.markPendingSubmissionsUnknown(2)
expect(restarted.cursor()).toEqual(cursor)
})
it('reports a rejected submission as never delivered, and never re-sends it', async () => {
const journal = await open()
await journal.appendSubmission({
@@ -9,7 +9,14 @@ export async function markJournalPendingSubmissionsUnknown(
fence: number,
reason: string = DISPATCH_DOUBT_HOST_RESTARTED
): Promise<string[]> {
const pending = journal.pendingSubmissions().map((entry) => entry.clientMessageId)
const pending = journal
.submissions()
.filter(
(entry) =>
entry.dispatchState === 'pending' ||
(entry.dispatchState === 'unknown' && entry.recovered !== true)
)
.map((entry) => entry.clientMessageId)
for (const clientMessageId of pending) {
await journal.resolveDispatch({
clientMessageId,
@@ -256,10 +256,16 @@ function applyDispatch(
if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') {
return
}
submission.fence = row.fence
submission.dispatchState = row.state
submission.providerItemId = row.providerItemId
submission.reason = row.reason
submission.resolvedAt = row.ts
if (row.recovered) {
submission.recovered = row.recovered
} else {
delete submission.recovered
}
if (row.state !== 'accepted' || !row.providerItemId) {
return
}
@@ -245,9 +245,7 @@ export class AgentSessionJournal {
}))
}
/** On restart every `pending` submission becomes `unknown` before the session
* accepts a writer; `reason` names the process fact for callers settling a
* different one. Orca never re-sends on the user's behalf. */
/** Retire unanswered sends after their execution owner ended, without assuming delivery. */
async markPendingSubmissionsUnknown(fence: number, reason?: string): Promise<string[]> {
return markJournalPendingSubmissionsUnknown(this, fence, reason)
}
@@ -38,36 +38,26 @@ describe('provider frame activity', () => {
}
})
it('uses Claude descriptions and safe semantic status without exposing tool labels', () => {
it('leaves the Claude line on the generic fallback, since Claude never narrates its turn', () => {
// Prose on these frames belongs to a spawned task, not to this turn.
for (const [kind, payload] of [
['message:system:task_started', { description: 'Trace the activity channel' }],
['message:system:task_progress', { summary: 'Checking remote compatibility' }],
['message:system:task_updated', { patch: { description: 'Validating the renderer' } }],
['message:system:control_request_progress', { status: 'api_retry' }],
['message:tool_progress', { tool_name: 'ReadSecretFile' }]
] as const) {
expect(claudeProviderFrameActivity(kind, payload)).toBeNull()
}
// `requesting` holds for nearly the whole turn and says no more than the fallback.
expect(
claudeProviderFrameActivity('message:system:task_started', {
description: 'Trace the activity channel'
})
).toBe('Working on: Trace the activity channel')
expect(
claudeProviderFrameActivity('message:system:task_progress', {
description: 'Reading tests',
summary: 'Checking remote compatibility'
})
).toBe('Checking remote compatibility')
expect(
claudeProviderFrameActivity('message:system:task_updated', {
patch: { description: 'Validating the renderer' }
})
).toBe('Validating the renderer')
claudeProviderFrameActivity('message:system:status', { status: 'requesting' })
).toBeNull()
expect(claudeProviderFrameActivity('message:system:status', { status: 'compacting' })).toBe(
'Compacting the conversation'
)
expect(
claudeProviderFrameActivity('message:system:control_request_progress', {
status: 'api_retry'
})
).toBe('Retrying a side question')
expect(
claudeProviderFrameActivity('message:tool_progress', {
tool_name: 'ReadSecretFile'
})
).toBeNull()
// An unmodeled frame still declines to answer, so it cannot clear live copy.
expect(claudeProviderFrameActivity('message:system:unknown_frame', {})).toBeUndefined()
})
it('falls through on protocol noise and bounds long copy', () => {
@@ -100,40 +100,29 @@ export function codexProviderFrameActivity(
return itemType ? (CODEX_ITEM_ACTIVITY[itemType] ?? null) : null
}
/**
* Claude does not narrate its own turn, so the activity line stays the generic fallback.
*
* Codex names each item it starts, which is what makes its line worth reading. Claude's only
* turn-wide frame is `system/status`, whose payload is a bare token — every sentence Orca ever
* put on this line for it was Orca's own wording for `requesting`, which is true for nearly the
* whole turn and says no more than the fallback does. Its `task_*` frames do carry prose, but
* they are keyed by task id and subagent type: they describe a spawned task, not this turn, and
* the background-tasks strip already owns that. Compaction is the one exception kept — a real,
* rare state that explains an otherwise unexplained wait, and the Codex map reports it too.
*/
export function claudeProviderFrameActivity(kind: string, payload: unknown): ActivityText {
const source = record(payload)
if (kind === 'message:system:task_started') {
if (source?.ambient === true || source?.skip_transcript === true) {
return null
}
const description = providerActivityText(stringField(source, 'description'))
return description ? providerActivityText(`Working on: ${description}`) : null
}
if (kind === 'message:system:task_progress') {
return providerActivityText(
stringField(source, 'summary') ?? stringField(source, 'description')
)
}
if (kind === 'message:system:task_updated') {
return providerActivityText(stringField(record(source?.patch), 'description'))
}
if (kind === 'message:system:status') {
const status = stringField(source, 'status')
return status === 'compacting'
? 'Compacting the conversation'
: status === 'requesting'
? 'Requesting a response'
: null
return stringField(source, 'status') === 'compacting' ? 'Compacting the conversation' : null
}
if (kind === 'message:system:control_request_progress') {
const status = stringField(source, 'status')
return status === 'started'
? 'Exploring a side question'
: status === 'api_retry'
? 'Retrying a side question'
: null
}
if (kind === 'message:tool_progress') {
if (
kind === 'message:system:task_started' ||
kind === 'message:system:task_progress' ||
kind === 'message:system:task_updated' ||
kind === 'message:system:control_request_progress' ||
kind === 'message:tool_progress'
) {
return null
}
return undefined
@@ -248,10 +248,11 @@ describe('provider turn activity routing', () => {
})
)
expect(state.rows).toHaveLength(turnRows)
// Only compaction reaches the line; task and side-question prose is not this turn's work.
expect(state.activities.slice(-3)).toEqual([
{ turnId: TURN_ID, text: 'Checking the renderer state' },
null,
{ turnId: TURN_ID, text: 'Compacting the conversation' },
{ turnId: TURN_ID, text: 'Exploring a side question' }
null
])
translator.handle(claudeMessage({ type: 'tool_progress', tool_name: 'SecretReader' }))
@@ -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)
}
}
@@ -86,6 +86,13 @@ export function createStructuredAgentSessionHostHandoff(
host.publishStatus?.(sessionId)
try {
await host.flush(sessionId)
const session = host.session(sessionId)
await session.journal.markPendingSubmissionsUnknown(
session.fence,
'provider_exited_before_acknowledgement'
)
host.subscribers.publish(sessionId, session.journal)
host.publishStatus?.(sessionId)
host.eventSink(sessionId).unbind()
return { state: 'stopped' }
} catch (error) {
@@ -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 {
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
@@ -34,6 +35,39 @@ afterEach(async () => {
})
describe('structured send idempotency', () => {
it('publishes a recovered retry as working before waiting for its provider', async () => {
const body: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'retry' }]
}
const input = { clientMessageId: 'retry-id', payloadFingerprint: 'fingerprint', body }
await journal.appendSubmission({ ...input, fence: 1 })
await journal.markPendingSubmissionsUnknown(2, 'provider_write_failed: broken pipe')
const originalItem = journal.snapshot().items[0]
const publish = vi.fn()
const dispatch = vi.fn(async () => {
expect(publish).toHaveBeenCalledOnce()
expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions(), 2)).toBe(true)
return { state: 'unknown' as const, reason: 'ack timeout' }
})
await performSend(
{
sessionId: 'session-1',
journal,
fence: 2,
adapter: { dispatch } as unknown as StructuredAgentSessionAdapter,
persistOptions: async () => undefined,
resolvedBy: 'caller',
publish,
now: () => 1
},
{ ...input, retryUnknown: true }
)
expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions(), 2)).toBe(true)
expect(journal.snapshot().items).toEqual([originalItem])
})
it('does not redispatch one send id reused across caller ledgers', async () => {
const body: AgentJournalMessageItem = {
kind: 'message',
@@ -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'
@@ -56,9 +59,11 @@ async function openJournal(sessionId = SESSION, now?: () => number) {
function indexed(session: {
journal: Awaited<ReturnType<typeof openJournal>>
hasProviderChild?: boolean
fence?: number
}) {
return {
journal: session.journal,
fence: session.fence ?? 1,
...(session.hasProviderChild !== undefined
? { hasProviderChild: session.hasProviderChild }
: {}),
@@ -69,14 +74,16 @@ function indexed(session: {
function feedFor(
sessions: Map<
string,
{ journal: Awaited<ReturnType<typeof openJournal>>; hasProviderChild?: boolean }
{ 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)
@@ -153,6 +160,59 @@ describe('StructuredAgentSessionStatusFeed', () => {
])
})
it('stops projecting an old-host unknown submission after the owner fence advances', async () => {
const journal = await openJournal()
const session = { journal, fence: 1 }
const { feed, events } = feedFor(new Map([[SESSION, session]]))
await journal.appendSubmission({
clientMessageId: 'old-host',
payloadFingerprint: 'fp',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'slow' }] },
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'old-host',
state: 'unknown',
reason: 'ack timeout',
fence: 1
})
feed.publish(SESSION)
expect(events.at(-1)).toMatchObject({ session: { status: 'working' } })
session.fence = 2
feed.publish(SESSION)
expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } })
})
it('publishes working from the pending submission, before the provider replays the turn', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
events.length = 0
await journal.appendSubmission({
clientMessageId: 'client-1',
payloadFingerprint: 'fingerprint-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] },
fence: 1
})
feed.publish(SESSION)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'working' })
})
await journal.resolveDispatch({
clientMessageId: 'client-1',
state: 'accepted',
providerIdentity: USER_IDENTITY,
fence: 1
})
feed.publish(SESSION)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle' })
})
})
it('publishes working, then idle once the running marker is tombstoned, and never a repeat', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
@@ -566,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'
@@ -31,6 +33,7 @@ type StatusFeedSession = {
journal: AgentSessionJournal
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
hasProviderChild?: boolean
fence?: number
}
export type StructuredAgentSessionStatusFeedDeps = {
@@ -40,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 {
@@ -56,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) {}
@@ -153,22 +196,54 @@ export class StructuredAgentSessionStatusFeed {
journal: AgentSessionJournal
): AgentSessionStatusSummary {
// An unreadable journal projects as "no turn": the chat itself shows the reset.
const items = journal.isReadOnly ? [] : journal.snapshot().items
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),
...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()
}
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type {
AgentSessionMutationEnvelope,
@@ -334,6 +335,11 @@ describe('an unexpected provider exit', () => {
acquisitionGeneration: 'generation-1'
})
const recoveredHistory = host.history({ sessionId: SESSION, direction: 'tail' })
expect(
recoveredHistory.ok &&
hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions)
).toBe(false)
expect(acquire).toHaveBeenCalledTimes(2)
expect(dispatch).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
@@ -115,6 +115,15 @@ export async function performSend(
if (!redeliver) {
await ctx.journal.appendSubmission({ ...input, fence: ctx.fence })
ctx.publish()
} else {
// Retry resumes work without moving or duplicating the original message.
await ctx.journal.resolveDispatch({
clientMessageId: input.clientMessageId,
state: 'unknown',
reason: 'dispatch_retry_in_progress',
fence: ctx.fence
})
ctx.publish()
}
const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body)
@@ -155,8 +164,7 @@ export async function performSend(
clientMessageId: input.clientMessageId,
state: 'unknown',
reason: DISPATCH_DOUBT_PERSISTENCE_FAILED,
fence: ctx.fence,
recovered: true
fence: ctx.fence
})
} catch {
// Nothing further to record; the pending row is settled on the next attach.
@@ -52,8 +52,7 @@ describe('provider-exit recovery tickets', () => {
journal: {
snapshot: () => ({ items: [] }),
appendLifecycleBatch,
pendingSubmissions: () => [],
resolveDispatch: vi.fn()
markPendingSubmissionsUnknown: vi.fn(async () => [])
}
} as unknown as StructuredAgentSessionHostSession
const store = {
@@ -94,11 +93,15 @@ describe('provider-exit recovery tickets', () => {
expect(result).toMatchObject({ settlementRetryRequired: false, releasedFence: 8 })
expect(appendLifecycleBatch).toHaveBeenCalledOnce()
expect(session.journal.markPendingSubmissionsUnknown).toHaveBeenCalledWith(
7,
'provider_exited_before_acknowledgement'
)
expect(session.hasProviderChild).toBe(false)
})
it('settles a submission the dead child never acknowledged', async () => {
const resolveDispatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 2 }))
const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1'])
const session = {
hasProviderChild: true,
fence: 7,
@@ -106,8 +109,7 @@ describe('provider-exit recovery tickets', () => {
journal: {
snapshot: () => ({ items: [] }),
appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })),
pendingSubmissions: () => [{ clientMessageId: 'client-1' }],
resolveDispatch
markPendingSubmissionsUnknown
}
} as unknown as StructuredAgentSessionHostSession
@@ -144,13 +146,10 @@ describe('provider-exit recovery tickets', () => {
}
)
expect(resolveDispatch).toHaveBeenCalledWith({
clientMessageId: 'client-1',
state: 'unknown',
reason: 'provider_exited_before_acknowledgement',
fence: 7,
recovered: true
})
expect(markPendingSubmissionsUnknown).toHaveBeenCalledWith(
7,
'provider_exited_before_acknowledgement'
)
})
it('does not release or reacquire while terminal settlement retry is still failing', async () => {
@@ -159,12 +158,11 @@ describe('provider-exit recovery tickets', () => {
fence: 7,
acquisitionGeneration: GENERATION,
journal: {
markPendingSubmissionsUnknown: vi.fn(async () => []),
snapshot: () => ({ items: [] }),
appendLifecycleBatch: vi.fn(async () => {
throw new Error('journal still unavailable')
}),
pendingSubmissions: () => [],
resolveDispatch: vi.fn()
})
}
} as unknown as StructuredAgentSessionHostSession
const release = vi.fn()
@@ -4,9 +4,7 @@ import type {
AgentJournalRenderItem
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { DISPATCH_DOUBT_PROVIDER_EXITED } from '../agent-session-journal/journal-dispatch-doubt-reasons'
import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition'
import { markJournalPendingSubmissionsUnknown } from '../agent-session-journal/journal-pending-submission-recovery'
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
import {
boundJournalStatusText,
@@ -83,6 +81,15 @@ export async function settleUnexpectedStructuredAgentSessionExit(
settlementRetryRequired = true
context.onBarrierError?.(unexpectedEvent.sessionId, error)
}
try {
await session.journal.markPendingSubmissionsUnknown(
session.fence,
'provider_exited_before_acknowledgement'
)
} catch (error) {
settlementRetryRequired = true
context.onBarrierError?.(unexpectedEvent.sessionId, error)
}
if (unexpectedEvent.settlementRetryRequired || settlementRetryRequired) {
const retried = await retryUnexpectedExitSettlement({
context,
@@ -97,19 +104,6 @@ export async function settleUnexpectedStructuredAgentSessionExit(
settlementRetryRequired = false
}
}
// A submission still `pending` was written to the child that just died,
// so its acknowledgement can never arrive. This is the process fact that
// puts delivery in doubt; elapsed time never does.
try {
await markJournalPendingSubmissionsUnknown(
session.journal,
session.fence,
DISPATCH_DOUBT_PROVIDER_EXITED
)
} catch (error) {
// The next attach settles them from its own crash boundary.
context.onBarrierError?.(unexpectedEvent.sessionId, error)
}
} finally {
// Provider exit was positively observed, so release the owner even when
// terminal settlement could not be durably accepted.
@@ -185,6 +179,10 @@ export async function retryUnexpectedExitSettlement(input: {
stableSettlementId: string
}): Promise<boolean> {
try {
await input.session.journal.markPendingSubmissionsUnknown(
input.session.fence,
'provider_exited_before_acknowledgement'
)
const mutations = unexpectedExitFallbackMutations(
input.event,
input.session,
@@ -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,
@@ -1,4 +1,5 @@
import { MAX_TAIL_PENDING_ANSI_CHARS } from './terminal-tail-limits'
import { classifyTerminalEscapeIntroducer } from '../../shared/terminal-escape-introducer'
import { ownRetainedString } from '../../shared/own-retained-string'
export function parseAnsiControlSequence(
@@ -11,8 +12,9 @@ export function parseAnsiControlSequence(
endIndex: number
}
| null {
const introducer = value[escapeIndex + 1]
if (introducer === '[') {
// charCodeAt, not value[i]: indexing mints a one-char string on every escape.
const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(escapeIndex + 1))
if (introducer === 'csi') {
for (let index = escapeIndex + 2; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 0x40 || code > 0x7e) {
@@ -30,7 +32,7 @@ export function parseAnsiControlSequence(
}
return null
}
if (introducer === ']') {
if (introducer === 'osc') {
for (let index = escapeIndex + 2; index < value.length; index += 1) {
if (value[index] === '\u0007') {
return { kind: 'other', endIndex: index }
@@ -41,7 +43,7 @@ export function parseAnsiControlSequence(
}
return null
}
if (isStTerminatedStringControlIntroducer(introducer)) {
if (introducer === 'string') {
for (let index = escapeIndex + 2; index < value.length; index += 1) {
if (value[index] === '\u001b' && value[index + 1] === '\\') {
return { kind: 'other', endIndex: index + 1 }
@@ -52,10 +54,6 @@ export function parseAnsiControlSequence(
return { kind: 'other', endIndex: escapeIndex + 1 }
}
function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean {
return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_'
}
export function hasCanonicalNumericCsiParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
@@ -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
}
@@ -29,9 +29,13 @@ const mocks = vi.hoisted(() => ({
pasteFromClipboard: vi.fn(),
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()
}))
@@ -75,12 +79,16 @@ vi.mock('./use-structured-agent-session', async () => {
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(),
stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId),
respond: mocks.respond,
@@ -172,8 +180,12 @@ describe('NativeChatStructuredSession', () => {
mocks.monitoringBackgroundTasks = false
mocks.supportsBackgroundTaskStop = false
mocks.supportsBackgroundTaskStopAll = true
mocks.turnId = null
mocks.stopBackgroundTask.mockReset()
mocks.backgroundTasks = []
mocks.settledBackgroundTasks = []
mocks.showBackgroundTasks = false
mocks.isWorking = false
})
it('routes app-menu paste into the structured composer', () => {
@@ -231,6 +243,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
@@ -240,33 +263,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()
@@ -276,6 +290,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
@@ -297,15 +333,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' })
@@ -338,27 +368,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)
@@ -374,15 +388,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()
@@ -309,11 +309,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) => {
@@ -357,7 +360,9 @@ export function NativeChatStructuredSession(
targetPtyId={null}
agent={props.agent}
canSend={!prompt}
isWorking={controller.isWorking}
// Stop, not status: only a provider-minted turn can be interrupted, so the button
// must not flip while a dispatch is still unanswered.
isWorking={controller.turnId !== null}
onStop={() => {
if (controller.turnId) {
void controller.cancel(controller.turnId)
@@ -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 }
}
@@ -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)
@@ -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 />)
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
}))
let fence = 3
let sessionCommands: { name: string; kind: 'command' | 'skill' }[] | undefined
let submissions: AgentJournalSubmission[] = []
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
@@ -25,7 +26,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
fence,
commands: sessionCommands,
items: [],
submissions: [],
submissions,
status: 'ready',
error: null,
hasOlder: false,
@@ -47,6 +48,7 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
})
}))
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import {
applyNativeChatSessionOptionSettingsMutation,
resolveStructuredLaunchSeedOptions
@@ -95,10 +97,72 @@ const OPTIONS = {
current: { model: 'gpt-live', effort: 'medium' }
}
describe('useStructuredAgentSession working state', () => {
beforeEach(() => {
vi.clearAllMocks()
fence = 3
submissions = []
mocks.call.mockResolvedValue(null)
})
it('reports work from an unanswered dispatch, and keeps the turn id provider-minted', () => {
submissions = [
{
clientMessageId: 'client-1',
fence: 3,
payloadFingerprint: 'fingerprint-1',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
agent: 'codex',
target: LOCAL_TARGET,
isVisible: true
})
)
expect(result.current.isWorking).toBe(true)
// Only the provider can mint a cancellable turn, so Stop stays unavailable here.
expect(result.current.turnId).toBeNull()
})
it('reports no work once the dispatch resolves and no turn is running', () => {
submissions = [
{
clientMessageId: 'client-1',
fence: 3,
payloadFingerprint: 'fingerprint-1',
dispatchState: 'accepted',
providerItemId: 'codex:thread-1:turn-1',
reason: null,
submittedAt: 1,
resolvedAt: 2
}
]
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
agent: 'codex',
target: LOCAL_TARGET,
isVisible: true
})
)
expect(result.current.isWorking).toBe(false)
})
})
describe('useStructuredAgentSession options', () => {
beforeEach(() => {
vi.clearAllMocks()
fence = 3
submissions = []
mocks.operationId
.mockReset()
.mockReturnValueOnce('operation-1')
@@ -22,7 +22,10 @@ import {
structuredAgentSessionOptionPicks,
structuredAgentSessionOptionSnapshot
} from '../../../../shared/structured-agent-session-options'
import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch
} from '../../../../shared/structured-agent-session-projection'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold'
@@ -80,11 +83,15 @@ export function useStructuredAgentSession(args: {
// Refresh options each turn to confirm which model the provider actually selected.
const turnId = activeStructuredAgentSessionTurnId(state.items)
// A dispatch the provider has not answered is already work; Claude's running row trails the
// send by seconds, and only a provider-minted turn is cancellable, so the two stay separate.
const isWorking =
turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence)
const turnActivity = useMemo(
() => 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) {
@@ -184,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',
@@ -210,9 +212,9 @@ export function useStructuredAgentSession(args: {
send: (...input: Parameters<typeof outboxController.send>) =>
!commandPending.current && outboxController.send(...input),
retry: outboxController.retry,
isWorking: turnId !== null,
isWorking,
turnActivity,
...backgroundTasksView,
backgroundTasks,
turnId,
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
stopBackgroundTask: (taskId?: string) =>
+28 -2
View File
@@ -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": {
+2 -1
View File
@@ -186,7 +186,8 @@ export const AgentJournalSubmissionSchema = z.object({
providerItemId: z.string().nullable(),
reason: z.string().nullable(),
submittedAt: z.number(),
resolvedAt: z.number().nullable()
resolvedAt: z.number().nullable(),
recovered: z.literal(true).optional()
})
export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody {
@@ -193,6 +193,7 @@ export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[n
* the turn reads as delivery unconfirmed, never as sent and never as failed. */
export type AgentJournalSubmission = {
clientMessageId: string
/** Execution fence of the latest dispatch attempt or recovery. */
fence: number
payloadFingerprint: string
dispatchState: AgentJournalDispatchState
@@ -202,6 +203,9 @@ export type AgentJournalSubmission = {
reason: string | null
submittedAt: number
resolvedAt: number | null
/** Set when crash reconciliation resolved the dispatch, not the provider. A live
* `unknown` is a send still outstanding; a recovered one outlived its writer. */
recovered?: true
}
/** Durable answer to "did my send land?", keyed by client message id. Only an
+58
View File
@@ -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
}
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentStatusOscProcessor } from './agent-status-osc'
/** Total characters swept by terminator/prefix searches across every chunk of a feed. */
function feedWithScanBudget(chunks: string[]) {
let searchedChars = 0
const indexOf = String.prototype.indexOf
const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function (
this: string,
search,
from = 0
) {
const found = indexOf.call(this, search, from)
searchedChars += (found === -1 ? this.length : found + String(search).length) - Number(from)
return found
})
try {
const process = createAgentStatusOscProcessor()
const results = chunks.map((chunk) => process(chunk))
return { results, searchedChars }
} finally {
spy.mockRestore()
}
}
describe('OSC 9999 split-frame scan budget', () => {
it('keeps per-chunk work flat as the split frame accumulates', () => {
// One unterminated marker whose payload arrives one character at a time.
const feedOf = (chunkCount: number): string[] => [
'\x1b]9999;{"state":"working","prompt":"',
...Array<string>(chunkCount).fill('x')
]
const small = feedWithScanBudget(feedOf(2000))
const large = feedWithScanBudget(feedOf(4000))
expect(small.results.every((result) => result.payloads.length === 0)).toBe(true)
// Re-scanning the accumulation would quadruple the budget when the feed doubles.
expect(large.searchedChars).toBeLessThan(small.searchedChars * 3)
})
it.each(['\x07', '\x1b\\'])(
'matches whole-string parsing when split at every offset with terminator %j',
(terminator) => {
const stream = `head\x1b]9999;{"state":"working","prompt":"p"}${terminator}tail`
const whole = createAgentStatusOscProcessor()(stream)
for (let split = 1; split < stream.length; split += 1) {
const process = createAgentStatusOscProcessor()
const first = process(stream.slice(0, split))
const second = process(stream.slice(split))
expect({
cleanData: first.cleanData + second.cleanData,
payloads: [...first.payloads, ...second.payloads]
}).toEqual({ cleanData: whole.cleanData, payloads: whole.payloads })
}
}
)
it('finds a string terminator straddling the resume boundary', () => {
const process = createAgentStatusOscProcessor()
// The ESC lands as the last character of the carried frame; the backslash arrives next.
expect(process('\x1b]9999;{"state":"working"}\x1b').payloads).toEqual([])
expect(process('\\rest').payloads).toMatchObject([{ state: 'working' }])
})
it('still parses a payload that completes many chunks later', () => {
const process = createAgentStatusOscProcessor()
process('\x1b]9999;{"state":"wor')
for (const chunk of ['k', 'i', 'n', 'g']) {
expect(process(chunk).payloads).toEqual([])
}
expect(process('"}\x07done').payloads).toMatchObject([{ state: 'working' }])
})
})
+10 -1
View File
@@ -57,6 +57,9 @@ function findAgentStatusTerminator(
export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk {
const MAX_PENDING = 64 * 1024
let pending = ''
// How much of `pending` already failed a terminator search, so a frame split across
// many chunks re-scans only the new bytes instead of the whole accumulation.
let pendingSearched = 0
return (data: string): ProcessedAgentStatusChunk => {
// Ordinary terminal output is by far the common case. Keep it on the
@@ -76,7 +79,9 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
}
const combined = pending + data
const resumeFrom = pendingSearched
pending = ''
pendingSearched = 0
const payloads: ParsedAgentStatusPayload[] = []
let lastPayloadCleanOffset: number | null = null
@@ -100,12 +105,16 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
cleanData += combined.slice(cursor, start)
const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length
const terminator = findAgentStatusTerminator(combined, payloadStart, nextTerminator)
// Minus one so a `\x1b\\` straddling the previous chunk boundary is still found.
const searchFrom =
start === 0 && resumeFrom > 0 ? Math.max(payloadStart, resumeFrom - 1) : payloadStart
const terminator = findAgentStatusTerminator(combined, searchFrom, nextTerminator)
if (terminator === null) {
const candidate = combined.slice(start)
// Own the frame so it stops pinning the consumed chunk it was sliced from.
pending = candidate.length > MAX_PENDING ? '' : ownRetainedString(candidate)
pendingSearched = pending.length
break
}
@@ -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'
})
})
-5
View File
@@ -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'
}
}
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import type { AgentSessionHistoryPage } from './agent-session-wire'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
oldestStructuredAgentSessionCursor,
reduceStructuredAgentSession,
type StructuredAgentSessionState
} from './structured-agent-session-reducer'
const CAP = 1024
function item(sequence: number): AgentJournalRenderItem {
return {
itemId: `item-${sequence}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `t-${sequence}` }] }
}
}
function page(items: AgentJournalRenderItem[], hasOlder: boolean): AgentSessionHistoryPage {
const oldest = items[0]?.sequence ?? 0
const newest = items.at(-1)?.sequence ?? 0
return {
sessionId: 'session-a',
epoch: 'epoch-a',
direction: 'tail',
items,
removedItemIds: [],
submissions: [],
window: {
oldest: { epoch: 'epoch-a', sequence: oldest },
newest: { epoch: 'epoch-a', sequence: newest },
nextCursor: { epoch: 'epoch-a', sequence: oldest }
},
liveCursor: { epoch: 'epoch-a', sequence: newest },
hasOlder,
hasNewer: false
}
}
function hydrate(items: AgentJournalRenderItem[], hasOlder = false): StructuredAgentSessionState {
return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page: page(items, hasOlder) }
})
}
function streamItems(
state: StructuredAgentSessionState,
sequences: number[]
): StructuredAgentSessionState {
return sequences.reduce(
(current, sequence) =>
reduceStructuredAgentSession(current, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence },
items: [item(sequence)],
removedItemIds: [],
submissions: []
}
}
}),
state
)
}
describe('structured agent session item retention', () => {
it('bounds retained items on a long live session', () => {
const streamed = streamItems(
hydrate([item(0)]),
Array.from({ length: CAP + 500 }, (_, index) => index + 1)
)
expect(streamed.items).toHaveLength(CAP)
expect(streamed.items.at(-1)?.sequence).toBe(CAP + 500)
expect(streamed.items[0]?.sequence).toBe(501)
})
it('offers paging for items the cap dropped', () => {
const streamed = streamItems(
hydrate([item(0)]),
Array.from({ length: CAP + 10 }, (_, index) => index + 1)
)
expect(streamed.hasOlder).toBe(true)
expect(oldestStructuredAgentSessionCursor(streamed)).toEqual({
epoch: 'epoch-a',
sequence: streamed.items[0]?.sequence
})
})
it('leaves a session under the cap untouched', () => {
const hydrated = hydrate([item(0)])
const streamed = streamItems(
hydrated,
Array.from({ length: 200 }, (_, index) => index + 1)
)
expect(streamed.items).toHaveLength(201)
expect(streamed.hasOlder).toBe(false)
})
it('widens the retained window when older items are paged in', () => {
const streamed = streamItems(
hydrate([item(1_000)], true),
Array.from({ length: CAP + 10 }, (_, index) => index + 1_001)
)
const older = reduceStructuredAgentSession(streamed, {
type: 'older-page',
requestedEpoch: 'epoch-a',
page: page(
Array.from({ length: 300 }, (_, index) => item(index + 700)),
true
)
})
expect(older.items).toHaveLength(CAP + 300)
// A live batch slides the widened window by one instead of collapsing it back to the cap.
const afterLive = streamItems(older, [3_000])
expect(afterLive.items).toHaveLength(CAP + 300)
expect(afterLive.items[0]?.sequence).toBe(701)
expect(afterLive.items.some((entry) => entry.sequence === 800)).toBe(true)
})
it('keeps item identity stable when a batch carries no journal change', () => {
const hydrated = hydrate([item(0)])
const unchanged = reduceStructuredAgentSession(hydrated, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
fence: 2,
batch: {
cursor: { epoch: 'epoch-a', sequence: 0 },
items: [],
removedItemIds: [],
submissions: []
}
}
})
expect(unchanged.items).toBe(hydrated.items)
})
})
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import { parsePaneKey } from './stable-pane-id'
import {
activeStructuredAgentSessionTurnId,
hasPersistedStructuredAgentSessionTurn,
hasUnansweredStructuredAgentSessionDispatch,
projectStructuredItemToNativeChat,
projectStructuredAgentSessionStatus,
projectStructuredAgentSessionStatusSummary,
@@ -19,6 +20,22 @@ function item(
return { itemId, sequence, revision: 1, observedAt: sequence, body }
}
function submission(
clientMessageId: string,
dispatchState: AgentJournalSubmission['dispatchState']
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: dispatchState === 'pending' ? null : 2
}
}
describe('structured agent session status projection', () => {
it('reuses immutable item projections and refreshes revisions and resolved prompts', () => {
const original = item('diff', 1, {
@@ -139,6 +156,75 @@ describe('structured agent session status projection', () => {
})
})
it('reads a session as working while a dispatch is unanswered, before any lifecycle row', () => {
const asked = item('asked', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const pending = [submission('m1', 'pending')]
expect(hasUnansweredStructuredAgentSessionDispatch(pending)).toBe(true)
expect(projectStructuredAgentSessionStatus([asked], pending)).toBe('working')
// The first send has no journalled message until the provider replays it.
expect(projectStructuredAgentSessionStatusSummary([], pending)).toEqual({
status: 'working',
latestPrompt: ''
})
expect(projectStructuredAgentSessionStatusSummary([asked], pending)).toEqual({
status: 'working',
latestPrompt: 'go'
})
})
it('does not resurrect old-host unknown work after its execution fence advances', () => {
const oldHostSubmission = { ...submission('m1', 'unknown'), fence: 2 }
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 2)).toBe(true)
expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 3)).toBe(false)
})
it('recognizes recovery from an older host without the optional marker', () => {
expect(
hasUnansweredStructuredAgentSessionDispatch([
{ ...submission('m1', 'unknown'), reason: 'host_restarted_before_acknowledgement' }
])
).toBe(false)
})
it('stops reading a resolved dispatch as work, and lets a pending prompt outrank it', () => {
const asked = item('asked', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'go' }]
})
const prompt = item('prompt', 2, {
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
for (const state of ['accepted', 'rejected'] as const) {
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', state)])).toBe(false)
expect(projectStructuredAgentSessionStatus([asked], [submission('m1', state)])).toBe('idle')
}
// The ack budget elapsing is a delivery answer, not an answer about the turn.
expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', 'unknown')])).toBe(true)
expect(
hasUnansweredStructuredAgentSessionDispatch([
{ ...submission('m1', 'unknown'), recovered: true }
])
).toBe(false)
expect(
projectStructuredAgentSessionStatus([asked, prompt], [submission('m1', 'pending')])
).toBe('attention')
expect(projectStructuredAgentSessionStatusSummary([], [])).toEqual({
status: null,
latestPrompt: ''
})
})
it('carries the running tool and the newest assistant prose the sidebar row shows', () => {
const ask = item('ask', 1, {
kind: 'message',
@@ -5,6 +5,7 @@ import {
} from './agent-status-field-normalization'
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalToolCallItem
} from './agent-session-journal-types'
import {
@@ -175,6 +176,33 @@ export function hasPersistedStructuredAgentSessionTurn(
)
}
/**
* A send the host has journaled that the provider has neither opened a turn for nor refused.
*
* Codex declares `turn/started` within ~150ms, but Claude's running row can only be written once
* the SDK echoes the user message back — a 3.4s median and 18s at p90 on real journals. Waiting
* on that echo to call a session working leaves the whole gap reading idle in the chat and in
* every session list, so the send itself is the evidence.
*
* A live `unknown` still counts because an ambiguous adapter reply does not prove the provider
* stopped. A recovered `unknown` does not — it outlived the host generation that sent it, so
* there is nothing still running to report.
*/
export function hasUnansweredStructuredAgentSessionDispatch(
submissions: readonly AgentJournalSubmission[],
currentFence?: number | null
): boolean {
return submissions.some(
(submission) =>
(currentFence == null || submission.fence >= currentFence) &&
(submission.dispatchState === 'pending' ||
(submission.dispatchState === 'unknown' &&
submission.recovered !== true &&
// Older hosts publish the recovery reason but omit the optional marker.
submission.reason !== 'host_restarted_before_acknowledgement'))
)
}
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
export function structuredAgentSessionTabId(sessionId: string): string {
@@ -182,7 +210,9 @@ export function structuredAgentSessionTabId(sessionId: string): string {
}
export function projectStructuredAgentSessionStatus(
items: readonly AgentJournalRenderItem[]
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null
): StructuredAgentSessionProjectedStatus {
if (
items.some(
@@ -193,7 +223,10 @@ export function projectStructuredAgentSessionStatus(
) {
return 'attention'
}
return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle'
return activeStructuredAgentSessionTurnId(items) ||
hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence)
? 'working'
: 'idle'
}
function messageProse(blocks: readonly NativeChatBlock[]): string {
@@ -270,12 +303,19 @@ export type StructuredAgentSessionStatusProjection = {
* the 8 KB body): a streamed reply re-projects on every journal checkpoint, so the frame
* has to stay small even though the row only ever renders one line of it. */
export function projectStructuredAgentSessionStatusSummary(
items: readonly AgentJournalRenderItem[]
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null
): StructuredAgentSessionStatusProjection {
if (!hasPersistedStructuredAgentSessionTurn(items)) {
// A first send has no journalled message until the provider replays it, so the pending
// dispatch is also what makes a brand-new session listable at all.
if (
!hasPersistedStructuredAgentSessionTurn(items) &&
!hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence)
) {
return { status: null, latestPrompt: '' }
}
const status = projectStructuredAgentSessionStatus(items)
const status = projectStructuredAgentSessionStatus(items, submissions, currentFence)
const activeToolCall = status === 'working' ? activeStructuredAgentSessionToolCall(items) : null
const toolName = activeToolCall
? normalizeOptionalField(activeToolCall.name, AGENT_STATUS_TOOL_NAME_MAX_LENGTH)
@@ -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',
+38 -24
View File
@@ -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 = {
@@ -18,6 +19,8 @@ export type StructuredAgentSessionState = {
fence: number | null
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
/** Head-trim floor for `items`; paging back raises it so a live batch cannot undo the page. */
retainedItemLimit: number
hasOlder: boolean
status: 'idle' | 'loading' | 'ready' | 'error'
error?: string
@@ -35,19 +38,23 @@ export type StructuredAgentSessionAction =
| { type: 'tail-page'; page: AgentSessionHistoryPage }
| { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage }
const MAX_RETAINED_SUBMISSIONS = 256
// Well above the renderer's initial read window (300) plus a page, so only genuinely
// long live sessions trim; anything trimmed is still reachable by paging older.
const MAX_RETAINED_ITEMS = 1024
export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
epoch: null,
cursor: null,
fence: null,
items: [],
submissions: [],
retainedItemLimit: MAX_RETAINED_ITEMS,
hasOlder: false,
status: 'idle',
handoff: null
}
const MAX_RETAINED_SUBMISSIONS = 256
function backgroundTaskStatesEqual(
left: AgentSessionBackgroundTaskState | null | undefined,
right: AgentSessionBackgroundTaskState | null | undefined
@@ -64,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)
)
}
@@ -91,6 +90,7 @@ function replacePage(
fence,
items: [...page.items].sort((left, right) => left.sequence - right.sequence),
submissions: page.submissions,
retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length),
hasOlder: page.hasOlder,
status: 'ready',
handoff: handoff ?? null,
@@ -121,6 +121,13 @@ function mergeItems(
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
}
function trimRetainedItems(
items: AgentJournalRenderItem[],
limit: number
): AgentJournalRenderItem[] {
return items.length <= limit ? items : items.slice(items.length - limit)
}
function mergeSubmissions(
current: readonly AgentJournalSubmission[],
incoming: readonly AgentJournalSubmission[]
@@ -186,6 +193,7 @@ export function reduceStructuredAgentSession(
submissions: sameEpoch
? mergeSubmissions(state.submissions, action.page.submissions)
: action.page.submissions,
retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, action.page.items.length),
hasOlder: action.page.hasOlder,
status: 'ready',
handoff: state.handoff,
@@ -202,9 +210,11 @@ export function reduceStructuredAgentSession(
if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) {
return state
}
const paged = mergeItems(state.items, action.page.items, action.page.removedItemIds)
return {
...state,
items: mergeItems(state.items, action.page.items, action.page.removedItemIds),
items: paged,
retainedItemLimit: Math.max(state.retainedItemLimit, paged.length),
submissions: mergeSubmissions(state.submissions, action.page.submissions),
hasOlder: action.page.hasOlder
}
@@ -246,13 +256,17 @@ export function reduceStructuredAgentSession(
) {
return state
}
const merged = journalUnchanged
? state.items
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds)
const items = trimRetainedItems(merged, state.retainedItemLimit)
return {
...state,
cursor: event.batch.cursor,
fence: event.fence ?? state.fence,
items: journalUnchanged
? state.items
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds),
items,
// A trim leaves older items behind the cursor, so paging must stay offered.
hasOlder: items.length < merged.length ? true : state.hasOlder,
submissions: journalUnchanged
? state.submissions
: mergeSubmissions(state.submissions, event.batch.submissions),
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
classifyTerminalEscapeIntroducer,
type TerminalEscapeIntroducer
} from './terminal-escape-introducer'
/** Independent restatement of the VT500 dispatch table, written from the ranges rather
* than the implementation, so a reordered branch in the real one shows up here. */
function expected(code: number): TerminalEscapeIntroducer {
if ('[' === String.fromCharCode(code)) {
return 'csi'
}
if (']' === String.fromCharCode(code)) {
return 'osc'
}
if ('PX^_'.includes(String.fromCharCode(code))) {
return 'string'
}
if (String.fromCharCode(code) >= ' ' && String.fromCharCode(code) <= '/') {
return 'intermediate'
}
if (code < 0x20 || code === 0x7f) {
return 'execute'
}
return 'final'
}
describe('terminal escape introducer', () => {
it('classifies every single-byte introducer the way the VT500 table does', () => {
for (let code = 0; code <= 0xff; code += 1) {
expect([code, classifyTerminalEscapeIntroducer(code)]).toEqual([code, expected(code)])
}
})
it('opens ST-terminated strings for DCS, SOS, PM and APC only', () => {
const strings = Array.from({ length: 0x100 }, (_, code) => code).filter(
(code) => classifyTerminalEscapeIntroducer(code) === 'string'
)
expect(strings.map((code) => String.fromCharCode(code))).toEqual(['P', 'X', '^', '_'])
})
it('reads a missing byte (ESC at end of input) as a completed two-byte sequence', () => {
// `charCodeAt` past the end is NaN; callers rely on that not becoming csi/osc/string.
expect(classifyTerminalEscapeIntroducer(''.charCodeAt(0))).toBe('final')
})
})
+32
View File
@@ -0,0 +1,32 @@
// The byte after ESC decides which VT500 sequence just opened. Two scanners need that
// decision -- the partial-tail state machine (`terminal-partial-escape-tail.ts`) and the
// preview normalizer's per-sequence parser (`terminal-ansi-normalization.ts`) -- and they
// must agree on the DCS/SOS/PM/APC set, so the table lives here rather than in each.
export type TerminalEscapeIntroducer =
| 'csi' // [
| 'osc' // ]
| 'string' // P / X / ^ / _ open DCS / SOS / PM / APC -- ST-terminated
| 'intermediate' // 0x20-0x2f, more bytes to come before the final
| 'execute' // C0 executes / DEL is ignored; the ESC is still pending
| 'final' // completes a two-byte sequence (ESC 7, ESC 8, ESC c, ...)
/** Classifies the code unit after ESC. `NaN` (ESC at end of input) reads as `final`. */
export function classifyTerminalEscapeIntroducer(code: number): TerminalEscapeIntroducer {
if (code === 0x5b) {
return 'csi'
}
if (code === 0x5d) {
return 'osc'
}
if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) {
return 'string'
}
if (code >= 0x20 && code <= 0x2f) {
return 'intermediate'
}
if (code < 0x20 || code === 0x7f) {
return 'execute'
}
return 'final'
}
+15 -16
View File
@@ -6,6 +6,8 @@
// partial sequence at the ingest boundary lets snapshot producers append it
// after the serialized screen so the continuation completes exactly as live.
import { classifyTerminalEscapeIntroducer } from './terminal-escape-introducer'
// Mirrors the VT500 parser states that can span a chunk boundary. C0 controls
// (except ESC/CAN/SUB) execute mid-sequence without aborting it, matching
// xterm's state machine.
@@ -32,23 +34,20 @@ export const MAX_PARTIAL_ESCAPE_TAIL_LENGTH = 4096
/** ESC-state transition shared by the fresh-ESC and abort-reprocess paths. */
function stateAfterEscByte(code: number): ScanState {
if (code === 0x5b) {
return 'csi' // [
switch (classifyTerminalEscapeIntroducer(code)) {
case 'csi':
return 'csi'
case 'osc':
return 'osc'
case 'string':
return 'string'
case 'intermediate':
return 'escIntermediate'
case 'execute':
return 'esc' // the ESC is still pending; a further ESC arrives via the callers
case 'final':
return 'ground'
}
if (code === 0x5d) {
return 'osc' // ]
}
// P / X / ^ / _ open DCS / SOS / PM / APC — ST-terminated strings.
if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) {
return 'string'
}
if (code >= 0x20 && code <= 0x2f) {
return 'escIntermediate'
}
if (code < 0x20 || code === 0x7f) {
return 'esc' // C0 executes / DEL is ignored mid-sequence; ESC via callers
}
return 'ground' // final byte — two-byte sequence (ESC 7, ESC 8, ESC c, …)
}
/** Returns the trailing incomplete escape sequence of `stream` ('' when the