mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
refactor: group native chat turn presentation (#34629)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
|
||||
|
||||
it('does not trim every preceding activity line to select the final activity', () => {
|
||||
const text = `${'Previous activity\n'.repeat(500)}Preparing the answer`
|
||||
const trim = vi.spyOn(String.prototype, 'trim')
|
||||
try {
|
||||
expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual({
|
||||
kind: 'description',
|
||||
text: 'Preparing the answer'
|
||||
})
|
||||
expect(trim.mock.calls.length).toBeLessThan(10)
|
||||
} finally {
|
||||
trim.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the last nonempty LF-delimited line before prompt normalization', () => {
|
||||
const cases: [string, string | null][] = [
|
||||
['', null],
|
||||
['\n', null],
|
||||
['\n\n', null],
|
||||
[' \r\n\t', null],
|
||||
['first\rsecond', 'first second'],
|
||||
['\nfirst\r\nsecond\r\n', 'second'],
|
||||
['first\n\u00a0\u2003\n', 'first'],
|
||||
['first\n\u2028second\u2029', 'second'],
|
||||
['a\n😀', '😀'],
|
||||
['a\n\ud800', '\ud800'],
|
||||
['a\n\udc00', '\udc00'],
|
||||
[`a\n${'x'.repeat(199)}😀`, 'x'.repeat(199)],
|
||||
[`first\n${' '.repeat(3000)}last`, 'last'],
|
||||
['first\n\0\n', '\0'],
|
||||
['first\n\ufeff\n', 'first']
|
||||
]
|
||||
for (const [text, expected] of cases) {
|
||||
expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual(
|
||||
expected ? { kind: 'description', text: expected } : null
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../agent-session-journal-types'
|
||||
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
|
||||
|
||||
function item(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem {
|
||||
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
|
||||
}
|
||||
|
||||
const turnStart = item(1, {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||||
})
|
||||
|
||||
describe('selectStructuredAgentTurnActivity', () => {
|
||||
it('shows the latest line of the live provider activity in the active turn', () => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[
|
||||
turnStart,
|
||||
item(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'completed'
|
||||
})
|
||||
],
|
||||
'turn-1',
|
||||
{ turnId: 'turn-1', text: 'Checking the results\nPreparing the answer' }
|
||||
)
|
||||
|
||||
expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' })
|
||||
})
|
||||
|
||||
it("never puts the model's reasoning on the indicator line", () => {
|
||||
const reasoning = item(2, {
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text: 'Let me check whether the journal already records this' }]
|
||||
})
|
||||
|
||||
// Reasoning is the turn's content; the row says the turn is thinking instead.
|
||||
expect(selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1')).toBeNull()
|
||||
// Provider-authored copy is unaffected, so Codex keeps its line.
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1', {
|
||||
turnId: 'turn-1',
|
||||
text: 'Running a command'
|
||||
})
|
||||
).toEqual({ kind: 'description', text: 'Running a command' })
|
||||
})
|
||||
|
||||
it('shows matching ephemeral provider activity, never an older journal status', () => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[turnStart, item(2, { kind: 'status', text: 'Older journal status' })],
|
||||
'turn-1',
|
||||
{ turnId: 'turn-1', text: 'Inspecting the session wire' }
|
||||
)
|
||||
|
||||
expect(activity).toEqual({ kind: 'description', text: 'Inspecting the session wire' })
|
||||
})
|
||||
|
||||
it('ignores ephemeral activity from another or settled turn', () => {
|
||||
const providerActivity = { turnId: 'turn-1', text: 'Inspecting the session wire' }
|
||||
|
||||
expect(selectStructuredAgentTurnActivity([turnStart], 'turn-2', providerActivity)).toBeNull()
|
||||
expect(selectStructuredAgentTurnActivity([turnStart], null, providerActivity)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['active', 'Still running pnpm test'],
|
||||
['most recently settled', 'Running shell pnpm lint now']
|
||||
])('never repeats the %s tool label as provider activity', (_kind, text) => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[
|
||||
turnStart,
|
||||
item(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'running'
|
||||
}),
|
||||
item(3, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm lint' },
|
||||
state: 'completed'
|
||||
})
|
||||
],
|
||||
'turn-1',
|
||||
{ turnId: 'turn-1', text }
|
||||
)
|
||||
|
||||
expect(activity).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStructuredAgentTurnActivity — which agent it answers for', () => {
|
||||
/** A row a subagent produced, which shares the session's journal. */
|
||||
function childItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem {
|
||||
return { ...item(sequence, body), agentId: 'task-1', producerKind: 'agent' }
|
||||
}
|
||||
|
||||
const childRunningBash = childItem(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'running'
|
||||
})
|
||||
|
||||
it("does not let a child's tool label suppress the provider's line for the parent", () => {
|
||||
// Child tool labels cannot dedupe the session parent’s own provider activity line.
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity([turnStart, childRunningBash], 'turn-1', {
|
||||
turnId: 'turn-1',
|
||||
text: 'pnpm test'
|
||||
})
|
||||
).toEqual({ kind: 'description', text: 'pnpm test' })
|
||||
})
|
||||
|
||||
it("still suppresses a line repeating the session's OWN running tool", () => {
|
||||
// The scoping must not disable the de-duplication it was narrowing.
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity(
|
||||
[
|
||||
turnStart,
|
||||
item(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'running'
|
||||
})
|
||||
],
|
||||
'turn-1',
|
||||
{ turnId: 'turn-1', text: 'pnpm test' }
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readAgentJournalTurn } from '../../agent-session-turn-record'
|
||||
import type { AgentJournalRenderItem } from '../../agent-session-journal-types'
|
||||
import type { AgentSessionTurnActivity } from '../../agent-session-wire'
|
||||
import { isRootAgentJournalItem } from '../../agent-session-journal-producer'
|
||||
import { normalizePromptField } from '../../agent-status-field-normalization'
|
||||
import { describeActiveToolCall, formatActiveToolLabel } from '../tools/native-chat-tool-activity'
|
||||
|
||||
export type NativeChatTurnActivity = { kind: 'description'; text: string }
|
||||
|
||||
function activityLine(text: string): string | null {
|
||||
let end = text.length
|
||||
while (end > 0) {
|
||||
const start = text.lastIndexOf('\n', end - 1) + 1
|
||||
const latest = text.slice(start, end).trim()
|
||||
if (latest) {
|
||||
return normalizePromptField(latest) || null
|
||||
}
|
||||
if (start === 0) {
|
||||
break
|
||||
}
|
||||
end = start - 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function recentToolActivityLabels(items: readonly AgentJournalRenderItem[]): Set<string> {
|
||||
const labels = new Set<string>()
|
||||
let foundRunning = false
|
||||
let foundSettled = false
|
||||
for (let index = items.length - 1; index >= 0 && (!foundRunning || !foundSettled); index -= 1) {
|
||||
const body = items[index]?.body
|
||||
if (body?.kind !== 'tool-call') {
|
||||
continue
|
||||
}
|
||||
const isRunning = body.state === 'running'
|
||||
if ((isRunning && foundRunning) || (!isRunning && foundSettled)) {
|
||||
continue
|
||||
}
|
||||
const descriptor = describeActiveToolCall({
|
||||
type: 'tool-call',
|
||||
name: body.name,
|
||||
input: body.input,
|
||||
state: body.state
|
||||
})
|
||||
const candidates = [
|
||||
formatActiveToolLabel(descriptor),
|
||||
descriptor.preview,
|
||||
descriptor.preview ? `${descriptor.toolName} ${descriptor.preview}` : descriptor.toolName
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const label = activityLine(candidate)?.toLowerCase()
|
||||
if (label) {
|
||||
labels.add(label)
|
||||
}
|
||||
}
|
||||
foundRunning ||= isRunning
|
||||
foundSettled ||= !isRunning
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
function repeatsRecentToolLabel(text: string, labels: ReadonlySet<string>): boolean {
|
||||
const normalized = text.toLowerCase()
|
||||
for (const label of labels) {
|
||||
if (
|
||||
normalized === label ||
|
||||
normalized.startsWith(`${label} `) ||
|
||||
normalized.endsWith(` ${label}`) ||
|
||||
normalized.includes(` ${label} `)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The live provider activity for this turn; callers provide the broad fallback.
|
||||
* Journal rows are history, not the present, so none of them ever becomes this line. */
|
||||
export function selectStructuredAgentTurnActivity(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
turnId: string | null,
|
||||
providerActivity?: AgentSessionTurnActivity | null
|
||||
): NativeChatTurnActivity | null {
|
||||
if (!turnId || providerActivity?.turnId !== turnId) {
|
||||
return null
|
||||
}
|
||||
const text = activityLine(providerActivity.text)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const turnStartIndex = items.findLastIndex((item) => {
|
||||
const turn = readAgentJournalTurn(item.body)
|
||||
return turn?.turnId === turnId && turn.state === 'running'
|
||||
})
|
||||
// Root-scoped: a subagent's tool label must not suppress the parent's own line.
|
||||
const turnItems = items.slice(Math.max(0, turnStartIndex)).filter(isRootAgentJournalItem)
|
||||
return repeatsRecentToolLabel(text, recentToolActivityLabels(turnItems))
|
||||
? null
|
||||
: { kind: 'description', text }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
nativeChatTurnAnswerRows,
|
||||
nativeChatTurnFold,
|
||||
type NativeChatTurnFoldRow
|
||||
} from './native-chat-turn-fold'
|
||||
|
||||
function row(overrides: Partial<NativeChatTurnFoldRow> = {}): NativeChatTurnFoldRow {
|
||||
return {
|
||||
turnKey: 'turn-1',
|
||||
role: 'assistant',
|
||||
rendersProse: true,
|
||||
draws: true,
|
||||
outlivesTurn: false,
|
||||
reportsFailure: false,
|
||||
explainsTurn: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** The turn in the reported session: a prompt, narration, work, then the answer. */
|
||||
const TURN: NativeChatTurnFoldRow[] = [
|
||||
row({ role: 'user' }),
|
||||
row(),
|
||||
row({ rendersProse: false }),
|
||||
row(),
|
||||
row()
|
||||
]
|
||||
|
||||
/** An error-toned system row: the agent or host reporting the turn failed. */
|
||||
const FAILURE = row({ role: 'system', reportsFailure: true })
|
||||
|
||||
const SETTLED = new Set(['turn-1'])
|
||||
const NONE = new Set<string>()
|
||||
|
||||
describe('nativeChatTurnAnswerRows', () => {
|
||||
it('names the last assistant row that renders prose, not the first', () => {
|
||||
expect(nativeChatTurnAnswerRows(TURN).get('turn-1')).toBe(4)
|
||||
})
|
||||
|
||||
it('ignores rows that render no prose, so a trailing tool run is not the answer', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ rendersProse: false })]
|
||||
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores reasoning and system rows, which are never the agent answering', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ role: 'reasoning' }), row({ role: 'system' })]
|
||||
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1)
|
||||
})
|
||||
|
||||
it('names a failure report as the answer when nothing the agent said comes after it', () => {
|
||||
const rows = [row({ role: 'user' }), row(), FAILURE]
|
||||
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2)
|
||||
})
|
||||
|
||||
it('names the later prose as the answer when the agent recovered from a failure', () => {
|
||||
const rows = [row({ role: 'user' }), FAILURE, row()]
|
||||
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2)
|
||||
})
|
||||
|
||||
it('reports no answer for a turn that only ran tools', () => {
|
||||
const rows = [row({ role: 'user' }), row({ rendersProse: false })]
|
||||
expect(nativeChatTurnAnswerRows(rows).has('turn-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnFold', () => {
|
||||
it('folds a settled turn to its answer', () => {
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
// A stored-only provider event draws nothing: a disclosure over it would open onto nothing.
|
||||
it('offers no disclosure for a turn whose only other row draws nothing', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: [
|
||||
row({ role: 'user' }),
|
||||
row({ role: 'system', rendersProse: false, draws: false }),
|
||||
row()
|
||||
],
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('folds nothing while the turn is still running', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: NONE,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reveals every row of a turn the reader opened, and still reports it foldable', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: SETTLED
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect([...foldableTurnKeys]).toEqual(['turn-1'])
|
||||
})
|
||||
|
||||
it('keeps a spawn roster or background task out of the fold', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ outlivesTurn: true }), row()]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.has(2)).toBe(false)
|
||||
expect(foldedRows.has(1)).toBe(true)
|
||||
})
|
||||
|
||||
it('folds a prose-less turn whole, so its commands do not return to the transcript', () => {
|
||||
const rows = [row({ role: 'user' }), row({ rendersProse: false }), row({ rendersProse: false })]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2])
|
||||
expect([...foldableTurnKeys]).toEqual(['turn-1'])
|
||||
})
|
||||
|
||||
it('offers no disclosure on a turn that is nothing but its answer', () => {
|
||||
const rows = [row({ role: 'user' }), row()]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('folds each settled turn to its own answer and leaves a running turn alone', () => {
|
||||
const rows = [
|
||||
row({ turnKey: 'turn-1', role: 'user' }),
|
||||
row({ turnKey: 'turn-1' }),
|
||||
row({ turnKey: 'turn-1' }),
|
||||
row({ turnKey: 'turn-2', role: 'user' }),
|
||||
row({ turnKey: 'turn-2' }),
|
||||
row({ turnKey: 'turn-2' })
|
||||
]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: new Set(['turn-1']),
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows]).toEqual([1])
|
||||
})
|
||||
|
||||
it('leaves rows before the first prompt alone', () => {
|
||||
const rows = [row({ turnKey: undefined }), row({ turnKey: undefined })]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ends a failed turn on its error, folding the work and prose before it', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ rendersProse: false }), FAILURE]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2])
|
||||
expect([...foldableTurnKeys]).toEqual(['turn-1'])
|
||||
})
|
||||
|
||||
it('folds an error the agent recovered from behind the answer that followed it', () => {
|
||||
const rows = [row({ role: 'user' }), row({ rendersProse: false }), FAILURE, row()]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('shows a turn that is nothing but its error as that error, with no disclosure', () => {
|
||||
const rows = [row({ role: 'user' }), FAILURE]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('never folds a compaction report, and keeps the last prose as the answer', () => {
|
||||
const rows = [
|
||||
row({ role: 'user' }),
|
||||
row(),
|
||||
row({ role: 'system', explainsTurn: true }),
|
||||
row(),
|
||||
row({ role: 'system', explainsTurn: true })
|
||||
]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows]).toEqual([1])
|
||||
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
// Derive settled-turn answers from the last assistant prose or system failure; the journal has no answer marker.
|
||||
// Earlier work/recovered errors fold behind that answer, while failed turns end visibly on their error.
|
||||
|
||||
import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../agent-session-compaction'
|
||||
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../agent-session-orca-stop'
|
||||
import { AGENT_SESSION_TURN_NOTICE_PRESENTATION } from '../../agent-session-turn-notice'
|
||||
import { nativeChatRowRendersContent } from '../../native-chat-row-content'
|
||||
import { isStoppedBeforeStartBlock } from '../native-chat-stopped-before-start'
|
||||
import { nativeChatRowRendersProse } from '../native-chat-trailing-run'
|
||||
import {
|
||||
isBackgroundTaskBlock,
|
||||
isSubagentGroupBlock,
|
||||
type NativeChatMessage,
|
||||
type NativeChatRole
|
||||
} from '../../native-chat-types'
|
||||
|
||||
/** Each surface supplies its own row-content results so folding sees exactly the prose it renders. */
|
||||
export type NativeChatTurnFoldRow = {
|
||||
/** The turn this row belongs to; undefined before the first user message. */
|
||||
turnKey: string | undefined
|
||||
role: NativeChatRole
|
||||
/** Whether the row draws prose — the only thing that can be an answer. */
|
||||
rendersProse: boolean
|
||||
/** Invisible rows cannot justify a disclosure: they hide nothing. */
|
||||
draws: boolean
|
||||
/** Spawn/task rows outlive their turn and may be the only outcome report; never fold them. */
|
||||
outlivesTurn: boolean
|
||||
/** System failures compete as final answers so failed turns end on visible errors. */
|
||||
reportsFailure: boolean
|
||||
/** Compaction/interruption/turn notices remain visible; hiding them leaves only generic turn status. */
|
||||
explainsTurn: boolean
|
||||
}
|
||||
|
||||
/** How the fold reads a transcript row, with the same derivation the row renders from. */
|
||||
export function nativeChatTurnFoldRow(
|
||||
message: NativeChatMessage,
|
||||
turnKey: string | undefined,
|
||||
hasReceipt: boolean
|
||||
): NativeChatTurnFoldRow {
|
||||
return {
|
||||
turnKey,
|
||||
role: message.role,
|
||||
rendersProse: nativeChatRowRendersProse(message),
|
||||
draws: hasReceipt || nativeChatRowRendersContent(message.blocks),
|
||||
// Use raw roster blocks: empty rosters render no block, making their plain-text twin the sole spawn record.
|
||||
outlivesTurn: message.blocks.some(
|
||||
(block) =>
|
||||
isSubagentGroupBlock(block) ||
|
||||
isBackgroundTaskBlock(block) ||
|
||||
isStoppedBeforeStartBlock(block)
|
||||
),
|
||||
// A row about Orca's own stop is stored red for clients that predate it; it reports no failure.
|
||||
reportsFailure: message.blocks.some(
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
(block.tone === 'error' || block.failure?.kind === 'usageLimitReached') &&
|
||||
block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION
|
||||
),
|
||||
explainsTurn: message.blocks.some(
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
(block.presentation === 'compaction' ||
|
||||
block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION ||
|
||||
block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ||
|
||||
block.presentation === AGENT_SESSION_TURN_NOTICE_PRESENTATION)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeChatTurnFold = {
|
||||
/** Rows a folded turn must not draw, by index. */
|
||||
foldedRows: ReadonlySet<number>
|
||||
/** Offer disclosure only for turns that actually hide rows. */
|
||||
foldableTurnKeys: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export const NATIVE_CHAT_EMPTY_TURN_FOLD: NativeChatTurnFold = {
|
||||
foldedRows: new Set(),
|
||||
foldableTurnKeys: new Set()
|
||||
}
|
||||
|
||||
/** Last assistant prose/system failure per turn; no answer means fold all work. */
|
||||
export function nativeChatTurnAnswerRows(
|
||||
rows: readonly NativeChatTurnFoldRow[]
|
||||
): ReadonlyMap<string, number> {
|
||||
const answers = new Map<string, number>()
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const isCandidate =
|
||||
row.rendersProse &&
|
||||
(row.role === 'assistant' || (row.role === 'system' && row.reportsFailure))
|
||||
if (row.turnKey !== undefined && isCandidate) {
|
||||
answers.set(row.turnKey, index)
|
||||
}
|
||||
}
|
||||
return answers
|
||||
}
|
||||
|
||||
/** Fold settled, unexpanded turns when they gain a duration/status row. Running turns stay open; newly settled turns start collapsed without separate disclosure cleanup. */
|
||||
export function nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys,
|
||||
expandedTurnKeys
|
||||
}: {
|
||||
rows: readonly NativeChatTurnFoldRow[]
|
||||
settledTurnKeys: ReadonlySet<string>
|
||||
expandedTurnKeys: ReadonlySet<string>
|
||||
}): NativeChatTurnFold {
|
||||
const answers = nativeChatTurnAnswerRows(rows)
|
||||
const foldedRows = new Set<number>()
|
||||
const foldableTurnKeys = new Set<string>()
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const { turnKey } = row
|
||||
// User anchors, durable task/roster reports and turn explanations remain outside the fold.
|
||||
if (
|
||||
turnKey === undefined ||
|
||||
row.role === 'user' ||
|
||||
!row.draws ||
|
||||
row.outlivesTurn ||
|
||||
row.explainsTurn ||
|
||||
!settledTurnKeys.has(turnKey)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// No-prose turns fold wholly behind the status anchor instead of leaving every command expanded.
|
||||
if (index === answers.get(turnKey)) {
|
||||
continue
|
||||
}
|
||||
foldableTurnKeys.add(turnKey)
|
||||
if (!expandedTurnKeys.has(turnKey)) {
|
||||
foldedRows.add(index)
|
||||
}
|
||||
}
|
||||
return { foldedRows, foldableTurnKeys }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../native-chat-types'
|
||||
import {
|
||||
nativeChatRowTurnKeys,
|
||||
nativeChatTurnBarRows,
|
||||
nativeChatTurnDrawOrder
|
||||
} from './native-chat-turn-grouping'
|
||||
|
||||
function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
|
||||
return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' }
|
||||
}
|
||||
|
||||
describe('nativeChatRowTurnKeys', () => {
|
||||
it('keeps rows after a mid-turn send with the turn that produced them', () => {
|
||||
// The #23621 shape: B lands mid-turn, three tool rows follow, all one turn.
|
||||
const messages = [
|
||||
message('A', 'user'),
|
||||
message('t1'),
|
||||
message('B', 'user'),
|
||||
message('t2'),
|
||||
message('t3'),
|
||||
message('t4')
|
||||
]
|
||||
const owned = new Map([
|
||||
['A', 'A'],
|
||||
['t1', 'A'],
|
||||
['B', 'A'],
|
||||
['t2', 'A'],
|
||||
['t3', 'A'],
|
||||
['t4', 'A']
|
||||
])
|
||||
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'A', 'A', 'A', 'A'])
|
||||
})
|
||||
|
||||
it('lets an unmapped user row key itself and unmapped rows inherit it', () => {
|
||||
// C is an optimistic echo the journal has not admitted yet: positional rules.
|
||||
const messages = [message('A', 'user'), message('t1'), message('C', 'user'), message('t2')]
|
||||
const owned = new Map([
|
||||
['A', 'A'],
|
||||
['t1', 'A']
|
||||
])
|
||||
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'C', 'C'])
|
||||
})
|
||||
|
||||
it('reproduces preceding-user grouping exactly when the host attributes nothing', () => {
|
||||
const messages = [
|
||||
message('lead'),
|
||||
message('A', 'user'),
|
||||
message('t1'),
|
||||
message('B', 'user'),
|
||||
message('t2')
|
||||
]
|
||||
const positional = [undefined, 'A', 'A', 'B', 'B']
|
||||
expect(nativeChatRowTurnKeys(messages, null)).toEqual(positional)
|
||||
expect(nativeChatRowTurnKeys(messages, new Map())).toEqual(positional)
|
||||
})
|
||||
|
||||
it('keys a provider-opened turn to a record no message carries', () => {
|
||||
const messages = [message('A', 'user'), message('t1'), message('t2')]
|
||||
const owned = new Map([
|
||||
['A', 'A'],
|
||||
['t1', 'wake-turn'],
|
||||
['t2', 'wake-turn']
|
||||
])
|
||||
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'wake-turn', 'wake-turn'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnBarRows', () => {
|
||||
it('anchors a turn with no user bubble above its first rendered row', () => {
|
||||
const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')]
|
||||
const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn']
|
||||
expect(nativeChatTurnBarRows(messages, turnKeys)).toEqual(
|
||||
new Map([
|
||||
['A', { index: 0, above: false }],
|
||||
['wake-turn', { index: 2, above: true }]
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnDrawOrder', () => {
|
||||
it("draws a message that opened a later turn after the earlier turn's remaining rows", () => {
|
||||
// B was sent while A ran and Claude queued it: A's tool run and answer follow B in the journal.
|
||||
const messages = [
|
||||
message('A', 'user'),
|
||||
message('a1'),
|
||||
message('B', 'user'),
|
||||
message('a2'),
|
||||
message('a-done'),
|
||||
message('b1')
|
||||
]
|
||||
const turnKeys = ['A', 'A', 'B', 'A', 'A', 'B']
|
||||
expect(nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B']))).toEqual([
|
||||
0, 1, 3, 4, 2, 5
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps each of several queued openers ahead of its own rows', () => {
|
||||
const messages = [
|
||||
message('A', 'user'),
|
||||
message('B', 'user'),
|
||||
message('C', 'user'),
|
||||
message('a-done'),
|
||||
message('b1'),
|
||||
message('c1')
|
||||
]
|
||||
const turnKeys = ['A', 'B', 'C', 'A', 'B', 'C']
|
||||
const order = nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B', 'C']))
|
||||
expect(order?.map((index) => messages[index]!.id)).toEqual([
|
||||
'A',
|
||||
'a-done',
|
||||
'B',
|
||||
'b1',
|
||||
'C',
|
||||
'c1'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a steer, and a message that opened no turn, where they were written', () => {
|
||||
const messages = [message('A', 'user'), message('steer', 'user'), message('a1')]
|
||||
expect(nativeChatTurnDrawOrder(messages, ['A', 'A', 'A'], new Set(['A']))).toBeNull()
|
||||
const waiting = [message('A', 'user'), message('C', 'user'), message('a1')]
|
||||
expect(nativeChatTurnDrawOrder(waiting, ['A', 'C', 'A'], new Set(['A']))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
// Share desktop/mobile turn ownership, bar placement and row order.
|
||||
|
||||
import type { AgentJournalRenderItem } from '../../agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../agent-session-turn-record'
|
||||
import type { NativeChatRole } from '../../native-chat-types'
|
||||
|
||||
type NativeChatTurnRow = { id: string; role: NativeChatRole }
|
||||
|
||||
/** Which rows can open a turn by themselves: in a conversation, its user rows. */
|
||||
export type NativeChatOpensTurn = (message: NativeChatTurnRow) => boolean
|
||||
|
||||
export const nativeChatUserRowOpensTurn: NativeChatOpensTurn = (message) => message.role === 'user'
|
||||
|
||||
/** Host turn attribution wins over mid-turn send position. Unmapped rows use preceding-user grouping;
|
||||
* opensTurn restricts which unmapped rows can anchor turns in interleaved streams. */
|
||||
export function nativeChatRowTurnKeys(
|
||||
messages: readonly NativeChatTurnRow[],
|
||||
turnKeysByItemId?: ReadonlyMap<string, string> | null,
|
||||
opensTurn: NativeChatOpensTurn = nativeChatUserRowOpensTurn
|
||||
): (string | undefined)[] {
|
||||
let currentTurnKey: string | undefined
|
||||
return messages.map((message) => {
|
||||
const owned = turnKeysByItemId?.get(message.id)
|
||||
if (owned !== undefined) {
|
||||
currentTurnKey = owned
|
||||
return owned
|
||||
}
|
||||
if (opensTurn(message)) {
|
||||
currentTurnKey = message.id
|
||||
}
|
||||
return currentTurnKey
|
||||
})
|
||||
}
|
||||
|
||||
/** Legacy unscoped hosts assign rows between root records to that turn’s mapped opener. Openers key themselves;
|
||||
* steers inherit only after later turn content proves continuation, so fresh tail sends keep waiting. Rows before the first record remain unmapped. */
|
||||
export function nativeChatJournalOrderTurnKeys(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
recordKeys: ReadonlyMap<string, string | null>
|
||||
): ReadonlyMap<string, string> {
|
||||
const openers = new Set([...recordKeys.values()].filter((key) => key !== null))
|
||||
const keys = new Map<string, string>()
|
||||
let currentKey: string | null = null
|
||||
// User items folded into the current turn, held until a later row proves the turn continued.
|
||||
let pendingUserItemIds: string[] = []
|
||||
for (const item of items) {
|
||||
if (recordKeys.has(item.itemId)) {
|
||||
currentKey = recordKeys.get(item.itemId) ?? null
|
||||
pendingUserItemIds = []
|
||||
continue
|
||||
}
|
||||
if (readAgentJournalTurn(item.body)) {
|
||||
continue
|
||||
}
|
||||
if (item.body.kind === 'message' && item.body.role === 'user') {
|
||||
if (openers.has(item.itemId)) {
|
||||
keys.set(item.itemId, item.itemId)
|
||||
} else if (currentKey !== null) {
|
||||
pendingUserItemIds.push(item.itemId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (currentKey !== null) {
|
||||
for (const userItemId of pendingUserItemIds) {
|
||||
keys.set(userItemId, currentKey)
|
||||
}
|
||||
pendingUserItemIds = []
|
||||
keys.set(item.itemId, currentKey)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Place each turn bar at its first row; use an above-row bar when the provider opened it or its user opener is unloaded. */
|
||||
export function nativeChatTurnBarRows(
|
||||
messages: readonly { id: string }[],
|
||||
turnKeys: readonly (string | undefined)[]
|
||||
): ReadonlyMap<string, { index: number; above: boolean }> {
|
||||
const bars = new Map<string, { index: number; above: boolean }>()
|
||||
turnKeys.forEach((turnKey, index) => {
|
||||
if (turnKey !== undefined && !bars.has(turnKey)) {
|
||||
bars.set(turnKey, { index, above: messages[index]?.id !== turnKey })
|
||||
}
|
||||
})
|
||||
return bars
|
||||
}
|
||||
|
||||
/** Return draw indexes or null for journal order. Provider-queued sends are written mid-turn but open only after earlier work ends;
|
||||
* draw each opener after that prior work and just before its own turn’s rows. */
|
||||
export function nativeChatTurnDrawOrder(
|
||||
messages: readonly NativeChatTurnRow[],
|
||||
turnKeys: readonly (string | undefined)[],
|
||||
openers: ReadonlySet<string>
|
||||
): number[] | null {
|
||||
const opensTurn = (index: number): boolean =>
|
||||
messages[index]?.role === 'user' &&
|
||||
turnKeys[index] === messages[index].id &&
|
||||
openers.has(messages[index].id)
|
||||
const firstRow = new Map<string, number>()
|
||||
turnKeys.forEach((turnKey, index) => {
|
||||
if (turnKey !== undefined && !firstRow.has(turnKey)) {
|
||||
firstRow.set(turnKey, index)
|
||||
}
|
||||
})
|
||||
const movedAfter = new Map<number, number[]>()
|
||||
const moved = new Set<number>()
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (!opensTurn(index)) {
|
||||
continue
|
||||
}
|
||||
let after = -1
|
||||
for (let row = index + 1; row < messages.length; row += 1) {
|
||||
const turnKey = turnKeys[row]
|
||||
if (turnKey === undefined || opensTurn(row)) {
|
||||
continue
|
||||
}
|
||||
// Its own turn's rows, or a later turn's, end the earlier turns' rows it waited behind.
|
||||
if (turnKey === message.id || (firstRow.get(turnKey) ?? row) > index) {
|
||||
break
|
||||
}
|
||||
after = row
|
||||
}
|
||||
if (after !== -1) {
|
||||
moved.add(index)
|
||||
movedAfter.set(after, [...(movedAfter.get(after) ?? []), index])
|
||||
}
|
||||
}
|
||||
if (moved.size === 0) {
|
||||
return null
|
||||
}
|
||||
return messages.flatMap((_, index) => [
|
||||
...(moved.has(index) ? [] : [index]),
|
||||
...(movedAfter.get(index) ?? [])
|
||||
])
|
||||
}
|
||||
|
||||
/** `rows` in the transcript's draw order. */
|
||||
export function nativeChatRowsInDrawOrder<T>(
|
||||
rows: readonly T[],
|
||||
drawOrder: readonly number[] | null
|
||||
): readonly T[] {
|
||||
return drawOrder === null ? rows : drawOrder.map((index) => rows[index]!)
|
||||
}
|
||||
Reference in New Issue
Block a user