diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts index 86cd518b09fe..c21ae759a7f0 100644 --- a/config/scripts/native-chat-live-session-benchmark.ts +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -15,8 +15,9 @@ import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-co import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation' import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status' import { - matchingNativeChatUserTexts, - selectPendingIndicesRepresentedByUserTexts + matchingNativeChatUserRows, + selectPendingIndicesRepresentedByUserRows, + type NativeChatUserRow } from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence' import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending' import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler' @@ -36,6 +37,7 @@ let sessionSink: NativeChatSession | null = null let messageArraySink: NativeChatMessage[] | null = null let contentSink = '' let pendingMatchSink: Set | null = null +let userRowSink: readonly NativeChatUserRow[] | null = null const benchmarkStartedAt = performance.now() function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] { @@ -94,10 +96,10 @@ function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSe } function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] { - pendingMatchSink = selectPendingIndicesRepresentedByUserTexts( - [], - matchingNativeChatUserTexts(messages) - ) + // An empty queue makes the renderer skip candidate-row construction entirely, so the + // row scan is the whole cost here — keep it escaping and let the matcher take its exit. + userRowSink = matchingNativeChatUserRows(messages) + pendingMatchSink = selectPendingIndicesRepresentedByUserRows([], []) return [] } @@ -328,6 +330,7 @@ strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape') strictEqual(contentSink.length > 0, true, 'message content did not escape') strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape') +strictEqual(Array.isArray(userRowSink), true, 'user row scan did not escape') console.log( `validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${( performance.now() - benchmarkStartedAt diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 7f5c41034f98..cd08f779e712 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -5,7 +5,7 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { applyCommandMarkerBoundaries } from './native-chat-pending' +import { applyCommandMarkerBoundaries } from './native-chat-command-marker' import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' const INITIAL_PROMPT = JSON.stringify({ diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 4da40f7a1fc2..4fc6df0f13b3 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -18,21 +18,23 @@ import { shouldShowNativeChatWorking } from './native-chat-working-suppression' import { - applyCommandMarkerBoundaries, appendPendingSendCache, - commandMarkersAsMessages, - appendCommandMarkerCache, launchPromptAsMessage, pendingSendsAsMessages, nextNativeChatPendingSendId, prunePendingSends, - readCommandMarkerCache, readPendingSendCache, shouldPruneLaunchPrompt, writePendingSendCache, - type NativeChatCommandMarker, type NativeChatPendingSend } from './native-chat-pending' +import { + appendCommandMarkerCache, + applyCommandMarkerBoundaries, + commandMarkersAsMessages, + readCommandMarkerCache, + type NativeChatCommandMarker +} from './native-chat-command-marker' import { deriveNativeChatStreamingText, nativeChatStreamingMessage diff --git a/src/renderer/src/components/native-chat/native-chat-command-marker.ts b/src/renderer/src/components/native-chat/native-chat-command-marker.ts new file mode 100644 index 000000000000..94634256e80d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-command-marker.ts @@ -0,0 +1,111 @@ +// Locally-recorded slash commands (e.g. `/clear`). They dispatch into the +// agent's TUI rather than the conversation, so native chat renders its own +// marker line and applies their transcript boundaries — kept out of the +// optimistic-send pruning in native-chat-pending.ts, which is a separate rule. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to + * the agent's TUI and are not chat turns, so we surface a small system line as + * feedback that the command ran rather than echoing a user bubble. */ +export type NativeChatCommandMarker = { + id: string + /** The command as typed, e.g. `/clear`. */ + command: string + sentAt: number +} + +export type NativeChatCommandMarkerScope = { + paneKey: string + agent: string + sessionId: string | null +} + +const COMMAND_MARKER_LIMIT = 8 +const commandMarkerCache = new Map() +let commandMarkerCounter = 0 + +function commandMarkerScopeKey(scope: NativeChatCommandMarkerScope): string { + return `${scope.paneKey}\0${scope.agent}\0${scope.sessionId ?? ''}` +} + +export function readCommandMarkerCache( + scope: NativeChatCommandMarkerScope +): NativeChatCommandMarker[] { + return [...(commandMarkerCache.get(commandMarkerScopeKey(scope)) ?? [])] +} + +export function appendCommandMarkerCache( + scope: NativeChatCommandMarkerScope, + command: string, + sentAt = Date.now() +): NativeChatCommandMarker[] { + commandMarkerCounter += 1 + const key = commandMarkerScopeKey(scope) + // Why: native/TUI view switches remount the chat surface, but slash commands + // are not transcript turns, so their local feedback needs a pane-scoped cache. + const next = [ + ...(commandMarkerCache.get(key) ?? []), + { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } + ].slice(-COMMAND_MARKER_LIMIT) + // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, + // sessionId changes on every /clear) is ephemeral and was never evicted, so it + // grew one entry per (pane, session) for the renderer's whole life. LRU-bound + // the key count (mirrors the #7566 draft/attachment caches in this folder). + setBoundedScopeCacheEntry(commandMarkerCache, key, next) + return [...next] +} + +export function clearCommandMarkerCacheForTests(): void { + commandMarkerCache.clear() + commandMarkerCounter = 0 +} + +function isClearCommand(command: string): boolean { + return command.trim().toLowerCase().split(/\s+/)[0] === '/clear' +} + +function latestClearSentAt(markers: readonly NativeChatCommandMarker[]): number | null { + let latest: number | null = null + for (const marker of markers) { + if (isClearCommand(marker.command) && (latest === null || marker.sentAt > latest)) { + latest = marker.sentAt + } + } + return latest +} + +export function applyCommandMarkerBoundaries( + messages: readonly NativeChatMessage[], + markers: readonly NativeChatCommandMarker[] +): NativeChatMessage[] { + const clearSentAt = latestClearSentAt(markers) + if (clearSentAt === null) { + return messages as NativeChatMessage[] + } + // Why: `/clear` mutates the TUI/transcript asynchronously. Hide the current + // transcript immediately so native chat reflects the command before the agent + // writes a replacement session or truncates the file. + return messages.filter((message) => message.timestamp !== null && message.timestamp > clearSentAt) +} + +/** Render command markers as compact `system` messages. The `system` role draws + * as a muted aside (not a user bubble); the text avoids the harness noise + * prefixes so stripNoiseMessages keeps it. */ +export function commandMarkersAsMessages( + markers: readonly NativeChatCommandMarker[] +): NativeChatMessage[] { + return markers.map((marker) => ({ + id: `command:${marker.id}`, + role: 'system' as const, + blocks: [{ type: 'text' as const, text: `Ran ${marker.command}` }], + timestamp: marker.sentAt, + source: 'scrape' as const + })) +} + +/** True when a message id was minted for a slash-command marker. */ +export function isCommandMarkerId(id: string): boolean { + return id.startsWith('command:') +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 1184affb3690..bdabded3051f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -79,17 +79,21 @@ export function advancedNativeChatUserContentCounts( return advanced } -/** User texts that already have a later non-user turn (ready to prune echoes). */ -export function advancedNativeChatUserTexts( +/** A transcript user turn, kept identifiable so a caller can decide which + * pending sends it is allowed to represent. */ +export type NativeChatUserRow = { id: string; text: string } + +/** User rows that already have a later non-user turn (ready to prune echoes). */ +export function advancedNativeChatUserRows( messages: readonly NativeChatMessage[] -): readonly string[] { - const advanced: string[] = [] - const waiting: string[] = [] +): readonly NativeChatUserRow[] { + const advanced: NativeChatUserRow[] = [] + const waiting: NativeChatUserRow[] = [] for (const message of messages) { if (message.role === 'user') { const text = normalizedNativeChatUserMessageText(message) if (text) { - waiting.push(text) + waiting.push({ id: message.id, text }) } continue } @@ -99,18 +103,18 @@ export function advancedNativeChatUserTexts( return advanced } -/** All user texts (for hiding optimistic echoes once the turn exists). */ -export function matchingNativeChatUserTexts( +/** All user rows (for hiding optimistic echoes once the turn exists). */ +export function matchingNativeChatUserRows( messages: readonly NativeChatMessage[] -): readonly string[] { - const texts: string[] = [] +): readonly NativeChatUserRow[] { + const rows: NativeChatUserRow[] = [] for (const message of messages) { const text = normalizedNativeChatUserMessageText(message) if (text) { - texts.push(text) + rows.push({ id: message.id, text }) } } - return texts + return rows } /** @@ -149,32 +153,49 @@ export function countLeadingPendingTextsGluedToUserText( return 0 } +/** A transcript row a glue match may consume, carrying the send boundaries it + * satisfies — this matcher has no clock of its own. */ +export type NativeChatGluedUserRow = { + text: string + /** Indices into `pending` this row landed after. A row that already existed + * when a send was issued can never be that send's echo. */ + representablePendingIndices: ReadonlySet +} + /** * Mark pending entries represented only by multi-send glue (2+ consecutive * optimistic texts concatenated into one transcript user row). Exact single * matches stay in the content-key/occurrence path so repeated prompts and * send boundaries keep their existing semantics. - * - * `userTexts` must already be filtered to rows after the oldest entry's send - * boundary — this matcher has no clock of its own. */ -export function selectPendingIndicesRepresentedByUserTexts( +export function selectPendingIndicesRepresentedByUserRows( pending: readonly NativeChatPendingOccurrence[], - userTexts: readonly string[] + rows: readonly NativeChatGluedUserRow[] ): Set { const represented = new Set() - if (pending.length < 2 || userTexts.length === 0) { + if (pending.length < 2 || rows.length === 0) { return represented } const remaining = pending.map((entry, index) => ({ index, text: normalizeNativeChatPendingText(entry.text) })) - for (const userText of userTexts) { - const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + for (const row of rows) { + const open: typeof remaining = [] + for (const entry of remaining) { + if (represented.has(entry.index) || entry.text.length === 0) { + continue + } + // Glue consumes a leading run, so a send this row predates ends the run + // rather than being skipped over — adjacency is what makes it glue. + if (!row.representablePendingIndices.has(entry.index)) { + break + } + open.push(entry) + } const gluedCount = countLeadingPendingTextsGluedToUserText( open.map((entry) => entry.text), - userText + row.text ) // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. if (gluedCount < 2) { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 29771fd9507b..1773fb10e611 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -2,24 +2,26 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { appendPendingSendCache, - appendCommandMarkerCache, - applyCommandMarkerBoundaries, - clearCommandMarkerCacheForTests, clearPendingSendCacheForTests, - commandMarkersAsMessages, - isCommandMarkerId, isLaunchPromptMessageId, isPendingMessageId, launchPromptAsMessage, nextNativeChatPendingSendId, pendingSendsAsMessages, prunePendingSends, - readCommandMarkerCache, readPendingSendCache, shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' +import { + appendCommandMarkerCache, + applyCommandMarkerBoundaries, + clearCommandMarkerCacheForTests, + commandMarkersAsMessages, + isCommandMarkerId, + readCommandMarkerCache +} from './native-chat-command-marker' import { stripNoiseMessages } from './native-chat-noise' function userMessage(id: string, text: string, timestamp = 1): NativeChatMessage { @@ -330,6 +332,84 @@ describe('glued rapid sends', () => { expect(prunePendingSends(pending, history)).toEqual(pending) expect(pendingSendsAsMessages(pending, history)).toHaveLength(2) }) + + // A row that already existed when a send was issued can never be that send's + // echo — for EVERY queued send, not just the oldest one the glue run starts at. + const mixedAgeGlue = (): { messages: NativeChatMessage[]; pending: NativeChatPendingSend[] } => { + const gluedRow = userMessage('glue-row', 'fix the bug', 6000) + return { + messages: [ + GLUE_BOUNDARY, + gluedRow, + assistantMessage('glue-answer', 'fixed', 6100), + userMessage('later-history', 'anything', 6200) + ], + // 'fix the' was queued before the row landed; 'bug' only after it. + pending: [ + gluePending('p1', 'fix the'), + { + id: 'p2', + text: 'bug', + sentAt: 7000, + afterMessageId: 'glue-answer', + afterMessageTimestamp: 6100 + } + ] + } + } + + it('keeps a queued send whose own boundary is newer than the glued row (STA-4477)', () => { + const { messages, pending } = mixedAgeGlue() + + expect(prunePendingSends(pending, messages)).toEqual(pending) + }) + + // Separate from the prune case on purpose: the render path is what makes the + // bubble visually vanish, and a shared `it` would mask it behind the first + // failing assertion. + it('still renders a queued send whose own boundary is newer than the row (STA-4477)', () => { + const { messages, pending } = mixedAgeGlue() + + expect(pendingSendsAsMessages(pending, messages)).toHaveLength(2) + }) + + // Glue is adjacency: a send the row predates ends the run rather than being + // skipped, so a row must never bridge across a send it cannot represent. + it('never glues across a send the row cannot represent (STA-4477)', () => { + const gluedRow = userMessage('glue-row', 'alpha gamma', 6000) + const messages = [GLUE_BOUNDARY, gluedRow, assistantMessage('glue-answer', 'ok', 6100)] + const pending = [ + gluePending('p1', 'alpha'), + // Queued after the row landed, so the row is not its echo... + { + id: 'p2', + text: 'beta', + sentAt: 7000, + afterMessageId: 'glue-row', + afterMessageTimestamp: 6000 + }, + // ...and 'alpha'+'gamma' must not close over the gap it leaves. + gluePending('p3', 'gamma') + ] + + expect(prunePendingSends(pending, messages)).toEqual(pending) + expect(pendingSendsAsMessages(pending, messages)).toHaveLength(3) + }) + + it('still retires the leading run the glued row did land after (STA-4477)', () => { + const gluedRow = userMessage('glue-row', 'first second', 6000) + const messages = [GLUE_BOUNDARY, gluedRow, assistantMessage('glue-answer', 'done', 6100)] + const third = { + id: 'p3', + text: 'third', + sentAt: 7000, + afterMessageId: 'glue-row', + afterMessageTimestamp: gluedRow.timestamp + } + const pending = [gluePending('p1', 'first'), gluePending('p2', 'second'), third] + + expect(prunePendingSends(pending, messages)).toEqual([third]) + }) }) describe('pendingSendsAsMessages', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index c322656f1526..406fee3b5795 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -8,15 +8,17 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { advancedNativeChatUserContentCounts, - advancedNativeChatUserTexts, + advancedNativeChatUserRows, assignNativeChatPendingOccurrence, matchingNativeChatUserContentCounts, - matchingNativeChatUserTexts, + matchingNativeChatUserRows, nativeChatPendingContentKey, nativeChatPendingMatchKey, nativeChatPendingMatchingAfter, nativeChatPendingOccurrence, - selectPendingIndicesRepresentedByUserTexts + selectPendingIndicesRepresentedByUserRows, + type NativeChatGluedUserRow, + type NativeChatUserRow } from './native-chat-pending-occurrence' /** An optimistic, not-yet-confirmed composer send. */ @@ -126,23 +128,41 @@ function messageIsAfterPendingTimestamp( } /** - * Rows a glue match may consume. Glue always starts at the oldest still-open - * echo, so that echo's send boundary is the floor: unbounded, an older turn - * whose text happens to split across the queue ("fix the bug" vs "fix the" + - * "bug") would retire sends issued long after it. A missing message boundary - * falls back to send time — a fuzzy match must never reach further back than - * an exact one. + * Rows a glue match may consume, each tagged with the sends it landed after. + * Unbounded, an older turn whose text happens to split across the queue ("fix + * the bug" vs "fix the" + "bug") would retire sends issued long after it — so + * every send carries its OWN boundary into the match, not just the oldest one + * the run starts at (#14663 pruned newer queued prompts against older rows). + * A missing message boundary falls back to send time: a fuzzy match must never + * reach further back than an exact one. */ -function gluedCandidateMessages( +function gluedCandidateRows( messages: readonly NativeChatMessage[], - open: readonly NativeChatPendingSend[] -): readonly NativeChatMessage[] { - const oldest = open[0] - if (!oldest) { + open: readonly NativeChatPendingSend[], + rowsOf: (messages: readonly NativeChatMessage[]) => readonly NativeChatUserRow[] +): readonly NativeChatGluedUserRow[] { + const [oldest, ...newer] = open.map((entry) => + entry.afterMessageId === undefined ? { ...entry, afterMessageId: null } : entry + ) + // Glue needs a run of 2+ sends, so a lone queued echo — by far the common + // case while an agent streams — skips the per-send boundary scans entirely. + if (!oldest || newer.length === 0) { return [] } - const anchor = oldest.afterMessageId === undefined ? { ...oldest, afterMessageId: null } : oldest - return messagesAfterPendingBoundary(messages, anchor) + const newerRowIds = newer.map( + (entry) => new Set(rowsOf(messagesAfterPendingBoundary(messages, entry)).map((row) => row.id)) + ) + // The run always starts at the oldest open send, so its rows are the whole + // candidate set and index 0 is representable in every one of them. + return rowsOf(messagesAfterPendingBoundary(messages, oldest)).map((row) => { + const representablePendingIndices = new Set([0]) + newerRowIds.forEach((ids, index) => { + if (ids.has(row.id)) { + representablePendingIndices.add(index + 1) + } + }) + return { text: row.text, representablePendingIndices } + }) } /** @@ -175,9 +195,9 @@ export function prunePendingSends( // transcript carries one row ("joke"+"continue"→"jokecontinue") that no exact // key matches. Drop those echoes once an assistant turn advances past it. const stillOpen = pending.filter((_, index) => exactKeep[index]) - const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( + const gluedRepresented = selectPendingIndicesRepresentedByUserRows( stillOpen, - advancedNativeChatUserTexts(gluedCandidateMessages(messages, stillOpen)) + gluedCandidateRows(messages, stillOpen, advancedNativeChatUserRows) ) const next = pending.filter((entry, index) => { if (!exactKeep[index]) { @@ -218,9 +238,9 @@ export function pendingSendsAsMessages( // Hide optimistic echoes that were glued into a single transcript user row // even before the assistant reply lands (matching, not advanced). const stillVisible = pending.filter((_, index) => exactVisible[index]) - const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( + const gluedRepresented = selectPendingIndicesRepresentedByUserRows( stillVisible, - matchingNativeChatUserTexts(gluedCandidateMessages(existingMessages, stillVisible)) + gluedCandidateRows(existingMessages, stillVisible, matchingNativeChatUserRows) ) return pending .filter((entry, index) => { @@ -299,107 +319,3 @@ export function nextNativeChatPendingSendId(now = Date.now()): string { export function isLaunchPromptMessageId(id: string): boolean { return id.startsWith('launch-pending:') } - -/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to - * the agent's TUI and are not chat turns, so we surface a small system line as - * feedback that the command ran rather than echoing a user bubble. */ -export type NativeChatCommandMarker = { - id: string - /** The command as typed, e.g. `/clear`. */ - command: string - sentAt: number -} - -export type NativeChatCommandMarkerScope = { - paneKey: string - agent: string - sessionId: string | null -} - -const COMMAND_MARKER_LIMIT = 8 -const commandMarkerCache = new Map() -let commandMarkerCounter = 0 - -function commandMarkerScopeKey(scope: NativeChatCommandMarkerScope): string { - return `${scope.paneKey}\0${scope.agent}\0${scope.sessionId ?? ''}` -} - -export function readCommandMarkerCache( - scope: NativeChatCommandMarkerScope -): NativeChatCommandMarker[] { - return [...(commandMarkerCache.get(commandMarkerScopeKey(scope)) ?? [])] -} - -export function appendCommandMarkerCache( - scope: NativeChatCommandMarkerScope, - command: string, - sentAt = Date.now() -): NativeChatCommandMarker[] { - commandMarkerCounter += 1 - const key = commandMarkerScopeKey(scope) - // Why: native/TUI view switches remount the chat surface, but slash commands - // are not transcript turns, so their local feedback needs a pane-scoped cache. - const next = [ - ...(commandMarkerCache.get(key) ?? []), - { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } - ].slice(-COMMAND_MARKER_LIMIT) - // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, - // sessionId changes on every /clear) is ephemeral and was never evicted, so it - // grew one entry per (pane, session) for the renderer's whole life. LRU-bound - // the key count (mirrors the #7566 draft/attachment caches in this folder). - setBoundedScopeCacheEntry(commandMarkerCache, key, next) - return [...next] -} - -export function clearCommandMarkerCacheForTests(): void { - commandMarkerCache.clear() - commandMarkerCounter = 0 -} - -function isClearCommand(command: string): boolean { - return command.trim().toLowerCase().split(/\s+/)[0] === '/clear' -} - -function latestClearSentAt(markers: readonly NativeChatCommandMarker[]): number | null { - let latest: number | null = null - for (const marker of markers) { - if (isClearCommand(marker.command) && (latest === null || marker.sentAt > latest)) { - latest = marker.sentAt - } - } - return latest -} - -export function applyCommandMarkerBoundaries( - messages: readonly NativeChatMessage[], - markers: readonly NativeChatCommandMarker[] -): NativeChatMessage[] { - const clearSentAt = latestClearSentAt(markers) - if (clearSentAt === null) { - return messages as NativeChatMessage[] - } - // Why: `/clear` mutates the TUI/transcript asynchronously. Hide the current - // transcript immediately so native chat reflects the command before the agent - // writes a replacement session or truncates the file. - return messages.filter((message) => message.timestamp !== null && message.timestamp > clearSentAt) -} - -/** Render command markers as compact `system` messages. The `system` role draws - * as a muted aside (not a user bubble); the text avoids the harness noise - * prefixes so stripNoiseMessages keeps it. */ -export function commandMarkersAsMessages( - markers: readonly NativeChatCommandMarker[] -): NativeChatMessage[] { - return markers.map((marker) => ({ - id: `command:${marker.id}`, - role: 'system' as const, - blocks: [{ type: 'text' as const, text: `Ran ${marker.command}` }], - timestamp: marker.sentAt, - source: 'scrape' as const - })) -} - -/** True when a message id was minted for a slash-command marker. */ -export function isCommandMarkerId(id: string): boolean { - return id.startsWith('command:') -}