From dd3b818e5c0fbe8940c70ccd0438362a658b9cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 21:24:04 +0200 Subject: [PATCH 01/16] perf(ui): bound session memory and restore history Keep inactive workspace and subagent histories in a paged IndexedDB cache while enforcing a global byte-aware hot-memory budget. Visible, streaming, compacting, and input-blocked sessions retain authority, and nested task sessions load only while their owning pane is visible. Bound tool, diff, diagnostic, speech, search, and nested-task rendering so large outputs cannot create unbounded parser or DOM work. Full large inputs and patches remain available through lazy copy actions. Harden HTTP, SSE, revert, deletion, and instance-reopen ordering with revision and lifecycle fences. Coalesce cache writes without retaining queued snapshots, preserve orphan deltas through an authoritative reload, and version cache invalidations across shared workspace keys. Validated with 395 UI tests, UI typecheck, production build, and independent memory, cache-concurrency, and rendering gatekeepers. --- .../components/instance/instance-shell2.tsx | 1 + .../instance/shell/useSessionCache.ts | 34 +- packages/ui/src/components/message-block.tsx | 1 + .../ui/src/components/message-section.tsx | 15 +- .../src/components/session/session-view.tsx | 7 + packages/ui/src/components/tool-call.tsx | 52 +- .../src/components/tool-call/diagnostics.ts | 6 +- .../src/components/tool-call/diff-render.tsx | 14 +- .../tool-call/renderers/apply-patch.tsx | 33 +- .../components/tool-call/renderers/bash.tsx | 15 +- .../tool-call/renderers/default.tsx | 6 +- .../components/tool-call/renderers/edit.tsx | 5 +- .../components/tool-call/renderers/patch.tsx | 5 +- .../components/tool-call/renderers/read.tsx | 4 +- .../components/tool-call/renderers/skill.tsx | 6 +- .../components/tool-call/renderers/task.tsx | 71 ++- .../tool-call/renderers/webfetch.tsx | 6 +- .../components/tool-call/renderers/write.tsx | 4 +- .../src/components/tool-call/search-text.ts | 16 +- packages/ui/src/components/tool-call/types.ts | 2 + .../ui/src/components/tool-call/utils.test.ts | 12 + packages/ui/src/components/tool-call/utils.ts | 37 +- .../ui/src/components/virtual-follow-list.tsx | 2 + packages/ui/src/lib/hooks/use-commands.ts | 8 +- .../ui/src/lib/i18n/messages/de/settings.ts | 4 +- .../ui/src/lib/i18n/messages/de/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/en/settings.ts | 4 +- .../ui/src/lib/i18n/messages/en/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/es/settings.ts | 4 +- .../ui/src/lib/i18n/messages/es/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/fr/settings.ts | 4 +- .../ui/src/lib/i18n/messages/fr/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/he/settings.ts | 4 +- .../ui/src/lib/i18n/messages/he/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ja/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ja/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ne/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ne/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ru/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ru/toolCall.ts | 3 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 4 +- .../src/lib/i18n/messages/zh-Hans/toolCall.ts | 3 + .../ui/src/lib/session-memory-budget.test.ts | 30 ++ packages/ui/src/lib/session-memory-budget.ts | 62 +++ .../ui/src/lib/session-message-cache.test.ts | 34 ++ packages/ui/src/lib/session-message-cache.ts | 483 ++++++++++++++++++ packages/ui/src/lib/sse-manager.ts | 2 + packages/ui/src/stores/client-state.ts | 9 + packages/ui/src/stores/delta-buffer.test.ts | 19 + packages/ui/src/stores/delta-buffer.ts | 43 +- packages/ui/src/stores/instances.ts | 15 +- packages/ui/src/stores/message-v2/bridge.ts | 48 +- packages/ui/src/stores/message-v2/bus.ts | 24 +- .../stores/message-v2/instance-store.test.ts | 53 ++ .../src/stores/message-v2/instance-store.ts | 167 +++++- packages/ui/src/stores/session-actions.ts | 6 + packages/ui/src/stores/session-api.ts | 254 +++++---- packages/ui/src/stores/session-events.ts | 109 +++- packages/ui/src/stores/session-memory.test.ts | 72 +++ packages/ui/src/stores/session-memory.ts | 107 ++++ .../ui/src/stores/session-message-cache.ts | 364 +++++++++++++ packages/ui/src/stores/session-metadata.ts | 2 + .../stores/session-request-authority.test.ts | 38 ++ .../ui/src/stores/session-state-purge.test.ts | 36 ++ packages/ui/src/stores/session-state.ts | 47 ++ 65 files changed, 2193 insertions(+), 256 deletions(-) create mode 100644 packages/ui/src/components/tool-call/utils.test.ts create mode 100644 packages/ui/src/lib/session-memory-budget.test.ts create mode 100644 packages/ui/src/lib/session-memory-budget.ts create mode 100644 packages/ui/src/lib/session-message-cache.test.ts create mode 100644 packages/ui/src/lib/session-message-cache.ts create mode 100644 packages/ui/src/stores/session-memory.test.ts create mode 100644 packages/ui/src/stores/session-memory.ts create mode 100644 packages/ui/src/stores/session-message-cache.ts create mode 100644 packages/ui/src/stores/session-state-purge.test.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 004d85d1e..dd8067842 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -609,6 +609,7 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, instanceSessions: allInstanceSessions, activeSessionId: activeSessionIdForInstance, + visible: () => Boolean(props.isActiveInstance), }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..d46d793fa 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,10 +1,6 @@ -import { createEffect, createSignal, type Accessor } from "solid-js" +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" import { messageStoreBus } from "../../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../../message-block" -import { getLogger } from "../../../lib/logger" -import { invalidateSessionMessageLoad } from "../../../stores/session-state" - -const log = getLogger("session") +import { evictResidentSessionMessages, setVisibleSessionMemory } from "../../../stores/session-memory" const SESSION_CACHE_LIMIT = 5 @@ -12,6 +8,7 @@ type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + visible: Accessor } type SessionCacheState = { @@ -23,13 +20,8 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState const [pendingEvictions, setPendingEvictions] = createSignal([]) const evictSession = (sessionId: string) => { - if (!sessionId) return - const instanceId = options.instanceId() - log.info("Evicting cached session", { instanceId, sessionId }) - const store = messageStoreBus.getInstance(instanceId) - invalidateSessionMessageLoad(instanceId, sessionId) - store?.clearSession(sessionId, { preserveScroll: true, notify: false }) - clearSessionRenderCache(instanceId, sessionId) + if (!sessionId) return false + return evictResidentSessionMessages(options.instanceId(), sessionId) } const scheduleEvictions = (ids: string[]) => { @@ -50,13 +42,15 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState createEffect(() => { const pending = pendingEvictions() if (!pending.length) return + const store = messageStoreBus.getInstance(options.instanceId()) const cached = new Set(cachedSessionIds()) const remaining: string[] = [] pending.forEach((id) => { + store?.getSessionRevision(id) if (cached.has(id)) { remaining.push(id) - } else { - evictSession(id) + } else if (!evictSession(id)) { + remaining.push(id) } }) if (remaining.length !== pending.length) { @@ -64,6 +58,16 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState } }) + createEffect(() => { + const instanceId = options.instanceId() + const sessionId = options.activeSessionId() + if (!sessionId || sessionId === "info") return + const isVisible = options.visible() + if (!isVisible) return + setVisibleSessionMemory(instanceId, sessionId, true) + onCleanup(() => setVisibleSessionMemory(instanceId, sessionId, false)) + }) + createEffect(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index cc3691ac5..e38596cfe 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -188,6 +188,7 @@ function clearInstanceCaches(instanceId: string) { } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) +messageStoreBus.onSessionCleared(clearSessionRenderCache) function removeSearchMarks(root: HTMLElement) { const marks = Array.from(root.querySelectorAll("mark.session-search-match")) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index f80302338..693e2c423 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -11,6 +11,7 @@ import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./vir import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" +import { isRestoringCachedSessionMessages } from "../stores/session-message-cache" import { useI18n } from "../lib/i18n" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" @@ -884,7 +885,6 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!element || !api) return if (!isActive()) return - if (props.loading) return if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return @@ -1064,7 +1064,7 @@ export default function MessageSection(props: MessageSectionProps) { // to prevent O(n) per-element reactive subscriptions. The effect // only needs to re-run when `messageIds` (memo) changes. untrack(() => { - if (loading) { + if (loading && ids.length === 0) { handleClearTimelineSelection() previousTimelineIds = [] setTimelineSegments([]) @@ -1131,6 +1131,14 @@ export default function MessageSection(props: MessageSectionProps) { } } + const prefixAdded = previousTimelineIds.length > 0 && ids.length > previousTimelineIds.length && + previousTimelineIds.every((id, index) => ids[ids.length - previousTimelineIds.length + index] === id) + if (prefixAdded) { + seedTimeline() + previousTimelineIds = [...ids] + return + } + const newIds: string[] = [] ids.forEach((id) => { if (!seenTimelineMessageIds.has(id)) { @@ -1430,6 +1438,7 @@ export default function MessageSection(props: MessageSectionProps) { getAnchorId={getMessageAnchorId} overscanPx={800} streamingActive={streamingActive} + shift={() => isRestoringCachedSessionMessages(props.instanceId, props.sessionId)} isActive={isActive} scrollToBottomOnActivate={() => false} initialScrollToBottom={() => false} @@ -1585,7 +1594,7 @@ export default function MessageSection(props: MessageSectionProps) { - +

{t("messageSection.loading.messages")}

diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index af11017a2..0df4a874f 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -23,6 +23,8 @@ import { SessionPreviewView } from "../session-preview-view" import { isSnapshotAutoFollowing } from "../virtual-follow-behavior" import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent" import { focusConversationStream } from "../focus-conversation" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" +import { invalidateSessionMessageLoad } from "../../stores/session-state" const log = getLogger("session") @@ -431,6 +433,11 @@ export const SessionView: Component = (props) => { }), "session.revert", ) + if (instances().get(props.instanceId) !== instance) return + if (messageStore().getSessionRevert(props.sessionId)?.messageID !== messageId) { + invalidateSessionMessageLoad(props.instanceId, props.sessionId) + invalidateSessionMessageCache(props.instanceId, props.sessionId) + } const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 0446ca23b..46bd9f607 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -11,6 +11,7 @@ import type { PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" +import { exceedsRetainedByteLimit } from "../lib/session-memory-budget" import { resolveToolRenderer } from "./tool-call/renderers" import { resolveToolExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" import { QuestionToolBlock } from "./tool-call/question-block" @@ -39,7 +40,9 @@ import { isToolStateError, isToolStateRunning, getDefaultToolAction, + limitToolOutputForRender, readToolStatePayload, + TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, } from "./tool-call/utils" import { getLogger } from "../lib/logger" import { useSpeech } from "../lib/hooks/use-speech" @@ -73,6 +76,7 @@ interface ToolCallProps { partVersion?: number instanceId: string sessionId: string + visibilitySessionId?: string onContentRendered?: () => void /** * When true, tool call starts collapsed regardless of user preferences. @@ -111,6 +115,7 @@ function ToolCallDetails(props: { toolCallIdentifier: () => string instanceId: string sessionId: string + visibilitySessionId: string messageId?: string messageVersion?: number partVersion?: number @@ -354,9 +359,12 @@ function ToolCallDetails(props: { const status = () => props.toolState()?.status || "" - const toolInputDisplay = createMemo((): { content: string; copyText: string; language: string } | null => { + const toolInputDisplay = createMemo((): { content: string; copyText: string | null; language: string } | null => { const input = props.toolInput() if (!input || Object.keys(input).length === 0) return null + if (exceedsRetainedByteLimit(input, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { content: props.t("toolCall.input.tooLarge"), copyText: null, language: "text" } + } try { const yamlText = stringifyYaml(input) @@ -418,6 +426,7 @@ function ToolCallDetails(props: { toolName: props.toolName, instanceId: props.instanceId, sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId, t: props.t, messageVersion: messageVersionAccessor, partVersion: partVersionAccessor, @@ -435,6 +444,7 @@ function ToolCallDetails(props: { partVersion={options.partVersion} instanceId={props.instanceId} sessionId={options.sessionId} + visibilitySessionId={options.visibilitySessionId ?? props.visibilitySessionId ?? props.sessionId} onContentRendered={props.onContentRendered} forceCollapsed={options.forceCollapsed} /> @@ -475,7 +485,7 @@ function ToolCallDetails(props: { if (state?.status === "error" && state.error) { return (
- {props.t("toolCall.error.label")} {state.error} + {props.t("toolCall.error.label")} {limitToolOutputForRender(state.error)}
) } @@ -522,6 +532,18 @@ function ToolCallDetails(props: { await copyToClipboard(text) } + const copyToolInput = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const input = props.toolInput() + if (!input) return + try { + await copyToClipboard(stringifyYaml(input)) + } catch { + await copyToClipboard(JSON.stringify(input, null, 2)) + } + } + const outputWrapTitle = () => props.outputWrapEnabled() ? props.t("toolCall.diff.disableWordWrap") @@ -535,6 +557,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement wrapToggle?: () => boolean | undefined }) => ( @@ -551,18 +574,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + + @@ -644,6 +665,7 @@ function ToolCallDetails(props: { expanded: props.inputSectionExpanded, onToggle: props.toggleInputSection, copyText: () => toolInputDisplay()?.copyText, + onCopy: toolInputDisplay()?.copyText === null ? (event) => void copyToolInput(event) : undefined, copyTitle: () => props.t("toolCall.io.copyInputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"), }) @@ -887,6 +909,7 @@ export default function ToolCall(props: ToolCallProps) { toolName, instanceId: props.instanceId, sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId ?? props.sessionId, t, messageVersion: () => props.messageVersion, partVersion: () => props.partVersion, @@ -1145,6 +1168,7 @@ export default function ToolCall(props: ToolCallProps) { toolCallIdentifier={toolCallIdentifier} instanceId={props.instanceId} sessionId={props.sessionId} + visibilitySessionId={props.visibilitySessionId ?? props.sessionId} messageId={props.messageId} messageVersion={props.messageVersion} partVersion={props.partVersion} diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index 8651df697..237b35a4e 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -102,6 +102,7 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat if (!Array.isArray(list) || list.length === 0) return [] const entries: DiagnosticEntry[] = [] + const limit = 100 const normalizedPath = normalizeDiagnosticPath(key) for (let index = 0; index < list.length; index++) { const diagnostic = list[index] @@ -111,17 +112,18 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 entries.push({ - id: `${normalizedPath}-${index}-${diagnostic.message}`, + id: String(index), severity: severityMeta.rank, tone, label: severityMeta.label, icon: severityMeta.icon, - message: diagnostic.message, + message: diagnostic.message.slice(0, 2_000), filePath: normalizedPath, displayPath: getRelativePath(normalizedPath), line, column, }) + if (entries.length >= limit) break } return entries.sort((a, b) => a.severity - b.severity) diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 563fe9d6e..efbd79343 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -5,7 +5,7 @@ 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 } from "./utils" +import { getRelativePath, limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils" import { getCacheEntry } from "../../lib/global-cache" import { copyToClipboard } from "../../lib/clipboard" @@ -65,6 +65,8 @@ export function createDiffContentRenderer(params: { } function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { + const renderedDiffText = limitToolOutputForRender(payload.diffText) + const diffWasTruncated = payload.diffText.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? params.t("toolCall.diff.label.withPath", { path: relativePath }) @@ -100,7 +102,7 @@ export function createDiffContentRenderer(params: { const cached = getCacheEntry(cacheEntryParams) if ( cached - && cached.text === payload.diffText + && cached.text === renderedDiffText && cached.theme === themeKey && cached.mode === currentMode() && cached.wrap === currentWrap() @@ -172,12 +174,14 @@ export function createDiffContentRenderer(params: {
- {cachedHtml() ? ( + {diffWasTruncated ? ( +
{renderedDiffText}
+ ) : cachedHtml() ? ( ) : ( - {payload.diffText}}> + {renderedDiffText}}> ) => string }) { return ( 0}> @@ -62,7 +66,7 @@ export const applyPatchRenderer: ToolRenderer = { } return getToolName("apply_patch") }, - getOutputChrome({ toolState }) { + getOutputChrome({ toolState, t }) { const state = toolState() if (!state || state.status === "pending") return undefined @@ -72,6 +76,23 @@ export const applyPatchRenderer: ToolRenderer = { .map((file) => (typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "")) .filter((diff) => diff.trim().length > 0) if (diffs.length > 0) { + if (diffs.reduce((total, diff) => total + diff.length, 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) { + return { + language: "diff", + suppressInnerHeader: false, + actions: ( + + ), + } + } return { language: "diff", copyText: diffs.join("\n"), suppressInnerHeader: false } } @@ -84,10 +105,11 @@ export const applyPatchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const payload = readToolStatePayload(state) - const files = createMemo(() => { + const allFiles = createMemo(() => { const list = (payload.metadata as any).files return Array.isArray(list) ? (list as ApplyPatchFile[]) : [] }) + const files = createMemo(() => allFiles().slice(0, APPLY_PATCH_FILE_RENDER_LIMIT)) const diagnosticsMap = createMemo(() => { const value = (payload.metadata as any).diagnostics return value && typeof value === "object" ? (value as DiagnosticsMap) : {} @@ -96,7 +118,7 @@ export const applyPatchRenderer: ToolRenderer = { if (files().length === 0) { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null if (!fallback) return null - return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" }) + return renderMarkdown({ content: limitToolOutputForRender(fallback), size: "large", disableHighlight: state.status === "running" }) } return ( @@ -124,6 +146,9 @@ export const applyPatchRenderer: ToolRenderer = { ) }} + APPLY_PATCH_FILE_RENDER_LIMIT}> +
{t("toolCall.output.truncated")}
+
) }, diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 36275163d..9fdec9852 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -1,7 +1,7 @@ import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolRenderer, ToolScrollHelpers } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { createStableAnsiStreamUpdater } from "../ansi-render" import { ansiToHtml, hasAnsi } from "../../../lib/ansi" @@ -99,7 +99,7 @@ function getBashCopyText(state: ToolState | undefined): string { const { input, metadata } = readToolStatePayload(state) const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const outputResult = formatUnknownForCopy( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output @@ -122,8 +122,8 @@ function BashToolBody(props: { if (!current || current.status === "pending") return "" const { input, metadata } = readToolStatePayload(current) - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const command = typeof input.command === "string" && input.command.length > 0 ? limitToolOutputForRender(`$ ${input.command}`) : "" + const outputResult = formatUnknownForRender( isToolStateCompleted(current) ? current.output : (isToolStateRunning(current) || isToolStateError(current)) && metadata.output @@ -132,10 +132,11 @@ function BashToolBody(props: { ) return [command, outputResult?.text].filter(Boolean).join("\n") }) + const renderedContent = createMemo(() => limitToolOutputForRender(joinedContent())) const finalMarkdown = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -147,7 +148,7 @@ function BashToolBody(props: { const finalAnsiHtml = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -169,7 +170,7 @@ function BashToolBody(props: {
} > - +
) diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx index f19682675..9433bb643 100644 --- a/packages/ui/src/components/tool-call/renderers/default.tsx +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const defaultRenderer: ToolRenderer = { @@ -16,7 +16,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForCopy(primaryOutput) if (!result) return undefined return { @@ -37,7 +37,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForRender(primaryOutput) if (!result) return null const content = ensureMarkdownContent(result.text, result.language, true) diff --git a/packages/ui/src/components/tool-call/renderers/edit.tsx b/packages/ui/src/components/tool-call/renderers/edit.tsx index b4f84fb1d..bde7cb018 100644 --- a/packages/ui/src/components/tool-call/renderers/edit.tsx +++ b/packages/ui/src/components/tool-call/renderers/edit.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -43,7 +43,8 @@ export const editRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/patch.tsx b/packages/ui/src/components/tool-call/renderers/patch.tsx index 356bba5a6..a190dfdfa 100644 --- a/packages/ui/src/components/tool-call/renderers/patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/patch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -43,7 +43,8 @@ export const patchRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/read.tsx b/packages/ui/src/components/tool-call/renderers/read.tsx index ff37e2e85..e4b73be66 100644 --- a/packages/ui/src/components/tool-call/renderers/read.tsx +++ b/packages/ui/src/components/tool-call/renderers/read.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getReadToolSearchText } from "../search-text" @@ -56,7 +56,7 @@ export const readRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const preview = typeof metadata.preview === "string" ? metadata.preview : null const language = inferLanguageFromPath(getReadPath(input)) - const content = ensureMarkdownContent(preview, language, true) + const content = ensureMarkdownContent(preview ? limitToolOutputForRender(preview) : preview, language, true) if (!content) return null return renderMarkdown({ content, disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/renderers/skill.tsx b/packages/ui/src/components/tool-call/renderers/skill.tsx index adaa2cc66..ab9017201 100644 --- a/packages/ui/src/components/tool-call/renderers/skill.tsx +++ b/packages/ui/src/components/tool-call/renderers/skill.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const skillRenderer: ToolRenderer = { @@ -12,7 +12,7 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return undefined - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForCopy(state.output)?.text ?? null if (!output) return undefined return { copyText: output, suppressInnerHeader: true } }, @@ -20,7 +20,7 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return null - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForRender(state.output)?.text ?? null const content = ensureMarkdownContent(output, undefined, false) if (!content) return null return
{renderMarkdown({ content })}
diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index fce9b3781..08c9d1570 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,11 +1,15 @@ -import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, limitToolOutputForRender, readToolStatePayload } from "../utils" import { messageStoreBus } from "../../../stores/message-v2/bus" +import { activeInstanceId } from "../../../stores/instances" import { loadMessages } from "../../../stores/session-api" -import { loading, messagesLoaded } from "../../../stores/session-state" +import { activeSessionId, loading, messagesLoaded } from "../../../stores/session-state" +import { setVisibleSessionMemory } from "../../../stores/session-memory" import { getTaskToolSearchText } from "../search-text" +import { Copy } from "lucide-solid" +import { copyToClipboard } from "../../../lib/clipboard" interface TaskSummaryItem { id: string @@ -17,6 +21,8 @@ interface TaskSummaryItem { title?: string } +const TASK_STEP_RENDER_LIMIT = 200 + function extractSessionIdFromTaskState(state?: ToolState): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} @@ -38,6 +44,7 @@ function TaskToolCallRow(props: { toolKey: string store: ReturnType sessionId: string + visibilitySessionId: string renderToolCall: NonNullable }) { const parts = createMemo(() => splitToolKey(props.toolKey)) @@ -74,6 +81,7 @@ function TaskToolCallRow(props: { messageVersion: messageVersion(), partVersion: partVersion(), sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId, forceCollapsed: true, }) }) @@ -169,9 +177,31 @@ export const taskRenderer: ToolRenderer = { const { input } = readToolStatePayload(state) return describeTaskTitle(input) }, - renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { + getOutputChrome({ toolState, t }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const prompt = typeof input.prompt === "string" ? input.prompt : "" + const output = state && "output" in state && typeof state.output === "string" ? state.output : null + if (prompt.length > 10_000) { + return { + actions: ( + + ), + } + } + return output ? { copyText: output } : undefined + }, + renderBody({ toolState, instanceId, visibilitySessionId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { const store = messageStoreBus.getOrCreate(instanceId) - const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const childSessionId = createMemo(() => { const state = toolState() @@ -195,13 +225,20 @@ export const taskRenderer: ToolRenderer = { createEffect(() => { const id = childSessionId() if (!id) return - if (requestedChildLoad()) return + if (activeInstanceId() !== instanceId) return + if (activeSessionId().get(instanceId) !== visibilitySessionId) return if (childSessionLoaded()) return if (childSessionLoading()) return - setRequestedChildLoad(true) void loadMessages(instanceId, id) }) + createEffect(() => { + const id = childSessionId() + if (!id || activeInstanceId() !== instanceId || activeSessionId().get(instanceId) !== visibilitySessionId) return + setVisibleSessionMemory(instanceId, id, true) + onCleanup(() => setVisibleSessionMemory(instanceId, id, false)) + }) + const [childToolKeys, setChildToolKeys] = createSignal([]) let indexedSessionId = "" @@ -241,10 +278,10 @@ export const taskRenderer: ToolRenderer = { indexedPartCounts.clear() const nextKeys: string[] = [] - for (const messageId of messageIds) { - nextKeys.push(...scanMessageToolParts(messageId, 0)) + for (let index = messageIds.length - 1; index >= 0 && nextKeys.length < TASK_STEP_RENDER_LIMIT; index -= 1) { + nextKeys.unshift(...scanMessageToolParts(messageIds[index], 0)) } - setChildToolKeys(nextKeys) + setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT)) } createEffect(() => { @@ -310,7 +347,7 @@ export const taskRenderer: ToolRenderer = { indexedMessageTail = messageIds[messageIds.length - 1] ?? "" if (appendedKeys.length > 0) { - setChildToolKeys((prev) => [...prev, ...appendedKeys]) + setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT)) } }) }) @@ -319,14 +356,14 @@ export const taskRenderer: ToolRenderer = { if (!state) return null const { input } = readToolStatePayload(state) const prompt = typeof input.prompt === "string" ? input.prompt : null - return ensureMarkdownContent(prompt, undefined, false) + return ensureMarkdownContent(prompt ? limitToolOutputForRender(prompt) : prompt, undefined, false) }) const outputContent = createMemo(() => { const state = toolState() if (!state) return null const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null - return ensureMarkdownContent(output, undefined, false) + return ensureMarkdownContent(output ? limitToolOutputForRender(output) : output, undefined, false) }) const agentLabel = createMemo(() => { @@ -372,7 +409,7 @@ export const taskRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] - return summary.map((entry, index) => { + return summary.slice(-TASK_STEP_RENDER_LIMIT).map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} @@ -420,10 +457,13 @@ export const taskRenderer: ToolRenderer = {
{t("toolCall.task.sections.steps")}
+ = TASK_STEP_RENDER_LIMIT || legacyItems().length >= TASK_STEP_RENDER_LIMIT}> +
{t("toolCall.output.truncated")}
+
0} fallback={ @@ -483,6 +523,7 @@ export const taskRenderer: ToolRenderer = { toolKey={key()} store={store} sessionId={childSessionId()} + visibilitySessionId={visibilitySessionId} renderToolCall={render()} /> )} diff --git a/packages/ui/src/components/tool-call/renderers/webfetch.tsx b/packages/ui/src/components/tool-call/renderers/webfetch.tsx index 59aa5645a..246cfad8b 100644 --- a/packages/ui/src/components/tool-call/renderers/webfetch.tsx +++ b/packages/ui/src/components/tool-call/renderers/webfetch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWebfetchToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return undefined const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForCopy( state.status === "completed" ? state.output : metadata.output, @@ -40,7 +40,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForRender( state.status === "completed" ? state.output : metadata.output, diff --git a/packages/ui/src/components/tool-call/renderers/write.tsx b/packages/ui/src/components/tool-call/renderers/write.tsx index 9a7f3d540..f72c0438f 100644 --- a/packages/ui/src/components/tool-call/renderers/write.tsx +++ b/packages/ui/src/components/tool-call/renderers/write.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWriteToolSearchText } from "../search-text" @@ -35,7 +35,7 @@ export const writeRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const contentValue = typeof input.content === "string" ? input.content : metadata.content const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true) + const content = ensureMarkdownContent(typeof contentValue === "string" ? limitToolOutputForRender(contentValue) : null, inferLanguageFromPath(filePath), true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/search-text.ts b/packages/ui/src/components/tool-call/search-text.ts index b50cf4ef2..b906be102 100644 --- a/packages/ui/src/components/tool-call/search-text.ts +++ b/packages/ui/src/components/tool-call/search-text.ts @@ -4,19 +4,31 @@ import { isToolStateCompleted, isToolStateError, isToolStateRunning, + limitToolOutputForRender, readToolStatePayload, } from "./utils" +import { exceedsRetainedByteLimit } from "../../lib/session-memory-budget" type QuestionOption = { label?: unknown; description?: unknown } type QuestionPrompt = { header?: unknown; question?: unknown; options?: unknown; multiple?: unknown; answer?: unknown } +const TOOL_SEARCH_CHARACTER_LIMIT = 10_000 +const searchSizes = new WeakMap() function appendString(values: string[], value: unknown) { - if (typeof value === "string" && value.trim().length > 0) values.push(value) + if (typeof value !== "string" || value.trim().length === 0) return + const used = searchSizes.get(values) ?? 0 + const remaining = TOOL_SEARCH_CHARACTER_LIMIT - used + if (remaining <= 0) return + const text = limitToolOutputForRender(value).slice(0, remaining) + values.push(text) + searchSizes.set(values, used + text.length) } function appendFormatted(values: string[], value: unknown) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) return + if (exceedsRetainedByteLimit(value, 10_000)) return const result = formatUnknown(value) - if (result?.text.trim()) values.push(result.text) + if (result?.text.trim()) appendString(values, result.text) } function appendBaseToolText(values: string[], context: ToolSearchTextContext) { diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index 496337877..db6d40ded 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -57,6 +57,7 @@ export interface ToolRendererContext { toolName: Accessor instanceId: string sessionId: string + visibilitySessionId: string t: (key: string, params?: Record) => string messageVersion?: Accessor partVersion?: Accessor @@ -73,6 +74,7 @@ export interface ToolRendererContext { messageVersion?: number partVersion?: number sessionId: string + visibilitySessionId?: string forceCollapsed?: boolean }) => JSXElement | null outputWrapEnabled?: Accessor diff --git a/packages/ui/src/components/tool-call/utils.test.ts b/packages/ui/src/components/tool-call/utils.test.ts new file mode 100644 index 000000000..772d18f14 --- /dev/null +++ b/packages/ui/src/components/tool-call/utils.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils.ts" + +test("tool output rendering keeps bounded head and tail content", () => { + const text = `HEAD${"x".repeat(20_000)}TAIL` + const rendered = limitToolOutputForRender(text) + assert.ok(rendered.length < text.length) + assert.ok(rendered.length < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 100) + assert.ok(rendered.startsWith("HEAD")) + assert.ok(rendered.endsWith("TAIL")) +}) diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 1ea469b5e..dc75ba019 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -4,6 +4,7 @@ import type { ToolState } from "@opencode-ai/sdk/v2" import type { DiffPayload } from "./types" import { getLogger } from "../../lib/logger" import { tGlobal } from "../../lib/i18n" +import { exceedsRetainedByteLimit } from "../../lib/session-memory-budget" const log = getLogger("session") @@ -12,6 +13,13 @@ export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateComplete export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError export const diffCapableTools = new Set(["edit", "patch"]) +export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000 + +export function limitToolOutputForRender(text: string): string { + if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text + const half = Math.floor(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT / 2) + return `${text.slice(0, half)}\n\n${tGlobal("toolCall.output.truncated")}\n\n${text.slice(-half)}` +} export function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" @@ -150,6 +158,19 @@ export function formatUnknown(value: unknown): { text: string; language?: string return null } +export function formatUnknownForRender(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: tGlobal("toolCall.output.tooLarge") } + } + const result = formatUnknown(value) + return result ? { ...result, text: limitToolOutputForRender(result.text) } : null +} + +export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) return null + return formatUnknown(value) +} + export function inferLanguageFromPath(path?: string): string | undefined { return getLanguageFromPath(path || "") } @@ -236,13 +257,14 @@ export function buildToolSpeechText(options: { }): string { const sections: string[] = [] - if (options.title.trim()) { - sections.push(options.title.trim()) + const title = limitToolOutputForRender(options.title).trim() + if (title) { + sections.push(title) } const { input, output } = readToolStatePayload(options.state) - const formattedInput = formatUnknown(input) - const formattedOutput = formatUnknown(output) + const formattedInput = formatUnknownForRender(input) + const formattedOutput = formatUnknownForRender(output) if (formattedInput?.text?.trim()) { sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`) @@ -252,13 +274,14 @@ export function buildToolSpeechText(options: { sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`) } - if (options.state?.status === "error" && options.state.error?.trim()) { - sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`) + const error = options.state?.status === "error" ? limitToolOutputForRender(options.state.error ?? "").trim() : "" + if (error) { + sections.push(`${options.t("toolCall.error.label")} ${error}`) } if (sections.length === 1 && options.state?.status === "pending") { sections.push(options.t("toolCall.pending.waitingToRun")) } - return sections.join("\n\n").trim() + return limitToolOutputForRender(sections.join("\n\n").trim()) } diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 8a7f17ad4..86982c83d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -76,6 +76,7 @@ export interface VirtualFollowListProps { resolveAutoPinHoldElement?: HoldTargetElementResolver autoPinHoldTopThresholdPx?: number suspendAutoPinToBottom?: Accessor + shift?: Accessor renderBeforeItems?: Accessor renderOverlay?: Accessor scrollToTopAriaLabel?: Accessor @@ -755,6 +756,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollRef={scrollElement()} data={props.items()} bufferSize={props.overscanPx ?? 400} + shift={props.shift?.() ?? false} onScroll={handleScroll} > {(item, index) => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index e11c97650..dcb26dd89 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -15,12 +15,13 @@ import { showAlertDialog } from "../../stores/alerts" import type { Instance } from "../../types/instance" import type { MessageRecord } from "../../stores/message-v2/types" import { messageStoreBus } from "../../stores/message-v2/bus" -import { cleanupBlankSessions } from "../../stores/session-state" +import { cleanupBlankSessions, invalidateSessionMessageLoad } from "../../stores/session-state" import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("actions") @@ -344,6 +345,11 @@ export function useCommands(options: UseCommandsOptions) { }), "session.revert", ) + if (activeInstance() !== instance) return + if (store.getSessionRevert(sessionId)?.messageID !== messageID) { + invalidateSessionMessageLoad(instance.id, sessionId) + invalidateSessionMessageCache(instance.id, sessionId) + } if (!restoredText) { const fallbackRecord = store.getMessage(messageID) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 49794fa81..c9475d15c 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen sowie Scrollpositionen, Panel-Layout, Fensterposition und Zoom wiederherstellen.", + "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen, das Layout wiederherstellen und aktuelle Sitzungsnachrichten für schnelleres Öffnen auf diesem Gerät zwischenspeichern.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", - "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. CodeNomad- und OpenCode-Daten werden nicht gelöscht.", + "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", "settings.appearance.startup.clearSuccess": "Gespeicherter Startzustand wurde gelöscht.", "settings.appearance.startup.clearError": "Der gespeicherte Startzustand konnte nicht gelöscht werden.", diff --git a/packages/ui/src/lib/i18n/messages/de/toolCall.ts b/packages/ui/src/lib/i18n/messages/de/toolCall.ts index c736663f8..670d344c6 100644 --- a/packages/ui/src/lib/i18n/messages/de/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/de/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Verzeichnis wird aufgelistet...", "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.input.tooLarge": "Die Eingabe wird nicht dargestellt, da sie zu groß ist.", + "toolCall.output.tooLarge": "Die strukturierte Ausgabe wird nicht dargestellt, da sie zu groß ist.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 7e79f5e06..47ba5e7ce 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore scroll positions, panel layout, window position, and zoom.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages; restore layout; and cache recent session messages on this device for faster reopening.", "settings.appearance.startup.clear.title": "Saved startup state", - "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. CodeNomad and OpenCode data are not deleted.", + "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", "settings.appearance.startup.clearSuccess": "Saved startup state cleared.", "settings.appearance.startup.clearError": "Could not clear the saved startup state.", diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 8bf7bec07..c7659c845 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listing directory...", "toolCall.renderer.bash.title.timeout": "Timeout: {timeout}", + "toolCall.output.truncated": "[Output truncated for rendering; copy to access the full output]", + "toolCall.input.tooLarge": "Input omitted from rendering because it is too large.", + "toolCall.output.tooLarge": "Structured output omitted from rendering because it is too large.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 268c30623..4417d8396 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas y los mensajes no enviados, y restaurar las posiciones de desplazamiento, el diseño de paneles, la posición de la ventana y el zoom.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, restaurar el diseño y guardar en caché los mensajes recientes en este dispositivo para abrirlos más rápido.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", - "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan datos de CodeNomad ni de OpenCode.", + "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", "settings.appearance.startup.clearSuccess": "Se borró el estado de inicio guardado.", "settings.appearance.startup.clearError": "No se pudo borrar el estado de inicio guardado.", diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index ea206440e..3cd68824e 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listando directorio...", "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.input.tooLarge": "La entrada no se muestra porque es demasiado grande.", + "toolCall.output.tooLarge": "La salida estructurada no se muestra porque es demasiado grande.", "toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}", "toolCall.renderer.read.detail.limit": "Límite: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 65c924f73..66a24e130 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives et les messages non envoyés, et restaurer les positions de défilement, la disposition des panneaux, la position de la fenêtre et le zoom.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, restaurer la disposition et mettre en cache les messages récents sur cet appareil pour accélérer leur réouverture.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", - "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les données CodeNomad et OpenCode ne sont pas supprimées.", + "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", "settings.appearance.startup.clearSuccess": "L’état de démarrage enregistré a été effacé.", "settings.appearance.startup.clearError": "Impossible d’effacer l’état de démarrage enregistré.", diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index e706e641f..5ab53393f 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Liste du répertoire...", "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.input.tooLarge": "Entrée omise de l’affichage car elle est trop volumineuse.", + "toolCall.output.tooLarge": "Sortie structurée omise de l’affichage car elle est trop volumineuse.", "toolCall.renderer.read.detail.offset": "Décalage : {offset}", "toolCall.renderer.read.detail.limit": "Limite : {limit}", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a7f9c7fb6..b571a02d5 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות והודעות שלא נשלחו, ושחזור מיקומי גלילה, פריסת חלוניות, מיקום החלון ורמת התקריב.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, שחזור הפריסה ושמירת הודעות אחרונות במטמון במכשיר זה לפתיחה מהירה יותר.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", - "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. נתוני CodeNomad ו-OpenCode לא יימחקו.", + "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", "settings.appearance.startup.clearSuccess": "מצב ההפעלה השמור נוקה.", "settings.appearance.startup.clearError": "לא ניתן לנקות את מצב ההפעלה השמור.", diff --git a/packages/ui/src/lib/i18n/messages/he/toolCall.ts b/packages/ui/src/lib/i18n/messages/he/toolCall.ts index 678092bc6..67e98a582 100644 --- a/packages/ui/src/lib/i18n/messages/he/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/he/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "מפרט ספרייה...", "toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}", + "toolCall.output.truncated": "[הפלט קוצר לצורך תצוגה; יש להעתיק כדי לגשת לפלט המלא]", + "toolCall.input.tooLarge": "הקלט לא מוצג מכיוון שהוא גדול מדי.", + "toolCall.output.tooLarge": "הפלט המובנה לא מוצג מכיוון שהוא גדול מדי.", "toolCall.renderer.read.detail.offset": "היסט: {offset}", "toolCall.renderer.read.detail.limit": "מגבלה: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 6b266a108..f67f47a6b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、未送信メッセージを再度開き、スクロール位置、パネル配置、ウィンドウ位置、ズームを復元します。", + "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元し、最近のセッションメッセージをこのデバイスにキャッシュして再表示を高速化します。", "settings.appearance.startup.clear.title": "保存された起動状態", - "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。CodeNomad と OpenCode のデータは削除されません。", + "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", "settings.appearance.startup.clearSuccess": "保存された起動状態を消去しました。", "settings.appearance.startup.clearError": "保存された起動状態を消去できませんでした。", diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 681d67f72..67624feb6 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "ディレクトリ一覧を取得中...", "toolCall.renderer.bash.title.timeout": "タイムアウト: {timeout}", + "toolCall.output.truncated": "[表示用に出力を省略しました。完全な出力にアクセスするにはコピーしてください]", + "toolCall.input.tooLarge": "入力が大きすぎるため表示を省略しました。", + "toolCall.output.tooLarge": "構造化出力が大きすぎるため表示を省略しました。", "toolCall.renderer.read.detail.offset": "オフセット: {offset}", "toolCall.renderer.read.detail.limit": "上限: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index cbfbb0953..dd5eae41a 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, र स्क्रोल स्थिति, प्यानल लेआउट, विन्डोको स्थान र जुम पुनर्स्थापना गर्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, लेआउट पुनर्स्थापना गर्नुहोस् र छिटो खोल्न हालका सत्र सन्देशहरू यस यन्त्रमा क्यास गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", - "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। CodeNomad र OpenCode का डेटा मेटिँदैनन्।", + "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", "settings.appearance.startup.clearSuccess": "सुरक्षित सुरुआत अवस्था खाली गरियो।", "settings.appearance.startup.clearError": "सुरक्षित सुरुआत अवस्था खाली गर्न सकिएन।", diff --git a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts index ad2b2a8c7..51356f933 100644 --- a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "डाइरेक्टरी सूचीबद्ध गर्दै...", "toolCall.renderer.bash.title.timeout": "समय समाप्त: {timeout}", + "toolCall.output.truncated": "[प्रदर्शनका लागि आउटपुट छोट्याइएको छ; पूर्ण आउटपुटका लागि प्रतिलिपि गर्नुहोस्]", + "toolCall.input.tooLarge": "इनपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", + "toolCall.output.tooLarge": "संरचित आउटपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", "toolCall.renderer.read.detail.offset": "अफसेट: {offset}", "toolCall.renderer.read.detail.limit": "सीमा: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index d364ab5a6..4c49cc0a5 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии и неотправленные сообщения, а также восстанавливать позиции прокрутки, расположение панелей, положение окна и масштаб.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения, восстанавливать макет и кэшировать недавние сообщения сессий на этом устройстве для быстрого открытия.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", - "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Данные CodeNomad и OpenCode не удаляются.", + "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", "settings.appearance.startup.clearSuccess": "Сохранённое состояние запуска очищено.", "settings.appearance.startup.clearError": "Не удалось очистить сохранённое состояние запуска.", diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index 7f18981ff..d84160e15 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Просмотр каталога…", "toolCall.renderer.bash.title.timeout": "Таймаут: {timeout}", + "toolCall.output.truncated": "[Вывод сокращён для отображения; скопируйте его для доступа к полному выводу]", + "toolCall.input.tooLarge": "Ввод не отображается, поскольку он слишком большой.", + "toolCall.output.tooLarge": "Структурированный вывод не отображается, поскольку он слишком большой.", "toolCall.renderer.read.detail.offset": "Смещение: {offset}", "toolCall.renderer.read.detail.limit": "Лимит: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 1eed86ce0..93b05b88c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话和未发送的消息,并恢复滚动位置、面板布局、窗口位置和缩放级别。", + "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,恢复布局,并在此设备上缓存最近的会话消息以便更快重新打开。", "settings.appearance.startup.clear.title": "已保存的启动状态", - "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除 CodeNomad 或 OpenCode 数据。", + "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", "settings.appearance.startup.clear.action": "清除已保存状态", "settings.appearance.startup.clearSuccess": "已清除保存的启动状态。", "settings.appearance.startup.clearError": "无法清除保存的启动状态。", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 07641548e..00621c696 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "正在列出目录...", "toolCall.renderer.bash.title.timeout": "超时:{timeout}", + "toolCall.output.truncated": "[输出已截断以便显示;复制即可访问完整输出]", + "toolCall.input.tooLarge": "输入内容过大,已省略显示。", + "toolCall.output.tooLarge": "结构化输出过大,已省略显示。", "toolCall.renderer.read.detail.offset": "偏移:{offset}", "toolCall.renderer.read.detail.limit": "限制:{limit}", diff --git a/packages/ui/src/lib/session-memory-budget.test.ts b/packages/ui/src/lib/session-memory-budget.test.ts new file mode 100644 index 000000000..e85923150 --- /dev/null +++ b/packages/ui/src/lib/session-memory-budget.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { estimateRetainedBytes, exceedsRetainedByteLimit, selectSessionMemoryEvictions } from "./session-memory-budget.ts" + +test("session memory eviction applies one byte budget across workspaces and subagents", () => { + const entries = Array.from({ length: 5 }, (_, workspace) => [ + { key: `${workspace}:parent`, byteSize: 8, lastTouched: workspace * 2 + 1, protected: workspace === 4 }, + { key: `${workspace}:subagent`, byteSize: 8, lastTouched: workspace * 2 + 2, protected: false }, + ]).flat() + + const evictions = selectSessionMemoryEvictions(entries, 40) + assert.deepEqual(evictions, ["0:parent", "0:subagent", "1:parent", "1:subagent", "2:parent"]) + assert.equal(evictions.includes("4:parent"), false) +}) + +test("session memory eviction favors old large sessions and permits protected overage", () => { + assert.deepEqual(selectSessionMemoryEvictions([ + { key: "visible", byteSize: 12, lastTouched: 3, protected: true }, + { key: "old-large", byteSize: 10, lastTouched: 1, protected: false }, + { key: "new-small", byteSize: 2, lastTouched: 2, protected: false }, + ], 12), ["old-large", "new-small"]) + assert.deepEqual(selectSessionMemoryEvictions([{ key: "visible", byteSize: 20, lastTouched: 1, protected: true }], 10), []) +}) + +test("retained byte estimates handle cycles without allocating serialized copies", () => { + const value: { text: string; self?: unknown } = { text: "hello" } + value.self = value + assert.ok(estimateRetainedBytes(value) >= 26) + assert.equal(exceedsRetainedByteLimit(Array.from({ length: 1_000 }, () => ({})), 10_000), true) +}) diff --git a/packages/ui/src/lib/session-memory-budget.ts b/packages/ui/src/lib/session-memory-budget.ts new file mode 100644 index 000000000..61709dca9 --- /dev/null +++ b/packages/ui/src/lib/session-memory-budget.ts @@ -0,0 +1,62 @@ +export const MAX_HOT_SESSION_MESSAGE_BYTES = 64 * 1024 * 1024 + +export interface SessionMemoryEntry { + key: string + byteSize: number + lastTouched: number + protected: boolean +} + +function measureRetainedBytes(value: unknown, limit: number): number { + const seen = new WeakSet() + const pending: unknown[] = [value] + let total = 0 + while (pending.length > 0 && total <= limit) { + 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") { + if (ArrayBuffer.isView(current)) { + total += 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)[key]) + } + } + } + return total +} + +export function estimateRetainedBytes(value: unknown, limit = Number.POSITIVE_INFINITY): number { + return measureRetainedBytes(value, limit) +} + +export function exceedsRetainedByteLimit(value: unknown, limit: number): boolean { + return measureRetainedBytes(value, limit) > limit +} + +export function selectSessionMemoryEvictions( + entries: readonly SessionMemoryEntry[], + byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES, +): string[] { + let total = entries.reduce((sum, entry) => sum + Math.max(0, entry.byteSize), 0) + if (total <= byteLimit) return [] + const candidates = entries + .filter((entry) => !entry.protected) + .sort((left, right) => left.lastTouched - right.lastTouched || right.byteSize - left.byteSize || left.key.localeCompare(right.key)) + const evictions: string[] = [] + for (const entry of candidates) { + if (total <= byteLimit) break + evictions.push(entry.key) + total -= Math.max(0, entry.byteSize) + } + return evictions +} diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts new file mode 100644 index 000000000..a62c79c6f --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createSessionMessageCacheKey, prepareSessionMessageCache, selectSessionMessageCacheEvictions } from "./session-message-cache.ts" + +test("session message cache keys normalize path separators", () => { + assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") +}) + +test("session message cache evicts the oldest entries to satisfy byte and count limits", () => { + const entries = [ + { key: "old", byteSize: 5, savedAt: 1, snapshotId: "1", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "middle", byteSize: 4, savedAt: 2, snapshotId: "2", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "new", byteSize: 3, savedAt: 3, snapshotId: "3", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + ] + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 7, 3), ["old"]) + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 20, 2), ["old"]) +}) + +test("session message cache retains the newest contiguous messages within its budget", () => { + const messages = ["one", "two", "three"].map((id) => ({ info: { id }, parts: [{ type: "text", text: id.repeat(10) }] })) + const byteLimit = messages.slice(1).reduce((total, message) => total + JSON.stringify(message).length * 2, 0) + const prepared = prepareSessionMessageCache("session", messages, "snapshot", byteLimit, 1) + assert.deepEqual(prepared?.manifest.messageIds, ["two", "three"]) + assert.equal(prepared?.manifest.startIndex, 1) + assert.equal(prepared?.manifest.complete, false) + assert.equal(prepared?.records.length, 2) +}) + +test("session message cache stores an authoritative empty manifest", () => { + const prepared = prepareSessionMessageCache("session", [], "snapshot", 100, 1) + assert.deepEqual(prepared?.manifest.messageIds, []) + assert.equal(prepared?.manifest.complete, true) + assert.equal(prepared?.manifest.totalCount, 0) +}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts new file mode 100644 index 000000000..c58ce1c74 --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.ts @@ -0,0 +1,483 @@ +const DATABASE_NAME = "codenomad-session-messages" +const DATABASE_VERSION = 2 +const MANIFEST_STORE = "session-manifests" +const MESSAGE_STORE = "session-messages" +const SESSION_INDEX = "by-session" + +export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 +export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 +export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 +export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 + +export interface SessionMessageCacheManifest { + key: string + snapshotId: string + messageIds: string[] + startIndex: number + totalCount: number + complete: boolean + byteSize: number + savedAt: number +} + +interface SessionMessageCacheRecord { + sessionKey: string + snapshotId: string + messageId: string + ordinal: number + payload: string +} + +export interface SessionMessageCacheCursor { + key: string + snapshotId: string + messageIds: readonly string[] + beforeIndex: number + startIndex: number + totalCount: number + complete: boolean +} + +export interface SessionMessageCachePage { + messages: unknown[] + startIndex: number + totalCount: number + done: boolean + complete: boolean +} + +interface PreparedSessionMessageCache { + manifest: SessionMessageCacheManifest + records: SessionMessageCacheRecord[] +} + +let databasePromise: Promise | null = null +let clearGeneration = 0 +let cacheEnabled = false +const resetListeners = new Set<() => void>() +let mutationQueue: Promise = Promise.resolve() + +export function isSessionMessageCacheEnabled(): boolean { + return cacheEnabled +} + +export function setSessionMessageCacheEnabled(enabled: boolean): void { + cacheEnabled = enabled + if (!enabled) resetListeners.forEach((listener) => listener()) +} + +export function onSessionMessageCacheReset(listener: () => void): () => void { + resetListeners.add(listener) + return () => resetListeners.delete(listener) +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) + }) +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")) + transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")) + }) +} + +function openDatabase(): Promise { + if (typeof indexedDB === "undefined") return Promise.resolve(null) + if (databasePromise) return databasePromise + + const opening = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + const database = request.result + for (const legacy of ["payloads", "metadata"]) { + if (database.objectStoreNames.contains(legacy)) database.deleteObjectStore(legacy) + } + if (!database.objectStoreNames.contains(MANIFEST_STORE)) { + database.createObjectStore(MANIFEST_STORE, { keyPath: "key" }) + } + if (!database.objectStoreNames.contains(MESSAGE_STORE)) { + const store = database.createObjectStore(MESSAGE_STORE, { + keyPath: ["sessionKey", "snapshotId", "messageId"], + }) + store.createIndex(SESSION_INDEX, "sessionKey") + } + } + request.onsuccess = () => { + const database = request.result + database.onversionchange = () => { + database.close() + databasePromise = null + } + resolve(database) + } + request.onerror = () => reject(request.error ?? new Error("Failed to open session message cache")) + }) + databasePromise = opening.catch((error) => { + databasePromise = null + throw error + }) + return databasePromise +} + +function createSnapshotId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function createSessionMessageCacheKey(workspace: string, sessionId: string): string { + return `${workspace.replace(/\\/g, "/").replace(/\/+$/, "")}\u0000${sessionId}` +} + +function messageIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { id?: unknown }; id?: unknown } + const id = candidate.info?.id ?? candidate.id + return typeof id === "string" && id.length > 0 ? id : null +} + +function messageSessionIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { sessionID?: unknown }; sessionId?: unknown } + const id = candidate.info?.sessionID ?? candidate.sessionId + return typeof id === "string" && id.length > 0 ? id : null +} + +function sessionIdFromKey(key: string): string { + return key.slice(key.lastIndexOf("\u0000") + 1) +} + +function isValidManifest(value: unknown, expectedKey: string): value is SessionMessageCacheManifest { + if (!value || typeof value !== "object") return false + const manifest = value as SessionMessageCacheManifest + return manifest.key === expectedKey && + typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && + Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && + new Set(manifest.messageIds).size === manifest.messageIds.length && + Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && + Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && + manifest.complete === (manifest.startIndex === 0) && + Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && + Number.isFinite(manifest.savedAt) +} + +function exceedsJsonByteLimit(value: unknown, limit: number): boolean { + const seen = new WeakSet() + let bytes = 0 + const addString = (text: string) => { + bytes += 4 + for (let index = 0; index < text.length && bytes <= limit; index += 1) { + const code = text.charCodeAt(index) + bytes += code < 0x20 || (code >= 0xd800 && code <= 0xdfff) ? 12 : code === 0x22 || code === 0x5c ? 4 : 2 + } + } + const visit = (current: unknown, depth: number): void => { + if (bytes > limit || depth > 100) { + bytes = limit + 1 + return + } + if (typeof current === "string") addString(current) + else if (typeof current === "number" || typeof current === "bigint") bytes += String(current).length * 2 + else if (typeof current === "boolean") bytes += current ? 8 : 10 + else if (current === null) bytes += 8 + else if (Array.isArray(current)) { + if (seen.has(current)) { bytes = limit + 1; return } + seen.add(current) + bytes += 4 + Math.max(0, current.length - 1) * 2 + for (const item of current) visit(item === undefined ? null : item, depth + 1) + } else if (current && typeof current === "object") { + if (seen.has(current)) { bytes = limit + 1; return } + seen.add(current) + bytes += 4 + let count = 0 + for (const key in current) { + if (!Object.prototype.hasOwnProperty.call(current, key)) continue + const item = (current as Record)[key] + if (item === undefined || typeof item === "function" || typeof item === "symbol") continue + if (count++ > 0) bytes += 2 + addString(key) + bytes += 2 + visit(item, depth + 1) + if (bytes > limit) break + } + } + } + visit(value, 0) + return bytes > limit +} + +export function prepareSessionMessageCache( + key: string, + messages: readonly unknown[], + snapshotId = createSnapshotId(), + byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, + savedAt = Date.now(), +): PreparedSessionMessageCache | null { + let byteSize = 0 + let startIndex = messages.length + const records: SessionMessageCacheRecord[] = [] + const seen = new Set() + try { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const messageId = messageIdOf(messages[index]) + if (!messageId || seen.has(messageId)) return null + seen.add(messageId) + const remaining = byteLimit - byteSize + if (exceedsJsonByteLimit(messages[index], remaining)) break + const payload = JSON.stringify(messages[index]) + const payloadBytes = payload.length * 2 + if (payloadBytes > byteLimit || byteSize + payloadBytes > byteLimit) break + byteSize += payloadBytes + startIndex = index + records.push({ + sessionKey: key, + snapshotId, + messageId, + ordinal: index, + payload, + }) + } + } catch { + return null + } + if (messages.length > 0 && startIndex === messages.length) return null + records.reverse() + return { + manifest: { + key, + snapshotId, + messageIds: records.map((record) => record.messageId), + startIndex, + totalCount: messages.length, + complete: startIndex === 0, + byteSize, + savedAt, + }, + records, + } +} + +export function selectSessionMessageCacheEvictions( + entries: readonly SessionMessageCacheManifest[], + byteLimit = MAX_TOTAL_MESSAGE_CACHE_BYTES, + entryLimit = MAX_SESSION_MESSAGE_CACHE_ENTRIES, +): string[] { + const oldestFirst = [...entries].sort((left, right) => left.savedAt - right.savedAt || left.key.localeCompare(right.key)) + let bytes = oldestFirst.reduce((total, entry) => total + entry.byteSize, 0) + let count = oldestFirst.length + const evictions: string[] = [] + for (const entry of oldestFirst) { + if (bytes <= byteLimit && count <= entryLimit) break + evictions.push(entry.key) + bytes -= entry.byteSize + count -= 1 + } + return evictions +} + +function enqueueMutation(operation: () => Promise): Promise { + const result = mutationQueue.then(operation) + mutationQueue = result.then(() => undefined, () => undefined) + return result +} + +async function readSessionRecordKeys(database: IDBDatabase, sessionKeys: readonly string[]): Promise> { + if (sessionKeys.length === 0) return new Map() + const transaction = database.transaction(MESSAGE_STORE, "readonly") + const completion = transactionDone(transaction) + const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) + const requests = sessionKeys.map(async (sessionKey) => [ + sessionKey, + await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), + ] as const) + const entries = await Promise.all(requests) + await completion + return new Map(entries) +} + +export async function openSessionMessageCache( + workspace: string, + sessionId: string, +): Promise { + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction(MANIFEST_STORE, "readonly") + const completion = transactionDone(transaction) + const key = createSessionMessageCacheKey(workspace, sessionId) + const manifest = await requestResult(transaction.objectStore(MANIFEST_STORE).get(key)) as unknown + await completion + if (generation !== clearGeneration) return null + if (!manifest) return null + if (!isValidManifest(manifest, key)) { + await deleteCorruptSessionMessageCacheKey(key, manifest) + return null + } + return { + key: manifest.key, + snapshotId: manifest.snapshotId, + messageIds: manifest.messageIds, + beforeIndex: manifest.messageIds.length, + startIndex: manifest.startIndex, + totalCount: manifest.totalCount, + complete: manifest.complete, + } +} + +export async function readSessionMessageCachePage( + cursor: SessionMessageCacheCursor, + limit = DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE, +): Promise<{ page: SessionMessageCachePage; cursor: SessionMessageCacheCursor } | null> { + if (cursor.beforeIndex <= 0) return null + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readonly") + const completion = transactionDone(transaction) + const manifestRequest = transaction.objectStore(MANIFEST_STORE).get(cursor.key) + const start = Math.max(0, cursor.beforeIndex - Math.max(1, limit)) + const ids = cursor.messageIds.slice(start, cursor.beforeIndex) + const messageStore = transaction.objectStore(MESSAGE_STORE) + const requests = ids.map((messageId) => requestResult(messageStore.get([cursor.key, cursor.snapshotId, messageId]))) + const manifest = await requestResult(manifestRequest) as unknown + const records = await Promise.all(requests) as Array + await completion + if (generation !== clearGeneration) return null + if (!isValidManifest(manifest, cursor.key) || manifest.snapshotId !== cursor.snapshotId) return null + if (records.some((record) => !record)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + + try { + const messages = records.map((record) => JSON.parse(record!.payload)) + const sessionId = sessionIdFromKey(cursor.key) + if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + const nextCursor = { ...cursor, beforeIndex: start } + return { + page: { + messages, + startIndex: cursor.startIndex + start, + totalCount: cursor.totalCount, + done: start === 0, + complete: cursor.complete, + }, + cursor: nextCursor, + } + } catch { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } +} + +export async function writeSessionMessageCache(workspace: string, sessionId: string, messages: unknown[]): Promise { + const generation = clearGeneration + const key = createSessionMessageCacheKey(workspace, sessionId) + const prepared = prepareSessionMessageCache(key, messages) + if (!prepared) { + await deleteSessionMessageCache(workspace, sessionId) + return false + } + return enqueueMutation(async () => { + const database = await openDatabase() + if (!database || generation !== clearGeneration) return false + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const existing = await requestResult(manifestRead.objectStore(MANIFEST_STORE).getAll()) as unknown[] + await manifestReadDone + const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { + const manifestKey = (manifest as { key?: unknown })?.key + return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) + }) + const invalidKeys = existing + .filter((manifest) => !validExisting.includes(manifest as SessionMessageCacheManifest)) + .map((manifest) => (manifest as { key?: unknown })?.key) + .filter((manifestKey): manifestKey is string => typeof manifestKey === "string" && manifestKey !== key) + prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) + const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] + const evictions = selectSessionMessageCacheEvictions(projected) + const recordKeys = await readSessionRecordKeys(database, [key, ...evictions, ...invalidKeys]) + if (generation !== clearGeneration) return false + + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const manifestStore = transaction.objectStore(MANIFEST_STORE) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + for (const record of prepared.records) messageStore.put(record) + manifestStore.put(prepared.manifest) + for (const evictedKey of evictions) { + for (const recordKey of recordKeys.get(evictedKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(evictedKey) + } + for (const invalidKey of invalidKeys) { + for (const recordKey of recordKeys.get(invalidKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(invalidKey) + } + await completion + return true + }) +} + +export async function deleteSessionMessageCache(workspace: string, sessionId: string): Promise { + const key = createSessionMessageCacheKey(workspace, sessionId) + await deleteSessionMessageCacheKey(key) +} + +async function deleteSessionMessageCacheKey(key: string): Promise { + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknown): Promise { + const observedSnapshotId = (observed as { snapshotId?: unknown })?.snapshotId + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const current = await requestResult(manifestRead.objectStore(MANIFEST_STORE).get(key)) as unknown + await manifestReadDone + if (!current) return + if (typeof observedSnapshotId === "string") { + if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return + } else if (isValidManifest(current, key)) { + return + } + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +export async function clearSessionMessageCache(): Promise { + clearGeneration += 1 + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + transaction.objectStore(MANIFEST_STORE).clear() + transaction.objectStore(MESSAGE_STORE).clear() + await completion + }) +} diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index eb788067e..53be8035a 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -166,6 +166,7 @@ class SSEManager { log.warn("Dropping malformed event", event) return } + if (this.shouldHandleEvent && !this.shouldHandleEvent(instanceId)) return log.info("Received event", { type: event.type, event }) @@ -293,6 +294,7 @@ class SSEManager { onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void onWorktreeReady?: (instanceId: string, event: WorktreeReadyEvent) => void | Promise onConnectionLost?: (instanceId: string, reason: string) => void | Promise + shouldHandleEvent?: (instanceId: string) => boolean getStatus(instanceId: string): ConnectionStatus | null { return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus()) diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index b13376e95..fb29dca9a 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,6 +2,7 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" +import { clearSessionMessageCache, setSessionMessageCacheEnabled } from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -233,6 +234,8 @@ export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) + setSessionMessageCacheEnabled(false) + await clearSessionMessageCache() }) } @@ -244,15 +247,19 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) return } setRestorePreviousStateEnabledSignal(false) + setSessionMessageCacheEnabled(false) try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) throw error } + await clearSessionMessageCache() }) } @@ -273,10 +280,12 @@ export function initializeClientState(): Promise { writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) + setSessionMessageCacheEnabled(!writeBlock) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true setClientStateIsPrimary(false) + setSessionMessageCacheEnabled(false) resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/delta-buffer.test.ts b/packages/ui/src/stores/delta-buffer.test.ts index 09072783e..952cbc8e6 100644 --- a/packages/ui/src/stores/delta-buffer.test.ts +++ b/packages/ui/src/stores/delta-buffer.test.ts @@ -6,6 +6,7 @@ import { clearPendingDeltasForPart, enqueueDelta, flushPendingDeltasForMessage, + holdDelta, resetDeltaBufferForTests, setFlushCallback, } from "./delta-buffer.ts" @@ -99,4 +100,22 @@ describe("delta buffer", () => { ], ]) }) + + it("holds an orphan delta until its HTTP baseline is resident", () => { + holdDelta("instance-1", "message-1", "part-1", "text", "late") + let attempts = 0 + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return false + }) + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return true + }) + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return true + }) + assert.equal(attempts, 2) + }) }) diff --git a/packages/ui/src/stores/delta-buffer.ts b/packages/ui/src/stores/delta-buffer.ts index 3307257a4..d63cb8ccd 100644 --- a/packages/ui/src/stores/delta-buffer.ts +++ b/packages/ui/src/stores/delta-buffer.ts @@ -9,19 +9,46 @@ const DELTA_FLUSH_INTERVAL = 50 -const pendingDeltas = new Map() +type PendingDelta = { instanceId: string; sessionId?: string; messageId: string; partId: string; field: string; delta: string } +const pendingDeltas = new Map() let deltaFlushTimer: ReturnType | null = null -export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string) { +export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string, sessionId?: string) { const key = `${instanceId}:${messageId}:${partId}:${field}` const existing = pendingDeltas.get(key) const accumulated = existing ? existing.delta + delta : delta - pendingDeltas.set(key, { instanceId, messageId, partId, field, delta: accumulated }) + const resolvedSessionId = sessionId ?? existing?.sessionId + pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: accumulated }) if (deltaFlushTimer === null) { deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL) } } +export function holdDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string, sessionId?: string) { + const key = `${instanceId}:${messageId}:${partId}:${field}` + const existing = pendingDeltas.get(key) + const resolvedSessionId = sessionId ?? existing?.sessionId + pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: existing ? existing.delta + delta : delta }) +} + +export function clearPendingDeltasForSession(instanceId: string, sessionId: string): void { + for (const [key, pending] of pendingDeltas) if (pending.instanceId === instanceId && pending.sessionId === sessionId) pendingDeltas.delete(key) +} + +export function clearPendingDeltasForInstance(instanceId: string): void { + for (const [key, pending] of pendingDeltas) if (pending.instanceId === instanceId) pendingDeltas.delete(key) +} + +export function clearPendingDeltasForMessage(instanceId: string, messageId: string): boolean { + let cleared = false + for (const [key, pending] of pendingDeltas) { + if (pending.instanceId !== instanceId || pending.messageId !== messageId) continue + pendingDeltas.delete(key) + cleared = true + } + return cleared +} + export function clearPendingDeltasForPart(instanceId: string, messageId: string, partId: string) { const keysToDelete: string[] = [] for (const key of pendingDeltas.keys()) { @@ -37,7 +64,7 @@ export function clearPendingDeltasForPart(instanceId: string, messageId: string, export function flushPendingDeltasForMessage( instanceId: string, messageId: string, - applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => void + applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => boolean | void ): void { const prefix = `${instanceId}:${messageId}:` const keysToFlush: string[] = [] @@ -49,19 +76,19 @@ export function flushPendingDeltasForMessage( for (const key of keysToFlush) { const pending = pendingDeltas.get(key) if (pending) { - pendingDeltas.delete(key) - applyDelta(instanceId, { + const applied = applyDelta(instanceId, { messageId: pending.messageId, partId: pending.partId, field: pending.field, delta: pending.delta, }) + if (applied !== false) pendingDeltas.delete(key) } } } export function setFlushCallback( - callback: (batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void + callback: (batch: PendingDelta[]) => void ) { // Store callback for flushDeltas to use flushCallback = callback @@ -76,7 +103,7 @@ export function resetDeltaBufferForTests() { flushCallback = null } -let flushCallback: ((batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void) | null = null +let flushCallback: ((batch: PendingDelta[]) => void) | null = null function flushDeltas() { deltaFlushTimer = null diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 04a40f3dc..77d1d50e6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -36,6 +36,7 @@ import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + purgeInstanceSessionState, sessions, setSessionPendingPermission, setSessionPendingQuestion, @@ -113,6 +114,7 @@ serverEvents.on("yolo.autoAccepted", (event) => { }) const [instances, setInstances] = createSignal>(new Map()) +sseManager.shouldHandleEvent = (instanceId) => instances().has(instanceId) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) @@ -679,8 +681,8 @@ async function rehydrateInstance(instanceId: string, options?: { reason?: string return pendingRehydrations.get(instanceId) } + const instance = instances().get(instanceId) const promise = (async () => { - const instance = instances().get(instanceId) if (!instance?.client) { return } @@ -689,8 +691,9 @@ async function rehydrateInstance(instanceId: string, options?: { reason?: string clearReloadableInstanceState(instanceId) await hydrateInstanceData(instanceId, { force: true }) + if (instances().get(instanceId) !== instance) return })().finally(() => { - pendingRehydrations.delete(instanceId) + if (pendingRehydrations.get(instanceId) === promise) pendingRehydrations.delete(instanceId) }) pendingRehydrations.set(instanceId, promise) @@ -702,14 +705,15 @@ async function disposeInstance(instanceId: string): Promise { return pendingDisposeRequests.get(instanceId)! } + const instance = instances().get(instanceId) const promise = (async () => { const ok = await postInstanceDispose(instanceId) - if (ok) { + if (ok && instances().get(instanceId) === instance) { await rehydrateInstance(instanceId, { reason: "disposed" }) } return ok })().finally(() => { - pendingDisposeRequests.delete(instanceId) + if (pendingDisposeRequests.get(instanceId) === promise) pendingDisposeRequests.delete(instanceId) }) pendingDisposeRequests.set(instanceId, promise) @@ -983,6 +987,8 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { initialHydrations.delete(id) initialSessionHydrations.delete(id) initialWorkspaceMetadataHydrations.delete(id) + pendingDisposeRequests.delete(id) + pendingRehydrations.delete(id) settleInstanceReadyWaiters(id, new Error(`Workspace ${id} was removed before it became ready`)) if (activeInstanceId() === id) { @@ -998,6 +1004,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { clearInstanceDeletedSessionAuthority(id) clearInstanceSessionExpansionState(id) clearInstanceSessionSelection(id) + purgeInstanceSessionState(id) if (removedInstance && removedOccurrence >= 0 && options.authoritative !== false) { publishInstanceLifecycleAuthority({ type: "removed", diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 44c3872a1..eab9b834e 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -14,7 +14,7 @@ interface SessionMetadata { parentId?: string | null } -function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined { +function resolveSessionMetadata(session?: Session | SessionMetadata | null): SessionMetadata | undefined { if (!session) return undefined return { id: session.id, @@ -71,6 +71,38 @@ export function seedSessionMessagesV2( return true } +export function mergeCachedSessionMessagePageV2( + instanceId: string, + session: Session | SessionMetadata, + messages: Message[], + messageInfos: Map, + expectedRevision: number, +): number | null { + if (!session || !Array.isArray(messages)) return null + const store = messageStoreBus.getOrCreate(instanceId) + if (!canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return null + const metadata = resolveSessionMetadata(session) + if (!metadata) return null + store.addOrUpdateSession({ + id: metadata.id, + title: metadata.title, + parentId: metadata.parentId ?? null, + revert: (session as Session)?.revert ?? undefined, + }) + store.mergeCachedMessages(metadata.id, messages.map((message) => ({ + id: message.id, + sessionId: message.sessionId, + role: message.type, + status: normalizeStatus(message.status), + createdAt: message.timestamp, + updatedAt: message.timestamp, + parts: message.parts, + isEphemeral: false, + bumpRevision: false, + })), messageInfos.values()) + return store.getSessionRevision(metadata.id) +} + interface MessageInfoOptions { status?: MessageStatus bumpRevision?: boolean @@ -81,17 +113,19 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number; completed?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : timeInfo.completed + const status = options?.status ?? "complete" store.upsertMessage({ id: info.id, sessionId: info.sessionID, role: info.role === "user" ? "user" : "assistant", - status: options?.status ?? "complete", + status, createdAt, updatedAt: endAt ?? createdAt, + isEphemeral: status === "sending" || status === "streaming", bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) @@ -111,12 +145,12 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | export function applyPartDeltaV2( instanceId: string, input: { messageId: string; partId: string; field: string; delta: string }, -): void { +): boolean { if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { - return + return false } const store = messageStoreBus.getOrCreate(instanceId) - store.applyPartDelta({ + return store.applyPartDelta({ messageId: input.messageId, partId: input.partId, field: input.field, diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 86900cd9f..f20307fa2 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -1,6 +1,6 @@ import { createInstanceMessageStore } from "./instance-store" import type { InstanceMessageStore } from "./instance-store" -import { clearCacheForInstance } from "../../lib/global-cache" +import { clearCacheForInstance, clearCacheForSession } from "../../lib/global-cache" import { getLogger } from "../../lib/logger" import type { ScrollSnapshot } from "./types" @@ -16,6 +16,7 @@ class MessageStoreBus { private stores = new Map() 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 scrollSnapshotHandlers = new Set< (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void >() @@ -30,6 +31,7 @@ class MessageStoreBus { store ?? createInstanceMessageStore(instanceId, { onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId), + onSessionChanged: (id, sessionId) => this.notifySessionChanged(id, sessionId), onScrollSnapshotChanged: (id, sessionId, scope, snapshot) => this.notifyScrollSnapshotChanged(id, sessionId, scope, snapshot), }) @@ -52,6 +54,7 @@ class MessageStoreBus { } private notifySessionCleared(instanceId: string, sessionId: string) { + clearCacheForSession(instanceId, sessionId) for (const handler of this.sessionClearHandlers) { try { handler(instanceId, sessionId) @@ -61,6 +64,21 @@ class MessageStoreBus { } } + onSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + this.sessionChangeHandlers.add(handler) + return () => this.sessionChangeHandlers.delete(handler) + } + + private notifySessionChanged(instanceId: string, sessionId: string) { + for (const handler of this.sessionChangeHandlers) { + try { + handler(instanceId, sessionId) + } catch (error) { + log.error("Failed to run session change handler", error) + } + } + } + onScrollSnapshotChanged( handler: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void, ): () => void { @@ -111,6 +129,10 @@ class MessageStoreBus { return this.registerInstance(instanceId) } + entries(): IterableIterator<[string, InstanceMessageStore]> { + return this.stores.entries() + } + clearInstanceScrollSnapshots(instanceId: string): void { this.stores.get(instanceId)?.clearScrollSnapshots() this.scrollSnapshotSeeds.delete(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 975b4b9f6..77c04ab10 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -41,4 +41,57 @@ describe("message-v2 permission state", () => { assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true) }) + it("protects legacy pending permissions that use sessionId", () => { + const store = createInstanceMessageStore("instance-1") + store.addOrUpdateSession({ id: "session-1" }) + store.upsertPermission({ permission: { id: "legacy", sessionId: "session-1", permission: "edit" }, enqueuedAt: 1 }) + assert.equal(store.hasSessionActiveWork("session-1"), true) + }) + +}) + +describe("message-v2 authoritative hydration", () => { + const message = (id: string) => ({ + id, + sessionId: "session-1", + role: "assistant" as const, + status: "complete" as const, + parts: [{ id: `part-${id}`, type: "text", text: id, messageID: id, sessionID: "session-1" }] as any, + }) + const info = (id: string) => ({ id, sessionID: "session-1", role: "assistant", time: { created: 1 } }) as any + + it("replaces stale messages and accepts an authoritative empty session", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1"), message("message-2")], [info("message-1"), info("message-2")]) + + store.hydrateMessages("session-1", [message("message-2")], [info("message-2")]) + assert.equal(store.getMessage("message-1"), undefined) + assert.equal(store.getMessageInfo("message-1"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-2"]) + + store.hydrateMessages("session-1", [], []) + assert.equal(store.getMessage("message-2"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), []) + }) + + it("prepends older cache pages without overwriting live messages", () => { + const store = createInstanceMessageStore("instance-1") + store.mergeCachedMessages("session-1", [message("message-3"), message("message-4")]) + store.upsertMessage({ + ...message("message-4"), + parts: [{ id: "part-message-4", type: "text", text: "live", messageID: "message-4", sessionID: "session-1" }] as any, + }) + store.mergeCachedMessages("session-1", [message("message-1"), message("message-2"), message("message-4")]) + + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-1", "message-2", "message-3", "message-4"]) + assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") + }) + + it("bumps authority when a revert anchor is not resident", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) + const revision = store.getSessionRevision("session-1") + store.setSessionRevert("session-1", { messageID: "evicted-anchor" }) + assert.ok(store.getSessionRevision("session-1") > revision) + }) }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 437bea3d4..837a18233 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -11,9 +11,13 @@ import { setPromptDisplayOverride, } from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" -import { mergePermissionRequest } from "../../types/permission" +import { getPermissionSessionId, mergePermissionRequest } from "../../types/permission" +import { getQuestionSessionId } from "../../types/question" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" +import { estimateRetainedBytes } from "../../lib/session-memory-budget" import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" + +const DERIVED_RENDER_MEMORY_MULTIPLIER = 3 import type { InstanceMessageState, LatestTodoSnapshot, @@ -35,6 +39,7 @@ const storeLog = getLogger("session") interface MessageStoreHooks { onSessionCleared?: (instanceId: string, sessionId: string) => void + onSessionChanged?: (instanceId: string, sessionId: string) => void onScrollSnapshotChanged?: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void } @@ -216,6 +221,7 @@ export interface InstanceMessageStore { setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void + mergeCachedMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void applyPartDelta: (input: { @@ -225,7 +231,7 @@ export interface InstanceMessageStore { delta: string bumpRevision?: boolean bumpSessionRevision: boolean - }) => void + }) => boolean removeMessage: (messageId: string, fallbackSessionId?: string) => void removeMessagePart: (messageId: string, partId: string, fallbackSessionId?: string) => void bufferPendingPart: (entry: PendingPartEntry) => void @@ -248,6 +254,10 @@ export interface InstanceMessageStore { getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined getSessionRevision: (sessionId: string) => number getSessionMessageIds: (sessionId: string) => string[] + getResidentSessionIds: () => string[] + getSessionApproximateByteSize: (sessionId: string) => number + hasSessionActiveWork: (sessionId: string) => boolean + hasSessionPendingInput: (sessionId: string) => boolean getLastAssistantMessageId: (sessionId: string) => string | undefined // Index of the most recent message in the session that contains a compaction part. // Returns -1 if there has been no compaction. @@ -347,12 +357,46 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function bumpSessionRevision(sessionId: string) { if (!sessionId) return setState("sessionRevisions", sessionId, (value = 0) => value + 1) + hooks?.onSessionChanged?.(instanceId, sessionId) } function getSessionRevisionValue(sessionId: string) { return state.sessionRevisions[sessionId] ?? 0 } + function getResidentSessionIds() { + return Object.values(state.sessions) + .filter((session) => session.messageIds.length > 0) + .map((session) => session.id) + } + + function getSessionApproximateByteSize(sessionId: string) { + const session = state.sessions[sessionId] + if (!session) return 0 + let bytes = estimateRetainedBytes(session) + estimateRetainedBytes(state.usage[sessionId]) + for (const messageId of session.messageIds) { + bytes += estimateRetainedBytes(state.messages[messageId]) + bytes += estimateRetainedBytes(messageInfoCache.get(messageId)) + } + // ponytail: account conservatively for parsed Markdown/diff caches without walking every renderer cache. + return bytes * DERIVED_RENDER_MEMORY_MULTIPLIER + } + + function hasSessionActiveWork(sessionId: string) { + const session = state.sessions[sessionId] + if (!session) return false + if (session.messageIds.some((messageId) => { + const status = state.messages[messageId]?.status + return status === "sending" || status === "streaming" + })) return true + return hasSessionPendingInput(sessionId) + } + + function hasSessionPendingInput(sessionId: string) { + return state.permissions.queue.some((entry) => getPermissionSessionId(entry.permission) === sessionId) || + state.questions.queue.some((entry) => getQuestionSessionId(entry.request) === sessionId) + } + function getLastAssistantMessageIdValue(sessionId: string) { return state.lastAssistantMessageIds[sessionId] } @@ -432,11 +476,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs) || inputs.length === 0) return + if (!Array.isArray(inputs)) return ensureSessionEntry(sessionId) const incomingIds = inputs.map((item) => item.id) + const incomingIdSet = new Set(incomingIds) + const staleIds = Object.values(state.messages) + .filter((record) => record.sessionId === sessionId && !incomingIdSet.has(record.id)) + .map((record) => record.id) const normalizedRecords: Record = {} const now = Date.now() @@ -471,6 +519,22 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const nextPermissionsByMessage: Record> = { ...state.permissions.byMessage, } + const nextQuestionsByMessage: Record> = { + ...state.questions.byMessage, + } + + if (staleIds.length > 0) { + clearRecordDisplayCacheForMessages(instanceId, staleIds) + for (const id of staleIds) { + delete nextMessages[id] + delete nextMessageInfoVersion[id] + delete nextPendingParts[id] + delete nextPermissionsByMessage[id] + delete nextQuestionsByMessage[id] + messageInfoCache.delete(id) + clearPromptDisplayOverride(instanceId, sessionId, id) + } + } Object.entries(normalizedRecords).forEach(([id, record]) => { nextMessages[id] = record @@ -486,10 +550,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } batch(() => { - setState("messages", () => nextMessages) - setState("messageInfoVersion", () => nextMessageInfoVersion) - setState("pendingParts", () => nextPendingParts) - setState("permissions", "byMessage", () => nextPermissionsByMessage) + setState("messages", reconcile(nextMessages)) + setState("messageInfoVersion", reconcile(nextMessageInfoVersion)) + setState("pendingParts", reconcile(nextPendingParts)) + setState("permissions", "byMessage", reconcile(nextPermissionsByMessage)) + setState("questions", "byMessage", reconcile(nextQuestionsByMessage)) if (usageState) { setState("usage", sessionId, usageState) @@ -502,6 +567,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt })) recomputeLastAssistantMessageId(sessionId, incomingIds) + clearLatestTodoSnapshot(sessionId) Object.values(normalizedRecords).forEach((record) => { maybeUpdateLatestTodoFromRecord(record) }) @@ -510,6 +576,61 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } + function mergeCachedMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { + if (!Array.isArray(inputs) || inputs.length === 0) return + ensureSessionEntry(sessionId) + const existingIds = new Set(state.sessions[sessionId]?.messageIds ?? []) + const additions = inputs.filter((input) => !existingIds.has(input.id) && !state.messages[input.id]) + if (additions.length === 0) return + + const infoById = new Map() + if (infos) { + for (const info of infos) { + if (typeof info.id === "string") infoById.set(info.id, info) + } + } + const additionIds = additions.map((input) => input.id) + const now = Date.now() + const normalizedRecords: Record = {} + for (const input of additions) { + const normalizedParts = normalizeParts(input.id, input.parts) + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input) + normalizedRecords[input.id] = { + id: input.id, + sessionId: input.sessionId, + role: input.role, + status: input.status, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + isEphemeral: input.isEphemeral ?? false, + clientPromptDisplayMetadata, + revision: 0, + partIds: normalizedParts?.ids ?? [], + parts: normalizedParts?.map ?? {}, + } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) + } + + batch(() => { + setState("messages", (current) => ({ ...current, ...normalizedRecords })) + for (const messageId of additionIds) { + const info = infoById.get(messageId) + if (!info) continue + messageInfoCache.set(messageId, info) + setState("messageInfoVersion", messageId, (version = 0) => version + 1) + updateUsageWithInfo(info) + } + setState("sessions", sessionId, (session) => ({ + ...session, + messageIds: [...additionIds, ...(session?.messageIds ?? [])], + updatedAt: Date.now(), + })) + recomputeLastAssistantMessageId(sessionId) + Object.values(normalizedRecords).forEach((record) => maybeUpdateLatestTodoFromRecord(record)) + bumpSessionRevision(sessionId) + }) + } + function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -695,13 +816,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt bumpSessionRevision?: boolean }) { if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { - return + return false } const message = state.messages[input.messageId] if (!message) { // Best-effort: drop deltas for unknown messages. - return + return false } let applied = false @@ -730,6 +851,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (applied && (input.bumpSessionRevision ?? true)) { bumpSessionRevision(message.sessionId) } + return applied } function removeMessage(messageId: string, fallbackSessionId?: string) { @@ -1010,9 +1132,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt draft.active = draft.queue[0] ?? null }), ) + const sessionId = getPermissionSessionId(entry.permission) + if (sessionId) bumpSessionRevision(sessionId) } function removePermission(permissionId: string) { + const sessionId = getPermissionSessionId(state.permissions.queue.find((item) => item.permission.id === permissionId)?.permission) setState( "permissions", produce((draft) => { @@ -1033,6 +1158,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) }), ) + if (sessionId) bumpSessionRevision(sessionId) } function getPermissionState(messageId?: string, partId?: string) { @@ -1098,9 +1224,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } }), ) + const sessionId = getQuestionSessionId(entry.request) + if (sessionId) bumpSessionRevision(sessionId) } function removeQuestion(requestId: string) { + const sessionId = getQuestionSessionId(state.questions.queue.find((item) => item.request.id === requestId)?.request) setState( "questions", produce((draft) => { @@ -1121,6 +1250,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) }), ) + if (sessionId) bumpSessionRevision(sessionId) } function getQuestionState(messageId?: string, partId?: string) { @@ -1132,14 +1262,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return { entry, active } } - function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) { + function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string): boolean { const session = state.sessions[sessionId] - if (!session) return + if (!session) return false const stopIndex = session.messageIds.indexOf(revertMessageId) - if (stopIndex === -1) return + if (stopIndex === -1) return false const removedIds = session.messageIds.slice(stopIndex) const keptIds = session.messageIds.slice(0, stopIndex) - if (removedIds.length === 0) return + if (removedIds.length === 0) return false removedIds.forEach((messageId) => clearPromptDisplayOverride(instanceId, sessionId, messageId)) @@ -1189,15 +1319,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt recomputeLastAssistantMessageId(sessionId, keptIds) bumpSessionRevision(sessionId) + return true } function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) { if (!sessionId) return ensureSessionEntry(sessionId) - if (revert?.messageID) { - pruneMessagesAfterRevert(sessionId, revert.messageID) - } + const pruned = revert?.messageID ? pruneMessagesAfterRevert(sessionId, revert.messageID) : false setState("sessions", sessionId, "revert", revert ?? null) + if (!pruned) bumpSessionRevision(sessionId) } function getSessionRevert(sessionId: string) { @@ -1344,6 +1474,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState, addOrUpdateSession, hydrateMessages, + mergeCachedMessages, upsertMessage, applyPartUpdate, applyPartDelta, @@ -1370,6 +1501,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt getScrollSnapshot, getSessionRevision: getSessionRevisionValue, getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], + getResidentSessionIds, + getSessionApproximateByteSize, + hasSessionActiveWork, + hasSessionPendingInput, getLastAssistantMessageId: getLastAssistantMessageIdValue, getLastCompactionMessageIndex, getMessage: (messageId: string) => state.messages[messageId], diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 6c0db47c7..ee893c6f7 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,6 +12,7 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -98,6 +99,7 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -463,8 +465,10 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI }), "part.delete", ) + if (instances().get(instanceId) !== instance) return // Optimistic removal; SSE will also broadcast a part-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessagePartV2(instanceId, messageId, partId) updateSessionInfo(instanceId, sessionId) } @@ -487,8 +491,10 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s }), "session.message.delete", ) + if (instances().get(instanceId) !== instance) return // Optimistic removal; SSE will also broadcast a message-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessageV2(instanceId, messageId) updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 28b84ce64..990752cd6 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -6,7 +6,7 @@ import { type Session, type SessionStatus, } from "../types/session" -import type { Message } from "../types/message" +import type { Message, MessageInfo } from "../types/message" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" import { instances, reconcilePendingSessionIndicators } from "./instances" @@ -22,7 +22,6 @@ import { cancelSessionGenerationAdmissions, markSessionDeletedAuthoritative, getAuthoritativelyDeletedSessionIdsForInstance, - getDescendantSessions, isBlankSession, messagesLoaded, getSessionMessagesLoadError, @@ -57,7 +56,8 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { clearPendingDeltasForMessage, clearPendingDeltasForSession } from "./delta-buffer" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -84,13 +84,44 @@ import { isProjectSessionListComplete, } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { + cacheAuthoritativeSessionMessages, + cancelCachedSessionMessageRestore, + clearCachedSessionMessageShift, + invalidateSessionMessageCache, + restoreCachedSessionMessagePages, +} from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("api") const sessionListRequestIds = new Map() let nextSessionListRequestId = 0 const pendingMetadataHydrations = new Map>() const sessionWorkspaceHints = new Map>() -messageStoreBus.onInstanceDestroyed((instanceId) => sessionWorkspaceHints.delete(instanceId)) +messageStoreBus.onInstanceDestroyed((instanceId) => { + sessionWorkspaceHints.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of pendingMetadataHydrations.keys()) if (key.startsWith(prefix)) pendingMetadataHydrations.delete(key) +}) + +function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Message[]; infos: Map } { + const infos = new Map() + const messages = apiMessages.map((apiMessage: any) => { + const info = (apiMessage.info || apiMessage) as MessageInfo + const messageId = info.id || String(Date.now()) + infos.set(messageId, info) + return { + id: messageId, + sessionId, + type: info.role === "user" ? "user" as const : "assistant" as const, + parts: (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)), + timestamp: info.time?.created || Date.now(), + status: (info as any).error ? "error" as const : "complete" as const, + version: 0, + } + }) + return { messages, infos } +} function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId @@ -265,6 +296,7 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = const key = `${instanceId}:${sessionId}` const current = pendingMetadataHydrations.get(key) if (current) return current + const instance = instances().get(instanceId) const hydration = (async () => { const candidates = await getSessionWorkspaceCandidates(instanceId, sessionId) let lastError: unknown @@ -272,7 +304,8 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)) for (const candidate of candidates) { try { - await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate) + await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate, () => instances().get(instanceId) === instance) + if (instances().get(instanceId) !== instance) return rememberSessionWorkspace(instanceId, sessionId, candidate.workspace) return } catch (error) { @@ -281,7 +314,9 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = } } throw lastError - })().finally(() => pendingMetadataHydrations.delete(key)) + })().finally(() => { + if (pendingMetadataHydrations.get(key) === hydration) pendingMetadataHydrations.delete(key) + }) pendingMetadataHydrations.set(key, hydration) return hydration } @@ -433,11 +468,11 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): let status: SessionStatus let retry = existingSession?.retry ?? null - if (existingStatus === "compacting") { + if (existingStatus === "compacting" && !statusResponseKnown) { status = "compacting" retry = null } else { - status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" + status = hasType ? mapSdkSessionStatus(rawStatus) : statusResponseKnown ? "idle" : existingStatus ?? "idle" retry = hasType ? mapSdkSessionRetry(rawStatus) : retry } sessionMap.set(apiSession.id, { @@ -666,6 +701,7 @@ async function createSession(instanceId: string, agent?: string): Promise 0 ? primaryAgents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") if (selectedAgent && isModelValid(instanceId, defaultModel)) { await setAgentModelPreference(instanceId, selectedAgent, defaultModel) @@ -680,6 +716,7 @@ async function createSession(instanceId: string, agent?: string): Promise { - const next = { ...prev } - next.creatingSession.set(instanceId, false) - return next - }) + if (instances().get(instanceId) === instance) { + setLoading((prev) => { + const next = { ...prev } + next.creatingSession.set(instanceId, false) + return next + }) + } } } @@ -789,12 +828,14 @@ async function forkSession( ...(await getSessionWorkspacePayload(instanceId, sourceSessionId)), messageID: options?.messageId, } + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await requestData( client.session.fork(request), "session.fork", ) + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") const forkedSession = { id: info.id, instanceId, @@ -882,6 +923,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { - const next = { ...prev } - const deleting = next.deletingSession.get(instanceId) - if (deleting) { - deleting.delete(sessionId) - } - return next - }) + if (instances().get(instanceId) === instance) { + setLoading((prev) => { + const next = { ...prev } + const deleting = next.deletingSession.get(instanceId) + if (deleting) deleting.delete(sessionId) + return next + }) + } } } function removeSessionRuntimeState(instanceId: string, sessionId: string): void { + clearPendingDeltasForSession(instanceId, sessionId) + invalidateSessionMessageCache(instanceId, sessionId) sessionWorkspaceHints.get(instanceId)?.delete(sessionId) cancelSessionGenerationAdmissions(instanceId, sessionId) markSessionDeletedAuthoritative(instanceId, sessionId) @@ -970,6 +1014,7 @@ async function fetchAgents(instanceId: string): Promise { try { log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) const response = await rootClient.app.agents() + if (instances().get(instanceId) !== instance) return const agentList = (response.data ?? []).map((agent) => ({ name: agent.name, description: agent.description || "", @@ -1004,6 +1049,7 @@ async function fetchProviders(instanceId: string): Promise { try { log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) const response = await rootClient.config.providers() + if (instances().get(instanceId) !== instance) return if (!response.data) return const providerList = response.data.providers.map((provider) => ({ @@ -1033,11 +1079,9 @@ async function fetchProviders(instanceId: string): Promise { async function loadMessages( instanceId: string, sessionId: string, - options?: { force?: boolean; skipChildren?: boolean }, + options?: { force?: boolean }, ): Promise { const force = options?.force ?? false - const skipChildren = options?.skipChildren ?? false - if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) @@ -1077,9 +1121,12 @@ async function loadMessages( throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) + const store = messageStoreBus.getOrCreate(instanceId) + let expectedRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false + const sessionForV2 = session setLoading((prev) => { const next = { ...prev } @@ -1092,10 +1139,66 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages = await requestData( - client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), - "session.messages", + const apiMessagesRequest = getSessionWorkspacePayload(instanceId, sessionId).then((workspacePayload) => + requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }), "session.messages"), + ) + const apiOutcome = apiMessagesRequest.then( + (messages) => ({ ok: true as const, messages }), + (error) => ({ ok: false as const, error }), ) + let outcome: Awaited | undefined + + if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { + const pages = restoreCachedSessionMessagePages(instanceId, sessionId) + const iterator = pages[Symbol.asyncIterator]() + try { + while (!outcome) { + const winner = await Promise.race([ + apiOutcome.then((value) => ({ kind: "http" as const, value })), + iterator.next().then((value) => ({ kind: "cache" as const, value })), + ]) + if (winner.kind === "http") { + outcome = winner.value + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + void iterator.return?.(undefined).catch(() => undefined) + break + } + if (winner.value.done) { + outcome = await apiOutcome + break + } + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + return + } + const cached = adaptApiMessages(sessionId, winner.value.value.messages) + const revision = mergeCachedSessionMessagePageV2( + instanceId, + sessionForV2, + cached.messages, + cached.infos, + expectedRevision, + ) + if (revision === null) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + void iterator.return?.(undefined).catch(() => undefined) + outcome = await apiOutcome + break + } + expectedRevision = revision + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + } + } catch (error) { + log.warn("Failed to restore cached session messages", { instanceId, sessionId, error }) + invalidateSessionMessageCache(instanceId, sessionId) + } + } + + outcome ??= await apiOutcome + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + if (!outcome.ok) throw outcome.error + const apiMessages = outcome.messages if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return @@ -1105,41 +1208,9 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) - if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - } - } else { - const messagesInfo = new Map() - const messages: Message[] = apiMessages.map((apiMessage: any) => { - const info = apiMessage.info || apiMessage - const role = info.role || "assistant" - const messageId = info.id || String(Date.now()) - - messagesInfo.set(messageId, info) - - const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) + const adapted = adaptApiMessages(sessionId, apiMessages) - const message: Message = { - id: messageId, - sessionId, - type: role === "user" ? "user" : "assistant", - parts, - timestamp: info.time?.created || Date.now(), - status: "complete" as const, - version: 0, - } - - return message - }) + if (apiMessages.length > 0) { let agentName = "" let providerID = "" @@ -1181,22 +1252,29 @@ async function loadMessages( return next }) - const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { - id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, + } + + const latestSession = sessions().get(instanceId)?.get(sessionId) ?? sessionForV2 + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!seedSessionMessagesV2(instanceId, latestSession, adapted.messages, adapted.infos, expectedRevision)) { + retryAfterRevisionConflict = true + } else { + for (const message of adapted.messages) { + retryAfterRevisionConflict ||= clearPendingDeltasForMessage(instanceId, message.id) } - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return - if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + if (restorePreviousStateEnabled() && !retryAfterRevisionConflict) { + void cacheAuthoritativeSessionMessages(instanceId, sessionId, store.getSessionRevision(sessionId)).catch((error) => + log.warn("Failed to persist authoritative session messages", { instanceId, sessionId, error }), + ) } } @@ -1209,6 +1287,13 @@ async function loadMessages( } throw error } finally { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + const clearShift = () => { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) + } + if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) + else setTimeout(clearShift, 0) + } if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } @@ -1221,24 +1306,13 @@ async function loadMessages( if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { await new Promise((resolve) => setTimeout(resolve, 50)) - return loadMessages(instanceId, sessionId, { force: true, skipChildren }) + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + return loadMessages(instanceId, sessionId, { force: true }) } if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return updateSessionInfo(instanceId, sessionId) - if (!skipChildren && session.parentId === null) { - for (const child of getDescendantSessions(instanceId, sessionId)) { - void loadMessages(instanceId, child.id, { skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId: sessionId, - error, - }), - ) - } - } } export { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 427579d04..17da93517 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -20,8 +20,11 @@ import type { EventSessionDeleted } from "../lib/sse-manager" import { requestData } from "../lib/opencode-api" import { enqueueDelta, + clearPendingDeltasForInstance, clearPendingDeltasForPart, + clearPendingDeltasForSession, flushPendingDeltasForMessage, + holdDelta, setFlushCallback, } from "./delta-buffer" import { @@ -63,7 +66,7 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, messagesLoaded, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" @@ -91,11 +94,22 @@ import { import { messageStoreBus } from "./message-v2/bus" import type { InstanceMessageStore } from "./message-v2/instance-store" import { handleConversationAssistantPartUpdated } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache, scheduleSessionMessageCacheWrite } from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" +import { scheduleSessionMemorySweep } from "./session-memory" const log = getLogger("sse") const pendingSessionFetches = new Map>() +const pendingSessionStatuses = new Map() let activeRetryToast: ToastHandle | null = null +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}:` + for (const key of pendingSessionFetches.keys()) if (key.startsWith(prefix)) pendingSessionFetches.delete(key) + for (const key of pendingSessionStatuses.keys()) if (key.startsWith(prefix)) pendingSessionStatuses.delete(key) + clearPendingDeltasForInstance(instanceId) +}) + function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { if (typeof document === "undefined") return false const pref = preferences() @@ -167,17 +181,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId) const client = getRootClient(instanceId) const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) + if (instances().get(instanceId) !== instance) return null try { const info = await requestData( client.session.get({ sessionID: sessionId, ...(workspace ? { workspace } : {}) }), "session.get", ) + if (instances().get(instanceId) !== instance) return null let rawStatus = (info as any)?.status let fetchedStatusKnown = false try { const statuses = await requestData>(client.session.status(), "session.status") + if (instances().get(instanceId) !== instance) return null rawStatus ??= statuses?.[sessionId] fetchedStatusKnown = true } catch (error) { @@ -249,20 +266,29 @@ function ensureSessionStatus( const existing = sessions().get(instanceId)?.get(sessionId) if (existing) { setSessionStatus(instanceId, sessionId, status, { retry }) + scheduleSessionMemorySweep() return } const key = `${instanceId}:${sessionId}` + pendingSessionStatuses.set(key, { status, retry }) if (pendingSessionFetches.has(key)) return const pending = (async () => { const fetched = await fetchSessionInfo(instanceId, sessionId, directory) if (!fetched) return - setSessionStatus(instanceId, sessionId, status, { retry }) + const latest = pendingSessionStatuses.get(key) ?? { status, retry } + setSessionStatus(instanceId, sessionId, latest.status, { retry: latest.retry, force: true }) + scheduleSessionMemorySweep() })() pendingSessionFetches.set(key, pending) - void pending.finally(() => pendingSessionFetches.delete(key)) + void pending.finally(() => { + if (pendingSessionFetches.get(key) === pending) { + pendingSessionFetches.delete(key) + pendingSessionStatuses.delete(key) + } + }) } type MessageRole = "user" | "assistant" @@ -306,6 +332,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } @@ -347,6 +375,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes clearPendingDeltasForPart(instanceId, messageId, part.id) } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) + if (messagesLoaded().get(instanceId)?.has(sessionId)) scheduleSessionMessageCacheWrite(instanceId, sessionId) handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) if (part.type === "tool") { @@ -363,6 +392,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) // Flush any pending deltas for this message before applying the update. // Deltas are buffered for up to 50ms; if message.updated arrives before @@ -372,15 +403,17 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes // message status/metadata update runs on the complete content. flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2) - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number; completed?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 ? timeInfo.end - : typeof timeInfo.updated === "number" && timeInfo.updated > 0 - ? timeInfo.updated - : typeof timeInfo.created === "number" && timeInfo.created > 0 - ? timeInfo.created - : Date.now() + : typeof timeInfo.completed === "number" && timeInfo.completed > 0 + ? timeInfo.completed + : typeof timeInfo.updated === "number" && timeInfo.updated > 0 + ? timeInfo.updated + : typeof timeInfo.created === "number" && timeInfo.created > 0 + ? timeInfo.created + : Date.now() withSession(instanceId, sessionId, (session) => { const currentUpdated = session.time?.updated ?? 0 @@ -392,7 +425,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const role: MessageRole = info.role === "user" ? "user" : "assistant" const hasError = Boolean((info as any).error) - const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0 + const hasEnded = + (typeof timeInfo.end === "number" && timeInfo.end > 0) || + (typeof timeInfo.completed === "number" && timeInfo.completed > 0) const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming" let record = store.getMessage(messageId) @@ -406,7 +441,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const endAt = (info.time as { end?: number } | undefined)?.end + const endAt = timeInfo.end ?? timeInfo.completed store.upsertMessage({ id: messageId, sessionId, @@ -420,13 +455,22 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) updateSessionInfo(instanceId, sessionId) + if ( + restorePreviousStateEnabled() && + messagesLoaded().get(instanceId)?.has(sessionId) && + (status === "complete" || status === "error") + ) { + scheduleSessionMessageCacheWrite(instanceId, sessionId) + } } } // Delta buffer callback setup setFlushCallback((batch) => { - for (const { instanceId, messageId, partId, field, delta } of batch) { - applyPartDeltaV2(instanceId, { messageId, partId, field, delta }) + for (const { instanceId, sessionId, messageId, partId, field, delta } of batch) { + if (!applyPartDeltaV2(instanceId, { messageId, partId, field, delta })) { + holdDelta(instanceId, messageId, partId, field, delta, sessionId) + } } }) @@ -435,7 +479,12 @@ function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return - enqueueDelta(instanceId, messageID, partID, field, delta) + const sessionId = props.sessionID ?? messageStoreBus.getInstance(instanceId)?.getMessage(messageID)?.sessionId + if (sessionId) { + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) + } + enqueueDelta(instanceId, messageID, partID, field, delta, sessionId) } function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { @@ -443,10 +492,21 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (!info) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return - const instanceSessions = sessions().get(instanceId) ?? new Map() - const existingSession = instanceSessions.get(info.id) + const hasRevert = Object.prototype.hasOwnProperty.call(info, "revert") + const incomingRevert = info.revert ?? null + const previousRevert = existingSession?.revert ?? null + const revertChanged = hasRevert && ( + incomingRevert?.messageID !== previousRevert?.messageID || + incomingRevert?.partID !== previousRevert?.partID || + incomingRevert?.snapshot !== previousRevert?.snapshot || + incomingRevert?.diff !== previousRevert?.diff + ) + if (revertChanged) { + invalidateSessionMessageLoad(instanceId, info.id) + invalidateSessionMessageCache(instanceId, info.id) + } if (!existingSession) { const newSession = { @@ -492,7 +552,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - setSessionRevertV2(instanceId, info.id, info.revert ?? null) + if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) if (!newSession.parentId) { prependSessionListId(instanceId, newSession.id) } @@ -511,14 +571,14 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo retry: existingSession.retry ?? null, metadata: (info as any).metadata ?? existingSession.metadata, time: mergedTime, - revert: info.revert + revert: hasRevert && info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } - : existingSession.revert, + : hasRevert ? undefined : existingSession.revert, } let updatedInstanceSessions: Map | undefined @@ -533,7 +593,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - setSessionRevertV2(instanceId, info.id, info.revert ?? null) + if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) } } @@ -541,6 +601,7 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v const properties = event.properties const sessionId = properties?.info?.id ?? properties?.sessionID ?? properties?.id if (!sessionId) return + clearPendingDeltasForSession(instanceId, sessionId) log.info(`[SSE] Session deleted: ${sessionId}`) removeSessionRuntimeState(instanceId, sessionId) @@ -558,6 +619,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { } ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory) + scheduleSessionMemorySweep() log.info(`[SSE] Session idle: ${sessionId}`) } @@ -569,6 +631,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi const status = mapSdkSessionStatus(rawStatus) const retry = mapSdkSessionRetry(rawStatus) ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry) + scheduleSessionMemorySweep() if (retry) { const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000)) const countdown = @@ -596,6 +659,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) + invalidateSessionMessageCache(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) @@ -631,6 +695,7 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void message = error.message } } + if (message.length > 10_000) message = `${message.slice(0, 10_000)}...` showAlertDialog(tGlobal("sessionEvents.sessionError.message", { message }), { title: tGlobal("sessionEvents.sessionError.title"), @@ -641,8 +706,10 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void { const { sessionID, messageID } = event.properties if (!sessionID || !messageID) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -650,8 +717,10 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void { const { sessionID, messageID, partID } = event.properties if (!sessionID || !messageID || !partID) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-memory.test.ts b/packages/ui/src/stores/session-memory.test.ts new file mode 100644 index 000000000..2a4d406d5 --- /dev/null +++ b/packages/ui/src/stores/session-memory.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { messageStoreBus } from "./message-v2/bus.ts" +import { runSessionMemorySweep, setVisibleSessionMemory } from "./session-memory.ts" +import { setSessions } from "./session-state.ts" + +function addMessage(instanceId: string, sessionId: string, status: "complete" | "streaming" = "complete") { + messageStoreBus.getOrCreate(instanceId).upsertMessage({ + id: `${sessionId}-message`, + sessionId, + role: "assistant", + status, + parts: [{ id: `${sessionId}-part`, type: "text", text: sessionId.repeat(100) }] as any, + }) +} + +test("resident message budget evicts globally across five workspaces while preserving visible and streaming sessions", () => { + const instanceIds = Array.from({ length: 5 }, (_, index) => `memory-workspace-${index}`) + try { + for (const instanceId of instanceIds) { + addMessage(instanceId, "parent") + addMessage(instanceId, "subagent") + } + addMessage(instanceIds[0], "streaming", "streaming") + setVisibleSessionMemory(instanceIds[4], "parent", true) + + const protectedBytes = messageStoreBus.getInstance(instanceIds[4])!.getSessionApproximateByteSize("parent") + + messageStoreBus.getInstance(instanceIds[0])!.getSessionApproximateByteSize("streaming") + const evicted = runSessionMemorySweep(protectedBytes) + + assert.equal(evicted.length, 9) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[4])!.getSessionMessageIds("parent"), ["parent-message"]) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("streaming"), ["streaming-message"]) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("parent"), []) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("subagent"), []) + } finally { + setVisibleSessionMemory(instanceIds[4], "parent", false) + for (const instanceId of instanceIds) messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("visible session leases keep a child resident until every visible owner releases it", () => { + const instanceId = "memory-visible-leases", sessionId = "child" + try { + addMessage(instanceId, sessionId) + setVisibleSessionMemory(instanceId, sessionId, true) + setVisibleSessionMemory(instanceId, sessionId, true) + setVisibleSessionMemory(instanceId, sessionId, false) + + assert.deepEqual(runSessionMemorySweep(0), []) + setVisibleSessionMemory(instanceId, sessionId, false) + assert.deepEqual(runSessionMemorySweep(0), [`${instanceId}\u0000${sessionId}`]) + } finally { + setVisibleSessionMemory(instanceId, sessionId, false) + messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("authoritative working status protects a resident session", () => { + const instanceId = "memory-working-status", sessionId = "session" + try { + addMessage(instanceId, sessionId) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, { id: sessionId, status: "working" } as any]]))) + assert.deepEqual(runSessionMemorySweep(0), []) + + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, { id: sessionId, status: "idle" } as any]]))) + assert.deepEqual(runSessionMemorySweep(0), [`${instanceId}\u0000${sessionId}`]) + } finally { + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + messageStoreBus.unregisterInstance(instanceId) + } +}) diff --git a/packages/ui/src/stores/session-memory.ts b/packages/ui/src/stores/session-memory.ts new file mode 100644 index 000000000..0f1e952e9 --- /dev/null +++ b/packages/ui/src/stores/session-memory.ts @@ -0,0 +1,107 @@ +import { getLogger } from "../lib/logger" +import { MAX_HOT_SESSION_MESSAGE_BYTES, selectSessionMemoryEvictions, type SessionMemoryEntry } from "../lib/session-memory-budget" +import { messageStoreBus } from "./message-v2/bus" +import { cancelCachedSessionMessageRestore, isRestoringCachedSessionMessages } from "./session-message-cache" +import { isSessionMessagesLoading, sessions } from "./session-state" + +const log = getLogger("session") +const SWEEP_DELAY_MS = 1_000 +const touched = new Map() +const visibleLeases = new Map() +let sequence = 0 +let sweepTimer: ReturnType | undefined + +function sessionKey(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` +} + +function splitSessionKey(key: string): [string, string] { + const separator = key.indexOf("\u0000") + return [key.slice(0, separator), key.slice(separator + 1)] +} + +function hasProtectedSessionWork( + store: ReturnType, + sessionId: string, + status: string | undefined, +): boolean { + return status === "idle" ? store.hasSessionPendingInput(sessionId) : store.hasSessionActiveWork(sessionId) +} + +export function scheduleSessionMemorySweep(): void { + if (sweepTimer) return + sweepTimer = setTimeout(() => { + sweepTimer = undefined + runSessionMemorySweep() + }, SWEEP_DELAY_MS) +} + +export function setVisibleSessionMemory(instanceId: string, sessionId: string, isVisible: boolean): void { + const key = sessionKey(instanceId, sessionId) + if (isVisible) { + visibleLeases.set(key, (visibleLeases.get(key) ?? 0) + 1) + touched.set(key, ++sequence) + } else { + const leases = visibleLeases.get(key) ?? 0 + if (leases <= 1) visibleLeases.delete(key) + else visibleLeases.set(key, leases - 1) + } + scheduleSessionMemorySweep() +} + +export function evictResidentSessionMessages(instanceId: string, sessionId: string): boolean { + const store = messageStoreBus.getInstance(instanceId) + const status = sessions().get(instanceId)?.get(sessionId)?.status + if ( + !store || + visibleLeases.has(sessionKey(instanceId, sessionId)) || + status === "working" || + status === "compacting" || + hasProtectedSessionWork(store, sessionId, status) + ) return false + cancelCachedSessionMessageRestore(instanceId, sessionId) + store.clearSession(sessionId, { preserveScroll: true }) + log.info("Evicted resident session messages", { instanceId, sessionId }) + return true +} + +export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES): string[] { + const entries: SessionMemoryEntry[] = [] + for (const [instanceId, store] of messageStoreBus.entries()) { + for (const sessionId of store.getResidentSessionIds()) { + const key = sessionKey(instanceId, sessionId) + const status = sessions().get(instanceId)?.get(sessionId)?.status + entries.push({ + key, + byteSize: store.getSessionApproximateByteSize(sessionId), + lastTouched: touched.get(key) ?? 0, + protected: visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId, status) || + isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId), + }) + } + } + + const evicted: string[] = [] + for (const key of selectSessionMemoryEvictions(entries, byteLimit)) { + const [instanceId, sessionId] = splitSessionKey(key) + if (evictResidentSessionMessages(instanceId, sessionId)) evicted.push(key) + } + return evicted +} + +messageStoreBus.onSessionChanged((instanceId, sessionId) => { + touched.set(sessionKey(instanceId, sessionId), ++sequence) + scheduleSessionMemorySweep() +}) + +messageStoreBus.onSessionCleared((instanceId, sessionId) => { + const key = sessionKey(instanceId, sessionId) + touched.delete(key) + visibleLeases.delete(key) +}) + +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}\u0000` + for (const key of touched.keys()) if (key.startsWith(prefix)) touched.delete(key) + for (const key of visibleLeases.keys()) if (key.startsWith(prefix)) visibleLeases.delete(key) +}) diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts new file mode 100644 index 000000000..a7661e899 --- /dev/null +++ b/packages/ui/src/stores/session-message-cache.ts @@ -0,0 +1,364 @@ +import { + deleteSessionMessageCache, + createSessionMessageCacheKey, + type SessionMessageCachePage, + isSessionMessageCacheEnabled, + onSessionMessageCacheReset, + openSessionMessageCache, + readSessionMessageCachePage, + writeSessionMessageCache, + MAX_SESSION_MESSAGE_CACHE_BYTES, +} from "../lib/session-message-cache" +import { createSignal } from "solid-js" +import { getLogger } from "../lib/logger" +import { instances } from "./instances" +import { messageStoreBus } from "./message-v2/bus" +import { estimateRetainedBytes } from "../lib/session-memory-budget" + +const log = getLogger("session") +const WRITE_DEBOUNCE_MS = 500 +const pendingWrites = new Map>() +const cacheGenerations = new Map() +const pendingInvalidations = new Map>() +const invalidatedEntries = new Set() +const invalidationVersions = new Map() +const pendingEntryInvalidations = new Map() +const activeRestores = new Map() +type CacheWriteRequest = { + instanceId: string + sessionId: string + expectedRevision?: number + allowEmpty: boolean + generation: number + reset: number + instanceToken: unknown + workspace: string + entryKey: string + invalidationVersion: number + done: Array<() => void> +} +const queuedWrites = new Map() +let writeWorkerRunning = false +const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) +let resetGeneration = 0 + +function pendingKey(instanceId: string, sessionId: string): string { + return `${instanceId}:${sessionId}` +} + +function cacheGeneration(key: string): number { + return cacheGenerations.get(key) ?? 0 +} + +function setRestoreActive(key: string, active: boolean): void { + setRestoringSessions((current) => { + const next = new Set(current) + if (active) next.add(key) + else next.delete(key) + return next + }) +} + +export function isRestoringCachedSessionMessages(instanceId: string, sessionId: string): boolean { + return restoringSessions().has(pendingKey(instanceId, sessionId)) +} + +export function cancelCachedSessionMessageRestore( + instanceId: string, + sessionId: string, + options?: { preserveShift?: boolean }, +): void { + const key = pendingKey(instanceId, sessionId) + activeRestores.delete(key) + if (!options?.preserveShift) setRestoreActive(key, false) +} + +export function clearCachedSessionMessageShift(instanceId: string, sessionId: string): void { + setRestoreActive(pendingKey(instanceId, sessionId), false) +} + +function workspaceForInstance(instanceId: string): string | null { + return instances().get(instanceId)?.folder ?? null +} + +function waitForIdle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => resolve(), { timeout: 2_000 }) + } else { + setTimeout(resolve, 0) + } + }) +} + +function snapshotSession(instanceId: string, sessionId: string, allowEmpty = false): unknown[] | null { + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + const messageIds = store.getSessionMessageIds(sessionId) + if (messageIds.length === 0 && (!allowEmpty || !store.state.sessions[sessionId])) return null + + const result: unknown[] = [] + let bytes = 0 + for (let index = messageIds.length - 1; index >= 0; index -= 1) { + const messageId = messageIds[index] + const record = store.getMessage(messageId) + const info = store.getMessageInfo(messageId) + if (!record || !info || record.isEphemeral || record.status === "sending" || record.status === "streaming") return null + const remaining = MAX_SESSION_MESSAGE_CACHE_BYTES / 2 - bytes + const recordBytes = estimateRetainedBytes(record, remaining) + if (recordBytes > remaining) break + const infoBytes = estimateRetainedBytes(info, remaining - recordBytes) + const messageBytes = recordBytes + infoBytes + if (messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2 || bytes + messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2) break + + const parts = record.partIds.map((partId) => record.parts[partId]?.data).filter(Boolean).map((part) => { + const { renderCache: _renderCache, pendingPermission: _pendingPermission, ...cacheable } = part as any + return cacheable + }) + result.unshift({ info, parts }) + bytes += messageBytes + } + return result.length > 0 || messageIds.length === 0 ? result : null +} + +async function drainQueuedWrites(): Promise { + if (writeWorkerRunning) return + writeWorkerRunning = true + try { + while (queuedWrites.size > 0) { + const [key, request] = queuedWrites.entries().next().value as [string, CacheWriteRequest] + queuedWrites.delete(key) + try { + await waitForIdle() + if (cacheGeneration(key) !== request.generation || resetGeneration !== request.reset) continue + if (instances().get(request.instanceId) !== request.instanceToken) continue + if ((invalidationVersions.get(request.entryKey) ?? 0) !== request.invalidationVersion) continue + const store = messageStoreBus.getInstance(request.instanceId) + if (!store) continue + if (request.expectedRevision !== undefined && store.getSessionRevision(request.sessionId) !== request.expectedRevision) continue + const messages = snapshotSession(request.instanceId, request.sessionId, request.allowEmpty) + if (!messages) continue + const written = await writeSessionMessageCache(request.workspace, request.sessionId, messages) + if ( + written && + cacheGeneration(key) === request.generation && + resetGeneration === request.reset && + instances().get(request.instanceId) === request.instanceToken && + (invalidationVersions.get(request.entryKey) ?? 0) === request.invalidationVersion && + pendingEntryInvalidations.get(request.entryKey) !== request.invalidationVersion + ) invalidatedEntries.delete(request.entryKey) + } catch (error) { + log.warn("Failed to cache completed session messages", { instanceId: request.instanceId, sessionId: request.sessionId, error }) + } finally { + request.done.forEach((resolve) => resolve()) + } + } + } finally { + writeWorkerRunning = false + if (queuedWrites.size > 0) void drainQueuedWrites() + } +} + +function enqueueSessionMessageCacheWrite( + instanceId: string, + sessionId: string, + options?: { expectedRevision?: number; allowEmpty?: boolean }, +): Promise { + const key = pendingKey(instanceId, sessionId) + const instanceToken = instances().get(instanceId) + const workspace = instanceToken?.folder + if (!instanceToken || !workspace) return Promise.resolve() + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 + return new Promise((resolve) => { + const current = queuedWrites.get(key) + if (current) { + current.expectedRevision = options?.expectedRevision + current.allowEmpty ||= Boolean(options?.allowEmpty) + current.generation = cacheGeneration(key) + current.reset = resetGeneration + current.instanceToken = instanceToken + current.workspace = workspace + current.entryKey = entryKey + current.invalidationVersion = invalidationVersion + current.done.push(resolve) + } else { + queuedWrites.set(key, { + instanceId, + sessionId, + expectedRevision: options?.expectedRevision, + allowEmpty: Boolean(options?.allowEmpty), + generation: cacheGeneration(key), + reset: resetGeneration, + instanceToken, + workspace, + entryKey, + invalidationVersion, + done: [resolve], + }) + } + void drainQueuedWrites() + }) +} + +export async function* restoreCachedSessionMessagePages( + instanceId: string, + sessionId: string, +): AsyncGenerator { + if (!isSessionMessageCacheEnabled()) return + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + if (invalidatedEntries.has(entryKey)) return + const key = pendingKey(instanceId, sessionId) + const generation = cacheGeneration(key) + const reset = resetGeneration + const token = Symbol(key) + let restoredPage = false + activeRestores.set(key, token) + setRestoreActive(key, true) + try { + await pendingInvalidations.get(key) + let cursor = await openSessionMessageCache(workspace, sessionId) + const scroll = messageStoreBus.getInstance(instanceId)?.getScrollSnapshot(sessionId, "message-stream") + const deferredAnchor = scroll && !scroll.atBottom ? scroll.anchorKey : undefined + let buffered: unknown[] = [] + while ( + cursor && + cursor.beforeIndex > 0 && + activeRestores.get(key) === token && + cacheGeneration(key) === generation && + resetGeneration === reset && + !invalidatedEntries.has(entryKey) + ) { + const result = await readSessionMessageCachePage(cursor) + if (!result) return + if ( + activeRestores.get(key) !== token || + cacheGeneration(key) !== generation || + resetGeneration !== reset || + invalidatedEntries.has(entryKey) + ) return + cursor = result.cursor + const page = result.page + if (deferredAnchor && !page.messages.some((message) => (message as any)?.info?.id === deferredAnchor) && cursor.beforeIndex > 0) { + buffered = [...page.messages, ...buffered] + continue + } + if (buffered.length > 0) { + restoredPage = true + yield { ...page, messages: [...page.messages, ...buffered] } + buffered = [] + } else { + restoredPage = true + yield page + } + } + if (buffered.length > 0 && (!deferredAnchor || cursor?.complete)) { + restoredPage = true + yield { messages: buffered, startIndex: cursor?.startIndex ?? 0, totalCount: cursor?.totalCount ?? buffered.length, done: true, complete: cursor?.complete ?? true } + } + } finally { + if (activeRestores.get(key) === token) { + activeRestores.delete(key) + if (!restoredPage) setRestoreActive(key, false) + } + } +} + +export async function cacheAuthoritativeSessionMessages( + instanceId: string, + sessionId: string, + expectedRevision: number, +): Promise { + if (!isSessionMessageCacheEnabled()) return + await enqueueSessionMessageCacheWrite(instanceId, sessionId, { expectedRevision, allowEmpty: true }) +} + +export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: string): void { + if (!isSessionMessageCacheEnabled()) return + const key = pendingKey(instanceId, sessionId) + const instanceToken = instances().get(instanceId) + const workspace = instanceToken?.folder + if (!instanceToken || !workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 + const existing = pendingWrites.get(key) + if (existing) clearTimeout(existing) + pendingWrites.set(key, setTimeout(() => { + pendingWrites.delete(key) + if (instances().get(instanceId) !== instanceToken) return + if ((invalidationVersions.get(entryKey) ?? 0) !== invalidationVersion) return + void enqueueSessionMessageCacheWrite(instanceId, sessionId) + }, WRITE_DEBOUNCE_MS)) +} + +export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { + const key = pendingKey(instanceId, sessionId) + cancelCachedSessionMessageRestore(instanceId, sessionId) + cacheGenerations.set(key, cacheGeneration(key) + 1) + const pending = pendingWrites.get(key) + if (pending) { + clearTimeout(pending) + pendingWrites.delete(key) + } + const queued = queuedWrites.get(key) + if (queued) { + queuedWrites.delete(key) + queued.done.forEach((resolve) => resolve()) + } + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = (invalidationVersions.get(entryKey) ?? 0) + 1 + invalidationVersions.set(entryKey, invalidationVersion) + pendingEntryInvalidations.set(entryKey, invalidationVersion) + invalidatedEntries.add(entryKey) + const invalidation = deleteSessionMessageCache(workspace, sessionId) + .then(() => { + if ((invalidationVersions.get(entryKey) ?? 0) === invalidationVersion) invalidatedEntries.delete(entryKey) + }) + .catch((error) => log.warn("Failed to invalidate cached session messages", { instanceId, sessionId, error })) + .finally(() => { + if (pendingInvalidations.get(key) === invalidation) pendingInvalidations.delete(key) + if (pendingEntryInvalidations.get(entryKey) === invalidationVersion) pendingEntryInvalidations.delete(entryKey) + }) + pendingInvalidations.set(key, invalidation) +} + +function resetPendingSessionMessageCacheWork(): void { + resetGeneration += 1 + activeRestores.clear() + setRestoringSessions(new Set()) + for (const timer of pendingWrites.values()) clearTimeout(timer) + pendingWrites.clear() + for (const request of queuedWrites.values()) request.done.forEach((resolve) => resolve()) + queuedWrites.clear() + pendingInvalidations.clear() + invalidatedEntries.clear() + invalidationVersions.clear() + pendingEntryInvalidations.clear() +} + +onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) + +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}:` + for (const [key, timer] of pendingWrites) { + if (!key.startsWith(prefix)) continue + clearTimeout(timer) + pendingWrites.delete(key) + } + for (const [key, request] of queuedWrites) { + if (!key.startsWith(prefix)) continue + queuedWrites.delete(key) + request.done.forEach((resolve) => resolve()) + } + for (const key of cacheGenerations.keys()) { + if (key.startsWith(prefix)) cacheGenerations.set(key, cacheGeneration(key) + 1) + } + for (const key of activeRestores.keys()) { + if (key.startsWith(prefix)) activeRestores.delete(key) + } + setRestoringSessions((current) => new Set([...current].filter((key) => !key.startsWith(prefix)))) +}) diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index bd4f4ab85..aeb960d6d 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -44,10 +44,12 @@ export async function hydrateSessionMetadataWithClient( instanceId: string, sessionId: string, query?: { workspace?: string }, + isCurrent: () => boolean = () => true, ): Promise { const expectedMetadata = sessions().get(instanceId)?.get(sessionId)?.metadata const latest = await requestData(client.session.get({ sessionID: sessionId, ...query }), "session.get") const metadata = normalizeMetadata(latest?.metadata) + if (!isCurrent()) return metadata withSession(instanceId, sessionId, (session) => { if (session.metadata !== expectedMetadata || !shouldReplaceSessionMetadata(session.metadata)) return false diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 99bfeb78f..8cded2410 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -176,4 +176,42 @@ describe("session request authority", () => { cleanup() } }) + + it("preserves historical assistant error status during hydration", async () => { + const instanceId = "errored-message-load", sessionId = "session" + const { client, cleanup } = setup(instanceId) + ;(client.session as any).messages = async () => ({ + data: [{ ...apiMessage("errored-message", sessionId), info: { ...apiMessage("errored-message", sessionId).info, error: { name: "ProviderError" } } }], + }) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + await loadMessages(instanceId, sessionId) + assert.equal(messageStoreBus.getOrCreate(instanceId).getMessage("errored-message")?.status, "error") + } finally { + cleanup() + } + }) + + it("loads subagent messages on demand instead of hydrating an entire family", async () => { + const instanceId = "lazy-subagent-messages", parentId = "parent", childId = "child" + const { client, cleanup } = setup(instanceId) + const calls: string[] = [] + ;(client.session as any).messages = async ({ sessionID }: { sessionID: string }) => { + calls.push(sessionID) + return { data: [apiMessage(`${sessionID}-message`, sessionID)] } + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [parentId, session(instanceId, parentId)], + [childId, session(instanceId, childId, parentId)], + ]))) + + try { + await loadMessages(instanceId, parentId) + assert.deepEqual(calls, [parentId]) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(childId), []) + } finally { + cleanup() + } + }) }) diff --git a/packages/ui/src/stores/session-state-purge.test.ts b/packages/ui/src/stores/session-state-purge.test.ts new file mode 100644 index 000000000..f13ded013 --- /dev/null +++ b/packages/ui/src/stores/session-state-purge.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + agents, + loading, + messagesLoaded, + providers, + purgeInstanceSessionState, + sessionInfoByInstance, + sessions, + setAgents, + setLoading, + setMessagesLoaded, + setProviders, + setSessionInfoByInstance, + setSessions, +} from "./session-state.ts" + +test("purging an instance removes its session metadata without touching other workspaces", () => { + const removed = "purged-instance" + const retained = "retained-instance" + setSessions(new Map([[removed, new Map()], [retained, new Map()]])) + setAgents(new Map([[removed, []], [retained, []]])) + setProviders(new Map([[removed, []], [retained, []]])) + setMessagesLoaded(new Map([[removed, new Set(["session"])], [retained, new Set()]])) + setSessionInfoByInstance(new Map([[removed, new Map()], [retained, new Map()]])) + setLoading((current) => ({ ...current, loadingMessages: new Map([[removed, new Set(["session"])], [retained, new Set()]]) })) + + purgeInstanceSessionState(removed) + + for (const state of [sessions(), agents(), providers(), messagesLoaded(), sessionInfoByInstance(), loading().loadingMessages]) { + assert.equal(state.has(removed), false) + assert.equal(state.has(retained), true) + } + purgeInstanceSessionState(retained) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index e9cf30bd0..f59b12b52 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -1207,6 +1207,52 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin } } +function removeInstanceMapEntry(map: Map, instanceId: string): Map { + if (!map.has(instanceId)) return map + const next = new Map(map) + next.delete(instanceId) + return next +} + +function purgeInstanceSessionState(instanceId: string): void { + if (!instanceId) return + const prefix = `${instanceId}:` + batch(() => { + setSessions((prev) => removeInstanceMapEntry(prev, instanceId)) + setActiveSessionId((prev) => removeInstanceMapEntry(prev, instanceId)) + setActiveParentSessionId((prev) => removeInstanceMapEntry(prev, instanceId)) + setAgents((prev) => removeInstanceMapEntry(prev, instanceId)) + setProviders((prev) => removeInstanceMapEntry(prev, instanceId)) + setMessagesLoaded((prev) => removeInstanceMapEntry(prev, instanceId)) + setMessageLoadErrors((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionListErrors((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionInfoByInstance((prev) => removeInstanceMapEntry(prev, instanceId)) + setThreadTotalsByInstance((prev) => removeInstanceMapEntry(prev, instanceId)) + setExpandedSessions((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionPagination((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionSearch((prev) => removeInstanceMapEntry(prev, instanceId)) + setInstanceIndicatorCounts((prev) => removeInstanceMapEntry(prev, instanceId)) + setLoading((prev) => ({ + fetchingSessions: removeInstanceMapEntry(prev.fetchingSessions, instanceId), + creatingSession: removeInstanceMapEntry(prev.creatingSession, instanceId), + deletingSession: removeInstanceMapEntry(prev.deletingSession, instanceId), + loadingMessages: removeInstanceMapEntry(prev.loadingMessages, instanceId), + })) + setAuthoritativeSessionSelectionInstanceIds((prev) => { + if (!prev.has(instanceId)) return prev + const next = new Set(prev) + next.delete(instanceId) + return next + }) + setSessionDraftPrompts((prev) => new Map([...prev].filter(([key]) => !key.startsWith(prefix)))) + setAuthoritativeDraftKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + setAuthoritativelyDeletedSessionKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + setAuthoritativeSessionExpansionKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + }) + for (const key of generationAdmissions.keys()) if (key.startsWith(prefix)) generationAdmissions.delete(key) + for (const key of messageLoadEpochs.keys()) if (key.startsWith(prefix)) messageLoadEpochs.delete(key) +} + export { sessions, setSessions, @@ -1291,6 +1337,7 @@ export { getSessionInfo, isBlankSession, cleanupBlankSessions, + purgeInstanceSessionState, SESSION_PAGE_SIZE, sessionPagination, sessionSearch, From c10cab65c35c159e5802ac00bc3eff30ba3ce3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 28 Jul 2026 21:52:54 +0200 Subject: [PATCH 02/16] fix(ui): harden session memory authority Fence asynchronous session, workspace, permission, question, command, and stream updates to the runtime that started them. Rotate stream authority across workspace restarts and preserve connecting transitions through the Tauri transport. Make persistent message restoration fail closed across incomplete reads, duplicate workspace owners, mutations, invalidation failures, and destructive client-state retries. Bound delta recovery, derived caches, search, diagnostics, task scans, and tool-output formatting by retained memory and CPU work. Preserve prompts and attachments across definite failures without replaying ambiguous destructive requests, and add focused UI, server, and Rust regressions. Validated with UI 435/435, Tauri 85/85, typechecks, and production build; the Windows worktree fixture remains branch-name dependent. --- .github/workflows/pr-build.yml | 14 ++ packages/server/src/api-types.ts | 4 +- .../src/permissions/auto-accept-manager.ts | 16 +- .../src/workspaces/instance-events.test.ts | 66 +++++++ .../server/src/workspaces/instance-events.ts | 85 +++++---- .../src-tauri/src/desktop_event_transport.rs | 17 +- .../src/desktop_event_transport/tests.rs | 22 ++- .../components/instance/instance-shell2.tsx | 6 +- .../shell/right-panel/useGitChanges.ts | 9 +- packages/ui/src/components/prompt-input.tsx | 34 +++- .../prompt-input/submitPrompt.test.ts | 32 +++- .../components/prompt-input/submitPrompt.ts | 50 ++++++ .../src/components/session/session-view.tsx | 5 +- packages/ui/src/components/tool-call.tsx | 30 +++- .../tool-call/diagnostic-selection.ts | 12 ++ .../components/tool-call/diagnostics.test.ts | 13 ++ .../src/components/tool-call/diagnostics.ts | 32 +++- .../components/tool-call/renderers/bash.tsx | 6 +- .../tool-call/renderers/default.tsx | 8 +- .../components/tool-call/renderers/skill.tsx | 5 +- .../components/tool-call/renderers/task.tsx | 54 ++++-- .../tool-call/renderers/webfetch.tsx | 13 +- .../components/tool-call/search-text.test.ts | 12 ++ .../src/components/tool-call/search-text.ts | 31 +++- packages/ui/src/components/tool-call/types.ts | 1 + .../ui/src/components/tool-call/utils.test.ts | 22 ++- packages/ui/src/components/tool-call/utils.ts | 11 +- packages/ui/src/lib/global-cache.test.ts | 67 +++++++ packages/ui/src/lib/global-cache.ts | 71 +++++++- packages/ui/src/lib/hooks/use-commands.ts | 20 +-- packages/ui/src/lib/server-events.ts | 7 +- packages/ui/src/lib/session-memory-budget.ts | 34 +++- .../ui/src/lib/session-message-cache.test.ts | 52 +++++- packages/ui/src/lib/session-message-cache.ts | 105 +++++++---- packages/ui/src/lib/session-search-matches.ts | 30 ++++ packages/ui/src/lib/session-search.test.ts | 11 ++ packages/ui/src/lib/session-search.ts | 139 +++++++++------ .../ui/src/lib/sse-manager-generation.test.ts | 11 ++ packages/ui/src/lib/sse-manager.ts | 28 ++- packages/ui/src/stores/client-state.test.ts | 74 ++++++++ packages/ui/src/stores/client-state.ts | 76 +++++++- packages/ui/src/stores/commands.test.ts | 15 ++ packages/ui/src/stores/commands.ts | 3 +- packages/ui/src/stores/delta-buffer.test.ts | 31 ++-- packages/ui/src/stores/delta-buffer.ts | 20 ++- packages/ui/src/stores/instances.ts | 96 ++++++++-- packages/ui/src/stores/message-v2/bridge.ts | 2 +- .../stores/message-v2/instance-store.test.ts | 8 + .../src/stores/message-v2/instance-store.ts | 70 +++----- packages/ui/src/stores/opencode-workspaces.ts | 10 +- packages/ui/src/stores/session-actions.ts | 92 ++++++++-- packages/ui/src/stores/session-api.ts | 161 ++++++++++++----- packages/ui/src/stores/session-events.ts | 98 ++++++++-- packages/ui/src/stores/session-memory.test.ts | 15 ++ packages/ui/src/stores/session-memory.ts | 19 +- .../ui/src/stores/session-message-cache.ts | 168 ++++++++++++++++-- .../stores/session-request-authority.test.ts | 81 ++++++++- packages/ui/src/stores/session-state.ts | 21 +++ packages/ui/src/types/instance.ts | 2 + 59 files changed, 1814 insertions(+), 433 deletions(-) create mode 100644 packages/server/src/workspaces/instance-events.test.ts create mode 100644 packages/ui/src/components/tool-call/diagnostic-selection.ts create mode 100644 packages/ui/src/components/tool-call/diagnostics.test.ts create mode 100644 packages/ui/src/components/tool-call/search-text.test.ts create mode 100644 packages/ui/src/lib/global-cache.test.ts create mode 100644 packages/ui/src/lib/session-search-matches.ts create mode 100644 packages/ui/src/lib/session-search.test.ts create mode 100644 packages/ui/src/lib/sse-manager-generation.test.ts create mode 100644 packages/ui/src/stores/commands.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 9ab747b15..f5df0b5c3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,9 +104,14 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/tool-call/diagnostics.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts + packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/session-memory-budget.test.ts + packages/ui/src/lib/session-message-cache.test.ts + packages/ui/src/lib/session-search.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 @@ -117,8 +122,11 @@ 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/commands.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/message-hydration-authority.test.ts + packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-metadata.test.ts packages/ui/src/stores/session-pagination.test.ts @@ -129,6 +137,12 @@ jobs: node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts + packages/ui/src/stores/session-request-authority.test.ts + packages/ui/src/stores/session-memory.test.ts + packages/ui/src/components/tool-call/search-text.test.ts + packages/ui/src/components/tool-call/utils.test.ts + packages/ui/src/lib/sse-manager-generation.test.ts + packages/ui/src/stores/session-state-purge.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d61b12f0e..bfd87b6f2 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -482,8 +482,8 @@ export type WorkspaceEventPayload = | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } - | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } - | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "instance.event"; instanceId: string; streamId?: string; event: InstanceStreamEvent } + | { type: "instance.eventStatus"; instanceId: string; streamId?: string; status: InstanceStreamStatus; reason?: string } | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index 3fc7e7e60..c64644742 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -70,6 +70,7 @@ export class AutoAcceptManager { private readonly hydration = new Map>() private readonly queuedEvents = new Map() private readonly instanceGeneration = new Map() + private readonly streamIds = new Map() private readonly sessionWorkspaces = new Map>() private readonly mutations = new Map>() private unsubscribe?: () => void @@ -78,8 +79,13 @@ export class AutoAcceptManager { start(): void { if (this.unsubscribe) return - const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => { + const handler = (payload: { instanceId?: string; streamId?: string; event?: InstanceStreamPayload }) => { if (!payload || !payload.instanceId || !payload.event) return + if (payload.streamId) { + const current = this.streamIds.get(payload.instanceId) + if (current && current !== payload.streamId) return + this.streamIds.set(payload.instanceId, payload.streamId) + } if (this.deps.persistence && !this.hydratedInstances.has(payload.instanceId)) { const queued = this.queuedEvents.get(payload.instanceId) ?? [] queued.push(payload.event) @@ -104,12 +110,19 @@ export class AutoAcceptManager { const onError = (event: { workspace?: { id?: string } }) => { if (event?.workspace?.id) this.clearInstance(event.workspace.id) } + const onStreamStatus = (event: { instanceId?: string; streamId?: string; status?: string }) => { + if (!event.instanceId || !event.streamId || event.status !== "connecting") return + if (this.streamIds.get(event.instanceId) !== event.streamId) this.queuedEvents.delete(event.instanceId) + this.streamIds.set(event.instanceId, event.streamId) + } this.deps.eventBus.on("instance.event", handler) + this.deps.eventBus.on("instance.eventStatus", onStreamStatus) this.deps.eventBus.on("workspace.started", onStarted) this.deps.eventBus.on("workspace.stopped", onStopped) this.deps.eventBus.on("workspace.error", onError) this.unsubscribe = () => { this.deps.eventBus.off("instance.event", handler) + this.deps.eventBus.off("instance.eventStatus", onStreamStatus) this.deps.eventBus.off("workspace.started", onStarted) this.deps.eventBus.off("workspace.stopped", onStopped) this.deps.eventBus.off("workspace.error", onError) @@ -237,6 +250,7 @@ export class AutoAcceptManager { this.hydration.delete(instanceId) this.queuedEvents.delete(instanceId) this.sessionWorkspaces.delete(instanceId) + this.streamIds.delete(instanceId) this.mutations.delete(instanceId) this.store.clearInstance(instanceId) this.pending.delete(instanceId) diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts new file mode 100644 index 000000000..099872a8a --- /dev/null +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { EventBus } from "../events/bus" +import { InstanceEventBridge } from "./instance-events" + +test("instance event bridge parses CRLF-delimited SSE frames with a stream id", () => { + const eventBus = new EventBus() + const published: any[] = [] + eventBus.on("instance.event", (event) => published.push(event)) + const bridge = new InstanceEventBridge({ + eventBus, + workspaceManager: {} as any, + logger: { debug() {}, trace() {}, warn() {}, isLevelEnabled: () => false } as any, + }) + + const remaining = (bridge as any).flushEvents( + 'data: {"type":"session.idle","properties":{"sessionID":"s"}}\r\n\r\n', + "instance", + "stream", + ) + + assert.equal(remaining, "") + assert.equal(published[0]?.streamId, "stream") + bridge.shutdown() +}) + +test("instance event bridge parses CR-delimited SSE frames", () => { + const eventBus = new EventBus() + const published: any[] = [] + eventBus.on("instance.event", (event) => published.push(event)) + const bridge = new InstanceEventBridge({ + eventBus, + workspaceManager: {} as any, + logger: { debug() {}, trace() {}, warn() {}, isLevelEnabled: () => false } as any, + }) + + const remaining = (bridge as any).flushEvents( + 'data: {"type":"session.idle","properties":{"sessionID":"s"}}\r\r', + "instance", + "stream", + ) + + assert.equal(remaining, "") + assert.equal(published.length, 1) + bridge.shutdown() +}) + +test("instance event bridge rotates stream authority when the workspace pid changes", () => { + const eventBus = new EventBus() + const bridge = new InstanceEventBridge({ + eventBus, + workspaceManager: { getInstancePort: () => undefined } as any, + logger: { debug() {}, trace() {}, warn() {}, isLevelEnabled: () => false } as any, + }) + const workspace = { id: "instance", pid: 1 } + eventBus.publish({ type: "workspace.started", workspace } as any) + const first = (bridge as any).streams.get(workspace.id) + eventBus.publish({ type: "workspace.started", workspace } as any) + assert.equal((bridge as any).streams.get(workspace.id).streamId, first.streamId) + + eventBus.publish({ type: "workspace.started", workspace: { ...workspace, pid: 2 } } as any) + const second = (bridge as any).streams.get(workspace.id) + assert.notEqual(second.streamId, first.streamId) + assert.equal(first.controller.signal.aborted, true) + bridge.shutdown() +}) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index e76e8978a..e65340c75 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -1,5 +1,6 @@ import { Agent, fetch } from "undici" import { Agent as UndiciAgent } from "undici" +import { randomUUID } from "node:crypto" import { EventBus } from "../events/bus" import { Logger } from "../logger" import { WorkspaceManager } from "./manager" @@ -8,6 +9,7 @@ import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types" const INSTANCE_HOST = "127.0.0.1" const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 }) const RECONNECT_DELAY_MS = 1000 +const MAX_EVENT_BUFFER_CHARACTERS = 16 * 1024 * 1024 interface InstanceEventBridgeOptions { workspaceManager: WorkspaceManager @@ -17,6 +19,8 @@ interface InstanceEventBridgeOptions { interface ActiveStream { controller: AbortController + streamId: string + runtimePid?: number task: Promise } @@ -25,7 +29,7 @@ export class InstanceEventBridge { constructor(private readonly options: InstanceEventBridgeOptions) { const bus = this.options.eventBus - bus.on("workspace.started", (event) => this.startStream(event.workspace.id)) + bus.on("workspace.started", (event) => this.startStream(event.workspace.id, event.workspace.pid)) bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped")) bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error")) } @@ -33,22 +37,25 @@ export class InstanceEventBridge { shutdown() { for (const [id, active] of this.streams) { active.controller.abort() - this.publishStatus(id, "disconnected") + this.publishStatus(id, active.streamId, "disconnected") } this.streams.clear() } - private startStream(workspaceId: string) { - if (this.streams.has(workspaceId)) { - return + private startStream(workspaceId: string, runtimePid?: number) { + const existing = this.streams.get(workspaceId) + if (existing) { + if (existing.runtimePid === runtimePid) return + this.stopStream(workspaceId, "workspace restarted") } const controller = new AbortController() - const task = this.runStream(workspaceId, controller.signal) + const streamId = randomUUID() + const task = this.runStream(workspaceId, streamId, controller.signal) .catch((error) => { if (!controller.signal.aborted) { this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed") - this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error)) + this.publishStatus(workspaceId, streamId, "error", error instanceof Error ? error.message : String(error)) } }) .finally(() => { @@ -58,7 +65,7 @@ export class InstanceEventBridge { } }) - this.streams.set(workspaceId, { controller, task }) + this.streams.set(workspaceId, { controller, streamId, runtimePid, task }) } private stopStream(workspaceId: string, reason?: string) { @@ -68,10 +75,10 @@ export class InstanceEventBridge { } active.controller.abort() this.streams.delete(workspaceId) - this.publishStatus(workspaceId, "disconnected", reason) + this.publishStatus(workspaceId, active.streamId, "disconnected", reason) } - private async runStream(workspaceId: string, signal: AbortSignal) { + private async runStream(workspaceId: string, streamId: string, signal: AbortSignal) { while (!signal.aborted) { const port = this.options.workspaceManager.getInstancePort(workspaceId) if (!port) { @@ -79,22 +86,23 @@ export class InstanceEventBridge { continue } - this.publishStatus(workspaceId, "connecting") + this.publishStatus(workspaceId, streamId, "connecting") try { - await this.consumeStream(workspaceId, port, signal) + await this.consumeStream(workspaceId, streamId, port, signal) + if (!signal.aborted) await this.delay(RECONNECT_DELAY_MS, signal) } catch (error) { if (signal.aborted) { break } this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected") - this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error)) + this.publishStatus(workspaceId, streamId, "error", error instanceof Error ? error.message : String(error)) await this.delay(RECONNECT_DELAY_MS, signal) } } } - private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { + private async consumeStream(workspaceId: string, streamId: string, port: number, signal: AbortSignal) { const url = `http://${INSTANCE_HOST}:${port}/global/event` const headers: Record = { Accept: "text/event-stream" } @@ -110,40 +118,51 @@ export class InstanceEventBridge { }) if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined) throw new Error(`Instance event stream unavailable (${response.status})`) } - this.publishStatus(workspaceId, "connected") + this.publishStatus(workspaceId, streamId, "connected") const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = "" - while (!signal.aborted) { - const { done, value } = await reader.read() - if (done || !value) { - break + try { + while (!signal.aborted) { + const { done, value } = await reader.read() + if (done || !value) { + break + } + buffer += decoder.decode(value, { stream: true }) + buffer = this.flushEvents(buffer, workspaceId, streamId) + if (buffer.length > MAX_EVENT_BUFFER_CHARACTERS) { + throw new Error("Instance event exceeded the stream buffer limit") + } } - buffer += decoder.decode(value, { stream: true }) - buffer = this.flushEvents(buffer, workspaceId) + } finally { + await reader.cancel().catch(() => undefined) + reader.releaseLock() } } - private flushEvents(buffer: string, workspaceId: string) { - let separatorIndex = buffer.indexOf("\n\n") + private flushEvents(buffer: string, workspaceId: string, streamId: string) { + let separator = /\r\n\r\n|\r\r|\n\n/.exec(buffer) - while (separatorIndex >= 0) { + while (separator) { + const separatorIndex = separator.index const chunk = buffer.slice(0, separatorIndex) - buffer = buffer.slice(separatorIndex + 2) - this.processChunk(chunk, workspaceId) - separatorIndex = buffer.indexOf("\n\n") + buffer = buffer.slice(separatorIndex + separator[0].length) + if (chunk.length > MAX_EVENT_BUFFER_CHARACTERS) throw new Error("Instance event exceeded the stream buffer limit") + this.processChunk(chunk, workspaceId, streamId) + separator = /\r\n\r\n|\r\r|\n\n/.exec(buffer) } return buffer } - private processChunk(chunk: string, workspaceId: string) { - const lines = chunk.split(/\r?\n/) + private processChunk(chunk: string, workspaceId: string, streamId: string) { + const lines = chunk.split(/\r\n|\r|\n/) const dataLines: string[] = [] for (const line of lines) { @@ -194,15 +213,15 @@ export class InstanceEventBridge { if (this.options.logger.isLevelEnabled("trace")) { this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload") } - this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event }) + this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, streamId, event }) } catch (error) { this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload") } } - private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) { - this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated") - this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason }) + private publishStatus(instanceId: string, streamId: string, status: InstanceStreamStatus, reason?: string) { + this.options.logger.debug({ instanceId, streamId, status, reason }, "Instance SSE status updated") + this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, streamId, status, reason }) } private delay(duration: number, signal: AbortSignal) { diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs index 372ca37c6..b5635a83a 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -316,11 +316,16 @@ fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value { } } -fn coalesced_instance_id(event: &Value) -> &str { - event +fn coalesced_instance_id(event: &Value) -> String { + let instance_id = event .get("instanceId") .and_then(Value::as_str) - .unwrap_or_default() + .unwrap_or_default(); + let stream_id = event + .get("streamId") + .and_then(Value::as_str) + .unwrap_or_default(); + format!("{}@{}", instance_id, stream_id) } fn snapshot_key(event: &Value) -> Option { @@ -467,7 +472,11 @@ fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map fn status_key(event: &Value) -> Option { match event.get("type")?.as_str()? { - "instance.eventStatus" => Some(coalesced_instance_id(event).to_string()), + "instance.eventStatus" => Some(format!( + "{}:{}", + coalesced_instance_id(event), + event.get("status").and_then(Value::as_str).unwrap_or_default() + )), "session.status" => snapshot_key(event), _ => None, } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs index f2440a201..09a78228b 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -113,7 +113,22 @@ fn coalesces_message_part_delta_events() { } #[test] -fn last_write_wins_for_status_events() { +fn does_not_coalesce_events_across_instance_streams() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + let mut old = delta_event("old"); + old["streamId"] = Value::String("old-stream".to_string()); + let mut new = delta_event("new"); + new["streamId"] = Value::String("new-stream".to_string()); + + pending.push(old, &mut stats); + pending.push(new, &mut stats); + + assert_eq!(pending.take_events().len(), 2); +} + +#[test] +fn preserves_connecting_before_connected_status() { let mut pending = PendingBatch::default(); let mut stats = fresh_stats(); pending.push( @@ -134,8 +149,9 @@ fn last_write_wins_for_status_events() { ); let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0]["status"].as_str(), Some("connected")); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["status"].as_str(), Some("connecting")); + assert_eq!(events[1]["status"].as_str(), Some("connected")); } #[test] diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index dd8067842..1ad02aef0 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -993,7 +993,11 @@ const InstanceShell2: Component = (props) => { await submit(session.id) } catch (error) { focusCreatedSessionPrompt(session.id) - throw error + const recoveryError = new Error(error instanceof Error ? error.message : String(error)) + ;(recoveryError as any).cause = error + ;(recoveryError as any).promptRecoverySessionId = session.id + if ((error as any)?.suppressPromptRecovery === true) (recoveryError as any).suppressPromptRecovery = true + throw recoveryError } } diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index de1899893..e4ad77633 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -7,7 +7,7 @@ import { getRootClient } from "../../../../stores/opencode-client" import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" -import { serverEvents } from "../../../../lib/server-events" +import { sseManager } from "../../../../lib/sse-manager" import { showToastNotification } from "../../../../lib/notifications" import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model" @@ -427,10 +427,9 @@ export function useGitChanges(options: UseGitChangesOptions) { createEffect(() => { if (options.rightPanelTab() !== "git-changes") return - const unsubscribe = serverEvents.on("instance.event", (event) => { - if (event.type !== "instance.event") return - if (event.instanceId !== options.instanceId) return - const eventType = (event.event as { type?: unknown } | undefined)?.type + const unsubscribe = sseManager.onAcceptedEvent((instanceId, event) => { + if (instanceId !== options.instanceId) return + const eventType = (event as { type?: unknown }).type if (eventType !== "session.updated") return void passiveRefreshGitStatus({ forceReloadSelectedDiff: true }) }) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index bb6ca60fe..c6ac6e134 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,13 +1,13 @@ import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js" import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid" import ExpandButton from "./expand-button" -import { clearAttachments, removeAttachment } from "../stores/attachments" +import { addAttachment, clearAttachments, getAttachments, removeAttachment } from "../stores/attachments" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" -import { preparePromptSubmission } from "./prompt-input/submitPrompt" +import { prepareFailedPromptRecovery, preparePromptSubmission } from "./prompt-input/submitPrompt" import { focusConversationStream } from "./focus-conversation" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" -import { agents, executeCustomCommand } from "../stores/sessions" +import { agents, executeCustomCommand, getSessionDraftPrompt, hydrateSessionDraftPrompt, setSessionDraftPrompt } from "../stores/sessions" import { getCommands } from "../stores/commands" import { showAlertDialog } from "../stores/alerts" import { useI18n } from "../lib/i18n" @@ -540,6 +540,34 @@ export default function PromptInput(props: PromptInputProps) { } } catch (error) { log.error("Failed to send message:", error) + const suppressRecovery = (error as any)?.suppressPromptRecovery === true + const recoverySessionId = typeof (error as any)?.promptRecoverySessionId === "string" + ? (error as any).promptRecoverySessionId + : props.sessionId + const currentPrompt = recoverySessionId === props.sessionId + ? prompt() + : getSessionDraftPrompt(props.instanceId, recoverySessionId) + const currentRecoveryAttachments = recoverySessionId === props.sessionId + ? attachments() + : getAttachments(props.instanceId, recoverySessionId) + if (!suppressRecovery) { + const recovery = prepareFailedPromptRecovery({ + submittedText: text, + submittedAttachments: currentAttachments, + currentText: currentPrompt, + currentAttachments: currentRecoveryAttachments, + }) + if (recoverySessionId === props.sessionId) setPrompt(recovery.text) + else { + setSessionDraftPrompt(props.instanceId, recoverySessionId, recovery.text) + hydrateSessionDraftPrompt(props.instanceId, recoverySessionId, recovery.text) + } + clearAttachments(props.instanceId, recoverySessionId) + for (const attachment of recovery.attachments) addAttachment(props.instanceId, recoverySessionId, attachment) + if (recoverySessionId === props.sessionId) syncAttachmentCounters(recovery.text) + } else if (!isKnownSlashCommand) { + void refreshHistory() + } showAlertDialog(t("promptInput.send.errorFallback"), { title: t("promptInput.send.errorTitle"), detail: error instanceof Error ? error.message : String(error), diff --git a/packages/ui/src/components/prompt-input/submitPrompt.test.ts b/packages/ui/src/components/prompt-input/submitPrompt.test.ts index ade3a3809..37cf63825 100644 --- a/packages/ui/src/components/prompt-input/submitPrompt.test.ts +++ b/packages/ui/src/components/prompt-input/submitPrompt.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { createTextAttachment } from "../../types/attachment" -import { preparePromptSubmission } from "./submitPrompt" +import { prepareFailedPromptRecovery, preparePromptSubmission } from "./submitPrompt" describe("preparePromptSubmission", () => { it("keeps placeholder-backed pasted text intact for message submission while resolving history text", () => { @@ -18,3 +18,33 @@ describe("preparePromptSubmission", () => { assert.equal(result.historyEntry, "Intro\nalpha\nbeta\ngamma\ndelta\nOutro") }) }) + +describe("prepareFailedPromptRecovery", () => { + it("remaps colliding pasted placeholders without losing either value", () => { + const oldPaste = { ...createTextAttachment("OLD", "pasted #1", "old.txt"), id: "old" } + const newPaste = { ...createTextAttachment("NEW", "pasted #1", "new.txt"), id: "new" } + const recovered = prepareFailedPromptRecovery({ + submittedText: "[pasted #1]", + submittedAttachments: [oldPaste], + currentText: "[pasted #1]", + currentAttachments: [newPaste], + }) + assert.equal(recovered.text, "[pasted #2]\n[pasted #1]") + assert.deepEqual(recovered.attachments.map((attachment) => attachment.display), ["pasted #1", "pasted #2"]) + }) + it("does not cascade counter replacements across multiple recovered pastes", () => { + const recovered = prepareFailedPromptRecovery({ + submittedText: "[pasted #1] [pasted #2]", + submittedAttachments: [ + { ...createTextAttachment("ONE", "pasted #1", "one.txt"), id: "old-1" }, + { ...createTextAttachment("TWO", "pasted #2", "two.txt"), id: "old-2" }, + ], + currentText: "[pasted #1] [pasted #2]", + currentAttachments: [ + { ...createTextAttachment("NEW ONE", "pasted #1", "new-one.txt"), id: "new-1" }, + { ...createTextAttachment("NEW TWO", "pasted #2", "new-two.txt"), id: "new-2" }, + ], + }) + assert.equal(recovered.text, "[pasted #3] [pasted #4]\n[pasted #1] [pasted #2]") + }) +}) diff --git a/packages/ui/src/components/prompt-input/submitPrompt.ts b/packages/ui/src/components/prompt-input/submitPrompt.ts index 8af78fce5..5560d1088 100644 --- a/packages/ui/src/components/prompt-input/submitPrompt.ts +++ b/packages/ui/src/components/prompt-input/submitPrompt.ts @@ -9,6 +9,56 @@ export interface PromptSubmissionResult { resolvedCommandArgs: string } +export function prepareFailedPromptRecovery(input: { + submittedText: string + submittedAttachments: Attachment[] + currentText: string + currentAttachments: Attachment[] +}): { text: string; attachments: Attachment[] } { + const usedCounters = new Set() + for (const attachment of input.currentAttachments) { + const match = attachment.display?.match(/^pasted #(\d+)/i) + if (match) usedCounters.add(Number(match[1])) + } + const allocatedCounters = new Set(usedCounters) + for (const attachment of input.submittedAttachments) { + const match = attachment.display?.match(/^pasted #(\d+)/i) + if (match) allocatedCounters.add(Number(match[1])) + } + + const counterReplacements = new Map() + const recovered: Attachment[] = [] + for (const attachment of input.submittedAttachments) { + if (input.currentAttachments.some((current) => current.id === attachment.id)) continue + const match = attachment.display?.match(/^pasted #(\d+)/i) + const originalCounter = match ? Number(match[1]) : undefined + if (originalCounter === undefined || !usedCounters.has(originalCounter)) { + if (originalCounter !== undefined) usedCounters.add(originalCounter) + recovered.push(attachment) + continue + } + + let nextCounter = 1 + while (allocatedCounters.has(nextCounter)) nextCounter += 1 + usedCounters.add(nextCounter) + allocatedCounters.add(nextCounter) + counterReplacements.set(originalCounter, nextCounter) + recovered.push({ + ...attachment, + display: attachment.display?.replace(/^pasted #\d+/i, `pasted #${nextCounter}`), + }) + } + const submittedText = input.submittedText.replace(/\[pasted #(\d+)(?=[\]\s])/gi, (token, counter) => { + const replacement = counterReplacements.get(Number(counter)) + return replacement === undefined ? token : `[pasted #${replacement}` + }) + + return { + text: input.currentText ? `${submittedText}\n${input.currentText}` : submittedText, + attachments: [...input.currentAttachments, ...recovered], + } +} + export function preparePromptSubmission(input: { mode: PromptSubmissionMode text: string diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 0df4a874f..9c11b232a 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -7,7 +7,7 @@ import { messageStoreBus } from "../../stores/message-v2/bus" import PromptInput from "../prompt-input" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { getAttachments, removeAttachment } from "../../stores/attachments" -import { instances, waitForInstanceWorkspaceMetadataHydration } from "../../stores/instances" +import { instances, isInstanceRuntimeCurrent, waitForInstanceWorkspaceMetadataHydration } from "../../stores/instances" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions" import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { deleteMessage } from "../../stores/session-actions" @@ -426,6 +426,7 @@ export const SessionView: Component = (props) => { if (!instance || !instance.client) return try { + invalidateSessionMessageCache(props.instanceId, props.sessionId) await requestData( instance.client.session.revert({ sessionID: props.sessionId, @@ -433,7 +434,7 @@ export const SessionView: Component = (props) => { }), "session.revert", ) - if (instances().get(props.instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(props.instanceId, instance)) return if (messageStore().getSessionRevert(props.sessionId)?.messageID !== messageId) { invalidateSessionMessageLoad(props.instanceId, props.sessionId) invalidateSessionMessageCache(props.instanceId, props.sessionId) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 46bd9f607..a19b4f5d0 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -480,12 +480,26 @@ function ToolCallDetails(props: { const outputChrome = createMemo(() => renderer().getOutputChrome?.(rendererContext) ?? {}) + const resolveOutputCopyText = () => outputChrome().copyText || outputChrome().getCopyText?.() || "" + const renderError = () => { const state = props.toolState() if (state?.status === "error" && state.error) { + const truncated = state.error.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT return (
- {props.t("toolCall.error.label")} {limitToolOutputForRender(state.error)} + {props.t("toolCall.error.label")} {limitToolOutputForRender(state.error)} + + +
) } @@ -653,7 +667,14 @@ function ToolCallDetails(props: {
+ + {(actions) =>
{actions()}
} +
+ {renderToolOutputBody()} + + )} >
@@ -690,6 +711,7 @@ function ToolCallDetails(props: { expanded: props.outputSectionExpanded, onToggle: props.toggleOutputSection, copyText: () => outputChrome().copyText, + onCopy: outputChrome().getCopyText ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined, copyTitle: () => props.t("toolCall.io.copyOutputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"), actions: () => outputChrome().actions, @@ -983,8 +1005,8 @@ export default function ToolCall(props: ToolCallProps) { return [typeLabel, detail].filter(Boolean).join(" ") }) - const headerCopyText = createMemo(() => headerOutputChrome().copyText || "") - const canCopyHeaderOutput = () => headerCopyText().length > 0 + const headerCopyText = () => headerOutputChrome().copyText || headerOutputChrome().getCopyText?.() || "" + const canCopyHeaderOutput = () => Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText) const canToggleOutputWrap = () => Boolean(headerOutputChrome().wrapToggle) const outputWrapTitle = () => outputWrapEnabled() diff --git a/packages/ui/src/components/tool-call/diagnostic-selection.ts b/packages/ui/src/components/tool-call/diagnostic-selection.ts new file mode 100644 index 000000000..304563099 --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostic-selection.ts @@ -0,0 +1,12 @@ +export function selectSeverityBounded(values: readonly T[], rank: (value: T) => number | undefined, limit: number): T[] { + const buckets: T[][] = [[], [], []] + const scanLimit = Math.max(limit, limit * 100) + for (let index = 0; index < values.length && index < scanLimit; index += 1) { + const value = values[index] + const valueRank = rank(value) + if (valueRank === undefined) continue + const bucket = buckets[Math.max(0, Math.min(2, valueRank))]! + if (bucket.length < limit) bucket.push(value) + } + return buckets.flat().slice(0, limit) +} diff --git a/packages/ui/src/components/tool-call/diagnostics.test.ts b/packages/ui/src/components/tool-call/diagnostics.test.ts new file mode 100644 index 000000000..62602150a --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostics.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { selectSeverityBounded } from "./diagnostic-selection.ts" + +test("diagnostic bounds retain errors that follow informational entries", () => { + const diagnostics = [ + ...Array.from({ length: 100 }, (_, index) => ({ message: `info ${index}`, severity: 3 })), + { message: "the error", severity: 1 }, + ] + const entries = selectSeverityBounded(diagnostics, (entry) => entry.severity === 1 ? 0 : 2, 100) + assert.equal(entries.length, 100) + assert.equal(entries[0]?.message, "the error") +}) diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index 237b35a4e..a84fe1e7d 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -1,6 +1,7 @@ import type { ToolState } from "@opencode-ai/sdk/v2" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { tGlobal } from "../../lib/i18n" +import { selectSeverityBounded } from "./diagnostic-selection" interface LspRangePosition { line?: number @@ -64,10 +65,8 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr } export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { - if (Object.keys(diagnostics).length === 0) return undefined - const normalizedPreferred = preferredPaths - .filter((value): value is string => typeof value === "string" && value.length > 0) + .filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096) .map((value) => normalizeDiagnosticPath(value)) if (normalizedPreferred.length === 0) return undefined @@ -76,7 +75,16 @@ export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPath if (diagnostics[preferred]) return preferred } - const keys = Object.keys(diagnostics) + const keys: string[] = [] + let scannedKeys = 0 + for (const key in diagnostics) { + if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue + scannedKeys += 1 + if (scannedKeys > 10_000) break + if (key.length > 4_096) continue + keys.push(key) + } + if (keys.length === 0) return undefined for (const preferred of normalizedPreferred) { const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred) @@ -101,11 +109,18 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat const list = diagnostics[key] if (!Array.isArray(list) || list.length === 0) return [] - const entries: DiagnosticEntry[] = [] const limit = 100 const normalizedPath = normalizeDiagnosticPath(key) - for (let index = 0; index < list.length; index++) { - const diagnostic = list[index] + const selected = selectSeverityBounded( + list, + (diagnostic) => diagnostic && typeof diagnostic.message === "string" + ? getSeverityMeta(determineSeverityTone(diagnostic.severity)).rank + : undefined, + limit, + ) + const entries: DiagnosticEntry[] = [] + for (const diagnostic of selected) { + const index = entries.length if (!diagnostic || typeof diagnostic.message !== "string") continue const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) const severityMeta = getSeverityMeta(tone) @@ -123,10 +138,9 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat line, column, }) - if (entries.length >= limit) break } - return entries.sort((a, b) => a.severity - b.severity) + return entries } export function diagnosticFileName(entries: DiagnosticEntry[]) { diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 9fdec9852..d5451bc2c 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -197,9 +197,9 @@ export const bashRenderer: ToolRenderer = { return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` }, getOutputChrome({ toolState }) { - const text = getBashCopyText(toolState()) - if (!text) return undefined - return { language: "bash", copyText: text, wrapToggle: true, suppressInnerHeader: true } + const state = toolState() + if (!state || state.status === "pending") return undefined + return { language: "bash", getCopyText: () => getBashCopyText(state), wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) { return diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx index 9433bb643..e0835f606 100644 --- a/packages/ui/src/components/tool-call/renderers/default.tsx +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -16,12 +16,12 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknownForCopy(primaryOutput) - if (!result) return undefined + if (primaryOutput === undefined || primaryOutput === null) return undefined + const rendered = formatUnknownForRender(primaryOutput) return { - language: result.language ?? "text", - copyText: result.text, + language: rendered?.language ?? "text", + getCopyText: () => formatUnknownForCopy(primaryOutput)?.text ?? null, wrapToggle: true, suppressInnerHeader: true, } diff --git a/packages/ui/src/components/tool-call/renderers/skill.tsx b/packages/ui/src/components/tool-call/renderers/skill.tsx index ab9017201..af6810537 100644 --- a/packages/ui/src/components/tool-call/renderers/skill.tsx +++ b/packages/ui/src/components/tool-call/renderers/skill.tsx @@ -12,9 +12,8 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return undefined - const output = formatUnknownForCopy(state.output)?.text ?? null - if (!output) return undefined - return { copyText: output, suppressInnerHeader: true } + if (state.output === undefined || state.output === null) return undefined + return { getCopyText: () => formatUnknownForCopy(state.output)?.text ?? null, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown }) { const state = toolState() diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 08c9d1570..88bf761b1 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -215,6 +215,11 @@ export const taskRenderer: ToolRenderer = { return loadedForInstance?.has(id) ?? false }) + const childSessionResident = createMemo(() => { + const id = childSessionId() + return Boolean(id && store.getSessionMessageIds(id).length > 0) + }) + const childSessionLoading = createMemo(() => { const id = childSessionId() if (!id) return false @@ -240,6 +245,7 @@ export const taskRenderer: ToolRenderer = { }) const [childToolKeys, setChildToolKeys] = createSignal([]) + const [childToolsTruncated, setChildToolsTruncated] = createSignal(false) let indexedSessionId = "" let indexedMessageCount = 0 @@ -252,20 +258,23 @@ export const taskRenderer: ToolRenderer = { indexedMessageTail = "" indexedPartCounts.clear() setChildToolKeys([]) + setChildToolsTruncated(false) } - function scanMessageToolParts(messageId: string, startIndex: number) { + function scanMessageToolParts(messageId: string, startIndex: number, limit: number) { const record = store.getMessage(messageId) if (!record) return [] as string[] const partIds = record.partIds const keys: string[] = [] - for (let idx = startIndex; idx < partIds.length; idx += 1) { + const oldestScannedIndex = Math.max(startIndex, partIds.length - 1_000) + if (oldestScannedIndex > startIndex) setChildToolsTruncated(true) + for (let idx = partIds.length - 1; idx >= oldestScannedIndex && keys.length < limit; idx -= 1) { const partId = partIds[idx] const entry = record.parts?.[partId] const data = entry?.data if (!data || (data as any).type !== "tool") continue - keys.push(`${messageId}::${partId}`) + keys.unshift(`${messageId}::${partId}`) } indexedPartCounts.set(messageId, partIds.length) return keys @@ -278,15 +287,18 @@ export const taskRenderer: ToolRenderer = { indexedPartCounts.clear() const nextKeys: string[] = [] - for (let index = messageIds.length - 1; index >= 0 && nextKeys.length < TASK_STEP_RENDER_LIMIT; index -= 1) { - nextKeys.unshift(...scanMessageToolParts(messageIds[index], 0)) + 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) + for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex]) } + setChildToolsTruncated(childToolsTruncated() || messageIds.length > 1_000 || nextKeys.length === TASK_STEP_RENDER_LIMIT) setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT)) } createEffect(() => { const id = childSessionId() - const loaded = childSessionLoaded() + const loaded = childSessionLoaded() || childSessionResident() if (!id || !loaded) { if (indexedSessionId) { @@ -323,9 +335,11 @@ export const taskRenderer: ToolRenderer = { const appendedKeys: string[] = [] // Scan any new messages appended since last index. - for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) { + for (let idx = Math.max(indexedMessageCount, messageIds.length - 1_000); idx < messageIds.length; idx += 1) { const messageId = messageIds[idx] - appendedKeys.push(...scanMessageToolParts(messageId, 0)) + const keys = scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT) + for (const key of keys) appendedKeys.push(key) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT) } // Scan a small window of recent messages for newly appended parts. @@ -339,14 +353,21 @@ export const taskRenderer: ToolRenderer = { const record = store.getMessage(messageId) const nextPartCount = record?.partIds.length ?? 0 if (nextPartCount > previousPartCount) { - appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount)) + const keys = scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT) + for (const key of keys) appendedKeys.push(key) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) 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)) + for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId) + } if (appendedKeys.length > 0) { + if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true) setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT)) } }) @@ -395,21 +416,22 @@ export const taskRenderer: ToolRenderer = { return null }) - const legacyItems = createMemo(() => { + const legacySummary = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() partVersion?.() const state = toolState() if (!state) return [] + const { metadata } = readToolStatePayload(state) + return Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] + }) + const legacyItems = createMemo(() => { // Prefer deriving steps from the child session when loaded. if (childSessionLoaded()) return [] - const { metadata } = readToolStatePayload(state) - const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] - - return summary.slice(-TASK_STEP_RENDER_LIMIT).map((entry, index) => { + return legacySummary().slice(-TASK_STEP_RENDER_LIMIT).map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} @@ -457,11 +479,11 @@ export const taskRenderer: ToolRenderer = {
{t("toolCall.task.sections.steps")}
- = TASK_STEP_RENDER_LIMIT || legacyItems().length >= TASK_STEP_RENDER_LIMIT}> + TASK_STEP_RENDER_LIMIT}>
{t("toolCall.output.truncated")}
formatUnknownForCopy(output)?.text ?? null, wrapToggle: true, suppressInnerHeader: true, } diff --git a/packages/ui/src/components/tool-call/search-text.test.ts b/packages/ui/src/components/tool-call/search-text.test.ts new file mode 100644 index 000000000..07a50966d --- /dev/null +++ b/packages/ui/src/components/tool-call/search-text.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getTaskToolSearchText } from "./search-text.ts" + +test("task search keeps visible output ahead of an oversized prompt", () => { + const values = getTaskToolSearchText({ + toolCall: { type: "tool", id: "tool", tool: "task", state: {} } as any, + toolName: "task", + toolState: { status: "completed", input: { prompt: "x".repeat(20_000) }, output: "UNIQUE_RESULT" } as any, + }) + assert.equal(values.join("\n").includes("UNIQUE_RESULT"), true) +}) diff --git a/packages/ui/src/components/tool-call/search-text.ts b/packages/ui/src/components/tool-call/search-text.ts index b906be102..96eb605ec 100644 --- a/packages/ui/src/components/tool-call/search-text.ts +++ b/packages/ui/src/components/tool-call/search-text.ts @@ -15,17 +15,22 @@ const TOOL_SEARCH_CHARACTER_LIMIT = 10_000 const searchSizes = new WeakMap() function appendString(values: string[], value: unknown) { - if (typeof value !== "string" || value.trim().length === 0) return + if (typeof value !== "string") return const used = searchSizes.get(values) ?? 0 const remaining = TOOL_SEARCH_CHARACTER_LIMIT - used if (remaining <= 0) return const text = limitToolOutputForRender(value).slice(0, remaining) - values.push(text) searchSizes.set(values, used + text.length) + if (text.trim().length === 0) return + values.push(text) } function appendFormatted(values: string[], value: unknown) { if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) return + if (typeof value === "string") { + appendString(values, value) + return + } if (exceedsRetainedByteLimit(value, 10_000)) return const result = formatUnknown(value) if (result?.text.trim()) appendString(values, result.text) @@ -122,7 +127,9 @@ export function getApplyPatchToolSearchText(context: ToolSearchTextContext): str const { metadata, output } = readToolStatePayload(context.toolState) const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as any[]) : [] - for (const file of files) { + for (let index = 0; index < files.length && index < 1_000; index += 1) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) break + const file = files[index] appendString(values, file?.filePath) appendString(values, file?.relativePath) appendString(values, file?.diff) @@ -149,11 +156,11 @@ export function getTaskToolSearchText(context: ToolSearchTextContext): string[] const values: string[] = [] const { input, metadata, output } = readToolStatePayload(context.toolState) appendBaseToolText(values, context) - appendString(values, input.prompt) - appendString(values, input.subagent_type) + appendToolErrorText(values, context) appendFormatted(values, output) appendFormatted(values, metadata.summary) - appendToolErrorText(values, context) + appendString(values, input.subagent_type) + appendString(values, input.prompt) return values } @@ -163,7 +170,9 @@ export function getTodoToolSearchText(context: ToolSearchTextContext): string[] const todos = Array.isArray((metadata as any).todos) ? ((metadata as any).todos as any[]) : [] appendBaseToolText(values, context) - for (const todo of todos) { + for (let index = 0; index < todos.length && index < 1_000; index += 1) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) break + const todo = todos[index] appendString(values, todo?.content) appendString(values, todo?.status) } @@ -179,11 +188,15 @@ export function getQuestionToolSearchText(context: ToolSearchTextContext): strin const answers = Array.isArray((metadata as any).answers) ? ((metadata as any).answers as unknown[]) : [] appendBaseToolText(values, context) - for (const question of questions) { + for (let questionIndex = 0; questionIndex < questions.length && questionIndex < 100; questionIndex += 1) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) break + const question = questions[questionIndex] appendString(values, question.header) appendString(values, question.question) const options = Array.isArray(question.options) ? (question.options as QuestionOption[]) : [] - for (const option of options) { + for (let optionIndex = 0; optionIndex < options.length && optionIndex < 100; optionIndex += 1) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) break + const option = options[optionIndex] appendString(values, option.label) appendString(values, option.description) } diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index db6d40ded..41465ec00 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -105,6 +105,7 @@ export interface ToolOutputChrome { title?: string language?: string copyText?: string | null + getCopyText?: () => string | null actions?: JSXElement wrapToggle?: boolean suppressInnerHeader?: boolean diff --git a/packages/ui/src/components/tool-call/utils.test.ts b/packages/ui/src/components/tool-call/utils.test.ts index 772d18f14..c9e258e77 100644 --- a/packages/ui/src/components/tool-call/utils.test.ts +++ b/packages/ui/src/components/tool-call/utils.test.ts @@ -1,12 +1,28 @@ import assert from "node:assert/strict" import test from "node:test" -import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils.ts" +import { formatUnknownForCopy, limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils.ts" -test("tool output rendering keeps bounded head and tail content", () => { +test("tool output rendering keeps a bounded prefix without exposing fenced tail content", () => { const text = `HEAD${"x".repeat(20_000)}TAIL` const rendered = limitToolOutputForRender(text) assert.ok(rendered.length < text.length) assert.ok(rendered.length < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 100) assert.ok(rendered.startsWith("HEAD")) - assert.ok(rendered.endsWith("TAIL")) + assert.equal(rendered.includes("TAIL"), false) +}) + +test("tool output truncation cannot expose HTML from the tail of a fenced block", () => { + const rendered = limitToolOutputForRender(`\`\`\`text\n${"x".repeat(20_000)}\n\`\`\`\n`) + assert.equal(rendered.includes(" { + const value = { output: "x".repeat(20_000) } + assert.equal(formatUnknownForCopy(value)?.text, JSON.stringify(value, null, 2)) +}) + +test("explicit copy fails safely for pathologically nested arrays", () => { + let value: unknown = "leaf" + for (let depth = 0; depth < 20_000; depth += 1) value = [value] + assert.doesNotThrow(() => formatUnknownForCopy(value)) }) diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index dc75ba019..da2c21026 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -17,8 +17,7 @@ export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000 export function limitToolOutputForRender(text: string): string { if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text - const half = Math.floor(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT / 2) - return `${text.slice(0, half)}\n\n${tGlobal("toolCall.output.truncated")}\n\n${text.slice(-half)}` + return `${text.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)}\n\n${tGlobal("toolCall.output.truncated")}` } export function isToolStateRunning(state: ToolState): state is ToolStateRunning { @@ -167,8 +166,12 @@ export function formatUnknownForRender(value: unknown): { text: string; language } export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null { - if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) return null - return formatUnknown(value) + try { + return formatUnknown(value) + } catch (error) { + log.error("Failed to format tool call output for copy", error) + return { text: tGlobal("toolCall.output.tooLarge") } + } } export function inferLanguageFromPath(path?: string): string | undefined { diff --git a/packages/ui/src/lib/global-cache.test.ts b/packages/ui/src/lib/global-cache.test.ts new file mode 100644 index 000000000..61fc0fcb7 --- /dev/null +++ b/packages/ui/src/lib/global-cache.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clearCacheForInstance, getCacheEntry, setCacheEntry } from "./global-cache.ts" + +test("global render cache rejects a single oversized derived value", () => { + const params = { instanceId: "instance", sessionId: "session", scope: "markdown", cacheId: "entry", version: "1" } + try { + setCacheEntry(params, "x".repeat(3 * 1024 * 1024)) + assert.equal(getCacheEntry(params), undefined) + } finally { + clearCacheForInstance("instance") + } +}) + +test("global render cache counts raw buffers and zero-byte entries", () => { + const oversized = { instanceId: "buffer", sessionId: "session", scope: "raw", cacheId: "entry", version: "1" } + try { + setCacheEntry(oversized, new ArrayBuffer(5 * 1024 * 1024)) + assert.equal(getCacheEntry(oversized), undefined) + + for (let index = 0; index <= 4_096; index += 1) { + setCacheEntry({ instanceId: "entries", sessionId: String(index), scope: "raw", cacheId: "entry", version: "1" }, null) + } + assert.equal(getCacheEntry({ instanceId: "entries", sessionId: "0", scope: "raw", cacheId: "entry", version: "1" }), undefined) + assert.equal(getCacheEntry({ instanceId: "entries", sessionId: "4096", scope: "raw", cacheId: "entry", version: "1" }), null) + } finally { + clearCacheForInstance("buffer") + clearCacheForInstance("entries") + } +}) + +test("global render cache counts retained backing buffers and cache keys", () => { + const viewParams = { instanceId: "view", sessionId: "session", scope: "raw", cacheId: "entry", version: "1" } + const keyParams = { instanceId: "key", sessionId: "session", scope: "raw", cacheId: "x".repeat(3 * 1024 * 1024), version: "1" } + try { + setCacheEntry(viewParams, new Uint8Array(new ArrayBuffer(5 * 1024 * 1024), 0, 1)) + setCacheEntry(keyParams, null) + assert.equal(getCacheEntry(viewParams), undefined) + assert.equal(getCacheEntry(keyParams), undefined) + } finally { + clearCacheForInstance("view") + clearCacheForInstance("key") + } +}) + +test("global render cache rejects growable buffers", () => { + const BufferConstructor = ArrayBuffer as typeof ArrayBuffer & { new(length: number, options: { maxByteLength: number }): ArrayBuffer & { resize?: (length: number) => void } } + const buffer = new BufferConstructor(1, { maxByteLength: 8 * 1024 * 1024 }) + if (typeof buffer.resize !== "function") return + const params = { instanceId: "growable", sessionId: "session", scope: "raw", cacheId: "entry", version: "1" } + try { + setCacheEntry(params, buffer) + assert.equal(getCacheEntry(params), undefined) + } finally { + clearCacheForInstance("growable") + } +}) + +test("global render cache does not trust spoofed buffer tags", () => { + const params = { instanceId: "spoof", sessionId: "session", scope: "raw", cacheId: "entry", version: "1" } + try { + setCacheEntry(params, { [Symbol.toStringTag]: "ArrayBuffer", payload: "x".repeat(3 * 1024 * 1024) }) + assert.equal(getCacheEntry(params), undefined) + } finally { + clearCacheForInstance("spoof") + } +}) diff --git a/packages/ui/src/lib/global-cache.ts b/packages/ui/src/lib/global-cache.ts index 1a5cb2575..c0e659d1c 100644 --- a/packages/ui/src/lib/global-cache.ts +++ b/packages/ui/src/lib/global-cache.ts @@ -1,3 +1,5 @@ +import { estimateRetainedBytes } from "./session-memory-budget" + export interface CacheEntryBaseParams { instanceId?: string sessionId?: string @@ -12,6 +14,7 @@ export interface CacheEntryParams extends CacheEntryBaseParams { type VersionedCacheEntry = { version: string value: unknown + byteSize: number } type CacheValueMap = Map @@ -19,7 +22,37 @@ type CacheScopeMap = Map type CacheSessionMap = Map const GLOBAL_KEY = "GLOBAL" +const MAX_SCOPE_CACHE_ENTRIES = 64 +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() +let retainedBytes = 0 +let retainedEntries = 0 + +function estimateCacheBytes(value: unknown): number { + return estimateRetainedBytes(value, MAX_CACHE_ENTRY_BYTES) +} + +function estimateCacheKeyBytes(params: CacheEntryParams): number { + return [params.instanceId, params.sessionId, params.scope, params.cacheId, params.version] + .reduce((total, value) => total + (value?.length ?? 0) * 2 + 16, 0) +} + +function recalculateRetainedBytes(): void { + retainedBytes = 0 + retainedEntries = 0 + for (const sessionMap of cacheStore.values()) { + for (const scopeMap of sessionMap.values()) { + for (const valueMap of scopeMap.values()) { + for (const entry of valueMap.values()) { + retainedBytes += entry.byteSize + retainedEntries += 1 + } + } + } + } +} function resolveKey(value?: string) { return value && value.length > 0 ? value : GLOBAL_KEY @@ -89,13 +122,42 @@ export function setCacheEntry(params: CacheEntryParams, value: T | undefined) if (value === undefined) { const existingMap = getScopeValueMap(params, false) + const existing = existingMap?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 existingMap?.delete(params.cacheId) cleanupHierarchy(instanceKey, sessionKey, params.scope) return } - const scopeEntries = getScopeValueMap(params, true) - scopeEntries?.set(params.cacheId, { version: params.version, value }) + const scopeEntries = getScopeValueMap(params, false) + const existing = scopeEntries?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 + const byteSize = estimateCacheBytes(value) + estimateCacheKeyBytes(params) + if (byteSize > MAX_CACHE_ENTRY_BYTES) { + scopeEntries?.delete(params.cacheId) + cleanupHierarchy(instanceKey, sessionKey, params.scope) + return + } + if (retainedBytes + byteSize > MAX_GLOBAL_CACHE_BYTES || retainedEntries >= MAX_GLOBAL_CACHE_ENTRIES) { + cacheStore.clear() + retainedBytes = 0 + retainedEntries = 0 + } + const target = getScopeValueMap(params, true) + if (!target) return + target.delete(params.cacheId) + target.set(params.cacheId, { version: params.version, value, byteSize }) + retainedBytes += byteSize + retainedEntries += 1 + while (target.size > MAX_SCOPE_CACHE_ENTRIES) { + const oldest = target.keys().next().value + if (oldest === undefined) break + retainedBytes -= target.get(oldest)?.byteSize ?? 0 + target.delete(oldest) + retainedEntries -= 1 + } } export function getCacheEntry(params: CacheEntryParams): T | undefined { @@ -104,6 +166,8 @@ export function getCacheEntry(params: CacheEntryParams): T | undefined { if (!entry || entry.version !== params.version) { return undefined } + scopeEntries!.delete(params.cacheId) + scopeEntries!.set(params.cacheId, entry) return entry.value as T } @@ -116,6 +180,7 @@ export function clearCacheScope(params: CacheEntryBaseParams): void { if (!scopeMap) return scopeMap.delete(params.scope) cleanupHierarchy(instanceKey, sessionKey) + recalculateRetainedBytes() } export function clearCacheForSession(instanceId?: string, sessionId?: string): void { @@ -127,10 +192,12 @@ export function clearCacheForSession(instanceId?: string, sessionId?: string): v if (sessionMap.size === 0) { cacheStore.delete(instanceKey) } + recalculateRetainedBytes() } export function clearCacheForInstance(instanceId?: string): void { const instanceKey = resolveKey(instanceId) cacheStore.delete(instanceKey) + recalculateRetainedBytes() } diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index dcb26dd89..5a095ebe4 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -7,9 +7,9 @@ import type { VisibilityPreference, } from "../../stores/preferences" import { createCommandRegistry, type Command } from "../commands" -import { activeInstanceId } from "../../stores/instances" +import { activeInstanceId, isInstanceRuntimeCurrent } from "../../stores/instances" import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs" -import type { ClientPart, MessageInfo } from "../../types/message" +import type { ClientPart } from "../../types/message" import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions" import { showAlertDialog } from "../../stores/alerts" import type { Instance } from "../../types/instance" @@ -255,6 +255,7 @@ export function useCommands(options: UseCommandsOptions) { }), "session.summarize", ) + invalidateSessionMessageCache(instance.id, sessionId) } catch (error) { log.error("Failed to compact session", error) const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback") @@ -300,25 +301,21 @@ export function useCommands(options: UseCommandsOptions) { const store = messageStoreBus.getOrCreate(instance.id) const messageIds = store.getSessionMessageIds(sessionId) - const infoMap = new Map() - messageIds.forEach((id) => { - const info = store.getMessageInfo(id) - if (info) infoMap.set(id, info) - }) const revertState = store.getSessionRevert(sessionId) ?? session.revert let after = 0 if (revertState?.messageID) { - const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID) + const revertInfo = store.getMessageInfo(revertState.messageID) after = revertInfo?.time?.created || 0 } let messageID = "" let restoredText: string | null = null - for (let i = messageIds.length - 1; i >= 0; i--) { + const firstScannedIndex = Math.max(0, messageIds.length - 10_000) + for (let i = messageIds.length - 1; i >= firstScannedIndex; i--) { const id = messageIds[i] const record = store.getMessage(id) - const info = infoMap.get(id) ?? store.getMessageInfo(id) + const info = store.getMessageInfo(id) if (record?.role === "user" && info?.time?.created) { if (after > 0 && info.time.created >= after) { continue @@ -338,6 +335,7 @@ export function useCommands(options: UseCommandsOptions) { } try { + invalidateSessionMessageCache(instance.id, sessionId) await requestData( instance.client.session.revert({ sessionID: sessionId, @@ -345,7 +343,7 @@ export function useCommands(options: UseCommandsOptions) { }), "session.revert", ) - if (activeInstance() !== instance) return + if (!isInstanceRuntimeCurrent(instance.id, instance)) return if (store.getSessionRevert(sessionId)?.messageID !== messageID) { invalidateSessionMessageLoad(instance.id, sessionId) invalidateSessionMessageCache(instance.id, sessionId) diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index bb747f1a0..db209404f 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -48,7 +48,10 @@ class ServerEvents { try { const connection = await connectWorkspaceEvents({ - onBatch: (events) => this.dispatchBatch(events), + onBatch: (events) => { + if (generation !== this.connectGeneration) return + this.dispatchBatch(events) + }, onError: () => { if (generation !== this.connectGeneration) { return @@ -70,6 +73,7 @@ class ServerEvents { this.openHandlers.forEach((handler) => handler()) }, onPing: (payload) => { + if (generation !== this.connectGeneration) return const identity = getClientIdentity() const pongPayload = { ...identity, pingTs: payload.ts } @@ -179,6 +183,7 @@ class ServerEvents { restart(reason = "manual restart"): void { this.retryDelay = RETRY_BASE_DELAY this.clearReconnectTimer() + this.emitTransportStatus("disconnected") if (this.connection) { this.connection.disconnect() diff --git a/packages/ui/src/lib/session-memory-budget.ts b/packages/ui/src/lib/session-memory-budget.ts index 61709dca9..6030d9091 100644 --- a/packages/ui/src/lib/session-memory-budget.ts +++ b/packages/ui/src/lib/session-memory-budget.ts @@ -7,6 +7,32 @@ export interface SessionMemoryEntry { protected: boolean } +const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get +const arrayBufferResizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")?.get +const sharedBufferPrototype = typeof SharedArrayBuffer === "undefined" ? undefined : SharedArrayBuffer.prototype +const sharedBufferByteLength = sharedBufferPrototype && Object.getOwnPropertyDescriptor(sharedBufferPrototype, "byteLength")?.get +const sharedBufferGrowable = sharedBufferPrototype && Object.getOwnPropertyDescriptor(sharedBufferPrototype, "growable")?.get + +function readBufferSize(value: object): { byteLength: number; growable: boolean } | undefined { + try { + if (arrayBufferByteLength) { + const byteLength = arrayBufferByteLength.call(value) as number + return { byteLength, growable: Boolean(arrayBufferResizable?.call(value)) } + } + } catch { + // Not an ArrayBuffer. + } + try { + if (sharedBufferByteLength) { + const byteLength = sharedBufferByteLength.call(value) as number + return { byteLength, growable: Boolean(sharedBufferGrowable?.call(value)) } + } + } catch { + // Not a SharedArrayBuffer. + } + return undefined +} + function measureRetainedBytes(value: unknown, limit: number): number { const seen = new WeakSet() const pending: unknown[] = [value] @@ -17,8 +43,14 @@ function measureRetainedBytes(value: unknown, limit: number): number { else if (typeof current === "number" || typeof current === "bigint") total += 8 else if (typeof current === "boolean") total += 4 else if (current && typeof current === "object") { + const buffer = readBufferSize(current) + if (buffer) { + total += buffer.growable ? limit + 1 : buffer.byteLength + continue + } if (ArrayBuffer.isView(current)) { - total += current.byteLength + const backing = readBufferSize(current.buffer) + total += backing?.growable ? limit + 1 : backing?.byteLength ?? current.byteLength continue } if (seen.has(current)) continue diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts index a62c79c6f..83e5b8596 100644 --- a/packages/ui/src/lib/session-message-cache.test.ts +++ b/packages/ui/src/lib/session-message-cache.test.ts @@ -1,6 +1,13 @@ import assert from "node:assert/strict" import test from "node:test" -import { createSessionMessageCacheKey, prepareSessionMessageCache, selectSessionMessageCacheEvictions } from "./session-message-cache.ts" +import { + createSessionMessageCacheKey, + clearSessionMessageCache, + isSessionMessageCacheUnsafe, + markSessionMessageCacheUnsafe, + prepareSessionMessageCache, + selectSessionMessageCacheEvictions, +} from "./session-message-cache.ts" test("session message cache keys normalize path separators", () => { assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") @@ -32,3 +39,46 @@ test("session message cache stores an authoritative empty manifest", () => { assert.equal(prepared?.manifest.complete, true) assert.equal(prepared?.manifest.totalCount, 0) }) + +test("session message cache persists an unsafe marker until a successful clear", async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const values = new Map() + const localStorage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + } + try { + Object.defineProperty(globalThis, "window", { configurable: true, value: { localStorage } }) + markSessionMessageCacheUnsafe(true) + assert.equal(isSessionMessageCacheUnsafe(), true) + await clearSessionMessageCache() + assert.equal(isSessionMessageCacheUnsafe(), false) + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage: { ...localStorage, setItem: () => { throw new Error("denied") } } }, + }) + assert.doesNotThrow(() => markSessionMessageCacheUnsafe(true)) + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as any).window + } +}) + +test("session message cache preserves the source range of a pre-truncated tail", () => { + const messages = ["six", "seven", "eight"].map((id) => ({ info: { id }, parts: [] })) + const prepared = prepareSessionMessageCache("session", messages, "snapshot", 10_000, 1, { + startIndex: 5, + totalCount: 8, + }) + assert.equal(prepared?.manifest.startIndex, 5) + assert.equal(prepared?.manifest.totalCount, 8) + assert.equal(prepared?.manifest.complete, false) +}) + +test("session message cache preparation enforces the manifest message limit", () => { + const messages = Array.from({ length: 20_001 }, (_, index) => ({ info: { id: String(index) }, parts: [] })) + const prepared = prepareSessionMessageCache("session", messages, "snapshot", 16 * 1024 * 1024, 1) + assert.equal(prepared?.manifest.messageIds.length, 20_000) + assert.equal(prepared?.manifest.startIndex, 1) +}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts index c58ce1c74..2ddef0dae 100644 --- a/packages/ui/src/lib/session-message-cache.ts +++ b/packages/ui/src/lib/session-message-cache.ts @@ -1,5 +1,6 @@ const DATABASE_NAME = "codenomad-session-messages" const DATABASE_VERSION = 2 +const UNSAFE_CACHE_STORAGE_KEY = "codenomad-session-message-cache-unsafe-v1" const MANIFEST_STORE = "session-manifests" const MESSAGE_STORE = "session-messages" const SESSION_INDEX = "by-session" @@ -8,6 +9,7 @@ export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 +const MAX_SESSION_MESSAGE_CACHE_MESSAGES = 20_000 export interface SessionMessageCacheManifest { key: string @@ -36,6 +38,7 @@ export interface SessionMessageCacheCursor { startIndex: number totalCount: number complete: boolean + remainingByteSize: number } export interface SessionMessageCachePage { @@ -57,6 +60,36 @@ let cacheEnabled = false const resetListeners = new Set<() => void>() let mutationQueue: Promise = Promise.resolve() +function cacheStorage(): Storage | undefined { + try { + const storage = (globalThis as any).window?.localStorage ?? globalThis.localStorage + return storage && typeof storage.getItem === "function" && typeof storage.setItem === "function" && typeof storage.removeItem === "function" + ? storage + : undefined + } catch { + return undefined + } +} + +export function isSessionMessageCacheUnsafe(): boolean { + try { + return cacheStorage()?.getItem(UNSAFE_CACHE_STORAGE_KEY) === "1" + } catch { + return false + } +} + +export function markSessionMessageCacheUnsafe(unsafe: boolean): void { + try { + const storage = cacheStorage() + if (!storage) return + if (unsafe) storage.setItem(UNSAFE_CACHE_STORAGE_KEY, "1") + else storage.removeItem(UNSAFE_CACHE_STORAGE_KEY) + } catch { + // The native restore preference remains the durable fallback. + } +} + export function isSessionMessageCacheEnabled(): boolean { return cacheEnabled } @@ -156,11 +189,12 @@ function isValidManifest(value: unknown, expectedKey: string): value is SessionM return manifest.key === expectedKey && typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && + manifest.messageIds.length <= MAX_SESSION_MESSAGE_CACHE_MESSAGES && new Set(manifest.messageIds).size === manifest.messageIds.length && Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && manifest.complete === (manifest.startIndex === 0) && - Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && + Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && manifest.byteSize <= MAX_SESSION_MESSAGE_CACHE_BYTES && Number.isFinite(manifest.savedAt) } @@ -215,13 +249,16 @@ export function prepareSessionMessageCache( snapshotId = createSnapshotId(), byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, savedAt = Date.now(), + source = { startIndex: 0, totalCount: messages.length }, ): PreparedSessionMessageCache | null { + if (source.startIndex < 0 || source.totalCount !== source.startIndex + messages.length) return null let byteSize = 0 let startIndex = messages.length const records: SessionMessageCacheRecord[] = [] const seen = new Set() try { for (let index = messages.length - 1; index >= 0; index -= 1) { + if (records.length >= MAX_SESSION_MESSAGE_CACHE_MESSAGES) break const messageId = messageIdOf(messages[index]) if (!messageId || seen.has(messageId)) return null seen.add(messageId) @@ -236,7 +273,7 @@ export function prepareSessionMessageCache( sessionKey: key, snapshotId, messageId, - ordinal: index, + ordinal: source.startIndex + index, payload, }) } @@ -250,9 +287,9 @@ export function prepareSessionMessageCache( key, snapshotId, messageIds: records.map((record) => record.messageId), - startIndex, - totalCount: messages.length, - complete: startIndex === 0, + startIndex: source.startIndex + startIndex, + totalCount: source.totalCount, + complete: source.startIndex + startIndex === 0, byteSize, savedAt, }, @@ -284,17 +321,14 @@ function enqueueMutation(operation: () => Promise): Promise { return result } -async function readSessionRecordKeys(database: IDBDatabase, sessionKeys: readonly string[]): Promise> { +async function readSessionRecordKeys(transaction: IDBTransaction, sessionKeys: readonly string[]): Promise> { if (sessionKeys.length === 0) return new Map() - const transaction = database.transaction(MESSAGE_STORE, "readonly") - const completion = transactionDone(transaction) const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) const requests = sessionKeys.map(async (sessionKey) => [ sessionKey, await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), ] as const) const entries = await Promise.all(requests) - await completion return new Map(entries) } @@ -324,6 +358,7 @@ export async function openSessionMessageCache( startIndex: manifest.startIndex, totalCount: manifest.totalCount, complete: manifest.complete, + remainingByteSize: manifest.byteSize, } } @@ -353,13 +388,18 @@ export async function readSessionMessageCachePage( } try { + const pageBytes = records.reduce((total, record) => total + record!.payload.length * 2, 0) + if (pageBytes > cursor.remainingByteSize) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } const messages = records.map((record) => JSON.parse(record!.payload)) const sessionId = sessionIdFromKey(cursor.key) if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) return null } - const nextCursor = { ...cursor, beforeIndex: start } + const nextCursor = { ...cursor, beforeIndex: start, remainingByteSize: cursor.remainingByteSize - pageBytes } return { page: { messages, @@ -376,10 +416,15 @@ export async function readSessionMessageCachePage( } } -export async function writeSessionMessageCache(workspace: string, sessionId: string, messages: unknown[]): Promise { +export async function writeSessionMessageCache( + workspace: string, + sessionId: string, + messages: unknown[], + source?: { startIndex: number; totalCount: number }, +): Promise { const generation = clearGeneration const key = createSessionMessageCacheKey(workspace, sessionId) - const prepared = prepareSessionMessageCache(key, messages) + const prepared = prepareSessionMessageCache(key, messages, undefined, undefined, undefined, source) if (!prepared) { await deleteSessionMessageCache(workspace, sessionId) return false @@ -387,10 +432,11 @@ export async function writeSessionMessageCache(workspace: string, sessionId: str return enqueueMutation(async () => { const database = await openDatabase() if (!database || generation !== clearGeneration) return false - const manifestRead = database.transaction(MANIFEST_STORE, "readonly") - const manifestReadDone = transactionDone(manifestRead) - const existing = await requestResult(manifestRead.objectStore(MANIFEST_STORE).getAll()) as unknown[] - await manifestReadDone + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const manifestStore = transaction.objectStore(MANIFEST_STORE) + const messageStore = transaction.objectStore(MESSAGE_STORE) + const existing = await requestResult(manifestStore.getAll()) as unknown[] const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { const manifestKey = (manifest as { key?: unknown })?.key return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) @@ -402,13 +448,7 @@ export async function writeSessionMessageCache(workspace: string, sessionId: str prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] const evictions = selectSessionMessageCacheEvictions(projected) - const recordKeys = await readSessionRecordKeys(database, [key, ...evictions, ...invalidKeys]) - if (generation !== clearGeneration) return false - - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const manifestStore = transaction.objectStore(MANIFEST_STORE) - const messageStore = transaction.objectStore(MESSAGE_STORE) + const recordKeys = await readSessionRecordKeys(transaction, [key, ...evictions, ...invalidKeys]) for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) for (const record of prepared.records) messageStore.put(record) manifestStore.put(prepared.manifest) @@ -434,10 +474,10 @@ async function deleteSessionMessageCacheKey(key: string): Promise { await enqueueMutation(async () => { const database = await openDatabase() if (!database) return - const recordKeys = await readSessionRecordKeys(database, [key]) const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") const completion = transactionDone(transaction) const messageStore = transaction.objectStore(MESSAGE_STORE) + const recordKeys = await readSessionRecordKeys(transaction, [key]) for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) transaction.objectStore(MANIFEST_STORE).delete(key) await completion @@ -449,22 +489,20 @@ async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknow await enqueueMutation(async () => { const database = await openDatabase() if (!database) return - const manifestRead = database.transaction(MANIFEST_STORE, "readonly") - const manifestReadDone = transactionDone(manifestRead) - const current = await requestResult(manifestRead.objectStore(MANIFEST_STORE).get(key)) as unknown - await manifestReadDone + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const manifestStore = transaction.objectStore(MANIFEST_STORE) + const messageStore = transaction.objectStore(MESSAGE_STORE) + const current = await requestResult(manifestStore.get(key)) as unknown if (!current) return if (typeof observedSnapshotId === "string") { if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return } else if (isValidManifest(current, key)) { return } - const recordKeys = await readSessionRecordKeys(database, [key]) - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const messageStore = transaction.objectStore(MESSAGE_STORE) + const recordKeys = await readSessionRecordKeys(transaction, [key]) for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - transaction.objectStore(MANIFEST_STORE).delete(key) + manifestStore.delete(key) await completion }) } @@ -480,4 +518,5 @@ export async function clearSessionMessageCache(): Promise { transaction.objectStore(MESSAGE_STORE).clear() await completion }) + markSessionMessageCacheUnsafe(false) } diff --git a/packages/ui/src/lib/session-search-matches.ts b/packages/ui/src/lib/session-search-matches.ts new file mode 100644 index 000000000..a408d0395 --- /dev/null +++ b/packages/ui/src/lib/session-search-matches.ts @@ -0,0 +1,30 @@ +export interface TextSearchOccurrence { + start: number + end: number + occurrence: number + preview: string +} + +const PREVIEW_RADIUS = 56 + +export function findTextSearchOccurrences(text: string, query: string, limit: number, normalizedQuery?: string): TextSearchOccurrence[] { + const haystack = text.toLocaleLowerCase() + const needle = normalizedQuery ?? query.toLocaleLowerCase() + const matches: TextSearchOccurrence[] = [] + let from = 0 + while (from < haystack.length && matches.length < limit) { + const start = haystack.indexOf(needle, from) + if (start === -1) break + const end = start + query.length + const previewStart = Math.max(0, start - PREVIEW_RADIUS) + const previewEnd = Math.min(text.length, end + PREVIEW_RADIUS) + matches.push({ + start, + end, + occurrence: matches.length, + preview: `${previewStart > 0 ? "..." : ""}${text.slice(previewStart, previewEnd).replace(/\s+/g, " ").trim()}${previewEnd < text.length ? "..." : ""}`, + }) + from = end > start ? end : start + 1 + } + return matches +} diff --git a/packages/ui/src/lib/session-search.test.ts b/packages/ui/src/lib/session-search.test.ts new file mode 100644 index 000000000..5c23b4de9 --- /dev/null +++ b/packages/ui/src/lib/session-search.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { findTextSearchOccurrences } from "./session-search-matches.ts" + +test("session search bounds matches from repetitive output", () => { + assert.equal(findTextSearchOccurrences("match ".repeat(2_000), "match", 1_000).length, 1_000) +}) + +test("text occurrence search reuses a bounded normalized query", () => { + assert.equal(findTextSearchOccurrences("Needle", "needle", 1, "needle")[0]?.start, 0) +}) diff --git a/packages/ui/src/lib/session-search.ts b/packages/ui/src/lib/session-search.ts index cd894780f..dd7e5f61e 100644 --- a/packages/ui/src/lib/session-search.ts +++ b/packages/ui/src/lib/session-search.ts @@ -4,6 +4,7 @@ import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { MessageRecord, MessageRole } from "../stores/message-v2/types" import { resolveToolRenderer } from "../components/tool-call/renderers" import { getDefaultToolSearchText } from "../components/tool-call/search-text" +import { findTextSearchOccurrences } from "./session-search-matches" export interface SessionSearchMatch { id: string @@ -30,36 +31,50 @@ export interface BuildSessionSearchMatchesOptions { includeThinking: boolean } -const PREVIEW_RADIUS = 56 - -function normalizeSearchValue(value: string): string { - return value.toLocaleLowerCase() -} - -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") +const MAX_SEARCH_MESSAGES = 10_000 +const MAX_SEARCH_PARTS = 20_000 +const MAX_SEARCH_PART_CHARACTERS = 1_000_000 +const MAX_SEARCH_SEGMENTS = 1_000 +const MAX_SEARCH_TOTAL_CHARACTERS = 5_000_000 +const MAX_SEARCH_QUERY_CHARACTERS = 1_000 + +function segmentToText(segment: unknown, budget = { characters: MAX_SEARCH_PART_CHARACTERS, segments: MAX_SEARCH_SEGMENTS }): string { + if (budget.characters <= 0 || budget.segments <= 0) return "" + budget.segments -= 1 + if (typeof segment === "string") { + const text = segment.slice(0, budget.characters) + budget.characters -= text.length + return text + } + if (Array.isArray(segment)) { + const values: string[] = [] + for (const entry of segment) { + const text = segmentToText(entry, budget) + if (text) values.push(text) + if (budget.characters <= 0 || budget.segments <= 0) break + } + return values.join("\n") + } if (!segment || typeof segment !== "object") return "" const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] } const parts: string[] = [] - if (typeof candidate.text === "string") parts.push(candidate.text) - if (typeof candidate.value === "string") parts.push(candidate.value) + if (typeof candidate.text === "string") parts.push(segmentToText(candidate.text, budget)) + if (typeof candidate.value === "string") parts.push(segmentToText(candidate.value, budget)) if (Array.isArray(candidate.content)) { - parts.push(candidate.content.map((entry) => segmentToText(entry)).filter(Boolean).join("\n")) + parts.push(segmentToText(candidate.content, budget)) } return parts.filter(Boolean).join("\n") } -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") - : "" +function extractReasoningText(part: ClientPart, characterLimit: number): string { + const budget = { characters: characterLimit, segments: MAX_SEARCH_SEGMENTS } + const text = segmentToText((part as any).text, budget) + const content = segmentToText((part as any).content, budget) return [text, content].filter(Boolean).join("\n") } -function extractGenericPartText(part: ClientPart): string { +function extractGenericPartText(part: ClientPart, characterLimit: number): string { const candidate = part as Record const values = [ candidate.text, @@ -70,7 +85,8 @@ function extractGenericPartText(part: ClientPart): string { candidate.filename, candidate.message, ] - return values.map((value) => segmentToText(value)).filter(Boolean).join("\n") + const budget = { characters: characterLimit, segments: MAX_SEARCH_SEGMENTS } + return values.map((value) => segmentToText(value, budget)).filter(Boolean).join("\n") } function extractToolText(part: Extract): string { @@ -81,14 +97,15 @@ function extractToolText(part: Extract): string { return values.filter((value) => value.trim().length > 0).join("\n") } -function extractMessageInfoText(info: MessageInfo | undefined): string { +function extractMessageInfoText(info: MessageInfo | undefined, characterLimit: number): string { if (!info || info.role !== "assistant" || !info.error) return "" const error = info.error as { data?: { message?: unknown }; message?: unknown; name?: unknown } const values = [error.data?.message, error.message, error.name] - return values.filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n") + const budget = { characters: characterLimit, segments: MAX_SEARCH_SEGMENTS } + return values.map((value) => segmentToText(value, budget)).filter(Boolean).join("\n") } -function extractSearchablePartText(part: ClientPart, includeThinking: boolean): SearchablePartText | null { +function extractSearchablePartText(part: ClientPart, includeThinking: boolean, characterLimit: number): SearchablePartText | null { if (!part || typeof part !== "object") return null if (isHiddenSyntheticTextPart(part)) return null @@ -96,19 +113,22 @@ function extractSearchablePartText(part: ClientPart, includeThinking: boolean): const partType = typeof (part as any).type === "string" ? (part as any).type : undefined if (part.type === "text") { - const text = typeof (part as any).text === "string" ? (part as any).text : segmentToText((part as any).text) + const text = typeof (part as any).text === "string" + ? (part as any).text.slice(0, characterLimit) + : segmentToText((part as any).text, { characters: characterLimit, segments: MAX_SEARCH_SEGMENTS }) return text.trim().length > 0 ? { partId, partType, text } : null } if (part.type === "reasoning") { if (!includeThinking) return null - const text = extractReasoningText(part) + const text = extractReasoningText(part, characterLimit) return text.trim().length > 0 ? { partId, partType, text } : null } if (part.type === "file") { const filename = (part as any).filename - return typeof filename === "string" && filename.trim().length > 0 ? { partId, partType, text: filename } : null + const text = typeof filename === "string" ? filename.slice(0, characterLimit) : "" + return text.trim().length > 0 ? { partId, partType, text } : null } if (part.type === "tool") { @@ -121,71 +141,76 @@ function extractSearchablePartText(part: ClientPart, includeThinking: boolean): return { partId, partType, text } } - const text = extractGenericPartText(part) + const text = extractGenericPartText(part, characterLimit) return text.trim().length > 0 ? { partId, partType, text } : null } -function buildPreview(text: string, start: number, end: number): string { - const from = Math.max(0, start - PREVIEW_RADIUS) - const to = Math.min(text.length, end + PREVIEW_RADIUS) - const prefix = from > 0 ? "..." : "" - const suffix = to < text.length ? "..." : "" - return `${prefix}${text.slice(from, to).replace(/\s+/g, " ").trim()}${suffix}` -} - -function collectRecordSearchableText(store: InstanceMessageStore, record: MessageRecord, includeThinking: boolean): SearchablePartText[] { +function collectRecordSearchableText( + store: InstanceMessageStore, + record: MessageRecord, + includeThinking: boolean, + partBudget: { remaining: number }, + characterBudget: { remaining: number }, +): SearchablePartText[] { const results: SearchablePartText[] = [] - for (const partId of record.partIds) { + const partCount = Math.min(record.partIds.length, partBudget.remaining) + partBudget.remaining -= partCount + for (let index = 0; index < partCount; index += 1) { + if (characterBudget.remaining <= 0) break + const partId = record.partIds[index] const part = record.parts[partId]?.data if (!part) continue - const text = extractSearchablePartText(part, includeThinking) - if (text) results.push(text) + const text = extractSearchablePartText(part, includeThinking, Math.min(MAX_SEARCH_PART_CHARACTERS, characterBudget.remaining)) + if (text) { + text.text = text.text.slice(0, characterBudget.remaining) + characterBudget.remaining -= text.text.length + results.push(text) + } } - const infoText = extractMessageInfoText(store.getMessageInfo(record.id)) + const infoText = extractMessageInfoText(store.getMessageInfo(record.id), characterBudget.remaining) if (infoText.trim().length > 0) { results.push({ partType: "error", text: infoText }) + characterBudget.remaining -= infoText.length } return results } export function buildSessionSearchMatches(options: BuildSessionSearchMatchesOptions): SessionSearchMatch[] { - const query = options.query.trim() + const query = options.query.slice(0, MAX_SEARCH_QUERY_CHARACTERS).trim() if (!query) return [] - const needle = normalizeSearchValue(query) const matches: SessionSearchMatch[] = [] + const maxMatches = 1_000 const messageIds = options.store.getSessionMessageIds(options.sessionId) + const partBudget = { remaining: MAX_SEARCH_PARTS } + const characterBudget = { remaining: MAX_SEARCH_TOTAL_CHARACTERS } + const normalizedQuery = query.toLocaleLowerCase() + const firstMessageIndex = Math.max(0, messageIds.length - MAX_SEARCH_MESSAGES) - for (const messageId of messageIds) { + for (let messageIndex = firstMessageIndex; messageIndex < messageIds.length; messageIndex += 1) { + const messageId = messageIds[messageIndex] const record = options.store.getMessage(messageId) if (!record) continue - const searchableParts = collectRecordSearchableText(options.store, record, options.includeThinking) + const searchableParts = collectRecordSearchableText(options.store, record, options.includeThinking, partBudget, characterBudget) for (const searchable of searchableParts) { - const haystack = normalizeSearchValue(searchable.text) - let from = 0 - let occurrence = 0 - while (from < haystack.length) { - const index = haystack.indexOf(needle, from) - if (index === -1) break - const end = index + query.length + const searchableText = searchable.text.slice(0, MAX_SEARCH_PART_CHARACTERS) + const occurrences = findTextSearchOccurrences(searchableText, query, maxMatches - matches.length, normalizedQuery) + for (const occurrence of occurrences) { matches.push({ - id: `${messageId}:${searchable.partId ?? searchable.partType ?? "info"}:${index}`, + id: `${messageId}:${searchable.partId ?? searchable.partType ?? "info"}:${occurrence.start}`, messageId, partId: searchable.partId, partType: searchable.partType, role: record.role, - start: index, - end, - occurrence, - preview: buildPreview(searchable.text, index, end), + ...occurrence, }) - occurrence += 1 - from = end > index ? end : index + 1 + if (matches.length >= maxMatches) return matches } } + if (partBudget.remaining === 0 || characterBudget.remaining === 0) break } return matches diff --git a/packages/ui/src/lib/sse-manager-generation.test.ts b/packages/ui/src/lib/sse-manager-generation.test.ts new file mode 100644 index 000000000..7224ce51c --- /dev/null +++ b/packages/ui/src/lib/sse-manager-generation.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { acceptInstanceStreamId } from "./sse-manager.ts" + +test("instance stream ids reject events from a replaced runtime", () => { + const streams = new Map() + assert.equal(acceptInstanceStreamId(streams, "instance", "old"), true) + assert.equal(acceptInstanceStreamId(streams, "instance", "new"), false) + assert.equal(acceptInstanceStreamId(streams, "instance", "new", true), true) + assert.equal(acceptInstanceStreamId(streams, "instance", "old"), false) +}) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 53be8035a..a3bcbbbb5 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -125,13 +125,30 @@ type SSEEvent = const [connectionStatus, setConnectionStatus] = createSignal>(new Map()) const [transportStatus, setTransportStatus] = createSignal("connecting") +export function acceptInstanceStreamId( + streams: Map, + instanceId: string, + streamId: string | undefined, + replace = false, +): boolean { + if (streamId === undefined) return true + const current = streams.get(instanceId) + if (current === undefined || replace) streams.set(instanceId, streamId) + return current === undefined || replace || current === streamId +} + class SSEManager { + private readonly streamIds = new Map() + private readonly acceptedEventHandlers = new Set<(instanceId: string, event: SSEEvent | InstanceStreamEvent) => void>() + constructor() { log.info("sseManager initialized: listening for SSE disconnect and reconnect") serverEvents.on("instance.eventStatus", (event) => { const payload = event as InstanceStatusPayload + if (!acceptInstanceStreamId(this.streamIds, payload.instanceId, payload.streamId, payload.status === "connecting")) return this.updateConnectionStatus(payload.instanceId, payload.status) + if (payload.status === "connected") this.onConnectionRestored?.(payload.instanceId) if (payload.status === "disconnected") { if (payload.reason === "workspace stopped") { return @@ -143,12 +160,14 @@ class SSEManager { serverEvents.on("instance.event", (event) => { const payload = event as InstanceEventPayload - this.updateConnectionStatus(payload.instanceId, "connected") + if (!acceptInstanceStreamId(this.streamIds, payload.instanceId, payload.streamId)) return + if (payload.streamId) this.updateConnectionStatus(payload.instanceId, "connected") this.handleEvent(payload.instanceId, payload.event as SSEEvent) }) serverEvents.onTransportStatus((status) => { log.info("SSE transport status changed", { status }) + if (status !== "connected") this.streamIds.clear() setTransportStatus(status) }) } @@ -167,7 +186,7 @@ class SSEManager { return } if (this.shouldHandleEvent && !this.shouldHandleEvent(instanceId)) return - + for (const handler of this.acceptedEventHandlers) handler(instanceId, event) log.info("Received event", { type: event.type, event }) switch (event.type) { @@ -294,8 +313,13 @@ class SSEManager { onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void onWorktreeReady?: (instanceId: string, event: WorktreeReadyEvent) => void | Promise onConnectionLost?: (instanceId: string, reason: string) => void | Promise + onConnectionRestored?: (instanceId: string) => void shouldHandleEvent?: (instanceId: string) => boolean + onAcceptedEvent(handler: (instanceId: string, event: SSEEvent | InstanceStreamEvent) => void): () => void { + this.acceptedEventHandlers.add(handler) + return () => this.acceptedEventHandlers.delete(handler) + } getStatus(instanceId: string): ConnectionStatus | null { return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus()) } diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 882a92348..96b80b225 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -115,8 +115,67 @@ describe("failed destructive transactions", () => { it(`${kind}: rolls back and persists mutations buffered during delayed failure`, () => destructiveContract(kind, false)) it(`${kind}: preserves retry dirt from an in-flight failed save`, () => destructiveContract(kind, true)) } + for (const kind of ["clear", "disable"] as const) { + it(`${kind}: retries only cache clearing after native acceptance`, async () => { + const originalIndexedDb = Object.getOwnPropertyDescriptor(globalThis, "indexedDB") + let nativeCalls = 0 + try { + Object.defineProperty(globalThis, "indexedDB", { + configurable: true, + value: { + open: () => { + const request: any = { error: new Error("cache clear failed") } + queueMicrotask(() => request.onerror?.()) + return request + }, + }, + }) + const state = await boot({ + loadClientState: async () => loadResult(), + clearClientState: async () => { nativeCalls += 1; return true }, + setClientStateRestoreEnabled: async (_token, enabled) => { if (!enabled) nativeCalls += 1; return true }, + }) + await assert.rejects(transact(state, kind), /cache clear failed/) + Object.defineProperty(globalThis, "indexedDB", { configurable: true, value: undefined }) + await transact(state, kind) + assert.equal(nativeCalls, 1) + } finally { + if (originalIndexedDb) Object.defineProperty(globalThis, "indexedDB", originalIndexedDb) + else delete (globalThis as any).indexedDB + } + }) + } }) describe("future envelopes and clear races", () => { + it("keeps primary ownership when startup cache clearing must retry", async () => { + const originalIndexedDb = Object.getOwnPropertyDescriptor(globalThis, "indexedDB") + let restoreUpdates = 0 + try { + Object.defineProperty(globalThis, "indexedDB", { + configurable: true, + value: { + open: () => { + const request: any = { error: new Error("open failed") } + queueMicrotask(() => request.onerror?.()) + return request + }, + }, + }) + const state = await boot({ + loadClientState: async () => ({ isPrimary: true, restoreEnabled: false, snapshot: null }), + setClientStateRestoreEnabled: async (_token, enabled) => { if (enabled) restoreUpdates += 1; return true }, + }) + assert.equal(state.clientStateIsPrimary(), true) + Object.defineProperty(globalThis, "indexedDB", { configurable: true, value: undefined }) + await state.setRestorePreviousStateEnabled(true) + assert.equal(state.restorePreviousStateEnabled(), true) + assert.equal(restoreUpdates, 1) + } finally { + if (originalIndexedDb) Object.defineProperty(globalThis, "indexedDB", originalIndexedDb) + else delete (globalThis as any).indexedDB + } + }) + it("serializes overlapping clear and disable transactions without stranding writes", async () => { const clear = deferred(), disable = deferred() const clearStarted = deferred(), disableStarted = deferred() @@ -176,6 +235,21 @@ describe("future envelopes and clear races", () => { assert.equal(state.clientStateIsPrimary(), true) assert.equal(clears, 1) }) + it("keeps a known-unsafe cache disabled when native disable is rejected", async () => { + const cache = await import("../lib/session-message-cache.ts") + const state = await boot({ + loadClientState: async () => loadResult(), + setClientStateRestoreEnabled: async () => false, + }) + cache.markSessionMessageCacheUnsafe(true) + cache.setSessionMessageCacheEnabled(false) + try { + await assert.rejects(state.setRestorePreviousStateEnabled(false), /update was rejected/) + assert.equal(cache.isSessionMessageCacheEnabled(), false) + } finally { + cache.markSessionMessageCacheUnsafe(false) + } + }) it("blocks captures before an in-flight clear reaches the native host", async () => { const clear = deferred(); let saves = 0 const state = await boot({ diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index fb29dca9a..3ccdc690c 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,7 +2,11 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" -import { clearSessionMessageCache, setSessionMessageCacheEnabled } from "../lib/session-message-cache" +import { + clearSessionMessageCache, + isSessionMessageCacheUnsafe, + setSessionMessageCacheEnabled, +} from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -25,7 +29,13 @@ let saveTimer: ReturnType | null = null let writeQueue: Promise = Promise.resolve() let destructiveQueue: Promise = Promise.resolve() let lastSaveError: unknown +let cacheClearPending = false +let pendingCacheClearOwner: "clear" | "disable" | null = null const transactionLayoutWrites = new Set() +function loseClientStateOwnership(): void { + setClientStateIsPrimary(false) + setSessionMessageCacheEnabled(false) +} function useLocalStorage(fallback: T, operation: (storage: Storage) => T): T { try { return operation(window.localStorage) @@ -94,7 +104,7 @@ function enqueuePendingSave(): Promise { const saveAttempt = writeQueue.then(async () => { try { if (!await saveNativeClientState(normalizedSnapshot)) { - setClientStateIsPrimary(false) + loseClientStateOwnership() throw new Error("Native client state save was rejected") } lastSaveError = undefined @@ -133,7 +143,7 @@ async function executeDestructiveTransaction(operation: () => Promise, retryDirty ||= dirty dirty = false if (!await operation()) { - if (loseOwnershipOnRejection) setClientStateIsPrimary(false) + if (loseOwnershipOnRejection) loseClientStateOwnership() throw new Error(rejectedMessage) } useLocalStorage(undefined, (storage) => { @@ -233,17 +243,32 @@ export async function flushClientState(): Promise { export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") + if (cacheClearPending && pendingCacheClearOwner === "clear") { + await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null + return + } await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) setSessionMessageCacheEnabled(false) + cacheClearPending = true + pendingCacheClearOwner = "clear" await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null }) } export async function setRestorePreviousStateEnabled(enabled: boolean): Promise { await runDestructiveTransition(async () => { - if (enabled === restorePreviousStateEnabled() && (!enabled || writeBlock === false)) return + if (enabled === restorePreviousStateEnabled() && (!enabled || writeBlock === false) && !cacheClearPending && !isSessionMessageCacheUnsafe()) return if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") if (enabled) { + if (cacheClearPending || isSessionMessageCacheUnsafe()) { + await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null + } if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) @@ -252,14 +277,24 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< } setRestorePreviousStateEnabledSignal(false) setSessionMessageCacheEnabled(false) + if (cacheClearPending && pendingCacheClearOwner === "disable") { + await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null + return + } try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(true) + setSessionMessageCacheEnabled(!isSessionMessageCacheUnsafe()) throw error } + cacheClearPending = true + pendingCacheClearOwner = "disable" await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null }) } @@ -276,7 +311,33 @@ export function initializeClientState(): Promise { writeBlock = false transactionLayoutWrites.clear() initialized = true - if (!loaded.isPrimary || !loaded.restoreEnabled) return + if (!loaded.isPrimary || !loaded.restoreEnabled) { + setSessionMessageCacheEnabled(false) + if (loaded.isPrimary && !loaded.restoreEnabled) { + cacheClearPending = true + pendingCacheClearOwner = "disable" + try { + await clearSessionMessageCache() + cacheClearPending = false + pendingCacheClearOwner = null + } catch (error) { + console.warn("[client-state] failed to clear disabled session cache", error) + } + } + return + } + if (isSessionMessageCacheUnsafe()) { + setSessionMessageCacheEnabled(false) + cacheClearPending = true + pendingCacheClearOwner = null + try { + await clearSessionMessageCache() + cacheClearPending = false + } catch (error) { + console.warn("[client-state] failed to clear unsafe session cache", error) + return + } + } writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) @@ -284,8 +345,7 @@ export function initializeClientState(): Promise { if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true - setClientStateIsPrimary(false) - setSessionMessageCacheEnabled(false) + loseClientStateOwnership() resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/commands.test.ts b/packages/ui/src/stores/commands.test.ts new file mode 100644 index 000000000..47751e525 --- /dev/null +++ b/packages/ui/src/stores/commands.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clearCommands, fetchCommands, getCommands } from "./commands.ts" + +test("command hydration cannot commit after runtime replacement", async () => { + let resolve!: (value: unknown) => void + const response = new Promise((done) => { resolve = done }) + let current = true + const hydration = fetchCommands("instance", { command: { list: () => response } } as any, () => current) + current = false + resolve({ data: [{ name: "stale" }] }) + await hydration + assert.deepEqual(getCommands("instance"), []) + clearCommands("instance") +}) diff --git a/packages/ui/src/stores/commands.ts b/packages/ui/src/stores/commands.ts index a9f0ed3c3..849bbe6d4 100644 --- a/packages/ui/src/stores/commands.ts +++ b/packages/ui/src/stores/commands.ts @@ -5,8 +5,9 @@ import { requestData } from "../lib/opencode-api" const [commandMap, setCommandMap] = createSignal>(new Map()) -export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise { +export async function fetchCommands(instanceId: string, client: OpencodeClient, isCurrent: () => boolean = () => true): Promise { const commands = await requestData(client.command.list(), "command.list").catch(() => []) + if (!isCurrent()) return setCommandMap((prev) => { const next = new Map(prev) next.set(instanceId, commands) diff --git a/packages/ui/src/stores/delta-buffer.test.ts b/packages/ui/src/stores/delta-buffer.test.ts index 952cbc8e6..65207f51f 100644 --- a/packages/ui/src/stores/delta-buffer.test.ts +++ b/packages/ui/src/stores/delta-buffer.test.ts @@ -6,9 +6,9 @@ import { clearPendingDeltasForPart, enqueueDelta, flushPendingDeltasForMessage, - holdDelta, resetDeltaBufferForTests, setFlushCallback, + setRecoveryCallback, } from "./delta-buffer.ts" type DeltaBatch = Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }> @@ -101,21 +101,18 @@ describe("delta buffer", () => { ]) }) - it("holds an orphan delta until its HTTP baseline is resident", () => { - holdDelta("instance-1", "message-1", "part-1", "text", "late") - let attempts = 0 - flushPendingDeltasForMessage("instance-1", "message-1", () => { - attempts += 1 - return false - }) - flushPendingDeltasForMessage("instance-1", "message-1", () => { - attempts += 1 - return true - }) - flushPendingDeltasForMessage("instance-1", "message-1", () => { - attempts += 1 - return true - }) - assert.equal(attempts, 2) + it("drops oversized deltas and requests bounded authoritative recovery", async () => { + const recoveries: unknown[] = [] + const flushed: DeltaBatch[] = [] + setRecoveryCallback((pending) => recoveries.push(pending)) + setFlushCallback((batch) => flushed.push(batch)) + + enqueueDelta("instance-1", "message-1", "part-1", "text", "x".repeat(300_000), "session-1") + await delay(75) + + assert.deepEqual(flushed, []) + assert.deepEqual(recoveries, [{ + instanceId: "instance-1", sessionId: "session-1", messageId: "message-1", partId: "part-1", field: "text", + }]) }) }) diff --git a/packages/ui/src/stores/delta-buffer.ts b/packages/ui/src/stores/delta-buffer.ts index d63cb8ccd..0e99517fc 100644 --- a/packages/ui/src/stores/delta-buffer.ts +++ b/packages/ui/src/stores/delta-buffer.ts @@ -8,6 +8,8 @@ */ const DELTA_FLUSH_INTERVAL = 50 +const MAX_PENDING_DELTA_CHARACTERS = 64_000 +const MAX_PENDING_DELTA_ENTRIES = 64 type PendingDelta = { instanceId: string; sessionId?: string; messageId: string; partId: string; field: string; delta: string } const pendingDeltas = new Map() @@ -18,17 +20,19 @@ export function enqueueDelta(instanceId: string, messageId: string, partId: stri const existing = pendingDeltas.get(key) const accumulated = existing ? existing.delta + delta : delta const resolvedSessionId = sessionId ?? existing?.sessionId + if (accumulated.length > MAX_PENDING_DELTA_CHARACTERS || (!existing && pendingDeltas.size >= MAX_PENDING_DELTA_ENTRIES)) { + pendingDeltas.delete(key) + recoveryCallback?.({ instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field }) + return + } pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: accumulated }) if (deltaFlushTimer === null) { deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL) } } -export function holdDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string, sessionId?: string) { - const key = `${instanceId}:${messageId}:${partId}:${field}` - const existing = pendingDeltas.get(key) - const resolvedSessionId = sessionId ?? existing?.sessionId - pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: existing ? existing.delta + delta : delta }) +export function requestDeltaRecovery(pending: Omit): void { + recoveryCallback?.(pending) } export function clearPendingDeltasForSession(instanceId: string, sessionId: string): void { @@ -94,6 +98,10 @@ export function setFlushCallback( flushCallback = callback } +export function setRecoveryCallback(callback: (pending: Omit) => void) { + recoveryCallback = callback +} + export function resetDeltaBufferForTests() { pendingDeltas.clear() if (deltaFlushTimer !== null) { @@ -101,9 +109,11 @@ export function resetDeltaBufferForTests() { deltaFlushTimer = null } flushCallback = null + recoveryCallback = null } let flushCallback: ((batch: PendingDelta[]) => void) | null = null +let recoveryCallback: ((pending: Omit) => void) | null = null function flushDeltas() { deltaFlushTimer = null diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 77d1d50e6..cee02e65d 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -16,6 +16,7 @@ import { fetchSessions, fetchAgents, fetchProviders, + loadMessages, clearInstanceDraftPrompts, clearSessionListRequestState, clearInstanceDeletedSessionAuthority, @@ -36,13 +37,17 @@ import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + activeSessionId, + invalidateSessionMessageLoad, purgeInstanceSessionState, sessions, setSessionPendingPermission, setSessionPendingQuestion, + resetInstanceSessionRequestState, } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" +import { clearPendingDeltasForInstance } from "./delta-buffer" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" import { clearRepliedPermissions, @@ -114,7 +119,8 @@ serverEvents.on("yolo.autoAccepted", (event) => { }) const [instances, setInstances] = createSignal>(new Map()) -sseManager.shouldHandleEvent = (instanceId) => instances().has(instanceId) +let workspaceListInitialized = false +sseManager.shouldHandleEvent = (instanceId) => !workspaceListInitialized || instances().has(instanceId) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) @@ -260,6 +266,10 @@ const connectionResyncs = new TrailingResyncCoordinator( const instance = instances().get(instanceId) if (!instance?.client || instance.status !== "ready") return await fetchSessions(instanceId, { reset: false }) + const residentSessionIds = messageStoreBus.getInstance(instanceId)?.getResidentSessionIds() ?? [] + for (const sessionId of residentSessionIds) invalidateSessionMessageLoad(instanceId, sessionId) + const activeId = activeSessionId().get(instanceId) + if (activeId && residentSessionIds.includes(activeId)) await loadMessages(instanceId, activeId, { force: true }) }, (instanceId, error) => { log.warn("Failed to resync sessions after instance connection", { instanceId, error }) @@ -270,13 +280,12 @@ function resyncConnectedInstance(instanceId: string): void { void connectionResyncs.request(instanceId) } -serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return - if (disconnectedInstance()?.id === event.instanceId) { +sseManager.onConnectionRestored = (instanceId) => { + if (disconnectedInstance()?.id === instanceId) { setDisconnectedInstance(null) } - resyncConnectedInstance(event.instanceId) -}) + resyncConnectedInstance(instanceId) +} function createRestoreCreationRequestId(): string { if (typeof globalThis.crypto?.randomUUID === "function") { @@ -353,7 +362,8 @@ function ensureActiveInstanceSelected(): void { function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string) { const mapped = workspaceDescriptorToInstance(descriptor, projectName) if (instances().has(descriptor.id)) { - updateInstance(descriptor.id, mapped) + const { client: _client, port: _port, proxyPath: _proxyPath, ...metadata } = mapped + updateInstance(descriptor.id, metadata) } else { addInstance(mapped) } @@ -378,8 +388,11 @@ function attachClient(descriptor: WorkspaceDescriptor) { const nextPort = descriptor.port ?? instance.port const nextProxyPath = descriptor.proxyPath + const runtimeChanged = Boolean(instance.client && ( + (descriptor.pid && instance.attachedPid !== descriptor.pid) || instance.proxyPath !== nextProxyPath + )) - if (instance.client && instance.proxyPath === nextProxyPath) { + if (!runtimeChanged && instance.client && instance.proxyPath === nextProxyPath) { if (nextPort && instance.port !== nextPort) { updateInstance(descriptor.id, { port: nextPort }) } @@ -387,6 +400,9 @@ function attachClient(descriptor: WorkspaceDescriptor) { } if (instance.client) { + clearOpenCodeWorkspaceCache(descriptor.id) + clearPendingDeltasForInstance(descriptor.id) + if (runtimeChanged) clearReloadableInstanceState(descriptor.id) sdkManager.destroyClientsForInstance(descriptor.id) } @@ -395,6 +411,7 @@ function attachClient(descriptor: WorkspaceDescriptor) { client, port: nextPort ?? 0, proxyPath: nextProxyPath, + attachedPid: descriptor.pid ?? instance.pid, status: "ready", }) sseManager.seedStatusIfMissing(descriptor.id, "connecting") @@ -471,6 +488,7 @@ function releaseInstanceResources(instanceId: string) { async function syncPendingPermissions(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance?.client) return + const isCurrent = () => isInstanceRuntimeCurrent(instanceId, instance) try { const syncStartedAt = Date.now() @@ -482,16 +500,20 @@ async function syncPendingPermissions(instanceId: string): Promise { log.warn("Failed to list legacy pending permissions", { instanceId, error }) return [] }) + if (!isCurrent()) return for (const permission of legacyRemote) { permissionRegistry.setSource(instanceId, permission.id, "legacy") remote.push({ request: permission, source: "legacy" }) } - for (const location of await getV2RequestLocations(instanceId)) { + const locations = await getV2RequestLocations(instanceId) + if (!isCurrent()) return + for (const location of locations) { const response = await requestData<{ location?: unknown; data: PermissionRequest[] }>( instance.client.v2.permission.request.list({ location }), "v2.permission.request.list", ) + if (!isCurrent()) return log.info("v2.permission.request.list", { instanceId, location, resolvedLocation: response.location }) for (const permission of response.data) { permissionRegistry.setSource(instanceId, permission.id, "v2") @@ -528,6 +550,7 @@ async function syncPendingPermissions(instanceId: string): Promise { async function syncPendingQuestions(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance?.client) return + const isCurrent = () => isInstanceRuntimeCurrent(instanceId, instance) try { const remote: Array<{ request: QuestionRequest; source: QuestionSource }> = [] @@ -538,16 +561,20 @@ async function syncPendingQuestions(instanceId: string): Promise { log.warn("Failed to list legacy pending questions", { instanceId, error }) return [] }) + if (!isCurrent()) return for (const request of legacyRemote) { questionRegistry.setSource(instanceId, request.id, "legacy") remote.push({ request, source: "legacy" }) } - for (const location of await getV2RequestLocations(instanceId)) { + const locations = await getV2RequestLocations(instanceId) + if (!isCurrent()) return + for (const location of locations) { const response = await requestData<{ location?: unknown; data: QuestionRequest[] }>( instance.client.v2.question.request.list({ location }), "v2.question.request.list", ) + if (!isCurrent()) return log.info("v2.question.request.list", { instanceId, location, resolvedLocation: response.location }) for (const request of response.data) { questionRegistry.setSource(instanceId, request.id, "v2") @@ -620,7 +647,7 @@ async function hydrateInstanceData(instanceId: string, options?: { await ensureInstanceConfigLoaded(instanceId) const instance = instances().get(instanceId) if (!instance?.client) return - await fetchCommands(instanceId, instance.client) + await fetchCommands(instanceId, instance.client, () => isInstanceRuntimeCurrent(instanceId, instance)) await syncPendingPermissions(instanceId) await syncPendingQuestions(instanceId) } catch (error) { @@ -668,6 +695,7 @@ async function postInstanceDispose(instanceId: string): Promise { } function clearReloadableInstanceState(instanceId: string): void { + resetInstanceSessionRequestState(instanceId) clearCacheForInstance(instanceId) clearCommands(instanceId) clearInstanceMetadata(instanceId) @@ -691,7 +719,7 @@ async function rehydrateInstance(instanceId: string, options?: { reason?: string clearReloadableInstanceState(instanceId) await hydrateInstanceData(instanceId, { force: true }) - if (instances().get(instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(instanceId, instance)) return })().finally(() => { if (pendingRehydrations.get(instanceId) === promise) pendingRehydrations.delete(instanceId) }) @@ -708,7 +736,7 @@ async function disposeInstance(instanceId: string): Promise { const instance = instances().get(instanceId) const promise = (async () => { const ok = await postInstanceDispose(instanceId) - if (ok && instances().get(instanceId) === instance) { + if (ok && isInstanceRuntimeCurrent(instanceId, instance)) { await rehydrateInstance(instanceId, { reason: "disposed" }) } return ok @@ -758,6 +786,8 @@ const initialWorkspaceLoad = (async function initializeWorkspaces(): Promise<{ e } catch (error) { log.error("Failed to load workspaces", error) return { error } + } finally { + workspaceListInitialized = true } })() let latestWorkspaceLoad = initialWorkspaceLoad @@ -910,7 +940,7 @@ function addInstance(instance: Instance) { .length setInstances((prev) => { const next = new Map(prev) - next.set(instance.id, instance) + next.set(instance.id, { ...instance, runtimeToken: Symbol(instance.id) }) return next }) ensureLogContainer(instance.id) @@ -929,13 +959,26 @@ function updateInstance(id: string, updates: Partial) { const next = new Map(prev) const instance = next.get(id) if (instance) { - next.set(id, { ...instance, ...updates }) + next.set(id, { + ...instance, + ...updates, + runtimeToken: (updates.client !== undefined && updates.client !== instance.client) || + (updates.pid !== undefined && updates.pid !== instance.pid) || + (updates.proxyPath !== undefined && updates.proxyPath !== instance.proxyPath) + ? Symbol(id) + : instance.runtimeToken, + }) } return next }) syncHasInstancesFlag() } +function isInstanceRuntimeCurrent(instanceId: string, captured: Instance | undefined): boolean { + const current = instances().get(instanceId) + return Boolean(current && captured && current.runtimeToken === captured.runtimeToken) +} + function removeInstance(id: string, options: { authoritative?: boolean } = {}) { const removedInstance = instances().get(id) const removedOccurrence = removedInstance @@ -1637,6 +1680,19 @@ function clearQuestionQueue(instanceId: string): void { recomputeActiveInterruption(instanceId) } +function clearSessionInterruptions(instanceId: string, sessionId: string): void { + for (const permission of [...getPermissionQueue(instanceId)]) { + if (getPermissionSessionId(permission) !== sessionId) continue + removePermissionFromQueue(instanceId, permission.id) + removePermissionV2(instanceId, permission.id) + } + for (const question of [...getQuestionQueue(instanceId)]) { + if (getQuestionSessionId(question) !== sessionId) continue + removeQuestionFromQueue(instanceId, question.id) + removeQuestionV2(instanceId, question.id) + } +} + function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId }) } @@ -1662,6 +1718,7 @@ async function sendQuestionReply( if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return await requestData( client.question.reply({ requestID: requestId, @@ -1681,7 +1738,9 @@ async function sendQuestionReply( ) } + if (!isInstanceRuntimeCurrent(instanceId, instance)) return removeQuestionFromQueue(instanceId, requestId) + removeQuestionV2(instanceId, requestId) } catch (error) { log.error("Failed to send question reply", error) throw error @@ -1700,6 +1759,7 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return await requestData( client.question.reject({ requestID: requestId, @@ -1717,7 +1777,9 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request ) } + if (!isInstanceRuntimeCurrent(instanceId, instance)) return removeQuestionFromQueue(instanceId, requestId) + removeQuestionV2(instanceId, requestId) } catch (error) { log.error("Failed to send question reject", error) throw error @@ -1742,6 +1804,7 @@ async function sendPermissionResponse( if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return await requestData( client.permission.reply({ requestID: requestId, @@ -1763,6 +1826,7 @@ async function sendPermissionResponse( ) } + if (!isInstanceRuntimeCurrent(instanceId, instance)) return markPermissionReplied(instanceId, requestId) // Remove from both local queues after successful response; the SSE replied event // is still accepted, but the UI no longer depends on receiving it. @@ -1845,7 +1909,9 @@ export { setActiveInstanceId, addInstance, updateInstance, + isInstanceRuntimeCurrent, removeInstance, + clearSessionInterruptions, createInstance, cancelRestoreCreationRequest, disposeRestoreCreatedInstance, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index eab9b834e..d6ad1eda1 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -125,7 +125,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null status, createdAt, updatedAt: endAt ?? createdAt, - isEphemeral: status === "sending" || status === "streaming", + isEphemeral: info.role === "user" ? false : status === "sending" || status === "streaming", bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 77c04ab10..d73f21085 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -87,6 +87,14 @@ describe("message-v2 authoritative hydration", () => { assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") }) + it("releases a directly removed message and its info version", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) + store.removeMessage("message-1") + assert.equal("message-1" in store.state.messages, false) + assert.equal("message-1" in store.state.messageInfoVersion, false) + }) + it("bumps authority when a revert anchor is not resident", () => { const store = createInstanceMessageStore("instance-1") store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 837a18233..98f5aa4e7 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -264,7 +264,7 @@ export interface InstanceMessageStore { getLastCompactionMessageIndex: (sessionId: string) => number getMessage: (messageId: string) => MessageRecord | undefined getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined - clearSession: (sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) => void + clearSession: (sessionId: string, options?: { preserveScroll?: boolean; preservePromptDisplay?: boolean; notify?: boolean }) => void clearScrollSnapshots: () => void clearInstance: () => void } @@ -882,19 +882,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("sessions", sessionId, "messageIds", (ids = []) => ids.filter((id) => id !== messageId)) }) - setState("messages", (prev) => { - if (!prev[messageId]) return prev - const next = { ...prev } - delete next[messageId] - return next - }) - - setState("messageInfoVersion", (prev) => { - if (!(messageId in prev)) return prev - const next = { ...prev } - delete next[messageId] - return next - }) + setState("messages", messageId, undefined as any) + setState("messageInfoVersion", messageId, undefined as any) messageInfoCache.delete(messageId) @@ -1355,56 +1344,37 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) { + function clearSession(sessionId: string, options?: { preserveScroll?: boolean; preservePromptDisplay?: boolean; notify?: boolean }) { if (!sessionId) return - clearPromptDisplayOverridesForSession(instanceId, sessionId) + if (!options?.preservePromptDisplay) clearPromptDisplayOverridesForSession(instanceId, sessionId) - const messageIds = Object.values(state.messages) - .filter((record) => record.sessionId === sessionId) - .map((record) => record.id) + const messageIds = [...(state.sessions[sessionId]?.messageIds ?? [])] storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length }) clearRecordDisplayCacheForMessages(instanceId, messageIds) batch(() => { - setState("messages", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => delete next[id]) - return next - }) + const permissionQueue = state.permissions.queue.filter((entry) => getPermissionSessionId(entry.permission) !== sessionId) + const questionQueue = state.questions.queue.filter((entry) => getQuestionSessionId(entry.request) !== sessionId) + setState("permissions", "queue", permissionQueue) + setState("permissions", "active", (active) => + active && getPermissionSessionId(active.permission) === sessionId ? permissionQueue[0] ?? null : active) + setState("questions", "queue", questionQueue) + setState("questions", "active", (active) => + active && getQuestionSessionId(active.request) === sessionId ? questionQueue[0] ?? null : active) - setState("messageInfoVersion", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => delete next[id]) - return next - }) + setState("messages", produce((next) => { messageIds.forEach((id) => delete next[id]) })) + + setState("messageInfoVersion", produce((next) => { messageIds.forEach((id) => delete next[id]) })) 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((next) => { messageIds.forEach((id) => delete next[id]) })) - setState("permissions", "byMessage", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("permissions", "byMessage", produce((next) => { messageIds.forEach((id) => delete next[id]) })) - setState("questions", "byMessage", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("questions", "byMessage", produce((next) => { messageIds.forEach((id) => delete next[id]) })) setState("usage", (prev) => { const next = { ...prev } diff --git a/packages/ui/src/stores/opencode-workspaces.ts b/packages/ui/src/stores/opencode-workspaces.ts index b6829583b..54db3979a 100644 --- a/packages/ui/src/stores/opencode-workspaces.ts +++ b/packages/ui/src/stores/opencode-workspaces.ts @@ -54,26 +54,32 @@ async function syncOpenCodeWorkspaces(instanceId: string): Promise { const existing = workspaceSyncs.get(instanceId) if (existing) return existing + let runtimeToken: symbol | undefined + const isCurrent = async () => (await getInstance(instanceId))?.runtimeToken === runtimeToken const task = (async () => { const instance = await getInstance(instanceId) if (!instance?.client || !instance.folder) return + runtimeToken = instance.runtimeToken const rootClient = getRootClient(instanceId) as any const workspaceApi = rootClient.experimental?.workspace if (!workspaceApi?.syncList || !workspaceApi?.list) { log.warn("OpenCode experimental workspace API unavailable", { instanceId }) - workspaceIdByWorktreeSlug.set(instanceId, new Map()) + if (await isCurrent()) workspaceIdByWorktreeSlug.set(instanceId, new Map()) return } await withWorkspaceSyncTimeout(workspaceApi.syncList({ directory: instance.folder })) + if (!await isCurrent()) return const result = await withWorkspaceSyncTimeout(workspaceApi.list({ directory: instance.folder })) + if (!await isCurrent()) return const workspaces = Array.isArray(result?.data) ? (result.data as OpenCodeWorkspace[]) : [] const next = mapOpenCodeWorkspacesToWorktreeSlugs(getWorktrees(instanceId), workspaces) workspaceIdByWorktreeSlug.set(instanceId, next) })() - .catch((error) => { + .catch(async (error) => { + if (!await isCurrent()) return log.warn("Failed to sync OpenCode workspaces", { instanceId, error }) if (!workspaceIdByWorktreeSlug.has(instanceId)) { workspaceIdByWorktreeSlug.set(instanceId, new Map()) diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index ee893c6f7..eef3664ca 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,5 +1,5 @@ import { preparePromptDisplayText } from "../lib/prompt-display-metadata" -import { instances } from "./instances" +import { instances, isInstanceRuntimeCurrent } from "./instances" import { getRootClient } from "./opencode-client" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" @@ -12,7 +12,7 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" -import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache } from "./session-message-cache" +import { invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -43,6 +43,13 @@ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv let lastTimestamp = 0 let localCounter = 0 +function uncertainDeliveryError(error: unknown): Error { + const result = new Error(error instanceof Error ? error.message : String(error)) + ;(result as any).cause = error + ;(result as any).suppressPromptRecovery = true + return result +} + function randomBase62(length: number): string { let result = "" const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto @@ -99,7 +106,7 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } - cancelCachedSessionMessageRestore(instanceId, sessionId) + invalidateSessionMessageCache(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -218,20 +225,47 @@ async function sendMessage( try { log.info("session.promptAsync", { instanceId, sessionId, requestBody }) const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) { + removeMessageV2(instanceId, messageId) + throw new Error("Instance no longer active") + } const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( client.session.promptAsync({ sessionID: sessionId, + messageID: messageId, ...workspacePayload, ...(requestBody as any), }), "session.promptAsync", ) - admission.complete() + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.complete() } catch (error) { - admission.rollback() - throw error + let verified = false + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const messages = await requestData( + client.session.messages({ sessionID: sessionId, ...workspacePayload }), + "session.messages", + ) + verified = true + if (messages.some((message) => message?.info?.id === messageId)) { + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.complete() + return + } + } catch { + // A failed verification leaves delivery ambiguous. + } + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (store.getMessageInfo(messageId)) { + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.complete() + return + } + if (store.getMessage(messageId)?.isEphemeral) removeMessageV2(instanceId, messageId) + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.rollback() + throw verified ? error : uncertainDeliveryError(error) } } catch (error) { log.error("Failed to send prompt", error) @@ -281,6 +315,8 @@ async function executeCustomCommand( } const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") + invalidateSessionMessageCache(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -291,10 +327,10 @@ async function executeCustomCommand( }), "session.command", ) - admission.complete() + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.complete() } catch (error) { - admission.rollback() - throw error + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.rollback() + throw uncertainDeliveryError(error) } } @@ -314,6 +350,8 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s const agent = session.agent || "build" const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") + invalidateSessionMessageCache(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -325,10 +363,10 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s }), "session.shell", ) - admission.complete() + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.complete() } catch (error) { - admission.rollback() - throw error + if (isInstanceRuntimeCurrent(instanceId, instance)) admission.rollback() + throw uncertainDeliveryError(error) } } @@ -344,10 +382,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) throw new Error("Instance not ready") const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { @@ -366,6 +408,7 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent: } const nextModel = await getDefaultModel(instanceId, agent) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return const shouldApplyModel = isModelValid(instanceId, nextModel) withSession(instanceId, sessionId, (current) => { @@ -377,6 +420,7 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent: if (agent && shouldApplyModel) { await setAgentModelPreference(instanceId, agent, nextModel) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return } if (shouldApplyModel) { @@ -389,6 +433,8 @@ async function updateSessionModel( sessionId: string, model: { providerId: string; modelId: string }, ): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) throw new Error("Instance not ready") const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { @@ -406,6 +452,7 @@ async function updateSessionModel( if (session.agent) { await setAgentModelPreference(instanceId, session.agent, model) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return } addRecentModelPreference(model) @@ -430,14 +477,17 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s throw new Error("Session title is required") } + const workspace = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return await requestData( client.session.update({ sessionID: sessionId, - ...(await getSessionWorkspacePayload(instanceId, sessionId)), + ...workspace, title: trimmedTitle, }), "session.update", ) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return withSession(instanceId, sessionId, (current) => { current.title = trimmedTitle @@ -455,17 +505,20 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI } const client = getRootClient(instanceId) + const workspace = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return + invalidateSessionMessageCache(instanceId, sessionId) await requestData( client.part.delete({ sessionID: sessionId, - ...(await getSessionWorkspacePayload(instanceId, sessionId)), + ...workspace, messageID: messageId, partID: partId, }), "part.delete", ) - if (instances().get(instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(instanceId, instance)) return // Optimistic removal; SSE will also broadcast a part-removed event. invalidateSessionMessageCache(instanceId, sessionId) @@ -481,17 +534,20 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s } const client = getRootClient(instanceId) + const workspace = await getSessionWorkspacePayload(instanceId, sessionId) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return + invalidateSessionMessageCache(instanceId, sessionId) // The SDK generator does not currently expose a typed method for deleting a message, // but the API is available at DELETE /session/:sessionID/message/:messageID. await requestData( (client as any).client.delete({ url: `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, - query: await getSessionWorkspacePayload(instanceId, sessionId), + query: workspace, }), "session.message.delete", ) - if (instances().get(instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(instanceId, instance)) return // Optimistic removal; SSE will also broadcast a message-removed event. invalidateSessionMessageCache(instanceId, sessionId) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 990752cd6..0d37d7096 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,9 +7,10 @@ import { type SessionStatus, } from "../types/session" import type { Message, MessageInfo } from "../types/message" +import type { Instance } from "../types/instance" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" -import { instances, reconcilePendingSessionIndicators } from "./instances" +import { clearSessionInterruptions, instances, isInstanceRuntimeCurrent, reconcilePendingSessionIndicators } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" import { activeSessionId, @@ -56,8 +57,8 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" -import { clearPendingDeltasForMessage, clearPendingDeltasForSession } from "./delta-buffer" +import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2, setSessionRevertV2 } from "./message-v2/bridge" +import { clearPendingDeltasForMessage, clearPendingDeltasForSession, requestDeltaRecovery } from "./delta-buffer" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -104,9 +105,13 @@ messageStoreBus.onInstanceDestroyed((instanceId) => { for (const key of pendingMetadataHydrations.keys()) if (key.startsWith(prefix)) pendingMetadataHydrations.delete(key) }) -function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Message[]; infos: Map } { +function adaptApiMessages( + sessionId: string, + apiMessages: any[], + sessionStatus: SessionStatus = "idle", +): { messages: Message[]; infos: Map } { const infos = new Map() - const messages = apiMessages.map((apiMessage: any) => { + const messages = apiMessages.map((apiMessage: any, index: number) => { const info = (apiMessage.info || apiMessage) as MessageInfo const messageId = info.id || String(Date.now()) infos.set(messageId, info) @@ -116,7 +121,13 @@ function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Me type: info.role === "user" ? "user" as const : "assistant" as const, parts: (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)), timestamp: info.time?.created || Date.now(), - status: (info as any).error ? "error" as const : "complete" as const, + status: (info as any).error + ? "error" as const + : info.role === "assistant" && index === apiMessages.length - 1 && + !info.time?.completed && !(info.time as { end?: number } | undefined)?.end && + (sessionStatus === "working" || sessionStatus === "compacting") + ? "streaming" as const + : "complete" as const, version: 0, } }) @@ -304,8 +315,8 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)) for (const candidate of candidates) { try { - await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate, () => instances().get(instanceId) === instance) - if (instances().get(instanceId) !== instance) return + await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate, () => isInstanceRuntimeCurrent(instanceId, instance)) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return rememberSessionWorkspace(instanceId, sessionId, candidate.workspace) return } catch (error) { @@ -326,12 +337,16 @@ async function hydrateRestoredSessionChain( requestedIds: Array, signal?: AbortSignal, ): Promise { + const instance = instances().get(instanceId) + if (!instance) throw new Error("Instance not ready") const client = getRootClient(instanceId) + const isCurrentInstance = () => isInstanceRuntimeCurrent(instanceId, instance) const pending = requestedIds.filter((id): id is string => Boolean(id) && id !== "info") const visited = new Set() let chainWorkspacePayload: { workspace?: string } = {} while (pending.length > 0) { signal?.throwIfAborted() + if (!isCurrentInstance()) return const sessionId = pending.shift()! if (visited.has(sessionId)) continue visited.add(sessionId) @@ -342,6 +357,7 @@ async function hydrateRestoredSessionChain( try { const workspaceCandidates = await getSessionWorkspaceCandidates(instanceId, sessionId, chainWorkspacePayload) signal?.throwIfAborted() + if (!isCurrentInstance()) return let apiSession: SDKSession | undefined let hydratedWorkspace: string | undefined let lastError: unknown @@ -351,6 +367,7 @@ async function hydrateRestoredSessionChain( client.session.get({ sessionID: sessionId, ...workspacePayload }), "session.get", ) + if (!isCurrentInstance()) return hydratedWorkspace = workspacePayload.workspace break } catch (error) { @@ -361,7 +378,7 @@ async function hydrateRestoredSessionChain( signal?.throwIfAborted() rememberSessionWorkspace(instanceId, sessionId, hydratedWorkspace) setSessions((prev) => { - if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId) || signal?.aborted) return prev + if (!isCurrentInstance() || 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))) @@ -391,7 +408,7 @@ async function hydrateRestoredSessionChain( } } -async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise { +async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], instance: Instance, directory?: string): Promise { const currentSessions = sessions().get(instanceId) ?? new Map() const loaded = new Map(currentSessions) for (const session of apiSessions) loaded.set(session.id, session) @@ -399,6 +416,7 @@ async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSe if (!apiSessions.some((session) => hasMissingParentChain(session, loaded))) return const page = await fetchV2Sessions(instanceId, { directory }) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return const items = getV2SessionItems(page) if (items.length === 0) return @@ -441,8 +459,9 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" }) const response = await fetchV2Sessions(instanceId, sessionListOptions) - if (!isLatestSessionListRequest(instanceId, requestId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isLatestSessionListRequest(instanceId, requestId)) return await recordSessionWorkspaceHints(instanceId, getV2SessionItems(response)) + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isLatestSessionListRequest(instanceId, requestId)) return let statusById: Record = {} let statusResponseKnown = false @@ -455,7 +474,7 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): } catch (error) { log.error("Failed to fetch session status:", error) } - if (!isLatestSessionListRequest(instanceId, requestId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isLatestSessionListRequest(instanceId, requestId)) return const sessionMap = new Map() @@ -564,12 +583,12 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): }) } catch (error) { log.error("Failed to fetch sessions:", error) - if (isLatestSessionListRequest(instanceId, requestId)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isLatestSessionListRequest(instanceId, requestId)) { setSessionListError(instanceId, getOpencodeErrorMessage(error, tGlobal("sessionList.loadError.detail"))) } throw error } finally { - if (isLatestSessionListRequest(instanceId, requestId)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isLatestSessionListRequest(instanceId, requestId)) { setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, false) @@ -600,7 +619,7 @@ async function searchSessions(instanceId: string, query: string): Promise search: trimmedQuery, directory: instance.folder, }) - if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return const searchResults = getV2SessionItems(response) @@ -625,9 +644,9 @@ async function searchSessions(instanceId: string, query: string): Promise }) void hydrateMissingSessionMetadata(instanceId, searchResults.map((session) => session.id)) - await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder) + await ensureV2ParentChainsLoaded(instanceId, searchResults, instance, instance.folder) - if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return const hydratedSessions = sessions().get(instanceId) const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) @@ -646,7 +665,7 @@ async function searchSessions(instanceId: string, query: string): Promise setSessionSearchResults(instanceId, trimmedQuery, currentSearchResults.map((session) => session.id), requestId) } catch (error) { log.error("Failed to search sessions:", error) - if (isLatestSessionSearch(instanceId, trimmedQuery, requestId)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isLatestSessionSearch(instanceId, trimmedQuery, requestId)) { clearSessionSearch(instanceId) } throw error @@ -701,10 +720,11 @@ async function createSession(instanceId: string, agent?: string): Promise 0 ? primaryAgents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) - if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") if (selectedAgent && isModelValid(instanceId, defaultModel)) { await setAgentModelPreference(instanceId, selectedAgent, defaultModel) + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") } setLoading((prev) => { @@ -716,7 +736,7 @@ async function createSession(instanceId: string, agent?: string): Promise { const next = { ...prev } next.creatingSession.set(instanceId, false) @@ -828,14 +849,14 @@ async function forkSession( ...(await getSessionWorkspacePayload(instanceId, sourceSessionId)), messageID: options?.messageId, } - if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await requestData( client.session.fork(request), "session.fork", ) - if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") + if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") const forkedSession = { id: info.id, instanceId, @@ -922,8 +943,10 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) @@ -955,6 +978,7 @@ function removeSessionRuntimeState(instanceId: string, sessionId: string): void deleteSessionAttachments(instanceId, sessionId) clearSessionDraftPrompt(instanceId, sessionId) setSessionExpanded(instanceId, sessionId, false) + clearSessionInterruptions(instanceId, sessionId) setSessions((prev) => { const next = new Map(prev) @@ -1014,7 +1038,7 @@ async function fetchAgents(instanceId: string): Promise { try { log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) const response = await rootClient.app.agents() - if (instances().get(instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(instanceId, instance)) return const agentList = (response.data ?? []).map((agent) => ({ name: agent.name, description: agent.description || "", @@ -1049,7 +1073,7 @@ async function fetchProviders(instanceId: string): Promise { try { log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) const response = await rootClient.config.providers() - if (instances().get(instanceId) !== instance) return + if (!isInstanceRuntimeCurrent(instanceId, instance)) return if (!response.data) return const providerList = response.data.providers.map((provider) => ({ @@ -1079,7 +1103,7 @@ async function fetchProviders(instanceId: string): Promise { async function loadMessages( instanceId: string, sessionId: string, - options?: { force?: boolean }, + options?: { force?: boolean; revisionRetryCount?: number }, ): Promise { const force = options?.force ?? false if (force) { @@ -1147,17 +1171,28 @@ async function loadMessages( (error) => ({ ok: false as const, error }), ) let outcome: Awaited | undefined + let failedOutcome: Extract, { ok: false }> | undefined + let restoredCache = false + let restoredCacheComplete = true + let restoredCacheFinished = false if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { const pages = restoreCachedSessionMessagePages(instanceId, sessionId) const iterator = pages[Symbol.asyncIterator]() + let cacheNext = iterator.next() try { while (!outcome) { const winner = await Promise.race([ - apiOutcome.then((value) => ({ kind: "http" as const, value })), - iterator.next().then((value) => ({ kind: "cache" as const, value })), + failedOutcome + ? new Promise(() => undefined) + : apiOutcome.then((value) => ({ kind: "http" as const, value })), + cacheNext.then((value) => ({ kind: "cache" as const, value })), ]) if (winner.kind === "http") { + if (!winner.value.ok) { + failedOutcome = winner.value + continue + } outcome = winner.value cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) void iterator.return?.(undefined).catch(() => undefined) @@ -1167,11 +1202,13 @@ async function loadMessages( outcome = await apiOutcome break } + cacheNext = iterator.next() if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { cancelCachedSessionMessageRestore(instanceId, sessionId) return } - const cached = adaptApiMessages(sessionId, winner.value.value.messages) + if (!isInstanceRuntimeCurrent(instanceId, instance)) return + const cached = adaptApiMessages(sessionId, winner.value.value.messages, "idle") const revision = mergeCachedSessionMessagePageV2( instanceId, sessionForV2, @@ -1186,6 +1223,9 @@ async function loadMessages( break } expectedRevision = revision + restoredCache = true + restoredCacheComplete &&= winner.value.value.complete + restoredCacheFinished ||= winner.value.value.done reconcilePendingPermissionsV2(instanceId, sessionId) reconcilePendingQuestionsV2(instanceId, sessionId) } @@ -1195,12 +1235,24 @@ async function loadMessages( } } - outcome ??= await apiOutcome + outcome ??= failedOutcome ?? await apiOutcome cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!outcome.ok && restoredCache && restoredCacheComplete && restoredCacheFinished) { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) ?? new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + log.warn("Using cached session messages after HTTP failure", { instanceId, sessionId, error: outcome.error }) + return + } if (!outcome.ok) throw outcome.error const apiMessages = outcome.messages - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return if (!Array.isArray(apiMessages)) { return @@ -1208,7 +1260,8 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) - const adapted = adaptApiMessages(sessionId, apiMessages) + const latestStatus = sessions().get(instanceId)?.get(sessionId)?.status ?? sessionForV2.status + const adapted = adaptApiMessages(sessionId, apiMessages, latestStatus) if (apiMessages.length > 0) { @@ -1230,14 +1283,14 @@ async function loadMessages( if (!agentName && !providerID && !modelID) { const defaultModel = await getDefaultModel(instanceId, session.agent) - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return agentName = session.agent providerID = defaultModel.providerId modelID = defaultModel.modelId } setSessions((prev) => { - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev const next = new Map(prev) const nextInstanceSessions = next.get(instanceId) if (!nextInstanceSessions) return next @@ -1255,13 +1308,14 @@ async function loadMessages( } const latestSession = sessions().get(instanceId)?.get(sessionId) ?? sessionForV2 - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + for (const message of adapted.messages) { + if (clearPendingDeltasForMessage(instanceId, message.id)) retryAfterRevisionConflict = true + } if (!seedSessionMessagesV2(instanceId, latestSession, adapted.messages, adapted.infos, expectedRevision)) { retryAfterRevisionConflict = true } else { - for (const message of adapted.messages) { - retryAfterRevisionConflict ||= clearPendingDeltasForMessage(instanceId, message.id) - } + if (latestSession.revert) setSessionRevertV2(instanceId, sessionId, latestSession.revert) setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() @@ -1282,19 +1336,19 @@ async function loadMessages( } catch (error) { log.error("Failed to load messages:", error) - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) } throw error } finally { - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { const clearShift = () => { - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) + if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) } if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) else setTimeout(clearShift, 0) } - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) @@ -1305,12 +1359,21 @@ async function loadMessages( } if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { - await new Promise((resolve) => setTimeout(resolve, 50)) - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return - return loadMessages(instanceId, sessionId, { force: true }) + if ((options?.revisionRetryCount ?? 0) < 2) { + await new Promise((resolve) => setTimeout(resolve, 50)) + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + return loadMessages(instanceId, sessionId, { force: true, revisionRetryCount: (options?.revisionRetryCount ?? 0) + 1 }) + } + setMessagesLoaded((prev) => { + const next = new Map(prev) + next.get(instanceId)?.delete(sessionId) + return next + }) + requestDeltaRecovery({ instanceId, sessionId, messageId: "reconcile", partId: "reconcile", field: "text" }) + return } - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 17da93517..959dac621 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -14,6 +14,7 @@ import type { EventSessionStatus, } from "@opencode-ai/sdk" import type { MessageStatus } from "./message-v2/types" +import type { Instance } from "../types/instance" import { getLogger } from "../lib/logger" import type { EventSessionDeleted } from "../lib/sse-manager" @@ -21,10 +22,12 @@ import { requestData } from "../lib/opencode-api" import { enqueueDelta, clearPendingDeltasForInstance, + clearPendingDeltasForMessage, clearPendingDeltasForPart, clearPendingDeltasForSession, flushPendingDeltasForMessage, - holdDelta, + requestDeltaRecovery, + setRecoveryCallback, setFlushCallback, } from "./delta-buffer" import { @@ -48,6 +51,7 @@ import { sendOsNotification } from "../lib/os-notifications" import { preferences } from "./preferences" import { instances, + isInstanceRuntimeCurrent, addPermissionToQueue, getPermissionQueue, removePermissionFromQueue, @@ -100,13 +104,19 @@ import { scheduleSessionMemorySweep } from "./session-memory" const log = getLogger("sse") const pendingSessionFetches = new Map>() +const pendingSessionFetchRuntimes = new Map() const pendingSessionStatuses = new Map() +const pendingDeltaRecoveries = new Map() +const pendingIdleMessageReconciliations = new Set() let activeRetryToast: ToastHandle | null = null messageStoreBus.onInstanceDestroyed((instanceId) => { const prefix = `${instanceId}:` for (const key of pendingSessionFetches.keys()) if (key.startsWith(prefix)) pendingSessionFetches.delete(key) + for (const key of pendingSessionFetchRuntimes.keys()) if (key.startsWith(prefix)) pendingSessionFetchRuntimes.delete(key) for (const key of pendingSessionStatuses.keys()) if (key.startsWith(prefix)) pendingSessionStatuses.delete(key) + for (const key of pendingDeltaRecoveries.keys()) if (key.startsWith(prefix)) pendingDeltaRecoveries.delete(key) + for (const key of pendingIdleMessageReconciliations) if (key.startsWith(prefix)) pendingIdleMessageReconciliations.delete(key) clearPendingDeltasForInstance(instanceId) }) @@ -181,25 +191,26 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId) const client = getRootClient(instanceId) const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) - if (instances().get(instanceId) !== instance) return null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return null try { const info = await requestData( client.session.get({ sessionID: sessionId, ...(workspace ? { workspace } : {}) }), "session.get", ) - if (instances().get(instanceId) !== instance) return null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return null let rawStatus = (info as any)?.status let fetchedStatusKnown = false try { const statuses = await requestData>(client.session.status(), "session.status") - if (instances().get(instanceId) !== instance) return null + if (!isInstanceRuntimeCurrent(instanceId, instance)) return null rawStatus ??= statuses?.[sessionId] fetchedStatusKnown = true } catch (error) { log.error("Failed to fetch session status", error) } + if (!isInstanceRuntimeCurrent(instanceId, instance)) return null const hasStatus = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" fetchedStatusKnown ||= Boolean(hasStatus) const fetchedStatus: SessionStatus = hasStatus ? mapSdkSessionStatus(rawStatus) : "idle" @@ -265,6 +276,8 @@ function ensureSessionStatus( ) { const existing = sessions().get(instanceId)?.get(sessionId) if (existing) { + const key = `${instanceId}:${sessionId}` + if (pendingSessionFetches.has(key)) pendingSessionStatuses.set(key, { status, retry }) setSessionStatus(instanceId, sessionId, status, { retry }) scheduleSessionMemorySweep() return @@ -272,7 +285,9 @@ function ensureSessionStatus( const key = `${instanceId}:${sessionId}` pendingSessionStatuses.set(key, { status, retry }) - if (pendingSessionFetches.has(key)) return + const runtime = instances().get(instanceId) + const pendingRuntime = pendingSessionFetchRuntimes.get(key) + if (pendingSessionFetches.has(key) && pendingRuntime && isInstanceRuntimeCurrent(instanceId, pendingRuntime)) return const pending = (async () => { const fetched = await fetchSessionInfo(instanceId, sessionId, directory) @@ -283,9 +298,11 @@ function ensureSessionStatus( })() pendingSessionFetches.set(key, pending) + if (runtime) pendingSessionFetchRuntimes.set(key, runtime) void pending.finally(() => { if (pendingSessionFetches.get(key) === pending) { pendingSessionFetches.delete(key) + pendingSessionFetchRuntimes.delete(key) pendingSessionStatuses.delete(key) } }) @@ -375,7 +392,11 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes clearPendingDeltasForPart(instanceId, messageId, part.id) } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) - if (messagesLoaded().get(instanceId)?.has(sessionId)) scheduleSessionMessageCacheWrite(instanceId, sessionId) + if (messagesLoaded().get(instanceId)?.has(sessionId)) { + scheduleSessionMessageCacheWrite(instanceId, sessionId) + } else { + invalidateSessionMessageCache(instanceId, sessionId) + } handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) if (part.type === "tool") { @@ -461,6 +482,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes (status === "complete" || status === "error") ) { scheduleSessionMessageCacheWrite(instanceId, sessionId) + } else { + invalidateSessionMessageCache(instanceId, sessionId) } } } @@ -469,11 +492,47 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes setFlushCallback((batch) => { for (const { instanceId, sessionId, messageId, partId, field, delta } of batch) { if (!applyPartDeltaV2(instanceId, { messageId, partId, field, delta })) { - holdDelta(instanceId, messageId, partId, field, delta, sessionId) + requestDeltaRecovery({ instanceId, ...(sessionId ? { sessionId } : {}), messageId, partId, field }) } } }) +setRecoveryCallback(({ instanceId, sessionId, messageId, partId }) => { + if (!sessionId) { + log.warn("Dropped orphan delta without a session", { instanceId, messageId, partId }) + return + } + const key = `${instanceId}:${sessionId}` + const existing = pendingDeltaRecoveries.get(key) + if (existing) { + existing.dirty = true + return + } + const recovery = { dirty: false } + pendingDeltaRecoveries.set(key, recovery) + void (async () => { + try { + let failures = 0 + do { + recovery.dirty = false + try { + await loadMessages(instanceId, sessionId, { force: true }) + failures = 0 + } catch (error) { + failures += 1 + if (failures >= 3) throw error + recovery.dirty = true + await new Promise((resolve) => setTimeout(resolve, failures * 100)) + } + } while (recovery.dirty && instances().has(instanceId) && sessions().get(instanceId)?.has(sessionId)) + } catch (error) { + log.warn("Failed to recover orphan delta", { instanceId, sessionId, error }) + } finally { + if (pendingDeltaRecoveries.get(key) === recovery) pendingDeltaRecoveries.delete(key) + } + })() +}) + function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void { const props = event.properties if (!props) return @@ -494,10 +553,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return const instanceSessions = sessions().get(instanceId) ?? new Map() const existingSession = instanceSessions.get(info.id) - const hasRevert = Object.prototype.hasOwnProperty.call(info, "revert") const incomingRevert = info.revert ?? null const previousRevert = existingSession?.revert ?? null - const revertChanged = hasRevert && ( + const revertChanged = ( incomingRevert?.messageID !== previousRevert?.messageID || incomingRevert?.partID !== previousRevert?.partID || incomingRevert?.snapshot !== previousRevert?.snapshot || @@ -552,7 +610,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) + setSessionRevertV2(instanceId, info.id, incomingRevert) if (!newSession.parentId) { prependSessionListId(instanceId, newSession.id) } @@ -571,14 +629,14 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo retry: existingSession.retry ?? null, metadata: (info as any).metadata ?? existingSession.metadata, time: mergedTime, - revert: hasRevert && info.revert + revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } - : hasRevert ? undefined : existingSession.revert, + : undefined, } let updatedInstanceSessions: Map | undefined @@ -593,7 +651,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) + setSessionRevertV2(instanceId, info.id, incomingRevert) } } @@ -619,6 +677,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { } ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory) + reconcileIdleSessionMessages(instanceId, sessionId) scheduleSessionMemorySweep() log.info(`[SSE] Session idle: ${sessionId}`) } @@ -631,6 +690,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi const status = mapSdkSessionStatus(rawStatus) const retry = mapSdkSessionRetry(rawStatus) ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry) + if (status === "idle") reconcileIdleSessionMessages(instanceId, sessionId) scheduleSessionMemorySweep() if (retry) { const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000)) @@ -654,6 +714,16 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi log.info(`[SSE] Session status updated: ${sessionId}`, { status }) } +function reconcileIdleSessionMessages(instanceId: string, sessionId: string): void { + if (!messageStoreBus.getInstance(instanceId)?.hasSessionActiveWork(sessionId)) return + const key = `${instanceId}:${sessionId}` + if (pendingIdleMessageReconciliations.has(key)) return + pendingIdleMessageReconciliations.add(key) + void loadMessages(instanceId, sessionId, { force: true }) + .catch((error) => log.warn("Failed to reconcile idle session messages", { instanceId, sessionId, error })) + .finally(() => pendingIdleMessageReconciliations.delete(key)) +} + function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void { const sessionID = event.properties?.sessionID if (!sessionID) return @@ -709,6 +779,7 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) + clearPendingDeltasForMessage(instanceId, messageID) invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) @@ -720,6 +791,7 @@ function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedE if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) + clearPendingDeltasForPart(instanceId, messageID, partID) invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) diff --git a/packages/ui/src/stores/session-memory.test.ts b/packages/ui/src/stores/session-memory.test.ts index 2a4d406d5..279877686 100644 --- a/packages/ui/src/stores/session-memory.test.ts +++ b/packages/ui/src/stores/session-memory.test.ts @@ -70,3 +70,18 @@ test("authoritative working status protects a resident session", () => { messageStoreBus.unregisterInstance(instanceId) } }) + +test("idle metadata cannot evict a normalized streaming message", () => { + const instanceId = "memory-idle-streaming", sessionId = "session" + try { + addMessage(instanceId, sessionId, "streaming") + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, { id: sessionId, status: "idle" } as any]]))) + assert.deepEqual(runSessionMemorySweep(0), []) + + addMessage(instanceId, sessionId, "complete") + assert.deepEqual(runSessionMemorySweep(0), [`${instanceId}\u0000${sessionId}`]) + } finally { + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + messageStoreBus.unregisterInstance(instanceId) + } +}) diff --git a/packages/ui/src/stores/session-memory.ts b/packages/ui/src/stores/session-memory.ts index 0f1e952e9..bb0dad672 100644 --- a/packages/ui/src/stores/session-memory.ts +++ b/packages/ui/src/stores/session-memory.ts @@ -1,7 +1,7 @@ import { getLogger } from "../lib/logger" import { MAX_HOT_SESSION_MESSAGE_BYTES, selectSessionMemoryEvictions, type SessionMemoryEntry } from "../lib/session-memory-budget" import { messageStoreBus } from "./message-v2/bus" -import { cancelCachedSessionMessageRestore, isRestoringCachedSessionMessages } from "./session-message-cache" +import { cancelCachedSessionMessageRestore, isRestoringCachedSessionMessages, isSessionMessageCacheWritePending, setSessionMessageCacheWriteSettledCallback } from "./session-message-cache" import { isSessionMessagesLoading, sessions } from "./session-state" const log = getLogger("session") @@ -23,9 +23,8 @@ function splitSessionKey(key: string): [string, string] { function hasProtectedSessionWork( store: ReturnType, sessionId: string, - status: string | undefined, ): boolean { - return status === "idle" ? store.hasSessionPendingInput(sessionId) : store.hasSessionActiveWork(sessionId) + return store.hasSessionActiveWork(sessionId) } export function scheduleSessionMemorySweep(): void { @@ -57,10 +56,13 @@ export function evictResidentSessionMessages(instanceId: string, sessionId: stri visibleLeases.has(sessionKey(instanceId, sessionId)) || status === "working" || status === "compacting" || - hasProtectedSessionWork(store, sessionId, status) + isSessionMessageCacheWritePending(instanceId, sessionId) || + isSessionMessagesLoading(instanceId, sessionId) || + isRestoringCachedSessionMessages(instanceId, sessionId) || + hasProtectedSessionWork(store, sessionId) ) return false cancelCachedSessionMessageRestore(instanceId, sessionId) - store.clearSession(sessionId, { preserveScroll: true }) + store.clearSession(sessionId, { preserveScroll: true, preservePromptDisplay: true }) log.info("Evicted resident session messages", { instanceId, sessionId }) return true } @@ -75,8 +77,9 @@ export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES) key, byteSize: store.getSessionApproximateByteSize(sessionId), lastTouched: touched.get(key) ?? 0, - protected: visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId, status) || - isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId), + protected: visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId) || + isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId) || + isSessionMessageCacheWritePending(instanceId, sessionId), }) } } @@ -105,3 +108,5 @@ messageStoreBus.onInstanceDestroyed((instanceId) => { for (const key of touched.keys()) if (key.startsWith(prefix)) touched.delete(key) for (const key of visibleLeases.keys()) if (key.startsWith(prefix)) visibleLeases.delete(key) }) + +setSessionMessageCacheWriteSettledCallback(scheduleSessionMemorySweep) diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts index a7661e899..99ca972b2 100644 --- a/packages/ui/src/stores/session-message-cache.ts +++ b/packages/ui/src/stores/session-message-cache.ts @@ -8,21 +8,36 @@ import { readSessionMessageCachePage, writeSessionMessageCache, MAX_SESSION_MESSAGE_CACHE_BYTES, + markSessionMessageCacheUnsafe, + setSessionMessageCacheEnabled, } from "../lib/session-message-cache" import { createSignal } from "solid-js" import { getLogger } from "../lib/logger" import { instances } from "./instances" +import { isInstanceRuntimeCurrent } from "./instances" +import type { Instance } from "../types/instance" import { messageStoreBus } from "./message-v2/bus" import { estimateRetainedBytes } from "../lib/session-memory-budget" const log = getLogger("session") const WRITE_DEBOUNCE_MS = 500 + +function disablePersistentCacheAfterInvalidationFailure(context: Record): void { + markSessionMessageCacheUnsafe(true) + setSessionMessageCacheEnabled(false) + void import("./client-state") + .then(({ setRestorePreviousStateEnabled }) => setRestorePreviousStateEnabled(false)) + .catch((error) => log.warn("Failed to persist disabled session cache state", { ...context, error })) +} const pendingWrites = new Map>() const cacheGenerations = new Map() const pendingInvalidations = new Map>() const invalidatedEntries = new Set() const invalidationVersions = new Map() const pendingEntryInvalidations = new Map() +const activeEntryInvalidations = new Map>() +const entryInvalidationKeys = new Map>() +const trailingEntryInvalidations = new Map() const activeRestores = new Map() type CacheWriteRequest = { instanceId: string @@ -31,16 +46,18 @@ type CacheWriteRequest = { allowEmpty: boolean generation: number reset: number - instanceToken: unknown + instanceToken: Instance workspace: string entryKey: string invalidationVersion: number done: Array<() => void> } const queuedWrites = new Map() +const activeWriteKeys = new Map() let writeWorkerRunning = false const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) let resetGeneration = 0 +let writeSettledCallback: (() => void) | undefined function pendingKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` @@ -50,6 +67,37 @@ function cacheGeneration(key: string): number { return cacheGenerations.get(key) ?? 0 } +function normalizedWorkspace(workspace: string): string { + const normalized = workspace.replace(/\\/g, "/").replace(/\/+$/, "") + return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized +} + +function hasSingleWorkspaceOwner(instanceId: string, workspace: string): boolean { + const expected = normalizedWorkspace(workspace) + let owners = 0 + for (const instance of instances().values()) { + if (normalizedWorkspace(instance.folder) !== expected) continue + owners += 1 + if (owners > 1) return false + } + return owners === 1 && normalizedWorkspace(instances().get(instanceId)?.folder ?? "") === expected +} + +function cleanupCacheFenceState(key: string, entryKey: string): void { + if (!pendingWrites.has(key) && !queuedWrites.has(key) && !activeWriteKeys.has(key) && !activeRestores.has(key) && !pendingInvalidations.has(key)) { + cacheGenerations.delete(key) + } + if ( + !pendingEntryInvalidations.has(entryKey) && + ![...queuedWrites.values()].some((request) => request.entryKey === entryKey) && + ![...activeWriteKeys.values()].includes(entryKey) + ) invalidationVersions.delete(entryKey) +} + +export function setSessionMessageCacheWriteSettledCallback(callback: () => void): void { + writeSettledCallback = callback +} + function setRestoreActive(key: string, active: boolean): void { setRestoringSessions((current) => { const next = new Set(current) @@ -91,7 +139,11 @@ function waitForIdle(): Promise { }) } -function snapshotSession(instanceId: string, sessionId: string, allowEmpty = false): unknown[] | null { +function snapshotSession( + instanceId: string, + sessionId: string, + allowEmpty = false, +): { messages: unknown[]; startIndex: number; totalCount: number } | null { const store = messageStoreBus.getInstance(instanceId) if (!store) return null const messageIds = store.getSessionMessageIds(sessionId) @@ -118,7 +170,9 @@ function snapshotSession(instanceId: string, sessionId: string, allowEmpty = fal result.unshift({ info, parts }) bytes += messageBytes } - return result.length > 0 || messageIds.length === 0 ? result : null + return result.length > 0 || messageIds.length === 0 + ? { messages: result, startIndex: messageIds.length - result.length, totalCount: messageIds.length } + : null } async function drainQueuedWrites(): Promise { @@ -128,28 +182,47 @@ async function drainQueuedWrites(): Promise { while (queuedWrites.size > 0) { const [key, request] = queuedWrites.entries().next().value as [string, CacheWriteRequest] queuedWrites.delete(key) + activeWriteKeys.set(key, request.entryKey) try { await waitForIdle() if (cacheGeneration(key) !== request.generation || resetGeneration !== request.reset) continue - if (instances().get(request.instanceId) !== request.instanceToken) continue + if (!isInstanceRuntimeCurrent(request.instanceId, request.instanceToken)) continue if ((invalidationVersions.get(request.entryKey) ?? 0) !== request.invalidationVersion) continue + if (!hasSingleWorkspaceOwner(request.instanceId, request.workspace)) { + await deleteSessionMessageCache(request.workspace, request.sessionId) + continue + } const store = messageStoreBus.getInstance(request.instanceId) if (!store) continue - if (request.expectedRevision !== undefined && store.getSessionRevision(request.sessionId) !== request.expectedRevision) continue - const messages = snapshotSession(request.instanceId, request.sessionId, request.allowEmpty) - if (!messages) continue - const written = await writeSessionMessageCache(request.workspace, request.sessionId, messages) + if (request.expectedRevision !== undefined && store.getSessionRevision(request.sessionId) !== request.expectedRevision) { + await deleteSessionMessageCache(request.workspace, request.sessionId) + continue + } + const snapshot = snapshotSession(request.instanceId, request.sessionId, request.allowEmpty) + if (!snapshot) { + await deleteSessionMessageCache(request.workspace, request.sessionId) + continue + } + const written = await writeSessionMessageCache( + request.workspace, + request.sessionId, + snapshot.messages, + { startIndex: snapshot.startIndex, totalCount: snapshot.totalCount }, + ) if ( written && cacheGeneration(key) === request.generation && resetGeneration === request.reset && - instances().get(request.instanceId) === request.instanceToken && + isInstanceRuntimeCurrent(request.instanceId, request.instanceToken) && (invalidationVersions.get(request.entryKey) ?? 0) === request.invalidationVersion && pendingEntryInvalidations.get(request.entryKey) !== request.invalidationVersion ) invalidatedEntries.delete(request.entryKey) } catch (error) { log.warn("Failed to cache completed session messages", { instanceId: request.instanceId, sessionId: request.sessionId, error }) } finally { + activeWriteKeys.delete(key) + cleanupCacheFenceState(key, request.entryKey) + writeSettledCallback?.() request.done.forEach((resolve) => resolve()) } } @@ -159,6 +232,11 @@ async function drainQueuedWrites(): Promise { } } +export function isSessionMessageCacheWritePending(instanceId: string, sessionId: string): boolean { + const key = pendingKey(instanceId, sessionId) + return pendingWrites.has(key) || queuedWrites.has(key) || activeWriteKeys.has(key) +} + function enqueueSessionMessageCacheWrite( instanceId: string, sessionId: string, @@ -168,6 +246,10 @@ function enqueueSessionMessageCacheWrite( const instanceToken = instances().get(instanceId) const workspace = instanceToken?.folder if (!instanceToken || !workspace) return Promise.resolve() + if (!hasSingleWorkspaceOwner(instanceId, workspace)) { + invalidateSessionMessageCache(instanceId, sessionId) + return Promise.resolve() + } const entryKey = createSessionMessageCacheKey(workspace, sessionId) const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 return new Promise((resolve) => { @@ -206,8 +288,10 @@ export async function* restoreCachedSessionMessagePages( sessionId: string, ): AsyncGenerator { if (!isSessionMessageCacheEnabled()) return - const workspace = workspaceForInstance(instanceId) + const instanceToken = instances().get(instanceId) + const workspace = instanceToken?.folder if (!workspace) return + if (!hasSingleWorkspaceOwner(instanceId, workspace)) return const entryKey = createSessionMessageCacheKey(workspace, sessionId) if (invalidatedEntries.has(entryKey)) return const key = pendingKey(instanceId, sessionId) @@ -226,6 +310,7 @@ export async function* restoreCachedSessionMessagePages( while ( cursor && cursor.beforeIndex > 0 && + isInstanceRuntimeCurrent(instanceId, instanceToken) && activeRestores.get(key) === token && cacheGeneration(key) === generation && resetGeneration === reset && @@ -234,6 +319,7 @@ export async function* restoreCachedSessionMessagePages( const result = await readSessionMessageCachePage(cursor) if (!result) return if ( + !isInstanceRuntimeCurrent(instanceId, instanceToken) || activeRestores.get(key) !== token || cacheGeneration(key) !== generation || resetGeneration !== reset || @@ -281,13 +367,17 @@ export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: const instanceToken = instances().get(instanceId) const workspace = instanceToken?.folder if (!instanceToken || !workspace) return + if (!hasSingleWorkspaceOwner(instanceId, workspace)) { + invalidateSessionMessageCache(instanceId, sessionId) + return + } const entryKey = createSessionMessageCacheKey(workspace, sessionId) const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 const existing = pendingWrites.get(key) if (existing) clearTimeout(existing) pendingWrites.set(key, setTimeout(() => { pendingWrites.delete(key) - if (instances().get(instanceId) !== instanceToken) return + if (!isInstanceRuntimeCurrent(instanceId, instanceToken)) return if ((invalidationVersions.get(entryKey) ?? 0) !== invalidationVersion) return void enqueueSessionMessageCacheWrite(instanceId, sessionId) }, WRITE_DEBOUNCE_MS)) @@ -296,7 +386,6 @@ export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { const key = pendingKey(instanceId, sessionId) cancelCachedSessionMessageRestore(instanceId, sessionId) - cacheGenerations.set(key, cacheGeneration(key) + 1) const pending = pendingWrites.get(key) if (pending) { clearTimeout(pending) @@ -310,19 +399,57 @@ export function invalidateSessionMessageCache(instanceId: string, sessionId: str const workspace = workspaceForInstance(instanceId) if (!workspace) return const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const generation = cacheGeneration(key) + 1 + cacheGenerations.set(key, generation) + const activeInvalidation = activeEntryInvalidations.get(entryKey) + if (activeInvalidation) { + invalidatedEntries.add(entryKey) + trailingEntryInvalidations.set(entryKey, { instanceId, sessionId, workspace }) + const keys = entryInvalidationKeys.get(entryKey) ?? new Set() + keys.add(key) + entryInvalidationKeys.set(entryKey, keys) + pendingInvalidations.set(key, activeInvalidation) + return + } const invalidationVersion = (invalidationVersions.get(entryKey) ?? 0) + 1 invalidationVersions.set(entryKey, invalidationVersion) pendingEntryInvalidations.set(entryKey, invalidationVersion) invalidatedEntries.add(entryKey) const invalidation = deleteSessionMessageCache(workspace, sessionId) - .then(() => { - if ((invalidationVersions.get(entryKey) ?? 0) === invalidationVersion) invalidatedEntries.delete(entryKey) + .catch((error) => { + log.warn("Failed to invalidate cached session messages; disabling persistent cache", { instanceId, sessionId, error }) + disablePersistentCacheAfterInvalidationFailure({ instanceId, sessionId }) }) - .catch((error) => log.warn("Failed to invalidate cached session messages", { instanceId, sessionId, error })) .finally(() => { - if (pendingInvalidations.get(key) === invalidation) pendingInvalidations.delete(key) + if (activeEntryInvalidations.get(entryKey) === invalidation) activeEntryInvalidations.delete(entryKey) if (pendingEntryInvalidations.get(entryKey) === invalidationVersion) pendingEntryInvalidations.delete(entryKey) + const keys = entryInvalidationKeys.get(entryKey) ?? new Set([key]) + entryInvalidationKeys.delete(entryKey) + for (const pendingKey of keys) { + if (pendingInvalidations.get(pendingKey) === invalidation) pendingInvalidations.delete(pendingKey) + cleanupCacheFenceState(pendingKey, entryKey) + } + const trailing = trailingEntryInvalidations.get(entryKey) + trailingEntryInvalidations.delete(entryKey) + if (trailing && isSessionMessageCacheEnabled()) { + queueMicrotask(() => { + if (workspaceForInstance(trailing.instanceId)) { + invalidateSessionMessageCache(trailing.instanceId, trailing.sessionId) + return + } + void deleteSessionMessageCache(trailing.workspace, trailing.sessionId) + .then(() => invalidatedEntries.delete(entryKey)) + .catch((error) => { + log.warn("Failed trailing cached session invalidation; disabling persistent cache", { sessionId: trailing.sessionId, error }) + disablePersistentCacheAfterInvalidationFailure({ sessionId: trailing.sessionId }) + }) + }) + } else if ((invalidationVersions.get(entryKey) ?? 0) === invalidationVersion) { + invalidatedEntries.delete(entryKey) + } }) + activeEntryInvalidations.set(entryKey, invalidation) + entryInvalidationKeys.set(entryKey, new Set([key])) pendingInvalidations.set(key, invalidation) } @@ -334,10 +461,15 @@ function resetPendingSessionMessageCacheWork(): void { pendingWrites.clear() for (const request of queuedWrites.values()) request.done.forEach((resolve) => resolve()) queuedWrites.clear() + activeWriteKeys.clear() pendingInvalidations.clear() invalidatedEntries.clear() invalidationVersions.clear() pendingEntryInvalidations.clear() + activeEntryInvalidations.clear() + entryInvalidationKeys.clear() + trailingEntryInvalidations.clear() + cacheGenerations.clear() } onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) @@ -354,9 +486,7 @@ messageStoreBus.onInstanceDestroyed((instanceId) => { queuedWrites.delete(key) request.done.forEach((resolve) => resolve()) } - for (const key of cacheGenerations.keys()) { - if (key.startsWith(prefix)) cacheGenerations.set(key, cacheGeneration(key) + 1) - } + for (const key of cacheGenerations.keys()) if (key.startsWith(prefix)) cacheGenerations.delete(key) for (const key of activeRestores.keys()) { if (key.startsWith(prefix)) activeRestores.delete(key) } diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 8cded2410..666841e4d 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -3,9 +3,9 @@ import { describe, it } from "node:test" import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" -import { addInstance, removeInstance } from "./instances.ts" +import { addInstance, instances, isInstanceRuntimeCurrent, removeInstance, updateInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" -import { loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { fetchSessions, loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" import { clearInstanceDeletedSessionAuthority, getSessionSearchResultIds, @@ -61,6 +61,20 @@ function setup(instanceId: string) { } describe("session request authority", () => { + it("keeps metadata refreshes in the same runtime but fences client replacement", () => { + const instanceId = "instance-runtime-token" + const { cleanup } = setup(instanceId) + try { + const captured = instances().get(instanceId)! + updateInstance(instanceId, { projectName: "refreshed" }) + assert.equal(isInstanceRuntimeCurrent(instanceId, captured), true) + updateInstance(instanceId, { client: { session: {} } as any }) + assert.equal(isInstanceRuntimeCurrent(instanceId, captured), false) + } finally { + cleanup() + } + }) + it("does not restore deleted search results or their parent chain", async () => { const instanceId = "late-search-delete" const { client, cleanup } = setup(instanceId) @@ -153,6 +167,69 @@ describe("session request authority", () => { } }) + it("rejects message hydration after in-place client replacement", async () => { + const instanceId = "replaced-message-client", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const response = deferred() + ;(client.session as any).messages = () => response.promise + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const request = loadMessages(instanceId, sessionId) + updateInstance(instanceId, { client: { session: {} } as any }) + response.resolve({ data: [apiMessage("stale-message", sessionId)] }) + await request + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), []) + } finally { + cleanup() + } + }) + + it("rejects session-list deletion after in-place client replacement", async () => { + const instanceId = "replaced-session-list", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const response = deferred() + ;(client.session as any).list = () => response.promise + ;(client.session as any).status = async () => ({ data: {} }) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const request = fetchSessions(instanceId) + updateInstance(instanceId, { client: { session: {} } as any }) + response.resolve({ data: [] }) + await request + assert.equal(sessions().get(instanceId)?.has(sessionId), true) + } finally { + cleanup() + } + }) + + it("does not reuse search authority after an instance reopens", async () => { + const instanceId = "reopened-session-search" + const { client, cleanup } = setup(instanceId) + const oldResponse = deferred() + const newResponse = deferred() + let calls = 0 + ;(client.session as any).list = () => (++calls === 1 ? oldResponse.promise : newResponse.promise) + + try { + const oldRequest = searchSessions(instanceId, "same") + removeInstance(instanceId, { authoritative: false }) + addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + const newRequest = searchSessions(instanceId, "same") + + newResponse.resolve({ data: [apiSession("new-session")] }) + await newRequest + oldResponse.resolve({ data: [apiSession("old-session")] }) + await oldRequest + + assert.deepEqual(getSessionSearchResultIds(instanceId), ["new-session"]) + assert.equal(sessions().get(instanceId)?.has("old-session") ?? false, false) + } 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) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index f59b12b52..2d5b637b8 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -44,6 +44,26 @@ function cancelSessionGenerationAdmissions(instanceId: string, sessionId: string generationAdmissions.delete(`${instanceId}:${sessionId}`) } +function resetInstanceSessionRequestState(instanceId: string): void { + const prefix = `${instanceId}:` + for (const [key, admission] of generationAdmissions) { + if (!key.startsWith(prefix)) continue + const sessionId = key.slice(prefix.length) + withSession(instanceId, sessionId, (session) => { + if (session.generationAdmissionToken !== admission.token) return false + session.generationAdmissionToken = undefined + Object.assign(session, admission.baseline) + }) + generationAdmissions.delete(key) + } + setLoading((prev) => ({ + fetchingSessions: removeInstanceMapEntry(prev.fetchingSessions, instanceId), + creatingSession: removeInstanceMapEntry(prev.creatingSession, instanceId), + deletingSession: removeInstanceMapEntry(prev.deletingSession, instanceId), + loadingMessages: removeInstanceMapEntry(prev.loadingMessages, instanceId), + })) +} + export interface SessionInfo { cost: number contextWindow: number @@ -1303,6 +1323,7 @@ export { hydrateSessionGenerationRecovery, beginSessionGenerationAdmission, cancelSessionGenerationAdmissions, + resetInstanceSessionRequestState, setSessionStatus, setActiveSession, setActiveParentSession, diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts index 4ba2be593..57c8a1991 100644 --- a/packages/ui/src/types/instance.ts +++ b/packages/ui/src/types/instance.ts @@ -45,4 +45,6 @@ export interface Instance { binaryLabel?: string binaryVersion?: string environmentVariables?: Record + runtimeToken?: symbol + attachedPid?: number } From 3e9763d3b2e732f23c68e60257108d939293bd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 1 Aug 2026 02:25:11 +0200 Subject: [PATCH 03/16] fix(server): keep instance timeout alive Replace AbortSignal.timeout with a managed AbortController timer so a stuck loopback request keeps the Node event loop alive until it is aborted. Preserve caller-provided signals and clear the timer when fetch settles. Validated with the instance-client timeout test and server typecheck; the full server suite passes except for the known local branch-name fixture. --- packages/server/src/workspaces/instance-client.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts index 5ee323f04..378b39519 100644 --- a/packages/server/src/workspaces/instance-client.ts +++ b/packages/server/src/workspaces/instance-client.ts @@ -53,11 +53,14 @@ export function createInstanceClient( return createOpencodeClient({ baseUrl: `http://${LOOPBACK_HOST}:${port}/`, headers, - fetch: (url, init) => - fetch(url, { - ...(init as RequestInit), - signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(timeoutMs), - }), + fetch: (url, init) => { + const requestInit = init as RequestInit + if (requestInit?.signal) return fetch(url, requestInit) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs) + return fetch(url, { ...requestInit, signal: controller.signal }).finally(() => clearTimeout(timeout)) + }, ...(directory ? { directory } : {}), }) } From 47e051f0e408fd4be8b0fa1efe9f561cfa6001e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 1 Aug 2026 03:23:59 +0200 Subject: [PATCH 04/16] fix(ui): restore responsive session switching Keep the experimental persistent message cache disabled in desktop restore flows. The bounded cache could expose only a transcript suffix while progressive IndexedDB hydration repeatedly copied and measured large histories on the renderer thread, preventing responsive session changes. Tab restoration and in-memory session eviction remain enabled; selected sessions now use the established authoritative HTTP load path. Add a regression check and validate request authority, memory eviction, UI typechecking, and the production build. --- packages/ui/src/stores/client-state.test.ts | 6 ++++++ packages/ui/src/stores/client-state.ts | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 96b80b225..084dd209d 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -34,6 +34,12 @@ const boot = async (api?: NativeApi, storage?: MemoryStorage) => { const transact = (state: ClientState, kind: TransactionKind) => kind === "clear" ? state.clearRestoredClientState() : state.setRestorePreviousStateEnabled(false) describe("client state ownership and persistence", () => { + it("keeps persistent message caching disabled during desktop restore", async () => { + const cache = await import("../lib/session-message-cache.ts") + cache.setSessionMessageCacheEnabled(false) + await boot({ loadClientState: async () => loadResult(snapshot("project")) }) + assert.equal(cache.isSessionMessageCacheEnabled(), false) + }) it("treats a rejected access claim as secondary without loading", async () => { let loads = 0 const state = await boot({ diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index 3ccdc690c..24da371d6 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -272,7 +272,8 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(true) + // ponytail: keep message caching off until restore no longer blocks the renderer on large sessions. + setSessionMessageCacheEnabled(false) return } setRestorePreviousStateEnabledSignal(false) @@ -287,7 +288,7 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(!isSessionMessageCacheUnsafe()) + setSessionMessageCacheEnabled(false) throw error } cacheClearPending = true @@ -341,7 +342,7 @@ export function initializeClientState(): Promise { writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) - setSessionMessageCacheEnabled(!writeBlock) + setSessionMessageCacheEnabled(false) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true From adaa0d581be63912903f0b9eb33256210759f2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 1 Aug 2026 10:21:17 +0200 Subject: [PATCH 05/16] fix(ui): cancel hidden session hydration Abort root and subagent message requests when selection or workspace visibility moves away, preventing late large histories from parsing and hydrating behind the active session. Derive request ownership from authoritative session ancestry so parent and selected-child paths share one cancellation boundary. Keep persistent transcript caching disabled, preserve desktop startup restoration, and make disabled-cache invalidation inert. Defer protected-session memory measurement to idle work while conservatively accounting for unknown size, then refresh measurements as sessions grow. Cover root, child, selected-child, workspace-switch, AbortSignal, memory-growth, global-budget, and authoritative empty-part scenarios. Validated with 444 UI tests, 118 Electron tests, typechecks, adversarial review, and a packaged Windows build from this worktree. --- .../components/tool-call/renderers/task.tsx | 2 +- .../ui/src/lib/i18n/messages/de/settings.ts | 2 +- .../ui/src/lib/i18n/messages/en/settings.ts | 2 +- .../ui/src/lib/i18n/messages/es/settings.ts | 2 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 2 +- .../ui/src/lib/i18n/messages/he/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 2 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 +- packages/ui/src/stores/client-state.test.ts | 2 +- packages/ui/src/stores/instances.ts | 15 +- .../stores/message-v2/instance-store.test.ts | 9 ++ .../src/stores/message-v2/instance-store.ts | 2 +- packages/ui/src/stores/session-api.ts | 13 +- packages/ui/src/stores/session-memory.test.ts | 73 ++++++++- packages/ui/src/stores/session-memory.ts | 50 ++++++- .../ui/src/stores/session-message-cache.ts | 5 +- .../stores/session-request-authority.test.ts | 138 +++++++++++++++++- packages/ui/src/stores/session-state.ts | 36 +++++ 20 files changed, 333 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 88bf761b1..6f256372a 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -234,7 +234,7 @@ export const taskRenderer: ToolRenderer = { if (activeSessionId().get(instanceId) !== visibilitySessionId) return if (childSessionLoaded()) return if (childSessionLoading()) return - void loadMessages(instanceId, id) + void loadMessages(instanceId, id).catch(() => undefined) }) createEffect(() => { diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index c9475d15c..4249b8172 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen, das Layout wiederherstellen und aktuelle Sitzungsnachrichten für schnelleres Öffnen auf diesem Gerät zwischenspeichern.", + "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen und das Layout wiederherstellen.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 47ba5e7ce..5cfa71d1a 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages; restore layout; and cache recent session messages on this device for faster reopening.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore the layout.", "settings.appearance.startup.clear.title": "Saved startup state", "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 4417d8396..e4f5aa222 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, restaurar el diseño y guardar en caché los mensajes recientes en este dispositivo para abrirlos más rápido.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, y restaurar el diseño.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 66a24e130..a8918fab4 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, restaurer la disposition et mettre en cache les messages récents sur cet appareil pour accélérer leur réouverture.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, et restaurer la disposition.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index b571a02d5..d532e45bd 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, שחזור הפריסה ושמירת הודעות אחרונות במטמון במכשיר זה לפתיחה מהירה יותר.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, ושחזור הפריסה.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f67f47a6b..05bf1e01d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元し、最近のセッションメッセージをこのデバイスにキャッシュして再表示を高速化します。", + "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元します。", "settings.appearance.startup.clear.title": "保存された起動状態", "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index dd5eae41a..62fab0875 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, लेआउट पुनर्स्थापना गर्नुहोस् र छिटो खोल्न हालका सत्र सन्देशहरू यस यन्त्रमा क्यास गर्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस् र लेआउट पुनर्स्थापना गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 4c49cc0a5..89e047c17 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения, восстанавливать макет и кэшировать недавние сообщения сессий на этом устройстве для быстрого открытия.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения и восстанавливать макет.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 93b05b88c..ffb121a13 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -129,7 +129,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,恢复布局,并在此设备上缓存最近的会话消息以便更快重新打开。", + "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,并恢复布局。", "settings.appearance.startup.clear.title": "已保存的启动状态", "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", "settings.appearance.startup.clear.action": "清除已保存状态", diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 084dd209d..c066c5c58 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -36,7 +36,7 @@ const transact = (state: ClientState, kind: TransactionKind) => kind === "clear" describe("client state ownership and persistence", () => { it("keeps persistent message caching disabled during desktop restore", async () => { const cache = await import("../lib/session-message-cache.ts") - cache.setSessionMessageCacheEnabled(false) + cache.setSessionMessageCacheEnabled(true) await boot({ loadClientState: async () => loadResult(snapshot("project")) }) assert.equal(cache.isSessionMessageCacheEnabled(), false) }) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index cee02e65d..a159b6237 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -39,6 +39,8 @@ import { reconcileSessionPendingState, activeSessionId, invalidateSessionMessageLoad, + invalidateOwnedSessionMessageLoads, + getSessionRoot, purgeInstanceSessionState, sessions, setSessionPendingPermission, @@ -122,7 +124,18 @@ const [instances, setInstances] = createSignal>(new Map()) let workspaceListInitialized = false sseManager.shouldHandleEvent = (instanceId) => !workspaceListInitialized || instances().has(instanceId) -const [activeInstanceId, setActiveInstanceId] = createSignal(null) +const [activeInstanceId, writeActiveInstanceId] = createSignal(null) + +function setActiveInstanceId(instanceId: string | null): void { + const previousInstanceId = activeInstanceId() + if (previousInstanceId && previousInstanceId !== instanceId) { + const previousSessionId = activeSessionId().get(previousInstanceId) + if (previousSessionId) { + invalidateOwnedSessionMessageLoads(previousInstanceId, getSessionRoot(previousInstanceId, previousSessionId)?.id ?? previousSessionId) + } + } + writeActiveInstanceId(instanceId) +} const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index d73f21085..17b60cb29 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -87,6 +87,15 @@ describe("message-v2 authoritative hydration", () => { assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") }) + it("removes stale parts when authoritative hydration returns an empty part list", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) + + store.hydrateMessages("session-1", [{ ...message("message-1"), parts: [] }], [info("message-1")]) + assert.deepEqual(store.getMessage("message-1")?.partIds, []) + assert.deepEqual(store.getMessage("message-1")?.parts, {}) + }) + it("releases a directly removed message and its info version", () => { const store = createInstanceMessageStore("instance-1") store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 98f5aa4e7..ceb1be391 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -642,7 +642,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function normalizeParts(messageId: string, parts: ClientPart[] | undefined) { - if (!parts || parts.length === 0) { + if (parts == null) { return null } const map: MessageRecord["parts"] = {} diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 0d37d7096..ebc9afad8 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -29,7 +29,8 @@ import { providers, setAgents, setMessagesLoaded, - advanceMessageLoadEpoch, + beginSessionMessageLoad, + finishSessionMessageLoad, isCurrentMessageLoad, setSessionMessagesLoadError, setProviders, @@ -1146,7 +1147,7 @@ async function loadMessages( } cancelCachedSessionMessageRestore(instanceId, sessionId) - const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) + const { epoch: loadEpoch, signal: loadSignal } = beginSessionMessageLoad(instanceId, sessionId) const store = messageStoreBus.getOrCreate(instanceId) let expectedRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false @@ -1164,7 +1165,7 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) const apiMessagesRequest = getSessionWorkspacePayload(instanceId, sessionId).then((workspacePayload) => - requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }), "session.messages"), + requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }, { signal: loadSignal }), "session.messages"), ) const apiOutcome = apiMessagesRequest.then( (messages) => ({ ok: true as const, messages }), @@ -1341,6 +1342,7 @@ async function loadMessages( } throw error } finally { + finishSessionMessageLoad(instanceId, sessionId, loadEpoch) if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { const clearShift = () => { if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) @@ -1362,7 +1364,10 @@ async function loadMessages( if ((options?.revisionRetryCount ?? 0) < 2) { await new Promise((resolve) => setTimeout(resolve, 50)) if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return - return loadMessages(instanceId, sessionId, { force: true, revisionRetryCount: (options?.revisionRetryCount ?? 0) + 1 }) + return loadMessages(instanceId, sessionId, { + force: true, + revisionRetryCount: (options?.revisionRetryCount ?? 0) + 1, + }) } setMessagesLoaded((prev) => { const next = new Map(prev) diff --git a/packages/ui/src/stores/session-memory.test.ts b/packages/ui/src/stores/session-memory.test.ts index 279877686..d4e4d989f 100644 --- a/packages/ui/src/stores/session-memory.test.ts +++ b/packages/ui/src/stores/session-memory.test.ts @@ -4,13 +4,13 @@ import { messageStoreBus } from "./message-v2/bus.ts" import { runSessionMemorySweep, setVisibleSessionMemory } from "./session-memory.ts" import { setSessions } from "./session-state.ts" -function addMessage(instanceId: string, sessionId: string, status: "complete" | "streaming" = "complete") { +function addMessage(instanceId: string, sessionId: string, status: "complete" | "streaming" = "complete", text = sessionId.repeat(100)) { messageStoreBus.getOrCreate(instanceId).upsertMessage({ id: `${sessionId}-message`, sessionId, role: "assistant", status, - parts: [{ id: `${sessionId}-part`, type: "text", text: sessionId.repeat(100) }] as any, + parts: [{ id: `${sessionId}-part`, type: "text", text }] as any, }) } @@ -24,9 +24,7 @@ test("resident message budget evicts globally across five workspaces while prese addMessage(instanceIds[0], "streaming", "streaming") setVisibleSessionMemory(instanceIds[4], "parent", true) - const protectedBytes = messageStoreBus.getInstance(instanceIds[4])!.getSessionApproximateByteSize("parent") + - messageStoreBus.getInstance(instanceIds[0])!.getSessionApproximateByteSize("streaming") - const evicted = runSessionMemorySweep(protectedBytes) + const evicted = runSessionMemorySweep(0) assert.equal(evicted.length, 9) assert.deepEqual(messageStoreBus.getInstance(instanceIds[4])!.getSessionMessageIds("parent"), ["parent-message"]) @@ -71,6 +69,71 @@ test("authoritative working status protects a resident session", () => { } }) +test("memory sweeps defer measuring a newly protected visible session", () => { + const instanceId = "memory-visible-measurement", sessionId = "session" + const store = messageStoreBus.getOrCreate(instanceId) + const measure = store.getSessionApproximateByteSize + try { + addMessage(instanceId, sessionId) + setVisibleSessionMemory(instanceId, sessionId, true) + ;(store as any).getSessionApproximateByteSize = () => { throw new Error("protected session was measured") } + assert.deepEqual(runSessionMemorySweep(0), []) + } finally { + ;(store as any).getSessionApproximateByteSize = measure + setVisibleSessionMemory(instanceId, sessionId, false) + messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("protected memory still contributes to the global eviction budget", () => { + const instanceId = "memory-protected-budget", visibleSessionId = "visible", hiddenSessionId = "hidden" + try { + addMessage(instanceId, visibleSessionId) + addMessage(instanceId, hiddenSessionId) + const store = messageStoreBus.getOrCreate(instanceId) + const byteLimit = Math.max( + store.getSessionApproximateByteSize(visibleSessionId), + store.getSessionApproximateByteSize(hiddenSessionId), + ) + setVisibleSessionMemory(instanceId, visibleSessionId, true) + + assert.deepEqual(runSessionMemorySweep(byteLimit), [`${instanceId}\u0000${hiddenSessionId}`]) + assert.deepEqual(store.getSessionMessageIds(visibleSessionId), [`${visibleSessionId}-message`]) + } finally { + setVisibleSessionMemory(instanceId, visibleSessionId, false) + messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("deferred measurement refreshes a protected session after it grows", () => { + const instanceId = "memory-protected-refresh", visibleSessionId = "visible", hiddenSessionId = "hidden" + const originalRequestIdleCallback = (globalThis as any).requestIdleCallback + const originalCancelIdleCallback = (globalThis as any).cancelIdleCallback + const callbacks: Array<() => void> = [] + ;(globalThis as any).requestIdleCallback = (callback: () => void) => { callbacks.push(callback); return callbacks.length } + ;(globalThis as any).cancelIdleCallback = () => undefined + try { + addMessage(instanceId, visibleSessionId) + setVisibleSessionMemory(instanceId, visibleSessionId, true) + callbacks.shift()?.() + addMessage(instanceId, hiddenSessionId) + callbacks.shift()?.() + + const store = messageStoreBus.getOrCreate(instanceId) + const initialLimit = store.getSessionApproximateByteSize(visibleSessionId) + store.getSessionApproximateByteSize(hiddenSessionId) + assert.deepEqual(runSessionMemorySweep(initialLimit), []) + + addMessage(instanceId, visibleSessionId, "complete", "x".repeat(100_000)) + callbacks.shift()?.() + assert.deepEqual(runSessionMemorySweep(initialLimit), [`${instanceId}\u0000${hiddenSessionId}`]) + } finally { + ;(globalThis as any).requestIdleCallback = originalRequestIdleCallback + ;(globalThis as any).cancelIdleCallback = originalCancelIdleCallback + setVisibleSessionMemory(instanceId, visibleSessionId, false) + messageStoreBus.unregisterInstance(instanceId) + } +}) + test("idle metadata cannot evict a normalized streaming message", () => { const instanceId = "memory-idle-streaming", sessionId = "session" try { diff --git a/packages/ui/src/stores/session-memory.ts b/packages/ui/src/stores/session-memory.ts index bb0dad672..70bcd1b5d 100644 --- a/packages/ui/src/stores/session-memory.ts +++ b/packages/ui/src/stores/session-memory.ts @@ -8,9 +8,38 @@ const log = getLogger("session") const SWEEP_DELAY_MS = 1_000 const touched = new Map() const visibleLeases = new Map() +const measuredBytes = new Map() +const pendingMeasurements = new Map }>() let sequence = 0 let sweepTimer: ReturnType | undefined +function cancelSessionMeasurement(key: string): void { + const pending = pendingMeasurements.get(key) + if (!pending) return + if (pending.idle) (globalThis as any).cancelIdleCallback?.(pending.handle) + else clearTimeout(pending.handle) + pendingMeasurements.delete(key) +} + +function scheduleSessionMeasurement(instanceId: string, sessionId: string): void { + const key = sessionKey(instanceId, sessionId) + if (pendingMeasurements.has(key)) return + const measure = () => { + pendingMeasurements.delete(key) + const store = messageStoreBus.getInstance(instanceId) + if (!store?.getResidentSessionIds().includes(sessionId)) return + measuredBytes.set(key, store.getSessionApproximateByteSize(sessionId)) + scheduleSessionMemorySweep() + } + if (typeof (globalThis as any).requestIdleCallback === "function") { + const handle = (globalThis as any).requestIdleCallback(measure, { timeout: 2_000 }) as number + pendingMeasurements.set(key, { idle: true, handle }) + } else { + const handle = setTimeout(measure, 50) + pendingMeasurements.set(key, { idle: false, handle }) + } +} + function sessionKey(instanceId: string, sessionId: string): string { return `${instanceId}\u0000${sessionId}` } @@ -73,13 +102,21 @@ export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES) for (const sessionId of store.getResidentSessionIds()) { const key = sessionKey(instanceId, sessionId) const status = sessions().get(instanceId)?.get(sessionId)?.status + const protectedSession = visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId) || + isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId) || + isSessionMessageCacheWritePending(instanceId, sessionId) + let byteSize = measuredBytes.get(key) + if (!protectedSession) { + byteSize = store.getSessionApproximateByteSize(sessionId) + measuredBytes.set(key, byteSize) + } + byteSize ??= byteLimit entries.push({ key, - byteSize: store.getSessionApproximateByteSize(sessionId), + // ponytail: reuse the last full measurement while a protected session is changing rapidly. + byteSize, lastTouched: touched.get(key) ?? 0, - protected: visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId) || - isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId) || - isSessionMessageCacheWritePending(instanceId, sessionId), + protected: protectedSession, }) } } @@ -94,6 +131,7 @@ export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES) messageStoreBus.onSessionChanged((instanceId, sessionId) => { touched.set(sessionKey(instanceId, sessionId), ++sequence) + scheduleSessionMeasurement(instanceId, sessionId) scheduleSessionMemorySweep() }) @@ -101,12 +139,16 @@ messageStoreBus.onSessionCleared((instanceId, sessionId) => { const key = sessionKey(instanceId, sessionId) touched.delete(key) visibleLeases.delete(key) + measuredBytes.delete(key) + cancelSessionMeasurement(key) }) messageStoreBus.onInstanceDestroyed((instanceId) => { const prefix = `${instanceId}\u0000` for (const key of touched.keys()) if (key.startsWith(prefix)) touched.delete(key) for (const key of visibleLeases.keys()) if (key.startsWith(prefix)) visibleLeases.delete(key) + for (const key of measuredBytes.keys()) if (key.startsWith(prefix)) measuredBytes.delete(key) + for (const key of pendingMeasurements.keys()) if (key.startsWith(prefix)) cancelSessionMeasurement(key) }) setSessionMessageCacheWriteSettledCallback(scheduleSessionMemorySweep) diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts index 99ca972b2..849e62996 100644 --- a/packages/ui/src/stores/session-message-cache.ts +++ b/packages/ui/src/stores/session-message-cache.ts @@ -25,9 +25,7 @@ const WRITE_DEBOUNCE_MS = 500 function disablePersistentCacheAfterInvalidationFailure(context: Record): void { markSessionMessageCacheUnsafe(true) setSessionMessageCacheEnabled(false) - void import("./client-state") - .then(({ setRestorePreviousStateEnabled }) => setRestorePreviousStateEnabled(false)) - .catch((error) => log.warn("Failed to persist disabled session cache state", { ...context, error })) + log.warn("Disabled persistent session message cache after invalidation failure", context) } const pendingWrites = new Map>() const cacheGenerations = new Map() @@ -384,6 +382,7 @@ export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: } export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { + if (!isSessionMessageCacheEnabled()) return const key = pendingKey(instanceId, sessionId) cancelCachedSessionMessageRestore(instanceId, sessionId) const pending = pendingWrites.get(key) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 666841e4d..43081db96 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -3,7 +3,7 @@ import { describe, it } from "node:test" import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" -import { addInstance, instances, isInstanceRuntimeCurrent, removeInstance, updateInstance } from "./instances.ts" +import { addInstance, instances, isInstanceRuntimeCurrent, removeInstance, setActiveInstanceId, updateInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" import { fetchSessions, loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" import { @@ -12,6 +12,7 @@ import { invalidateSessionMessageLoad, loading, messagesLoaded, + setActiveSession, sessions, setSessions, } from "./session-state.ts" @@ -167,6 +168,141 @@ describe("session request authority", () => { } }) + it("cancels the previous session load when selection moves to another session", async () => { + const instanceId = "switched-message-load", firstSessionId = "first", secondSessionId = "second" + const { client, cleanup } = setup(instanceId) + const firstRequestStarted = deferred() + let firstSignal: AbortSignal | undefined + ;(client.session as any).messages = ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { + if (sessionID !== firstSessionId) return Promise.resolve({ data: [apiMessage("second-message", secondSessionId)] }) + firstSignal = options?.signal + firstRequestStarted.resolve() + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }) + }) + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [firstSessionId, session(instanceId, firstSessionId)], + [secondSessionId, session(instanceId, secondSessionId)], + ]))) + + try { + setActiveSession(instanceId, firstSessionId) + const request = loadMessages(instanceId, firstSessionId) + await firstRequestStarted.promise + setActiveSession(instanceId, secondSessionId) + assert.equal(firstSignal?.aborted, true) + await loadMessages(instanceId, secondSessionId) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(secondSessionId), ["second-message"]) + + await request + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(firstSessionId), []) + assert.equal(messagesLoaded().get(instanceId)?.has(firstSessionId) ?? false, false) + assert.equal(loading().loadingMessages.get(instanceId)?.has(firstSessionId) ?? false, false) + } finally { + cleanup() + } + }) + + it("cancels child loads owned by the session being left", async () => { + const instanceId = "switched-child-load", parentSessionId = "parent", childSessionId = "child" + const { client, cleanup } = setup(instanceId) + const started = deferred() + let childSignal: AbortSignal | undefined + ;(client.session as any).messages = (_parameters: unknown, options?: { signal?: AbortSignal }) => { + childSignal = options?.signal + started.resolve() + return new Promise((_resolve, reject) => options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + )) + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [parentSessionId, session(instanceId, parentSessionId)], + [childSessionId, session(instanceId, childSessionId, parentSessionId)], + ["next", session(instanceId, "next")], + ]))) + + try { + setActiveSession(instanceId, parentSessionId) + const request = loadMessages(instanceId, childSessionId) + await started.promise + setActiveSession(instanceId, "next") + assert.equal(childSignal?.aborted, true) + await request + assert.equal(loading().loadingMessages.get(instanceId)?.has(childSessionId) ?? false, false) + } finally { + cleanup() + } + }) + + it("cancels a selected child load through its root ownership", async () => { + const instanceId = "selected-child-load", parentSessionId = "parent", childSessionId = "child" + const { client, cleanup } = setup(instanceId) + const started = deferred() + let childSignal: AbortSignal | undefined + ;(client.session as any).messages = (_parameters: unknown, options?: { signal?: AbortSignal }) => { + childSignal = options?.signal + started.resolve() + return new Promise((_resolve, reject) => options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + )) + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [parentSessionId, session(instanceId, parentSessionId)], + [childSessionId, session(instanceId, childSessionId, parentSessionId)], + ["next", session(instanceId, "next")], + ]))) + + try { + setActiveSession(instanceId, childSessionId) + const request = loadMessages(instanceId, childSessionId) + await started.promise + setActiveSession(instanceId, "next") + assert.equal(childSignal?.aborted, true) + await request + } finally { + cleanup() + } + }) + + it("cancels child loads when their workspace loses visibility", async () => { + const instanceId = "hidden-child-load", parentSessionId = "parent", childSessionId = "child" + const { client, cleanup } = setup(instanceId) + const started = deferred() + let childSignal: AbortSignal | undefined + ;(client.session as any).messages = (_parameters: unknown, options?: { signal?: AbortSignal }) => { + childSignal = options?.signal + started.resolve() + return new Promise((_resolve, reject) => options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + )) + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [parentSessionId, session(instanceId, parentSessionId)], + [childSessionId, session(instanceId, childSessionId, parentSessionId)], + ]))) + + try { + setActiveInstanceId(instanceId) + setActiveSession(instanceId, parentSessionId) + const request = loadMessages(instanceId, childSessionId) + await started.promise + setActiveInstanceId("another-instance") + assert.equal(childSignal?.aborted, true) + await request + } finally { + setActiveInstanceId(null) + cleanup() + } + }) + it("rejects message hydration after in-place client replacement", async () => { const instanceId = "replaced-message-client", sessionId = "session" const { client, cleanup } = setup(instanceId) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 2d5b637b8..743624235 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -100,6 +100,7 @@ const [messagesLoaded, setMessagesLoaded] = createSignal const [messageLoadErrors, setMessageLoadErrors] = createSignal>>(new Map()) const [sessionListErrors, setSessionListErrors] = createSignal>(new Map()) const messageLoadEpochs = new Map() +const messageLoadControllers = new Map() let nextMessageLoadEpoch = 0 const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) @@ -357,11 +358,34 @@ function clearLoadedFlag(instanceId: string, sessionId: string) { function advanceMessageLoadEpoch(instanceId: string, sessionId: string): number { const key = getDraftKey(instanceId, sessionId) + messageLoadControllers.get(key)?.controller.abort() + messageLoadControllers.delete(key) const epoch = ++nextMessageLoadEpoch messageLoadEpochs.set(key, epoch) return epoch } +function beginSessionMessageLoad(instanceId: string, sessionId: string): { epoch: number; signal: AbortSignal } { + const epoch = advanceMessageLoadEpoch(instanceId, sessionId) + const controller = new AbortController() + const ownerId = getSessionRoot(instanceId, sessionId)?.id ?? sessionId + messageLoadControllers.set(getDraftKey(instanceId, sessionId), { epoch, ownerSessionId: ownerId, controller }) + return { epoch, signal: controller.signal } +} + +function finishSessionMessageLoad(instanceId: string, sessionId: string, epoch: number): void { + const key = getDraftKey(instanceId, sessionId) + if (messageLoadControllers.get(key)?.epoch === epoch) messageLoadControllers.delete(key) +} + +function invalidateOwnedSessionMessageLoads(instanceId: string, ownerSessionId: string): void { + const prefix = `${instanceId}:` + const sessionIds = [...messageLoadControllers] + .filter(([key, load]) => key.startsWith(prefix) && load.ownerSessionId === ownerSessionId) + .map(([key]) => key.slice(prefix.length)) + for (const sessionId of sessionIds) invalidateSessionMessageLoad(instanceId, sessionId) +} + function isCurrentMessageLoad(instanceId: string, sessionId: string, epoch: number): boolean { return messageLoadEpochs.get(getDraftKey(instanceId, sessionId)) === epoch } @@ -744,6 +768,10 @@ function writeSessionSelection( } function writeActiveSession(instanceId: string, sessionId: string | null): void { + const previousSessionId = activeSessionId().get(instanceId) + if (previousSessionId && previousSessionId !== sessionId) { + invalidateOwnedSessionMessageLoads(instanceId, getSessionRoot(instanceId, previousSessionId)?.id ?? previousSessionId) + } writeSessionSelection(setActiveSessionId, instanceId, sessionId) if (sessionId) { // Backfill authoritative Yolo state for the now-active session so the badge @@ -1270,6 +1298,11 @@ function purgeInstanceSessionState(instanceId: string): void { setAuthoritativeSessionExpansionKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) }) for (const key of generationAdmissions.keys()) if (key.startsWith(prefix)) generationAdmissions.delete(key) + for (const [key, load] of messageLoadControllers) { + if (!key.startsWith(prefix)) continue + load.controller.abort() + messageLoadControllers.delete(key) + } for (const key of messageLoadEpochs.keys()) if (key.startsWith(prefix)) messageLoadEpochs.delete(key) } @@ -1289,6 +1322,9 @@ export { getSessionListError, setSessionListError, advanceMessageLoadEpoch, + beginSessionMessageLoad, + finishSessionMessageLoad, + invalidateOwnedSessionMessageLoads, isCurrentMessageLoad, invalidateSessionMessageLoad, setSessionMessagesLoadError, From 9e11ff5225e7b91090e4bcc328285d2a383319de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 1 Aug 2026 10:37:30 +0200 Subject: [PATCH 06/16] ci(windows): serialize Tauri tests Run the Windows Tauri crate tests with a single test thread so process-election and registration-timeout tests do not contend for scheduler time and fail nondeterministically on shared runners. The same 85-test suite passes locally in serial mode, including 20 repeated isolated executions of the previously flaky simultaneous-startup election case. Linux test parallelism is unchanged. --- .github/workflows/pr-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index f5df0b5c3..7fd30b774 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -188,4 +188,4 @@ jobs: - name: Test Tauri crate on Windows working-directory: packages/tauri-app/src-tauri - run: cargo test --locked + run: cargo test --locked -- --test-threads=1 From 59b415c01c0e5d19040b8bfe0963781b6ca08f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 2 Aug 2026 10:28:36 +0200 Subject: [PATCH 07/16] refactor(ui): remove unused transcript cache Keep PR #626 focused on renderer memory safety by deleting the disabled IndexedDB transcript cache, its progressive restore path, cache mutation hooks, and cache-specific tests. Evicted sessions now always reload their complete authoritative history from OpenCode, preserving CodeNomad's existing search, timeline, usage, revert, and scroll semantics. Retain whole-session byte-budget eviction, bounded renderer work, runtime and request fencing, state purge, and desktop UI-state restoration. Keep revision retries cancellable while a session is hidden and suppress stale workspace-resolution failures before they can repopulate or report against a replaced runtime. Validated with 202 targeted UI tests, 118 Electron native tests, UI and Electron typechecks, the production UI build, and a focused hidden-retry regression test. --- .github/workflows/pr-build.yml | 1 - .../ui/src/components/message-section.tsx | 2 - .../src/components/session/session-view.tsx | 3 - .../ui/src/components/virtual-follow-list.tsx | 2 - packages/ui/src/lib/hooks/use-commands.ts | 4 - .../ui/src/lib/i18n/messages/de/settings.ts | 2 +- .../ui/src/lib/i18n/messages/en/settings.ts | 2 +- .../ui/src/lib/i18n/messages/es/settings.ts | 2 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 2 +- .../ui/src/lib/i18n/messages/he/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 2 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 +- .../ui/src/lib/session-message-cache.test.ts | 84 --- packages/ui/src/lib/session-message-cache.ts | 522 ------------------ packages/ui/src/stores/client-state.test.ts | 80 --- packages/ui/src/stores/client-state.ts | 80 +-- packages/ui/src/stores/message-v2/bridge.ts | 32 -- .../stores/message-v2/instance-store.test.ts | 13 - .../src/stores/message-v2/instance-store.ts | 57 -- packages/ui/src/stores/session-actions.ts | 8 - packages/ui/src/stores/session-api.ts | 131 +---- packages/ui/src/stores/session-events.ts | 25 +- packages/ui/src/stores/session-memory.ts | 9 +- .../ui/src/stores/session-message-cache.ts | 493 ----------------- .../stores/session-request-authority.test.ts | 36 ++ 27 files changed, 70 insertions(+), 1530 deletions(-) delete mode 100644 packages/ui/src/lib/session-message-cache.test.ts delete mode 100644 packages/ui/src/lib/session-message-cache.ts delete mode 100644 packages/ui/src/stores/session-message-cache.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 7fd30b774..6ccd41537 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -110,7 +110,6 @@ jobs: packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/session-memory-budget.test.ts - packages/ui/src/lib/session-message-cache.test.ts packages/ui/src/lib/session-search.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 693e2c423..b964e9d35 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -11,7 +11,6 @@ import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./vir import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" -import { isRestoringCachedSessionMessages } from "../stores/session-message-cache" import { useI18n } from "../lib/i18n" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" @@ -1438,7 +1437,6 @@ export default function MessageSection(props: MessageSectionProps) { getAnchorId={getMessageAnchorId} overscanPx={800} streamingActive={streamingActive} - shift={() => isRestoringCachedSessionMessages(props.instanceId, props.sessionId)} isActive={isActive} scrollToBottomOnActivate={() => false} initialScrollToBottom={() => false} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 9c11b232a..79ee3a081 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -23,7 +23,6 @@ import { SessionPreviewView } from "../session-preview-view" import { isSnapshotAutoFollowing } from "../virtual-follow-behavior" import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent" import { focusConversationStream } from "../focus-conversation" -import { invalidateSessionMessageCache } from "../../stores/session-message-cache" import { invalidateSessionMessageLoad } from "../../stores/session-state" const log = getLogger("session") @@ -426,7 +425,6 @@ export const SessionView: Component = (props) => { if (!instance || !instance.client) return try { - invalidateSessionMessageCache(props.instanceId, props.sessionId) await requestData( instance.client.session.revert({ sessionID: props.sessionId, @@ -437,7 +435,6 @@ export const SessionView: Component = (props) => { if (!isInstanceRuntimeCurrent(props.instanceId, instance)) return if (messageStore().getSessionRevert(props.sessionId)?.messageID !== messageId) { invalidateSessionMessageLoad(props.instanceId, props.sessionId) - invalidateSessionMessageCache(props.instanceId, props.sessionId) } const restoredText = getUserMessageText(messageId) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 86982c83d..8a7f17ad4 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -76,7 +76,6 @@ export interface VirtualFollowListProps { resolveAutoPinHoldElement?: HoldTargetElementResolver autoPinHoldTopThresholdPx?: number suspendAutoPinToBottom?: Accessor - shift?: Accessor renderBeforeItems?: Accessor renderOverlay?: Accessor scrollToTopAriaLabel?: Accessor @@ -756,7 +755,6 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollRef={scrollElement()} data={props.items()} bufferSize={props.overscanPx ?? 400} - shift={props.shift?.() ?? false} onScroll={handleScroll} > {(item, index) => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 5a095ebe4..807aebeef 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -21,7 +21,6 @@ import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" -import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("actions") @@ -255,7 +254,6 @@ export function useCommands(options: UseCommandsOptions) { }), "session.summarize", ) - invalidateSessionMessageCache(instance.id, sessionId) } catch (error) { log.error("Failed to compact session", error) const message = error instanceof Error ? error.message : tGlobal("commands.compactSession.errorFallback") @@ -335,7 +333,6 @@ export function useCommands(options: UseCommandsOptions) { } try { - invalidateSessionMessageCache(instance.id, sessionId) await requestData( instance.client.session.revert({ sessionID: sessionId, @@ -346,7 +343,6 @@ export function useCommands(options: UseCommandsOptions) { if (!isInstanceRuntimeCurrent(instance.id, instance)) return if (store.getSessionRevert(sessionId)?.messageID !== messageID) { invalidateSessionMessageLoad(instance.id, sessionId) - invalidateSessionMessageCache(instance.id, sessionId) } if (!restoredText) { diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 4249b8172..606b2d6bf 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen und das Layout wiederherstellen.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", - "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", + "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. Projektdateien und OpenCode-Unterhaltungen werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", "settings.appearance.startup.clearSuccess": "Gespeicherter Startzustand wurde gelöscht.", "settings.appearance.startup.clearError": "Der gespeicherte Startzustand konnte nicht gelöscht werden.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 5cfa71d1a..6161618c9 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "Restore previous state", "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore the layout.", "settings.appearance.startup.clear.title": "Saved startup state", - "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", + "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. Project files and OpenCode conversations are not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", "settings.appearance.startup.clearSuccess": "Saved startup state cleared.", "settings.appearance.startup.clearError": "Could not clear the saved startup state.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index e4f5aa222..06582f9d0 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "Restaurar el estado anterior", "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, y restaurar el diseño.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", - "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", + "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan los archivos del proyecto ni las conversaciones de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", "settings.appearance.startup.clearSuccess": "Se borró el estado de inicio guardado.", "settings.appearance.startup.clearError": "No se pudo borrar el estado de inicio guardado.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index a8918fab4..4819dccc2 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "Restaurer l’état précédent", "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, et restaurer la disposition.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", - "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", + "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les fichiers du projet et les conversations OpenCode ne sont pas supprimés.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", "settings.appearance.startup.clearSuccess": "L’état de démarrage enregistré a été effacé.", "settings.appearance.startup.clearError": "Impossible d’effacer l’état de démarrage enregistré.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index d532e45bd..f66c82637 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "שחזור המצב הקודם", "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, ושחזור הפריסה.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", - "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", + "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. קובצי הפרויקט ושיחות OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", "settings.appearance.startup.clearSuccess": "מצב ההפעלה השמור נוקה.", "settings.appearance.startup.clearError": "לא ניתן לנקות את מצב ההפעלה השמור.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 05bf1e01d..f0cf76184 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "前回の状態を復元", "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元します。", "settings.appearance.startup.clear.title": "保存された起動状態", - "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", + "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。プロジェクトファイルと OpenCode の会話は削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", "settings.appearance.startup.clearSuccess": "保存された起動状態を消去しました。", "settings.appearance.startup.clearError": "保存された起動状態を消去できませんでした。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 62fab0875..d9ed7f4a1 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस् र लेआउट पुनर्स्थापना गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", - "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", + "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। परियोजना फाइलहरू र OpenCode संवादहरू मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", "settings.appearance.startup.clearSuccess": "सुरक्षित सुरुआत अवस्था खाली गरियो।", "settings.appearance.startup.clearError": "सुरक्षित सुरुआत अवस्था खाली गर्न सकिएन।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 89e047c17..2bcce10d6 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения и восстанавливать макет.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", - "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", + "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Файлы проекта и разговоры OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", "settings.appearance.startup.clearSuccess": "Сохранённое состояние запуска очищено.", "settings.appearance.startup.clearError": "Не удалось очистить сохранённое состояние запуска.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index ffb121a13..2a35480b5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -131,7 +131,7 @@ export const settingsMessages = { "settings.appearance.startup.restore.title": "恢复上次状态", "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,并恢复布局。", "settings.appearance.startup.clear.title": "已保存的启动状态", - "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", + "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除项目文件或 OpenCode 对话。", "settings.appearance.startup.clear.action": "清除已保存状态", "settings.appearance.startup.clearSuccess": "已清除保存的启动状态。", "settings.appearance.startup.clearError": "无法清除保存的启动状态。", diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts deleted file mode 100644 index 83e5b8596..000000000 --- a/packages/ui/src/lib/session-message-cache.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { - createSessionMessageCacheKey, - clearSessionMessageCache, - isSessionMessageCacheUnsafe, - markSessionMessageCacheUnsafe, - prepareSessionMessageCache, - selectSessionMessageCacheEvictions, -} from "./session-message-cache.ts" - -test("session message cache keys normalize path separators", () => { - assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") -}) - -test("session message cache evicts the oldest entries to satisfy byte and count limits", () => { - const entries = [ - { key: "old", byteSize: 5, savedAt: 1, snapshotId: "1", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - { key: "middle", byteSize: 4, savedAt: 2, snapshotId: "2", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - { key: "new", byteSize: 3, savedAt: 3, snapshotId: "3", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - ] - assert.deepEqual(selectSessionMessageCacheEvictions(entries, 7, 3), ["old"]) - assert.deepEqual(selectSessionMessageCacheEvictions(entries, 20, 2), ["old"]) -}) - -test("session message cache retains the newest contiguous messages within its budget", () => { - const messages = ["one", "two", "three"].map((id) => ({ info: { id }, parts: [{ type: "text", text: id.repeat(10) }] })) - const byteLimit = messages.slice(1).reduce((total, message) => total + JSON.stringify(message).length * 2, 0) - const prepared = prepareSessionMessageCache("session", messages, "snapshot", byteLimit, 1) - assert.deepEqual(prepared?.manifest.messageIds, ["two", "three"]) - assert.equal(prepared?.manifest.startIndex, 1) - assert.equal(prepared?.manifest.complete, false) - assert.equal(prepared?.records.length, 2) -}) - -test("session message cache stores an authoritative empty manifest", () => { - const prepared = prepareSessionMessageCache("session", [], "snapshot", 100, 1) - assert.deepEqual(prepared?.manifest.messageIds, []) - assert.equal(prepared?.manifest.complete, true) - assert.equal(prepared?.manifest.totalCount, 0) -}) - -test("session message cache persists an unsafe marker until a successful clear", async () => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") - const values = new Map() - const localStorage = { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => values.set(key, value), - removeItem: (key: string) => values.delete(key), - } - try { - Object.defineProperty(globalThis, "window", { configurable: true, value: { localStorage } }) - markSessionMessageCacheUnsafe(true) - assert.equal(isSessionMessageCacheUnsafe(), true) - await clearSessionMessageCache() - assert.equal(isSessionMessageCacheUnsafe(), false) - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { localStorage: { ...localStorage, setItem: () => { throw new Error("denied") } } }, - }) - assert.doesNotThrow(() => markSessionMessageCacheUnsafe(true)) - } finally { - if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) - else delete (globalThis as any).window - } -}) - -test("session message cache preserves the source range of a pre-truncated tail", () => { - const messages = ["six", "seven", "eight"].map((id) => ({ info: { id }, parts: [] })) - const prepared = prepareSessionMessageCache("session", messages, "snapshot", 10_000, 1, { - startIndex: 5, - totalCount: 8, - }) - assert.equal(prepared?.manifest.startIndex, 5) - assert.equal(prepared?.manifest.totalCount, 8) - assert.equal(prepared?.manifest.complete, false) -}) - -test("session message cache preparation enforces the manifest message limit", () => { - const messages = Array.from({ length: 20_001 }, (_, index) => ({ info: { id: String(index) }, parts: [] })) - const prepared = prepareSessionMessageCache("session", messages, "snapshot", 16 * 1024 * 1024, 1) - assert.equal(prepared?.manifest.messageIds.length, 20_000) - assert.equal(prepared?.manifest.startIndex, 1) -}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts deleted file mode 100644 index 2ddef0dae..000000000 --- a/packages/ui/src/lib/session-message-cache.ts +++ /dev/null @@ -1,522 +0,0 @@ -const DATABASE_NAME = "codenomad-session-messages" -const DATABASE_VERSION = 2 -const UNSAFE_CACHE_STORAGE_KEY = "codenomad-session-message-cache-unsafe-v1" -const MANIFEST_STORE = "session-manifests" -const MESSAGE_STORE = "session-messages" -const SESSION_INDEX = "by-session" - -export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 -export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 -export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 -export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 -const MAX_SESSION_MESSAGE_CACHE_MESSAGES = 20_000 - -export interface SessionMessageCacheManifest { - key: string - snapshotId: string - messageIds: string[] - startIndex: number - totalCount: number - complete: boolean - byteSize: number - savedAt: number -} - -interface SessionMessageCacheRecord { - sessionKey: string - snapshotId: string - messageId: string - ordinal: number - payload: string -} - -export interface SessionMessageCacheCursor { - key: string - snapshotId: string - messageIds: readonly string[] - beforeIndex: number - startIndex: number - totalCount: number - complete: boolean - remainingByteSize: number -} - -export interface SessionMessageCachePage { - messages: unknown[] - startIndex: number - totalCount: number - done: boolean - complete: boolean -} - -interface PreparedSessionMessageCache { - manifest: SessionMessageCacheManifest - records: SessionMessageCacheRecord[] -} - -let databasePromise: Promise | null = null -let clearGeneration = 0 -let cacheEnabled = false -const resetListeners = new Set<() => void>() -let mutationQueue: Promise = Promise.resolve() - -function cacheStorage(): Storage | undefined { - try { - const storage = (globalThis as any).window?.localStorage ?? globalThis.localStorage - return storage && typeof storage.getItem === "function" && typeof storage.setItem === "function" && typeof storage.removeItem === "function" - ? storage - : undefined - } catch { - return undefined - } -} - -export function isSessionMessageCacheUnsafe(): boolean { - try { - return cacheStorage()?.getItem(UNSAFE_CACHE_STORAGE_KEY) === "1" - } catch { - return false - } -} - -export function markSessionMessageCacheUnsafe(unsafe: boolean): void { - try { - const storage = cacheStorage() - if (!storage) return - if (unsafe) storage.setItem(UNSAFE_CACHE_STORAGE_KEY, "1") - else storage.removeItem(UNSAFE_CACHE_STORAGE_KEY) - } catch { - // The native restore preference remains the durable fallback. - } -} - -export function isSessionMessageCacheEnabled(): boolean { - return cacheEnabled -} - -export function setSessionMessageCacheEnabled(enabled: boolean): void { - cacheEnabled = enabled - if (!enabled) resetListeners.forEach((listener) => listener()) -} - -export function onSessionMessageCacheReset(listener: () => void): () => void { - resetListeners.add(listener) - return () => resetListeners.delete(listener) -} - -function requestResult(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) - }) -} - -function transactionDone(transaction: IDBTransaction): Promise { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve() - transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")) - transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")) - }) -} - -function openDatabase(): Promise { - if (typeof indexedDB === "undefined") return Promise.resolve(null) - if (databasePromise) return databasePromise - - const opening = new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) - request.onupgradeneeded = () => { - const database = request.result - for (const legacy of ["payloads", "metadata"]) { - if (database.objectStoreNames.contains(legacy)) database.deleteObjectStore(legacy) - } - if (!database.objectStoreNames.contains(MANIFEST_STORE)) { - database.createObjectStore(MANIFEST_STORE, { keyPath: "key" }) - } - if (!database.objectStoreNames.contains(MESSAGE_STORE)) { - const store = database.createObjectStore(MESSAGE_STORE, { - keyPath: ["sessionKey", "snapshotId", "messageId"], - }) - store.createIndex(SESSION_INDEX, "sessionKey") - } - } - request.onsuccess = () => { - const database = request.result - database.onversionchange = () => { - database.close() - databasePromise = null - } - resolve(database) - } - request.onerror = () => reject(request.error ?? new Error("Failed to open session message cache")) - }) - databasePromise = opening.catch((error) => { - databasePromise = null - throw error - }) - return databasePromise -} - -function createSnapshotId(): string { - return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -export function createSessionMessageCacheKey(workspace: string, sessionId: string): string { - return `${workspace.replace(/\\/g, "/").replace(/\/+$/, "")}\u0000${sessionId}` -} - -function messageIdOf(value: unknown): string | null { - if (!value || typeof value !== "object") return null - const candidate = value as { info?: { id?: unknown }; id?: unknown } - const id = candidate.info?.id ?? candidate.id - return typeof id === "string" && id.length > 0 ? id : null -} - -function messageSessionIdOf(value: unknown): string | null { - if (!value || typeof value !== "object") return null - const candidate = value as { info?: { sessionID?: unknown }; sessionId?: unknown } - const id = candidate.info?.sessionID ?? candidate.sessionId - return typeof id === "string" && id.length > 0 ? id : null -} - -function sessionIdFromKey(key: string): string { - return key.slice(key.lastIndexOf("\u0000") + 1) -} - -function isValidManifest(value: unknown, expectedKey: string): value is SessionMessageCacheManifest { - if (!value || typeof value !== "object") return false - const manifest = value as SessionMessageCacheManifest - return manifest.key === expectedKey && - typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && - Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && - manifest.messageIds.length <= MAX_SESSION_MESSAGE_CACHE_MESSAGES && - new Set(manifest.messageIds).size === manifest.messageIds.length && - Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && - Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && - manifest.complete === (manifest.startIndex === 0) && - Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && manifest.byteSize <= MAX_SESSION_MESSAGE_CACHE_BYTES && - Number.isFinite(manifest.savedAt) -} - -function exceedsJsonByteLimit(value: unknown, limit: number): boolean { - const seen = new WeakSet() - let bytes = 0 - const addString = (text: string) => { - bytes += 4 - for (let index = 0; index < text.length && bytes <= limit; index += 1) { - const code = text.charCodeAt(index) - bytes += code < 0x20 || (code >= 0xd800 && code <= 0xdfff) ? 12 : code === 0x22 || code === 0x5c ? 4 : 2 - } - } - const visit = (current: unknown, depth: number): void => { - if (bytes > limit || depth > 100) { - bytes = limit + 1 - return - } - if (typeof current === "string") addString(current) - else if (typeof current === "number" || typeof current === "bigint") bytes += String(current).length * 2 - else if (typeof current === "boolean") bytes += current ? 8 : 10 - else if (current === null) bytes += 8 - else if (Array.isArray(current)) { - if (seen.has(current)) { bytes = limit + 1; return } - seen.add(current) - bytes += 4 + Math.max(0, current.length - 1) * 2 - for (const item of current) visit(item === undefined ? null : item, depth + 1) - } else if (current && typeof current === "object") { - if (seen.has(current)) { bytes = limit + 1; return } - seen.add(current) - bytes += 4 - let count = 0 - for (const key in current) { - if (!Object.prototype.hasOwnProperty.call(current, key)) continue - const item = (current as Record)[key] - if (item === undefined || typeof item === "function" || typeof item === "symbol") continue - if (count++ > 0) bytes += 2 - addString(key) - bytes += 2 - visit(item, depth + 1) - if (bytes > limit) break - } - } - } - visit(value, 0) - return bytes > limit -} - -export function prepareSessionMessageCache( - key: string, - messages: readonly unknown[], - snapshotId = createSnapshotId(), - byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, - savedAt = Date.now(), - source = { startIndex: 0, totalCount: messages.length }, -): PreparedSessionMessageCache | null { - if (source.startIndex < 0 || source.totalCount !== source.startIndex + messages.length) return null - let byteSize = 0 - let startIndex = messages.length - const records: SessionMessageCacheRecord[] = [] - const seen = new Set() - try { - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (records.length >= MAX_SESSION_MESSAGE_CACHE_MESSAGES) break - const messageId = messageIdOf(messages[index]) - if (!messageId || seen.has(messageId)) return null - seen.add(messageId) - const remaining = byteLimit - byteSize - if (exceedsJsonByteLimit(messages[index], remaining)) break - const payload = JSON.stringify(messages[index]) - const payloadBytes = payload.length * 2 - if (payloadBytes > byteLimit || byteSize + payloadBytes > byteLimit) break - byteSize += payloadBytes - startIndex = index - records.push({ - sessionKey: key, - snapshotId, - messageId, - ordinal: source.startIndex + index, - payload, - }) - } - } catch { - return null - } - if (messages.length > 0 && startIndex === messages.length) return null - records.reverse() - return { - manifest: { - key, - snapshotId, - messageIds: records.map((record) => record.messageId), - startIndex: source.startIndex + startIndex, - totalCount: source.totalCount, - complete: source.startIndex + startIndex === 0, - byteSize, - savedAt, - }, - records, - } -} - -export function selectSessionMessageCacheEvictions( - entries: readonly SessionMessageCacheManifest[], - byteLimit = MAX_TOTAL_MESSAGE_CACHE_BYTES, - entryLimit = MAX_SESSION_MESSAGE_CACHE_ENTRIES, -): string[] { - const oldestFirst = [...entries].sort((left, right) => left.savedAt - right.savedAt || left.key.localeCompare(right.key)) - let bytes = oldestFirst.reduce((total, entry) => total + entry.byteSize, 0) - let count = oldestFirst.length - const evictions: string[] = [] - for (const entry of oldestFirst) { - if (bytes <= byteLimit && count <= entryLimit) break - evictions.push(entry.key) - bytes -= entry.byteSize - count -= 1 - } - return evictions -} - -function enqueueMutation(operation: () => Promise): Promise { - const result = mutationQueue.then(operation) - mutationQueue = result.then(() => undefined, () => undefined) - return result -} - -async function readSessionRecordKeys(transaction: IDBTransaction, sessionKeys: readonly string[]): Promise> { - if (sessionKeys.length === 0) return new Map() - const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) - const requests = sessionKeys.map(async (sessionKey) => [ - sessionKey, - await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), - ] as const) - const entries = await Promise.all(requests) - return new Map(entries) -} - -export async function openSessionMessageCache( - workspace: string, - sessionId: string, -): Promise { - const generation = clearGeneration - const database = await openDatabase() - if (!database || generation !== clearGeneration) return null - const transaction = database.transaction(MANIFEST_STORE, "readonly") - const completion = transactionDone(transaction) - const key = createSessionMessageCacheKey(workspace, sessionId) - const manifest = await requestResult(transaction.objectStore(MANIFEST_STORE).get(key)) as unknown - await completion - if (generation !== clearGeneration) return null - if (!manifest) return null - if (!isValidManifest(manifest, key)) { - await deleteCorruptSessionMessageCacheKey(key, manifest) - return null - } - return { - key: manifest.key, - snapshotId: manifest.snapshotId, - messageIds: manifest.messageIds, - beforeIndex: manifest.messageIds.length, - startIndex: manifest.startIndex, - totalCount: manifest.totalCount, - complete: manifest.complete, - remainingByteSize: manifest.byteSize, - } -} - -export async function readSessionMessageCachePage( - cursor: SessionMessageCacheCursor, - limit = DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE, -): Promise<{ page: SessionMessageCachePage; cursor: SessionMessageCacheCursor } | null> { - if (cursor.beforeIndex <= 0) return null - const generation = clearGeneration - const database = await openDatabase() - if (!database || generation !== clearGeneration) return null - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readonly") - const completion = transactionDone(transaction) - const manifestRequest = transaction.objectStore(MANIFEST_STORE).get(cursor.key) - const start = Math.max(0, cursor.beforeIndex - Math.max(1, limit)) - const ids = cursor.messageIds.slice(start, cursor.beforeIndex) - const messageStore = transaction.objectStore(MESSAGE_STORE) - const requests = ids.map((messageId) => requestResult(messageStore.get([cursor.key, cursor.snapshotId, messageId]))) - const manifest = await requestResult(manifestRequest) as unknown - const records = await Promise.all(requests) as Array - await completion - if (generation !== clearGeneration) return null - if (!isValidManifest(manifest, cursor.key) || manifest.snapshotId !== cursor.snapshotId) return null - if (records.some((record) => !record)) { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } - - try { - const pageBytes = records.reduce((total, record) => total + record!.payload.length * 2, 0) - if (pageBytes > cursor.remainingByteSize) { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } - const messages = records.map((record) => JSON.parse(record!.payload)) - const sessionId = sessionIdFromKey(cursor.key) - if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } - const nextCursor = { ...cursor, beforeIndex: start, remainingByteSize: cursor.remainingByteSize - pageBytes } - return { - page: { - messages, - startIndex: cursor.startIndex + start, - totalCount: cursor.totalCount, - done: start === 0, - complete: cursor.complete, - }, - cursor: nextCursor, - } - } catch { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } -} - -export async function writeSessionMessageCache( - workspace: string, - sessionId: string, - messages: unknown[], - source?: { startIndex: number; totalCount: number }, -): Promise { - const generation = clearGeneration - const key = createSessionMessageCacheKey(workspace, sessionId) - const prepared = prepareSessionMessageCache(key, messages, undefined, undefined, undefined, source) - if (!prepared) { - await deleteSessionMessageCache(workspace, sessionId) - return false - } - return enqueueMutation(async () => { - const database = await openDatabase() - if (!database || generation !== clearGeneration) return false - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const manifestStore = transaction.objectStore(MANIFEST_STORE) - const messageStore = transaction.objectStore(MESSAGE_STORE) - const existing = await requestResult(manifestStore.getAll()) as unknown[] - const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { - const manifestKey = (manifest as { key?: unknown })?.key - return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) - }) - const invalidKeys = existing - .filter((manifest) => !validExisting.includes(manifest as SessionMessageCacheManifest)) - .map((manifest) => (manifest as { key?: unknown })?.key) - .filter((manifestKey): manifestKey is string => typeof manifestKey === "string" && manifestKey !== key) - prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) - const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] - const evictions = selectSessionMessageCacheEvictions(projected) - const recordKeys = await readSessionRecordKeys(transaction, [key, ...evictions, ...invalidKeys]) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - for (const record of prepared.records) messageStore.put(record) - manifestStore.put(prepared.manifest) - for (const evictedKey of evictions) { - for (const recordKey of recordKeys.get(evictedKey) ?? []) messageStore.delete(recordKey) - manifestStore.delete(evictedKey) - } - for (const invalidKey of invalidKeys) { - for (const recordKey of recordKeys.get(invalidKey) ?? []) messageStore.delete(recordKey) - manifestStore.delete(invalidKey) - } - await completion - return true - }) -} - -export async function deleteSessionMessageCache(workspace: string, sessionId: string): Promise { - const key = createSessionMessageCacheKey(workspace, sessionId) - await deleteSessionMessageCacheKey(key) -} - -async function deleteSessionMessageCacheKey(key: string): Promise { - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const messageStore = transaction.objectStore(MESSAGE_STORE) - const recordKeys = await readSessionRecordKeys(transaction, [key]) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - transaction.objectStore(MANIFEST_STORE).delete(key) - await completion - }) -} - -async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknown): Promise { - const observedSnapshotId = (observed as { snapshotId?: unknown })?.snapshotId - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const manifestStore = transaction.objectStore(MANIFEST_STORE) - const messageStore = transaction.objectStore(MESSAGE_STORE) - const current = await requestResult(manifestStore.get(key)) as unknown - if (!current) return - if (typeof observedSnapshotId === "string") { - if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return - } else if (isValidManifest(current, key)) { - return - } - const recordKeys = await readSessionRecordKeys(transaction, [key]) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - manifestStore.delete(key) - await completion - }) -} - -export async function clearSessionMessageCache(): Promise { - clearGeneration += 1 - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - transaction.objectStore(MANIFEST_STORE).clear() - transaction.objectStore(MESSAGE_STORE).clear() - await completion - }) - markSessionMessageCacheUnsafe(false) -} diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index c066c5c58..882a92348 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -34,12 +34,6 @@ const boot = async (api?: NativeApi, storage?: MemoryStorage) => { const transact = (state: ClientState, kind: TransactionKind) => kind === "clear" ? state.clearRestoredClientState() : state.setRestorePreviousStateEnabled(false) describe("client state ownership and persistence", () => { - it("keeps persistent message caching disabled during desktop restore", async () => { - const cache = await import("../lib/session-message-cache.ts") - cache.setSessionMessageCacheEnabled(true) - await boot({ loadClientState: async () => loadResult(snapshot("project")) }) - assert.equal(cache.isSessionMessageCacheEnabled(), false) - }) it("treats a rejected access claim as secondary without loading", async () => { let loads = 0 const state = await boot({ @@ -121,67 +115,8 @@ describe("failed destructive transactions", () => { it(`${kind}: rolls back and persists mutations buffered during delayed failure`, () => destructiveContract(kind, false)) it(`${kind}: preserves retry dirt from an in-flight failed save`, () => destructiveContract(kind, true)) } - for (const kind of ["clear", "disable"] as const) { - it(`${kind}: retries only cache clearing after native acceptance`, async () => { - const originalIndexedDb = Object.getOwnPropertyDescriptor(globalThis, "indexedDB") - let nativeCalls = 0 - try { - Object.defineProperty(globalThis, "indexedDB", { - configurable: true, - value: { - open: () => { - const request: any = { error: new Error("cache clear failed") } - queueMicrotask(() => request.onerror?.()) - return request - }, - }, - }) - const state = await boot({ - loadClientState: async () => loadResult(), - clearClientState: async () => { nativeCalls += 1; return true }, - setClientStateRestoreEnabled: async (_token, enabled) => { if (!enabled) nativeCalls += 1; return true }, - }) - await assert.rejects(transact(state, kind), /cache clear failed/) - Object.defineProperty(globalThis, "indexedDB", { configurable: true, value: undefined }) - await transact(state, kind) - assert.equal(nativeCalls, 1) - } finally { - if (originalIndexedDb) Object.defineProperty(globalThis, "indexedDB", originalIndexedDb) - else delete (globalThis as any).indexedDB - } - }) - } }) describe("future envelopes and clear races", () => { - it("keeps primary ownership when startup cache clearing must retry", async () => { - const originalIndexedDb = Object.getOwnPropertyDescriptor(globalThis, "indexedDB") - let restoreUpdates = 0 - try { - Object.defineProperty(globalThis, "indexedDB", { - configurable: true, - value: { - open: () => { - const request: any = { error: new Error("open failed") } - queueMicrotask(() => request.onerror?.()) - return request - }, - }, - }) - const state = await boot({ - loadClientState: async () => ({ isPrimary: true, restoreEnabled: false, snapshot: null }), - setClientStateRestoreEnabled: async (_token, enabled) => { if (enabled) restoreUpdates += 1; return true }, - }) - assert.equal(state.clientStateIsPrimary(), true) - Object.defineProperty(globalThis, "indexedDB", { configurable: true, value: undefined }) - await state.setRestorePreviousStateEnabled(true) - assert.equal(state.restorePreviousStateEnabled(), true) - assert.equal(restoreUpdates, 1) - } finally { - if (originalIndexedDb) Object.defineProperty(globalThis, "indexedDB", originalIndexedDb) - else delete (globalThis as any).indexedDB - } - }) - it("serializes overlapping clear and disable transactions without stranding writes", async () => { const clear = deferred(), disable = deferred() const clearStarted = deferred(), disableStarted = deferred() @@ -241,21 +176,6 @@ describe("future envelopes and clear races", () => { assert.equal(state.clientStateIsPrimary(), true) assert.equal(clears, 1) }) - it("keeps a known-unsafe cache disabled when native disable is rejected", async () => { - const cache = await import("../lib/session-message-cache.ts") - const state = await boot({ - loadClientState: async () => loadResult(), - setClientStateRestoreEnabled: async () => false, - }) - cache.markSessionMessageCacheUnsafe(true) - cache.setSessionMessageCacheEnabled(false) - try { - await assert.rejects(state.setRestorePreviousStateEnabled(false), /update was rejected/) - assert.equal(cache.isSessionMessageCacheEnabled(), false) - } finally { - cache.markSessionMessageCacheUnsafe(false) - } - }) it("blocks captures before an in-flight clear reaches the native host", async () => { const clear = deferred(); let saves = 0 const state = await boot({ diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index 24da371d6..b13376e95 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,11 +2,6 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" -import { - clearSessionMessageCache, - isSessionMessageCacheUnsafe, - setSessionMessageCacheEnabled, -} from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -29,13 +24,7 @@ let saveTimer: ReturnType | null = null let writeQueue: Promise = Promise.resolve() let destructiveQueue: Promise = Promise.resolve() let lastSaveError: unknown -let cacheClearPending = false -let pendingCacheClearOwner: "clear" | "disable" | null = null const transactionLayoutWrites = new Set() -function loseClientStateOwnership(): void { - setClientStateIsPrimary(false) - setSessionMessageCacheEnabled(false) -} function useLocalStorage(fallback: T, operation: (storage: Storage) => T): T { try { return operation(window.localStorage) @@ -104,7 +93,7 @@ function enqueuePendingSave(): Promise { const saveAttempt = writeQueue.then(async () => { try { if (!await saveNativeClientState(normalizedSnapshot)) { - loseClientStateOwnership() + setClientStateIsPrimary(false) throw new Error("Native client state save was rejected") } lastSaveError = undefined @@ -143,7 +132,7 @@ async function executeDestructiveTransaction(operation: () => Promise, retryDirty ||= dirty dirty = false if (!await operation()) { - if (loseOwnershipOnRejection) loseClientStateOwnership() + if (loseOwnershipOnRejection) setClientStateIsPrimary(false) throw new Error(rejectedMessage) } useLocalStorage(undefined, (storage) => { @@ -243,59 +232,27 @@ export async function flushClientState(): Promise { export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") - if (cacheClearPending && pendingCacheClearOwner === "clear") { - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null - return - } await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) - setSessionMessageCacheEnabled(false) - cacheClearPending = true - pendingCacheClearOwner = "clear" - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null }) } export async function setRestorePreviousStateEnabled(enabled: boolean): Promise { await runDestructiveTransition(async () => { - if (enabled === restorePreviousStateEnabled() && (!enabled || writeBlock === false) && !cacheClearPending && !isSessionMessageCacheUnsafe()) return + if (enabled === restorePreviousStateEnabled() && (!enabled || writeBlock === false)) return if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") if (enabled) { - if (cacheClearPending || isSessionMessageCacheUnsafe()) { - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null - } if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) - // ponytail: keep message caching off until restore no longer blocks the renderer on large sessions. - setSessionMessageCacheEnabled(false) return } setRestorePreviousStateEnabledSignal(false) - setSessionMessageCacheEnabled(false) - if (cacheClearPending && pendingCacheClearOwner === "disable") { - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null - return - } try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(false) throw error } - cacheClearPending = true - pendingCacheClearOwner = "disable" - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null }) } @@ -312,41 +269,14 @@ export function initializeClientState(): Promise { writeBlock = false transactionLayoutWrites.clear() initialized = true - if (!loaded.isPrimary || !loaded.restoreEnabled) { - setSessionMessageCacheEnabled(false) - if (loaded.isPrimary && !loaded.restoreEnabled) { - cacheClearPending = true - pendingCacheClearOwner = "disable" - try { - await clearSessionMessageCache() - cacheClearPending = false - pendingCacheClearOwner = null - } catch (error) { - console.warn("[client-state] failed to clear disabled session cache", error) - } - } - return - } - if (isSessionMessageCacheUnsafe()) { - setSessionMessageCacheEnabled(false) - cacheClearPending = true - pendingCacheClearOwner = null - try { - await clearSessionMessageCache() - cacheClearPending = false - } catch (error) { - console.warn("[client-state] failed to clear unsafe session cache", error) - return - } - } + if (!loaded.isPrimary || !loaded.restoreEnabled) return writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) - setSessionMessageCacheEnabled(false) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true - loseClientStateOwnership() + setClientStateIsPrimary(false) resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index d6ad1eda1..7e9345360 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -71,38 +71,6 @@ export function seedSessionMessagesV2( return true } -export function mergeCachedSessionMessagePageV2( - instanceId: string, - session: Session | SessionMetadata, - messages: Message[], - messageInfos: Map, - expectedRevision: number, -): number | null { - if (!session || !Array.isArray(messages)) return null - const store = messageStoreBus.getOrCreate(instanceId) - if (!canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return null - const metadata = resolveSessionMetadata(session) - if (!metadata) return null - store.addOrUpdateSession({ - id: metadata.id, - title: metadata.title, - parentId: metadata.parentId ?? null, - revert: (session as Session)?.revert ?? undefined, - }) - store.mergeCachedMessages(metadata.id, messages.map((message) => ({ - id: message.id, - sessionId: message.sessionId, - role: message.type, - status: normalizeStatus(message.status), - createdAt: message.timestamp, - updatedAt: message.timestamp, - parts: message.parts, - isEphemeral: false, - bumpRevision: false, - })), messageInfos.values()) - return store.getSessionRevision(metadata.id) -} - interface MessageInfoOptions { status?: MessageStatus bumpRevision?: boolean diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 17b60cb29..46d2b37bc 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -74,19 +74,6 @@ describe("message-v2 authoritative hydration", () => { assert.deepEqual(store.getSessionMessageIds("session-1"), []) }) - it("prepends older cache pages without overwriting live messages", () => { - const store = createInstanceMessageStore("instance-1") - store.mergeCachedMessages("session-1", [message("message-3"), message("message-4")]) - store.upsertMessage({ - ...message("message-4"), - parts: [{ id: "part-message-4", type: "text", text: "live", messageID: "message-4", sessionID: "session-1" }] as any, - }) - store.mergeCachedMessages("session-1", [message("message-1"), message("message-2"), message("message-4")]) - - assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-1", "message-2", "message-3", "message-4"]) - assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") - }) - it("removes stale parts when authoritative hydration returns an empty part list", () => { const store = createInstanceMessageStore("instance-1") store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index ceb1be391..a97afded8 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -221,7 +221,6 @@ export interface InstanceMessageStore { setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void - mergeCachedMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void applyPartDelta: (input: { @@ -576,61 +575,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } - function mergeCachedMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs) || inputs.length === 0) return - ensureSessionEntry(sessionId) - const existingIds = new Set(state.sessions[sessionId]?.messageIds ?? []) - const additions = inputs.filter((input) => !existingIds.has(input.id) && !state.messages[input.id]) - if (additions.length === 0) return - - const infoById = new Map() - if (infos) { - for (const info of infos) { - if (typeof info.id === "string") infoById.set(info.id, info) - } - } - const additionIds = additions.map((input) => input.id) - const now = Date.now() - const normalizedRecords: Record = {} - for (const input of additions) { - const normalizedParts = normalizeParts(input.id, input.parts) - const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input) - normalizedRecords[input.id] = { - id: input.id, - sessionId: input.sessionId, - role: input.role, - status: input.status, - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - isEphemeral: input.isEphemeral ?? false, - clientPromptDisplayMetadata, - revision: 0, - partIds: normalizedParts?.ids ?? [], - parts: normalizedParts?.map ?? {}, - } - setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) - } - - batch(() => { - setState("messages", (current) => ({ ...current, ...normalizedRecords })) - for (const messageId of additionIds) { - const info = infoById.get(messageId) - if (!info) continue - messageInfoCache.set(messageId, info) - setState("messageInfoVersion", messageId, (version = 0) => version + 1) - updateUsageWithInfo(info) - } - setState("sessions", sessionId, (session) => ({ - ...session, - messageIds: [...additionIds, ...(session?.messageIds ?? [])], - updatedAt: Date.now(), - })) - recomputeLastAssistantMessageId(sessionId) - Object.values(normalizedRecords).forEach((record) => maybeUpdateLatestTodoFromRecord(record)) - bumpSessionRevision(sessionId) - }) - } - function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -1444,7 +1388,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState, addOrUpdateSession, hydrateMessages, - mergeCachedMessages, upsertMessage, applyPartUpdate, applyPartDelta, diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index eef3664ca..add475c93 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,7 +12,6 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" -import { invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -106,7 +105,6 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } - invalidateSessionMessageCache(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -316,7 +314,6 @@ async function executeCustomCommand( const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") - invalidateSessionMessageCache(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -351,7 +348,6 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) if (!isInstanceRuntimeCurrent(instanceId, instance)) throw new Error("Instance no longer active") - invalidateSessionMessageCache(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -507,7 +503,6 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI const client = getRootClient(instanceId) const workspace = await getSessionWorkspacePayload(instanceId, sessionId) if (!isInstanceRuntimeCurrent(instanceId, instance)) return - invalidateSessionMessageCache(instanceId, sessionId) await requestData( client.part.delete({ @@ -521,7 +516,6 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI if (!isInstanceRuntimeCurrent(instanceId, instance)) return // Optimistic removal; SSE will also broadcast a part-removed event. - invalidateSessionMessageCache(instanceId, sessionId) removeMessagePartV2(instanceId, messageId, partId) updateSessionInfo(instanceId, sessionId) } @@ -536,7 +530,6 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s const client = getRootClient(instanceId) const workspace = await getSessionWorkspacePayload(instanceId, sessionId) if (!isInstanceRuntimeCurrent(instanceId, instance)) return - invalidateSessionMessageCache(instanceId, sessionId) // The SDK generator does not currently expose a typed method for deleting a message, // but the API is available at DELETE /session/:sessionID/message/:messageID. @@ -550,7 +543,6 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s if (!isInstanceRuntimeCurrent(instanceId, instance)) return // Optimistic removal; SSE will also broadcast a message-removed event. - invalidateSessionMessageCache(instanceId, sessionId) removeMessageV2(instanceId, messageId) updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index ebc9afad8..52918bddd 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -58,7 +58,7 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2, setSessionRevertV2 } from "./message-v2/bridge" +import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2, setSessionRevertV2 } from "./message-v2/bridge" import { clearPendingDeltasForMessage, clearPendingDeltasForSession, requestDeltaRecovery } from "./delta-buffer" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" @@ -86,14 +86,6 @@ import { isProjectSessionListComplete, } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" -import { - cacheAuthoritativeSessionMessages, - cancelCachedSessionMessageRestore, - clearCachedSessionMessageShift, - invalidateSessionMessageCache, - restoreCachedSessionMessagePages, -} from "./session-message-cache" -import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("api") const sessionListRequestIds = new Map() @@ -972,7 +964,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise - requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }, { signal: loadSignal }), "session.messages"), - ) - const apiOutcome = apiMessagesRequest.then( - (messages) => ({ ok: true as const, messages }), - (error) => ({ ok: false as const, error }), - ) - let outcome: Awaited | undefined - let failedOutcome: Extract, { ok: false }> | undefined - let restoredCache = false - let restoredCacheComplete = true - let restoredCacheFinished = false - - if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { - const pages = restoreCachedSessionMessagePages(instanceId, sessionId) - const iterator = pages[Symbol.asyncIterator]() - let cacheNext = iterator.next() - try { - while (!outcome) { - const winner = await Promise.race([ - failedOutcome - ? new Promise(() => undefined) - : apiOutcome.then((value) => ({ kind: "http" as const, value })), - cacheNext.then((value) => ({ kind: "cache" as const, value })), - ]) - if (winner.kind === "http") { - if (!winner.value.ok) { - failedOutcome = winner.value - continue - } - outcome = winner.value - cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) - void iterator.return?.(undefined).catch(() => undefined) - break - } - if (winner.value.done) { - outcome = await apiOutcome - break - } - cacheNext = iterator.next() - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { - cancelCachedSessionMessageRestore(instanceId, sessionId) - return - } - if (!isInstanceRuntimeCurrent(instanceId, instance)) return - const cached = adaptApiMessages(sessionId, winner.value.value.messages, "idle") - const revision = mergeCachedSessionMessagePageV2( - instanceId, - sessionForV2, - cached.messages, - cached.infos, - expectedRevision, - ) - if (revision === null) { - cancelCachedSessionMessageRestore(instanceId, sessionId) - void iterator.return?.(undefined).catch(() => undefined) - outcome = await apiOutcome - break - } - expectedRevision = revision - restoredCache = true - restoredCacheComplete &&= winner.value.value.complete - restoredCacheFinished ||= winner.value.value.done - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) - } - } catch (error) { - log.warn("Failed to restore cached session messages", { instanceId, sessionId, error }) - invalidateSessionMessageCache(instanceId, sessionId) - } - } - - outcome ??= failedOutcome ?? await apiOutcome - cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) - if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return - if (!outcome.ok && restoredCache && restoredCacheComplete && restoredCacheFinished) { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) ?? new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - log.warn("Using cached session messages after HTTP failure", { instanceId, sessionId, error: outcome.error }) - return + let apiMessages: any[] + try { + const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + apiMessages = await requestData( + client.session.messages({ sessionID: sessionId, ...workspacePayload }, { signal: loadSignal }), + "session.messages", + ) + } catch (error) { + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + throw error } - if (!outcome.ok) throw outcome.error - const apiMessages = outcome.messages if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return @@ -1326,11 +1239,6 @@ async function loadMessages( }) reconcilePendingPermissionsV2(instanceId, sessionId) reconcilePendingQuestionsV2(instanceId, sessionId) - if (restorePreviousStateEnabled() && !retryAfterRevisionConflict) { - void cacheAuthoritativeSessionMessages(instanceId, sessionId, store.getSessionRevision(sessionId)).catch((error) => - log.warn("Failed to persist authoritative session messages", { instanceId, sessionId, error }), - ) - } } @@ -1342,14 +1250,8 @@ async function loadMessages( } throw error } finally { - finishSessionMessageLoad(instanceId, sessionId, loadEpoch) - if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { - const clearShift = () => { - if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) - } - if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) - else setTimeout(clearShift, 0) - } + const retryPending = retryAfterRevisionConflict && (options?.revisionRetryCount ?? 0) < 2 + if (!retryPending) finishSessionMessageLoad(instanceId, sessionId, loadEpoch) if (isInstanceRuntimeCurrent(instanceId, instance) && isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } @@ -1363,7 +1265,10 @@ async function loadMessages( if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { if ((options?.revisionRetryCount ?? 0) < 2) { await new Promise((resolve) => setTimeout(resolve, 50)) - if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!isInstanceRuntimeCurrent(instanceId, instance) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { + finishSessionMessageLoad(instanceId, sessionId, loadEpoch) + return + } return loadMessages(instanceId, sessionId, { force: true, revisionRetryCount: (options?.revisionRetryCount ?? 0) + 1, diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 959dac621..598bfc1af 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -70,7 +70,7 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, messagesLoaded, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" @@ -98,8 +98,6 @@ import { import { messageStoreBus } from "./message-v2/bus" import type { InstanceMessageStore } from "./message-v2/instance-store" import { handleConversationAssistantPartUpdated } from "./conversation-speech" -import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache, scheduleSessionMessageCacheWrite } from "./session-message-cache" -import { restorePreviousStateEnabled } from "./client-state" import { scheduleSessionMemorySweep } from "./session-memory" const log = getLogger("sse") @@ -350,7 +348,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return - cancelCachedSessionMessageRestore(instanceId, sessionId) if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } @@ -392,11 +389,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes clearPendingDeltasForPart(instanceId, messageId, part.id) } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) - if (messagesLoaded().get(instanceId)?.has(sessionId)) { - scheduleSessionMessageCacheWrite(instanceId, sessionId) - } else { - invalidateSessionMessageCache(instanceId, sessionId) - } handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) if (part.type === "tool") { @@ -414,7 +406,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return - cancelCachedSessionMessageRestore(instanceId, sessionId) // Flush any pending deltas for this message before applying the update. // Deltas are buffered for up to 50ms; if message.updated arrives before @@ -476,15 +467,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) updateSessionInfo(instanceId, sessionId) - if ( - restorePreviousStateEnabled() && - messagesLoaded().get(instanceId)?.has(sessionId) && - (status === "complete" || status === "error") - ) { - scheduleSessionMessageCacheWrite(instanceId, sessionId) - } else { - invalidateSessionMessageCache(instanceId, sessionId) - } } } @@ -541,7 +523,6 @@ function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent const sessionId = props.sessionID ?? messageStoreBus.getInstance(instanceId)?.getMessage(messageID)?.sessionId if (sessionId) { if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return - cancelCachedSessionMessageRestore(instanceId, sessionId) } enqueueDelta(instanceId, messageID, partID, field, delta, sessionId) } @@ -563,7 +544,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo ) if (revertChanged) { invalidateSessionMessageLoad(instanceId, info.id) - invalidateSessionMessageCache(instanceId, info.id) } if (!existingSession) { @@ -729,7 +709,6 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) - invalidateSessionMessageCache(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) @@ -780,7 +759,6 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) clearPendingDeltasForMessage(instanceId, messageID) - invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -792,7 +770,6 @@ function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedE log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) clearPendingDeltasForPart(instanceId, messageID, partID) - invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-memory.ts b/packages/ui/src/stores/session-memory.ts index 70bcd1b5d..06d3f3c52 100644 --- a/packages/ui/src/stores/session-memory.ts +++ b/packages/ui/src/stores/session-memory.ts @@ -1,7 +1,6 @@ import { getLogger } from "../lib/logger" import { MAX_HOT_SESSION_MESSAGE_BYTES, selectSessionMemoryEvictions, type SessionMemoryEntry } from "../lib/session-memory-budget" import { messageStoreBus } from "./message-v2/bus" -import { cancelCachedSessionMessageRestore, isRestoringCachedSessionMessages, isSessionMessageCacheWritePending, setSessionMessageCacheWriteSettledCallback } from "./session-message-cache" import { isSessionMessagesLoading, sessions } from "./session-state" const log = getLogger("session") @@ -85,12 +84,9 @@ export function evictResidentSessionMessages(instanceId: string, sessionId: stri visibleLeases.has(sessionKey(instanceId, sessionId)) || status === "working" || status === "compacting" || - isSessionMessageCacheWritePending(instanceId, sessionId) || isSessionMessagesLoading(instanceId, sessionId) || - isRestoringCachedSessionMessages(instanceId, sessionId) || hasProtectedSessionWork(store, sessionId) ) return false - cancelCachedSessionMessageRestore(instanceId, sessionId) store.clearSession(sessionId, { preserveScroll: true, preservePromptDisplay: true }) log.info("Evicted resident session messages", { instanceId, sessionId }) return true @@ -103,8 +99,7 @@ export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES) const key = sessionKey(instanceId, sessionId) const status = sessions().get(instanceId)?.get(sessionId)?.status const protectedSession = visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId) || - isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId) || - isSessionMessageCacheWritePending(instanceId, sessionId) + isSessionMessagesLoading(instanceId, sessionId) let byteSize = measuredBytes.get(key) if (!protectedSession) { byteSize = store.getSessionApproximateByteSize(sessionId) @@ -150,5 +145,3 @@ messageStoreBus.onInstanceDestroyed((instanceId) => { for (const key of measuredBytes.keys()) if (key.startsWith(prefix)) measuredBytes.delete(key) for (const key of pendingMeasurements.keys()) if (key.startsWith(prefix)) cancelSessionMeasurement(key) }) - -setSessionMessageCacheWriteSettledCallback(scheduleSessionMemorySweep) diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts deleted file mode 100644 index 849e62996..000000000 --- a/packages/ui/src/stores/session-message-cache.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { - deleteSessionMessageCache, - createSessionMessageCacheKey, - type SessionMessageCachePage, - isSessionMessageCacheEnabled, - onSessionMessageCacheReset, - openSessionMessageCache, - readSessionMessageCachePage, - writeSessionMessageCache, - MAX_SESSION_MESSAGE_CACHE_BYTES, - markSessionMessageCacheUnsafe, - setSessionMessageCacheEnabled, -} from "../lib/session-message-cache" -import { createSignal } from "solid-js" -import { getLogger } from "../lib/logger" -import { instances } from "./instances" -import { isInstanceRuntimeCurrent } from "./instances" -import type { Instance } from "../types/instance" -import { messageStoreBus } from "./message-v2/bus" -import { estimateRetainedBytes } from "../lib/session-memory-budget" - -const log = getLogger("session") -const WRITE_DEBOUNCE_MS = 500 - -function disablePersistentCacheAfterInvalidationFailure(context: Record): void { - markSessionMessageCacheUnsafe(true) - setSessionMessageCacheEnabled(false) - log.warn("Disabled persistent session message cache after invalidation failure", context) -} -const pendingWrites = new Map>() -const cacheGenerations = new Map() -const pendingInvalidations = new Map>() -const invalidatedEntries = new Set() -const invalidationVersions = new Map() -const pendingEntryInvalidations = new Map() -const activeEntryInvalidations = new Map>() -const entryInvalidationKeys = new Map>() -const trailingEntryInvalidations = new Map() -const activeRestores = new Map() -type CacheWriteRequest = { - instanceId: string - sessionId: string - expectedRevision?: number - allowEmpty: boolean - generation: number - reset: number - instanceToken: Instance - workspace: string - entryKey: string - invalidationVersion: number - done: Array<() => void> -} -const queuedWrites = new Map() -const activeWriteKeys = new Map() -let writeWorkerRunning = false -const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) -let resetGeneration = 0 -let writeSettledCallback: (() => void) | undefined - -function pendingKey(instanceId: string, sessionId: string): string { - return `${instanceId}:${sessionId}` -} - -function cacheGeneration(key: string): number { - return cacheGenerations.get(key) ?? 0 -} - -function normalizedWorkspace(workspace: string): string { - const normalized = workspace.replace(/\\/g, "/").replace(/\/+$/, "") - return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized -} - -function hasSingleWorkspaceOwner(instanceId: string, workspace: string): boolean { - const expected = normalizedWorkspace(workspace) - let owners = 0 - for (const instance of instances().values()) { - if (normalizedWorkspace(instance.folder) !== expected) continue - owners += 1 - if (owners > 1) return false - } - return owners === 1 && normalizedWorkspace(instances().get(instanceId)?.folder ?? "") === expected -} - -function cleanupCacheFenceState(key: string, entryKey: string): void { - if (!pendingWrites.has(key) && !queuedWrites.has(key) && !activeWriteKeys.has(key) && !activeRestores.has(key) && !pendingInvalidations.has(key)) { - cacheGenerations.delete(key) - } - if ( - !pendingEntryInvalidations.has(entryKey) && - ![...queuedWrites.values()].some((request) => request.entryKey === entryKey) && - ![...activeWriteKeys.values()].includes(entryKey) - ) invalidationVersions.delete(entryKey) -} - -export function setSessionMessageCacheWriteSettledCallback(callback: () => void): void { - writeSettledCallback = callback -} - -function setRestoreActive(key: string, active: boolean): void { - setRestoringSessions((current) => { - const next = new Set(current) - if (active) next.add(key) - else next.delete(key) - return next - }) -} - -export function isRestoringCachedSessionMessages(instanceId: string, sessionId: string): boolean { - return restoringSessions().has(pendingKey(instanceId, sessionId)) -} - -export function cancelCachedSessionMessageRestore( - instanceId: string, - sessionId: string, - options?: { preserveShift?: boolean }, -): void { - const key = pendingKey(instanceId, sessionId) - activeRestores.delete(key) - if (!options?.preserveShift) setRestoreActive(key, false) -} - -export function clearCachedSessionMessageShift(instanceId: string, sessionId: string): void { - setRestoreActive(pendingKey(instanceId, sessionId), false) -} - -function workspaceForInstance(instanceId: string): string | null { - return instances().get(instanceId)?.folder ?? null -} - -function waitForIdle(): Promise { - return new Promise((resolve) => { - if (typeof requestIdleCallback === "function") { - requestIdleCallback(() => resolve(), { timeout: 2_000 }) - } else { - setTimeout(resolve, 0) - } - }) -} - -function snapshotSession( - instanceId: string, - sessionId: string, - allowEmpty = false, -): { messages: unknown[]; startIndex: number; totalCount: number } | null { - const store = messageStoreBus.getInstance(instanceId) - if (!store) return null - const messageIds = store.getSessionMessageIds(sessionId) - if (messageIds.length === 0 && (!allowEmpty || !store.state.sessions[sessionId])) return null - - const result: unknown[] = [] - let bytes = 0 - for (let index = messageIds.length - 1; index >= 0; index -= 1) { - const messageId = messageIds[index] - const record = store.getMessage(messageId) - const info = store.getMessageInfo(messageId) - if (!record || !info || record.isEphemeral || record.status === "sending" || record.status === "streaming") return null - const remaining = MAX_SESSION_MESSAGE_CACHE_BYTES / 2 - bytes - const recordBytes = estimateRetainedBytes(record, remaining) - if (recordBytes > remaining) break - const infoBytes = estimateRetainedBytes(info, remaining - recordBytes) - const messageBytes = recordBytes + infoBytes - if (messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2 || bytes + messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2) break - - const parts = record.partIds.map((partId) => record.parts[partId]?.data).filter(Boolean).map((part) => { - const { renderCache: _renderCache, pendingPermission: _pendingPermission, ...cacheable } = part as any - return cacheable - }) - result.unshift({ info, parts }) - bytes += messageBytes - } - return result.length > 0 || messageIds.length === 0 - ? { messages: result, startIndex: messageIds.length - result.length, totalCount: messageIds.length } - : null -} - -async function drainQueuedWrites(): Promise { - if (writeWorkerRunning) return - writeWorkerRunning = true - try { - while (queuedWrites.size > 0) { - const [key, request] = queuedWrites.entries().next().value as [string, CacheWriteRequest] - queuedWrites.delete(key) - activeWriteKeys.set(key, request.entryKey) - try { - await waitForIdle() - if (cacheGeneration(key) !== request.generation || resetGeneration !== request.reset) continue - if (!isInstanceRuntimeCurrent(request.instanceId, request.instanceToken)) continue - if ((invalidationVersions.get(request.entryKey) ?? 0) !== request.invalidationVersion) continue - if (!hasSingleWorkspaceOwner(request.instanceId, request.workspace)) { - await deleteSessionMessageCache(request.workspace, request.sessionId) - continue - } - const store = messageStoreBus.getInstance(request.instanceId) - if (!store) continue - if (request.expectedRevision !== undefined && store.getSessionRevision(request.sessionId) !== request.expectedRevision) { - await deleteSessionMessageCache(request.workspace, request.sessionId) - continue - } - const snapshot = snapshotSession(request.instanceId, request.sessionId, request.allowEmpty) - if (!snapshot) { - await deleteSessionMessageCache(request.workspace, request.sessionId) - continue - } - const written = await writeSessionMessageCache( - request.workspace, - request.sessionId, - snapshot.messages, - { startIndex: snapshot.startIndex, totalCount: snapshot.totalCount }, - ) - if ( - written && - cacheGeneration(key) === request.generation && - resetGeneration === request.reset && - isInstanceRuntimeCurrent(request.instanceId, request.instanceToken) && - (invalidationVersions.get(request.entryKey) ?? 0) === request.invalidationVersion && - pendingEntryInvalidations.get(request.entryKey) !== request.invalidationVersion - ) invalidatedEntries.delete(request.entryKey) - } catch (error) { - log.warn("Failed to cache completed session messages", { instanceId: request.instanceId, sessionId: request.sessionId, error }) - } finally { - activeWriteKeys.delete(key) - cleanupCacheFenceState(key, request.entryKey) - writeSettledCallback?.() - request.done.forEach((resolve) => resolve()) - } - } - } finally { - writeWorkerRunning = false - if (queuedWrites.size > 0) void drainQueuedWrites() - } -} - -export function isSessionMessageCacheWritePending(instanceId: string, sessionId: string): boolean { - const key = pendingKey(instanceId, sessionId) - return pendingWrites.has(key) || queuedWrites.has(key) || activeWriteKeys.has(key) -} - -function enqueueSessionMessageCacheWrite( - instanceId: string, - sessionId: string, - options?: { expectedRevision?: number; allowEmpty?: boolean }, -): Promise { - const key = pendingKey(instanceId, sessionId) - const instanceToken = instances().get(instanceId) - const workspace = instanceToken?.folder - if (!instanceToken || !workspace) return Promise.resolve() - if (!hasSingleWorkspaceOwner(instanceId, workspace)) { - invalidateSessionMessageCache(instanceId, sessionId) - return Promise.resolve() - } - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 - return new Promise((resolve) => { - const current = queuedWrites.get(key) - if (current) { - current.expectedRevision = options?.expectedRevision - current.allowEmpty ||= Boolean(options?.allowEmpty) - current.generation = cacheGeneration(key) - current.reset = resetGeneration - current.instanceToken = instanceToken - current.workspace = workspace - current.entryKey = entryKey - current.invalidationVersion = invalidationVersion - current.done.push(resolve) - } else { - queuedWrites.set(key, { - instanceId, - sessionId, - expectedRevision: options?.expectedRevision, - allowEmpty: Boolean(options?.allowEmpty), - generation: cacheGeneration(key), - reset: resetGeneration, - instanceToken, - workspace, - entryKey, - invalidationVersion, - done: [resolve], - }) - } - void drainQueuedWrites() - }) -} - -export async function* restoreCachedSessionMessagePages( - instanceId: string, - sessionId: string, -): AsyncGenerator { - if (!isSessionMessageCacheEnabled()) return - const instanceToken = instances().get(instanceId) - const workspace = instanceToken?.folder - if (!workspace) return - if (!hasSingleWorkspaceOwner(instanceId, workspace)) return - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - if (invalidatedEntries.has(entryKey)) return - const key = pendingKey(instanceId, sessionId) - const generation = cacheGeneration(key) - const reset = resetGeneration - const token = Symbol(key) - let restoredPage = false - activeRestores.set(key, token) - setRestoreActive(key, true) - try { - await pendingInvalidations.get(key) - let cursor = await openSessionMessageCache(workspace, sessionId) - const scroll = messageStoreBus.getInstance(instanceId)?.getScrollSnapshot(sessionId, "message-stream") - const deferredAnchor = scroll && !scroll.atBottom ? scroll.anchorKey : undefined - let buffered: unknown[] = [] - while ( - cursor && - cursor.beforeIndex > 0 && - isInstanceRuntimeCurrent(instanceId, instanceToken) && - activeRestores.get(key) === token && - cacheGeneration(key) === generation && - resetGeneration === reset && - !invalidatedEntries.has(entryKey) - ) { - const result = await readSessionMessageCachePage(cursor) - if (!result) return - if ( - !isInstanceRuntimeCurrent(instanceId, instanceToken) || - activeRestores.get(key) !== token || - cacheGeneration(key) !== generation || - resetGeneration !== reset || - invalidatedEntries.has(entryKey) - ) return - cursor = result.cursor - const page = result.page - if (deferredAnchor && !page.messages.some((message) => (message as any)?.info?.id === deferredAnchor) && cursor.beforeIndex > 0) { - buffered = [...page.messages, ...buffered] - continue - } - if (buffered.length > 0) { - restoredPage = true - yield { ...page, messages: [...page.messages, ...buffered] } - buffered = [] - } else { - restoredPage = true - yield page - } - } - if (buffered.length > 0 && (!deferredAnchor || cursor?.complete)) { - restoredPage = true - yield { messages: buffered, startIndex: cursor?.startIndex ?? 0, totalCount: cursor?.totalCount ?? buffered.length, done: true, complete: cursor?.complete ?? true } - } - } finally { - if (activeRestores.get(key) === token) { - activeRestores.delete(key) - if (!restoredPage) setRestoreActive(key, false) - } - } -} - -export async function cacheAuthoritativeSessionMessages( - instanceId: string, - sessionId: string, - expectedRevision: number, -): Promise { - if (!isSessionMessageCacheEnabled()) return - await enqueueSessionMessageCacheWrite(instanceId, sessionId, { expectedRevision, allowEmpty: true }) -} - -export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: string): void { - if (!isSessionMessageCacheEnabled()) return - const key = pendingKey(instanceId, sessionId) - const instanceToken = instances().get(instanceId) - const workspace = instanceToken?.folder - if (!instanceToken || !workspace) return - if (!hasSingleWorkspaceOwner(instanceId, workspace)) { - invalidateSessionMessageCache(instanceId, sessionId) - return - } - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 - const existing = pendingWrites.get(key) - if (existing) clearTimeout(existing) - pendingWrites.set(key, setTimeout(() => { - pendingWrites.delete(key) - if (!isInstanceRuntimeCurrent(instanceId, instanceToken)) return - if ((invalidationVersions.get(entryKey) ?? 0) !== invalidationVersion) return - void enqueueSessionMessageCacheWrite(instanceId, sessionId) - }, WRITE_DEBOUNCE_MS)) -} - -export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { - if (!isSessionMessageCacheEnabled()) return - const key = pendingKey(instanceId, sessionId) - cancelCachedSessionMessageRestore(instanceId, sessionId) - const pending = pendingWrites.get(key) - if (pending) { - clearTimeout(pending) - pendingWrites.delete(key) - } - const queued = queuedWrites.get(key) - if (queued) { - queuedWrites.delete(key) - queued.done.forEach((resolve) => resolve()) - } - const workspace = workspaceForInstance(instanceId) - if (!workspace) return - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - const generation = cacheGeneration(key) + 1 - cacheGenerations.set(key, generation) - const activeInvalidation = activeEntryInvalidations.get(entryKey) - if (activeInvalidation) { - invalidatedEntries.add(entryKey) - trailingEntryInvalidations.set(entryKey, { instanceId, sessionId, workspace }) - const keys = entryInvalidationKeys.get(entryKey) ?? new Set() - keys.add(key) - entryInvalidationKeys.set(entryKey, keys) - pendingInvalidations.set(key, activeInvalidation) - return - } - const invalidationVersion = (invalidationVersions.get(entryKey) ?? 0) + 1 - invalidationVersions.set(entryKey, invalidationVersion) - pendingEntryInvalidations.set(entryKey, invalidationVersion) - invalidatedEntries.add(entryKey) - const invalidation = deleteSessionMessageCache(workspace, sessionId) - .catch((error) => { - log.warn("Failed to invalidate cached session messages; disabling persistent cache", { instanceId, sessionId, error }) - disablePersistentCacheAfterInvalidationFailure({ instanceId, sessionId }) - }) - .finally(() => { - if (activeEntryInvalidations.get(entryKey) === invalidation) activeEntryInvalidations.delete(entryKey) - if (pendingEntryInvalidations.get(entryKey) === invalidationVersion) pendingEntryInvalidations.delete(entryKey) - const keys = entryInvalidationKeys.get(entryKey) ?? new Set([key]) - entryInvalidationKeys.delete(entryKey) - for (const pendingKey of keys) { - if (pendingInvalidations.get(pendingKey) === invalidation) pendingInvalidations.delete(pendingKey) - cleanupCacheFenceState(pendingKey, entryKey) - } - const trailing = trailingEntryInvalidations.get(entryKey) - trailingEntryInvalidations.delete(entryKey) - if (trailing && isSessionMessageCacheEnabled()) { - queueMicrotask(() => { - if (workspaceForInstance(trailing.instanceId)) { - invalidateSessionMessageCache(trailing.instanceId, trailing.sessionId) - return - } - void deleteSessionMessageCache(trailing.workspace, trailing.sessionId) - .then(() => invalidatedEntries.delete(entryKey)) - .catch((error) => { - log.warn("Failed trailing cached session invalidation; disabling persistent cache", { sessionId: trailing.sessionId, error }) - disablePersistentCacheAfterInvalidationFailure({ sessionId: trailing.sessionId }) - }) - }) - } else if ((invalidationVersions.get(entryKey) ?? 0) === invalidationVersion) { - invalidatedEntries.delete(entryKey) - } - }) - activeEntryInvalidations.set(entryKey, invalidation) - entryInvalidationKeys.set(entryKey, new Set([key])) - pendingInvalidations.set(key, invalidation) -} - -function resetPendingSessionMessageCacheWork(): void { - resetGeneration += 1 - activeRestores.clear() - setRestoringSessions(new Set()) - for (const timer of pendingWrites.values()) clearTimeout(timer) - pendingWrites.clear() - for (const request of queuedWrites.values()) request.done.forEach((resolve) => resolve()) - queuedWrites.clear() - activeWriteKeys.clear() - pendingInvalidations.clear() - invalidatedEntries.clear() - invalidationVersions.clear() - pendingEntryInvalidations.clear() - activeEntryInvalidations.clear() - entryInvalidationKeys.clear() - trailingEntryInvalidations.clear() - cacheGenerations.clear() -} - -onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) - -messageStoreBus.onInstanceDestroyed((instanceId) => { - const prefix = `${instanceId}:` - for (const [key, timer] of pendingWrites) { - if (!key.startsWith(prefix)) continue - clearTimeout(timer) - pendingWrites.delete(key) - } - for (const [key, request] of queuedWrites) { - if (!key.startsWith(prefix)) continue - queuedWrites.delete(key) - request.done.forEach((resolve) => resolve()) - } - for (const key of cacheGenerations.keys()) if (key.startsWith(prefix)) cacheGenerations.delete(key) - for (const key of activeRestores.keys()) { - if (key.startsWith(prefix)) activeRestores.delete(key) - } - setRestoringSessions((current) => new Set([...current].filter((key) => !key.startsWith(prefix)))) -}) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 43081db96..947eb0b39 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -205,6 +205,42 @@ describe("session request authority", () => { } }) + it("cancels a revision retry while the session is hidden", async () => { + const instanceId = "hidden-message-retry", sessionId = "session", nextSessionId = "next" + const { client, cleanup } = setup(instanceId) + const firstResponse = deferred() + let calls = 0 + ;(client.session as any).messages = () => { + calls += 1 + return calls === 1 ? firstResponse.promise : Promise.resolve({ data: [apiMessage("retried-message", sessionId)] }) + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [sessionId, session(instanceId, sessionId)], + [nextSessionId, session(instanceId, nextSessionId)], + ]))) + + try { + setActiveSession(instanceId, sessionId) + const request = loadMessages(instanceId, sessionId) + messageStoreBus.getOrCreate(instanceId).upsertMessage({ + id: "live-message", + sessionId, + role: "assistant", + status: "complete", + parts: [], + }) + firstResponse.resolve({ data: [apiMessage("http-message", sessionId)] }) + await new Promise((resolve) => setImmediate(resolve)) + setActiveSession(instanceId, nextSessionId) + await request + + assert.equal(calls, 1) + assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false) + } finally { + cleanup() + } + }) + it("cancels child loads owned by the session being left", async () => { const instanceId = "switched-child-load", parentSessionId = "parent", childSessionId = "child" const { client, cleanup } = setup(instanceId) From 484b9b0a73e2e25e40825ad45f8380e3da07b6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 2 Aug 2026 19:14:00 +0200 Subject: [PATCH 08/16] fix: harden session recovery and desktop event bounds Preserve complete-session search, scroll restoration, prompts, interruptions, and reconnect behavior while keeping renderer work and retained state bounded. Fence reconnect and revision recovery by runtime generation and abort authority, reconcile complete snapshots safely, make search cancellable and resumable, and keep delayed permission and question events from reviving resolved work. Cancel stale auto-accept and SDK operations across runtime rotation, bound native SSE parsing and batching without rejecting server-valid events, and cover the recovered races in UI, server, and Rust tests. Validated with UI and server typechecks, UI production builds, targeted CI-equivalent UI/server suites, Electron native tests, and the locked Tauri test suite. --- .github/workflows/pr-build.yml | 9 + .../permissions/auto-accept-manager.test.ts | 316 +++++++- .../src/permissions/auto-accept-manager.ts | 165 +++- .../src/permissions/opencode-replier.test.ts | 49 ++ .../src/permissions/opencode-replier.ts | 5 +- .../opencode-yolo-metadata.test.ts | 40 +- .../src/permissions/opencode-yolo-metadata.ts | 21 +- .../src/workspaces/instance-client.test.ts | 138 ++++ .../server/src/workspaces/instance-client.ts | 105 ++- packages/tauri-app/Cargo.lock | 13 + packages/tauri-app/src-tauri/Cargo.toml | 1 + .../src-tauri/src/desktop_event_transport.rs | 58 +- .../src/desktop_event_transport/assembler.rs | 57 +- .../src/desktop_event_transport/stream.rs | 368 +++++++-- .../src/desktop_event_transport/tests.rs | 44 ++ .../src/desktop_event_transport/transport.rs | 30 +- packages/ui/src/components/message-block.tsx | 20 +- .../ui/src/components/message-section.tsx | 247 +++++- packages/ui/src/components/prompt-input.tsx | 4 +- .../prompt-input/submitPrompt.test.ts | 26 +- .../components/prompt-input/submitPrompt.ts | 4 + .../src/components/session/session-view.tsx | 4 +- packages/ui/src/components/tool-call.tsx | 7 +- .../components/tool-call/renderers/task.tsx | 6 +- .../components/tool-call/search-text.test.ts | 408 +++++++++- .../src/components/tool-call/search-text.ts | 298 ++++---- packages/ui/src/components/tool-call/types.ts | 3 +- .../ui/src/components/tool-call/utils.test.ts | 10 +- packages/ui/src/components/tool-call/utils.ts | 6 + .../virtual-follow-behavior.test.ts | 18 + .../src/components/virtual-follow-behavior.ts | 13 + .../ui/src/components/virtual-follow-list.tsx | 4 + packages/ui/src/lib/api-client.ts | 4 +- .../native/desktop-events-reconnect.test.ts | 62 ++ packages/ui/src/lib/native/desktop-events.ts | 34 +- packages/ui/src/lib/opencode-api.test.ts | 26 +- packages/ui/src/lib/opencode-api.ts | 26 +- packages/ui/src/lib/retry-utils.ts | 47 +- packages/ui/src/lib/session-search-matches.ts | 72 +- packages/ui/src/lib/session-search.test.ts | 36 +- packages/ui/src/lib/session-search.ts | 389 ++++++---- packages/ui/src/lib/trailing-resync.test.ts | 23 + packages/ui/src/lib/trailing-resync.ts | 34 +- packages/ui/src/stores/delta-buffer.test.ts | 12 + packages/ui/src/stores/delta-buffer.ts | 19 + .../stores/instances-reconnect-resync.test.ts | 711 ++++++++++++++++++ packages/ui/src/stores/instances.ts | 302 ++++++-- .../stores/message-v2/instance-store.test.ts | 38 +- .../src/stores/message-v2/instance-store.ts | 25 +- packages/ui/src/stores/opencode-workspaces.ts | 1 + .../ui/src/stores/permission-replies.test.ts | 23 +- packages/ui/src/stores/permission-replies.ts | 39 +- .../ui/src/stores/request-locations.test.ts | 49 +- packages/ui/src/stores/request-locations.ts | 15 +- .../stores/session-actions-delivery.test.ts | 120 +++ packages/ui/src/stores/session-actions.ts | 14 +- packages/ui/src/stores/session-api.ts | 149 ++-- packages/ui/src/stores/session-events.ts | 139 +++- packages/ui/src/stores/session-memory.test.ts | 23 + packages/ui/src/stores/session-memory.ts | 7 + .../stores/session-revision-recovery.test.ts | 460 +++++++++++ packages/ui/src/stores/session-state.ts | 8 +- packages/ui/src/stores/worktree-ready.test.ts | 35 + packages/ui/src/stores/worktrees.ts | 46 +- .../src/styles/messaging/message-section.css | 9 + 65 files changed, 4774 insertions(+), 720 deletions(-) create mode 100644 packages/server/src/permissions/opencode-replier.test.ts create mode 100644 packages/ui/src/lib/native/desktop-events-reconnect.test.ts create mode 100644 packages/ui/src/stores/instances-reconnect-resync.test.ts create mode 100644 packages/ui/src/stores/session-actions-delivery.test.ts create mode 100644 packages/ui/src/stores/session-revision-recovery.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 6ccd41537..70561598f 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,11 +104,14 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/prompt-input/submitPrompt.test.ts packages/ui/src/components/tool-call/diagnostics.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/native/desktop-events-reconnect.test.ts + packages/ui/src/lib/opencode-api.test.ts packages/ui/src/lib/session-memory-budget.test.ts packages/ui/src/lib/session-search.test.ts packages/ui/src/lib/trailing-resync.test.ts @@ -124,6 +127,8 @@ jobs: packages/ui/src/stores/commands.test.ts packages/ui/src/stores/delta-buffer.test.ts packages/ui/src/stores/instances-restore-cancellation.test.ts + packages/ui/src/stores/permission-replies.test.ts + packages/ui/src/stores/request-locations.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts @@ -142,6 +147,10 @@ jobs: packages/ui/src/components/tool-call/utils.test.ts packages/ui/src/lib/sse-manager-generation.test.ts packages/ui/src/stores/session-state-purge.test.ts + packages/ui/src/stores/instances-reconnect-resync.test.ts + packages/ui/src/stores/session-revision-recovery.test.ts + packages/ui/src/stores/session-actions-delivery.test.ts + packages/ui/src/components/virtual-follow-behavior.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index d803587be..491184325 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -20,8 +20,8 @@ const noopLogger: Logger = { }, } as unknown as Logger -function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { - bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) +function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record, streamId?: string) { + bus.publish({ type: "instance.event", instanceId, streamId, event: { ...event } as InstanceStreamEvent }) } /** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ @@ -129,7 +129,7 @@ describe("AutoAcceptManager persistence", () => { const writes: unknown[][] = [] const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, - async persist(...args) { writes.push(args); await gate }, + async persist(...args) { writes.push(args.slice(0, 4)); await gate }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) const toggle = manager.toggle("inst", "root") @@ -160,8 +160,10 @@ describe("AutoAcceptManager persistence", () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() let attempts = 0 + const signals: AbortSignal[] = [] const persistence: AutoAcceptPersistence = { - async loadSessions() { + async loadSessions(_instanceId, signal) { + signals.push(signal) if (++attempts === 1) throw new Error("temporary failure") return [{ id: "root", parentId: null, yoloEnabled: true }] }, @@ -175,12 +177,36 @@ describe("AutoAcceptManager persistence", () => { }) await flushMicrotasks() assert.equal(replier.calls.length, 0) + assert.equal(signals[0]?.aborted, true) await manager.hydrateInstance("inst") await flushMicrotasks() + assert.notEqual(signals[1], signals[0]) assert.equal(replier.calls.length, 1) manager.stop() }) + it("releases generation controllers after arbitrary hydration failures", async () => { + const bus = new EventBus(noopLogger) + const signals: AbortSignal[] = [] + const persistence: AutoAcceptPersistence = { + async loadSessions(instanceId, signal) { + signals.push(signal) + throw new Error(`unknown workspace: ${instanceId}`) + }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + + await assert.rejects(manager.hydrateInstance("invalid-a"), /unknown workspace/) + await assert.rejects(manager.hydrateInstance("invalid-b"), /unknown workspace/) + + assert.equal(signals.every((signal) => signal.aborted), true) + assert.equal( + (manager as unknown as { generationControllers: Map }).generationControllers.size, + 0, + ) + }) + it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => { const bus = new EventBus(noopLogger) const changes: Record[] = [] @@ -188,23 +214,139 @@ describe("AutoAcceptManager persistence", () => { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) let writes = 0 + let staleSignal: AbortSignal | undefined const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, - async persist() { writes += 1; await gate }, + async persist(_instanceId, _rootSessionId, _enabled, _workspaceId, signal) { + staleSignal = signal + await gate + if (signal.aborted) throw signal.reason + writes += 1 + }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) const toggle = manager.toggle("inst", "root") const queued = manager.toggle("inst", "root") await flushMicrotasks() manager.clearInstance("inst") + assert.equal(staleSignal?.aborted, true) release() assert.equal(await toggle, false) assert.equal(await queued, false) - assert.equal(writes, 1) + assert.equal(writes, 0) assert.equal(manager.isEnabled("inst", "root"), false) assert.equal(changes.length, 0) }) + it("reports a committed toggle after runtime rotation and hydrates the durable value", async () => { + const bus = new EventBus(noopLogger) + const changes: Array<{ enabled?: boolean }> = [] + let durableEnabled = false + const persistence: AutoAcceptPersistence = { + async loadSessions() { + return [{ id: "root", parentId: null, yoloEnabled: durableEnabled }] + }, + async persist(_instanceId, _rootSessionId, enabled) { + durableEnabled = enabled + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "new", status: "connecting" }) + }, + } + bus.on("yolo.stateChanged", (event) => changes.push(event)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "old", status: "connecting" }) + await manager.hydrateInstance("inst") + + assert.equal(await manager.toggle("inst", "root"), true) + await manager.hydrateInstance("inst") + + assert.equal(durableEnabled, true) + assert.equal(manager.isEnabled("inst", "root"), true) + assert.deepEqual(changes.map((event) => event.enabled), [true]) + manager.stop() + }) + + it("publishes the authoritative disabled state when rotation aborts persistence after commit", async () => { + const bus = new EventBus(noopLogger) + const changes: Array<{ sessionId?: string; enabled?: boolean }> = [] + let durableEnabled = true + let loads = 0 + let persistSignal: AbortSignal | undefined + const persistence: AutoAcceptPersistence = { + async loadSessions() { + loads += 1 + return [ + { id: "root", parentId: null, yoloEnabled: durableEnabled }, + ...(loads === 1 ? [] : [{ id: "unrelated", parentId: null, yoloEnabled: true }]), + ] + }, + async persist(_instanceId, _rootSessionId, enabled, _workspaceId, signal) { + durableEnabled = enabled + persistSignal = signal + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "replacement", status: "connecting" }) + throw signal.reason + }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "old", status: "connecting" }) + await manager.hydrateInstance("inst") + bus.on("yolo.stateChanged", (event) => changes.push(event)) + + assert.equal(await manager.toggle("inst", "root"), false) + + assert.equal(persistSignal?.aborted, true) + assert.equal(loads, 2) + assert.equal(manager.isEnabled("inst", "root"), false) + assert.equal(manager.isEnabled("inst", "unrelated"), true) + assert.deepEqual(changes.map(({ sessionId, enabled }) => [sessionId, enabled]), [ + ["unrelated", true], + ["root", false], + ]) + manager.stop() + }) + + it("rebinds a toggle behind replacement hydration when runtime rotation interrupts initial hydration", async () => { + const bus = new EventBus(noopLogger) + const changes: Array<{ enabled?: boolean }> = [] + const writes: boolean[] = [] + let releaseOld!: () => void + let releaseReplacement!: () => void + let loads = 0 + const oldGate = new Promise((resolve) => { releaseOld = resolve }) + const replacementGate = new Promise((resolve) => { releaseReplacement = resolve }) + const persistence: AutoAcceptPersistence = { + async loadSessions() { + await (++loads === 1 ? oldGate : replacementGate) + return [{ id: "root", parentId: null, yoloEnabled: false }] + }, + async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) }, + } + bus.on("yolo.stateChanged", (event) => changes.push(event)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + manager.start() + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "old", status: "connecting" }) + const oldHydration = manager.hydrateInstance("inst") + const toggle = manager.toggle("inst", "root") + + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "replacement", status: "connecting" }) + releaseOld() + await oldHydration + await flushMicrotasks() + + assert.equal(loads, 2) + assert.deepEqual(writes, [], "stale authority must not persist before replacement hydration") + assert.equal(changes.length, 0, "stale authority must not publish replacement state") + assert.equal(manager.isEnabled("inst", "root"), false) + + releaseReplacement() + assert.equal(await toggle, true) + assert.deepEqual(writes, [true]) + assert.equal(manager.isEnabled("inst", "root"), true) + assert.deepEqual(changes.map((event) => event.enabled), [true]) + manager.stop() + }) + it("moves persisted Yolo state when late ancestry changes the family root", async () => { const bus = new EventBus(noopLogger) const writes: unknown[][] = [] @@ -215,7 +357,7 @@ describe("AutoAcceptManager persistence", () => { { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true }, ] }, - async persist(...args) { writes.push(args) }, + async persist(...args) { writes.push(args.slice(0, 4)) }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) manager.start() @@ -504,6 +646,166 @@ describe("AutoAcceptManager lifecycle", () => { assert.equal(replier.calls.length, 0) }) + + it("stop() fences late hydration and refuses new work until restarted", async () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + const signals: AbortSignal[] = [] + let release!: () => void + let loads = 0 + const gate = new Promise((resolve) => { release = resolve }) + const persistence: AutoAcceptPersistence = { + async loadSessions(_instanceId, signal) { + loads += 1 + signals.push(signal) + if (loads === 1) await gate + return [{ id: "root", parentId: null, yoloEnabled: true }] + }, + async persist() {}, + } + bus.on("yolo.stateChanged", (event) => changes.push(event)) + const manager = new AutoAcceptManager({ + eventBus: bus, + logger: noopLogger, + replier: makeRecordingReplier(), + persistence, + }) + manager.start() + + const hydration = manager.hydrateInstance("inst") + manager.stop() + assert.equal(signals[0]?.aborted, true) + release() + await hydration + await manager.hydrateInstance("inst") + + assert.equal(loads, 1, "stopped managers must not create new generation work") + assert.equal(manager.isEnabled("inst", "root"), false) + assert.equal(changes.length, 0) + + manager.start() + await manager.hydrateInstance("inst") + assert.equal(loads, 2) + assert.equal(signals[1]?.aborted, false) + assert.equal(manager.isEnabled("inst", "root"), true) + manager.stop() + }) + + it("stop() ignores a reply that settles after cancellation", async () => { + const bus = new EventBus(noopLogger) + const accepted: Record[] = [] + let signal: AbortSignal | undefined + let release!: () => void + const replier: PermissionReplier = (_reply, replySignal) => { + signal = replySignal + return new Promise((resolve) => { release = resolve }) + } + bus.on("yolo.autoAccepted", (event) => accepted.push(event)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) + manager.toggle("inst", "root") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "permission", sessionID: "root" }, + }) + + manager.stop() + assert.equal(signal?.aborted, true) + release() + await flushMicrotasks() + + assert.equal(accepted.length, 0) + }) +}) + +describe("AutoAcceptManager runtime rotation", () => { + it("clears runtime state and rehydrates persisted roots for the new stream", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + let loads = 0 + const persistence: AutoAcceptPersistence = { + async loadSessions() { + loads += 1 + return loads === 1 + ? [ + { id: "old-root", parentId: null, yoloEnabled: true }, + { id: "old-child", parentId: "old-root", yoloEnabled: false }, + { id: "stale", parentId: null, yoloEnabled: false }, + ] + : [{ id: "new-root", parentId: null, yoloEnabled: true }] + }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence }) + manager.start() + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "old", status: "connecting" }) + await manager.hydrateInstance("inst") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "stale-permission", sessionID: "stale" }, + }, "old") + + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "new", status: "connecting" }) + await manager.hydrateInstance("inst") + await flushMicrotasks() + + assert.equal(loads, 2) + assert.equal(manager.isEnabled("inst", "old-child"), false) + assert.equal(manager.isEnabled("inst", "new-root"), true) + assert.equal(replier.calls.length, 0, "stale pending permission must not drain into the new runtime") + manager.stop() + }) + + it("ignores an old reply completion without disturbing the new in-flight reply", async () => { + const bus = new EventBus(noopLogger) + const completions: Array<() => void> = [] + const calls: AutoAcceptReply[] = [] + const accepted: Record[] = [] + const signals: AbortSignal[] = [] + const replier: PermissionReplier = (reply, signal) => { + calls.push(reply) + signals.push(signal) + return new Promise((resolve) => completions.push(resolve)) + } + bus.on("yolo.autoAccepted", (event) => accepted.push(event)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "old", status: "connecting" }) + publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) + manager.toggle("inst", "root") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "same-permission", sessionID: "root" }, + }, "old") + + bus.publish({ type: "instance.eventStatus", instanceId: "inst", streamId: "new", status: "connecting" }) + assert.equal(signals[0]?.aborted, true) + publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) + manager.toggle("inst", "root") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "same-permission", sessionID: "root" }, + }, "new") + assert.equal(calls.length, 2) + assert.equal(signals[1]?.aborted, false) + + completions[0]() + await flushMicrotasks() + assert.equal(accepted.length, 0) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "same-permission", sessionID: "root" }, + }, "new") + assert.equal(calls.length, 2, "old completion must not clear the new in-flight marker") + + completions[1]() + await flushMicrotasks() + assert.equal(accepted.length, 1) + assert.equal(accepted[0].permissionId, "same-permission") + manager.stop() + }) }) describe("AutoAcceptManager pending permissions drain", () => { diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index c64644742..d9be8473a 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -27,7 +27,7 @@ export interface AutoAcceptReply { reply: PermissionReplyValue } -export type PermissionReplier = (reply: AutoAcceptReply) => Promise +export type PermissionReplier = (reply: AutoAcceptReply, signal: AbortSignal) => Promise interface PendingPermission { permissionId: string @@ -48,14 +48,21 @@ export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo { } export interface AutoAcceptPersistence { - loadSessions(instanceId: string): Promise - persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise + loadSessions(instanceId: string, signal: AbortSignal): Promise + persist( + instanceId: string, + rootSessionId: string, + enabled: boolean, + workspaceId: string | undefined, + signal: AbortSignal, + ): Promise } const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) +const REBIND_TOGGLE_AFTER_ROTATION = new Error("Rebind Yolo toggle after runtime rotation") export class AutoAcceptManager { private static readonly MAX_REPLY_ATTEMPTS = 3 @@ -70,15 +77,18 @@ export class AutoAcceptManager { private readonly hydration = new Map>() private readonly queuedEvents = new Map() private readonly instanceGeneration = new Map() + private readonly generationControllers = new Map() private readonly streamIds = new Map() private readonly sessionWorkspaces = new Map>() private readonly mutations = new Map>() private unsubscribe?: () => void + private stopped = false constructor(private readonly deps: AutoAcceptManagerDeps) {} start(): void { if (this.unsubscribe) return + this.stopped = false const handler = (payload: { instanceId?: string; streamId?: string; event?: InstanceStreamPayload }) => { if (!payload || !payload.instanceId || !payload.event) return if (payload.streamId) { @@ -112,7 +122,15 @@ export class AutoAcceptManager { } const onStreamStatus = (event: { instanceId?: string; streamId?: string; status?: string }) => { if (!event.instanceId || !event.streamId || event.status !== "connecting") return - if (this.streamIds.get(event.instanceId) !== event.streamId) this.queuedEvents.delete(event.instanceId) + const current = this.streamIds.get(event.instanceId) + if (current && current !== event.streamId) { + this.invalidateRuntimeState(event.instanceId) + this.streamIds.set(event.instanceId, event.streamId) + void this.hydrateInstance(event.instanceId).catch((error) => { + this.deps.logger.warn({ instanceId: event.instanceId, err: error }, "Failed to hydrate persisted Yolo state") + }) + return + } this.streamIds.set(event.instanceId, event.streamId) } this.deps.eventBus.on("instance.event", handler) @@ -132,6 +150,20 @@ export class AutoAcceptManager { stop(): void { this.unsubscribe?.() this.unsubscribe = undefined + this.stopped = true + const instanceIds = new Set([ + ...this.generationControllers.keys(), + ...this.instanceGeneration.keys(), + ...this.streamIds.keys(), + ...this.hydratedInstances, + ...this.hydration.keys(), + ...this.queuedEvents.keys(), + ...this.sessionWorkspaces.keys(), + ...this.mutations.keys(), + ...this.pending.keys(), + ]) + for (const instanceId of instanceIds) this.invalidateRuntimeState(instanceId) + this.streamIds.clear() } isEnabled(instanceId: string, sessionId: string): boolean { @@ -139,13 +171,22 @@ export class AutoAcceptManager { } hydrateInstance(instanceId: string): Promise { - if (!this.deps.persistence || this.hydratedInstances.has(instanceId)) return Promise.resolve() + if (this.stopped || !this.deps.persistence || this.hydratedInstances.has(instanceId)) return Promise.resolve() const existing = this.hydration.get(instanceId) if (existing) return existing const generation = this.instanceGeneration.get(instanceId) ?? 0 + const signal = this.generationSignal(instanceId) let hydrated = false - const pending = this.deps.persistence.loadSessions(instanceId).then((sessions) => { - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return + const priorMutation = this.mutations.get(instanceId) + const load = priorMutation + ? priorMutation.catch(() => false).then(() => { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return undefined + return this.deps.persistence!.loadSessions(instanceId, signal) + }) + : this.deps.persistence.loadSessions(instanceId, signal) + const pending = load.then((sessions) => { + if (!sessions) return + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return this.store.clearInstance(instanceId) const workspaces = new Map() for (const session of sessions) { @@ -161,9 +202,13 @@ export class AutoAcceptManager { } this.hydratedInstances.add(instanceId) hydrated = true + }).catch((error) => { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return + this.releaseFailedHydration(instanceId, signal) + throw error }).finally(() => { - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return - this.hydration.delete(instanceId) + if (this.hydration.get(instanceId) === pending) this.hydration.delete(instanceId) + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return if (!hydrated) return const queued = this.queuedEvents.get(instanceId) ?? [] this.queuedEvents.delete(instanceId) @@ -174,6 +219,7 @@ export class AutoAcceptManager { } toggle(instanceId: string, sessionId: string): boolean | Promise { + if (this.stopped) return this.store.isEnabled(instanceId, sessionId) if (this.deps.persistence) return this.togglePersisted(instanceId, sessionId) const enabled = this.store.toggle(instanceId, sessionId) this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled }) @@ -184,10 +230,12 @@ export class AutoAcceptManager { } private async togglePersisted(instanceId: string, sessionId: string): Promise { - await this.hydrateInstance(instanceId) const generation = this.instanceGeneration.get(instanceId) ?? 0 + const signal = this.generationSignal(instanceId) + await this.hydrateInstance(instanceId) const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => { - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) { + if (!this.stopped && this.streamIds.has(instanceId)) throw REBIND_TOGGLE_AFTER_ROTATION return this.store.isEnabled(instanceId, sessionId) } const rootSessionId = this.store.familyRoot(instanceId, sessionId) @@ -198,9 +246,10 @@ export class AutoAcceptManager { rootSessionId, enabled, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + signal, ) - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { - return this.store.isEnabled(instanceId, rootSessionId) + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) { + return enabled } let persistedRootSessionId = rootSessionId let currentRootSessionId = this.store.familyRoot(instanceId, sessionId) @@ -211,6 +260,7 @@ export class AutoAcceptManager { currentRootSessionId, enabled, this.sessionWorkspaces.get(instanceId)?.get(currentRootSessionId), + signal, ) if (enabled) { await this.deps.persistence!.persist( @@ -218,13 +268,14 @@ export class AutoAcceptManager { persistedRootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(persistedRootSessionId), + signal, ) } persistedRootSessionId = currentRootSessionId currentRootSessionId = this.store.familyRoot(instanceId, sessionId) } - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { - return this.store.isEnabled(instanceId, currentRootSessionId) + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) { + return enabled } if (enabled) { this.store.setEnabled(instanceId, currentRootSessionId, true) @@ -236,22 +287,55 @@ export class AutoAcceptManager { this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: currentRootSessionId, enabled }) if (enabled) this.drainPending(instanceId, currentRootSessionId) return enabled + }).catch((error) => { + if (error === REBIND_TOGGLE_AFTER_ROTATION) throw error + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) { + if (!this.stopped && this.streamIds.has(instanceId)) throw error + return this.store.isEnabled(instanceId, sessionId) + } + throw error }) const settled = mutation.finally(() => { if (this.mutations.get(instanceId) === settled) this.mutations.delete(instanceId) }) this.mutations.set(instanceId, settled) - return settled + try { + const enabled = await settled + if (!this.hasRuntimeAuthority(instanceId, generation, signal) && !this.stopped && this.streamIds.has(instanceId)) { + return this.resolveToggleAfterRotation(instanceId, sessionId) + } + return enabled + } catch (error) { + if (error === REBIND_TOGGLE_AFTER_ROTATION) return this.togglePersisted(instanceId, sessionId) + if (!this.hasRuntimeAuthority(instanceId, generation, signal) && !this.stopped && this.streamIds.has(instanceId)) { + return this.resolveToggleAfterRotation(instanceId, sessionId) + } + throw error + } + } + + private async resolveToggleAfterRotation(instanceId: string, sessionId: string): Promise { + await this.hydrateInstance(instanceId) + const rootSessionId = this.store.familyRoot(instanceId, sessionId) + const enabled = this.store.isEnabled(instanceId, rootSessionId) + // Hydration publishes enabled roots; disabled roots need an explicit UI update. + if (!enabled) this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: rootSessionId, enabled }) + return enabled } clearInstance(instanceId: string): void { + this.invalidateRuntimeState(instanceId) + this.streamIds.delete(instanceId) + } + + private invalidateRuntimeState(instanceId: string): void { + this.generationControllers.get(instanceId)?.abort() + this.generationControllers.delete(instanceId) this.instanceGeneration.set(instanceId, (this.instanceGeneration.get(instanceId) ?? 0) + 1) this.hydratedInstances.delete(instanceId) this.hydration.delete(instanceId) this.queuedEvents.delete(instanceId) this.sessionWorkspaces.delete(instanceId) - this.streamIds.delete(instanceId) - this.mutations.delete(instanceId) this.store.clearInstance(instanceId) this.pending.delete(instanceId) const prefix = `${instanceId}:` @@ -264,7 +348,7 @@ export class AutoAcceptManager { } handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { - if (!event || typeof event.type !== "string") return + if (this.stopped || !event || typeof event.type !== "string") return if (SESSION_UPSERT_TYPES.has(event.type)) { this.ingestSession(instanceId, event.properties) @@ -320,20 +404,21 @@ export class AutoAcceptManager { const added = after.filter((id) => !before.includes(id)) if (removed.length === 0 && added.length === 0) return const generation = this.instanceGeneration.get(instanceId) ?? 0 + const signal = this.generationSignal(instanceId) const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => { - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return false const enabledRoots = new Set(this.store.enabledRoots(instanceId)) for (const rootSessionId of added) { if (!enabledRoots.has(rootSessionId)) continue await this.deps.persistence!.persist( - instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), signal, ) } for (const rootSessionId of removed) { if (enabledRoots.has(rootSessionId)) continue - if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return false await this.deps.persistence!.persist( - instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), + instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId), signal, ) } return false @@ -343,6 +428,7 @@ export class AutoAcceptManager { }) this.mutations.set(instanceId, settled) void settled.catch((error) => { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return this.deps.logger.warn({ instanceId, err: error }, "Failed to migrate persisted Yolo family root") }) } @@ -393,25 +479,29 @@ export class AutoAcceptManager { if (this.inFlight.has(key)) return const attempts = this.replyAttempts.get(key) ?? 0 if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return + const generation = this.instanceGeneration.get(instanceId) ?? 0 + const signal = this.generationSignal(instanceId) this.inFlight.add(key) this.replyAttempts.set(key, attempts + 1) const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } - void this.deps.replier(reply) + void this.deps.replier(reply, signal) .then(() => { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return this.replyAttempts.delete(key) this.removePending(instanceId, permissionId) this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) }) .catch((error) => { + if (!this.hasRuntimeAuthority(instanceId, generation, signal)) return this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed") if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) { this.removePending(instanceId, permissionId) } }) .finally(() => { - this.inFlight.delete(key) + if (this.hasRuntimeAuthority(instanceId, generation, signal)) this.inFlight.delete(key) }) } @@ -455,6 +545,31 @@ export class AutoAcceptManager { } if (instancePending.size === 0) this.pending.delete(instanceId) } + + private generationSignal(instanceId: string): AbortSignal { + if (this.stopped) { + const controller = new AbortController() + controller.abort() + return controller.signal + } + let controller = this.generationControllers.get(instanceId) + if (!controller || controller.signal.aborted) { + controller = new AbortController() + this.generationControllers.set(instanceId, controller) + } + return controller.signal + } + + private releaseFailedHydration(instanceId: string, signal: AbortSignal): void { + const controller = this.generationControllers.get(instanceId) + if (controller?.signal !== signal) return + controller.abort() + this.generationControllers.delete(instanceId) + } + + private hasRuntimeAuthority(instanceId: string, generation: number, signal: AbortSignal): boolean { + return !this.stopped && !signal.aborted && (this.instanceGeneration.get(instanceId) ?? 0) === generation + } } interface InstanceStreamPayload { diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts new file mode 100644 index 000000000..6708240cf --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { Logger } from "../logger" +import type { WorkspaceManager } from "../workspaces/manager" +import { createOpencodePermissionReplier } from "./opencode-replier" + +test("aborts a permission reply when its runtime generation is revoked", async () => { + const original = globalThis.fetch + let deadline: ReturnType | undefined + let requestSignal: AbortSignal | undefined + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener("abort", () => reject(requestSignal?.reason), { once: true }) + }) + }) as typeof fetch + + try { + const workspaceManager = { + getInstancePort: () => 4321, + getInstanceAuthorizationHeader: () => undefined, + get: () => ({ path: "/repo" }), + } as unknown as WorkspaceManager + const replier = createOpencodePermissionReplier({ workspaceManager, logger: {} as Logger }) + const controller = new AbortController() + const reply = replier({ + instanceId: "instance", + permissionId: "permission", + sessionId: "session", + source: "v2", + reply: "once", + }, controller.signal) + + await new Promise((resolve) => setImmediate(resolve)) + controller.abort() + + await assert.rejects(Promise.race([ + reply, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Permission reply cancellation test timed out")), 1_000) + }), + ]), (error: Error) => error.name === "AbortError") + assert.equal(requestSignal?.aborted, true) + } finally { + if (deadline) clearTimeout(deadline) + globalThis.fetch = original + } +}) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 2ed9bfc9e..c893a603e 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -17,13 +17,14 @@ interface OpencodeReplierDeps { * for the installed SDK version — no hand-assembled URLs. */ export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { - return async (reply: AutoAcceptReply) => { + return async (reply: AutoAcceptReply, signal: AbortSignal) => { + if (signal.aborted) throw signal.reason ?? new DOMException("Operation aborted", "AbortError") const client = createInstanceClient(deps.workspaceManager, reply.instanceId) if (!client) { throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) } - const opts = { throwOnError: true } as const + const opts = { throwOnError: true, signal } as const if (reply.source === "v2") { await client.v2.session.permission.reply( diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index d31d56523..04af16b61 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -30,8 +30,9 @@ describe("OpenCode Yolo metadata", () => { }, } const persistence = createOpencodeYoloPersistence({} as never, () => client as never) - const [session] = await persistence.loadSessions("instance") - await persistence.persist("instance", "root", true, session?.workspaceId) + const signal = new AbortController().signal + const [session] = await persistence.loadSessions("instance", signal) + await persistence.persist("instance", "root", true, session?.workspaceId, signal) assert.equal(session?.workspaceId, "workspace") assert.equal(calls[0]?.workspace, "workspace") assert.equal(calls[1]?.workspace, "workspace") @@ -49,8 +50,9 @@ describe("OpenCode Yolo metadata", () => { }, } const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + const signal = new AbortController().signal await Promise.all([ - persistence.persist("instance-a", "root", true), + persistence.persist("instance-a", "root", true, undefined, signal), persistence.setWorktreeSlug("instance-b", "root", "feature"), ]) assert.deepEqual(metadata, { @@ -58,4 +60,36 @@ describe("OpenCode Yolo metadata", () => { codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" }, }) }) + + it("does not start a queued metadata write after its generation is aborted", async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let gets = 0 + let updates = 0 + const client = { + session: { + async get() { + gets += 1 + if (gets === 1) await gate + return { data: { metadata: {} } } + }, + async update() { + updates += 1 + return { data: {} } + }, + }, + } + const persistence = createOpencodeYoloPersistence({} as never, () => client as never) + const blocker = persistence.setWorktreeSlug("instance", "root", "feature") + await new Promise((resolve) => setImmediate(resolve)) + const controller = new AbortController() + const stale = persistence.persist("instance", "root", true, undefined, controller.signal) + controller.abort() + release() + + await blocker + await assert.rejects(stale, (error: Error) => error.name === "AbortError") + assert.equal(gets, 1) + assert.equal(updates, 1) + }) }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index bca929015..308116ada 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -62,14 +62,18 @@ export function createOpencodeYoloPersistence( sessionId: string, workspaceId: string | undefined, update: (metadata: unknown) => Metadata, + signal?: AbortSignal, ): Promise => { const writeKey = sessionId const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => { + throwIfAborted(signal) const client = clientFor(instanceId) const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) } - const { data: session } = await client.session.get(scope, { throwOnError: true }) + const options = { throwOnError: true, ...(signal ? { signal } : {}) } as const + const { data: session } = await client.session.get(scope, options) + throwIfAborted(signal) const metadata = update(session.metadata) - const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true }) + const { data } = await client.session.update({ ...scope, metadata }, options) return record(data?.metadata ?? metadata) }) const settled = write.finally(() => { @@ -79,10 +83,11 @@ export function createOpencodeYoloPersistence( return settled } return { - async loadSessions(instanceId): Promise { + async loadSessions(instanceId, signal): Promise { + throwIfAborted(signal) const { data } = await clientFor(instanceId).session.list( { scope: "project", limit: SESSION_LIST_LIMIT }, - { throwOnError: true }, + { throwOnError: true, signal }, ) return (data ?? []).map((session) => ({ id: session.id, @@ -92,9 +97,9 @@ export function createOpencodeYoloPersistence( yoloEnabled: hasPersistedYolo(session.id, session.metadata), })) }, - persist(instanceId, rootSessionId, enabled, workspaceId): Promise { + persist(instanceId, rootSessionId, enabled, workspaceId, signal): Promise { return updateMetadata(instanceId, rootSessionId, workspaceId, - (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined) + (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled), signal).then(() => undefined) }, async hasProjectSession(instanceId, sessionId): Promise { const { data } = await clientFor(instanceId).session.list( @@ -109,3 +114,7 @@ export function createOpencodeYoloPersistence( }, } } + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw signal.reason ?? new DOMException("Operation aborted", "AbortError") +} diff --git a/packages/server/src/workspaces/instance-client.test.ts b/packages/server/src/workspaces/instance-client.test.ts index 636bc9f1a..4799459ef 100644 --- a/packages/server/src/workspaces/instance-client.test.ts +++ b/packages/server/src/workspaces/instance-client.test.ts @@ -172,4 +172,142 @@ describe("createInstanceClient", () => { globalThis.fetch = original } }) + + it("preserves cancellation from the SDK Request signal", async () => { + const original = globalThis.fetch + let effectiveSignal: AbortSignal | null | undefined + globalThis.fetch = (async (_input: any, init: any) => { + effectiveSignal = (init as RequestInit | undefined)?.signal + return new Promise((_resolve, reject) => { + effectiveSignal?.addEventListener("abort", () => reject(effectiveSignal?.reason), { once: true }) + }) + }) as typeof fetch + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 1_000 }) + const controller = new AbortController() + const cancellation = new Error("caller cancelled") + const request = client!.global.health({ signal: controller.signal }) + await new Promise((resolve) => setImmediate(resolve)) + controller.abort(cancellation) + + const result = await Promise.race([ + request, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("cancellation was not preserved")), 200)), + ]) + assert.ok(result.error) + assert.equal(effectiveSignal?.reason, cancellation) + } finally { + globalThis.fetch = original + } + }) + + it("keeps the timeout active while the response body is stalled", async () => { + const original = globalThis.fetch + let effectiveSignal: AbortSignal | null | undefined + globalThis.fetch = (async (_input: any, init: any) => { + effectiveSignal = (init as RequestInit | undefined)?.signal + return new Response(new ReadableStream({ + start(controller) { + effectiveSignal?.addEventListener("abort", () => controller.error(effectiveSignal?.reason), { once: true }) + }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) + + await assert.rejects(Promise.race([ + client!.global.health(), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("response body outlived timeout")), 200)), + ]), (error: Error) => error.name === "TimeoutError") + assert.equal(effectiveSignal?.aborted, true) + } finally { + globalThis.fetch = original + } + }) + + it("cleans timeout and caller cancellation after the response body completes", async () => { + const original = globalThis.fetch + let effectiveSignal: AbortSignal | null | undefined + globalThis.fetch = (async (_input: any, init: any) => { + effectiveSignal = (init as RequestInit | undefined)?.signal + return new Response(JSON.stringify({ healthy: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) + const controller = new AbortController() + + await client!.global.health({ signal: controller.signal }) + await new Promise((resolve) => setTimeout(resolve, 20)) + controller.abort(new Error("late cancellation")) + + assert.equal(effectiveSignal?.aborted, false) + } finally { + globalThis.fetch = original + } + }) + + it("cleans timeout when a zero-length response body is not consumed", async () => { + const original = globalThis.fetch + let effectiveSignal: AbortSignal | null | undefined + globalThis.fetch = (async (_input: any, init: any) => { + effectiveSignal = (init as RequestInit | undefined)?.signal + return new Response(new ReadableStream({ start: (controller) => controller.close() }), { + status: 200, + headers: { "content-length": "0" }, + }) + }) as typeof fetch + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) + const controller = new AbortController() + + await client!.global.health({ signal: controller.signal }) + await new Promise((resolve) => setTimeout(resolve, 20)) + controller.abort(new Error("late cancellation")) + + assert.equal(effectiveSignal?.aborted, false) + } finally { + globalThis.fetch = original + } + }) + + it("cleans an unread response when the SDK rejects it after headers", async () => { + const original = globalThis.fetch + let effectiveSignal: AbortSignal | null | undefined + let bodyCancelled = false + globalThis.fetch = (async (_input: any, init: any) => { + effectiveSignal = (init as RequestInit | undefined)?.signal + return new Response(new ReadableStream({ + cancel() { + bodyCancelled = true + }, + }), { + status: 200, + headers: { "content-type": "text/html" }, + }) + }) as typeof fetch + try { + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) + const controller = new AbortController() + + await assert.rejects(client!.global.health({ signal: controller.signal, throwOnError: true })) + await new Promise((resolve) => setTimeout(resolve, 20)) + controller.abort(new Error("late cancellation")) + + assert.equal(bodyCancelled, true) + assert.equal(effectiveSignal?.aborted, false) + } finally { + globalThis.fetch = original + } + }) }) diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts index 378b39519..90bdabd56 100644 --- a/packages/server/src/workspaces/instance-client.ts +++ b/packages/server/src/workspaces/instance-client.ts @@ -54,13 +54,106 @@ export function createInstanceClient( baseUrl: `http://${LOOPBACK_HOST}:${port}/`, headers, fetch: (url, init) => { - const requestInit = init as RequestInit - if (requestInit?.signal) return fetch(url, requestInit) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs) - return fetch(url, { ...requestInit, signal: controller.signal }).finally(() => clearTimeout(timeout)) + const requestInit = init as RequestInit | undefined + const sources = [ + ...(url instanceof Request ? [url.signal] : []), + ...(requestInit?.signal ? [requestInit.signal] : []), + ] + const { signal, cleanup } = composeRequestSignal(sources, timeoutMs) + return fetch(url, { ...requestInit, signal }).then( + (response) => responseWithSignalCleanup(response, cleanup), + (error) => { + cleanup() + throw error + }, + ) }, ...(directory ? { directory } : {}), }) } + +function composeRequestSignal( + sources: readonly AbortSignal[], + timeoutMs: number, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController() + const listeners = new Map void>() + let timeout: ReturnType | undefined + let cleaned = false + + const cleanup = () => { + if (cleaned) return + cleaned = true + if (timeout) clearTimeout(timeout) + for (const [source, listener] of listeners) source.removeEventListener("abort", listener) + listeners.clear() + } + const abort = (reason: unknown) => { + if (!controller.signal.aborted) controller.abort(reason) + cleanup() + } + + for (const source of new Set(sources)) { + if (source.aborted) { + abort(source.reason) + break + } + const listener = () => abort(source.reason) + listeners.set(source, listener) + source.addEventListener("abort", listener, { once: true }) + } + + if (!controller.signal.aborted) { + timeout = setTimeout( + () => abort(new DOMException("Request timed out", "TimeoutError")), + timeoutMs, + ) + } + + return { signal: controller.signal, cleanup } +} + +function responseWithSignalCleanup(response: Response, cleanup: () => void): Response | Promise { + if (!response.body || response.headers.get("content-length") === "0") { + cleanup() + return response + } + + // The SDK rejects this response in an interceptor before reading its body. + if (response.headers.get("content-type") === "text/html") { + return response.body.cancel().catch(() => undefined).then(() => response).finally(cleanup) + } + + const reader = response.body.getReader() + const body = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + cleanup() + controller.close() + } else { + controller.enqueue(value) + } + } catch (error) { + cleanup() + controller.error(error) + } + }, + async cancel(reason) { + cleanup() + await reader.cancel(reason) + }, + }) + const wrapped = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + Object.defineProperties(wrapped, { + redirected: { value: response.redirected }, + type: { value: response.type }, + url: { value: response.url }, + }) + return wrapped +} diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 9e3819644..904679971 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -519,6 +519,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tempfile", + "tokio", "url", "uuid", "webkit2gtk", @@ -4691,9 +4692,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index d99219a8c..c8c223a25 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ tauri-build = { version = "2.5.2", features = [] } [dependencies] tauri = { version = "2.5.2", features = [ "devtools"] } +tokio = { version = "1", features = ["macros", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs index b5635a83a..3dcfa102c 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -1,9 +1,8 @@ use parking_lot::Mutex; -use reqwest::blocking::{Client, Response}; use reqwest::StatusCode; +use reqwest::{Client, Response}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::io::{BufRead, BufReader}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; use std::sync::Arc; @@ -23,12 +22,23 @@ const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; const FLUSH_INTERVAL_MS: u64 = 16; const DELTA_STREAM_WINDOW_MS: u64 = 48; const MAX_BATCH_EVENTS: usize = 256; +const MAX_BATCH_BYTES: usize = 2 * 1024 * 1024; const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0; const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000; const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000; -const STREAM_STALL_TIMEOUT_MS: u64 = 30_000; +const STREAM_READ_TIMEOUT_MS: u64 = 30_000; +const STREAM_STALL_TIMEOUT_MS: u64 = 35_000; +const SSE_READ_BUFFER_BYTES: usize = 8 * 1024; +const SERVER_MAX_EVENT_CHARACTERS: usize = 16 * 1024 * 1024; +const MAX_UTF8_BYTES_PER_CHARACTER: usize = 4; +const MAX_WORKSPACE_EVENT_ENVELOPE_BYTES: usize = 64 * 1024; +const MAX_SSE_LINE_BYTES: usize = + SERVER_MAX_EVENT_CHARACTERS * MAX_UTF8_BYTES_PER_CHARACTER + MAX_WORKSPACE_EVENT_ENVELOPE_BYTES; +const MAX_SSE_FRAME_BYTES: usize = MAX_SSE_LINE_BYTES + MAX_WORKSPACE_EVENT_ENVELOPE_BYTES; +const MAX_COALESCED_DELTA_BYTES: usize = 1024 * 1024; +const READER_CHANNEL_CAPACITY: usize = 1; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DesktopEventStreamConfig { @@ -198,12 +208,14 @@ enum EventDeliveryPolicy { Passthrough, } +#[derive(Debug)] enum OpenStreamErrorKind { Unauthorized, Http, Transport, } +#[derive(Debug)] struct OpenStreamError { kind: OpenStreamErrorKind, message: String, @@ -213,6 +225,7 @@ struct OpenStreamError { #[derive(Default)] struct PendingBatch { events: Vec, + estimated_bytes: usize, } impl DesktopEventTransportManager { @@ -445,7 +458,7 @@ fn snapshot_superseded_delta_scope(event: &Value) -> Option { )) } -fn append_delta(target: &mut Value, event: &Value) { +fn append_delta(target: &mut Value, event: &Value) -> bool { let next_delta = coalesced_payload_event(event) .get("properties") .and_then(|value| value.get("delta")) @@ -457,9 +470,39 @@ fn append_delta(target: &mut Value, event: &Value) { .and_then(Value::as_object_mut) .and_then(|props| props.get_mut("delta")) { - let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta; + let existing = existing_delta.as_str().unwrap_or_default(); + let Some(combined_len) = existing.len().checked_add(next_delta.len()) else { + return false; + }; + if combined_len > MAX_COALESCED_DELTA_BYTES { + return false; + } + + let mut combined = String::with_capacity(combined_len); + combined.push_str(existing); + combined.push_str(next_delta); *existing_delta = Value::String(combined); } + + true +} + +fn serialized_value_bytes(value: &Value) -> usize { + struct Counter(usize); + + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(bytes.len()); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut counter = Counter(0); + serde_json::to_writer(&mut counter, value).map_or(usize::MAX, |_| counter.0) } fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map> { @@ -475,7 +518,10 @@ fn status_key(event: &Value) -> Option { "instance.eventStatus" => Some(format!( "{}:{}", coalesced_instance_id(event), - event.get("status").and_then(Value::as_str).unwrap_or_default() + event + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() )), "session.status" => snapshot_key(event), _ => None, diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs index f91bcb760..fc9e25187 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -2,13 +2,16 @@ use super::*; impl PendingBatch { pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) { + let event_bytes = serialized_value_bytes(&event); match classify_event(&event) { EventDeliveryPolicy::CoalesceDelta(key) => { let Some(scope) = delta_scope(&event) else { self.events.push(PendingEntry::Event(event)); + self.estimated_bytes = self.estimated_bytes.saturating_add(event_bytes); return; }; + let mut replacement_bytes = None; if let Some(PendingEntry::Delta { key: existing_key, event: existing_event, @@ -16,11 +19,18 @@ impl PendingBatch { }) = self.events.last_mut() { if existing_key == &key { - append_delta(existing_event, &event); - stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); - return; + let old_bytes = serialized_value_bytes(existing_event); + if append_delta(existing_event, &event) { + replacement_bytes = + Some((old_bytes, serialized_value_bytes(existing_event))); + stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); + } } } + if let Some((old_bytes, new_bytes)) = replacement_bytes { + self.replace_bytes(old_bytes, new_bytes); + return; + } self.events.push(PendingEntry::Delta { key, @@ -28,6 +38,7 @@ impl PendingBatch { event, started_at: Instant::now(), }); + self.estimated_bytes = self.estimated_bytes.saturating_add(event_bytes); } EventDeliveryPolicy::CoalesceStatus(key) => { if let Some(PendingEntry::Status { @@ -36,13 +47,19 @@ impl PendingBatch { }) = self.events.last_mut() { if existing_key == &key { + let old_bytes = serialized_value_bytes(existing_event); *existing_event = event; + self.estimated_bytes = self + .estimated_bytes + .saturating_sub(old_bytes) + .saturating_add(event_bytes); stats.status_coalesces = stats.status_coalesces.saturating_add(1); return; } } self.events.push(PendingEntry::Status { key, event }); + self.estimated_bytes = self.estimated_bytes.saturating_add(event_bytes); } EventDeliveryPolicy::CoalesceSnapshot(key) => { if let Some(part_scope) = snapshot_superseded_delta_scope(&event) { @@ -51,7 +68,11 @@ impl PendingBatch { self.events.last(), Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope ) { - self.events.pop(); + if let Some(entry) = self.events.pop() { + self.estimated_bytes = self + .estimated_bytes + .saturating_sub(pending_entry_bytes(&entry)); + } dropped = dropped.saturating_add(1); } if dropped > 0 { @@ -66,21 +87,29 @@ impl PendingBatch { }) = self.events.last_mut() { if existing_key == &key { + let old_bytes = serialized_value_bytes(existing_event); *existing_event = event; + self.estimated_bytes = self + .estimated_bytes + .saturating_sub(old_bytes) + .saturating_add(event_bytes); stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1); return; } } self.events.push(PendingEntry::Snapshot { key, event }); + self.estimated_bytes = self.estimated_bytes.saturating_add(event_bytes); } EventDeliveryPolicy::Passthrough => { self.events.push(PendingEntry::Event(event)); + self.estimated_bytes = self.estimated_bytes.saturating_add(event_bytes); } } } pub(super) fn take_events(&mut self) -> Vec { + self.estimated_bytes = 0; let pending = std::mem::take(&mut self.events); pending .into_iter() @@ -101,6 +130,10 @@ impl PendingBatch { self.events.len() } + pub(super) fn pending_bytes(&self) -> usize { + self.estimated_bytes + } + pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool { matches!( self.events.as_slice(), @@ -109,4 +142,20 @@ impl PendingBatch { < Duration::from_millis(DELTA_STREAM_WINDOW_MS) ) } + + fn replace_bytes(&mut self, old_bytes: usize, new_bytes: usize) { + self.estimated_bytes = self + .estimated_bytes + .saturating_sub(old_bytes) + .saturating_add(new_bytes); + } +} + +fn pending_entry_bytes(entry: &PendingEntry) -> usize { + match entry { + PendingEntry::Delta { event, .. } + | PendingEntry::Status { event, .. } + | PendingEntry::Snapshot { event, .. } + | PendingEntry::Event(event) => serialized_value_bytes(event), + } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index 33737f9c8..e94de7894 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -1,17 +1,17 @@ use super::*; -use reqwest::blocking::RequestBuilder; +use reqwest::RequestBuilder; pub(super) fn build_stream_client() -> Result { + build_stream_client_with_read_timeout(Duration::from_millis(STREAM_READ_TIMEOUT_MS)) +} + +fn build_stream_client_with_read_timeout( + read_timeout: Duration, +) -> Result { Client::builder() .connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS)) + .read_timeout(read_timeout) .tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS)) - // Note: reqwest's blocking client doesn't expose a per-read timeout. - // The global `.timeout()` would kill the entire SSE stream, so we - // rely on: - // 1. tcp_keepalive to detect dead connections (OS will RST after - // several unacked probes, typically ~2 min). - // 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS). - // 3. Reader thread breaking on channel send error (consumer dropped). .build() .map_err(|error: reqwest::Error| OpenStreamError { kind: OpenStreamErrorKind::Transport, @@ -36,11 +36,14 @@ pub(super) fn open_stream( config, ); - let response = request.send().map_err(|error| OpenStreamError { - kind: OpenStreamErrorKind::Transport, - message: error.to_string(), - status_code: None, - })?; + let response = + tauri::async_runtime::block_on(async { request.send().await }).map_err(|error| { + OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + } + })?; if response.status().is_success() { return Ok(response); @@ -152,53 +155,164 @@ fn read_session_cookie_from_webview( } pub(super) fn read_sse( - response: Response, + mut response: Response, tx: SyncSender, stop: Arc, generation_atomic: Arc, generation: u64, + mut cancel: tokio::sync::oneshot::Receiver<()>, ) { - let mut reader = BufReader::new(response); - let mut line = String::new(); - let mut event_name: Option = None; - let mut data_lines: Vec = Vec::new(); - - loop { - if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { - let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); - return; - } + let mut buffer = [0_u8; SSE_READ_BUFFER_BYTES]; + let mut decoder = SseDecoder::new(MAX_SSE_LINE_BYTES, MAX_SSE_FRAME_BYTES); - line.clear(); - match reader.read_line(&mut line) { - Ok(0) => { - let _ = flush_sse_frame(&tx, &event_name, &data_lines); - let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); + tauri::async_runtime::block_on(async move { + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); return; } - Ok(_) => { - if tx.send(ReaderMessage::Activity).is_err() { - return; // consumer dropped — stop reading + + let next_chunk = tokio::select! { + _ = &mut cancel => { + let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); + return; } - let trimmed = line.trim_end_matches(['\r', '\n']); - if handle_sse_line(trimmed, &mut event_name, &mut data_lines) { - if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { - return; + chunk = response.chunk() => chunk, + }; + + match next_chunk { + Ok(None) => { + decoder.discard_frame(); + let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); + return; + } + Ok(Some(chunk)) => { + if tx.send(ReaderMessage::Activity).is_err() { + return; // consumer dropped - stop reading + } + for bytes in chunk.chunks(buffer.len()) { + buffer[..bytes.len()].copy_from_slice(bytes); + if let Err(error) = decoder.push(&buffer[..bytes.len()], &tx) { + let _ = tx.send(ReaderMessage::End(Some(error))); + return; + } } - event_name = None; - data_lines.clear(); + } + Err(error) => { + decoder.discard_frame(); + let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); + return; + } + } + } + }); +} + +struct SseDecoder { + line: Vec, + event_name: Option, + data_lines: Vec, + frame_bytes: usize, + max_line_bytes: usize, + max_frame_bytes: usize, + skip_lf: bool, +} + +impl SseDecoder { + fn new(max_line_bytes: usize, max_frame_bytes: usize) -> Self { + Self { + line: Vec::with_capacity(max_line_bytes.min(SSE_READ_BUFFER_BYTES)), + event_name: None, + data_lines: Vec::new(), + frame_bytes: 0, + max_line_bytes, + max_frame_bytes, + skip_lf: false, + } + } + + fn push(&mut self, bytes: &[u8], tx: &SyncSender) -> Result<(), String> { + for &byte in bytes { + if self.skip_lf { + self.skip_lf = false; + if byte == b'\n' { continue; } } - Err(error) => { - let _ = flush_sse_frame(&tx, &event_name, &data_lines); - let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); - return; + + match byte { + b'\r' => { + self.finish_line(tx)?; + self.skip_lf = true; + } + b'\n' => self.finish_line(tx)?, + _ => { + checked_sse_size(self.line.len(), 1, self.max_line_bytes, "SSE line")?; + self.line.push(byte); + } } } + + Ok(()) + } + + fn discard_frame(&mut self) { + self.line.clear(); + self.event_name = None; + self.data_lines.clear(); + self.frame_bytes = 0; + self.skip_lf = false; + } + + fn finish_line(&mut self, tx: &SyncSender) -> Result<(), String> { + if self.line.is_empty() { + return self.flush_frame(tx); + } + + let line_bytes = self + .line + .len() + .checked_add(1) + .ok_or_else(|| "SSE frame size overflow".to_string())?; + self.frame_bytes = checked_sse_size( + self.frame_bytes, + line_bytes, + self.max_frame_bytes, + "SSE frame", + )?; + + let line = std::str::from_utf8(&self.line) + .map_err(|error| format!("invalid UTF-8 in SSE stream: {error}"))?; + handle_sse_line(line, &mut self.event_name, &mut self.data_lines); + self.line.clear(); + Ok(()) + } + + fn flush_frame(&mut self, tx: &SyncSender) -> Result<(), String> { + flush_sse_frame(tx, &self.event_name, &self.data_lines) + .map_err(|_| "desktop event consumer dropped".to_string())?; + self.event_name = None; + self.data_lines.clear(); + self.frame_bytes = 0; + Ok(()) } } +fn checked_sse_size( + current: usize, + additional: usize, + maximum: usize, + label: &str, +) -> Result { + let next = current + .checked_add(additional) + .ok_or_else(|| format!("{label} size overflow"))?; + if next > maximum { + return Err(format!("{label} exceeded {maximum} bytes")); + } + Ok(next) +} + fn handle_sse_line( trimmed: &str, event_name: &mut Option, @@ -256,6 +370,176 @@ fn parse_sse_payload(lines: &[String]) -> Option { #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + fn decode_single_event(input: &[u8]) -> Value { + let (tx, rx) = mpsc::sync_channel(1); + let mut decoder = SseDecoder::new(256, 1024); + decoder.push(input, &tx).expect("stream should decode"); + decoder.discard_frame(); + + match rx.recv().expect("event should be emitted") { + ReaderMessage::Event(payload) => payload, + _ => panic!("expected event frame"), + } + } + + #[test] + fn decodes_lf_crlf_and_cr_only_streams() { + for input in [ + &b"data: {\"ending\":\"lf\"}\n\n"[..], + &b"data: {\"ending\":\"crlf\"}\r\n\r\n"[..], + &b"data: {\"ending\":\"cr\"}\r\r"[..], + ] { + assert!(decode_single_event(input).get("ending").is_some()); + } + } + + #[test] + fn rejects_oversized_line_when_peer_withholds_lf() { + let (tx, _rx) = mpsc::sync_channel(1); + let mut decoder = SseDecoder::new(8, 64); + + let error = decoder + .push(b"data: 123", &tx) + .expect_err("ninth byte must exceed the line limit"); + + assert_eq!(error, "SSE line exceeded 8 bytes"); + } + + #[test] + fn rejects_oversized_frame_across_bounded_lines() { + let (tx, _rx) = mpsc::sync_channel(1); + let mut decoder = SseDecoder::new(16, 15); + + let error = decoder + .push(b"data: a\ndata: b\n", &tx) + .expect_err("second line must exceed the frame limit"); + + assert_eq!(error, "SSE frame exceeded 15 bytes"); + } + + #[test] + fn server_maximum_wire_frame_fits_and_next_byte_is_rejected_without_allocation() { + let maximum_server_event_bytes = SERVER_MAX_EVENT_CHARACTERS + .checked_mul(MAX_UTF8_BYTES_PER_CHARACTER) + .and_then(|bytes| bytes.checked_add(MAX_WORKSPACE_EVENT_ENVELOPE_BYTES)) + .expect("configured SSE line limit should fit usize"); + + assert_eq!(maximum_server_event_bytes, MAX_SSE_LINE_BYTES); + assert_eq!( + checked_sse_size( + 0, + maximum_server_event_bytes, + MAX_SSE_LINE_BYTES, + "SSE line" + ), + Ok(MAX_SSE_LINE_BYTES) + ); + assert_eq!( + checked_sse_size(MAX_SSE_LINE_BYTES, 1, MAX_SSE_LINE_BYTES, "SSE line"), + Err(format!("SSE line exceeded {MAX_SSE_LINE_BYTES} bytes")) + ); + assert!(MAX_SSE_LINE_BYTES + 1 <= MAX_SSE_FRAME_BYTES); + assert_eq!( + checked_sse_size(0, MAX_SSE_FRAME_BYTES, MAX_SSE_FRAME_BYTES, "SSE frame"), + Ok(MAX_SSE_FRAME_BYTES) + ); + assert_eq!( + checked_sse_size(MAX_SSE_FRAME_BYTES, 1, MAX_SSE_FRAME_BYTES, "SSE frame"), + Err(format!("SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes")) + ); + } + + #[test] + fn discards_unterminated_frame_at_eof() { + let (tx, rx) = mpsc::sync_channel(1); + let mut decoder = SseDecoder::new(256, 1024); + decoder + .push(b"data: {\"incomplete\":true}\n", &tx) + .expect("line should decode"); + + decoder.discard_frame(); + + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + } + + #[test] + fn read_timeout_ends_a_live_but_silent_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let (release_tx, release_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("client should connect"); + let mut request = [0_u8; 1024]; + socket.read(&mut request).expect("request should arrive"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + .expect("headers should send"); + socket.flush().expect("headers should flush"); + release_rx.recv().expect("test should release server"); + }); + let client = build_stream_client_with_read_timeout(Duration::from_millis(20)) + .expect("client should build"); + let mut response = tauri::async_runtime::block_on(async { + client.get(format!("http://{address}/events")).send().await + }) + .expect("response headers should arrive"); + + let error = tauri::async_runtime::block_on(async { response.chunk().await }) + .expect_err("silent body should time out"); + + assert!(error.is_timeout(), "unexpected error: {error}"); + release_tx.send(()).expect("server should release"); + server.join().expect("server should stop"); + } + + #[test] + fn cancellation_ends_a_silent_reader_promptly() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let (release_tx, release_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("client should connect"); + let mut request = [0_u8; 1024]; + socket.read(&mut request).expect("request should arrive"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + .expect("headers should send"); + socket.flush().expect("headers should flush"); + release_rx.recv().expect("test should release server"); + }); + let client = build_stream_client_with_read_timeout(Duration::from_secs(5)) + .expect("client should build"); + let response = tauri::async_runtime::block_on(async { + client.get(format!("http://{address}/events")).send().await + }) + .expect("response headers should arrive"); + let (tx, rx) = mpsc::sync_channel(READER_CHANNEL_CAPACITY); + let stop = Arc::new(AtomicBool::new(false)); + let generation = Arc::new(AtomicU64::new(1)); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let started_at = Instant::now(); + let reader = thread::spawn(move || { + read_sse(response, tx, stop, generation, 1, cancel_rx); + }); + + cancel_tx.send(()).expect("reader should be running"); + reader.join().expect("reader should stop"); + + assert!(started_at.elapsed() < Duration::from_secs(1)); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)), + Ok(ReaderMessage::End(Some(reason))) if reason == "stopped" + )); + release_tx.send(()).expect("server should release"); + server.join().expect("server should stop"); + } #[test] fn named_ping_event_is_routed_to_ping_channel() { diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs index 09a78228b..2434e55be 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -112,6 +112,48 @@ fn coalesces_message_part_delta_events() { ); } +#[test] +fn splits_coalesced_delta_run_before_size_overflow_without_losing_content() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + let first = "a".repeat(MAX_COALESCED_DELTA_BYTES / 2); + let second = "b".repeat(MAX_COALESCED_DELTA_BYTES - first.len()); + pending.push(delta_event(&first), &mut stats); + pending.push(delta_event(&second), &mut stats); + + let PendingEntry::Delta { event, .. } = &pending.events[0] else { + panic!("expected coalesced delta"); + }; + assert_eq!( + event["event"]["properties"]["delta"].as_str().map(str::len), + Some(MAX_COALESCED_DELTA_BYTES) + ); + pending.push(delta_event("b"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0]["event"]["properties"]["delta"] + .as_str() + .map(str::len), + Some(MAX_COALESCED_DELTA_BYTES) + ); + assert_eq!(events[1]["event"]["properties"]["delta"], "b"); + assert_eq!(stats.delta_coalesces, 1); +} + +#[test] +fn tracks_cumulative_batch_bytes_for_flush_budget() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + let chunk = "x".repeat(MAX_BATCH_BYTES / 2); + + pending.push(delta_event_for("part-1", &chunk), &mut stats); + pending.push(delta_event_for("part-2", &chunk), &mut stats); + + assert!(pending.pending_bytes() >= MAX_BATCH_BYTES); +} + #[test] fn does_not_coalesce_events_across_instance_streams() { let mut pending = PendingBatch::default(); @@ -318,6 +360,7 @@ fn holds_single_delta_within_stream_window() { event: delta_event("Hello"), started_at: Instant::now(), }], + ..PendingBatch::default() }; assert!(pending.should_hold_single_delta(Instant::now())); @@ -333,6 +376,7 @@ fn flushes_single_delta_after_stream_window() { event: delta_event("Hello"), started_at, }], + ..PendingBatch::default() }; assert!(!pending.should_hold_single_delta(Instant::now())); diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs index 5f0ed2314..703051602 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -19,7 +19,8 @@ fn send_connection_pong( )) .json(&body); - let _ = attach_session_cookie(request, app, config).send(); + let request = attach_session_cookie(request, app, config); + let _ = tauri::async_runtime::block_on(async { request.send().await }); } pub(super) fn run_transport_loop( @@ -224,16 +225,18 @@ fn consume_stream( stop: Arc, stats: &mut DesktopEventTransportStats, ) -> Option { - let (tx, rx) = mpsc::sync_channel::(4096); + let (tx, rx) = mpsc::sync_channel::(READER_CHANNEL_CAPACITY); + let (reader_cancel_tx, reader_cancel_rx) = tokio::sync::oneshot::channel(); let reader_stop = stop.clone(); let reader_generation_atomic = generation_atomic.clone(); - thread::spawn(move || { + let reader = thread::spawn(move || { read_sse( response, tx, reader_stop, reader_generation_atomic, generation, + reader_cancel_rx, ) }); @@ -241,9 +244,9 @@ fn consume_stream( let mut sequence = 0_u64; let mut last_reader_activity = Instant::now(); - loop { + let disconnect_reason = loop { if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { - return Some("stopped".to_string()); + break Some("stopped".to_string()); } match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) { @@ -259,7 +262,9 @@ fn consume_stream( stats.raw_events = stats.raw_events.saturating_add(1); pending.push(event, stats); - if pending.pending_len() >= MAX_BATCH_EVENTS { + if pending.pending_len() >= MAX_BATCH_EVENTS + || pending.pending_bytes() >= MAX_BATCH_BYTES + { emit_pending_batch( app, generation, @@ -281,7 +286,7 @@ fn consume_stream( stats, ); } - return reason; + break reason; } Err(RecvTimeoutError::Timeout) => { if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) @@ -297,7 +302,7 @@ fn consume_stream( stats, ); } - return Some("stream stalled".to_string()); + break Some("stream stalled".to_string()); } if !pending.is_empty() { @@ -325,10 +330,15 @@ fn consume_stream( stats, ); } - return Some("reader disconnected".to_string()); + break Some("reader disconnected".to_string()); } } - } + }; + + drop(rx); + let _ = reader_cancel_tx.send(()); + let _ = reader.join(); + disconnect_reason } fn emit_pending_batch( diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index e38596cfe..e86ccadd0 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -18,6 +18,7 @@ import { useSpeech } from "../lib/hooks/use-speech" import { createFollowScroll } from "../lib/follow-scroll" import { inferReasoningDurationMs } from "../lib/message-timing" import type { SessionSearchMatch } from "../lib/session-search" +import { iterateTextSearchOccurrences } from "../lib/session-search-matches" import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu" import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" @@ -208,8 +209,8 @@ function getPartIdForSearchContainer(container: HTMLElement): string | undefined function applySearchMarks(root: HTMLElement, query: string, activeMatch?: SessionSearchMatch | null, scrollActive = false) { removeSearchMarks(root) - const normalizedQuery = query.trim().toLocaleLowerCase() - if (!normalizedQuery) return + const needle = query.trim() + if (!needle) return const containers = Array.from(root.querySelectorAll(".message-text, .tool-call, .message-reasoning-text")) let occurrenceInActivePart = 0 @@ -223,7 +224,7 @@ function applySearchMarks(root: HTMLElement, query: string, activeMatch?: Sessio const parent = node.parentElement if (!parent) return NodeFilter.FILTER_REJECT if (parent.closest("button, input, textarea, select, mark.session-search-match")) return NodeFilter.FILTER_REJECT - if (!node.nodeValue || !node.nodeValue.toLocaleLowerCase().includes(normalizedQuery)) return NodeFilter.FILTER_REJECT + if (!node.nodeValue || iterateTextSearchOccurrences(node.nodeValue, needle).next().done) return NodeFilter.FILTER_REJECT return NodeFilter.FILTER_ACCEPT }, }) @@ -235,25 +236,22 @@ function applySearchMarks(root: HTMLElement, query: string, activeMatch?: Sessio for (const textNode of textNodes) { const original = textNode.nodeValue ?? "" - const lower = original.toLocaleLowerCase() const fragment = document.createDocumentFragment() let cursor = 0 - while (cursor < original.length) { - const index = lower.indexOf(normalizedQuery, cursor) - if (index === -1) break - if (index > cursor) { - fragment.appendChild(document.createTextNode(original.slice(cursor, index))) + for (const occurrence of iterateTextSearchOccurrences(original, needle)) { + if (occurrence.start > cursor) { + fragment.appendChild(document.createTextNode(original.slice(cursor, occurrence.start))) } const mark = document.createElement("mark") const isActive = Boolean(canContainActiveMatch && activeMatch && occurrenceInActivePart === activeMatch.occurrence) mark.className = isActive ? "session-search-match session-search-match-active" : "session-search-match" - mark.textContent = original.slice(index, index + normalizedQuery.length) + mark.textContent = original.slice(occurrence.start, occurrence.end) fragment.appendChild(mark) if (canContainActiveMatch) { if (isActive) activeMark = mark occurrenceInActivePart += 1 } - cursor = index + normalizedQuery.length + cursor = occurrence.end } if (cursor < original.length) { fragment.appendChild(document.createTextNode(original.slice(cursor))) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index b964e9d35..6de8bb6b1 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -7,7 +7,7 @@ import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" -import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./virtual-follow-behavior" +import { canRestoreMessageScroll, isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing, shouldCancelPendingMessageScrollRestore } from "./virtual-follow-behavior" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" @@ -22,10 +22,12 @@ import { partHasRenderableText } from "../types/message" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" import { getMessageSelectionActionPosition } from "../lib/message-selection-position" -import { buildSessionSearchMatches } from "../lib/session-search" -import type { SessionSearchMatch } from "../lib/session-search" +import { createSessionSearchPager, findLastSessionSearchPage, retainSessionSearchPage, SESSION_SEARCH_PAGE_SIZE } from "../lib/session-search" +import type { SessionSearchMatch, SessionSearchPager, SessionSearchResult } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" +import { resolveToolRenderer } from "./tool-call/renderers" import { collectToolDeletionCompanionPartIds, executeBulkDeletionPlan } from "./tool-deletion-companions" +import { getLogger } from "../lib/logger" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 @@ -33,11 +35,13 @@ const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8 const SEARCH_DEBOUNCE_MS = 250 const SEARCH_MIN_CHARS = 3 const OPEN_SESSION_SEARCH_EVENT = "codenomad:open-session-search" +const log = getLogger("session") export interface MessageSectionProps { instanceId: string sessionId: string loading?: boolean + loadComplete?: boolean loadError?: string | null emptyStateVariant?: "messages" | "no-session" onRevert?: (messageId: string) => void @@ -161,7 +165,23 @@ export default function MessageSection(props: MessageSectionProps) { const [searchedQuery, setSearchedQuery] = createSignal("") const [isSearchPending, setIsSearchPending] = createSignal(false) const [searchMatches, setSearchMatches] = createSignal([]) + const [searchTotalMatches, setSearchTotalMatches] = createSignal(0) + const [searchPageOffset, setSearchPageOffset] = createSignal(0) + const [searchHasMore, setSearchHasMore] = createSignal(false) + const [searchPageIndex, setSearchPageIndex] = createSignal(0) const [activeSearchIndex, setActiveSearchIndex] = createSignal(0) + let searchGeneration = 0 + let searchAbortController: AbortController | null = null + let searchPager: SessionSearchPager | null = null + let searchPages = new Map() + let searchPagerNextPageIndex = 0 + let searchLastPageIndex: number | null = null + let searchKnownTotalMatches: number | null = null + let searchRunQuery = "" + let searchRunIncludeThinking = false + let searchRefreshTimeout: number | undefined + let searchRefreshRequested = false + let lastCompletedSearchRevision = -1 let deleteMenuRef: HTMLDivElement | undefined let deleteMenuButtonRef: HTMLButtonElement | undefined let searchInputRef: HTMLInputElement | undefined @@ -721,6 +741,12 @@ export default function MessageSection(props: MessageSectionProps) { lastGoodScrollSnapshots.set(sessionId, snapshot) } + function cancelPendingScrollRestoreFromUser() { + if (!shouldCancelPendingMessageScrollRestore(didRestoreScroll(), restoringScrollSnapshot)) return + scrollRestoreGeneration += 1 + setDidRestoreScroll(true) + } + createEffect( on( () => props.sessionId, @@ -884,10 +910,18 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!element || !api) return if (!isActive()) return - if (visibleMessageIds().length === 0) return + const visibleIds = visibleMessageIds() + if (visibleIds.length === 0) return if (didRestoreScroll()) return const snapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + const failedLoadAnchorAvailable = !snapshot?.anchorKey || visibleIds.includes(snapshot.anchorKey) + if (!canRestoreMessageScroll( + Boolean(props.loading), + props.loadComplete, + Boolean(props.loadError), + failedLoadAnchorAvailable, + )) return if (!snapshot) { api.setAutoScroll(true) api.scrollToBottom({ immediate: true }) @@ -944,19 +978,164 @@ export default function MessageSection(props: MessageSectionProps) { } function closeSearch() { + cancelSearchRun() setIsSearchOpen(false) setSearchQuery("") setDebouncedSearchQuery("") setSearchedQuery("") setIsSearchPending(false) setSearchMatches([]) + setSearchTotalMatches(0) + setSearchPageOffset(0) + setSearchHasMore(false) + setSearchPageIndex(0) setActiveSearchIndex(0) } + function cancelSearchRun() { + searchGeneration += 1 + searchAbortController?.abort() + searchAbortController = null + searchPager = null + searchPages.clear() + searchPagerNextPageIndex = 0 + searchLastPageIndex = null + searchKnownTotalMatches = null + searchRunQuery = "" + if (searchRefreshTimeout !== undefined) window.clearTimeout(searchRefreshTimeout) + searchRefreshTimeout = undefined + searchRefreshRequested = false + } + + function scheduleSearchRefresh() { + if (searchRefreshTimeout !== undefined) window.clearTimeout(searchRefreshTimeout) + searchRefreshTimeout = window.setTimeout(() => { + searchRefreshTimeout = undefined + if (isSearchPending()) { + searchRefreshRequested = true + return + } + const query = debouncedSearchQuery() + if (query.trim().length < SEARCH_MIN_CHARS) return + void startSearch(query, Boolean(preferences().showThinkingBlocks)) + }, SEARCH_DEBOUNCE_MS) + } + + async function startSearch(query: string, includeThinking: boolean) { + cancelSearchRun() + const generation = searchGeneration + const controller = new AbortController() + searchAbortController = controller + searchRunQuery = query + searchRunIncludeThinking = includeThinking + resetSearchPager() + searchPages.clear() + await loadSearchPage(query, generation, 0, 0) + } + + function resetSearchPager() { + const controller = searchAbortController + if (!controller) return + searchPager = createSessionSearchPager({ + store: store(), + sessionId: props.sessionId, + query: searchRunQuery, + includeThinking: searchRunIncludeThinking, + resolveToolSearchText: (context) => resolveToolRenderer(context.toolName).getSearchText?.(context), + limit: SESSION_SEARCH_PAGE_SIZE, + signal: controller.signal, + }) + searchPagerNextPageIndex = 0 + } + + async function readSearchPage(pageIndex: number): Promise { + const cached = searchPages.get(pageIndex) + if (cached) { + retainSessionSearchPage(searchPages, pageIndex, cached) + return cached + } + if (pageIndex < searchPagerNextPageIndex) resetSearchPager() + if (!searchPager) throw new Error("Session search pager unavailable") + + while (searchPagerNextPageIndex <= pageIndex) { + const currentIndex = searchPagerNextPageIndex + const result = await searchPager.nextPage() + retainSessionSearchPage(searchPages, currentIndex, result) + searchPagerNextPageIndex += 1 + if (result.totalMatches !== null) searchKnownTotalMatches = result.totalMatches + if (!result.hasMore) searchLastPageIndex = currentIndex + if (!result.hasMore && currentIndex < pageIndex) throw new Error("Session search page unavailable") + } + return searchPages.get(pageIndex)! + } + + async function loadSearchPage(query: string, generation: number, requestedPage: number | "last", targetIndex: number) { + const startedRevision = store().getSessionRevision(props.sessionId) + const controller = searchAbortController + if (!controller || !searchPager) return + setIsSearchPending(true) + + try { + const resolved = requestedPage === "last" + ? searchLastPageIndex === null + ? await findLastSessionSearchPage(readSearchPage) + : { pageIndex: searchLastPageIndex, result: await readSearchPage(searchLastPageIndex) } + : { pageIndex: requestedPage, result: await readSearchPage(requestedPage) } + if (generation !== searchGeneration || controller.signal.aborted) return + const { pageIndex, result } = resolved + setSearchMatches(result.matches) + setSearchTotalMatches(result.totalMatches ?? searchKnownTotalMatches) + setSearchPageOffset(result.offset) + setSearchHasMore(result.hasMore) + setSearchPageIndex(pageIndex) + setSearchedQuery(query) + setActiveSearchIndex(Math.min(Math.max(targetIndex, 0), Math.max(result.matches.length - 1, 0))) + lastCompletedSearchRevision = startedRevision + } catch (error) { + if (generation !== searchGeneration || controller.signal.aborted || (error as Error)?.name === "AbortError") return + log.error("Failed to search session", error) + lastCompletedSearchRevision = store().getSessionRevision(props.sessionId) + searchRefreshRequested = false + setSearchMatches([]) + setSearchTotalMatches(0) + setSearchPageOffset(0) + setSearchHasMore(false) + setSearchPageIndex(0) + setSearchedQuery(query) + setActiveSearchIndex(0) + } finally { + if (generation === searchGeneration) { + setIsSearchPending(false) + if (searchRefreshRequested || store().getSessionRevision(props.sessionId) !== lastCompletedSearchRevision) { + searchRefreshRequested = false + scheduleSearchRefresh() + } + } + } + } + function moveSearchMatch(direction: 1 | -1) { - const count = searchMatches().length - if (count === 0) return - setActiveSearchIndex((index) => (index + direction + count) % count) + const matches = searchMatches() + if (matches.length === 0 || isSearchPending()) return + const target = activeSearchIndex() + direction + if (target >= 0 && target < matches.length) { + setActiveSearchIndex(target) + return + } + const page = searchPageIndex() + if (direction === 1 && searchHasMore()) { + void loadSearchPage(searchedQuery(), searchGeneration, page + 1, 0) + return + } + if (direction === -1 && page > 0) { + void loadSearchPage(searchedQuery(), searchGeneration, page - 1, Number.MAX_SAFE_INTEGER) + return + } + if (direction === 1) { + void loadSearchPage(searchedQuery(), searchGeneration, 0, 0) + return + } + void loadSearchPage(searchedQuery(), searchGeneration, "last", Number.MAX_SAFE_INTEGER) } function isSelectionWithinStream(range: Range | null) { @@ -1277,12 +1456,18 @@ export default function MessageSection(props: MessageSectionProps) { createEffect(() => { const query = searchQuery() + cancelSearchRun() + lastCompletedSearchRevision = -1 if (query.trim().length < SEARCH_MIN_CHARS) { setDebouncedSearchQuery("") setActiveSearchIndex(0) setSearchedQuery("") setIsSearchPending(false) setSearchMatches([]) + setSearchTotalMatches(0) + setSearchPageOffset(0) + setSearchHasMore(false) + setSearchPageIndex(0) return } setIsSearchPending(true) @@ -1293,7 +1478,6 @@ export default function MessageSection(props: MessageSectionProps) { }) createEffect(() => { - sessionRevision() const query = debouncedSearchQuery() const includeThinking = Boolean(preferences().showThinkingBlocks) if (query.trim().length < SEARCH_MIN_CHARS) { @@ -1302,18 +1486,19 @@ export default function MessageSection(props: MessageSectionProps) { setIsSearchPending(true) const frame = requestAnimationFrame(() => { - const matches = buildSessionSearchMatches({ - store: store(), - sessionId: props.sessionId, - query, - includeThinking, - }) - setSearchMatches(matches) - setSearchedQuery(query) - setActiveSearchIndex(0) - setIsSearchPending(false) + void startSearch(query, includeThinking) }) - onCleanup(() => cancelAnimationFrame(frame)) + onCleanup(() => { + cancelAnimationFrame(frame) + cancelSearchRun() + }) + }) + + createEffect(() => { + const revision = sessionRevision() + const query = debouncedSearchQuery() + if (query.trim().length < SEARCH_MIN_CHARS || lastCompletedSearchRevision < 0) return + if (revision !== lastCompletedSearchRevision) scheduleSearchRefresh() }) createEffect(() => { @@ -1456,6 +1641,7 @@ export default function MessageSection(props: MessageSectionProps) { clearQuoteSelection() persistMessageScrollSnapshot() }} + onUserScrollIntent={cancelPendingScrollRestoreFromUser} onMouseUp={() => handleStreamMouseUp()} onClick={(e) => { if (selectedTimelineIds().size === 0) return @@ -1528,7 +1714,10 @@ export default function MessageSection(props: MessageSectionProps) {