mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 06:13:26 +00:00
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:
parent
20dfdc2954
commit
bb39dab1fc
80 changed files with 3136 additions and 935 deletions
2
.github/workflows/comment-pr-artifacts.yml
vendored
2
.github/workflows/comment-pr-artifacts.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
.github/workflows/pr-build.yml
vendored
17
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/restrict-non-dev-prs.yml
vendored
4
.github/workflows/restrict-non-dev-prs.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
17
packages/ui/src/components/markdown-render-limit.test.ts
Normal file
17
packages/ui/src/components/markdown-render-limit.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
94
packages/ui/src/components/tool-call/renderers/todo-data.ts
Normal file
94
packages/ui/src/components/tool-call/renderers/todo-data.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = () => {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ export const toolCallMessages = {
|
|||
"toolCall.renderer.bash.title.timeout": "Délai : {timeout}",
|
||||
"toolCall.output.truncated": "[Sortie tronquée pour l’affichage ; copiez-la pour accéder à la sortie complète]",
|
||||
"toolCall.output.tooLarge": "Sortie structurée omise de l’affichage car elle est trop volumineuse.",
|
||||
"toolCall.permission.fullDiffRequired": "Copiez le diff complet avant d’approuver 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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "סגור חיפוש",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "検索を閉じる",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "खोज बन्द गर्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "Закрыть поиск",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "关闭搜索",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
||||
|
|
|
|||
37
packages/ui/src/lib/message-render-cache.test.ts
Normal file
37
packages/ui/src/lib/message-render-cache.test.ts
Normal 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")
|
||||
})
|
||||
132
packages/ui/src/lib/message-render-cache.ts
Normal file
132
packages/ui/src/lib/message-render-cache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
77
packages/ui/src/lib/retained-size.test.ts
Normal file
77
packages/ui/src/lib/retained-size.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
129
packages/ui/src/lib/session-transcript-measurement.test.ts
Normal file
129
packages/ui/src/lib/session-transcript-measurement.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
97
packages/ui/src/lib/session-transcript-measurement.ts
Normal file
97
packages/ui/src/lib/session-transcript-measurement.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
]])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface SessionRecord {
|
|||
|
||||
export interface PendingPartEntry {
|
||||
messageId: string
|
||||
sessionId?: string
|
||||
part: ClientPart
|
||||
receivedAt: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
52
packages/ui/src/stores/session-message-pages.test.ts
Normal file
52
packages/ui/src/stores/session-message-pages.test.ts
Normal 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/)
|
||||
})
|
||||
51
packages/ui/src/stores/session-message-pages.ts
Normal file
51
packages/ui/src/stores/session-message-pages.ts
Normal 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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1299,7 +1299,6 @@ export {
|
|||
getMessageLoadSignal,
|
||||
finishMessageLoad,
|
||||
invalidateSessionMessageLoad,
|
||||
clearInstanceSessionState,
|
||||
setSessionMessagesLoadError,
|
||||
sessionInfoByInstance,
|
||||
setSessionInfoByInstance,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue