perf(ui): harden bounded transcript memory

Re-scope the renderer-memory fix on the native V2 baseline after removal of the duplicate Tauri event transport. Bound Markdown, reasoning, tool output, diagnostics, diffs, task steps, todos, and per-message part rendering while retaining complete authoritative content behind lazy copy actions.

Coordinate message records and derived render caches under one 64 MiB byte-aware LRU. Protect mounted, loading, and live transcripts; cap pending parts and prompt display overrides; fence stale cache writes; and conservatively account measurement failures.

Reload evicted transcripts through bounded complete V2 pagination, reject stale lifecycle completions, purge all per-instance session state, and prevent delayed SSE/delta work from recreating removed stores. Keep DEV-v2 PR validation enabled without reintroducing native Tauri transport changes.

Validated with full UI and Electron typecheck, UI production build, 248 standard UI tests, 71 browser-condition UI tests, 118 Electron native tests, and git diff checks. The long-duration WebKit soak remains pending while the PR stays draft.
This commit is contained in:
Pascal André 2026-08-13 13:09:45 +02:00
parent 20dfdc2954
commit bb39dab1fc
No known key found for this signature in database
80 changed files with 3136 additions and 935 deletions

View file

@ -32,7 +32,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi

View file

@ -32,7 +32,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@ -103,13 +103,21 @@ jobs:
- name: Test changed runnable UI behavior
run: >-
node --import tsx --test
node --import tsx --test --test-force-exit
packages/ui/src/components/markdown-render-limit.test.ts
packages/ui/src/components/message-block-render-limit.test.ts
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/tool-call/render-memory.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/lib/global-cache.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/message-render-cache.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/retained-size.test.ts
packages/ui/src/lib/session-transcript-lru.test.ts
packages/ui/src/lib/session-transcript-measurement.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
@ -120,12 +128,14 @@ jobs:
packages/ui/src/stores/restore-workspace-commit-gates.test.ts
packages/ui/src/stores/client-state-codec.test.ts
packages/ui/src/stores/client-state.test.ts
packages/ui/src/stores/delta-buffer.test.ts
packages/ui/src/stores/instances-restore-cancellation.test.ts
packages/ui/src/stores/message-v2/instance-store.test.ts
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
packages/ui/src/stores/message-v2/message-status.test.ts
packages/ui/src/stores/message-v2/normalizers.test.ts
packages/ui/src/stores/session-generation-recovery.test.ts
packages/ui/src/stores/session-message-pages.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/types/session.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
@ -133,9 +143,12 @@ jobs:
- name: Test restore ownership integration
run: >-
node --conditions=browser --import tsx --test --test-force-exit
packages/ui/src/components/tool-call/renderer-copy.test.ts
packages/ui/src/lib/hooks/use-active-session-message-load.test.ts
packages/ui/src/stores/instances-restore-ownership.test.ts
packages/ui/src/stores/message-v2/bus.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
packages/ui/src/stores/session-native-events.test.ts
packages/ui/src/stores/session-actions.test.ts
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts

View file

@ -14,7 +14,7 @@ permissions:
jobs:
restrict-non-dev-prs:
if: ${{ github.event.pull_request.base.ref != 'dev' }}
if: ${{ github.event.pull_request.base.ref != 'dev' && github.event.pull_request.base.ref != 'DEV-v2' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
@ -39,7 +39,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target the \`dev\` or \`DEV-v2\` branch. Please retarget this PR to an authorized development branch."
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}

View file

@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getMarkdownTextForRender } from "./markdown.tsx"
import { TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils.ts"
test("ordinary markdown is bounded before parsing or escaping", () => {
const rendered = getMarkdownTextForRender(`${"<".repeat(20_000)}COPY_TAIL`)
assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)
assert.equal(rendered.includes("COPY_TAIL"), false)
assert.equal(rendered.includes("Output truncated for rendering"), true)
})
test("truncated markdown retains the full original outside the parse input", () => {
const source = `${"x".repeat(20_000)}COPY_TAIL`
assert.equal(getMarkdownTextForRender(source).includes("COPY_TAIL"), false)
assert.equal(source.includes("COPY_TAIL"), true)
})

View file

@ -1,9 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils"
const log = getLogger("session")
@ -89,6 +90,10 @@ function renderFallbackHtml(content: string): string {
return escapeHtml(content).replace(/\n/g, "<br />")
}
export function getMarkdownTextForRender(content: string): string {
return limitToolOutputForRender(content)
}
interface MarkdownProps {
part: TextPart
instanceId?: string
@ -158,7 +163,7 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntitiesLocally(rawText)
const text = decodeHtmlEntitiesLocally(getMarkdownTextForRender(rawText))
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
@ -346,15 +351,31 @@ export function Markdown(props: MarkdownProps) {
})
return (
<div
ref={containerRef}
class="markdown-body"
dir="auto"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
<>
<div
ref={containerRef}
class="markdown-body"
dir="auto"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
<Show when={(typeof props.part.text === "string" ? props.part.text.length : 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT}>
<button
type="button"
class="message-action-button markdown-source-copy"
onClick={() => void copyToClipboard(typeof props.part.text === "string" ? props.part.text : "")}
aria-label={t("messageItem.actions.copyTitle")}
title={t("messageItem.actions.copyTitle")}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</Show>
</>
)
}

View file

@ -0,0 +1,57 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
extractReasoningTextForCopy,
extractReasoningTextForRender,
extractReasoningTitleForRender,
REASONING_RENDER_CHARACTER_LIMIT,
REASONING_RENDER_NODE_LIMIT,
REASONING_TITLE_CHARACTER_LIMIT,
} from "../lib/message-render-cache.ts"
import { buildRecordDisplayData, MESSAGE_PART_DISPLAY_LIMIT } from "../stores/message-v2/record-display-cache.ts"
import type { MessageRecord } from "../stores/message-v2/types.ts"
test("caps parts before traversing or cloning the record tail", () => {
const accessed: string[] = []
const partIds = Array.from({ length: MESSAGE_PART_DISPLAY_LIMIT + 1 }, (_, index) => `part-${index}`)
const parts = new Proxy({}, {
get(_target, partId: string) {
accessed.push(partId)
return { revision: 1, data: { id: partId, type: "text", text: partId } }
},
})
const record = { id: "bounded-message", revision: 1, partIds, parts } as unknown as MessageRecord
const display = buildRecordDisplayData("render-limit-test", record)
assert.equal(display.orderedParts.length, MESSAGE_PART_DISPLAY_LIMIT)
assert.equal(display.truncated, true)
assert.equal(accessed.length, MESSAGE_PART_DISPLAY_LIMIT)
assert.equal(accessed.includes(`part-${MESSAGE_PART_DISPLAY_LIMIT}`), false)
})
test("bounds reasoning display work while copy remains authoritative and lazy", () => {
const tail = "COPY_ONLY_TAIL"
const part = { type: "reasoning", text: `${"x".repeat(REASONING_RENDER_CHARACTER_LIMIT)}${tail}` }
const rendered = extractReasoningTextForRender(part)
assert.equal(rendered.length, REASONING_RENDER_CHARACTER_LIMIT)
assert.equal(rendered.includes(tail), false)
assert.equal(extractReasoningTextForCopy(part).endsWith(tail), true)
assert.equal(extractReasoningTitleForRender(`\n**bounded title**\n${"t".repeat(REASONING_TITLE_CHARACTER_LIMIT)}`), "bounded title")
})
test("stops traversing nested reasoning at the display node budget", () => {
let furthestIndex = -1
const content = new Proxy(Array.from({ length: REASONING_RENDER_NODE_LIMIT }, (_, index) => String(index)), {
get(target, key, receiver) {
if (typeof key === "string" && /^\d+$/.test(key)) furthestIndex = Number(key)
return Reflect.get(target, key, receiver)
},
})
extractReasoningTextForRender({ type: "reasoning", content })
assert.ok(furthestIndex >= 0)
assert.ok(furthestIndex < content.length - 1)
})

View file

@ -20,11 +20,20 @@ import { copyToClipboard } from "../lib/clipboard"
import SpeechActionButton from "./speech-action-button"
import type { VisibilityPreference } from "../stores/preferences"
import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } from "../types/tool-state"
import {
clearInstanceMessageRenderCaches,
clearSessionMessageRenderCache,
getSessionMessageRenderCache,
peekSessionMessageRenderCache,
purgeMessageRenderCache,
extractReasoningTextForCopy,
extractReasoningTitleForRender,
} from "../lib/message-render-cache"
import { accountSessionTranscript } from "../stores/session-transcript-memory"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const NO_STEP_BORDER = "none"
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
@ -53,38 +62,6 @@ function extractTaskSessionId(state: ToolState | undefined): string {
return typeof directId === "string" ? directId : ""
}
function reasoningHasRenderableContent(part: ClientPart): boolean {
if (!part || part.type !== "reasoning") {
return false
}
const checkSegment = (segment: unknown): boolean => {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
return true
}
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
return true
}
if (Array.isArray(candidate.content)) {
return candidate.content.some((entry) => checkSegment(entry))
}
}
return false
}
if (checkSegment((part as any).text)) {
return true
}
if (Array.isArray((part as any).content)) {
return (part as any).content.some((entry: unknown) => checkSegment(entry))
}
return false
}
interface TaskSessionLocation {
sessionId: string
instanceId: string
@ -132,48 +109,27 @@ interface CachedBlockEntry {
toolKeys: string[]
}
interface SessionRenderCache {
messageItems: Map<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
}
const renderCaches = new Map<string, SessionRenderCache>()
function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
clearSessionMessageRenderCache(instanceId, sessionId)
}
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key)
if (!cache) {
cache = {
messageItems: new Map(),
toolItems: new Map(),
messageBlocks: new Map(),
}
renderCaches.set(key, cache)
function clearMessageRenderCache(instanceId: string, sessionId: string, messageIds: readonly string[]) {
const cache = peekSessionMessageRenderCache(instanceId, sessionId)
if (!cache) return
purgeMessageRenderCache(cache, messageIds)
if (cache.messageBlocks.size === 0 && cache.messageItems.size === 0 && cache.toolItems.size === 0 && cache.recordDisplayCache.size === 0) {
clearSessionMessageRenderCache(instanceId, sessionId)
}
return cache
}
function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:`
for (const key of renderCaches.keys()) {
if (key.startsWith(prefix)) {
renderCaches.delete(key)
}
}
clearInstanceMessageRenderCaches(instanceId)
}
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
messageStoreBus.onSessionCleared(clearSessionRenderCache)
messageStoreBus.onMessagesRemoved(clearMessageRenderCache)
function removeSearchMarks(root: HTMLElement) {
const marks = Array.from(root.querySelectorAll("mark.session-search-match"))
@ -257,6 +213,7 @@ interface ContentDisplayItem {
key: string
messageId: string
startPartId: string
partCount: number
}
interface ToolDisplayItem {
@ -272,6 +229,7 @@ interface MessageContentItemProps {
store: () => InstanceMessageStore
messageId: string
startPartId: string
partCount: number
messageIndex: number
lastAssistantIndex: () => number
onRevert?: (messageId: string) => void
@ -315,7 +273,7 @@ function MessageContentItem(props: MessageContentItemProps) {
if (startIndex === -1) return []
const resolved: ClientPart[] = []
for (let idx = startIndex; idx < ids.length; idx++) {
for (let idx = startIndex; idx < ids.length && resolved.length < props.partCount; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
@ -478,7 +436,7 @@ interface StepDisplayItem {
type ReasoningDisplayItem = {
type: "reasoning"
key: string
part: ClientPart
text: string
messageInfo?: MessageInfo
durationMs?: number
showAgentMeta?: boolean
@ -500,8 +458,9 @@ type CompactionDisplayItem = {
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
interface MessageDisplayBlock {
record: MessageRecord
messageId: string
items: MessageBlockItem[]
truncated: boolean
}
interface MessageBlockProps {
@ -527,12 +486,23 @@ export default function MessageBlock(props: MessageBlockProps) {
const { t } = useI18n()
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const sessionCache = getSessionMessageRenderCache(props.instanceId, props.sessionId) as {
messageItems: Map<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
recordDisplayCache: Map<string, unknown>
}
let blockRef: HTMLDivElement | undefined
const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId))
const activeSearchMatch = () => props.activeSearchMatch?.() ?? null
const isActiveSearchResult = () => activeSearchMatch()?.messageId === props.messageId
let lastInlineScrolledSearchMatchId: string | null = null
const handleContentRendered = () => {
props.onContentRendered?.()
accountSessionTranscript(props.instanceId, props.sessionId)
}
onCleanup(() => accountSessionTranscript(props.instanceId, props.sessionId))
createEffect(() => {
const query = props.searchQuery?.() ?? ""
@ -582,7 +552,9 @@ export default function MessageBlock(props: MessageBlockProps) {
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const displayData = buildRecordDisplayData(props.instanceId, current)
sessionCache.recordDisplayCache.set(current.id, { revision: current.revision, data: displayData })
const { orderedParts } = displayData
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
@ -611,8 +583,11 @@ export default function MessageBlock(props: MessageBlockProps) {
key: segmentKey,
messageId: current.id,
startPartId,
partCount: pendingParts.length,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.partCount = pendingParts.length
}
items.push(cached)
@ -690,7 +665,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "reasoning") {
flushContent()
if (props.showThinking() && reasoningHasRenderableContent(part)) {
const text = typeof (part as any).text === "string" ? (part as any).text : ""
if (props.showThinking() && text.trim().length > 0) {
const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
@ -700,7 +676,7 @@ export default function MessageBlock(props: MessageBlockProps) {
items.push({
type: "reasoning",
key,
part,
text,
messageInfo: info,
durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status),
showAgentMeta,
@ -718,13 +694,14 @@ export default function MessageBlock(props: MessageBlockProps) {
flushContent()
const resultBlock: MessageDisplayBlock = { record: current, items }
const resultBlock: MessageDisplayBlock = { messageId: current.id, items, truncated: displayData.truncated }
sessionCache.messageBlocks.set(current.id, {
signature: cacheSignature,
block: resultBlock,
contentKeys: blockContentKeys.slice(),
toolKeys: blockToolKeys.slice(),
})
accountSessionTranscript(props.instanceId, props.sessionId)
const messagePrefix = `${current.id}:`
for (const [key] of sessionCache.messageItems) {
@ -755,13 +732,13 @@ export default function MessageBlock(props: MessageBlockProps) {
return (
<Show when={block()}>
{(resolvedBlock) => (
<Show when={resolvedBlock().items.some(isDisplayItemVisible)}>
<Show when={resolvedBlock().truncated || resolvedBlock().items.some(isDisplayItemVisible)}>
<div
ref={(el) => {
blockRef = el
}}
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-message-id={resolvedBlock().messageId}
data-search-result={isSearchResult() ? "true" : undefined}
data-search-active={isActiveSearchResult() ? "true" : undefined}
>
@ -774,12 +751,13 @@ export default function MessageBlock(props: MessageBlockProps) {
sessionId={props.sessionId}
store={props.store}
messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item() as ContentDisplayItem).startPartId}
startPartId={(item() as ContentDisplayItem).startPartId}
partCount={(item() as ContentDisplayItem).partCount}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
onContentRendered={handleContentRendered}
/>
</Match>
<Match when={item().type === "tool"}>
@ -794,7 +772,7 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
onContentRendered={props.onContentRendered}
onContentRendered={handleContentRendered}
/>
</div>
</Show>
@ -822,7 +800,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onContentRendered={props.onContentRendered}
onContentRendered={handleContentRendered}
/>
</Match>
<Match when={item().type === "compaction"}>
@ -837,7 +815,11 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match>
<Match when={item().type === "reasoning"}>
<ReasoningCard
part={(item() as ReasoningDisplayItem).part}
text={(item() as ReasoningDisplayItem).text}
partId={(item() as ReasoningDisplayItem).partId}
copyText={() => extractReasoningTextForCopy(
props.store().getMessage((item() as ReasoningDisplayItem).messageId)?.parts[(item() as ReasoningDisplayItem).partId]?.data,
)}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
durationMs={(item() as ReasoningDisplayItem).durationMs}
instanceId={props.instanceId}
@ -845,13 +827,30 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
onContentRendered={props.onContentRendered}
onContentRendered={handleContentRendered}
forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId}
/>
</Match>
</Switch>
)}
</Index>
<Show when={resolvedBlock().truncated}>
<div class="tool-call-diagnostic-message" role="status">
<span>{t("toolCall.output.truncated")}</span>
<button
type="button"
class="tool-call-header-icon-button tool-call-header-copy"
onClick={() => {
const current = props.store().getMessage(resolvedBlock().messageId)
if (current) void copyToClipboard(JSON.stringify(orderedMessageParts(current), null, 2))
}}
aria-label={t("toolCall.io.copyOutputAriaLabel")}
title={t("toolCall.io.copyOutputTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
</div>
</Show>
)}
@ -859,6 +858,13 @@ export default function MessageBlock(props: MessageBlockProps) {
)
}
function orderedMessageParts(record: MessageRecord): ClientPart[] {
return record.partIds.flatMap((partId) => {
const part = record.parts[partId]?.data
return part ? [part] : []
})
}
interface StepCardProps {
kind: "start" | "finish"
part: ClientPart
@ -1063,7 +1069,9 @@ function formatCostValue(value: number) {
}
interface ReasoningCardProps {
part: ClientPart
text: string
partId: string
copyText: () => string
messageInfo?: MessageInfo
durationMs?: number
instanceId: string
@ -1171,51 +1179,9 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID
}
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
const reasoningText = () => props.text
const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") {
return segment
}
if (segment && typeof segment === "object") {
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const pieces: string[] = []
if (typeof obj.text === "string") {
pieces.push(obj.text)
}
if (typeof obj.value === "string") {
pieces.push(obj.value)
}
if (Array.isArray(obj.content)) {
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
}
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
}
return ""
}
const textValue = stringifySegment(part.text)
if (textValue.trim().length > 0) {
return textValue
}
if (Array.isArray(part.content)) {
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
}
return ""
}
const extractedTitle = () => {
const firstLine = reasoningText()
.split(/\r?\n/)
.map((line: string) => line.trim())
.find((line: string) => line.length > 0)
if (!firstLine) return ""
const match = firstLine.match(/^\*\*([^*]+)\*\*/)
return match?.[1]?.trim() ?? ""
}
const extractedTitle = () => extractReasoningTitleForRender(reasoningText())
const thoughtDurationTitle = () => {
const duration = props.durationMs
@ -1257,14 +1223,14 @@ function ReasoningCard(props: ReasoningCardProps) {
const toggle = () => setExpanded((prev) => !prev)
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.partId || "reasoning"}`,
text: reasoningText,
})
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
const handleCopyReasoning = async () => {
const text = reasoningText()
const text = props.copyText()
if (!text.trim()) return
await copyToClipboard(text)
}
@ -1296,7 +1262,7 @@ function ReasoningCard(props: ReasoningCardProps) {
return (
<div
class="delete-hover-scope message-reasoning-card"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
data-part-id={props.partId || undefined}
>
<div class="message-reasoning-header">
<button

View file

@ -120,7 +120,6 @@ export default function MessageSection(props: MessageSectionProps) {
const [searchedQuery, setSearchedQuery] = createSignal("")
const [isSearchPending, setIsSearchPending] = createSignal(false)
const [searchMatches, setSearchMatches] = createSignal<SessionSearchMatch[]>([])
const [searchResultsPartial, setSearchResultsPartial] = createSignal(false)
const [activeSearchIndex, setActiveSearchIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
@ -816,7 +815,6 @@ export default function MessageSection(props: MessageSectionProps) {
setSearchedQuery("")
setIsSearchPending(false)
setSearchMatches([])
setSearchResultsPartial(false)
return
}
setIsSearchPending(true)
@ -836,14 +834,13 @@ export default function MessageSection(props: MessageSectionProps) {
setIsSearchPending(true)
const frame = requestAnimationFrame(() => {
const result = buildSessionSearchMatches({
const matches = buildSessionSearchMatches({
store: store(),
sessionId: props.sessionId,
query,
includeThinking,
})
setSearchMatches(result.matches)
setSearchResultsPartial(result.partial)
setSearchMatches(matches)
setSearchedQuery(query)
setActiveSearchIndex(0)
setIsSearchPending(false)
@ -1184,7 +1181,7 @@ export default function MessageSection(props: MessageSectionProps) {
? t("messageSection.search.count.searching")
: searchMatches().length === 0
? t("messageSection.search.count.none")
: t(searchResultsPartial() ? "messageSection.search.count.partial" : "messageSection.search.count.matches", {
: t("messageSection.search.count.matches", {
current: String(activeSearchIndex() + 1),
total: String(searchMatches().length),
})}
@ -1220,9 +1217,6 @@ export default function MessageSection(props: MessageSectionProps) {
</button>
</div>
</div>
<Show when={isSearchSettled() && searchResultsPartial()}>
<div class="modal-empty-state message-search-empty" role="status">{t("messageSection.search.partialNotice", { count: String(searchMatches().length) })}</div>
</Show>
<Show when={trimmedSearchQuery().length >= SEARCH_MIN_CHARS && isSearchPending()}>
<div class="modal-empty-state message-search-empty">{t("messageSection.search.searching")}</div>
</Show>

View file

@ -13,9 +13,7 @@ import {
} from "../stores/instances"
import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { isPermissionDiffTooLarge, PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants"
import { copyToClipboard } from "../lib/clipboard"
import { limitToolTitleForRender } from "./tool-call/utils"
import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants"
const LazyToolCall = lazy(() => import("./tool-call"))
@ -141,20 +139,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
const [permissionRejectReasons, setPermissionRejectReasons] = createSignal<Map<string, string>>(new Map())
const [accessedDiffs, setAccessedDiffs] = createSignal<Map<string, string>>(new Map())
const permissionDiff = (permission: PermissionRequest): string => {
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
return typeof metadata.diff === "string" ? metadata.diff : ""
}
const approvalBlocked = (permission: PermissionRequest) =>
isPermissionDiffTooLarge(permissionDiff(permission)) && accessedDiffs().get(permission.id) !== permissionDiff(permission)
const copyFullPermissionDiff = async (permission: PermissionRequest) => {
if (!await copyToClipboard(permissionDiff(permission))) return
setAccessedDiffs((previous) => new Map(previous).set(permission.id, permissionDiff(permission)))
}
const getPermissionRejectReason = (permissionId: string) => permissionRejectReasons().get(permissionId) ?? ""
@ -190,7 +174,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
if (!permissionId) return
if (permissionSubmitting().has(permissionId)) return
if (response !== "reject" && approvalBlocked(permission)) return
setPermissionBusy(permissionId, true)
setPermissionItemError(permissionId, null)
@ -214,11 +197,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
createEffect(() => {
const current = new Map(permissionQueue().map((permission) => [permission.id, permissionDiff(permission)]))
setAccessedDiffs((previous) => new Map([...previous].filter(([id, diff]) => current.get(id) === diff)))
})
type InterruptionItem =
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest }
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
@ -339,10 +317,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const primaryTitle = () => {
if (item.kind === "permission") {
return limitToolTitleForRender(getPermissionDisplayTitle(item.payload))
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? limitToolTitleForRender(first) : t("permissionApproval.kind.question")
return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question")
}
const secondaryTitle = () => {
@ -406,18 +384,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<code>{primaryTitle()}</code>
</div>
<Show when={item.kind === "permission"}>
<Show when={approvalBlocked(item.payload as PermissionRequest)}>
<div class="tool-call-diagnostic-message" role="status">
{t("toolCall.permission.fullDiffRequired")}
<button
type="button"
class="tool-call-permission-button"
onClick={() => void copyFullPermissionDiff(item.payload as PermissionRequest)}
>
{t("toolCall.diff.copyPatch")}
</button>
</div>
</Show>
<div class="tool-call-permission-reject-reason">
<textarea
id={`permission-center-reject-reason-${item.id}`}
@ -436,7 +402,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id) || approvalBlocked(item.payload as PermissionRequest)}
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "once")}
>
{t("permissionApproval.actions.allowOnce")}
@ -444,7 +410,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id) || approvalBlocked(item.payload as PermissionRequest)}
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "always")}
>
{t("permissionApproval.actions.alwaysAllow")}

View file

@ -1,6 +1,5 @@
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor, type JSXElement } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, Volume2, WrapText, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
@ -33,7 +32,8 @@ import type {
import {
buildToolSpeechText,
ensureMarkdownContent,
formatUnknownForRender,
formatToolInputForCopy,
formatToolInputForRender,
getRelativePath,
getToolName,
isToolStateCompleted,
@ -193,7 +193,6 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [permissionApprovalBlocked, setPermissionApprovalBlocked] = createSignal(false)
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
@ -219,10 +218,8 @@ function ToolCallDetails(props: {
if (!permission) {
setPermissionSubmitting(false)
setPermissionError(null)
setPermissionApprovalBlocked(false)
} else {
setPermissionError(null)
setPermissionApprovalBlocked(true)
}
})
@ -236,7 +233,6 @@ function ToolCallDetails(props: {
async function handlePermissionResponse(permission: PermissionRequest, response: "once" | "always" | "reject", message?: string) {
if (!permission) return
if (response !== "reject" && permissionApprovalBlocked()) return
setPermissionSubmitting(true)
setPermissionError(null)
try {
@ -362,26 +358,11 @@ function ToolCallDetails(props: {
const status = () => props.toolState()?.status || ""
const formatToolInput = () => {
const input = props.toolInput()
try {
return { text: stringifyYaml(input), language: "yaml" }
} catch (error) {
log.error("Failed to convert tool call input to YAML", error)
try {
return { text: JSON.stringify(input, null, 2), language: "json" }
} catch (nestedError) {
log.error("Failed to stringify tool call input", nestedError)
return null
}
}
}
const toolInputDisplay = createMemo((): { content: string; language: string } | null => {
const input = props.toolInput()
if (!input || Object.keys(input).length === 0) return null
if (!props.inputSectionExpanded()) return { content: "", language: "yaml" }
const formatted = formatUnknownForRender(input)
if (!input) return null
if (!props.inputSectionExpanded()) return { content: "", language: "json" }
const formatted = formatToolInputForRender(input)
if (!formatted) return null
const language = formatted.language ?? "text"
const content = ensureMarkdownContent(formatted.text, language, true)
@ -483,6 +464,7 @@ function ToolCallDetails(props: {
const outputChrome = createMemo<ToolOutputChrome>(() => renderer().getOutputChrome?.(rendererContext) ?? {})
const resolveOutputCopyText = () => outputChrome().copyText || outputChrome().getCopyText?.() || ""
const canCopyOutput = () => outputChrome().hasCopyText ?? Boolean(outputChrome().copyText || outputChrome().getCopyText)
const renderError = () => {
const state = props.toolState()
@ -508,7 +490,6 @@ function ToolCallDetails(props: {
active={props.isPermissionActive}
submitting={permissionSubmitting}
error={permissionError}
onApprovalBlockedChange={setPermissionApprovalBlocked}
renderDiff={renderDiffContent}
fallbackSessionId={() => props.sessionId}
onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)}
@ -544,8 +525,7 @@ function ToolCallDetails(props: {
}
const copyToolInput = async (event: MouseEvent) => {
const formatted = formatToolInput()
await copyIoText(event, formatted?.text)
await copyIoText(event, formatToolInputForCopy(props.toolInput())?.text)
}
const outputWrapTitle = () =>
@ -579,7 +559,13 @@ function ToolCallDetails(props: {
</Show>
<Show when={Boolean(options.copyText?.() || options.onCopy)}>
<button type="button" class="tool-call-header-icon-button tool-call-header-copy tool-call-io-copy" onClick={(event) => options.onCopy ? options.onCopy(event) : void copyIoText(event, options.copyText?.())} aria-label={options.copyAriaLabel?.() ?? props.t("toolCall.io.copyOutputAriaLabel")} title={options.copyTitle?.() ?? props.t("toolCall.io.copyOutputTitle")}>
<button
type="button"
class="tool-call-header-icon-button tool-call-header-copy tool-call-io-copy"
onClick={(event) => options.onCopy ? options.onCopy(event) : void copyIoText(event, options.copyText?.())}
aria-label={options.copyAriaLabel?.() ?? props.t("toolCall.io.copyOutputAriaLabel")}
title={options.copyTitle?.() ?? props.t("toolCall.io.copyOutputTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
@ -687,7 +673,7 @@ function ToolCallDetails(props: {
expanded: props.outputSectionExpanded,
onToggle: props.toggleOutputSection,
copyText: () => outputChrome().copyText,
onCopy: outputChrome().getCopyText ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined,
onCopy: canCopyOutput() ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined,
copyTitle: () => props.t("toolCall.io.copyOutputTitle"),
copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"),
actions: () => outputChrome().actions,
@ -831,7 +817,9 @@ export default function ToolCall(props: ToolCallProps) {
const hasToolInput = createMemo(() => {
const input = toolInput()
return input && Object.keys(input).length > 0
if (!input) return false
for (const key in input) if (Object.prototype.hasOwnProperty.call(input, key)) return true
return false
})
const [toolCallRootEl, setToolCallRootEl] = createSignal<HTMLDivElement | undefined>()
@ -844,10 +832,7 @@ export default function ToolCall(props: ToolCallProps) {
if (override !== undefined) return override
return diagnosticsDefaultExpanded()
}
const diagnosticsView = createMemo(() => {
const state = toolState()
return extractDiagnosticsView(state)
})
const diagnosticsView = createMemo(() => extractDiagnosticsView(toolState()))
const toggleInputSection = () => {
setInputSectionOverride((prev) => {
@ -982,7 +967,7 @@ export default function ToolCall(props: ToolCallProps) {
const renderedHeaderTitleDetail = createMemo(() => limitToolTitleForRender(headerTitleDetail()))
const headerCopyText = () => headerOutputChrome().copyText || headerOutputChrome().getCopyText?.() || ""
const canCopyHeaderOutput = () => Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText)
const canCopyHeaderOutput = () => headerOutputChrome().hasCopyText ?? Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText)
const canToggleOutputWrap = () => Boolean(headerOutputChrome().wrapToggle)
const outputWrapTitle = () =>
outputWrapEnabled()

View file

@ -53,8 +53,11 @@ const DIAGNOSTIC_SCAN_LIMIT = 10_000
export function hasDiagnosticMessages(diagnostics: DiagnosticsMap): boolean {
let scanned = 0
let scannedKeys = 0
for (const key in diagnostics) {
if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue
scannedKeys += 1
if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return true
const list = diagnostics[key]
if (!Array.isArray(list)) continue
const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned
@ -98,8 +101,11 @@ export function extractDiagnosticsView(state: ToolState | undefined): Diagnostic
typeof value === "string" ? value : undefined,
))
let scanned = 0
let scannedKeys = 0
for (const key in diagnosticsMap) {
if (!Object.prototype.hasOwnProperty.call(diagnosticsMap, key)) continue
scannedKeys += 1
if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return { ...view, truncated: true }
const list = diagnosticsMap[key]
if (!Array.isArray(list)) continue
const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned

View file

@ -1,11 +1,11 @@
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import { Show, Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "../../types/tool-state"
import useMediaQuery from "@suid/material/useMediaQuery"
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath, limitToolOutputForRender, shouldRenderDiffAsPlainText } from "./utils"
import { getRelativePath, limitToolOutputForRender, shouldRenderDiffPayloadAsPlainText } from "./utils"
import { getCacheEntry } from "../../lib/global-cache"
import { copyToClipboard } from "../../lib/clipboard"
@ -66,7 +66,7 @@ export function createDiffContentRenderer(params: {
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
const renderedDiffText = limitToolOutputForRender(payload.diffText)
const diffWasTruncated = shouldRenderDiffAsPlainText(payload.diffText)
const diffWasTruncated = shouldRenderDiffPayloadAsPlainText(payload)
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath
? params.t("toolCall.diff.label.withPath", { path: relativePath })
@ -130,7 +130,7 @@ export function createDiffContentRenderer(params: {
: params.t("toolCall.diff.enableWordWrap")
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const copyFullDiff = async () => {
const copiedDiff = payload.diffText
const copiedDiff = payload.copyText ?? payload.diffText
if (await copyToClipboard(copiedDiff)) options?.onFullDiffAccess?.(copiedDiff)
}
@ -158,24 +158,26 @@ export function createDiffContentRenderer(params: {
>
<Copy class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class="file-viewer-toolbar-icon-button"
onClick={() => handleModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
<Show when={!diffWasTruncated}>
<button
type="button"
class="file-viewer-toolbar-icon-button"
onClick={() => handleModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
</div>
</div>
{diffWasTruncated ? (

View file

@ -3,9 +3,9 @@ import type { PermissionRequest } from "../../types/permission"
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
import { getPermissionSessionId } from "../../types/permission"
import { useI18n } from "../../lib/i18n"
import { isPermissionDiffTooLarge, PERMISSION_REJECT_REASON_MAX_LENGTH } from "./permission-constants"
import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./permission-constants"
import type { DiffPayload, DiffRenderOptions } from "./types"
import { getRelativePath, limitToolTitleForRender } from "./utils"
import { getRelativePath } from "./utils"
type PermissionResponse = "once" | "always" | "reject"
@ -15,7 +15,6 @@ export type PermissionToolBlockProps = {
submitting: Accessor<boolean>
error: Accessor<string | null>
onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise<void>
onApprovalBlockedChange?: (blocked: boolean) => void
renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null
fallbackSessionId: Accessor<string>
}
@ -23,16 +22,10 @@ export type PermissionToolBlockProps = {
export function PermissionToolBlock(props: PermissionToolBlockProps) {
const { t } = useI18n()
const [rejectReason, setRejectReason] = createSignal("")
const [accessedDiff, setAccessedDiff] = createSignal("")
createEffect(() => {
const permission = props.permission()
const diff = (() => {
const metadata = (permission?.metadata ?? {}) as Record<string, unknown>
return typeof metadata.diff === "string" ? metadata.diff : ""
})()
props.permission()?.id
setRejectReason("")
if (!permission || accessedDiff() !== diff) setAccessedDiff("")
})
const diffPayload = () => {
@ -64,9 +57,6 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
respond("reject", rejectReason().trim() || undefined)
}
const approvalBlocked = () => isPermissionDiffTooLarge(diffPayload()?.diffText) && accessedDiff() !== diffPayload()?.diffText
createEffect(() => props.onApprovalBlockedChange?.(approvalBlocked()))
return (
<Show when={props.permission()}>
{(permission) => (
@ -79,7 +69,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
</div>
<div class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{limitToolTitleForRender(getPermissionDisplayTitle(permission()))}</code>
<code>{getPermissionDisplayTitle(permission())}</code>
</div>
<Show when={diffPayload()}>
{(payload) => (
@ -87,7 +77,6 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
{props.renderDiff(payload(), {
variant: "permission-diff",
disableScrollTracking: true,
onFullDiffAccess: setAccessedDiff,
label: payload().filePath
? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") })
: t("toolCall.permission.requestedDiff.label"),
@ -95,9 +84,6 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
</div>
)}
</Show>
<Show when={approvalBlocked()}>
<div class="tool-call-diagnostic-message" role="status">{t("toolCall.permission.fullDiffRequired")}</div>
</Show>
<Show when={!props.active()}>
<p class="tool-call-permission-queued-text">{t("toolCall.permission.queuedText")}</p>
</Show>
@ -119,7 +105,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting() || approvalBlocked()}
disabled={props.submitting()}
onClick={() => respond("once")}
>
{t("toolCall.permission.actions.allowOnce")}
@ -127,7 +113,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting() || approvalBlocked()}
disabled={props.submitting()}
onClick={() => respond("always")}
>
{t("toolCall.permission.actions.alwaysAllow")}

View file

@ -1,7 +1 @@
import { TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils"
export const PERMISSION_REJECT_REASON_MAX_LENGTH = 2000
export function isPermissionDiffTooLarge(diffText: string | null | undefined): boolean {
return (diffText?.length ?? 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT
}

View file

@ -1,23 +1,21 @@
import assert from "node:assert/strict"
import test from "node:test"
import { buildDiagnosticView } from "./diagnostics.ts"
import { isPermissionDiffTooLarge } from "./permission-constants.ts"
import { getLegacyTaskSummary, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./renderers/task-summary.ts"
import { formatUnknownForCopy, formatUnknownForRender, limitToolOutputForRender, limitToolTitleForRender, shouldRenderDiffAsPlainText, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, TOOL_TITLE_RENDER_CHARACTER_LIMIT } from "./utils.ts"
import { getLegacyTaskSummary, getTaskOutputCopyText, getTruncatedTaskStepTitleCopyText, isTaskScanTruncated, isTaskStepListTruncated, resolveTaskStepTruncation, stringifyChildTaskSteps, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./renderers/task-summary.ts"
import { extractTodosFromState, getRenderedTodos, getTodoCopyText, getTodoTitleKind, hasTodoCopyText, TODO_ITEM_RENDER_LIMIT } from "./renderers/todo-data.ts"
import { extractDiffPayload, formatToolInputForCopy, formatToolInputForRender, formatUnknownForCopy, formatUnknownForRender, limitToolOutputForRender, limitToolTitleForRender, shouldRenderDiffAsPlainText, shouldRenderDiffPayloadAsPlainText, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, TOOL_TITLE_RENDER_CHARACTER_LIMIT } from "./utils.ts"
test("large tool output is bounded for rendering and complete for lazy copy", () => {
const full = `HEAD${"x".repeat(20_000)}COPY_TAIL`
const rendered = limitToolOutputForRender(full)
assert.ok(rendered.length < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 100)
assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)
assert.equal(rendered.includes("COPY_TAIL"), false)
assert.equal(formatUnknownForRender({ full })?.text.includes("COPY_TAIL"), false)
assert.equal(formatUnknownForCopy({ full })?.text.includes("COPY_TAIL"), true)
})
test("tool titles and permission diffs share bounded policies", () => {
test("tool titles and diffs share bounded policies", () => {
assert.equal(limitToolTitleForRender("x".repeat(20_000)).length, TOOL_TITLE_RENDER_CHARACTER_LIMIT)
assert.equal(isPermissionDiffTooLarge("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)), false)
assert.equal(isPermissionDiffTooLarge("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 1)), true)
assert.equal(shouldRenderDiffAsPlainText("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)), false)
assert.equal(shouldRenderDiffAsPlainText("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 1)), true)
})
@ -45,3 +43,124 @@ test("legacy task summaries render 200 recent rows and copy all rows", () => {
assert.equal(bounded.truncated, true)
assert.equal(JSON.parse(stringifyLegacyTaskSummary(summary)).length, 250)
})
test("task step truncation starts after the exact 200-item limit", () => {
assert.equal(isTaskStepListTruncated(TASK_STEP_RENDER_LIMIT), false)
assert.equal(isTaskStepListTruncated(TASK_STEP_RENDER_LIMIT + 1), true)
assert.equal(getLegacyTaskSummary(Array.from({ length: TASK_STEP_RENDER_LIMIT })).truncated, false)
})
test("task truncation combines scan caps and ignores legacy state when child steps render", () => {
assert.equal(isTaskScanTruncated(false, true, false), true)
assert.equal(isTaskScanTruncated(true, false, false), true)
assert.equal(resolveTaskStepTruncation(true, false, true), false)
assert.equal(resolveTaskStepTruncation(false, false, true), true)
assert.equal(resolveTaskStepTruncation(false, true, false), true)
})
test("expanded input renders bounded JSON but copies the complete display format", () => {
const input = `${"x".repeat(20_000)}COPY_TAIL`
const rendered = formatToolInputForRender(input)
const copied = formatToolInputForCopy(input)
assert.equal(rendered?.language, "json")
assert.equal(rendered?.text.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)
assert.equal(rendered?.text.includes("COPY_TAIL"), false)
assert.equal(copied?.text, JSON.stringify(input, null, 2))
assert.equal(copied?.text.includes("COPY_TAIL"), true)
})
test("a long legacy task title exposes its full copy below the step limit", () => {
const title = `${"x".repeat(TOOL_TITLE_RENDER_CHARACTER_LIMIT)}COPY_TAIL`
assert.equal(getLegacyTaskSummary([{ title }]).truncated, false)
assert.equal(getTruncatedTaskStepTitleCopyText(title), title)
assert.equal(getTruncatedTaskStepTitleCopyText("short"), null)
})
test("oversized structured input is not serialized until copy", () => {
let serialized = 0
const input = { body: "x".repeat(20_000), toJSON: () => { serialized += 1; return { body: "copied" } } }
const rendered = formatToolInputForRender(input)
assert.equal(serialized, 0)
assert.equal(rendered?.language, "json")
assert.ok((rendered?.text.length ?? Infinity) < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)
assert.equal(typeof JSON.parse(rendered?.text ?? ""), "string")
assert.equal(formatToolInputForCopy(input)?.text, JSON.stringify({ body: "copied" }, null, 2))
assert.equal(serialized, 1)
})
test("aggregate diffs use copy text length as the truncation fallback", () => {
assert.equal(shouldRenderDiffPayloadAsPlainText({ diffText: "visible", copyText: "x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 1) }), true)
assert.equal(shouldRenderDiffPayloadAsPlainText({ diffText: "visible", copyText: "visible plus omitted" }), false)
assert.equal(shouldRenderDiffPayloadAsPlainText({ diffText: "visible", copyText: "visible" }), false)
})
test("todo title uses the full list while rendering is bounded and copy remains complete", () => {
const tail = "COPY_TAIL"
const state = {
status: "completed",
input: {},
metadata: {
todos: [
{ id: "1", content: "x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT), status: "completed" },
{ id: "2", content: tail, status: "pending" },
],
},
output: "",
} as any
assert.equal(getTodoTitleKind(state), "updating")
const rendered = getRenderedTodos(JSON.parse(getTodoCopyText(state)))
assert.equal(rendered.truncated, true)
assert.equal(rendered.items.some((todo) => todo.content.includes(tail)), false)
assert.equal(getTodoCopyText(state).includes(tail), true)
})
test("todo rendering stops normalizing after its visible character budget", () => {
const todos = [{ content: "x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT), status: "pending" }] as any[]
Object.defineProperty(todos, 1, { get: () => { throw new Error("unbounded todo scan") } })
todos.length = 2
const state = { status: "completed", input: {}, metadata: { todos }, output: "" } as any
assert.equal(getRenderedTodos(extractTodosFromState(state)).truncated, true)
})
test("todo leading whitespace does not consume the visible content budget", () => {
const state = {
status: "completed",
input: {},
metadata: { todos: [{ content: `${" ".repeat(5_000)}meaningful todo`, status: "pending" }] },
output: "",
} as any
const rendered = getRenderedTodos(extractTodosFromState(state))
assert.equal(rendered.items[0]?.content, "meaningful todo")
})
test("todo leading whitespace retains truncation and lazy full copy", () => {
const content = `${" ".repeat(200_000)}meaningful todo`
const state = { status: "completed", input: {}, metadata: { todos: [{ content, status: "pending" }] }, output: "" } as any
const rendered = getRenderedTodos(extractTodosFromState(state))
assert.equal(rendered.items.length, 0)
assert.equal(rendered.truncated, true)
assert.equal(hasTodoCopyText(state), true)
assert.equal(getTodoCopyText(state).includes(content), true)
const many = Array.from({ length: TODO_ITEM_RENDER_LIMIT + 1 }, (_, index) => ({ content: String(index), status: "pending" }))
assert.equal(getRenderedTodos(extractTodosFromState({ ...state, metadata: { todos: many } })).truncated, true)
})
test("oversized diff eligibility accepts a bounded hunk prefix", () => {
const diff = `@@ -1 +1 @@\n-${"x".repeat(20_000)}\n+y`
const state = { status: "completed", input: {}, metadata: { diff }, output: "" } as any
assert.equal(extractDiffPayload("edit", state)?.diffText, diff)
})
test("task output copy retains content omitted from rendering", () => {
assert.equal(getTaskOutputCopyText({ output: `x${"y".repeat(20_000)}COPY_TAIL` })?.includes("COPY_TAIL"), true)
})
test("child task step copy includes steps omitted from rendering", () => {
const partIds = Array.from({ length: TASK_STEP_RENDER_LIMIT + 1 }, (_, index) => `part-${index}`)
const message = { partIds, parts: Object.fromEntries(partIds.map((id) => [id, { data: { id, type: "tool" } }])) }
assert.equal(JSON.parse(stringifyChildTaskSteps(["message"], () => message)).length, TASK_STEP_RENDER_LIMIT + 1)
})

View file

@ -8,7 +8,9 @@ import { readRenderer } from "./renderers/read.tsx"
import { skillRenderer } from "./renderers/skill.tsx"
import { webfetchRenderer } from "./renderers/webfetch.tsx"
import { writeRenderer } from "./renderers/write.tsx"
import { getApplyPatchCopyText } from "./renderers/apply-patch-data.ts"
import { getApplyPatchCopyAccess, getApplyPatchCopyOutput, getApplyPatchCopyText, getApplyPatchFilesForRender, getApplyPatchPathLabel, getApplyPatchRenderData, hasApplyPatchCopyText } from "./renderers/apply-patch-data.ts"
import { getTaskOutputCopyText } from "./renderers/task-summary.ts"
import { getTodoCopyText } from "./renderers/todo-data.ts"
const full = `${"x".repeat(10_000)}COPY_TAIL`
const base = { toolName: () => "tool", t: (key: string) => key } as any
@ -32,6 +34,103 @@ for (const [name, renderer, state] of cases) {
})
}
for (const [name, renderer] of [["bash", bashRenderer], ["default", defaultRenderer], ["webfetch", webfetchRenderer], ["skill", skillRenderer]] as const) {
test(`${name} hides copy chrome for known empty output`, () => {
const state = { status: "completed", input: {}, metadata: {}, output: "" }
assert.equal(renderer.getOutputChrome?.({ ...base, toolName: () => name, toolState: () => state } as any), undefined)
})
}
test("apply_patch keeps the complete diff available for lazy copy", () => {
assert.equal(getApplyPatchCopyText([{ diff: full }]).includes("COPY_TAIL"), true)
assert.equal(hasApplyPatchCopyText([{ diff: full }]), true)
})
test("apply_patch joins aggregate output only when lazy copy resolves", () => {
const originalJoin = Array.prototype.join
let joins = 0
Array.prototype.join = function (...args: Parameters<typeof originalJoin>) {
joins += 1
return originalJoin.apply(this, args)
}
try {
const chrome = getApplyPatchCopyAccess([{ diff: "+one" }, { diff: "+two" }], "")
assert.equal(joins, 0)
assert.equal(chrome?.getCopyText?.(), "+one\n+two")
assert.equal(joins, 1)
} finally {
Array.prototype.join = originalJoin
}
})
test("empty apply_patch metadata falls back to completed output", () => {
const output = "fallback output"
assert.equal(getApplyPatchCopyOutput([{ diff: "" }], output), output)
})
test("apply_patch retains diagnostic-only files in render data", () => {
assert.deepEqual(getApplyPatchFilesForRender([{ filePath: "empty.ts", diff: "" }], ["empty.ts"]).files, [{ filePath: "empty.ts", diff: "" }])
assert.deepEqual(getApplyPatchFilesForRender([], ["diagnostic-only.ts"]).files, [{ filePath: "diagnostic-only.ts" }])
assert.deepEqual(getApplyPatchFilesForRender([{ filePath: "C:\\repo\\src\\same.ts", diff: "" }], ["src/same.ts"]).files, [{ filePath: "C:\\repo\\src\\same.ts", diff: "" }])
})
test("apply_patch preserves lazy copy access when a diff exceeds the scan limit", () => {
const fallback = "visible fallback"
const files = [{ diff: " ".repeat(10_001) }]
assert.equal(hasApplyPatchCopyText(files), true)
assert.equal(getApplyPatchCopyAccess(files, fallback)?.getCopyText?.(), fallback)
const rendered = getApplyPatchRenderData(getApplyPatchFilesForRender(files, []).files, 20, 100)
assert.equal(rendered.rendered[0]?.diffText, "")
assert.equal(rendered.truncated, true)
})
test("apply_patch does not scan through unbounded leading whitespace", () => {
const whitespace = " ".repeat(20_000)
assert.equal(getApplyPatchCopyAccess([{ diff: whitespace }, { diff: "+later" }], "fallback")?.getCopyText?.(), "+later")
assert.equal(getApplyPatchCopyAccess([{ diff: whitespace }], "fallback")?.getCopyText?.(), "fallback")
assert.equal(getApplyPatchFilesForRender([{ diff: `${whitespace}+visible` }], []).files.length, 1)
const rendered = getApplyPatchRenderData([{ diff: `${whitespace}+visible` }], 20, 100)
assert.equal(rendered.rendered[0]?.diffText, "")
assert.equal(rendered.truncated, true)
assert.equal(getApplyPatchCopyAccess([{ diff: `${whitespace}+visible` }], "")?.getCopyText?.()?.endsWith("+visible"), true)
})
test("apply_patch keeps files beyond the collection scan limit available to lazy copy", () => {
const files = Array.from({ length: 10_001 }, (_, index) => ({ diff: index === 10_000 ? "+COPY_TAIL" : "" }))
const access = getApplyPatchCopyAccess(files, "")
assert.equal(access?.hasCopyText, true)
assert.equal(access?.getCopyText?.(), "+COPY_TAIL")
})
test("task and todo keep complete output available for lazy copy", () => {
assert.equal(getTaskOutputCopyText({ status: "completed", output: full })?.includes("COPY_TAIL"), true)
assert.equal(getTodoCopyText({ status: "completed", input: {}, output: "", metadata: { todos: [{ content: full, status: "pending" }] } } as any).includes("COPY_TAIL"), true)
})
test("apply_patch signals per-file and outer truncation without losing copy payloads", () => {
const perFile = getApplyPatchRenderData([{ diff: full }], 20, 10_000)
assert.equal(perFile.truncated, true)
assert.equal(perFile.rendered[0]?.diffText.includes("COPY_TAIL"), false)
assert.equal(getApplyPatchCopyText(perFile.rendered.map(({ file }) => file)).includes("COPY_TAIL"), true)
const files = Array.from({ length: 21 }, (_, index) => ({ diff: `diff-${index}` }))
const outer = getApplyPatchRenderData(files, 20, Number.POSITIVE_INFINITY)
assert.equal(outer.rendered.length, 20)
assert.equal(outer.truncated, true)
assert.equal(getApplyPatchCopyText(files).includes("diff-20"), true)
})
test("apply_patch selection stops after the rendered file limit", () => {
const files = Array.from({ length: 20 }, (_, index) => ({ filePath: `file-${index}`, diff: `+${index}` })) as any[]
Object.defineProperty(files, 20, { get: () => { throw new Error("unbounded file scan") } })
files.length = 21
const selected = getApplyPatchFilesForRender(files, [])
assert.equal(selected.files.length, 20)
assert.equal(selected.truncated, true)
})
test("apply_patch path labels are bounded from the path tail", () => {
const label = getApplyPatchPathLabel(`C:\\repo\\${"x".repeat(20_000)}.ts`)
assert.equal(label.length, 384)
assert.equal(label.endsWith(".ts"), true)
})

View file

@ -6,17 +6,135 @@ export type ApplyPatchFile = {
patch?: string
}
export const APPLY_PATCH_FILE_RENDER_LIMIT = 20
const APPLY_PATCH_SCAN_LIMIT = 10_000
export function getApplyPatchPathLabel(path: string, limit = 384): string {
const tail = path.slice(-(limit + 1)).replace(/\\/g, "/")
const separator = tail.lastIndexOf("/")
const label = separator >= 0 ? tail.slice(separator + 1) : tail
return label.length <= limit ? label : `...${label.slice(-(limit - 3))}`
}
export function* getApplyPatchDiagnosticPaths(diagnostics: Record<string, unknown>): Generator<string> {
for (const path in diagnostics) if (Object.prototype.hasOwnProperty.call(diagnostics, path)) yield path
}
function getApplyPatchDiff(file: ApplyPatchFile): string {
return typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
}
function probeApplyPatchCopyText(file: ApplyPatchFile, limit: number) {
const diff = getApplyPatchDiff(file)
const prefix = diff.slice(0, Math.max(0, limit))
return { hasContent: /\S/.test(prefix), scanned: prefix.length, truncated: prefix.length < diff.length }
}
export function hasApplyPatchCopyText(files: ApplyPatchFile[], scanLimit = APPLY_PATCH_SCAN_LIMIT): boolean {
let characters = 0
let index = 0
for (; index < files.length && index < scanLimit; index += 1) {
const probe = probeApplyPatchCopyText(files[index], scanLimit - characters)
if (probe.hasContent || probe.truncated) return true
characters += probe.scanned
}
return index < files.length
}
export function getApplyPatchCopyText(files: ApplyPatchFile[], limit = Number.POSITIVE_INFINITY): string {
const diffs: string[] = []
let characters = 0
for (const file of files) {
const diff = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
if (!diff.trim()) continue
const remaining = limit - characters
if (remaining <= 0) break
diffs.push(diff.slice(0, remaining))
const diff = getApplyPatchDiff(file)
const copyText = diff.slice(0, remaining)
if (!copyText.trim()) {
if (copyText.length < diff.length) break
continue
}
diffs.push(copyText)
characters += Math.min(diff.length, remaining)
if (diff.length > remaining) break
}
return diffs.join("\n")
}
export function getApplyPatchCopyOutput(files: ApplyPatchFile[], fallback: unknown): string | null {
return getApplyPatchCopyText(files) || (typeof fallback === "string" && fallback.length > 0 ? fallback : null)
}
export function getApplyPatchCopyAccess(files: ApplyPatchFile[], fallback: unknown) {
if (hasApplyPatchCopyText(files)) {
return { language: "diff" as const, getCopyText: () => getApplyPatchCopyOutput(files, fallback), hasCopyText: true as const }
}
if (typeof fallback !== "string" || fallback.length === 0) return undefined
return { language: "text" as const, getCopyText: () => fallback, hasCopyText: true as const }
}
export function getApplyPatchRenderData(files: ApplyPatchFile[], fileLimit: number, characterLimit: number, sourceTruncated = false) {
const rendered: Array<{ file: ApplyPatchFile; diffText: string }> = []
let characters = 0
let scannedCharacters = 0
let truncated = sourceTruncated
for (const file of files) {
if (rendered.length >= fileLimit || characters >= characterLimit) {
truncated = true
break
}
const remaining = characterLimit - characters
const fullDiff = getApplyPatchDiff(file)
const probe = probeApplyPatchCopyText(file, Math.min(APPLY_PATCH_SCAN_LIMIT - scannedCharacters, remaining))
scannedCharacters += probe.scanned
const firstContent = probe.hasContent ? fullDiff.slice(0, probe.scanned).search(/\S/) : -1
const diffText = firstContent >= 0 ? fullDiff.slice(firstContent, firstContent + remaining) : ""
rendered.push({ file, diffText })
characters += diffText.length
if (probe.truncated || (firstContent >= 0 && fullDiff.length - firstContent > remaining)) truncated = true
}
return { rendered, truncated }
}
export function getApplyPatchFilesForRender(files: ApplyPatchFile[], diagnosticPaths: Iterable<string>, limit = APPLY_PATCH_FILE_RENDER_LIMIT) {
const normalize = (path: string) => path.replace(/\\/g, "/")
const matchesPath = (left: string, right: string) => left === right || left.endsWith(`/${right}`) || right.endsWith(`/${left}`)
const paths: Array<{ raw: string; normalized: string }> = []
let diagnosticPathsTruncated = false
for (const path of diagnosticPaths) {
if (paths.length >= limit) {
diagnosticPathsTruncated = true
break
}
paths.push({ raw: path, normalized: normalize(path) })
}
const rendered: ApplyPatchFile[] = []
const knownPaths: string[] = []
let scannedFiles = 0
let scannedCharacters = 0
for (let fileIndex = 0; fileIndex < files.length && rendered.length < limit; fileIndex += 1) {
const file = files[fileIndex]
scannedFiles += 1
const normalizedFilePaths = [file.filePath, file.relativePath]
.filter((path): path is string => typeof path === "string")
.map(normalize)
const matchesDiagnostic = normalizedFilePaths.some((path) => paths.some((diagnostic) => matchesPath(path, diagnostic.normalized)))
const probe = probeApplyPatchCopyText(file, APPLY_PATCH_SCAN_LIMIT - scannedCharacters)
scannedCharacters += probe.scanned
if (probe.hasContent || probe.truncated || matchesDiagnostic) {
rendered.push(file)
knownPaths.push(...normalizedFilePaths)
}
if (scannedFiles >= 10_000) break
}
for (const path of paths) {
if (rendered.length >= limit) break
if (!knownPaths.some((knownPath) => matchesPath(knownPath, path.normalized))) {
rendered.push({ filePath: path.raw })
knownPaths.push(path.normalized)
}
}
return {
files: rendered,
truncated: scannedFiles < files.length || diagnosticPathsTruncated,
}
}

View file

@ -1,12 +1,10 @@
import { For, Show, createMemo } from "solid-js"
import type { ToolRenderer } from "../types"
import { getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils"
import { getToolName, isToolStateCompleted, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils"
import { buildDiagnosticView, hasDiagnosticMessages, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
import { DiagnosticsPayloadAccess } from "../diagnostics-section"
import { getApplyPatchToolSearchText } from "../search-text"
import { getApplyPatchCopyText, type ApplyPatchFile } from "./apply-patch-data"
const APPLY_PATCH_FILE_RENDER_LIMIT = 20
import { APPLY_PATCH_FILE_RENDER_LIMIT, getApplyPatchCopyAccess, getApplyPatchCopyText, getApplyPatchDiagnosticPaths, getApplyPatchFilesForRender, getApplyPatchPathLabel, getApplyPatchRenderData, hasApplyPatchCopyText, type ApplyPatchFile } from "./apply-patch-data"
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
return (
@ -64,46 +62,35 @@ export const applyPatchRenderer: ToolRenderer = {
const payload = readToolStatePayload(state)
const files = Array.isArray((payload.metadata as any).files) ? ((payload.metadata as any).files as ApplyPatchFile[]) : []
if (files.some((file) => typeof file.diff === "string" || typeof file.patch === "string")) {
return {
language: "diff",
getCopyText: () => getApplyPatchCopyText(files),
suppressInnerHeader: false,
}
}
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
if (!fallback) return undefined
return { language: "text", getCopyText: () => fallback, wrapToggle: true, suppressInnerHeader: true }
const fallback = isToolStateCompleted(state) && typeof state.output === "string" && state.output.length > 0 ? state.output : null
const access = getApplyPatchCopyAccess(files, fallback)
if (!access) return undefined
return access.language === "diff"
? { ...access, suppressInnerHeader: false }
: { ...access, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
const state = toolState()
if (!state || state.status === "pending") return null
const payload = readToolStatePayload(state)
const allFiles = createMemo(() => {
const list = (payload.metadata as any).files
return Array.isArray(list) ? (list as ApplyPatchFile[]) : []
})
const files = createMemo(() => {
const rendered: ApplyPatchFile[] = []
let characters = 0
for (const file of allFiles()) {
if (rendered.length >= APPLY_PATCH_FILE_RENDER_LIMIT || characters >= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) break
const diff = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const remaining = TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters
rendered.push(diff.length > remaining ? { ...file, diff: file.diff ? diff.slice(0, remaining + 1) : undefined, patch: file.patch ? diff.slice(0, remaining + 1) : undefined } : file)
characters += Math.min(diff.length, remaining)
}
return rendered
})
const diagnosticsMap = createMemo(() => {
const value = (payload.metadata as any).diagnostics
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
})
const allFiles = createMemo(() => {
const list = (payload.metadata as any).files
return getApplyPatchFilesForRender(Array.isArray(list) ? list as ApplyPatchFile[] : [], getApplyPatchDiagnosticPaths(diagnosticsMap()))
})
const renderData = createMemo(() => getApplyPatchRenderData(allFiles().files, APPLY_PATCH_FILE_RENDER_LIMIT, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, allFiles().truncated))
const files = createMemo(() => renderData().rendered)
const fallback = createMemo(() => {
if (!isToolStateCompleted(state) || hasApplyPatchCopyText(files().map(({ file }) => file))) return null
return typeof state.output === "string" && state.output.length > 0 ? state.output : null
})
const diagnosticViews = createMemo(() => {
let remaining = 100
return files().map((file) => {
return files().map(({ file }) => {
const view = buildDiagnosticView(diagnosticsMap(), [file.filePath, file.relativePath])
const entries = view.entries.slice(0, remaining)
remaining -= entries.length
@ -115,8 +102,11 @@ export const applyPatchRenderer: ToolRenderer = {
const renderedKeys = new Set(views.map((view) => view.key).filter(Boolean))
if (views.some((view) => view.truncated)) return true
let scanned = 0
let scannedKeys = 0
for (const key in diagnosticsMap()) {
if (!Object.prototype.hasOwnProperty.call(diagnosticsMap(), key)) continue
scannedKeys += 1
if (scannedKeys > 10_000) return true
const list = diagnosticsMap()[key]
if (!renderedKeys.has(key) && Array.isArray(list)) {
const remaining = 10_000 - scanned
@ -131,18 +121,20 @@ export const applyPatchRenderer: ToolRenderer = {
return false
})
if (files().length === 0) {
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
if (!fallback) return null
return renderMarkdown({ content: limitToolOutputForRender(fallback), size: "large", disableHighlight: state.status === "running" })
}
if (files().length === 0 && !fallback() && !renderData().truncated) return null
return (
<div class="tool-call-apply-patch">
<Show when={fallback()}>
{(content) => renderMarkdown({ content: limitToolOutputForRender(content()), size: "large", disableHighlight: state.status === "running" })}
</Show>
<For each={files()}>
{(file, index) => {
{(renderedFile, index) => {
const file = renderedFile.file
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const label = getApplyPatchPathLabel(labelBase)
const fullDiff = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const diffText = renderedFile.diffText
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => diagnosticViews()[index()]?.entries ?? [])
@ -150,19 +142,19 @@ export const applyPatchRenderer: ToolRenderer = {
<div class="tool-call-apply-patch-file">
<Show when={diffText.trim().length > 0}>
{renderDiff(
{ diffText, filePath },
{ diffText, copyText: fullDiff, filePath },
{
label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }),
cacheKey: `apply_patch:${labelBase}:${index()}`,
label: limitToolTitleForRender(t("toolCall.diff.label.withPath", { path: label })),
cacheKey: `apply_patch:${index()}`,
},
)}
</Show>
<DiagnosticsInline entries={entries()} label={labelBase} t={t} />
<DiagnosticsInline entries={entries()} label={label} t={t} />
</div>
)
}}
</For>
<Show when={allFiles().length > files().length}>
<Show when={renderData().truncated}>
<div class="tool-call-diagnostic-message" role="status">{t("toolCall.output.truncated")}</div>
</Show>
<Show when={hasDiagnosticMessages(diagnosticsMap())}>

View file

@ -199,7 +199,12 @@ export const bashRenderer: ToolRenderer = {
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
return { language: "bash", getCopyText: () => getBashCopyText(state), wrapToggle: true, suppressInnerHeader: true }
const { input, metadata } = readToolStatePayload(state)
const output = isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) ? metadata.output : undefined
const hasCopyText = (typeof input.command === "string" && input.command.length > 0)
|| (output !== undefined && output !== null && output !== "" && (!Array.isArray(output) || output.length > 0))
if (!hasCopyText) return undefined
return { language: "bash", getCopyText: () => getBashCopyText(state), hasCopyText: true, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} onContentRendered={onContentRendered} />

View file

@ -16,12 +16,13 @@ export const defaultRenderer: ToolRenderer = {
? metadata.output
: metadata.diff ?? metadata.preview ?? input.content
if (primaryOutput === undefined || primaryOutput === null) return undefined
if (primaryOutput === undefined || primaryOutput === null || primaryOutput === "" || (Array.isArray(primaryOutput) && primaryOutput.length === 0)) return undefined
const result = formatUnknownForRender(primaryOutput)
return {
language: result?.language ?? "text",
getCopyText: () => formatUnknownForCopy(primaryOutput)?.text ?? null,
hasCopyText: true,
wrapToggle: true,
suppressInnerHeader: true,
}

View file

@ -12,8 +12,8 @@ export const skillRenderer: ToolRenderer = {
const state = toolState()
if (!state || state.status !== "completed") return undefined
if (state.output === undefined || state.output === null) return undefined
return { getCopyText: () => formatUnknownForCopy(state.output)?.text ?? null, suppressInnerHeader: true }
if (state.output === undefined || state.output === null || state.output === "" || (Array.isArray(state.output) && state.output.length === 0)) return undefined
return { getCopyText: () => formatUnknownForCopy(state.output)?.text ?? null, hasCopyText: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderMarkdown }) {
const state = toolState()

View file

@ -1,14 +1,53 @@
import { limitToolTitleForRender } from "../utils"
export const TASK_STEP_RENDER_LIMIT = 200
export function isTaskStepListTruncated(count: number): boolean {
return count > TASK_STEP_RENDER_LIMIT
}
export function isTaskScanTruncated(...sources: boolean[]): boolean {
return sources.some(Boolean)
}
export function resolveTaskStepTruncation(childSourceActive: boolean, childTruncated: boolean, legacyTruncated: boolean): boolean {
return childTruncated || (!childSourceActive && legacyTruncated)
}
export function getTaskOutputCopyText(state: unknown): string | null {
const output = (state as { output?: unknown } | null | undefined)?.output
return typeof output === "string" && output.length > 0 ? output : null
}
export function stringifyChildTaskSteps(
messageIds: readonly string[],
getMessage: (messageId: string) => { partIds: readonly string[]; parts: Record<string, { data?: unknown } | undefined> } | undefined,
): string {
const steps: unknown[] = []
for (const messageId of messageIds) {
const message = getMessage(messageId)
if (!message) continue
for (const partId of message.partIds) {
const part = message.parts[partId]?.data as { type?: unknown } | undefined
if (part?.type === "tool") steps.push(part)
}
}
return JSON.stringify(steps, null, 2)
}
export function getLegacyTaskSummary(summary: unknown) {
const entries = Array.isArray(summary) ? summary : []
return {
entries,
renderedEntries: entries.slice(-TASK_STEP_RENDER_LIMIT),
truncated: entries.length > TASK_STEP_RENDER_LIMIT,
truncated: isTaskStepListTruncated(entries.length),
}
}
export function stringifyLegacyTaskSummary(summary: unknown): string {
return JSON.stringify(getLegacyTaskSummary(summary).entries, null, 2)
}
export function getTruncatedTaskStepTitleCopyText(title: string): string | null {
return limitToolTitleForRender(title) === title ? null : title
}

View file

@ -5,13 +5,16 @@ import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload } from "../utils"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { loadMessages } from "../../../stores/session-api"
import { messagesLoaded } from "../../../stores/session-state"
import { getSessionMessagesLoadError, messagesLoaded, sessions } from "../../../stores/session-state"
import { setSessionTranscriptVisible } from "../../../stores/session-transcript-memory"
import { waitForInstanceWorkspaceMetadataHydration } from "../../../stores/instances"
import { useActiveSessionMessageLoad } from "../../../lib/hooks/use-active-session-message-load"
import { getTaskToolSearchText } from "../search-text"
import { copyToClipboard } from "../../../lib/clipboard"
import { getLegacyTaskSummary, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./task-summary"
import LoadErrorState from "../../load-error-state"
import { getLegacyTaskSummary, getTaskOutputCopyText, getTruncatedTaskStepTitleCopyText, isTaskScanTruncated, isTaskStepListTruncated, resolveTaskStepTruncation, stringifyChildTaskSteps, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./task-summary"
const TASK_MESSAGE_SCAN_LIMIT = 10_000
interface TaskSummaryItem {
id: string
@ -23,6 +26,8 @@ interface TaskSummaryItem {
title?: string
}
type TaskScanBudget = { remaining: number }
function extractSessionIdFromTaskState(state?: ToolState): string {
if (!state) return ""
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
@ -169,6 +174,10 @@ export const taskRenderer: ToolRenderer = {
tools: ["task"],
getSearchText: getTaskToolSearchText,
getAction: ({ t }) => t("toolCall.task.action.delegating"),
getOutputChrome({ toolState }) {
const output = getTaskOutputCopyText(toolState())
return output ? { getCopyText: () => output, hasCopyText: true } : undefined
},
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined
@ -177,6 +186,7 @@ export const taskRenderer: ToolRenderer = {
},
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
const store = messageStoreBus.getOrCreate(instanceId)
const childSessionId = createMemo(() => {
const state = toolState()
return extractSessionIdFromTaskState(state)
@ -189,13 +199,25 @@ export const taskRenderer: ToolRenderer = {
return loadedForInstance?.has(id) ?? false
})
const childSessionLoadError = createMemo(() => {
const id = childSessionId()
return id && !childSessionLoaded() ? getSessionMessagesLoadError(instanceId, id) : undefined
})
function retryChildSessionLoad() {
const id = childSessionId()
if (!id) return
void loadMessages(instanceId, id, { force: true }).catch(() => {})
}
useActiveSessionMessageLoad({
isActive: () => Boolean(childSessionId()),
instanceId: () => instanceId,
session: () => {
const id = childSessionId()
return id ? { id } : undefined
return id ? sessions().get(instanceId)?.get(id) : undefined
},
shouldLoad: () => !childSessionLoaded(),
loadMessages: (childInstanceId, id, options) => loadMessages(childInstanceId, id, {
registerInvalidation: options?.registerInvalidation,
}),
@ -226,21 +248,29 @@ export const taskRenderer: ToolRenderer = {
setChildToolsTruncated(false)
}
function scanMessageToolParts(messageId: string, startIndex: number, limit: number) {
function scanMessageToolParts(messageId: string, startIndex: number, limit: number, budget: TaskScanBudget) {
if (budget.remaining <= 0) {
setChildToolsTruncated(true)
return [] as string[]
}
budget.remaining -= 1
const record = store.getMessage(messageId)
if (!record) return [] as string[]
const partIds = record.partIds
const keys: string[] = []
const oldestScannedIndex = Math.max(startIndex, partIds.length - 1_000)
const oldestScannedIndex = Math.max(startIndex, partIds.length - budget.remaining)
if (oldestScannedIndex > startIndex) setChildToolsTruncated(true)
for (let idx = partIds.length - 1; idx >= oldestScannedIndex && keys.length < limit; idx -= 1) {
let idx = partIds.length - 1
for (; idx >= oldestScannedIndex && keys.length < limit && budget.remaining > 0; idx -= 1) {
budget.remaining -= 1
const partId = partIds[idx]
const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.unshift(`${messageId}::${partId}`)
}
if (idx >= oldestScannedIndex) setChildToolsTruncated(true)
indexedPartCounts.set(messageId, partIds.length)
return keys
}
@ -250,14 +280,17 @@ export const taskRenderer: ToolRenderer = {
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
indexedPartCounts.clear()
setChildToolsTruncated(false)
const nextKeys: string[] = []
const oldestScannedIndex = Math.max(0, messageIds.length - 1_000)
for (let index = messageIds.length - 1; index >= oldestScannedIndex && nextKeys.length < TASK_STEP_RENDER_LIMIT; index -= 1) {
const keys = scanMessageToolParts(messageIds[index], 0, TASK_STEP_RENDER_LIMIT - nextKeys.length)
const scanLimit = TASK_STEP_RENDER_LIMIT + 1
const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT }
const oldestScannedIndex = Math.max(0, messageIds.length - TASK_MESSAGE_SCAN_LIMIT)
for (let index = messageIds.length - 1; index >= oldestScannedIndex && nextKeys.length < scanLimit && budget.remaining > 0; index -= 1) {
const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget)
for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex])
}
setChildToolsTruncated(messageIds.length > 1_000 || nextKeys.length === TASK_STEP_RENDER_LIMIT)
setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length)))
setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT))
}
@ -298,36 +331,41 @@ export const taskRenderer: ToolRenderer = {
}
const appendedKeys: string[] = []
const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT }
// Scan any new messages appended since last index.
const appendedStart = Math.max(indexedMessageCount, messageIds.length - 1_000)
const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT)
if (appendedStart > indexedMessageCount) setChildToolsTruncated(true)
for (let idx = appendedStart; idx < messageIds.length; idx += 1) {
for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) {
const messageId = messageIds[idx]
appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
setChildToolsTruncated(true)
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
}
}
// Scan a small window of recent messages for newly appended parts.
// Deltas typically affect the most recent tool call, so this avoids
// iterating every message on every revision.
// Scan the bounded indexed window so out-of-order updates are not missed.
const existingCount = Math.min(indexedMessageCount, messageIds.length)
const windowStart = Math.max(0, existingCount - 3)
for (let idx = windowStart; idx < existingCount; idx += 1) {
const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT)
for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) {
const messageId = messageIds[idx]
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
const record = store.getMessage(messageId)
const nextPartCount = record?.partIds.length ?? 0
if (nextPartCount > previousPartCount) {
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
setChildToolsTruncated(true)
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
}
}
}
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
if (indexedPartCounts.size > 1_000) {
const retainedIds = new Set(messageIds.slice(-1_000))
if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) {
const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT))
for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId)
}
@ -356,7 +394,7 @@ export const taskRenderer: ToolRenderer = {
const state = toolState()
if (!state) return null
const { input } = readToolStatePayload(state)
return typeof input.subagent_type === "string" ? input.subagent_type : null
return typeof input.subagent_type === "string" ? limitToolTitleForRender(input.subagent_type) : null
})
const modelLabel = createMemo(() => {
@ -365,8 +403,8 @@ export const taskRenderer: ToolRenderer = {
const { metadata } = readToolStatePayload(state)
const model = (metadata as any).model
if (!model || typeof model !== "object") return null
const providerId = typeof model.providerID === "string" ? model.providerID : null
const modelId = typeof model.modelID === "string" ? model.modelID : null
const providerId = typeof model.providerID === "string" ? limitToolTitleForRender(model.providerID) : null
const modelId = typeof model.modelID === "string" ? limitToolTitleForRender(model.modelID) : null
if (!providerId && !modelId) return null
if (providerId && modelId) return `${providerId}/${modelId}`
return providerId ?? modelId
@ -375,9 +413,9 @@ export const taskRenderer: ToolRenderer = {
const headerMeta = createMemo(() => {
const agent = agentLabel()
const model = modelLabel()
if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model })
if (agent) return t("toolCall.task.meta.agent", { agent })
if (model) return t("toolCall.task.meta.model", { model })
if (agent && model) return limitToolTitleForRender(t("toolCall.task.meta.agentModel", { agent, model }))
if (agent) return limitToolTitleForRender(t("toolCall.task.meta.agent", { agent }))
if (model) return limitToolTitleForRender(t("toolCall.task.meta.model", { model }))
return null
})
@ -405,6 +443,8 @@ export const taskRenderer: ToolRenderer = {
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
})
})
const childSourceActive = () => childToolKeys().length > 0 || childToolsTruncated()
const stepsTruncated = () => resolveTaskStepTruncation(childSourceActive(), childToolsTruncated(), legacySummary().truncated)
createEffect(() => {
const childCount = childToolKeys().length
@ -437,14 +477,31 @@ export const taskRenderer: ToolRenderer = {
</section>
</Show>
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
<Show when={childSessionLoadError()}>
{(error) => (
<LoadErrorState
title={t("messageSection.loadError.title")}
error={error()}
retryLabel={t("messageSection.loadError.reload")}
onRetry={retryChildSessionLoad}
variant="compact"
/>
)}
</Show>
<Show when={childToolKeys().length > 0 || legacyItems().length > 0 || stepsTruncated()}>
<section class="tool-call-task-section">
<header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
<span class="tool-call-io-actions">
<span class="tool-call-task-section-meta">
{t("toolCall.task.steps.count", { count: childToolsTruncated() || legacySummary().truncated ? `${TASK_STEP_RENDER_LIMIT}+` : childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
{t("toolCall.task.steps.count", { count: stepsTruncated() ? `${TASK_STEP_RENDER_LIMIT}+` : childSourceActive() ? childToolKeys().length : legacyItems().length })}
</span>
<Show when={childToolsTruncated()}>
<button type="button" class="tool-call-header-icon-button tool-call-header-copy" onClick={() => void copyToClipboard(stringifyChildTaskSteps(store.getSessionMessageIds(childSessionId() ?? ""), store.getMessage))} aria-label={t("toolCall.io.copyOutputAriaLabel")} title={t("toolCall.io.copyOutputTitle")}>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={childToolKeys().length === 0 && legacySummary().truncated}>
<button type="button" class="tool-call-header-icon-button tool-call-header-copy" onClick={() => void copyToClipboard(stringifyLegacyTaskSummary(legacySummary().entries))} aria-label={t("toolCall.io.copyOutputAriaLabel")} title={t("toolCall.io.copyOutputTitle")}>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
@ -453,7 +510,7 @@ export const taskRenderer: ToolRenderer = {
</span>
</header>
<div class="tool-call-task-section-body">
<Show when={childToolsTruncated() || legacySummary().truncated}>
<Show when={stepsTruncated()}>
<div class="tool-call-diagnostic-message" role="status">{t("toolCall.task.steps.truncated", { count: TASK_STEP_RENDER_LIMIT })}</div>
</Show>
<Show
@ -470,7 +527,9 @@ export const taskRenderer: ToolRenderer = {
<For each={legacyItems()}>
{(item) => {
const icon = getToolIcon(item.tool)
const description = limitToolTitleForRender(describeToolTitle(item))
const fullDescription = describeToolTitle(item)
const description = limitToolTitleForRender(fullDescription)
const copyTitle = getTruncatedTaskStepTitleCopyText(fullDescription)
const toolLabel = limitToolTitleForRender(getToolName(item.tool))
const status = normalizeStatus(item.status ?? item.state?.status)
const statusIcon = summarizeStatusIcon(status)
@ -485,6 +544,13 @@ export const taskRenderer: ToolRenderer = {
<span class="tool-call-task-label">{toolLabel}</span>
<span class="tool-call-task-separator" aria-hidden="true"></span>
<span class="tool-call-task-text">{description}</span>
<Show when={copyTitle}>
{(title) => (
<button type="button" class="tool-call-header-icon-button tool-call-header-copy" onClick={() => void copyToClipboard(title())} aria-label={t("toolCall.io.copyOutputAriaLabel")} title={t("toolCall.io.copyOutputTitle")}>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
<Show when={statusIcon}>
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
{statusIcon}

View file

@ -0,0 +1,94 @@
import type { ToolState } from "../../../types/tool-state"
import { readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
export interface TodoViewItem {
id: string
content: string
status: TodoViewStatus
}
type TodoViewItems = TodoViewItem[] & { partial?: boolean }
export const TODO_ITEM_RENDER_LIMIT = 200
function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
return "pending"
}
export function extractTodosFromState(state?: ToolState): TodoViewItems {
if (!state) return []
const { metadata } = readToolStatePayload(state)
const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
const normalized: TodoViewItems = []
let characters = 0
let scannedCharacters = 0
let index = 0
for (; index < todos.length && index < 10_000 && normalized.length < TODO_ITEM_RENDER_LIMIT && scannedCharacters < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT; index += 1) {
const todo = todos[index]
const rawContent = typeof todo?.content === "string" ? todo.content : ""
const contentPrefix = rawContent.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - scannedCharacters)
scannedCharacters += contentPrefix.length
const contentStart = contentPrefix.search(/\S/)
if (contentStart < 0) {
if (contentPrefix.length < rawContent.length) break
continue
}
const remaining = TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters
const contentSlice = contentPrefix.slice(contentStart, contentStart + remaining)
const content = contentSlice.trimEnd()
if (!content) continue
const status = normalizeTodoStatus(todo.status)
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : String(index)
normalized.push({ id, content, status })
characters += content.length
if (contentStart + contentSlice.length < rawContent.length) break
}
if (index < todos.length) normalized.partial = true
return normalized
}
export function getRenderedTodos(todos: TodoViewItem[]) {
const items: TodoViewItem[] = []
let characters = 0
for (let index = 0; index < todos.length && index < TODO_ITEM_RENDER_LIMIT; index += 1) {
if (characters >= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) break
const todo = todos[index]
const content = todo.content.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters)
items.push({ ...todo, content })
characters += content.length
}
return { items, truncated: Boolean((todos as TodoViewItems).partial) || items.length < todos.length }
}
export function hasTodoCopyText(state?: ToolState): boolean {
if (!state) return false
const { metadata } = readToolStatePayload(state)
return Array.isArray((metadata as any).todos) && (metadata as any).todos.length > 0
}
export function getTodoCopyText(state?: ToolState): string {
if (!state) return "[]"
const { metadata } = readToolStatePayload(state)
return JSON.stringify(Array.isArray((metadata as any).todos) ? (metadata as any).todos : [], null, 2)
}
export function getTodoTitleKind(state?: ToolState): "plan" | "creating" | "completing" | "updating" {
if (state?.status !== "completed") return "plan"
const { metadata } = readToolStatePayload(state)
const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
if (todos.length === 0) return "plan"
let allPending = true
let allCompleted = true
for (let index = 0; index < todos.length && index < 10_000; index += 1) {
const status = normalizeTodoStatus(todos[index]?.status)
allPending = allPending && status === "pending"
allCompleted = allCompleted && status === "completed"
if (!allPending && !allCompleted) return "updating"
}
if (todos.length > 10_000) return "updating"
if (allPending) return "creating"
if (allCompleted) return "completing"
return "updating"
}

View file

@ -1,45 +1,11 @@
import { For, Show } from "solid-js"
import type { ToolState } from "../../../types/tool-state"
import { CheckCircle, CircleEllipsis, MinusCircle, PauseCircle } from "lucide-solid"
import { CheckCircle, CircleEllipsis, Copy, MinusCircle, PauseCircle } from "lucide-solid"
import type { ToolRenderer } from "../types"
import { limitToolOutputForRender, readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n"
import { getTodoToolSearchText } from "../search-text"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
export interface TodoViewItem {
id: string
content: string
status: TodoViewStatus
}
function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
return "pending"
}
function extractTodosFromState(state?: ToolState): TodoViewItem[] {
if (!state) return []
const { metadata } = readToolStatePayload(state)
const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
const items: TodoViewItem[] = []
let characters = 0
for (let index = 0; index < todos.length && characters < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT; index++) {
const todo = todos[index]
const remaining = TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters
const content = typeof todo?.content === "string" ? todo.content.slice(0, remaining + 1).trim() : ""
if (!content) continue
const status = normalizeTodoStatus((todo as any).status)
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : String(index)
const renderedContent = limitToolOutputForRender(content)
characters += Math.min(content.length, remaining)
items.push({ id, content: renderedContent, status })
}
return items
}
import { extractTodosFromState, getRenderedTodos, getTodoCopyText, getTodoTitleKind, hasTodoCopyText, type TodoViewItem, type TodoViewStatus } from "./todo-data"
import { copyToClipboard } from "../../../lib/clipboard"
function summarizeTodos(todos: TodoViewItem[]) {
return todos.reduce(
@ -86,17 +52,18 @@ interface TodoListViewProps {
export function TodoListView(props: TodoListViewProps) {
const { t } = useI18n()
const todos = extractTodosFromState(props.state)
const counts = summarizeTodos(todos)
const allTodos = extractTodosFromState(props.state)
const todos = getRenderedTodos(allTodos)
const counts = summarizeTodos(allTodos)
if (counts.total === 0) {
if (counts.total === 0 && !todos.truncated) {
return <div class="tool-call-todo-empty">{props.emptyLabel ?? t("toolCall.renderer.todo.empty")}</div>
}
return (
<div class="tool-call-todo-region">
<div class="tool-call-todos" role="list">
<For each={todos}>
<For each={todos.items}>
{(todo) => {
const label = getTodoStatusLabel(t, todo.status)
return (
@ -124,20 +91,20 @@ export function TodoListView(props: TodoListViewProps) {
}}
</For>
</div>
<Show when={todos.truncated}>
<div class="tool-call-diagnostic-message">
<span role="status">{t("toolCall.output.truncated")}</span>
<button type="button" class="tool-call-header-icon-button tool-call-header-copy" onClick={() => void copyToClipboard(getTodoCopyText(props.state))} aria-label={t("toolCall.io.copyOutputAriaLabel")} title={t("toolCall.io.copyOutputTitle")}>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
</div>
)
}
export function getTodoTitle(state?: ToolState): string {
if (!state) return tGlobal("toolCall.renderer.todo.title.plan")
const todos = extractTodosFromState(state)
if (state.status !== "completed" || todos.length === 0) return tGlobal("toolCall.renderer.todo.title.plan")
const counts = summarizeTodos(todos)
if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating")
if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing")
return tGlobal("toolCall.renderer.todo.title.updating")
return tGlobal(`toolCall.renderer.todo.title.${getTodoTitleKind(state)}`)
}
export const todoRenderer: ToolRenderer = {
@ -147,6 +114,10 @@ export const todoRenderer: ToolRenderer = {
getTitle({ toolState }) {
return getTodoTitle(toolState())
},
getOutputChrome({ toolState }) {
const state = toolState()
return hasTodoCopyText(state) ? { getCopyText: () => getTodoCopyText(state), hasCopyText: true } : undefined
},
renderBody({ toolState }) {
const state = toolState()
if (!state) return null

View file

@ -22,12 +22,13 @@ export const webfetchRenderer: ToolRenderer = {
const { metadata } = readToolStatePayload(state)
const output = state.status === "completed" ? state.output : metadata.output
if (output === undefined || output === null) return undefined
if (output === undefined || output === null || output === "" || (Array.isArray(output) && output.length === 0)) return undefined
const result = formatUnknownForRender(output)
return {
language: result?.language ?? "text",
getCopyText: () => formatUnknownForCopy(output)?.text ?? null,
hasCopyText: true,
wrapToggle: true,
suppressInnerHeader: true,
}

View file

@ -6,19 +6,15 @@ import {
isToolStateRunning,
readToolStatePayload,
} from "./utils"
import { exceedsRetainedByteLimit } from "../../lib/retained-size"
import { getApplyPatchCopyText } from "./renderers/apply-patch-data"
type QuestionOption = { label?: unknown; description?: unknown }
type QuestionPrompt = { header?: unknown; question?: unknown; options?: unknown; multiple?: unknown; answer?: unknown }
const SEARCH_FORMAT_BYTE_LIMIT = 2_000_000
function appendString(values: string[], value: unknown) {
if (typeof value === "string" && value.trim().length > 0) values.push(value)
}
function appendFormatted(values: string[], value: unknown) {
if (typeof value !== "string" && exceedsRetainedByteLimit(value, SEARCH_FORMAT_BYTE_LIMIT)) return
const result = formatUnknown(value)
if (result?.text.trim()) values.push(result.text)
}
@ -112,13 +108,14 @@ export function getDiffToolSearchText(context: ToolSearchTextContext): string[]
export function getApplyPatchToolSearchText(context: ToolSearchTextContext): string[] {
const values = getDiffToolSearchText(context)
const { metadata, output } = readToolStatePayload(context.toolState)
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as any[]).slice(0, 10_000) : []
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as any[]) : []
for (const file of files.slice(0, 200)) {
for (const file of files) {
appendString(values, file?.filePath)
appendString(values, file?.relativePath)
appendString(values, file?.diff)
appendString(values, file?.patch)
}
appendString(values, getApplyPatchCopyText(files, SEARCH_FORMAT_BYTE_LIMIT))
appendFormatted(values, (metadata as any).diagnostics)
appendFormatted(values, output)
@ -151,7 +148,7 @@ export function getTaskToolSearchText(context: ToolSearchTextContext): string[]
export function getTodoToolSearchText(context: ToolSearchTextContext): string[] {
const values: string[] = []
const { metadata } = readToolStatePayload(context.toolState)
const todos = Array.isArray((metadata as any).todos) ? ((metadata as any).todos as any[]).slice(0, 10_000) : []
const todos = Array.isArray((metadata as any).todos) ? ((metadata as any).todos as any[]) : []
appendBaseToolText(values, context)
for (const todo of todos) {
@ -166,14 +163,14 @@ export function getTodoToolSearchText(context: ToolSearchTextContext): string[]
export function getQuestionToolSearchText(context: ToolSearchTextContext): string[] {
const values: string[] = []
const { input, metadata } = readToolStatePayload(context.toolState)
const questions = Array.isArray(input.questions) ? (input.questions as QuestionPrompt[]).slice(0, 10_000) : []
const answers = Array.isArray((metadata as any).answers) ? ((metadata as any).answers as unknown[]).slice(0, 10_000) : []
const questions = Array.isArray(input.questions) ? (input.questions as QuestionPrompt[]) : []
const answers = Array.isArray((metadata as any).answers) ? ((metadata as any).answers as unknown[]) : []
appendBaseToolText(values, context)
for (const question of questions) {
appendString(values, question.header)
appendString(values, question.question)
const options = Array.isArray(question.options) ? (question.options as QuestionOption[]).slice(0, 10_000) : []
const options = Array.isArray(question.options) ? (question.options as QuestionOption[]) : []
for (const option of options) {
appendString(values, option.label)
appendString(values, option.description)
@ -185,19 +182,3 @@ export function getQuestionToolSearchText(context: ToolSearchTextContext): strin
appendToolErrorText(values, context)
return values
}
export function getToolSearchText(context: ToolSearchTextContext): string[] {
switch (context.toolName) {
case "bash": return getBashToolSearchText(context)
case "read": return getReadToolSearchText(context)
case "write": return getWriteToolSearchText(context)
case "edit":
case "patch": return getDiffToolSearchText(context)
case "apply_patch": return getApplyPatchToolSearchText(context)
case "webfetch": return getWebfetchToolSearchText(context)
case "task": return getTaskToolSearchText(context)
case "todowrite": return getTodoToolSearchText(context)
case "question": return getQuestionToolSearchText(context)
default: return getDefaultToolSearchText(context)
}
}

View file

@ -6,6 +6,7 @@ export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
export interface DiffPayload {
diffText: string
copyText?: string
filePath?: string
}
@ -105,6 +106,7 @@ export interface ToolOutputChrome {
language?: string
copyText?: string | null
getCopyText?: () => string | null
hasCopyText?: boolean
actions?: JSXElement
wrapToggle?: boolean
suppressInnerHeader?: boolean

View file

@ -13,16 +13,27 @@ export type { ToolStateCompleted, ToolStateError, ToolStateRunning }
export const diffCapableTools = new Set(["edit", "patch"])
export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000
export const TOOL_TITLE_RENDER_CHARACTER_LIMIT = 384
export const MESSAGE_PART_RENDER_LIMIT = 200
export function getItemsForRender<T>(items: readonly T[], limit: number) {
return { parts: items.slice(0, limit), truncated: items.length > limit }
}
export function limitToolOutputForRender(text: string): string {
if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text
return `${text.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)}\n\n${tGlobal("toolCall.output.truncated")}`
const suffix = `\n\n${tGlobal("toolCall.output.truncated")}`
return `${text.slice(0, Math.max(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - suffix.length))}${suffix}`
}
export function shouldRenderDiffAsPlainText(text: string): boolean {
return text.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT
}
export function shouldRenderDiffPayloadAsPlainText(payload: DiffPayload): boolean {
return shouldRenderDiffAsPlainText(payload.diffText)
|| (payload.copyText?.length ?? 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT
}
export function limitToolTitleForRender(text: string): string {
if (text.length <= TOOL_TITLE_RENDER_CHARACTER_LIMIT) return text
return `${text.slice(0, TOOL_TITLE_RENDER_CHARACTER_LIMIT - 3)}...`
@ -173,6 +184,24 @@ export function formatUnknownForRender(value: unknown): { text: string; language
return result ? { ...result, text: limitToolOutputForRender(result.text) } : null
}
export function formatToolInputForCopy(input: unknown): { text: string; language?: string } | null {
try {
const text = JSON.stringify(input, null, 2)
return typeof text === "string" ? { text, language: "json" } : null
} catch (error) {
log.error("Failed to stringify tool call input", error)
return null
}
}
export function formatToolInputForRender(input: unknown): { text: string; language?: string } | null {
if (typeof input !== "string" && exceedsRetainedByteLimit(input, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) {
return { text: JSON.stringify(tGlobal("toolCall.output.tooLarge")), language: "json" }
}
const formatted = formatToolInputForCopy(input)
return formatted ? { ...formatted, text: limitToolOutputForRender(formatted.text) } : null
}
export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null {
try {
return formatUnknown(value)
@ -195,7 +224,11 @@ export function extractDiffPayload(toolName: string, state?: ToolState): DiffPay
let diffText: string | null = null
for (const candidate of candidates) {
if (typeof candidate === "string" && isRenderableDiffText(candidate)) {
if (typeof candidate !== "string") continue
const renderable = candidate.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT
? /(^|\n)@@/.test(candidate.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT))
: isRenderableDiffText(candidate)
if (renderable) {
diffText = candidate
break
}

View file

@ -1,6 +1,13 @@
import assert from "node:assert/strict"
import test from "node:test"
import { clearCacheForInstance, getCacheEntry, setCacheEntry } from "./global-cache.ts"
import {
captureCacheAuthority,
clearCacheForInstance,
clearCacheForSession,
getCacheEntry,
onCacheSessionChanged,
setCacheEntry,
} from "./global-cache.ts"
test("global render cache rejects oversized values and bounds each scope", () => {
const oversized = { instanceId: "instance", sessionId: "session", scope: "markdown", cacheId: "oversized", version: "1" }
@ -16,3 +23,87 @@ test("global render cache rejects oversized values and bounds each scope", () =>
clearCacheForInstance("instance")
}
})
test("cleared cache authority rejects stale async session and instance writes", () => {
const sessionEntry = { instanceId: "authority", sessionId: "session", scope: "markdown", cacheId: "part", version: "1" }
const diffEntry = { ...sessionEntry, scope: "tool-call", cacheId: "diff" }
const otherSessionEntry = { ...sessionEntry, sessionId: "other" }
try {
const staleSessionAuthority = captureCacheAuthority(sessionEntry)
const staleDiffAuthority = captureCacheAuthority(diffEntry)
clearCacheForSession(sessionEntry.instanceId, sessionEntry.sessionId)
setCacheEntry(sessionEntry, "stale", staleSessionAuthority)
setCacheEntry(diffEntry, "stale", staleDiffAuthority)
setCacheEntry(otherSessionEntry, "current", captureCacheAuthority(otherSessionEntry))
assert.equal(getCacheEntry(sessionEntry), undefined)
assert.equal(getCacheEntry(diffEntry), undefined)
assert.equal(getCacheEntry(otherSessionEntry), "current")
const staleInstanceAuthority = captureCacheAuthority(otherSessionEntry)
clearCacheForInstance(sessionEntry.instanceId)
setCacheEntry(otherSessionEntry, "stale", staleInstanceAuthority)
assert.equal(getCacheEntry(otherSessionEntry), undefined)
} finally {
clearCacheForInstance(sessionEntry.instanceId)
}
})
test("cache misses implicitly reject stale async writes after session clear", () => {
const entry = { instanceId: "implicit-authority", sessionId: "session", scope: "markdown", cacheId: "part", version: "1" }
try {
assert.equal(getCacheEntry(entry), undefined)
clearCacheForSession(entry.instanceId, entry.sessionId)
setCacheEntry(entry, "stale")
assert.equal(getCacheEntry(entry), undefined)
} finally {
clearCacheForInstance(entry.instanceId)
}
})
test("semantic cache hits reject late async writes after session clear", () => {
const entry = { instanceId: "semantic-authority", sessionId: "session", scope: "diff", cacheId: "part", version: "1" }
try {
setCacheEntry(entry, { mode: "split" })
assert.deepEqual(getCacheEntry(entry), { mode: "split" })
clearCacheForSession(entry.instanceId, entry.sessionId)
setCacheEntry(entry, { mode: "unified" })
assert.equal(getCacheEntry(entry), undefined)
} finally {
clearCacheForInstance(entry.instanceId)
}
})
test("session-owned cache writes request transcript remeasurement", () => {
const entry = { instanceId: "cache-accounting", sessionId: "session", scope: "markdown", cacheId: "part", version: "1" }
const changed: string[] = []
const stop = onCacheSessionChanged((instanceId, sessionId) => changed.push(`${instanceId}/${sessionId}`))
try {
setCacheEntry(entry, "rendered")
assert.deepEqual(changed, ["cache-accounting/session"])
} finally {
stop()
clearCacheForInstance(entry.instanceId)
}
})
test("cache authority rejects superseded and scope-evicted async writes", () => {
const entry = { instanceId: "authority-races", sessionId: "session", scope: "markdown", cacheId: "part", version: "1" }
try {
setCacheEntry(entry, "existing")
const evictedAuthority = captureCacheAuthority(entry)
for (let index = 0; index < 64; index += 1) {
setCacheEntry({ ...entry, cacheId: `other-${index}` }, index)
}
setCacheEntry(entry, "resurrected", evictedAuthority)
assert.equal(getCacheEntry(entry), undefined)
const staleAuthority = captureCacheAuthority(entry)
const currentAuthority = captureCacheAuthority(entry)
setCacheEntry(entry, "stale", staleAuthority)
setCacheEntry(entry, "current", currentAuthority)
assert.equal(getCacheEntry(entry), "current")
} finally {
clearCacheForInstance(entry.instanceId)
}
})

View file

@ -11,10 +11,21 @@ export interface CacheEntryParams extends CacheEntryBaseParams {
version: string
}
export interface CacheAuthority {
instanceKey: string
sessionKey: string
scope: string
cacheId: string
version: string
generation: number
writeToken: number
}
type VersionedCacheEntry = {
version: string
value: unknown
byteSize: number
keyBytes: number
}
type CacheValueMap = Map<string, VersionedCacheEntry>
@ -27,6 +38,11 @@ const MAX_CACHE_ENTRY_BYTES = 4 * 1024 * 1024
const MAX_GLOBAL_CACHE_BYTES = 32 * 1024 * 1024
const MAX_GLOBAL_CACHE_ENTRIES = 4_096
const cacheStore = new Map<string, CacheSessionMap>()
let cacheGeneration = 0
let writeSequence = 0
const pendingWrites = new Map<string, number>()
const pendingAuthorityByParams = new WeakMap<CacheEntryParams, CacheAuthority>()
const cacheSessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>()
let retainedBytes = 0
let retainedEntries = 0
@ -49,6 +65,72 @@ function resolveKey(value?: string) {
return value && value.length > 0 ? value : GLOBAL_KEY
}
function writeKey(instanceKey: string, sessionKey: string, scope: string, cacheId: string): string {
return `${instanceKey}\u0000${sessionKey}\u0000${scope}\u0000${cacheId}`
}
function invalidateAllPendingWrites(): void {
cacheGeneration += 1
pendingWrites.clear()
}
function invalidatePendingWrite(instanceKey: string, sessionKey: string, scope: string, cacheId: string): void {
pendingWrites.delete(writeKey(instanceKey, sessionKey, scope, cacheId))
}
function notifyCacheSessionChanged(params: CacheEntryBaseParams): void {
if (!params.instanceId || !params.sessionId) return
for (const handler of cacheSessionChangeHandlers) handler(params.instanceId, params.sessionId)
}
export function onCacheSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void {
cacheSessionChangeHandlers.add(handler)
return () => cacheSessionChangeHandlers.delete(handler)
}
export function* getCacheRetainedEntriesForSession(instanceId: string, sessionId: string): Generator<{ value: unknown; keyBytes: number }> {
const scopeMap = cacheStore.get(resolveKey(instanceId))?.get(resolveKey(sessionId))
if (!scopeMap) return
for (const valueMap of scopeMap.values()) {
for (const entry of valueMap.values()) yield { value: entry.value, keyBytes: entry.keyBytes }
}
}
export function captureCacheAuthority(params: CacheEntryParams): CacheAuthority {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
const scopePrefix = `${instanceKey}\u0000${sessionKey}\u0000${params.scope}\u0000`
const currentKey = writeKey(instanceKey, sessionKey, params.scope, params.cacheId)
const scopeWrites = [...pendingWrites.keys()].filter((key) => key.startsWith(scopePrefix) && key !== currentKey)
if (scopeWrites.length >= MAX_SCOPE_CACHE_ENTRIES) pendingWrites.delete(scopeWrites[0]!)
if (pendingWrites.size >= MAX_GLOBAL_CACHE_ENTRIES && !pendingWrites.has(currentKey)) invalidateAllPendingWrites()
const writeToken = ++writeSequence
pendingWrites.set(currentKey, writeToken)
const authority = {
instanceKey,
sessionKey,
scope: params.scope,
cacheId: params.cacheId,
version: params.version,
generation: cacheGeneration,
writeToken,
}
pendingAuthorityByParams.set(params, authority)
return authority
}
function hasCacheAuthority(params: CacheEntryParams, authority: CacheAuthority): boolean {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
return instanceKey === authority.instanceKey
&& sessionKey === authority.sessionKey
&& params.scope === authority.scope
&& params.cacheId === authority.cacheId
&& params.version === authority.version
&& cacheGeneration === authority.generation
&& pendingWrites.get(writeKey(instanceKey, sessionKey, params.scope, params.cacheId)) === authority.writeToken
}
function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
@ -107,9 +189,13 @@ function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: st
}
}
export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined): void {
export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined, authority?: CacheAuthority): void {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
const resolvedAuthority = authority ?? pendingAuthorityByParams.get(params)
pendingAuthorityByParams.delete(params)
if (resolvedAuthority && !hasCacheAuthority(params, resolvedAuthority)) return
invalidatePendingWrite(instanceKey, sessionKey, params.scope, params.cacheId)
if (value === undefined) {
const existingMap = getScopeValueMap(params, false)
@ -118,6 +204,7 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
if (existing) retainedEntries -= 1
existingMap?.delete(params.cacheId)
cleanupHierarchy(instanceKey, sessionKey, params.scope)
if (existing) notifyCacheSessionChanged(params)
return
}
@ -131,17 +218,19 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
if (byteSize > MAX_CACHE_ENTRY_BYTES) {
scopeEntries?.delete(params.cacheId)
cleanupHierarchy(instanceKey, sessionKey, params.scope)
if (existing) notifyCacheSessionChanged(params)
return
}
if (retainedBytes + byteSize > MAX_GLOBAL_CACHE_BYTES || retainedEntries >= MAX_GLOBAL_CACHE_ENTRIES) {
cacheStore.clear()
retainedBytes = 0
retainedEntries = 0
invalidateAllPendingWrites()
}
const target = getScopeValueMap(params, true)
if (!target) return
target.delete(params.cacheId)
target.set(params.cacheId, { version: params.version, value, byteSize })
target.set(params.cacheId, { version: params.version, value, byteSize, keyBytes })
retainedBytes += byteSize
retainedEntries += 1
while (target.size > MAX_SCOPE_CACHE_ENTRIES) {
@ -150,23 +239,29 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
retainedBytes -= target.get(oldest)?.byteSize ?? 0
target.delete(oldest)
retainedEntries -= 1
invalidatePendingWrite(instanceKey, sessionKey, params.scope, oldest)
}
notifyCacheSessionChanged(params)
}
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
const scopeEntries = getScopeValueMap(params, false)
const entry = scopeEntries?.get(params.cacheId)
if (!entry || entry.version !== params.version) {
captureCacheAuthority(params)
return undefined
}
invalidatePendingWrite(resolveKey(params.instanceId), resolveKey(params.sessionId), params.scope, params.cacheId)
scopeEntries!.delete(params.cacheId)
scopeEntries!.set(params.cacheId, entry)
captureCacheAuthority(params)
return entry.value as T
}
export function clearCacheScope(params: CacheEntryBaseParams): void {
const instanceKey = resolveKey(params.instanceId)
const sessionKey = resolveKey(params.sessionId)
invalidateAllPendingWrites()
const sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) return
const scopeMap = sessionMap.get(sessionKey)
@ -179,6 +274,7 @@ export function clearCacheScope(params: CacheEntryBaseParams): void {
export function clearCacheForSession(instanceId?: string, sessionId?: string): void {
const instanceKey = resolveKey(instanceId)
const sessionKey = resolveKey(sessionId)
invalidateAllPendingWrites()
const sessionMap = cacheStore.get(instanceKey)
if (!sessionMap) return
sessionMap.delete(sessionKey)
@ -190,6 +286,7 @@ export function clearCacheForSession(instanceId?: string, sessionId?: string): v
export function clearCacheForInstance(instanceId?: string): void {
const instanceKey = resolveKey(instanceId)
invalidateAllPendingWrites()
cacheStore.delete(instanceKey)
recalculateRetainedSize()
}

View file

@ -1,4 +1,5 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { describe, it } from "node:test"
import { createRoot, createSignal } from "solid-js"
@ -117,15 +118,89 @@ describe("useActiveSessionMessageLoad", () => {
}
})
it("invalidates the owned request on session, workspace, and unmount changes", async () => {
const invalidated: string[] = []
const [instanceId, setInstanceId] = createSignal("one")
const [session, setSession] = createSignal<{ id: string } | undefined>({ id: "a" })
it("waits for a mounted child session to be registered, then loads it once", async () => {
const loads: string[] = []
const [session, setSession] = createSignal<{ id: string } | undefined>()
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
useActiveSessionMessageLoad({
isActive: () => true,
instanceId: () => "inst",
session,
loadMessages: (_instanceId, sessionId) => {
loads.push(sessionId)
},
waitForHydration: () => Promise.resolve(),
})
})
try {
await tick()
assert.deepEqual(loads, [], "an unregistered child must not load")
setSession({ id: "child" })
await tick()
assert.deepEqual(loads, ["child"], "registration must reactively trigger the load")
setSession({ id: "child" })
await tick()
assert.deepEqual(loads, ["child"], "same-id session updates must not loop")
} finally {
dispose()
}
})
it("reloads a still-mounted transcript after its loaded state is invalidated", async () => {
const [loaded, setLoaded] = createSignal(false)
let loads = 0
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
useActiveSessionMessageLoad({
isActive: () => true,
instanceId: () => "inst",
session: () => ({ id: "child" }),
shouldLoad: () => !loaded(),
loadMessages: () => {
loads += 1
setLoaded(true)
},
waitForHydration: () => Promise.resolve(),
})
})
try {
await tick()
assert.equal(loads, 1)
setLoaded(false)
await tick()
assert.equal(loads, 2, "invalidation must reload without remounting")
} finally {
dispose()
}
})
it("retains mounted child transcripts and releases them on cleanup", () => {
const source = readFileSync(new URL("../../components/tool-call/renderers/task.tsx", import.meta.url), "utf8")
assert.match(source, /setSessionTranscriptVisible\(instanceId, id, true\)/)
assert.match(source, /onCleanup\(\(\) => setSessionTranscriptVisible\(instanceId, id, false\)\)/)
assert.match(source, /sessions\(\)\.get\(instanceId\)\?\.get\(id\)/)
assert.match(source, /shouldLoad: \(\) =>/)
assert.match(source, /getSessionMessagesLoadError\(instanceId, id\)/)
assert.match(source, /loadMessages\(instanceId, id, \{ force: true \}\)/)
assert.match(source, /onRetry=\{retryChildSessionLoad\}/)
assert.doesNotMatch(source, /requestedChildLoad/)
})
it("invalidates pending loads on session, workspace, visibility, and unmount changes", async () => {
const invalidated: string[] = []
const [instanceId, setInstanceId] = createSignal("one")
const [session, setSession] = createSignal<{ id: string } | undefined>({ id: "a" })
const [active, setActive] = createSignal(true)
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
useActiveSessionMessageLoad({
isActive: active,
instanceId,
session,
loadMessages: (workspace, sessionId, options) => {
@ -140,41 +215,13 @@ describe("useActiveSessionMessageLoad", () => {
setSession({ id: "b" })
await tick()
setInstanceId("two")
await tick()
dispose()
assert.deepEqual(invalidated, ["one:a", "one:b", "two:b"])
})
it("uses the same abort policy when root or subagent views become hidden", async () => {
const invalidated: string[] = []
const [active, setActive] = createSignal(true)
const [session, setSession] = createSignal<{ id: string } | undefined>({ id: "root" })
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
useActiveSessionMessageLoad({
isActive: active,
instanceId: () => "inst",
session,
loadMessages: (_workspace, sessionId, options) => {
options?.registerInvalidation?.(() => invalidated.push(sessionId))
return new Promise<void>(() => {})
},
waitForHydration: () => Promise.resolve(),
})
})
await tick()
setActive(false)
await tick()
setSession({ id: "subagent" })
setActive(true)
await tick()
setActive(false)
await tick()
dispose()
assert.deepEqual(invalidated, ["root", "subagent"])
assert.deepEqual(invalidated, ["one:a", "one:b", "two:b", "two:b"])
})
})

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
/**
* Dependencies for {@link useActiveSessionMessageLoad}. Everything is injected
@ -13,6 +13,8 @@ export interface ActiveSessionMessageLoadDeps {
instanceId: () => string
/** The current session object (or undefined). Read reactively. */
session: () => { id: string } | undefined
/** Optional reactive gate. Each false-to-true transition reloads the same session. */
shouldLoad?: () => boolean
/** Loads the messages for a session. */
loadMessages: (
instanceId: string,
@ -46,8 +48,22 @@ export function useActiveSessionMessageLoad(deps: ActiveSessionMessageLoadDeps):
const sessionId = deps.isActive() ? deps.session()?.id : undefined
return sessionId ? `${deps.instanceId()}\u0000${sessionId}` : null
})
const [reloadVersion, setReloadVersion] = createSignal(0)
let previousLoadBinding: string | null = null
createEffect(() => {
if (!deps.shouldLoad) return
const binding = activeBinding()
const loadBinding = binding && deps.shouldLoad() ? binding : null
if (loadBinding && loadBinding !== previousLoadBinding) setReloadVersion((value) => value + 1)
previousLoadBinding = loadBinding
})
createEffect(() => {
const binding = activeBinding()
if (deps.shouldLoad) {
reloadVersion()
if (!untrack(deps.shouldLoad)) return
}
if (!binding) return
const [instanceId, sessionId] = binding.split("\u0000")
let invalidate = () => {}

View file

@ -1,6 +1,8 @@
import { type Accessor, createMemo } from "solid-js"
import {
type CacheAuthority,
type CacheEntryParams,
captureCacheAuthority,
getCacheEntry,
setCacheEntry,
clearCacheScope,
@ -14,6 +16,7 @@ import {
* automatically fall back to the global buckets.
*/
export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle {
let pendingAuthority: CacheAuthority | undefined
const resolvedEntry = createMemo<CacheEntryParams>(() => {
const instanceId = normalizeId(resolveValue(params.instanceId))
const sessionId = normalizeId(resolveValue(params.sessionId))
@ -35,10 +38,14 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
return {
get<T>() {
return getCacheEntry<T>(resolvedEntry())
const entry = resolvedEntry()
const value = getCacheEntry<T>(entry)
pendingAuthority = captureCacheAuthority(entry)
return value
},
set<T>(value: T | undefined) {
setCacheEntry(resolvedEntry(), value)
set<T>(value: T | undefined, authority?: CacheAuthority) {
setCacheEntry(resolvedEntry(), value, authority ?? pendingAuthority)
pendingAuthority = undefined
},
clearScope() {
clearCacheScope(scopeParams())
@ -54,6 +61,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
params() {
return resolvedEntry()
},
authority() {
return pendingAuthority = captureCacheAuthority(resolvedEntry())
},
}
}
@ -80,9 +90,10 @@ interface UseGlobalCacheParams {
interface GlobalCacheHandle {
get<T>(): T | undefined
set<T>(value: T | undefined): void
set<T>(value: T | undefined, authority?: CacheAuthority): void
clearScope(): void
clearSession(): void
clearInstance(): void
params(): CacheEntryParams
authority(): CacheAuthority
}

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "Suche...",
"messageSection.search.count.none": "Keine Treffer",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "Die ersten {count} Treffer werden angezeigt. Die Ergebnisse sind unvollständig.",
"messageSection.search.previousAriaLabel": "Vorheriger Treffer",
"messageSection.search.nextAriaLabel": "Nächster Treffer",
"messageSection.search.closeAriaLabel": "Suche schließen",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "Zeitüberschreitung: {timeout}",
"toolCall.output.truncated": "[Ausgabe für die Darstellung gekürzt; kopieren Sie sie für die vollständige Ausgabe]",
"toolCall.output.tooLarge": "Die strukturierte Ausgabe wird nicht dargestellt, da sie zu groß ist.",
"toolCall.permission.fullDiffRequired": "Kopieren Sie den vollständigen Diff, bevor Sie diese übergroße Änderung genehmigen.",
"toolCall.task.steps.truncated": "Die neuesten {count} Schritte werden angezeigt. Kopieren Sie die Zusammenfassung für alle älteren Schritte.",
"toolCall.task.steps.truncated": "Die neuesten {count} Schritte werden angezeigt; ältere Schritte wurden ausgelassen.",
"toolCall.renderer.read.detail.offset": "Offset: {offset}",
"toolCall.renderer.read.detail.limit": "Limit: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "Searching...",
"messageSection.search.count.none": "No matches",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "Showing the first {count} matches. Results are partial.",
"messageSection.search.previousAriaLabel": "Previous match",
"messageSection.search.nextAriaLabel": "Next match",
"messageSection.search.closeAriaLabel": "Close search",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "Timeout: {timeout}",
"toolCall.output.truncated": "[Output truncated for rendering; copy to access the full output]",
"toolCall.output.tooLarge": "Structured output omitted from rendering because it is too large.",
"toolCall.permission.fullDiffRequired": "Copy the full diff before approving this oversized change.",
"toolCall.task.steps.truncated": "Showing the most recent {count} steps. Copy the summary for all legacy steps.",
"toolCall.task.steps.truncated": "Showing the most recent {count} steps; older steps are omitted.",
"toolCall.renderer.read.detail.offset": "Offset: {offset}",
"toolCall.renderer.read.detail.limit": "Limit: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "Buscando...",
"messageSection.search.count.none": "Sin coincidencias",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "Se muestran las primeras {count} coincidencias. Los resultados son parciales.",
"messageSection.search.previousAriaLabel": "Coincidencia anterior",
"messageSection.search.nextAriaLabel": "Siguiente coincidencia",
"messageSection.search.closeAriaLabel": "Cerrar búsqueda",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "Tiempo de espera: {timeout}",
"toolCall.output.truncated": "[Salida truncada para la visualización; cópiala para acceder a la salida completa]",
"toolCall.output.tooLarge": "La salida estructurada no se muestra porque es demasiado grande.",
"toolCall.permission.fullDiffRequired": "Copia el diff completo antes de aprobar este cambio de gran tamaño.",
"toolCall.task.steps.truncated": "Se muestran los {count} pasos más recientes. Copia el resumen para ver todos los pasos anteriores.",
"toolCall.task.steps.truncated": "Se muestran los {count} pasos más recientes; se omiten los anteriores.",
"toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}",
"toolCall.renderer.read.detail.limit": "Límite: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "Recherche...",
"messageSection.search.count.none": "Aucun résultat",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "Affichage des {count} premiers résultats. Les résultats sont partiels.",
"messageSection.search.previousAriaLabel": "Résultat précédent",
"messageSection.search.nextAriaLabel": "Résultat suivant",
"messageSection.search.closeAriaLabel": "Fermer la recherche",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "Délai : {timeout}",
"toolCall.output.truncated": "[Sortie tronquée pour laffichage ; copiez-la pour accéder à la sortie complète]",
"toolCall.output.tooLarge": "Sortie structurée omise de laffichage car elle est trop volumineuse.",
"toolCall.permission.fullDiffRequired": "Copiez le diff complet avant dapprouver cette modification volumineuse.",
"toolCall.task.steps.truncated": "Affichage des {count} étapes les plus récentes. Copiez le résumé pour toutes les étapes précédentes.",
"toolCall.task.steps.truncated": "Affichage des {count} étapes les plus récentes ; les étapes antérieures sont omises.",
"toolCall.renderer.read.detail.offset": "Décalage : {offset}",
"toolCall.renderer.read.detail.limit": "Limite : {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "מחפש...",
"messageSection.search.count.none": "אין התאמות",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "מוצגות {count} ההתאמות הראשונות. התוצאות חלקיות.",
"messageSection.search.previousAriaLabel": "התאמה קודמת",
"messageSection.search.nextAriaLabel": "התאמה הבאה",
"messageSection.search.closeAriaLabel": "סגור חיפוש",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}",
"toolCall.output.truncated": "[הפלט קוצר לצורך תצוגה; יש להעתיק כדי לגשת לפלט המלא]",
"toolCall.output.tooLarge": "הפלט המובנה לא מוצג מכיוון שהוא גדול מדי.",
"toolCall.permission.fullDiffRequired": "יש להעתיק את ההבדל המלא לפני אישור שינוי גדול זה.",
"toolCall.task.steps.truncated": "מוצגים {count} השלבים האחרונים. יש להעתיק את הסיכום לכל השלבים הקודמים.",
"toolCall.task.steps.truncated": "מוצגים {count} השלבים האחרונים; שלבים קודמים הושמטו.",
"toolCall.renderer.read.detail.offset": "היסט: {offset}",
"toolCall.renderer.read.detail.limit": "מגבלה: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "検索中...",
"messageSection.search.count.none": "一致なし",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "最初の{count}件を表示しています。結果は一部のみです。",
"messageSection.search.previousAriaLabel": "前の一致",
"messageSection.search.nextAriaLabel": "次の一致",
"messageSection.search.closeAriaLabel": "検索を閉じる",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "タイムアウト: {timeout}",
"toolCall.output.truncated": "[表示用に出力を省略しました。完全な出力にアクセスするにはコピーしてください]",
"toolCall.output.tooLarge": "構造化出力が大きすぎるため表示を省略しました。",
"toolCall.permission.fullDiffRequired": "この大きな変更を承認する前に、完全な差分をコピーしてください。",
"toolCall.task.steps.truncated": "最新の{count}件の手順を表示しています。以前のすべての手順は要約をコピーしてください。",
"toolCall.task.steps.truncated": "最新の{count}件の手順を表示しています。以前の手順は省略されています。",
"toolCall.renderer.read.detail.offset": "オフセット: {offset}",
"toolCall.renderer.read.detail.limit": "上限: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "खोज्दै...",
"messageSection.search.count.none": "कुनै परिणाम भेटिएन",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "पहिलो {count} परिणामहरू देखाइँदैछन्। परिणामहरू आंशिक छन्।",
"messageSection.search.previousAriaLabel": "अघिल्लो परिणाम",
"messageSection.search.nextAriaLabel": "अर्को परिणाम",
"messageSection.search.closeAriaLabel": "खोज बन्द गर्नुहोस्",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "समय समाप्त: {timeout}",
"toolCall.output.truncated": "[प्रदर्शनका लागि आउटपुट छोट्याइएको छ; पूर्ण आउटपुटका लागि प्रतिलिपि गर्नुहोस्]",
"toolCall.output.tooLarge": "संरचित आउटपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।",
"toolCall.permission.fullDiffRequired": "यो ठूलो परिवर्तन स्वीकृत गर्नु अघि पूर्ण diff प्रतिलिपि गर्नुहोस्।",
"toolCall.task.steps.truncated": "पछिल्ला {count} चरणहरू देखाइँदैछन्। सबै पुराना चरणहरूका लागि सारांश प्रतिलिपि गर्नुहोस्।",
"toolCall.task.steps.truncated": "पछिल्ला {count} चरणहरू देखाइँदैछन्; पुराना चरणहरू हटाइएका छन्।",
"toolCall.renderer.read.detail.offset": "अफसेट: {offset}",
"toolCall.renderer.read.detail.limit": "सीमा: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "Поиск...",
"messageSection.search.count.none": "Нет совпадений",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "Показаны первые {count} совпадений. Результаты неполные.",
"messageSection.search.previousAriaLabel": "Предыдущее совпадение",
"messageSection.search.nextAriaLabel": "Следующее совпадение",
"messageSection.search.closeAriaLabel": "Закрыть поиск",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "Таймаут: {timeout}",
"toolCall.output.truncated": "[Вывод сокращён для отображения; скопируйте его для доступа к полному выводу]",
"toolCall.output.tooLarge": "Структурированный вывод не отображается, поскольку он слишком большой.",
"toolCall.permission.fullDiffRequired": "Скопируйте полный diff перед одобрением этого большого изменения.",
"toolCall.task.steps.truncated": "Показаны последние {count} шагов. Скопируйте сводку, чтобы получить все предыдущие шаги.",
"toolCall.task.steps.truncated": "Показаны последние {count} шагов; более ранние шаги опущены.",
"toolCall.renderer.read.detail.offset": "Смещение: {offset}",
"toolCall.renderer.read.detail.limit": "Лимит: {limit}",

View file

@ -36,8 +36,6 @@ export const messagingMessages = {
"messageSection.search.count.searching": "正在搜索...",
"messageSection.search.count.none": "无匹配项",
"messageSection.search.count.matches": "{current} / {total}",
"messageSection.search.count.partial": "{current} / {total}+",
"messageSection.search.partialNotice": "正在显示前 {count} 个匹配项。结果不完整。",
"messageSection.search.previousAriaLabel": "上一个匹配项",
"messageSection.search.nextAriaLabel": "下一个匹配项",
"messageSection.search.closeAriaLabel": "关闭搜索",

View file

@ -58,8 +58,7 @@ export const toolCallMessages = {
"toolCall.renderer.bash.title.timeout": "超时:{timeout}",
"toolCall.output.truncated": "[输出已截断以便显示;复制即可访问完整输出]",
"toolCall.output.tooLarge": "结构化输出过大,已省略显示。",
"toolCall.permission.fullDiffRequired": "批准此超大更改前,请先复制完整差异。",
"toolCall.task.steps.truncated": "正在显示最近的 {count} 个步骤。复制摘要可获取所有旧步骤。",
"toolCall.task.steps.truncated": "正在显示最近的 {count} 个步骤;更早的步骤已省略。",
"toolCall.renderer.read.detail.offset": "偏移:{offset}",
"toolCall.renderer.read.detail.limit": "限制:{limit}",

View file

@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import test from "node:test"
import { clearSessionMessageRenderCache, getSessionMessageRenderCache, peekSessionMessageRenderCache, purgeMessageRenderCache } from "./message-render-cache.ts"
test("purges every render-cache entry owned by omitted messages", () => {
const cache = {
messageBlocks: new Map<string, unknown>([["removed", {}], ["kept", {}]]),
recordDisplayCache: new Map<string, unknown>([["removed", {}], ["kept", {}]]),
messageItems: new Map([
["removed:content:part", { messageId: "removed" }],
["kept:content:part", { messageId: "kept" }],
]),
toolItems: new Map([
["removed:tool", { messageId: "removed" }],
["kept:tool", { messageId: "kept" }],
]),
}
purgeMessageRenderCache(cache, ["removed"])
assert.deepEqual([...cache.messageBlocks.keys()], ["kept"])
assert.deepEqual([...cache.recordDisplayCache.keys()], ["kept"])
assert.deepEqual([...cache.messageItems.keys()], ["kept:content:part"])
assert.deepEqual([...cache.toolItems.keys()], ["kept:tool"])
})
test("clears a session render cache without touching another session", () => {
getSessionMessageRenderCache("instance", "removed").recordDisplayCache.set("message", { orderedParts: [{ text: "cached" }] })
getSessionMessageRenderCache("instance", "kept").messageBlocks.set("message", { text: "kept" })
clearSessionMessageRenderCache("instance", "removed")
assert.equal(peekSessionMessageRenderCache("instance", "removed"), undefined)
assert.equal(peekSessionMessageRenderCache("instance", "kept")?.messageBlocks.size, 1)
clearSessionMessageRenderCache("instance", "kept")
})

View file

@ -0,0 +1,132 @@
interface MessageCacheItem {
messageId: string
}
export interface MessageRenderCache {
messageItems: Map<string, MessageCacheItem>
toolItems: Map<string, MessageCacheItem>
messageBlocks: Map<string, unknown>
recordDisplayCache: Map<string, unknown>
}
const renderCaches = new Map<string, MessageRenderCache>()
export const REASONING_RENDER_CHARACTER_LIMIT = 10_000
export const REASONING_RENDER_NODE_LIMIT = 1_000
export const REASONING_TITLE_CHARACTER_LIMIT = 384
interface TraversalCursor {
array: unknown[]
index: number
}
function isTraversalCursor(item: unknown): item is TraversalCursor {
if (!item || typeof item !== "object") return false
const cursor = item as Partial<TraversalCursor>
return Array.isArray(cursor.array) && typeof cursor.index === "number"
}
function extractReasoningSource(source: unknown, characterLimit: number, nodeLimit: number): string {
const pieces: string[] = []
const stack: unknown[] = [source]
const seen = new WeakSet<object>()
let characters = 0
let visited = 0
while (stack.length > 0 && characters < characterLimit && visited < nodeLimit) {
const item = stack.pop()
visited += 1
if (isTraversalCursor(item)) {
if (item.index >= item.array.length) continue
stack.push({ array: item.array, index: item.index + 1 }, item.array[item.index])
continue
}
if (typeof item === "string") {
const separatorLength = pieces.length > 0 ? 1 : 0
const available = characterLimit - characters - separatorLength
if (available <= 0) break
const candidate = item.slice(0, available)
if (/\S/.test(candidate)) {
pieces.push(candidate)
characters += candidate.length + separatorLength
}
if (candidate.length < item.length) break
continue
}
if (!item || typeof item !== "object" || seen.has(item)) continue
seen.add(item)
if (Array.isArray(item)) {
stack.push({ array: item, index: 0 })
continue
}
const segment = item as { text?: unknown; value?: unknown; content?: unknown }
stack.push(segment.content, segment.value, segment.text)
}
return pieces.join("\n")
}
function extractReasoningText(part: unknown, characterLimit: number, nodeLimit: number): string {
const reasoning = part as { text?: unknown; content?: unknown } | null
if (!reasoning || typeof reasoning !== "object") return ""
const text = extractReasoningSource(reasoning.text, characterLimit, nodeLimit)
return text || extractReasoningSource(reasoning.content, characterLimit, nodeLimit)
}
export function extractReasoningTextForRender(part: unknown): string {
return extractReasoningText(part, REASONING_RENDER_CHARACTER_LIMIT, REASONING_RENDER_NODE_LIMIT)
}
export function extractReasoningTextForCopy(part: unknown): string {
return extractReasoningText(part, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY)
}
export function extractReasoningTitleForRender(text: string): string {
const bounded = text.slice(0, REASONING_TITLE_CHARACTER_LIMIT)
const firstLine = bounded.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? ""
return firstLine.match(/^\*\*([^*]+)\*\*/)?.[1]?.trim() ?? ""
}
function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
export function getSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key)
if (!cache) {
cache = { messageItems: new Map(), toolItems: new Map(), messageBlocks: new Map(), recordDisplayCache: new Map() }
renderCaches.set(key, cache)
}
return cache
}
export function peekSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache | undefined {
return renderCaches.get(makeSessionCacheKey(instanceId, sessionId))
}
export function clearSessionMessageRenderCache(instanceId: string, sessionId: string): void {
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
}
export function clearInstanceMessageRenderCaches(instanceId: string): void {
const prefix = `${instanceId}:`
for (const key of renderCaches.keys()) if (key.startsWith(prefix)) renderCaches.delete(key)
}
export function purgeMessageRenderCache(cache: MessageRenderCache, messageIds: readonly string[]): void {
const removed = new Set(messageIds)
for (const messageId of removed) cache.messageBlocks.delete(messageId)
for (const messageId of removed) cache.recordDisplayCache.delete(messageId)
for (const [key, item] of cache.messageItems) {
if (removed.has(item.messageId)) cache.messageItems.delete(key)
}
for (const [key, item] of cache.toolItems) {
if (removed.has(item.messageId)) cache.toolItems.delete(key)
}
}

View file

@ -0,0 +1,77 @@
import assert from "node:assert/strict"
import test from "node:test"
import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "./retained-size.ts"
test("retained-size estimators traverse Map keys and values and Set values", async () => {
const key = { payload: "key" }
const value = { payload: "value" }
const member = { payload: "member" }
const map = new Map([[key, value]])
const set = new Set([member])
const expectedMap = 32 + 24 + estimateRetainedBytes(key) + estimateRetainedBytes(value)
const expectedSet = 32 + 16 + estimateRetainedBytes(member)
assert.equal(estimateRetainedBytes(map), expectedMap)
assert.equal(await estimateRetainedBytesIncrementally(map, { yieldEvery: 1 }), expectedMap)
assert.equal(estimateRetainedBytes(set), expectedSet)
assert.equal(await estimateRetainedBytesIncrementally(set, { yieldEvery: 1 }), expectedSet)
})
test("retained-size estimators count a shared ArrayBuffer backing store once", async () => {
const buffer = new ArrayBuffer(64)
const references = [buffer, new Uint8Array(buffer), new DataView(buffer)]
const expected = estimateRetainedBytes([null, null, null]) + buffer.byteLength
assert.equal(estimateRetainedBytes(references), expected)
assert.equal(await estimateRetainedBytesIncrementally(references, { yieldEvery: 1 }), expected)
})
test("incremental retained-size consumes root iterables lazily", async () => {
let yielded = 0
function* roots() {
yielded += 1
yield { text: "one" }
yielded += 1
yield { text: "two" }
}
const measurement = estimateRetainedBytesIncrementally(roots(), { rootIterable: true, yieldEvery: 1 })
assert.ok(yielded < 2)
assert.ok(await measurement > 0)
assert.equal(yielded, 2)
})
test("incremental retained-size consumes array children lazily", async () => {
let accessed = 0
const values = ["one"]
Object.defineProperty(values, 1, { enumerable: true, get: () => { accessed += 1; return "two" } })
values.length = 2
const controller = new AbortController()
const measurement = estimateRetainedBytesIncrementally(values, { signal: controller.signal, yieldEvery: 1 })
await Promise.resolve()
assert.equal(accessed, 0)
controller.abort()
await assert.rejects(measurement, { name: "AbortError" })
})
test("limited retained-size does not enumerate an entire collection", () => {
let yielded = 0
const map = new Map(Array.from({ length: 100 }, (_, index) => [index, index]))
const entries = map.entries.bind(map)
;(map as any)[Symbol.iterator] = function* () {
for (const entry of entries()) {
yielded += 1
yield entry
}
}
assert.ok(estimateRetainedBytes(map, 40) > 40)
assert.equal(yielded, 0)
})
test("incremental retained-size stops at byte and node ceilings", async () => {
assert.equal(await estimateRetainedBytesIncrementally("large", { maxBytes: 1 }), Number.POSITIVE_INFINITY)
assert.equal(await estimateRetainedBytesIncrementally({ child: {} }, { maxNodes: 1 }), Number.POSITIVE_INFINITY)
})

View file

@ -1,5 +1,9 @@
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get
const arrayBufferResizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")?.get
const MAP_ENTRY_BYTES = 24
const SET_ENTRY_BYTES = 16
type RetainedChild = { value: unknown; keyBytes: number }
function bufferSize(value: object): { bytes: number; growable: boolean } | undefined {
try {
@ -12,32 +16,46 @@ function bufferSize(value: object): { bytes: number; growable: boolean } | undef
export function estimateRetainedBytes(value: unknown, limit = Number.POSITIVE_INFINITY): number {
const seen = new WeakSet<object>()
const pending: unknown[] = [value]
const children: Iterator<RetainedChild>[] = []
let total = 0
while (pending.length > 0 && total <= limit) {
while ((pending.length > 0 || children.length > 0) && total <= limit) {
if (pending.length === 0) {
const child = children[children.length - 1]!.next()
if (child.done) {
children.pop()
continue
}
total += child.value.keyBytes
if (total > limit) break
pending.push(child.value.value)
}
const current = pending.pop()
if (typeof current === "string") total += current.length * 2 + 16
else if (typeof current === "number" || typeof current === "bigint") total += 8
else if (typeof current === "boolean") total += 4
else if (current && typeof current === "object") {
const bytes = bufferSize(current)
if (bytes !== undefined) {
total += bytes.growable ? limit + 1 : bytes.bytes
continue
}
if (ArrayBuffer.isView(current)) {
const backing = bufferSize(current.buffer)
if (seen.has(current.buffer)) continue
seen.add(current.buffer)
const backing = bufferSize(current.buffer as object)
total += backing?.growable ? limit + 1 : backing?.bytes ?? current.byteLength
continue
}
if (seen.has(current)) continue
seen.add(current)
total += Array.isArray(current) ? 24 + current.length * 8 : 32
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue
total += key.length * 2 + 8
if (total > limit) break
pending.push((current as Record<string, unknown>)[key])
const bytes = bufferSize(current)
if (bytes !== undefined) {
total += bytes.growable ? limit + 1 : bytes.bytes
continue
}
total += Array.isArray(current)
? 24 + current.length * 8
: current instanceof Map
? 32 + current.size * MAP_ENTRY_BYTES
: current instanceof Set
? 32 + current.size * SET_ENTRY_BYTES
: 32
if (total <= limit) children.push(objectChildren(current))
}
}
return total
@ -49,41 +67,92 @@ export function exceedsRetainedByteLimit(value: unknown, limit: number): boolean
export async function estimateRetainedBytesIncrementally(
value: unknown,
options: { signal?: AbortSignal; yieldEvery?: number } = {},
options: {
signal?: AbortSignal
yieldEvery?: number
rootIterable?: boolean
maxBytes?: number
maxNodes?: number
} = {},
): Promise<number> {
const seen = new WeakSet<object>()
const pending: unknown[] = [value]
const pending: unknown[] = []
const children: Iterator<RetainedChild>[] = []
const roots = options.rootIterable ? (value as Iterable<unknown>)[Symbol.iterator]() : undefined
if (!roots) pending.push(value)
const yieldEvery = Math.max(1, options.yieldEvery ?? 500)
const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY
const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY
let processed = 0
let total = 0
while (pending.length > 0) {
while (pending.length > 0 || children.length > 0 || roots) {
options.signal?.throwIfAborted()
if (++processed % yieldEvery === 0) await new Promise<void>((resolve) => setTimeout(resolve, 0))
if (pending.length === 0) {
const child = children[children.length - 1]?.next()
if (child && !child.done) {
total += child.value.keyBytes
if (total > maxBytes) return Number.POSITIVE_INFINITY
pending.push(child.value.value)
} else if (child) {
children.pop()
continue
} else {
const next = roots!.next()
if (next.done) break
pending.push(next.value)
}
}
if (++processed > maxNodes) return Number.POSITIVE_INFINITY
if (processed % yieldEvery === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
options.signal?.throwIfAborted()
}
const current = pending.pop()
if (typeof current === "string") total += current.length * 2 + 16
else if (typeof current === "number" || typeof current === "bigint") total += 8
else if (typeof current === "boolean") total += 4
else if (current && typeof current === "object") {
const bytes = bufferSize(current)
if (bytes !== undefined) {
total += bytes.growable ? Number.POSITIVE_INFINITY : bytes.bytes
continue
}
if (ArrayBuffer.isView(current)) {
const backing = bufferSize(current.buffer)
if (seen.has(current.buffer)) continue
seen.add(current.buffer)
const backing = bufferSize(current.buffer as object)
total += backing?.growable ? Number.POSITIVE_INFINITY : backing?.bytes ?? current.byteLength
continue
}
if (seen.has(current)) continue
seen.add(current)
total += Array.isArray(current) ? 24 + current.length * 8 : 32
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue
total += key.length * 2 + 8
pending.push((current as Record<string, unknown>)[key])
const bytes = bufferSize(current)
if (bytes !== undefined) {
total += bytes.growable ? Number.POSITIVE_INFINITY : bytes.bytes
continue
}
total += Array.isArray(current)
? 24 + current.length * 8
: current instanceof Map
? 32 + current.size * MAP_ENTRY_BYTES
: current instanceof Set
? 32 + current.size * SET_ENTRY_BYTES
: 32
children.push(objectChildren(current))
}
if (total > maxBytes) return Number.POSITIVE_INFINITY
}
options.signal?.throwIfAborted()
return total
}
function* objectChildren(current: object): Generator<RetainedChild> {
if (current instanceof Map) {
for (const [key, entry] of current) {
yield { value: key, keyBytes: 0 }
yield { value: entry, keyBytes: 0 }
}
} else if (current instanceof Set) {
for (const entry of current) yield { value: entry, keyBytes: 0 }
}
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue
yield { value: (current as Record<string, unknown>)[key], keyBytes: key.length * 2 + 8 }
}
}

View file

@ -1,35 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { buildSessionSearchMatches, SESSION_SEARCH_MATCH_LIMIT, SESSION_SEARCH_WORK_CHARACTER_LIMIT } from "./session-search.ts"
test("session search retains 250 matches and reports partial results", () => {
const messageId = "message"
const partId = "part"
const text = Array.from({ length: 300 }, () => "needle").join(" ")
const record = { id: messageId, role: "assistant", partIds: [partId], parts: { [partId]: { data: { id: partId, type: "text", text } } } }
const store = {
getSessionMessageIds: () => [messageId],
getMessage: () => record,
getMessageInfo: () => undefined,
}
const result = buildSessionSearchMatches({ store: store as any, sessionId: "session", query: "needle", includeThinking: false })
assert.equal(result.matches.length, SESSION_SEARCH_MATCH_LIMIT)
assert.equal(result.partial, true)
})
test("session search reports complete results below the limit", () => {
const record = { id: "message", role: "user", partIds: ["part"], parts: { part: { data: { id: "part", type: "text", text: "one needle" } } } }
const store = { getSessionMessageIds: () => ["message"], getMessage: () => record, getMessageInfo: () => undefined }
const result = buildSessionSearchMatches({ store: store as any, sessionId: "session", query: "needle", includeThinking: false })
assert.equal(result.matches.length, 1)
assert.equal(result.partial, false)
})
test("session search bounds work even without enough matches", () => {
const text = `${"x".repeat(SESSION_SEARCH_WORK_CHARACTER_LIMIT + 1)}needle`
const record = { id: "message", role: "user", partIds: ["part"], parts: { part: { data: { id: "part", type: "text", text } } } }
const store = { getSessionMessageIds: () => ["message"], getMessage: () => record, getMessageInfo: () => undefined }
const result = buildSessionSearchMatches({ store: store as any, sessionId: "session", query: "needle", includeThinking: false })
assert.equal(result.matches.length, 0)
assert.equal(result.partial, true)
})

View file

@ -2,7 +2,8 @@ import type { ClientPart, MessageInfo } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { MessageRecord, MessageRole } from "../stores/message-v2/types"
import { getToolSearchText } from "../components/tool-call/search-text"
import { resolveToolRenderer } from "../components/tool-call/renderers"
import { getDefaultToolSearchText } from "../components/tool-call/search-text"
export interface SessionSearchMatch {
id: string
@ -20,7 +21,6 @@ interface SearchablePartText {
partId?: string
partType?: string
text: string
truncated?: boolean
}
export interface BuildSessionSearchMatchesOptions {
@ -30,74 +30,55 @@ export interface BuildSessionSearchMatchesOptions {
includeThinking: boolean
}
export interface SessionSearchResult {
matches: SessionSearchMatch[]
partial: boolean
}
export const SESSION_SEARCH_MATCH_LIMIT = 250
export const SESSION_SEARCH_WORK_CHARACTER_LIMIT = 2_000_000
const PREVIEW_RADIUS = 56
function normalizeSearchValue(value: string): string {
return value.toLocaleLowerCase()
}
function segmentToText(segment: unknown, limit = SESSION_SEARCH_WORK_CHARACTER_LIMIT): { text: string; truncated: boolean } {
function segmentToText(segment: unknown): string {
if (typeof segment === "string") return segment
if (Array.isArray(segment)) return segment.map((entry) => segmentToText(entry)).filter(Boolean).join("\n")
if (!segment || typeof segment !== "object") return ""
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const parts: string[] = []
const pending: unknown[] = [segment]
const seen = new WeakSet<object>()
let characters = 0
let nodes = 0
let truncated = false
while (pending.length > 0 && characters < limit && nodes < 10_000) {
const current = pending.pop()
nodes += 1
if (typeof current === "string") {
const text = current.slice(0, limit - characters)
if (text) parts.push(text)
characters += text.length
continue
}
if (!current || typeof current !== "object" || seen.has(current)) continue
seen.add(current)
if (Array.isArray(current)) {
const count = Math.min(current.length, 10_000 - nodes - pending.length)
if (count < current.length) truncated = true
for (let index = count - 1; index >= 0; index -= 1) pending.push(current[index])
continue
}
const candidate = current as { text?: unknown; value?: unknown; content?: unknown }
if (candidate.content !== undefined) pending.push(candidate.content)
if (candidate.value !== undefined) pending.push(candidate.value)
if (candidate.text !== undefined) pending.push(candidate.text)
if (typeof candidate.text === "string") parts.push(candidate.text)
if (typeof candidate.value === "string") parts.push(candidate.value)
if (Array.isArray(candidate.content)) {
parts.push(candidate.content.map((entry) => segmentToText(entry)).filter(Boolean).join("\n"))
}
return { text: parts.join("\n"), truncated: truncated || pending.length > 0 }
return parts.filter(Boolean).join("\n")
}
function extractToolText(part: Extract<ClientPart, { type: "tool" }>): { text: string; truncated: boolean } {
function extractReasoningText(part: ClientPart): string {
const text = segmentToText((part as any).text)
const content = Array.isArray((part as any).content)
? (part as any).content.map((entry: unknown) => segmentToText(entry)).filter(Boolean).join("\n")
: ""
return [text, content].filter(Boolean).join("\n")
}
function extractGenericPartText(part: ClientPart): string {
const candidate = part as Record<string, unknown>
const values = [
candidate.text,
candidate.content,
candidate.value,
candidate.title,
candidate.name,
candidate.filename,
candidate.message,
]
return values.map((value) => segmentToText(value)).filter(Boolean).join("\n")
}
function extractToolText(part: Extract<ClientPart, { type: "tool" }>): string {
const toolName = typeof part.tool === "string" ? part.tool : ""
const context = { toolCall: part, toolState: (part as any).state, toolName }
const values = getToolSearchText(context)
const rendered: string[] = []
let characters = 0
let truncated = false
for (const value of values) {
if (!value.trim()) continue
const remaining = SESSION_SEARCH_WORK_CHARACTER_LIMIT - characters
if (remaining <= 0) {
truncated = true
break
}
rendered.push(value.slice(0, remaining))
characters += Math.min(value.length, remaining)
if (value.length > remaining) {
truncated = true
break
}
}
return { text: rendered.join("\n"), truncated }
const renderer = resolveToolRenderer(toolName)
const values = renderer.getSearchText?.(context) ?? getDefaultToolSearchText(context)
return values.filter((value) => value.trim().length > 0).join("\n")
}
function extractMessageInfoText(info: MessageInfo | undefined): string {
@ -115,15 +96,14 @@ function extractSearchablePartText(part: ClientPart, includeThinking: boolean):
const partType = typeof (part as any).type === "string" ? (part as any).type : undefined
if (part.type === "text") {
const raw = (part as any).text
const result = typeof raw === "string" ? { text: raw, truncated: false } : segmentToText(raw)
return result.text.trim().length > 0 ? { partId, partType, ...result } : null
const text = typeof (part as any).text === "string" ? (part as any).text : segmentToText((part as any).text)
return text.trim().length > 0 ? { partId, partType, text } : null
}
if (part.type === "reasoning") {
if (!includeThinking) return null
const result = segmentToText([(part as any).text, (part as any).content])
return result.text.trim().length > 0 ? { partId, partType, ...result } : null
const text = extractReasoningText(part)
return text.trim().length > 0 ? { partId, partType, text } : null
}
if (part.type === "file") {
@ -132,8 +112,8 @@ function extractSearchablePartText(part: ClientPart, includeThinking: boolean):
}
if (part.type === "tool") {
const result = extractToolText(part)
return result.text.trim().length > 0 ? { partId, partType, ...result } : null
const text = extractToolText(part)
return text.trim().length > 0 ? { partId, partType, text } : null
}
if (part.type === "compaction") {
@ -141,9 +121,8 @@ function extractSearchablePartText(part: ClientPart, includeThinking: boolean):
return { partId, partType, text }
}
const candidate = part as Record<string, unknown>
const result = segmentToText([candidate.text, candidate.content, candidate.value, candidate.title, candidate.name, candidate.filename, candidate.message])
return result.text.trim().length > 0 ? { partId, partType, ...result } : null
const text = extractGenericPartText(part)
return text.trim().length > 0 ? { partId, partType, text } : null
}
function buildPreview(text: string, start: number, end: number): string {
@ -154,36 +133,38 @@ function buildPreview(text: string, start: number, end: number): string {
return `${prefix}${text.slice(from, to).replace(/\s+/g, " ").trim()}${suffix}`
}
export function buildSessionSearchMatches(options: BuildSessionSearchMatchesOptions): SessionSearchResult {
function collectRecordSearchableText(store: InstanceMessageStore, record: MessageRecord, includeThinking: boolean): SearchablePartText[] {
const results: SearchablePartText[] = []
for (const partId of record.partIds) {
const part = record.parts[partId]?.data
if (!part) continue
const text = extractSearchablePartText(part, includeThinking)
if (text) results.push(text)
}
const infoText = extractMessageInfoText(store.getMessageInfo(record.id))
if (infoText.trim().length > 0) {
results.push({ partType: "error", text: infoText })
}
return results
}
export function buildSessionSearchMatches(options: BuildSessionSearchMatchesOptions): SessionSearchMatch[] {
const query = options.query.trim()
if (!query) return { matches: [], partial: false }
if (!query) return []
const needle = normalizeSearchValue(query)
const matches: SessionSearchMatch[] = []
const messageIds = options.store.getSessionMessageIds(options.sessionId)
let remainingWork = SESSION_SEARCH_WORK_CHARACTER_LIMIT
for (const messageId of messageIds) {
if (remainingWork <= 0) return { matches, partial: true }
const record = options.store.getMessage(messageId)
if (!record) continue
const searchableParts = function* (): Generator<SearchablePartText> {
for (const partId of record.partIds) {
if (remainingWork <= 0) return
const part = record.parts[partId]?.data
if (!part) continue
const searchable = extractSearchablePartText(part, options.includeThinking)
if (searchable) yield searchable
}
const infoText = extractMessageInfoText(options.store.getMessageInfo(record.id))
if (infoText.trim()) yield { partType: "error", text: infoText }
}
const searchableParts = collectRecordSearchableText(options.store, record, options.includeThinking)
for (const searchable of searchableParts()) {
if (remainingWork <= 0) return { matches, partial: true }
const text = searchable.text.slice(0, remainingWork)
remainingWork -= text.length
const haystack = normalizeSearchValue(text)
for (const searchable of searchableParts) {
const haystack = normalizeSearchValue(searchable.text)
let from = 0
let occurrence = 0
while (from < haystack.length) {
@ -199,16 +180,13 @@ export function buildSessionSearchMatches(options: BuildSessionSearchMatchesOpti
start: index,
end,
occurrence,
preview: buildPreview(text, index, end),
preview: buildPreview(searchable.text, index, end),
})
if (matches.length >= SESSION_SEARCH_MATCH_LIMIT) return { matches, partial: true }
occurrence += 1
from = end > index ? end : index + 1
}
if (text.length < searchable.text.length) return { matches, partial: true }
if (searchable.truncated) return { matches, partial: true }
}
}
return { matches, partial: false }
return matches
}

View file

@ -22,6 +22,14 @@ describe("session transcript LRU", () => {
)
})
it("evicts an unbounded entry without turning retained bytes into NaN", () => {
const entries = [entry("a", "unbounded", Number.POSITIVE_INFINITY, 1), entry("b", "fits", 4, 2)]
assert.deepEqual(
selectTranscriptEvictions(entries, 4, () => false).map(({ sessionId }) => sessionId),
["unbounded"],
)
})
it("skips every protected state and permits temporary protected overage", () => {
const states: Record<string, TranscriptProtectionState> = {
visible: { visible: true }, loading: { loading: true },
@ -53,4 +61,39 @@ describe("session transcript LRU", () => {
lru.account("b", "third", sizes.get("b/third")!)
assert.deepEqual(evicted, ["b/new"])
})
it("preserves access order when touches happen before accounting completes", () => {
const evicted: string[] = []
const lru = new SessionTranscriptLru({
byteBudget: 12,
isProtected: () => false,
evict: (instanceId, sessionId) => evicted.push(`${instanceId}/${sessionId}`),
})
lru.touch("a", "old")
lru.touch("b", "new")
lru.account("b", "new", 6)
lru.account("a", "old", 6)
lru.account("c", "latest", 6)
assert.deepEqual(evicted, ["a/old"])
})
it("does not retain a pending touch after empty terminal accounting", () => {
const evicted: string[] = []
const lru = new SessionTranscriptLru({
byteBudget: 12,
isProtected: () => false,
evict: (instanceId, sessionId) => evicted.push(`${instanceId}/${sessionId}`),
})
assert.equal(lru.touch("a", "empty"), true)
assert.equal(lru.touch("a", "empty"), false)
lru.account("a", "empty", 0)
lru.account("b", "old", 6)
lru.account("c", "new", 6)
lru.account("a", "empty", 6)
assert.deepEqual(evicted, ["b/old"])
})
})

View file

@ -33,15 +33,21 @@ export function selectTranscriptEvictions(
byteBudget: number,
isProtected: (entry: TranscriptLruEntry) => boolean,
): TranscriptLruEntry[] {
let retainedBytes = entries.reduce((total, entry) => total + entry.bytes, 0)
if (retainedBytes <= byteBudget) return []
let retainedBytes = 0
let unboundedEntries = 0
for (const entry of entries) {
if (Number.isFinite(entry.bytes)) retainedBytes += entry.bytes
else unboundedEntries += 1
}
if (unboundedEntries === 0 && retainedBytes <= byteBudget) return []
const selected: TranscriptLruEntry[] = []
for (const entry of [...entries].sort((left, right) => left.lastUsed - right.lastUsed)) {
if (isProtected(entry)) continue
selected.push(entry)
retainedBytes -= entry.bytes
if (retainedBytes <= byteBudget) break
if (Number.isFinite(entry.bytes)) retainedBytes -= entry.bytes
else unboundedEntries -= 1
if (unboundedEntries === 0 && retainedBytes <= byteBudget) break
}
return selected
}
@ -54,6 +60,7 @@ interface SessionTranscriptLruOptions {
export class SessionTranscriptLru {
private entries = new Map<string, TranscriptLruEntry>()
private pendingTouches = new Map<string, number>()
private sequence = 0
constructor(private options: SessionTranscriptLruOptions) {}
@ -62,31 +69,47 @@ export class SessionTranscriptLru {
const key = this.key(instanceId, sessionId)
if (bytes <= 0) {
this.entries.delete(key)
this.pendingTouches.delete(key)
return
}
const current = this.entries.get(key)
const lastUsed = current?.lastUsed ?? this.pendingTouches.get(key) ?? ++this.sequence
this.pendingTouches.delete(key)
this.entries.set(key, {
instanceId,
sessionId,
bytes,
lastUsed: current?.lastUsed ?? ++this.sequence,
lastUsed,
})
this.enforce()
}
touch(instanceId: string, sessionId: string): void {
const entry = this.entries.get(this.key(instanceId, sessionId))
if (entry) entry.lastUsed = ++this.sequence
touch(instanceId: string, sessionId: string): boolean {
const key = this.key(instanceId, sessionId)
const lastUsed = ++this.sequence
const entry = this.entries.get(key)
if (entry) {
entry.lastUsed = lastUsed
return false
}
const needsAccounting = !this.pendingTouches.has(key)
this.pendingTouches.set(key, lastUsed)
return needsAccounting
}
forget(instanceId: string, sessionId: string): void {
this.entries.delete(this.key(instanceId, sessionId))
const key = this.key(instanceId, sessionId)
this.entries.delete(key)
this.pendingTouches.delete(key)
}
forgetInstance(instanceId: string): void {
for (const [key, entry] of this.entries) {
if (entry.instanceId === instanceId) this.entries.delete(key)
}
for (const key of this.pendingTouches.keys()) {
if (key.startsWith(`${instanceId}\u0000`)) this.pendingTouches.delete(key)
}
}
enforce(): void {

View file

@ -0,0 +1,129 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { SessionTranscriptMeasurementQueue } from "./session-transcript-measurement.ts"
const deferred = () => {
let resolve!: (value: number) => void
const promise = new Promise<number>((done) => {
resolve = done
})
return { promise, resolve }
}
describe("session transcript measurement queue", () => {
it("starts measurement on the original deadline despite repeated mutations", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] })
const measurements: AbortSignal[] = []
const queue = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: (_instanceId, _sessionId, signal) => {
measurements.push(signal)
return new Promise<number>(() => {})
},
account: () => {},
onError: () => {},
})
queue.schedule("instance", "session")
for (let elapsed = 10; elapsed < 100; elapsed += 10) {
context.mock.timers.tick(10)
queue.schedule("instance", "session")
}
context.mock.timers.tick(10)
assert.equal(measurements.length, 1)
assert.equal(measurements[0]?.aborted, false)
})
it("discards a dirty result and accounts the follow-up measurement", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] })
const measurements = [deferred(), deferred()]
const accounted: number[] = []
const signals: AbortSignal[] = []
let measurementIndex = 0
const queue = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: (_instanceId, _sessionId, signal) => {
signals.push(signal)
return measurements[measurementIndex++]!.promise
},
account: (_instanceId, _sessionId, bytes) => accounted.push(bytes),
onError: () => {},
})
queue.schedule("instance", "session")
context.mock.timers.tick(100)
queue.schedule("instance", "session")
assert.equal(signals[0]?.aborted, true)
measurements[0].resolve(10)
await Promise.resolve()
assert.deepEqual(accounted, [])
context.mock.timers.tick(100)
measurements[1].resolve(20)
await Promise.resolve()
assert.deepEqual(accounted, [20])
})
it("accounts terminal measurement failures conservatively", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] })
const accounted: number[] = []
const errors: unknown[] = []
const queue = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: async () => { throw new Error("measurement failed") },
account: (_instanceId, _sessionId, bytes) => accounted.push(bytes),
onError: (_instanceId, _sessionId, error) => errors.push(error),
})
queue.schedule("instance", "session")
context.mock.timers.tick(100)
await Promise.resolve()
assert.deepEqual(accounted, [Number.POSITIVE_INFINITY])
assert.equal(errors.length, 1)
})
it("replaces a known estimate with conservative accounting after a later failure", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] })
const accounted: number[] = []
let attempt = 0
const queue = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: async () => {
attempt += 1
if (attempt === 1) return 10
throw new Error("measurement failed")
},
account: (_instanceId, _sessionId, bytes) => accounted.push(bytes),
onError: () => {},
})
queue.schedule("instance", "session")
context.mock.timers.tick(100)
await Promise.resolve()
queue.schedule("instance", "session")
context.mock.timers.tick(100)
await Promise.resolve()
assert.deepEqual(accounted, [10, Number.POSITIVE_INFINITY])
})
it("accounts conservatively even when error reporting fails", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] })
const accounted: number[] = []
const queue = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: async () => { throw new Error("measurement failed") },
account: (_instanceId, _sessionId, bytes) => accounted.push(bytes),
onError: () => { throw new Error("logger failed") },
})
queue.schedule("instance", "session")
context.mock.timers.tick(100)
await Promise.resolve()
assert.deepEqual(accounted, [Number.POSITIVE_INFINITY])
})
})

View file

@ -0,0 +1,97 @@
type PendingMeasurement = {
revision: number
timer?: ReturnType<typeof setTimeout>
controller?: AbortController
}
type SessionTranscriptMeasurementOptions = {
delayMs: number
measure: (instanceId: string, sessionId: string, signal: AbortSignal) => Promise<number>
account: (instanceId: string, sessionId: string, bytes: number) => void
onError: (instanceId: string, sessionId: string, error: unknown) => void
}
export class SessionTranscriptMeasurementQueue {
private pending = new Map<string, PendingMeasurement>()
constructor(private options: SessionTranscriptMeasurementOptions) {}
schedule(instanceId: string, sessionId: string): void {
const entryKey = this.key(instanceId, sessionId)
const current = this.pending.get(entryKey)
if (current) {
if (current.controller) {
current.controller.abort()
const replacement = { revision: current.revision + 1 }
this.pending.set(entryKey, replacement)
this.arm(instanceId, sessionId, replacement)
return
}
current.revision += 1
return
}
const pending = { revision: 1 }
this.pending.set(entryKey, pending)
this.arm(instanceId, sessionId, pending)
}
cancel(instanceId: string, sessionId: string): void {
const entryKey = this.key(instanceId, sessionId)
const pending = this.pending.get(entryKey)
if (!pending) return
if (pending.timer !== undefined) clearTimeout(pending.timer)
pending.controller?.abort()
this.pending.delete(entryKey)
}
cancelInstance(instanceId: string): void {
for (const [entryKey, pending] of this.pending) {
if (!entryKey.startsWith(`${instanceId}\u0000`)) continue
if (pending.timer !== undefined) clearTimeout(pending.timer)
pending.controller?.abort()
this.pending.delete(entryKey)
}
}
private arm(instanceId: string, sessionId: string, pending: PendingMeasurement): void {
pending.timer = setTimeout(() => {
pending.timer = undefined
void this.measure(instanceId, sessionId, pending)
}, this.options.delayMs)
}
private async measure(instanceId: string, sessionId: string, pending: PendingMeasurement): Promise<void> {
const entryKey = this.key(instanceId, sessionId)
const measuredRevision = pending.revision
const controller = new AbortController()
pending.controller = controller
try {
const bytes = await this.options.measure(instanceId, sessionId, controller.signal)
if (controller.signal.aborted || this.pending.get(entryKey) !== pending) return
if (pending.revision === measuredRevision) {
this.pending.delete(entryKey)
this.options.account(instanceId, sessionId, bytes)
return
}
} catch (error) {
if (!controller.signal.aborted && this.pending.get(entryKey) === pending && pending.revision === measuredRevision) {
this.options.account(instanceId, sessionId, Number.POSITIVE_INFINITY)
try {
this.options.onError(instanceId, sessionId, error)
} catch {
// Accounting is authoritative; error reporting must not undo it.
}
}
} finally {
if (this.pending.get(entryKey) !== pending) return
pending.controller = undefined
if (pending.revision !== measuredRevision) this.arm(instanceId, sessionId, pending)
else this.pending.delete(entryKey)
}
}
private key(instanceId: string, sessionId: string): string {
return `${instanceId}\u0000${sessionId}`
}
}

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, it } from "node:test"
import { setTimeout as delay } from "node:timers/promises"
import {
clearPendingDeltasForInstance,
clearPendingDeltasForPart,
enqueueDelta,
flushPendingDeltasForMessage,
@ -99,4 +100,19 @@ describe("delta buffer", () => {
],
])
})
it("clears a removed instance without dropping another instance's pending deltas", async () => {
const flushed: DeltaBatch[] = []
setFlushCallback((batch) => flushed.push(batch))
enqueueDelta("removed", "message-1", "part-1", "text", "stale")
enqueueDelta("active", "message-2", "part-2", "text", "keep")
clearPendingDeltasForInstance("removed")
await delay(75)
assert.deepEqual(flushed, [[
{ instanceId: "active", messageId: "message-2", partId: "part-2", field: "text", delta: "keep" },
]])
})
})

View file

@ -34,6 +34,16 @@ export function clearPendingDeltasForPart(instanceId: string, messageId: string,
}
}
export function clearPendingDeltasForInstance(instanceId: string): void {
for (const [key, pending] of pendingDeltas) {
if (pending.instanceId === instanceId) pendingDeltas.delete(key)
}
if (pendingDeltas.size === 0 && deltaFlushTimer !== null) {
clearTimeout(deltaFlushTimer)
deltaFlushTimer = null
}
}
export function flushPendingDeltasForMessage(
instanceId: string,
messageId: string,

View file

@ -14,7 +14,11 @@ import {
fetchSessions,
fetchAgents,
fetchProviders,
clearInstanceDraftPrompts,
clearSessionListRequestState,
clearInstanceDeletedSessionAuthority,
clearInstanceSessionExpansionState,
clearInstanceSessionSelection,
resetSessionPagination,
} from "./sessions"
import {
@ -332,11 +336,6 @@ function ensureActiveInstanceSelected(): void {
}
function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string) {
const existing = instances().get(descriptor.id)
const replaceClient = Boolean(existing?.client && (
existing.proxyPath !== descriptor.proxyPath
|| (descriptor.pid !== undefined && existing.pid !== descriptor.pid)
))
const mapped = workspaceDescriptorToInstance(descriptor, projectName)
if (instances().has(descriptor.id)) {
updateInstance(descriptor.id, mapped)
@ -345,7 +344,7 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string)
}
if (descriptor.status === "ready") {
attachClient(descriptor, replaceClient)
attachClient(descriptor)
// If no tab is currently selected (common after UI refresh),
// auto-select the first ready instance.
ensureActiveInstanceSelected()
@ -358,14 +357,14 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string)
}
}
function attachClient(descriptor: WorkspaceDescriptor, replaceClient = false) {
function attachClient(descriptor: WorkspaceDescriptor) {
const instance = instances().get(descriptor.id)
if (!instance) return
const nextPort = descriptor.port ?? instance.port
const nextProxyPath = descriptor.proxyPath
if (!replaceClient && instance.client && instance.proxyPath === nextProxyPath) {
if (instance.client && instance.proxyPath === nextProxyPath) {
if (nextPort && instance.port !== nextPort) {
updateInstance(descriptor.id, { port: nextPort })
}
@ -373,7 +372,6 @@ function attachClient(descriptor: WorkspaceDescriptor, replaceClient = false) {
}
if (instance.client) {
if (replaceClient) messageStoreBus.unregisterInstance(descriptor.id)
sdkManager.destroyClientsForInstance(descriptor.id)
}
@ -1017,8 +1015,12 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) {
// Clean up session indexes and drafts for removed instance
clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id)
clearSessionListRequestState(id)
clearInstanceAttachments(id)
clearInstanceDeletedSessionAuthority(id)
clearInstanceSessionExpansionState(id)
clearInstanceSessionSelection(id)
if (removedInstance && removedOccurrence >= 0 && options.authoritative !== false) {
publishInstanceLifecycleAuthority({
type: "removed",

View file

@ -3,6 +3,7 @@ import { describe, it } from "node:test"
import { messageStoreBus } from "./bus.ts"
import { messagesLoaded, setMessagesLoaded } from "../session-state.ts"
import { getCacheEntry, setCacheEntry } from "../../lib/global-cache.ts"
describe("message store scroll snapshots", () => {
it("seeds an unregistered instance without claiming runtime authority", () => {
@ -92,3 +93,62 @@ describe("message store scroll snapshots", () => {
}
})
})
describe("authoritative message removal", () => {
it("publishes omitted ids and clears render caches outside transcript accounting", () => {
const instanceId = "authoritative-omission", sessionId = "session"
const store = messageStoreBus.getOrCreate(instanceId)
const removed: string[] = []
const stopListening = messageStoreBus.onMessagesRemoved((id, removedSessionId, messageIds) => {
if (id === instanceId && removedSessionId === sessionId) removed.push(...messageIds)
})
const cacheEntry = { instanceId, sessionId, scope: "markdown", cacheId: "old-part", version: "1" }
try {
store.hydrateMessages(sessionId, [{ id: "old", sessionId, role: "assistant", status: "complete" }])
setCacheEntry(cacheEntry, "retained render output")
store.hydrateMessages(sessionId, [{ id: "new", sessionId, role: "user", status: "complete" }])
assert.deepEqual(removed, ["old"])
assert.equal(getCacheEntry(cacheEntry), undefined)
setCacheEntry(cacheEntry, "new retained render output")
store.reconcileEmptyAuthoritativeSnapshot(sessionId)
assert.deepEqual(removed, ["old", "new"])
assert.equal(getCacheEntry(cacheEntry), undefined)
} finally {
stopListening()
messageStoreBus.unregisterInstance(instanceId)
}
})
it("clears global caches for direct deletion and revert pruning", () => {
const instanceId = "direct-and-revert-removal", sessionId = "session"
const store = messageStoreBus.getOrCreate(instanceId)
const removed: string[] = []
const stopListening = messageStoreBus.onMessagesRemoved((id, removedSessionId, messageIds) => {
if (id === instanceId && removedSessionId === sessionId) removed.push(...messageIds)
})
const cacheEntry = { instanceId, sessionId, scope: "tool-call", cacheId: "part", version: "1" }
try {
store.hydrateMessages(sessionId, [{ id: "direct", sessionId, role: "assistant", status: "complete" }])
setCacheEntry(cacheEntry, "direct cache")
store.removeMessage("direct", sessionId)
assert.deepEqual(removed, ["direct"])
assert.equal(getCacheEntry(cacheEntry), undefined)
store.hydrateMessages(sessionId, [
{ id: "keep", sessionId, role: "user", status: "complete" },
{ id: "revert", sessionId, role: "assistant", status: "complete" },
])
setCacheEntry(cacheEntry, "revert cache")
store.setSessionRevert(sessionId, { messageID: "revert" })
assert.deepEqual(removed, ["direct", "revert"])
assert.equal(getCacheEntry(cacheEntry), undefined)
} finally {
stopListening()
messageStoreBus.unregisterInstance(instanceId)
}
})
})

View file

@ -17,6 +17,7 @@ class MessageStoreBus {
private teardownHandlers = new Set<(instanceId: string) => void>()
private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>()
private sessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>()
private messageRemovalHandlers = new Set<(instanceId: string, sessionId: string, messageIds: readonly string[]) => void>()
private scrollSnapshotHandlers = new Set<
(instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void
>()
@ -32,6 +33,7 @@ class MessageStoreBus {
createInstanceMessageStore(instanceId, {
onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId),
onSessionChanged: (id, sessionId) => this.notifySessionChanged(id, sessionId),
onMessagesRemoved: (id, sessionId, messageIds) => this.notifyMessagesRemoved(id, sessionId, messageIds),
onScrollSnapshotChanged: (id, sessionId, scope, snapshot) =>
this.notifyScrollSnapshotChanged(id, sessionId, scope, snapshot),
})
@ -79,6 +81,22 @@ class MessageStoreBus {
}
}
onMessagesRemoved(handler: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void): () => void {
this.messageRemovalHandlers.add(handler)
return () => this.messageRemovalHandlers.delete(handler)
}
private notifyMessagesRemoved(instanceId: string, sessionId: string, messageIds: readonly string[]) {
clearCacheForSession(instanceId, sessionId)
for (const handler of this.messageRemovalHandlers) {
try {
handler(instanceId, sessionId, messageIds)
} catch (error) {
log.error("Failed to run message removal handler", error)
}
}
}
onScrollSnapshotChanged(
handler: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void,
): () => void {

View file

@ -2,6 +2,9 @@ import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { createInstanceMessageStore } from "./instance-store.ts"
import { getSessionMessageRenderCache, peekSessionMessageRenderCache } from "../../lib/message-render-cache.ts"
import { buildRecordDisplayData } from "./record-display-cache.ts"
import { clearCacheForInstance, setCacheEntry } from "../../lib/global-cache.ts"
describe("message-v2 permission state", () => {
it("keeps one permission attachment when a duplicate moves from global to a tool part", () => {
@ -44,17 +47,13 @@ describe("message-v2 permission state", () => {
})
describe("message-v2 revert state", () => {
it("prunes reverted messages and their permission and question queues", () => {
it("prunes reverted messages and their question queue", () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })
store.hydrateMessages("session-1", [
{ id: "keep", sessionId: "session-1", role: "user", status: "complete" },
{ id: "revert", sessionId: "session-1", role: "assistant", status: "complete" },
])
store.upsertPermission({
permission: { id: "permission", sessionID: "session-1", action: "edit", resources: [] },
messageId: "revert", enqueuedAt: 1,
})
store.upsertQuestion({
request: { id: "question", sessionID: "session-1", questions: [] },
messageId: "revert", enqueuedAt: 1,
@ -63,14 +62,292 @@ describe("message-v2 revert state", () => {
store.setSessionRevert("session-1", { messageID: "revert" })
assert.deepEqual(store.getSessionMessageIds("session-1"), ["keep"])
assert.equal(store.state.permissions.queue.length, 0)
assert.equal(store.state.permissions.active, null)
assert.equal(store.state.questions.queue.length, 0)
assert.equal(store.state.questions.active, null)
})
it("accounts for added and cleared revert state without messages", async () => {
let changes = 0
const store = createInstanceMessageStore("instance-1", { onSessionChanged: () => { changes += 1 } })
store.addOrUpdateSession({ id: "session-1" })
const baselineChanges = changes
store.setSessionRevert("session-1", { messageID: "revert", partID: "part" })
assert.ok(await store.estimateSessionRetainedBytes("session-1") > 0)
assert.equal(changes, baselineChanges + 1)
store.setSessionRevert("session-1", null)
assert.equal(await store.estimateSessionRetainedBytes("session-1"), 0)
assert.equal(changes, baselineChanges + 2)
})
})
describe("message-v2 hydrateMessages vs pending optimistic sends", () => {
it("reports no retained transcript bytes for an empty session", async () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })
assert.equal(await store.estimateSessionRetainedBytes("session-1"), 0)
})
it("accounts for render caches and clears them with session eviction", async () => {
const store = createInstanceMessageStore("cache-accounting")
store.addOrUpdateSession({ id: "session-1" })
store.hydrateMessages("session-1", [{ id: "message-1", sessionId: "session-1", role: "assistant", status: "complete", parts: [{ id: "part-1", type: "text", text: "source" } as any] }])
const baselineBytes = await store.estimateSessionRetainedBytes("session-1")
const renderCache = getSessionMessageRenderCache("cache-accounting", "session-1")
renderCache.recordDisplayCache.set("message-1", { revision: 1, data: { orderedParts: [{ text: "display-copy" }] } })
const recordCachedBytes = await store.estimateSessionRetainedBytes("session-1")
renderCache.messageBlocks.set("message-1", { text: "x".repeat(4_000) })
const cachedBytes = await store.estimateSessionRetainedBytes("session-1")
store.clearSession("session-1")
assert.ok(recordCachedBytes > baselineBytes)
assert.ok(cachedBytes > recordCachedBytes + 8_000)
assert.equal(peekSessionMessageRenderCache("cache-accounting", "session-1"), undefined)
assert.equal(await store.estimateSessionRetainedBytes("session-1"), 0)
})
it("clears derived caches with instance cleanup", () => {
const store = createInstanceMessageStore("instance-cache-cleanup")
store.hydrateMessages("session-1", [{ id: "message-1", sessionId: "session-1", role: "assistant", status: "complete" }])
getSessionMessageRenderCache("instance-cache-cleanup", "session-1").messageBlocks.set("message-1", {})
buildRecordDisplayData("instance-cache-cleanup", store.getMessage("message-1")!)
store.clearInstance()
assert.equal(peekSessionMessageRenderCache("instance-cache-cleanup", "session-1"), undefined)
})
it("accounts for the module display cache and associated orphan pending parts", async () => {
const store = createInstanceMessageStore("cache-accounting-orphans")
store.addOrUpdateSession({ id: "session-1" })
store.hydrateMessages("session-1", [{ id: "message-1", sessionId: "session-1", role: "assistant", status: "complete", parts: [{ id: "part-1", type: "text", text: "source" } as any] }])
const baseline = await store.estimateSessionRetainedBytes("session-1")
buildRecordDisplayData("cache-accounting-orphans", store.getMessage("message-1")!)
const displayCached = await store.estimateSessionRetainedBytes("session-1")
store.bufferPendingPart({ messageId: "orphan", sessionId: "session-1", part: { type: "text", text: "x".repeat(4_000) } as any, receivedAt: Date.now() })
const withPending = await store.estimateSessionRetainedBytes("session-1")
store.clearSession("session-1")
assert.ok(displayCached > baseline)
assert.ok(withPending > displayCached + 8_000)
assert.equal(store.state.pendingParts.orphan, undefined)
})
it("accounts session-owned global cache values without double-counting shared objects", async () => {
const store = createInstanceMessageStore("global-cache-accounting")
store.addOrUpdateSession({ id: "session-1" })
const shared = { text: "x".repeat(4_000) }
getSessionMessageRenderCache("global-cache-accounting", "session-1").messageBlocks.set("message", shared)
const localBytes = await store.estimateSessionRetainedBytes("session-1")
const cacheEntry = { instanceId: "global-cache-accounting", sessionId: "session-1", scope: "markdown", cacheId: "part", version: "1" }
setCacheEntry(cacheEntry, { text: "y".repeat(4_000) })
const uniqueBytes = await store.estimateSessionRetainedBytes("session-1")
setCacheEntry(cacheEntry, shared)
const sharedBytes = await store.estimateSessionRetainedBytes("session-1")
assert.ok(uniqueBytes > localBytes + 8_000)
assert.ok(sharedBytes > localBytes)
assert.ok(sharedBytes < localBytes + 1_000)
clearCacheForInstance("global-cache-accounting")
})
it("byte-bounds individual and aggregate pending parts while accounting session orphans", async () => {
let changes = 0
const invalidated: string[] = []
const store = createInstanceMessageStore("pending-byte-cap", {
onSessionChanged: () => { changes += 1 },
onSessionCleared: (_instanceId, sessionId) => invalidated.push(sessionId),
})
store.bufferPendingPart({ messageId: "oversized", part: { type: "text", text: "x".repeat(600_000) } as any, receivedAt: 0 })
assert.equal(store.state.pendingParts.oversized, undefined)
store.bufferPendingPart({ messageId: "oversized-known", sessionId: "session-oversized", part: { type: "text", text: "x".repeat(600_000) } as any, receivedAt: 0 })
assert.deepEqual(invalidated, ["session-oversized"])
assert.equal(changes, 1)
store.bufferPendingPart({ messageId: "session-orphan", sessionId: "session-1", part: { type: "text", text: "x".repeat(100_000) } as any, receivedAt: 1 })
assert.ok(await store.estimateSessionRetainedBytes("session-1") > 0)
assert.equal(changes, 2)
for (let index = 0; index < 50; index += 1) {
store.bufferPendingPart({ messageId: `orphan-${index}`, part: { type: "text", text: "x".repeat(100_000) } as any, receivedAt: index + 2 })
}
assert.ok(Object.values(store.state.pendingParts).flat().length < 51)
store.clearInstance()
})
it("invalidates an unknown-owner pending drop when live ownership becomes known", () => {
const invalidated: string[] = []
const store = createInstanceMessageStore("pending-owner-reconciliation", {
onSessionCleared: (_instanceId, sessionId) => invalidated.push(sessionId),
})
store.bufferPendingPart({ messageId: "unknown", part: { type: "text", text: "x".repeat(600_000) } as any, receivedAt: 0 })
store.upsertMessage({ id: "unknown", sessionId: "session-1", role: "assistant", status: "streaming" })
assert.deepEqual(invalidated, ["session-1"])
store.clearInstance()
})
it("consumes an unknown-owner drop during authoritative hydration", () => {
const invalidated: string[] = []
const store = createInstanceMessageStore("pending-authoritative-reconciliation", {
onSessionCleared: (_instanceId, sessionId) => invalidated.push(sessionId),
})
store.bufferPendingPart({ messageId: "known", part: { type: "text", text: "x".repeat(600_000) } as any, receivedAt: 0 })
store.hydrateMessages("session-1", [{ id: "known", sessionId: "session-1", role: "assistant", status: "complete", parts: [{ id: "part", type: "text", text: "authoritative" } as any] }])
assert.deepEqual(invalidated, [])
assert.equal(store.getMessage("known")?.parts.part?.data.text, "authoritative")
store.clearInstance()
})
it("caps pending-part bytes globally across instance stores", () => {
const first = createInstanceMessageStore("pending-global-first")
const second = createInstanceMessageStore("pending-global-second")
for (let index = 0; index < 16; index += 1) {
const store = index % 2 === 0 ? first : second
store.bufferPendingPart({ messageId: `orphan-${index}`, part: { type: "text", text: "x".repeat(300_000) } as any, receivedAt: index })
}
const retained = Object.values(first.state.pendingParts).flat().length + Object.values(second.state.pendingParts).flat().length
assert.ok(retained < 16)
first.clearInstance()
second.clearInstance()
})
it("caps pending parts that have no associated session", () => {
const store = createInstanceMessageStore("pending-cap")
for (let index = 0; index < 101; index += 1) {
store.bufferPendingPart({ messageId: `unknown-${index}`, part: { type: "text", text: "late" } as any, receivedAt: Date.now() })
}
assert.equal(Object.keys(store.state.pendingParts).length, 100)
assert.equal(store.state.pendingParts["unknown-0"], undefined)
})
it("caps pending parts per session and globally across arbitrary session ids", () => {
const store = createInstanceMessageStore("pending-scoped-cap")
for (let index = 0; index < 101; index += 1) {
store.bufferPendingPart({ messageId: `same-${index}`, sessionId: "same", part: { type: "text", text: "late" } as any, receivedAt: index })
}
assert.equal(Object.values(store.state.pendingParts).flat().filter((entry) => entry.sessionId === "same").length, 100)
assert.equal(store.state.pendingParts["same-0"], undefined)
for (let index = 0; index < 501; index += 1) {
store.bufferPendingPart({ messageId: `global-${index}`, sessionId: `arbitrary-${index}`, part: { type: "text", text: "late" } as any, receivedAt: 1_000 + index })
}
assert.equal(Object.values(store.state.pendingParts).flat().length, 500)
})
it("preserves prompt display overrides during volatile eviction but clears them explicitly", () => {
const store = createInstanceMessageStore("volatile-eviction")
const displayMetadata = { segments: [{ kind: "inline", length: 4 }] } as any
store.upsertMessage({
id: "message-1",
sessionId: "session-1",
role: "user",
status: "complete",
clientPromptDisplayMetadata: displayMetadata,
})
store.evictSessionTranscript("session-1")
const restored = createInstanceMessageStore("volatile-eviction")
restored.hydrateMessages("session-1", [{ id: "message-1", sessionId: "session-1", role: "user", status: "complete" }])
assert.deepEqual(restored.getMessage("message-1")?.clientPromptDisplayMetadata, displayMetadata)
store.clearSession("session-1")
const cleared = createInstanceMessageStore("volatile-eviction")
cleared.hydrateMessages("session-1", [{ id: "message-1", sessionId: "session-1", role: "user", status: "complete" }])
assert.equal(cleared.getMessage("message-1")?.clientPromptDisplayMetadata, undefined)
restored.clearInstance()
cleared.clearInstance()
store.clearInstance()
})
it("evicts the least recently used prompt display override after 512 entries", () => {
const store = createInstanceMessageStore("prompt-display-count-cap")
const displayMetadata = { segments: [{ kind: "inline", length: 4 }] } as any
for (let index = 0; index < 512; index += 1) {
store.upsertMessage({
id: `message-${index}`,
sessionId: "session-1",
role: "user",
status: "complete",
clientPromptDisplayMetadata: displayMetadata,
})
}
store.evictSessionTranscript("session-1")
const reader = createInstanceMessageStore("prompt-display-count-cap")
reader.hydrateMessages("session-1", [{ id: "message-0", sessionId: "session-1", role: "user", status: "complete" }])
reader.upsertMessage({
id: "message-512",
sessionId: "session-1",
role: "user",
status: "complete",
clientPromptDisplayMetadata: displayMetadata,
})
reader.evictSessionTranscript("session-1")
const restored = createInstanceMessageStore("prompt-display-count-cap")
restored.hydrateMessages("session-1", [
{ id: "message-0", sessionId: "session-1", role: "user", status: "complete" },
{ id: "message-1", sessionId: "session-1", role: "user", status: "complete" },
{ id: "message-512", sessionId: "session-1", role: "user", status: "complete" },
])
assert.deepEqual(restored.getMessage("message-0")?.clientPromptDisplayMetadata, displayMetadata)
assert.equal(restored.getMessage("message-1")?.clientPromptDisplayMetadata, undefined)
assert.deepEqual(restored.getMessage("message-512")?.clientPromptDisplayMetadata, displayMetadata)
reader.clearInstance()
restored.clearInstance()
store.clearInstance()
})
it("bounds prompt display overrides by aggregate and per-entry bytes", () => {
const store = createInstanceMessageStore("prompt-display-byte-cap")
const oversizedMetadata = { segments: Array.from({ length: 1_000 }, () => ({ kind: "inline", length: 1 })) } as any
store.upsertMessage({
id: "oversized",
sessionId: "session-1",
role: "user",
status: "complete",
clientPromptDisplayMetadata: oversizedMetadata,
})
for (let index = 0; index < 40; index += 1) {
store.upsertMessage({
id: `message-${index}`,
sessionId: "session-1",
role: "user",
status: "complete",
clientPromptDisplayMetadata: {
segments: Array.from({ length: 300 }, () => ({ kind: "inline", length: index + 1 })),
} as any,
})
}
store.evictSessionTranscript("session-1")
const restored = createInstanceMessageStore("prompt-display-byte-cap")
restored.hydrateMessages("session-1", [
{ id: "oversized", sessionId: "session-1", role: "user", status: "complete" },
{ id: "message-0", sessionId: "session-1", role: "user", status: "complete" },
{ id: "message-39", sessionId: "session-1", role: "user", status: "complete" },
])
assert.equal(restored.getMessage("oversized")?.clientPromptDisplayMetadata, undefined)
assert.equal(restored.getMessage("message-0")?.clientPromptDisplayMetadata, undefined)
assert.equal(restored.getMessage("message-39")?.clientPromptDisplayMetadata?.segments.length, 300)
restored.clearInstance()
const cleared = createInstanceMessageStore("prompt-display-byte-cap")
cleared.hydrateMessages("session-1", [{ id: "message-39", sessionId: "session-1", role: "user", status: "complete" }])
assert.equal(cleared.getMessage("message-39")?.clientPromptDisplayMetadata, undefined)
cleared.clearInstance()
store.clearInstance()
})
it("keeps an in-flight pending 'sending' message visible when a force reload snapshot doesn't include it yet", () => {
const store = createInstanceMessageStore("instance-1")
store.addOrUpdateSession({ id: "session-1" })

View file

@ -2,18 +2,13 @@ import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import type { SetStoreFunction } from "solid-js/store"
import { getLogger } from "../../lib/logger"
import {
clearPromptDisplayOverride,
clearPromptDisplayOverridesForInstance,
clearPromptDisplayOverridesForSession,
getPromptDisplayOverride,
movePromptDisplayOverride,
setPromptDisplayOverride,
} from "../message-prompt-display"
import { getCacheRetainedEntriesForSession } from "../../lib/global-cache"
import type { ClientPart, MessageInfo } from "../../types/message"
import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata"
import { mergePermissionRequest } from "../../types/permission"
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import { estimateRetainedBytesIncrementally } from "../../lib/retained-size"
import { clearRecordDisplayCacheForInstance, clearRecordDisplayCacheForMessages, getRecordDisplayCacheEntries } from "./record-display-cache"
import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "../../lib/retained-size"
import { clearInstanceMessageRenderCaches, clearSessionMessageRenderCache, peekSessionMessageRenderCache } from "../../lib/message-render-cache"
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe"
import type {
InstanceMessageState,
@ -37,6 +32,7 @@ const storeLog = getLogger("session")
interface MessageStoreHooks {
onSessionCleared?: (instanceId: string, sessionId: string) => void
onSessionChanged?: (instanceId: string, sessionId: string) => void
onMessagesRemoved?: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void
onScrollSnapshotChanged?: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void
}
@ -81,6 +77,122 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
}
const PENDING_PART_MAX_AGE_MS = 30_000
const PENDING_PARTS_PER_MESSAGE_LIMIT = 100
const PENDING_PARTS_PER_SESSION_LIMIT = 100
const PENDING_PARTS_GLOBAL_LIMIT = 500
const PENDING_PART_MAX_RETAINED_BYTES = 1024 * 1024
const PENDING_PARTS_PER_SESSION_BYTE_LIMIT = 4 * 1024 * 1024
const PENDING_PARTS_GLOBAL_BYTE_LIMIT = 8 * 1024 * 1024
const DROPPED_PENDING_MESSAGE_LIMIT = 500
const MAX_TRANSCRIPT_MEASUREMENT_BYTES = 64 * 1024 * 1024
const MAX_TRANSCRIPT_MEASUREMENT_NODES = 500_000
const pendingPartRetainedBytes = Symbol("pendingPartRetainedBytes")
const pendingPartBudgetId = Symbol("pendingPartBudgetId")
const PROMPT_DISPLAY_OVERRIDE_ENTRY_LIMIT = 512
const PROMPT_DISPLAY_OVERRIDE_BYTE_LIMIT = 1024 * 1024
const PROMPT_DISPLAY_OVERRIDE_ENTRY_BYTE_LIMIT = 64 * 1024
let nextPendingPartBudgetId = 0
const pendingPartBudgetEntries = new Map<number, {
instanceId: string
bytes: number
receivedAt: number
isRetained: () => boolean
remove: () => void
}>()
const promptDisplayOverrides = new Map<string, {
instanceId: string
sessionId: string
messageId: string
metadata: PromptDisplayMetadata
bytes: number
}>()
let promptDisplayOverrideBytes = 0
type SizedPendingPartEntry = PendingPartEntry & { [pendingPartRetainedBytes]?: number; [pendingPartBudgetId]?: number }
function promptDisplayOverrideKey(sessionId: string, messageId: string): string {
return `${sessionId}\u0000${messageId}`
}
function deletePromptDisplayOverride(key: string): void {
const existing = promptDisplayOverrides.get(key)
if (!existing) return
promptDisplayOverrideBytes -= existing.bytes
promptDisplayOverrides.delete(key)
}
function getPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): PromptDisplayMetadata | undefined {
const key = promptDisplayOverrideKey(sessionId, messageId)
const existing = promptDisplayOverrides.get(key)
if (!existing) return undefined
promptDisplayOverrides.delete(key)
promptDisplayOverrides.set(key, { ...existing, instanceId })
return existing.metadata
}
function setPromptDisplayOverride(
instanceId: string,
sessionId: string,
messageId: string,
metadata: PromptDisplayMetadata | undefined,
): void {
const key = promptDisplayOverrideKey(sessionId, messageId)
deletePromptDisplayOverride(key)
if (!metadata) return
const keyBytes = (sessionId.length + messageId.length) * 2 + 64
const bytes = estimateRetainedBytes(metadata, PROMPT_DISPLAY_OVERRIDE_ENTRY_BYTE_LIMIT) + keyBytes
if (bytes > PROMPT_DISPLAY_OVERRIDE_ENTRY_BYTE_LIMIT) return
promptDisplayOverrides.set(key, { instanceId, sessionId, messageId, metadata, bytes })
promptDisplayOverrideBytes += bytes
while (promptDisplayOverrides.size > PROMPT_DISPLAY_OVERRIDE_ENTRY_LIMIT || promptDisplayOverrideBytes > PROMPT_DISPLAY_OVERRIDE_BYTE_LIMIT) {
const oldest = promptDisplayOverrides.keys().next().value
if (oldest === undefined) break
deletePromptDisplayOverride(oldest)
}
}
function movePromptDisplayOverride(instanceId: string, sessionId: string, oldMessageId: string, newMessageId: string): void {
const oldKey = promptDisplayOverrideKey(sessionId, oldMessageId)
const metadata = promptDisplayOverrides.get(oldKey)?.metadata
if (!metadata) return
deletePromptDisplayOverride(oldKey)
setPromptDisplayOverride(instanceId, sessionId, newMessageId, metadata)
}
function clearPromptDisplayOverride(_instanceId: string, sessionId: string, messageId: string): void {
deletePromptDisplayOverride(promptDisplayOverrideKey(sessionId, messageId))
}
function clearPromptDisplayOverridesForSession(_instanceId: string, sessionId: string): void {
for (const [key, entry] of promptDisplayOverrides) if (entry.sessionId === sessionId) deletePromptDisplayOverride(key)
}
function clearPromptDisplayOverridesForInstance(instanceId: string, sessionIds: string[] = []): void {
const sessions = new Set(sessionIds)
for (const [key, entry] of promptDisplayOverrides) {
if (entry.instanceId === instanceId || sessions.has(entry.sessionId)) deletePromptDisplayOverride(key)
}
}
function getPendingPartRetainedBytes(entry: PendingPartEntry): number {
return (entry as SizedPendingPartEntry)[pendingPartRetainedBytes]
?? estimateRetainedBytes(entry, PENDING_PART_MAX_RETAINED_BYTES)
}
function enforceGlobalPendingPartBudget(): void {
for (const [id, entry] of pendingPartBudgetEntries) if (!entry.isRetained()) pendingPartBudgetEntries.delete(id)
let bytes = 0
for (const entry of pendingPartBudgetEntries.values()) bytes += entry.bytes
if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) return
const oldest = [...pendingPartBudgetEntries.entries()].sort((left, right) =>
left[1].receivedAt - right[1].receivedAt || left[0] - right[0])
for (const [id, entry] of oldest) {
if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break
pendingPartBudgetEntries.delete(id)
bytes -= entry.bytes
entry.remove()
}
}
function clonePart(part: ClientPart): ClientPart {
// Cloning is intentionally disabled; message parts
@ -265,6 +377,7 @@ export interface InstanceMessageStore {
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
estimateSessionRetainedBytes: (sessionId: string, signal?: AbortSignal) => Promise<number>
hasLiveSessionMessages: (sessionId: string) => boolean
evictSessionTranscript: (sessionId: string) => void
clearSession: (sessionId: string, options?: { preserveScroll?: boolean }) => void
clearScrollSnapshots: () => void
clearInstance: () => void
@ -279,6 +392,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
// Requests awaiting same-ID persistence confirmation.
const pendingSendIds = new Set<string>()
const droppedPendingMessageIds = new Set<string>()
const optimisticPartIdsByMessage = new Map<string, Set<string>>()
function forgetPendingSend(messageId: string): void {
@ -427,22 +541,50 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function estimateSessionRetainedBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
const session = state.sessions[sessionId]
if (!session) return Promise.resolve(0)
const messages = session.messageIds.map((id) => state.messages[id]).filter(Boolean)
const messageIds = new Set(session.messageIds)
return estimateRetainedBytesIncrementally({
session,
messages,
messageInfos: session.messageIds.map((id) => messageInfoCache.get(id)).filter(Boolean),
messageInfoVersions: session.messageIds.map((id) => state.messageInfoVersion[id]),
pendingParts: session.messageIds.map((id) => state.pendingParts[id]).filter(Boolean),
usage: state.usage[sessionId],
revision: state.sessionRevisions[sessionId],
lastAssistantMessageId: state.lastAssistantMessageIds[sessionId],
latestTodo: state.latestTodos[sessionId],
permissions: state.permissions.queue.filter((value) => value.messageId && messageIds.has(value.messageId)),
questions: state.questions.queue.filter((value) => value.messageId && messageIds.has(value.messageId)),
}, { signal })
const renderCache = peekSessionMessageRenderCache(instanceId, sessionId)
const globalCacheEntries = [...getCacheRetainedEntriesForSession(instanceId, sessionId)]
const globalCacheKeyBytes = globalCacheEntries.reduce((total, entry) => total + entry.keyBytes, 0)
let hasPendingParts = false
for (const messageId in state.pendingParts) {
if (state.pendingParts[messageId]?.some((entry) => entry.sessionId === sessionId)) {
hasPendingParts = true
break
}
}
if ((!session || (session.messageIds.length === 0 && !session.revert)) && !renderCache && !hasPendingParts && globalCacheEntries.length === 0) {
return Promise.resolve(0)
}
function* retainedValues(): Generator<unknown> {
if (session) yield session
for (const messageId of session?.messageIds ?? []) {
yield state.messages[messageId]
yield messageInfoCache.get(messageId)
yield state.messageInfoVersion[messageId]
yield state.pendingParts[messageId]
}
for (const messageId in state.pendingParts) {
const entries = state.pendingParts[messageId]
if (entries?.some((entry) => entry.sessionId === sessionId) && !state.messages[messageId]) yield entries
}
for (const entry of state.permissions.queue) if (entry.permission.sessionID === sessionId) yield entry
for (const entry of state.questions.queue) if (entry.request.sessionID === sessionId) yield entry
if (session) {
yield state.usage[sessionId]
yield state.sessionRevisions[sessionId]
yield state.lastAssistantMessageIds[sessionId]
yield state.latestTodos[sessionId]
}
yield renderCache
if (session) yield* getRecordDisplayCacheEntries(instanceId, session.messageIds)
for (const entry of globalCacheEntries) yield entry.value
}
if (globalCacheKeyBytes > MAX_TRANSCRIPT_MEASUREMENT_BYTES) return Promise.resolve(Number.POSITIVE_INFINITY)
return estimateRetainedBytesIncrementally(retainedValues(), {
signal,
rootIterable: true,
maxBytes: MAX_TRANSCRIPT_MEASUREMENT_BYTES - globalCacheKeyBytes,
maxNodes: MAX_TRANSCRIPT_MEASUREMENT_NODES,
}).then((bytes) => Number.isFinite(bytes) ? bytes + globalCacheKeyBytes : bytes)
}
function ensureSessionEntry(sessionId: string): SessionRecord {
@ -536,6 +678,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
seenInputIds.add(input.id)
return true
})
for (const input of dedupedInputs) droppedPendingMessageIds.delete(input.id)
const serverIds = dedupedInputs.map((item) => item.id)
const serverIdSet = new Set(serverIds)
@ -622,6 +765,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
delete nextPermissionsByMessage[id]
})
clearRecordDisplayCacheForMessages(instanceId, omittedIds)
hooks?.onMessagesRemoved?.(instanceId, sessionId, omittedIds)
}
// A send that reappears under its own id is confirmed — it is no longer in
@ -839,6 +983,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
clearPromptDisplayOverride(instanceId, sessionId, id)
})
clearRecordDisplayCacheForMessages(instanceId, droppedIds)
hooks?.onMessagesRemoved?.(instanceId, sessionId, droppedIds)
batch(() => {
setState(
@ -931,6 +1076,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
function upsertMessage(input: MessageUpsertInput) {
const pendingDropRequiresReload = droppedPendingMessageIds.delete(input.id)
const normalizedParts = normalizeParts(input.id, input.parts)
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now()
@ -974,21 +1120,103 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
flushPendingParts(input.id)
recomputeLastAssistantMessageId(input.sessionId)
bumpSessionRevision(input.sessionId)
if (pendingDropRequiresReload) hooks?.onSessionCleared?.(instanceId, input.sessionId)
}
function markPendingPartDropped(messageId: string, sessionId?: string): void {
if (sessionId) {
hooks?.onSessionCleared?.(instanceId, sessionId)
hooks?.onSessionChanged?.(instanceId, sessionId)
return
}
droppedPendingMessageIds.delete(messageId)
droppedPendingMessageIds.add(messageId)
while (droppedPendingMessageIds.size > DROPPED_PENDING_MESSAGE_LIMIT) {
const oldest = droppedPendingMessageIds.values().next().value
if (oldest === undefined) break
droppedPendingMessageIds.delete(oldest)
}
}
function bufferPendingPart(entry: PendingPartEntry) {
setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
const sessionId = entry.sessionId ?? (typeof (entry.part as any).sessionID === "string" ? (entry.part as any).sessionID : undefined)
const nextEntry = { ...entry, sessionId } as SizedPendingPartEntry
const retainedBytes = estimateRetainedBytes(nextEntry, PENDING_PART_MAX_RETAINED_BYTES)
if (retainedBytes > PENDING_PART_MAX_RETAINED_BYTES) {
markPendingPartDropped(entry.messageId, sessionId)
return
}
const budgetId = ++nextPendingPartBudgetId
Object.defineProperty(nextEntry, pendingPartRetainedBytes, { value: retainedBytes })
Object.defineProperty(nextEntry, pendingPartBudgetId, { value: budgetId })
const changedSessions = new Set<string>()
const droppedMessages = new Map<string, string | undefined>()
if (sessionId) changedSessions.add(sessionId)
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
const messageEntries = [...(draft[entry.messageId] ?? []), nextEntry]
for (const dropped of messageEntries.slice(0, Math.max(0, messageEntries.length - PENDING_PARTS_PER_MESSAGE_LIMIT))) {
droppedMessages.set(entry.messageId, dropped.sessionId)
}
draft[entry.messageId] = messageEntries.slice(-PENDING_PARTS_PER_MESSAGE_LIMIT)
const pending: { messageId: string; entry: PendingPartEntry; bytes: number }[] = []
for (const messageId in draft) {
for (const value of draft[messageId] ?? []) pending.push({ messageId, entry: value, bytes: getPendingPartRetainedBytes(value) })
}
pending.sort((left, right) => left.entry.receivedAt - right.entry.receivedAt)
const sessionEntries = pending.filter((value) => value.entry.sessionId === sessionId)
const remove = new Set(pending.slice(0, Math.max(0, pending.length - PENDING_PARTS_GLOBAL_LIMIT)))
for (const value of sessionEntries.slice(0, Math.max(0, sessionEntries.length - PENDING_PARTS_PER_SESSION_LIMIT))) remove.add(value)
let sessionBytes = sessionEntries.reduce((total, value) => total + value.bytes, 0)
for (const value of sessionEntries) {
if (sessionBytes <= PENDING_PARTS_PER_SESSION_BYTE_LIMIT) break
remove.add(value)
sessionBytes -= value.bytes
}
let globalBytes = pending.reduce((total, value) => total + value.bytes, 0)
for (const value of pending) {
if (globalBytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break
remove.add(value)
globalBytes -= value.bytes
}
for (const value of remove) {
droppedMessages.set(value.messageId, value.entry.sessionId)
if (value.entry.sessionId) changedSessions.add(value.entry.sessionId)
const list = draft[value.messageId]
const index = list?.indexOf(value.entry) ?? -1
if (index >= 0) list.splice(index, 1)
if (list?.length === 0) delete draft[value.messageId]
}
}))
const isRetained = () => state.pendingParts[entry.messageId]?.some((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? false
if (isRetained()) {
pendingPartBudgetEntries.set(budgetId, {
instanceId,
bytes: retainedBytes,
receivedAt: entry.receivedAt,
isRetained,
remove: () => {
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
const list = draft[entry.messageId]
const index = list?.findIndex((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? -1
if (index >= 0) list.splice(index, 1)
if (list?.length === 0) delete draft[entry.messageId]
}))
markPendingPartDropped(entry.messageId, sessionId)
},
})
enforceGlobalPendingPartBudget()
}
for (const [messageId, droppedSessionId] of droppedMessages) markPendingPartDropped(messageId, droppedSessionId)
for (const changedSessionId of changedSessions) hooks?.onSessionChanged?.(instanceId, changedSessionId)
}
function clearPendingPartsForMessage(messageId: string) {
setState("pendingParts", (prev) => {
if (!prev[messageId]) {
return prev
}
const next = { ...prev }
delete next[messageId]
return next
})
if (!state.pendingParts[messageId]) return
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
delete draft[messageId]
}))
}
function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) {
@ -1040,7 +1268,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function applyPartUpdate(input: PartUpdateInput) {
const message = state.messages[input.messageId]
if (!message) {
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
bufferPendingPart({ messageId: input.messageId, sessionId: typeof (input.part as any).sessionID === "string" ? (input.part as any).sessionID : undefined, part: input.part, receivedAt: Date.now() })
return
}
@ -1153,6 +1381,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
if (!sessionIds.size && fallbackSessionId) sessionIds.add(fallbackSessionId)
clearRecordDisplayCacheForMessages(instanceId, [messageId])
sessionIds.forEach((sessionId) => hooks?.onMessagesRemoved?.(instanceId, sessionId, [messageId]))
batch(() => {
sessionIds.forEach((sessionId) => {
@ -1566,6 +1795,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
removedIds.forEach((id) => messageInfoCache.delete(id))
clearRecordDisplayCacheForMessages(instanceId, removedIds)
hooks?.onMessagesRemoved?.(instanceId, sessionId, removedIds)
setState("pendingParts", (prev) => {
const next = { ...prev }
@ -1575,13 +1806,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next
})
const removed = new Set(removedIds)
setState("permissions", produce((draft) => {
removedIds.forEach((id) => delete draft.byMessage[id])
draft.queue = draft.queue.filter((entry) => !entry.messageId || !removed.has(entry.messageId))
draft.active = draft.queue[0] ?? null
}))
setState("permissions", "byMessage", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
const removed = new Set(removedIds)
setState("questions", produce((draft) => {
removedIds.forEach((id) => delete draft.byMessage[id])
draft.queue = draft.queue.filter((entry) => !entry.messageId || !removed.has(entry.messageId))
@ -1593,7 +1826,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
recomputeLastAssistantMessageId(sessionId, keptIds)
bumpSessionRevision(sessionId)
}
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
@ -1603,6 +1835,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
pruneMessagesAfterRevert(sessionId, revert.messageID)
}
setState("sessions", sessionId, "revert", revert ?? null)
bumpSessionRevision(sessionId)
}
function getSessionRevert(sessionId: string) {
@ -1630,10 +1863,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return state.scrollState[key]
}
function clearSession(sessionId: string, options?: { preserveScroll?: boolean }) {
function clearSession(sessionId: string, options?: { preserveScroll?: boolean; preservePromptDisplayOverrides?: boolean }) {
if (!sessionId) return
clearPromptDisplayOverridesForSession(instanceId, sessionId)
if (!options?.preservePromptDisplayOverrides) clearPromptDisplayOverridesForSession(instanceId, sessionId)
const messageIds = Object.values(state.messages)
.filter((record) => record.sessionId === sessionId)
@ -1641,6 +1874,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length })
clearRecordDisplayCacheForMessages(instanceId, messageIds)
clearSessionMessageRenderCache(instanceId, sessionId)
messageIds.forEach((id) => forgetPendingSend(id))
batch(() => {
@ -1658,13 +1892,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageIds.forEach((id) => messageInfoCache.delete(id))
setState("pendingParts", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
setState("pendingParts", produce((draft: Record<string, PendingPartEntry[]>) => {
for (const id of messageIds) delete draft[id]
for (const id in draft) if (draft[id]?.some((entry) => entry.sessionId === sessionId)) delete draft[id]
}))
setState("permissions", "byMessage", (prev) => {
const next = { ...prev }
@ -1732,9 +1963,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
hooks?.onSessionCleared?.(instanceId, sessionId)
}
function evictSessionTranscript(sessionId: string) {
clearSession(sessionId, { preserveScroll: true, preservePromptDisplayOverrides: true })
}
function clearInstance() {
for (const [id, entry] of pendingPartBudgetEntries) if (entry.instanceId === instanceId) pendingPartBudgetEntries.delete(id)
droppedPendingMessageIds.clear()
clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions))
clearRecordDisplayCacheForInstance(instanceId)
clearInstanceMessageRenderCaches(instanceId)
messageInfoCache.clear()
pendingSendIds.clear()
optimisticPartIdsByMessage.clear()
@ -1791,6 +2030,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
estimateSessionRetainedBytes,
hasLiveSessionMessages,
evictSessionTranscript,
clearSession,
clearScrollSnapshots,
clearInstance,

View file

@ -1,10 +1,12 @@
import type { ClientPart } from "../../types/message"
import { extractReasoningTextForRender } from "../../lib/message-render-cache"
import type { MessageRecord } from "./types"
type ClientPartWithRevision = ClientPart & { revision?: number }
export interface RecordDisplayData {
orderedParts: ClientPartWithRevision[]
truncated: boolean
}
interface RecordDisplayCacheEntry {
@ -13,6 +15,7 @@ interface RecordDisplayCacheEntry {
}
const recordDisplayCache = new Map<string, RecordDisplayCacheEntry>()
export const MESSAGE_PART_DISPLAY_LIMIT = 200
function makeCacheKey(instanceId: string, messageId: string) {
return `${instanceId}:${messageId}`
@ -27,13 +30,26 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord
const orderedParts: ClientPartWithRevision[] = []
for (const partId of record.partIds) {
for (let index = 0; index < record.partIds.length && index < MESSAGE_PART_DISPLAY_LIMIT; index += 1) {
const partId = record.partIds[index]
const entry = record.parts[partId]
if (!entry?.data) continue
orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision })
const part = entry.data as ClientPart
if (part.type === "reasoning") {
const time = (part as any).time
orderedParts.push({
id: part.id,
type: "reasoning",
text: extractReasoningTextForRender(part),
time: time ? { start: time.start, end: time.end, created: time.created } : undefined,
revision: entry.revision,
} as ClientPartWithRevision)
continue
}
orderedParts.push({ ...part, revision: entry.revision })
}
const data: RecordDisplayData = { orderedParts }
const data: RecordDisplayData = { orderedParts, truncated: record.partIds.length > MESSAGE_PART_DISPLAY_LIMIT }
recordDisplayCache.set(cacheKey, { revision: record.revision, data })
return data
}
@ -53,3 +69,10 @@ export function clearRecordDisplayCacheForMessages(instanceId: string, messageId
recordDisplayCache.delete(makeCacheKey(instanceId, messageId))
}
}
export function* getRecordDisplayCacheEntries(instanceId: string, messageIds: Iterable<string>): Generator<unknown> {
for (const messageId of messageIds) {
const entry = recordDisplayCache.get(makeCacheKey(instanceId, messageId))
if (entry) yield entry
}
}

View file

@ -45,6 +45,7 @@ export interface SessionRecord {
export interface PendingPartEntry {
messageId: string
sessionId?: string
part: ClientPart
receivedAt: number
}

View file

@ -4,7 +4,7 @@ import {
type Session,
} from "../types/session"
import type { Message } from "../types/message"
import type { SessionInfo as SDKSession, SessionMessagesResponse, SessionsResponse } from "@opencode-ai/client"
import type { SessionInfo as SDKSession, SessionsResponse } from "@opencode-ai/client"
import { instances, reconcilePendingSessionIndicators } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
@ -58,9 +58,10 @@ import { normalizeSessionMessage } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { getOpencodeErrorMessage } from "../lib/opencode-api"
import { getRootClient } from "./opencode-client"
import { getRootClient, type OpenCodeClient } from "./opencode-client"
import { tGlobal } from "../lib/i18n"
import {
getWorktrees,
@ -73,10 +74,16 @@ import {
isProjectSessionListComplete,
} from "./session-list-options"
import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery"
import { listAllSessionMessages } from "./session-message-pages"
const log = getLogger("api")
const sessionListRequestIds = new Map<string, number>()
let nextSessionListRequestId = 0
const MAX_MESSAGE_REVISION_RETRIES = 1
function hasInstanceClientAuthority(instanceId: string, client: OpenCodeClient): boolean {
return instances().get(instanceId)?.client === client
}
function beginSessionListRequest(instanceId: string): number {
const requestId = ++nextSessionListRequestId
@ -154,8 +161,6 @@ async function hydrateRestoredSessionChain(
requestedIds: Array<string | null | undefined>,
signal?: AbortSignal,
): Promise<void> {
const instanceClient = instances().get(instanceId)?.client
if (!instanceClient) return
const client = getRootClient(instanceId)
const pending = requestedIds.filter((id): id is string => Boolean(id) && id !== "info")
const visited = new Set<string>()
@ -170,13 +175,10 @@ async function hydrateRestoredSessionChain(
if (!session) {
try {
signal?.throwIfAborted()
const apiSession = await client.session.get({ sessionID: sessionId }, { signal })
const apiSession = await client.session.get({ sessionID: sessionId })
signal?.throwIfAborted()
if (instances().get(instanceId)?.client !== instanceClient) return
setSessions((prev) => {
if (instances().get(instanceId)?.client !== instanceClient
|| getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)
|| signal?.aborted) return prev
if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId) || signal?.aborted) return prev
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
instanceSessions.set(sessionId, toClientSessionV2(instanceId, apiSession, instanceSessions.get(sessionId)))
@ -196,8 +198,6 @@ async function hydrateRestoredSessionChain(
}
async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise<void> {
const instanceClient = instances().get(instanceId)?.client
if (!instanceClient) return
const currentSessions = sessions().get(instanceId) ?? new Map<string, Session>()
const loaded = new Map<string, SDKSession | Session>(currentSessions)
for (const session of apiSessions) loaded.set(session.id, session)
@ -205,12 +205,10 @@ async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSe
if (!apiSessions.some((session) => hasMissingParentChain(session, loaded))) return
const page = await fetchV2Sessions(instanceId, { directory })
if (instances().get(instanceId)?.client !== instanceClient) return
const items = getV2SessionItems(page)
if (items.length === 0) return
setSessions((prev) => {
if (instances().get(instanceId)?.client !== instanceClient) return prev
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId)
@ -238,7 +236,6 @@ async function fetchSessions(instanceId: string, options?: {
}
const requestId = beginSessionListRequest(instanceId)
const instanceClient = instance.client
options?.registerInvalidation?.(() => {
if (isLatestSessionListRequest(instanceId, requestId)) clearSessionListRequestState(instanceId)
})
@ -262,7 +259,7 @@ async function fetchSessions(instanceId: string, options?: {
return null
}),
])
if (!isLatestSessionListRequest(instanceId, requestId) || instances().get(instanceId)?.client !== instanceClient) {
if (!isLatestSessionListRequest(instanceId, requestId)) {
if (options?.strictStatus) throw new Error("Foreground session refresh was superseded")
return
}
@ -299,7 +296,6 @@ async function fetchSessions(instanceId: string, options?: {
for (const sessionId of remotelyDeletedSessionIds) removeSessionRuntimeState(instanceId, sessionId)
setSessions((prev) => {
if (instances().get(instanceId)?.client !== instanceClient) return prev
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId)
@ -389,7 +385,6 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
}
const requestId = beginSessionSearch(instanceId, trimmedQuery)
const instanceClient = instance.client
try {
log.info("v2.session.search", { instanceId, query: trimmedQuery, directory: instance.folder })
@ -397,8 +392,7 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
search: trimmedQuery,
directory: instance.folder,
})
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)
|| instances().get(instanceId)?.client !== instanceClient) return
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return
const searchResults = getV2SessionItems(response)
@ -408,7 +402,6 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
}
setSessions((prev) => {
if (instances().get(instanceId)?.client !== instanceClient) return prev
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId)
@ -424,8 +417,7 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
})
await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder)
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)
|| instances().get(instanceId)?.client !== instanceClient) return
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return
const hydratedSessions = sessions().get(instanceId)
const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId)
@ -489,6 +481,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceClient = instance.client
const activeId = activeSessionId().get(instanceId)
const activeLocation = activeId && activeId !== "info"
@ -501,9 +494,11 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const selectedAgent = agent || (primaryAgents.length > 0 ? primaryAgents[0].name : "")
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready")
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready")
}
setLoading((prev) => {
@ -521,6 +516,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
: null,
location: activeLocation ?? { directory: instance.folder },
})
if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready")
const session = toClientSessionV2(instanceId, info)
session.agent = selectedAgent
session.model = defaultModel
@ -568,6 +564,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
if (preferences().autoCleanupBlankSessions) {
await cleanupBlankSessions(instanceId, session.id)
if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready")
}
return session
@ -575,11 +572,13 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
log.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
if (hasInstanceClientAuthority(instanceId, instanceClient)) {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
}
@ -592,6 +591,7 @@ async function forkSession(
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceClient = instance.client
const client = getRootClient(instanceId)
@ -604,6 +604,7 @@ async function forkSession(
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const info = await client.session.fork(request)
if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready")
const forkedSession = toClientSessionV2(instanceId, info)
setSessions((prev) => {
@ -652,6 +653,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceClient = instance.client
const client = getRootClient(instanceId)
@ -666,6 +668,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
try {
log.info(`[HTTP] DELETE /session.remove for instance ${instanceId}`, { sessionId })
await client.session.remove({ sessionID: sessionId })
if (!hasInstanceClientAuthority(instanceId, instanceClient)) return
removeSessionRuntimeState(instanceId, sessionId)
@ -673,14 +676,16 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
log.error("Failed to delete session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
if (hasInstanceClientAuthority(instanceId, instanceClient)) {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
}
}
}
@ -708,6 +713,7 @@ function removeSessionRuntimeState(instanceId: string, sessionId: string): void
// Drop normalized message state and caches for this session.
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
clearCacheForSession(instanceId, sessionId)
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
@ -742,14 +748,14 @@ async function fetchAgents(instanceId: string): Promise<void> {
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceClient = instance.client
const rootClient = getRootClient(instanceId)
const instanceClient = instance.client
try {
log.info(`[HTTP] GET /agent.list for instance ${instanceId}`)
const response = await rootClient.agent.list({ location: { directory: instance.folder } })
if (instances().get(instanceId)?.client !== instanceClient) return
if (!hasInstanceClientAuthority(instanceId, instanceClient)) return
const agentList = (response.data ?? []).map((agent) => ({
name: agent.name,
description: agent.description || "",
@ -778,15 +784,16 @@ async function fetchProviders(instanceId: string): Promise<void> {
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceClient = instance.client
const rootClient = getRootClient(instanceId)
const instanceClient = instance.client
try {
log.info(`[HTTP] GET /provider.list for instance ${instanceId}`)
const response = await rootClient.provider.list({ location: { directory: instance.folder } })
if (!hasInstanceClientAuthority(instanceId, instanceClient)) return
const models = await rootClient.model.list({ location: { directory: instance.folder } })
if (instances().get(instanceId)?.client !== instanceClient) return
if (!hasInstanceClientAuthority(instanceId, instanceClient)) return
const providerList = response.data.map((provider) => ({
id: provider.id,
name: provider.name,
@ -816,6 +823,7 @@ async function loadMessages(
options?: {
force?: boolean
registerInvalidation?: (invalidate: () => void) => void
revisionRetryCount?: number
},
): Promise<void> {
const force = options?.force ?? false
@ -852,7 +860,6 @@ async function loadMessages(
}
const client = getRootClient(instanceId)
const instanceClient = instance.client
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
@ -862,10 +869,8 @@ async function loadMessages(
const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId)
const signal = getMessageLoadSignal(instanceId, sessionId)
const isCurrentLoad = () => isCurrentMessageLoad(instanceId, sessionId, loadEpoch)
&& instances().get(instanceId)?.client === instanceClient
options?.registerInvalidation?.(() => {
if (isCurrentLoad()) invalidateSessionMessageLoad(instanceId, sessionId)
if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) invalidateSessionMessageLoad(instanceId, sessionId)
})
const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId)
let retryAfterRevisionConflict = false
@ -881,10 +886,14 @@ async function loadMessages(
try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const response: SessionMessagesResponse = await client.message.list({ sessionID: sessionId }, { signal })
const apiMessages = response.data
const apiMessages = await listAllSessionMessages(
client,
sessionId,
signal,
() => isCurrentMessageLoad(instanceId, sessionId, loadEpoch),
)
if (!isCurrentLoad() || !sessions().get(instanceId)?.has(sessionId)) return
if (!apiMessages || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return
if (!Array.isArray(apiMessages)) {
return
@ -950,14 +959,14 @@ async function loadMessages(
if (!agentName && !providerID && !modelID) {
const defaultModel = await getDefaultModel(instanceId, session.agent)
if (!isCurrentLoad() || !sessions().get(instanceId)?.has(sessionId)) return
if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return
agentName = session.agent
providerID = defaultModel.providerId
modelID = defaultModel.modelId
}
setSessions((prev) => {
if (!isCurrentLoad()) return prev
if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev
const next = new Map(prev)
const nextInstanceSessions = next.get(instanceId)
if (!nextInstanceSessions) return next
@ -975,7 +984,7 @@ async function loadMessages(
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert,
}
if (!isCurrentLoad()) return
if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return
if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) {
retryAfterRevisionConflict = true
} else {
@ -994,9 +1003,9 @@ async function loadMessages(
} catch (error) {
if (signal?.aborted || !isCurrentLoad()) return
if (signal?.aborted || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return
log.error("Failed to load messages:", error)
if (isCurrentLoad()) {
if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) {
setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail")))
}
throw error
@ -1012,16 +1021,25 @@ async function loadMessages(
}
}
if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) {
const revisionRetryCount = options?.revisionRetryCount ?? 0
if (retryAfterRevisionConflict
&& revisionRetryCount < MAX_MESSAGE_REVISION_RETRIES
&& sessions().get(instanceId)?.has(sessionId)) {
await new Promise((resolve) => setTimeout(resolve, 50))
if (!isCurrentLoad()) return
if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return
return loadMessages(instanceId, sessionId, {
force: true,
registerInvalidation: options?.registerInvalidation,
revisionRetryCount: revisionRetryCount + 1,
})
}
if (!isCurrentLoad() || !sessions().get(instanceId)?.has(sessionId)) return
if (retryAfterRevisionConflict) {
setSessionMessagesLoadError(instanceId, sessionId, tGlobal("messageSection.loadError.detail"))
return
}
if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return
updateSessionInfo(instanceId, sessionId)
}

View file

@ -29,6 +29,7 @@ import { getLogger } from "../lib/logger"
import type { EventSessionDeleted, NativeSessionEvent } from "../lib/sse-manager"
import {
enqueueDelta,
clearPendingDeltasForInstance,
clearPendingDeltasForPart,
flushPendingDeltasForMessage,
setFlushCallback,
@ -103,6 +104,7 @@ const nativeRefreshes = new Map<string, {
speakAfter: boolean
timer?: ReturnType<typeof setTimeout>
running?: Promise<void>
cancelled: boolean
}>()
let activeRetryToast: ToastHandle | null = null
@ -118,8 +120,9 @@ function speakCompletedAssistantText(instanceId: string, sessionId: string): voi
}
function requestNativeSessionRefresh(instanceId: string, sessionId: string, final = false): void {
if (!instances().has(instanceId)) return
const key = `${instanceId}:${sessionId}`
const refresh = nativeRefreshes.get(key) ?? { instanceId, sessionId, pending: false, speakAfter: false }
const refresh = nativeRefreshes.get(key) ?? { instanceId, sessionId, pending: false, speakAfter: false, cancelled: false }
refresh.pending = true
refresh.speakAfter ||= final
if (refresh.timer) clearTimeout(refresh.timer)
@ -129,15 +132,16 @@ function requestNativeSessionRefresh(instanceId: string, sessionId: string, fina
if (refresh.running) return refresh.running
refresh.running = (async () => {
do {
if (refresh.cancelled || !instances().has(instanceId)) return
refresh.pending = false
try {
await loadMessages(refresh.instanceId, refresh.sessionId, { force: true })
} catch (error) {
log.error("Failed to refresh native session messages", { instanceId, sessionId, error })
}
} while (refresh.pending)
} while (refresh.pending && !refresh.cancelled)
if (refresh.speakAfter) {
if (refresh.speakAfter && !refresh.cancelled && instances().has(instanceId)) {
refresh.speakAfter = false
speakCompletedAssistantText(refresh.instanceId, refresh.sessionId)
}
@ -162,11 +166,13 @@ function requestNativeSessionRefresh(instanceId: string, sessionId: string, fina
function clearNativeSessionRefresh(instanceId: string, sessionId: string): void {
const refresh = nativeRefreshes.get(`${instanceId}:${sessionId}`)
if (refresh) refresh.cancelled = true
if (refresh?.timer) clearTimeout(refresh.timer)
nativeRefreshes.delete(`${instanceId}:${sessionId}`)
}
function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent): void {
if (!instances().has(instanceId)) return
const sessionId = event.data?.sessionID
if (!sessionId) return
@ -334,9 +340,11 @@ function ensureSessionStatus(
}
messageStoreBus.onInstanceDestroyed((instanceId) => {
clearPendingDeltasForInstance(instanceId)
const prefix = `${instanceId}:`
for (const [key, refresh] of nativeRefreshes) {
if (!key.startsWith(prefix)) continue
refresh.cancelled = true
if (refresh.timer) clearTimeout(refresh.timer)
nativeRefreshes.delete(key)
}
@ -350,6 +358,7 @@ function resolveMessageRole(info?: MessageInfo | null): "user" | "assistant" {
}
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
if (!instances().has(instanceId)) return
const instanceSessions = sessions().get(instanceId)
if (event.type === "message.part.updated") {
@ -474,11 +483,13 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
// Delta buffer callback setup
setFlushCallback((batch) => {
for (const { instanceId, messageId, partId, field, delta } of batch) {
if (!instances().has(instanceId)) continue
applyPartDeltaV2(instanceId, { messageId, partId, field, delta })
}
})
function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void {
if (!instances().has(instanceId)) return
const props = event.properties
if (!props) return
const { messageID, partID, field, delta } = props
@ -490,6 +501,7 @@ function handleSessionUpdate(
instanceId: string,
event: SessionCreated | SessionRevertStaged | SessionRevertCleared | SessionRevertCommitted,
): void {
if (!instances().has(instanceId)) return
if (event.type !== "session.created") {
const revert = event.type === "session.revert.staged" ? event.data.revert : null
setSessionRevertV2(instanceId, event.data.sessionID, revert)
@ -584,6 +596,7 @@ function handleSessionUpdate(
}
function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): void {
if (!instances().has(instanceId)) return
const sessionId = event.data?.sessionID ?? event.properties?.info?.id ?? event.properties?.sessionID ?? event.properties?.id
if (!sessionId) return
@ -593,6 +606,7 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v
}
function handleSessionIdle(instanceId: string, event: SessionIdle): void {
if (!instances().has(instanceId)) return
const sessionId = event.data.sessionID
if (!sessionId) return
@ -609,6 +623,7 @@ function handleSessionIdle(instanceId: string, event: SessionIdle): void {
}
function handleSessionStatus(instanceId: string, event: SessionStatus2): void {
if (!instances().has(instanceId)) return
const sessionId = event.data.sessionID
if (!sessionId) return
@ -639,6 +654,7 @@ function handleSessionStatus(instanceId: string, event: SessionStatus2): void {
}
function handleSessionCompacted(instanceId: string, event: SessionCompactionEnded): void {
if (!instances().has(instanceId)) return
const sessionID = event.data.sessionID
if (!sessionID) return
@ -666,6 +682,7 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
}
function handleSessionError(instanceId: string, event: SessionExecutionFailed): void {
if (!instances().has(instanceId)) return
const error = event.data.error
const sessionId = event.data.sessionID
if (sessionId) messageStoreBus.getOrCreate(instanceId).failPendingSends(sessionId)
@ -688,6 +705,7 @@ function handleSessionError(instanceId: string, event: SessionExecutionFailed):
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
if (!instances().has(instanceId)) return
const { sessionID, messageID } = event.properties
if (!sessionID || !messageID) return
@ -697,6 +715,7 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v
}
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
if (!instances().has(instanceId)) return
const { sessionID, messageID, partID } = event.properties
if (!sessionID || !messageID || !partID) return
@ -723,6 +742,7 @@ function handleTuiToast(_instanceId: string, event: TuiToastShow): void {
}
function handlePermissionUpdated(instanceId: string, event: PermissionAsked): void {
if (!instances().has(instanceId)) return
const permission = event.data as PermissionRequest
if (!permission) return
const permissionId = getPermissionId(permission)
@ -749,6 +769,7 @@ function handlePermissionUpdated(instanceId: string, event: PermissionAsked): vo
}
function handlePermissionReplied(instanceId: string, event: PermissionReplied): void {
if (!instances().has(instanceId)) return
const requestId = getRequestIdFromPermissionReply(event.data)
if (!requestId) return
@ -759,6 +780,7 @@ function handlePermissionReplied(instanceId: string, event: PermissionReplied):
}
function handleQuestionAsked(instanceId: string, event: QuestionAsked): void {
if (!instances().has(instanceId)) return
const request = event.data as QuestionRequest
if (!request) return
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
@ -779,6 +801,7 @@ function handleQuestionAnswered(
instanceId: string,
event: QuestionReplied | QuestionRejected,
): void {
if (!instances().has(instanceId)) return
const requestId = getRequestIdFromQuestionReply(event.data)
if (!requestId) return

View file

@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { OpenCodeClient } from "./opencode-client.ts"
import { listAllSessionMessages } from "./session-message-pages.ts"
test("loads every cursor page", async () => {
const requests: unknown[] = []
const responses = [
{ data: [{ id: "one" }], cursor: { next: "next-page" } },
{ data: [{ id: "two" }] },
]
const client = {
message: {
list: async (request: unknown) => {
requests.push(request)
return responses.shift()!
},
},
} as unknown as OpenCodeClient
assert.deepEqual(await listAllSessionMessages(client, "session"), [{ id: "one" }, { id: "two" }])
assert.deepEqual(requests, [
{ sessionID: "session", limit: 200, order: "asc" },
{ sessionID: "session", limit: 200, cursor: "next-page" },
])
})
test("rejects the message-count ceiling before appending a page", async () => {
const data = new Array(100_001).fill({ id: "message" })
Object.defineProperty(data, Symbol.iterator, {
value: () => { throw new Error("page was appended") },
})
const client = {
message: { list: async () => ({ data }) },
} as unknown as OpenCodeClient
await assert.rejects(listAllSessionMessages(client, "large-count"), /exceeded 100000 messages/)
})
test("rejects the retained-byte ceiling without serializing the payload", async () => {
const data = [{
id: "large-message",
payload: new Uint8Array(64 * 1024 * 1024 + 1),
toJSON: () => { throw new Error("payload was serialized") },
}]
const client = {
message: { list: async () => ({ data }) },
} as unknown as OpenCodeClient
await assert.rejects(listAllSessionMessages(client, "large-bytes"), /exceeded 64 MiB/)
})

View file

@ -0,0 +1,51 @@
import type { SessionMessageInfo as SDKMessage } from "@opencode-ai/client"
import { estimateRetainedBytes } from "../lib/retained-size"
import type { OpenCodeClient } from "./opencode-client"
const PAGE_LIMIT = 200
const MAX_PAGES = 10_000
const MAX_MESSAGES = 100_000
const MAX_RETAINED_BYTES = 64 * 1024 * 1024
async function listAllSessionMessages(
client: OpenCodeClient,
sessionId: string,
signal?: AbortSignal,
isAuthoritative: () => boolean = () => true,
): Promise<SDKMessage[] | null> {
const messages: SDKMessage[] = []
const seenCursors = new Set<string>()
let retainedBytes = 0
let cursor: string | undefined
for (let page = 0; page < MAX_PAGES; page += 1) {
signal?.throwIfAborted()
if (!isAuthoritative()) return null
const response = await client.message.list(cursor
? { sessionID: sessionId, limit: PAGE_LIMIT, cursor }
: { sessionID: sessionId, limit: PAGE_LIMIT, order: "asc" }, { signal })
if (!isAuthoritative()) return null
if (messages.length + response.data.length > MAX_MESSAGES) {
throw new Error(`Message reload exceeded ${MAX_MESSAGES} messages for session ${sessionId}`)
}
if (response.data.length > 0) {
const remainingBytes = MAX_RETAINED_BYTES - retainedBytes
const pageBytes = estimateRetainedBytes(response.data, remainingBytes)
if (pageBytes > remainingBytes) {
throw new Error(`Message reload exceeded 64 MiB for session ${sessionId}`)
}
retainedBytes += pageBytes
}
messages.push(...response.data)
const nextCursor = response.cursor?.next ?? undefined
if (!nextCursor) return messages
if (seenCursors.has(nextCursor)) throw new Error(`Repeated message cursor for session ${sessionId}`)
seenCursors.add(nextCursor)
cursor = nextCursor
}
throw new Error(`Message pagination exceeded ${MAX_PAGES} pages for session ${sessionId}`)
}
export { listAllSessionMessages }

View file

@ -5,7 +5,13 @@ import { sdkManager } from "../lib/sdk-manager.ts"
import type { Session } from "../types/session.ts"
import { addInstance, removeInstance } from "./instances.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts"
import {
handleMessagePartDelta,
handleMessageUpdate,
handleNativeSessionEvent,
handleSessionIdle,
handleSessionStatus,
} from "./session-events.ts"
import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts"
const delay = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
@ -141,13 +147,30 @@ describe("native session event reducer", () => {
type: "session.status",
data: { sessionID: fetchedSessionId, status: { type: "running" } },
} as any)
handleMessagePartDelta(instanceId, {
type: "message.part.delta",
properties: { messageID: "stale-message", partID: "part", field: "text", delta: "stale" },
} as any)
removeInstance(instanceId, { authoritative: false })
handleMessageUpdate(instanceId, {
type: "message.updated",
properties: { info: { id: "late-message", sessionID: refreshSessionId, role: "assistant", time: { created: 1 } } },
} as any)
handleMessagePartDelta(instanceId, {
type: "message.part.delta",
properties: { messageID: "late-message", partID: "part", field: "text", delta: "late" },
} as any)
assert.equal(messageStoreBus.getInstance(instanceId), undefined)
const reopenedClient = { session: {}, message: { list: async () => ({ data: [] }) } } as any
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client: reopenedClient })
resolveGet({ id: fetchedSessionId, title: fetchedSessionId, parentID: null, projectID: "project",
location: { directory: "/work" }, time: { created: 1, updated: 1 } })
await delay(120)
assert.equal(messageCalls, 0)
assert.equal(sessions().has(instanceId), false)
assert.equal(sessions().get(instanceId)?.has(fetchedSessionId) ?? false, false)
assert.equal(messageStoreBus.getInstance(instanceId), undefined)
removeInstance(instanceId, { authoritative: false })
sdkManager.destroyClientsForInstance(instanceId)
})
})

View file

@ -4,15 +4,27 @@ import { describe, it } from "node:test"
import { sdkManager } from "../lib/sdk-manager.ts"
import { serverApi } from "../lib/api-client.ts"
import type { Session } from "../types/session.ts"
import { addInstance, removeInstance, updateInstance } from "./instances.ts"
import { addInstance, removeInstance } from "./instances.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { fetchSessions, loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts"
import {
createSession,
deleteSession,
fetchAgents,
fetchProviders,
fetchSessions,
forkSession,
loadMessages,
removeSessionRuntimeState,
searchSessions,
} from "./session-api.ts"
import {
agents,
clearInstanceDeletedSessionAuthority,
getSessionMessagesLoadError,
getSessionSearchResultIds,
invalidateSessionMessageLoad,
loading,
messagesLoaded,
providers,
sessions,
setSessions,
} from "./session-state.ts"
@ -140,30 +152,148 @@ describe("session request authority", () => {
}
})
it("aborts obsolete message requests and ignores clients that do not honor abort", async () => {
const instanceId = "aborted-message-load", sessionId = "session"
it("loads every message page before hydrating the transcript", async () => {
const instanceId = "paginated-message-load", sessionId = "session"
const { client, cleanup } = setup(instanceId)
const response = deferred<any>()
let signal: AbortSignal | undefined
;(client as any).message = { list: (_input: unknown, options: { signal?: AbortSignal }) => {
signal = options.signal
return response.promise
const finalPage = deferred<any>()
const inputs: any[] = []
;(client as any).message = { list: async (input: any) => {
inputs.push(input)
if (!input.cursor) return { data: [apiMessage("first", sessionId)], cursor: { next: "page-2" } }
return finalPage.promise
} }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
const store = messageStoreBus.getOrCreate(instanceId)
store.upsertMessage({ id: "evicted", sessionId, role: "assistant", status: "complete", createdAt: 1, updatedAt: 1 })
messagesLoaded().set(instanceId, new Set([sessionId]))
store.clearSession(sessionId)
try {
assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false)
const request = loadMessages(instanceId, sessionId)
invalidateSessionMessageLoad(instanceId, sessionId)
assert.equal(signal?.aborted, true)
response.resolve({ data: [apiMessage("late-message", sessionId)] })
await new Promise<void>((resolve) => setImmediate(resolve))
assert.deepEqual(store.getSessionMessageIds(sessionId), [])
finalPage.resolve({ data: [apiMessage("second", sessionId)], cursor: {} })
await request
assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), [])
assert.deepEqual(inputs, [
{ sessionID: sessionId, limit: 200, order: "asc" },
{ sessionID: sessionId, limit: 200, cursor: "page-2" },
])
assert.deepEqual(store.getSessionMessageIds(sessionId), ["first", "second"])
} finally {
cleanup()
}
})
it("does not eagerly load descendants with a root transcript", async () => {
it("rejects a repeated cursor without hydrating a partial transcript", async () => {
const instanceId = "repeated-message-cursor", sessionId = "session"
const { client, cleanup } = setup(instanceId)
;(client as any).message = { list: async () => ({
data: [apiMessage("partial", sessionId)],
cursor: { next: "repeat" },
}) }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await assert.rejects(loadMessages(instanceId, sessionId), /Repeated message cursor/)
assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), [])
assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false)
} finally {
cleanup()
}
})
it("bounds retries while message revisions keep changing", async () => {
const instanceId = "bounded-message-retry", sessionId = "session"
const { client, cleanup } = setup(instanceId)
let calls = 0
;(client as any).message = { list: async (input: any) => {
calls += 1
if (!input.cursor) return { data: [apiMessage(`first-${calls}`, sessionId)], cursor: { next: `page-${calls}` } }
messageStoreBus.getOrCreate(instanceId).upsertMessage({
id: `stream-${calls}`, sessionId, role: "assistant", status: "streaming", createdAt: calls, updatedAt: calls,
})
return { data: [apiMessage(`last-${calls}`, sessionId)], cursor: {} }
} }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId, { force: true })
assert.equal(calls, 4)
assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false)
assert.ok(getSessionMessagesLoadError(instanceId, sessionId), "exhausted conflicts must remain explicit")
} finally {
cleanup()
}
})
it("rejects late create and fork responses after an instance reopens", async () => {
const instanceId = "late-session-mutations"
const { client, cleanup } = setup(instanceId)
const created = deferred<any>()
const forked = deferred<any>()
;(client.session as any).create = () => created.promise
;(client.session as any).fork = () => forked.promise
const reopenedClient = { session: { active: async () => ({}) } } as any
try {
const createRequest = createSession(instanceId)
await new Promise<void>((resolve) => setImmediate(resolve))
removeInstance(instanceId, { authoritative: false })
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client: reopenedClient })
created.resolve(apiSession("late-created"))
await assert.rejects(createRequest, /Instance no longer ready/)
assert.equal(sessions().get(instanceId)?.has("late-created") ?? false, false)
removeInstance(instanceId, { authoritative: false })
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
const forkRequest = forkSession(instanceId, "source")
removeInstance(instanceId, { authoritative: false })
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client: reopenedClient })
forked.resolve(apiSession("late-fork"))
await assert.rejects(forkRequest, /Instance no longer ready/)
assert.equal(sessions().get(instanceId)?.has("late-fork") ?? false, false)
} finally {
cleanup()
}
})
it("ignores late delete, agent, and provider responses after an instance reopens", async () => {
const instanceId = "late-instance-operations", sessionId = "session"
const { client, cleanup } = setup(instanceId)
const removed = deferred<any>()
const agentList = deferred<any>()
const providerList = deferred<any>()
;(client.session as any).remove = () => removed.promise
;(client as any).agent = { list: () => agentList.promise }
;(client as any).provider = { list: () => providerList.promise }
;(client as any).model = { list: async () => ({ data: [] }) }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
const deleteRequest = deleteSession(instanceId, sessionId)
const agentsRequest = fetchAgents(instanceId)
const providersRequest = fetchProviders(instanceId)
removeInstance(instanceId, { authoritative: false })
const reopenedClient = { session: { active: async () => ({}) } } as any
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client: reopenedClient })
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
removed.resolve({ data: true })
agentList.resolve({ data: [{ name: "late-agent", mode: "primary" }] })
providerList.resolve({ data: [{ id: "late-provider", name: "Late" }] })
await Promise.all([deleteRequest, agentsRequest, providersRequest])
assert.equal(sessions().get(instanceId)?.has(sessionId), true)
assert.equal(agents().has(instanceId), false)
assert.equal(providers().has(instanceId), false)
} finally {
cleanup()
}
})
it("does not eagerly load descendant transcripts", async () => {
const instanceId = "root-only-load", rootId = "root", childId = "child"
const { client, cleanup } = setup(instanceId)
const calls: string[] = []
@ -184,44 +314,29 @@ describe("session request authority", () => {
}
})
it("purges every session-state bucket when an instance is removed", async () => {
it("purges session state and rejects late loads when an instance closes", async () => {
const instanceId = "instance-state-purge", sessionId = "session"
const { cleanup } = setup(instanceId)
const { client, cleanup } = setup(instanceId)
const response = deferred<any>()
let signal: AbortSignal | undefined
;(client as any).message = { list: (_input: unknown, options: { signal?: AbortSignal }) => {
signal = options.signal
return response.promise
} }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
messagesLoaded().set(instanceId, new Set([sessionId]))
try {
const request = loadMessages(instanceId, sessionId, { force: true })
removeInstance(instanceId, { authoritative: false })
assert.equal(signal?.aborted, true)
response.resolve({ data: [apiMessage("late", sessionId)] })
await request
assert.equal(sessions().has(instanceId), false)
assert.equal(messagesLoaded().has(instanceId), false)
assert.equal(loading().loadingMessages.has(instanceId), false)
} finally {
cleanup()
}
})
it("reloads complete native history after an evicted session is selected again", async () => {
const instanceId = "evicted-message-reload", sessionId = "session"
const { client, cleanup } = setup(instanceId)
let calls = 0
;(client as any).message = { list: async () => ({ data: [
apiMessage(`message-${++calls}-a`, sessionId),
apiMessage(`message-${calls}-b`, sessionId),
] }) }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId)
const store = messageStoreBus.getOrCreate(instanceId)
store.restoreScrollSnapshot(sessionId, "message-stream", { scrollTop: 240, atBottom: false, updatedAt: 1 })
store.clearSession(sessionId, { preserveScroll: true })
assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false)
assert.deepEqual(store.getScrollSnapshot(sessionId, "message-stream"), { scrollTop: 240, atBottom: false, updatedAt: 1 })
await loadMessages(instanceId, sessionId)
assert.equal(calls, 2)
assert.deepEqual(store.getSessionMessageIds(sessionId), ["message-2-a", "message-2-b"])
assert.equal(messageStoreBus.getInstance(instanceId), undefined)
} finally {
cleanup()
}
@ -254,25 +369,6 @@ describe("session request authority", () => {
}
})
it("does not accept a late response from a replaced client", async () => {
const instanceId = "replaced-message-client", sessionId = "session"
const { client: oldClient, cleanup } = setup(instanceId)
const oldResponse = deferred<any>()
;(oldClient as any).message = { list: () => oldResponse.promise }
setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
const oldRequest = loadMessages(instanceId, sessionId)
const newClient = { session: { active: async () => ({}) }, message: { list: async () => ({ data: [] }) } } as any
updateInstance(instanceId, { client: newClient })
oldResponse.resolve({ data: [apiMessage("old-client-message", sessionId)] })
await oldRequest
assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), [])
} finally {
cleanup()
}
})
it("keeps a newer load authoritative when an older request finishes last", async () => {
const instanceId = "newer-message-load", sessionId = "session"
const { client, cleanup } = setup(instanceId)
@ -369,7 +465,7 @@ describe("session request authority", () => {
assert.equal(sessions().get(instanceId)?.get("compacting")?.runtimeStatusKnown, true)
assert.deepEqual(statusOptions, [])
await loadMessages(instanceId, "compacting", { force: true })
assert.deepEqual(messageOptions, { sessionID: "compacting" })
assert.deepEqual(messageOptions, { sessionID: "compacting", limit: 200, order: "asc" })
} finally {
cleanup()
}

View file

@ -1299,7 +1299,6 @@ export {
getMessageLoadSignal,
finishMessageLoad,
invalidateSessionMessageLoad,
clearInstanceSessionState,
setSessionMessagesLoadError,
sessionInfoByInstance,
setSessionInfoByInstance,

View file

@ -1,5 +1,7 @@
import { createEffect, createRoot } from "solid-js"
import { getLogger } from "../lib/logger"
import { onCacheSessionChanged } from "../lib/global-cache"
import { SessionTranscriptMeasurementQueue } from "../lib/session-transcript-measurement"
import { isSessionTranscriptProtected, SessionTranscriptLru } from "../lib/session-transcript-lru"
import { messageStoreBus } from "./message-v2/bus"
import { loading, sessions } from "./session-state"
@ -8,7 +10,6 @@ export const SESSION_TRANSCRIPT_BYTE_BUDGET = 64 * 1024 * 1024
const log = getLogger("session")
const visible = new Map<string, number>()
const pendingMeasurements = new Map<string, { timer: ReturnType<typeof setTimeout>; controller: AbortController }>()
const key = (instanceId: string, sessionId: string) => `${instanceId}\u0000${sessionId}`
const coordinator = new SessionTranscriptLru({
@ -27,46 +28,39 @@ const coordinator = new SessionTranscriptLru({
},
evict: (instanceId, sessionId) => {
log.info("Evicting inactive session transcript", { instanceId, sessionId })
messageStoreBus.getInstance(instanceId)?.clearSession(sessionId, { preserveScroll: true })
messageStoreBus.getInstance(instanceId)?.evictSessionTranscript(sessionId)
},
})
const measurements = new SessionTranscriptMeasurementQueue({
delayMs: 100,
measure: async (instanceId, sessionId, signal) =>
await messageStoreBus.getInstance(instanceId)?.estimateSessionRetainedBytes(sessionId, signal) ?? 0,
account: (instanceId, sessionId, bytes) => coordinator.account(instanceId, sessionId, bytes),
onError: (instanceId, sessionId, error) => {
log.warn("Failed to measure session transcript", { instanceId, sessionId, error })
},
})
export function accountSessionTranscript(instanceId: string, sessionId: string): void {
const entryKey = key(instanceId, sessionId)
const previous = pendingMeasurements.get(entryKey)
if (previous) {
clearTimeout(previous.timer)
previous.controller.abort()
}
const controller = new AbortController()
const timer = setTimeout(async () => {
try {
const bytes = await messageStoreBus.getInstance(instanceId)?.estimateSessionRetainedBytes(sessionId, controller.signal)
if (!controller.signal.aborted && pendingMeasurements.get(entryKey)?.controller === controller) {
coordinator.account(instanceId, sessionId, bytes ?? 0)
}
} catch (error) {
if (!controller.signal.aborted) log.warn("Failed to measure session transcript", { instanceId, sessionId, error })
} finally {
if (pendingMeasurements.get(entryKey)?.controller === controller) pendingMeasurements.delete(entryKey)
}
}, 100)
pendingMeasurements.set(entryKey, { timer, controller })
measurements.schedule(instanceId, sessionId)
}
export function touchSessionTranscript(instanceId: string, sessionId: string): void {
coordinator.touch(instanceId, sessionId)
measurements.schedule(instanceId, sessionId)
}
export function setSessionTranscriptVisible(instanceId: string, sessionId: string, value: boolean): void {
const entryKey = key(instanceId, sessionId)
if (value) {
visible.set(entryKey, (visible.get(entryKey) ?? 0) + 1)
coordinator.touch(instanceId, sessionId)
touchSessionTranscript(instanceId, sessionId)
} else {
const count = (visible.get(entryKey) ?? 0) - 1
if (count > 0) visible.set(entryKey, count)
else visible.delete(entryKey)
measurements.schedule(instanceId, sessionId)
}
coordinator.enforce()
}
@ -82,14 +76,9 @@ createRoot(() => createEffect(() => {
}))
messageStoreBus.onSessionChanged(accountSessionTranscript)
onCacheSessionChanged(accountSessionTranscript)
messageStoreBus.onSessionCleared((instanceId, sessionId) => {
const entryKey = key(instanceId, sessionId)
const pending = pendingMeasurements.get(entryKey)
if (pending) {
clearTimeout(pending.timer)
pending.controller.abort()
}
pendingMeasurements.delete(entryKey)
measurements.cancel(instanceId, sessionId)
coordinator.forget(instanceId, sessionId)
})
messageStoreBus.onInstanceDestroyed((instanceId) => {
@ -97,10 +86,5 @@ messageStoreBus.onInstanceDestroyed((instanceId) => {
for (const entryKey of visible.keys()) {
if (entryKey.startsWith(`${instanceId}\u0000`)) visible.delete(entryKey)
}
for (const [entryKey, pending] of pendingMeasurements) {
if (!entryKey.startsWith(`${instanceId}\u0000`)) continue
clearTimeout(pending.timer)
pending.controller.abort()
pendingMeasurements.delete(entryKey)
}
measurements.cancelInstance(instanceId)
})

View file

@ -38,22 +38,17 @@ export function getPermissionKind(permission: PermissionRequest | null | undefin
return permission?.action ?? "permission"
}
export function getPermissionPatterns(permission: PermissionRequest | null | undefined): string[] {
return permission?.resources.filter((value) => typeof value === "string") ?? []
}
export function getPermissionDisplayTitle(permission: PermissionRequest | null | undefined): string {
const kind = getPermissionKind(permission).slice(0, 384)
const titleLimit = 384
let title = `${kind}: `
let count = 0
let scanned = 0
for (const resource of permission?.resources ?? []) {
if (++scanned > 10_000) break
if (typeof resource !== "string") continue
const separator = count > 0 ? ", " : ""
const remaining = titleLimit - title.length - separator.length
if (remaining <= 3) break
title += separator + resource.slice(0, remaining)
count += 1
const kind = getPermissionKind(permission)
const patterns = getPermissionPatterns(permission)
if (patterns.length > 0) {
return `${kind}: ${patterns.join(", ")}`
}
return count > 0 ? title : kind
return kind
}
export function getRequestIdFromPermissionReply(