mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/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:
@@ -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 |
@@ -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",
|
||||
|
||||
Generated
+14
@@ -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
|
||||
|
||||
@@ -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' } }]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
+5
-1
@@ -21,7 +21,10 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession,
|
||||
private readonly handoffStatus: (
|
||||
sessionId: string
|
||||
) => Parameters<AgentSessionSubscribers['open']>[0]['handoff']
|
||||
) => Parameters<AgentSessionSubscribers['open']>[0]['handoff'],
|
||||
/** Task edges change the status summary too; the feed's equality check
|
||||
* keeps a no-op re-projection from reaching subscribers. */
|
||||
private readonly onPublished: (sessionId: string) => void
|
||||
) {}
|
||||
|
||||
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
|
||||
@@ -53,6 +56,7 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
const state = publishedState !== undefined ? publishedState : this.state(sessionId)
|
||||
if (session && state !== undefined) {
|
||||
this.subscribers.backgroundTasks(sessionId, state, session.fence)
|
||||
this.onPublished(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+34
@@ -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',
|
||||
|
||||
+205
-4
@@ -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()
|
||||
}
|
||||
|
||||
+6
@@ -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.
|
||||
|
||||
+13
-15
@@ -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()
|
||||
|
||||
+13
-15
@@ -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 }
|
||||
}
|
||||
|
||||
+20
-12
@@ -1,8 +1,8 @@
|
||||
// The background-tasks strip's view of one session's wire state.
|
||||
//
|
||||
// The strip stands for work that OUTLIVED a turn, not work in flight: a running
|
||||
// turn already has the working status, the turn activity line, and its own
|
||||
// durable rows, so the strip is suppressed while one is open.
|
||||
// The strip stands for work that OUTLIVES a turn. It stays mounted through a
|
||||
// running turn — a fan-out's children keep reporting long after the parent
|
||||
// settles — but only an idle session lets it animate or speak for itself.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
@@ -10,22 +10,30 @@ import type {
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
|
||||
export type StructuredSessionBackgroundTasksView = {
|
||||
isMonitoringBackgroundTasks: boolean
|
||||
backgroundTasks: readonly AgentSessionBackgroundTask[]
|
||||
supportsBackgroundTaskStop: boolean
|
||||
supportsBackgroundTaskStopAll: boolean
|
||||
/** The strip renders whenever the host reports monitoring — mid-turn included. */
|
||||
show: boolean
|
||||
/** Idle-only: gates the animated monitoring indicator and conversation
|
||||
* commands, never the strip itself. A running turn owns the voice. */
|
||||
isMonitoring: boolean
|
||||
tasks: AgentSessionBackgroundTask[]
|
||||
settledTasks: AgentSessionBackgroundTask[]
|
||||
supportsStop: boolean
|
||||
supportsStopAll: boolean
|
||||
}
|
||||
|
||||
export function structuredSessionBackgroundTasksView(
|
||||
state: AgentSessionBackgroundTaskState | null | undefined,
|
||||
backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
|
||||
turnId: string | null
|
||||
): StructuredSessionBackgroundTasksView {
|
||||
const monitoring = backgroundTasks?.state === 'monitoring'
|
||||
return {
|
||||
isMonitoringBackgroundTasks: turnId === null && state?.state === 'monitoring',
|
||||
backgroundTasks: state?.tasks ?? [],
|
||||
supportsBackgroundTaskStop: state?.supportsTaskStop === true,
|
||||
show: monitoring,
|
||||
isMonitoring: turnId === null && monitoring,
|
||||
tasks: backgroundTasks?.tasks ?? [],
|
||||
settledTasks: backgroundTasks?.settledTasks ?? [],
|
||||
supportsStop: backgroundTasks?.supportsTaskStop === true,
|
||||
// Absent means the host predates the field and does accept an untargeted
|
||||
// stop; only a host that says `false` has none to offer.
|
||||
supportsBackgroundTaskStopAll: state?.supportsStopAll !== false
|
||||
supportsStopAll: backgroundTasks?.supportsStopAll !== false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co
|
||||
items,
|
||||
triggerKey: '/',
|
||||
prefix: '/',
|
||||
dispatchable: true,
|
||||
grouped: true,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: true,
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state'
|
||||
import {
|
||||
applyPickerSuggestion,
|
||||
deriveComposerAutocomplete,
|
||||
EMPTY_HISTORY,
|
||||
type ComposerAutocomplete
|
||||
} from './native-chat-composer-state'
|
||||
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
|
||||
|
||||
const COMMAND = {
|
||||
kind: 'command' as const,
|
||||
id: 'command:clear',
|
||||
name: 'clear',
|
||||
token: '/clear',
|
||||
description: 'Clear history',
|
||||
skillCollision: false
|
||||
}
|
||||
@@ -20,6 +27,7 @@ function picker(items = [COMMAND]): Extract<ComposerAutocomplete, { mode: 'slash
|
||||
items,
|
||||
triggerKey: '/:0',
|
||||
prefix: '/',
|
||||
dispatchable: true,
|
||||
grouped: false,
|
||||
commandsEnabled: true,
|
||||
skillsEnabled: false,
|
||||
@@ -27,7 +35,7 @@ function picker(items = [COMMAND]): Extract<ComposerAutocomplete, { mode: 'slash
|
||||
}
|
||||
}
|
||||
|
||||
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false) {
|
||||
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false, draft = '/') {
|
||||
const callbacks = {
|
||||
completePickerItem: vi.fn(),
|
||||
dispatchPickerCommand: vi.fn(),
|
||||
@@ -43,7 +51,7 @@ function setup(autocomplete: ComposerAutocomplete = picker(), composing = false)
|
||||
useNativeChatComposerKeyDown({
|
||||
autocomplete,
|
||||
activeSuggestion: 0,
|
||||
draft: '/',
|
||||
draft,
|
||||
history: EMPTY_HISTORY,
|
||||
isComposing: () => composing,
|
||||
...callbacks
|
||||
@@ -81,6 +89,36 @@ describe('useNativeChatComposerKeyDown', () => {
|
||||
expect(callbacks.send).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['claude', 'openclaude', 'codex', 'grok'] as const)(
|
||||
'completes mid-prompt command Enter without dispatching or losing prose for %s',
|
||||
(agent) => {
|
||||
const draft = 'Explain /cle before continuing'
|
||||
const caret = 'Explain /cle'.length
|
||||
const autocomplete = deriveComposerAutocomplete(
|
||||
draft,
|
||||
caret,
|
||||
[COMMAND],
|
||||
[],
|
||||
getNativeChatAgentProfile(agent)
|
||||
)
|
||||
expect(autocomplete.mode).toBe('slash')
|
||||
const { handler, callbacks } = setup(autocomplete, false, draft)
|
||||
const event = keyEvent('Enter')
|
||||
handler(event as never)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled()
|
||||
expect(callbacks.send).not.toHaveBeenCalled()
|
||||
expect(callbacks.completePickerItem).toHaveBeenCalledOnce()
|
||||
const [item] = callbacks.completePickerItem.mock.calls[0]
|
||||
expect(applyPickerSuggestion(draft, caret, item)).toEqual({
|
||||
draft: 'Explain /clear before continuing',
|
||||
caret: 'Explain /clear '.length,
|
||||
insertedToken: '/clear'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('dismisses Escape without interrupting the agent', () => {
|
||||
const { handler, callbacks } = setup()
|
||||
handler(keyEvent('Escape') as never)
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useNativeChatComposerKeyDown({
|
||||
return
|
||||
}
|
||||
|
||||
if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') {
|
||||
if (autocomplete.mode === 'slash') {
|
||||
const items = autocomplete.items
|
||||
if (event.key === 'ArrowDown' && items.length > 0) {
|
||||
event.preventDefault()
|
||||
@@ -66,7 +66,9 @@ export function useNativeChatComposerKeyDown({
|
||||
if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) {
|
||||
event.preventDefault()
|
||||
const item = items[activeSuggestion] ?? items[0]
|
||||
if (event.key === 'Enter' && item.kind === 'command') {
|
||||
// A mid-prompt command is part of the sentence being written, so Enter
|
||||
// completes the token instead of sending the command on its own.
|
||||
if (event.key === 'Enter' && item.kind === 'command' && autocomplete.dispatchable) {
|
||||
dispatchPickerCommand(item)
|
||||
} else {
|
||||
completePickerItem(item)
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ const COMMAND = {
|
||||
kind: 'command' as const,
|
||||
id: 'command:status',
|
||||
name: 'status',
|
||||
token: '/status',
|
||||
description: 'Show status',
|
||||
skillCollision: false
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
classifyNativeChatSend,
|
||||
deriveComposerAutocomplete,
|
||||
editReplacesTriggerToken,
|
||||
isSkillPickerTriggered,
|
||||
type ComposerAutocomplete,
|
||||
type NativeChatPickerItem,
|
||||
type NativeChatSendClassification
|
||||
@@ -68,13 +69,7 @@ export function useNativeChatPickerState(args: {
|
||||
setActiveSuggestion
|
||||
} = args
|
||||
const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent])
|
||||
const beforeCaret = draft.slice(0, caret)
|
||||
const skillPickerTriggered =
|
||||
profile?.skillPrefix === '$'
|
||||
? /(?:^|\s)\$\S*$/.test(beforeCaret)
|
||||
: profile?.skillPrefix === '/'
|
||||
? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret)
|
||||
: false
|
||||
const skillPickerTriggered = isSkillPickerTriggered(draft.slice(0, caret), profile)
|
||||
const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered)
|
||||
const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}`
|
||||
const dismissalContext = `${draftScopeKey}:${agent}`
|
||||
@@ -114,7 +109,7 @@ export function useNativeChatPickerState(args: {
|
||||
}, [dismissalContext])
|
||||
|
||||
useEffect(() => {
|
||||
if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') {
|
||||
if (autocomplete.mode !== 'slash') {
|
||||
lastOpenKeyRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -127,10 +122,10 @@ export function useNativeChatPickerState(args: {
|
||||
|
||||
const completeItem = useCallback(
|
||||
(item: NativeChatPickerItem) => {
|
||||
if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') {
|
||||
if (autocomplete.mode !== 'slash') {
|
||||
return
|
||||
}
|
||||
const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix)
|
||||
const result = applyPickerSuggestion(draft, caret, item)
|
||||
if (item.kind === 'skill' && textareaRef.current?.insertSkill) {
|
||||
const from = result.caret - result.insertedToken.length - 1
|
||||
textareaRef.current.insertSkill(from, caret, result.insertedToken)
|
||||
@@ -174,10 +169,7 @@ export function useNativeChatPickerState(args: {
|
||||
null,
|
||||
sessionSkillNames
|
||||
)
|
||||
if (
|
||||
(next.mode !== 'slash' && next.mode !== 'skill') ||
|
||||
next.triggerKey !== dismissed.triggerKey
|
||||
) {
|
||||
if (next.mode !== 'slash' || next.triggerKey !== dismissed.triggerKey) {
|
||||
setDismissed(null)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatSkillDiscovery } from './use-native-chat-skills'
|
||||
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { isSkillPickerTriggered } from './native-chat-composer-state'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
@@ -56,6 +58,10 @@ function Probe({ enabled }: { enabled: boolean }): null {
|
||||
return null
|
||||
}
|
||||
|
||||
function DraftProbe({ draft }: { draft: string }): React.JSX.Element {
|
||||
return <Probe enabled={isSkillPickerTriggered(draft, getNativeChatAgentProfile('codex'))} />
|
||||
}
|
||||
|
||||
describe('useNativeChatSkills', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = stateForHost('local')
|
||||
@@ -132,6 +138,24 @@ describe('useNativeChatSkills', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses one discovery while typing and reopening leading and mid-prompt slash tokens', async () => {
|
||||
const view = render(<DraftProbe draft="Explain" />)
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(<DraftProbe draft="Explain /" />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
|
||||
for (const draft of ['Explain /b', 'Explain /br', 'Explain /bro']) {
|
||||
view.rerender(<DraftProbe draft={draft} />)
|
||||
expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser'])
|
||||
}
|
||||
view.rerender(<DraftProbe draft="Explain $browser " />)
|
||||
expect(mocks.snapshots.at(-1)?.status).toBe('idle')
|
||||
view.rerender(<DraftProbe draft="/" />)
|
||||
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
|
||||
view.rerender(<DraftProbe draft="/bro" />)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces discovery failure instead of remaining loading', async () => {
|
||||
mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed'))
|
||||
render(<Probe enabled />)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user