From fd9b5b46a913c515e94e4e26f185d43866c2581f Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 15 Aug 2026 08:43:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Codex=20=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B=E7=BC=A9=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260814-codex-compaction-ux-74b3f2a1.md | 61 +++ README.md | 2 +- src/lib/opencode-session-state.ts | 361 ++++++++++++++++- .../Chat/ContextCompactionTimelineItem.tsx | 39 ++ src/pages/Chat/OpencodeChatPanel.tsx | 76 +++- .../Chat/session-transcript-view-model.ts | 73 ++++ src/stores/opencode.ts | 242 ++++++++++-- src/styles/globals.css | 31 ++ src/types/opencode.ts | 26 ++ tests/e2e/opencode-slash-commands.spec.ts | 365 +++++++++++++++++- tests/unit/opencode-chat-panel.test.tsx | 302 ++++++++++++++- tests/unit/opencode-session-state.test.ts | 261 +++++++++++++ tests/unit/opencode-store.test.ts | 278 ++++++++++++- 13 files changed, 2044 insertions(+), 73 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260814-codex-compaction-ux-74b3f2a1.md create mode 100644 src/pages/Chat/ContextCompactionTimelineItem.tsx diff --git a/.project-docs/30-worklog/tasks/20260814-codex-compaction-ux-74b3f2a1.md b/.project-docs/30-worklog/tasks/20260814-codex-compaction-ux-74b3f2a1.md new file mode 100644 index 0000000..46cef7b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260814-codex-compaction-ux-74b3f2a1.md @@ -0,0 +1,61 @@ +# Task: Implement Codex-style context compaction interaction + +## Identity + +- Task ID: 20260814-codex-compaction-ux-74b3f2a1 +- Mode: Feature +- Branch: codex/20260814-codex-compaction-ux-74b3f2a1-codex-compaction-ux +- Worktree: D:\Datas\OthersProjects\makelore-codex-compaction-ux-74b3f2a1 +- Base commit: 953b0f491e44036bc8273fb29dd880e38defae24 +- Owner: codex +- Status: Completed + +## Scope + +- Replace the transient top-of-transcript context-compaction notice in Makelore Code with Codex-style chronological timeline events. +- Model manual and automatic compaction as project-scoped, per-session transcript state that survives completion and history hydration. +- Preserve the existing composer, stop, queued-prompt, session-switching, OpenCode API, provider, and model-threshold behavior. +- Add focused reducer, Store, Renderer, and Electron E2E coverage and synchronize README behavior documentation. + +## Intent And Constraints + +- Use the locally installed Codex Desktop 26.810.4967 interaction as the concrete reference: a muted inline activity item while running and a static item retained at the same chronological position when complete. +- Keep one canonical compaction timeline source of truth; remove the parallel `compactingSessionIds` boolean after migration. +- Keep UI event identity immutable while associating native Part/event identities separately for replay deduplication. +- Correlate completion and failure using session generation, run token, and native identities so stale events cannot mutate a newer run. +- `session.compacted` completes only the matching compaction item; it is not full run idle and must never release queued prompts early. +- Historical completed items are static content; only a real-time running-to-completed transition may use a live region. +- Keep changes surgical and inside the isolated owned worktree. + +## Outcome + +- Replaced the transient top notice with persistent, chronologically anchored compaction timeline items for both manual and automatic compaction. +- Added a canonical per-session compaction state model with immutable UI identity, native Part/event association, history hydration, replay deduplication, and monotonic running-to-completed status. +- Kept compaction observation separate from run idleness: `session.compacted` completes the matching item, while only real idle state releases queued prompts. +- Added failure, abort, POST rejection, runtime-generation, stale-event, background hydration, scroll, accessibility, and multi-session handling. +- Updated README behavior documentation and Electron E2E coverage. + +## Verification + +- Baseline before implementation: `pnpm test` — 156 files, 1691 tests passed. +- Focused final tests: `pnpm exec vitest run tests/unit/opencode-session-state.test.ts tests/unit/opencode-store.test.ts tests/unit/opencode-chat-panel.test.tsx` — 3 files, 236 tests passed. +- Full unit suite: `pnpm test` — 156 files, 1712 tests passed. +- Type check: `pnpm run typecheck` — passed. +- Lint: `pnpm run lint:check` — passed with 0 errors and 6 pre-existing warnings. +- Production compile: `pnpm run build:vite` — passed; only existing chunk-size and dynamic/static import warnings remained. +- Electron E2E: `pnpm exec playwright test tests/e2e/opencode-slash-commands.spec.ts` — 3 tests passed. +- Whitespace validation: `git diff --check` — passed. +- Final read-only Sol review: PASS after adding a regression guard against completed-to-running hydration downgrades. + +## Follow-ups + +- Non-blocking: when validating a future OpenCode runtime upgrade, run a real long-context smoke test to reconfirm the native compaction Part fields (`auto`, Part identity, and completion timing). The current E2E uses a controlled EventSource through the real Store/Renderer lifecycle rather than launching the OpenCode process. + +## Promotion Candidates + +- Target: README/current architecture documentation. + Proposal: treat context compaction as a persistent per-session transcript event, and preserve the invariant that `session.compacted` is not equivalent to `session.idle`. + Evidence: reducer, Store, Renderer, and Electron E2E coverage in this task; final Sol review PASS. + Future impact: OpenCode runtime upgrades must preserve native Part/replay correlation and must not let compaction completion release the active run queue. + Semantic conflict: none found against current project memory. + Human confirmation: not required; the user explicitly approved implementing the Codex-referenced interaction. diff --git a/README.md b/README.md index 9025da2..ee21f2f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 - 不同 Session 由 OpenCode 自己并发运行;同一 Session 的后续消息按顺序排队。Makelore 只展示运行中、待处理和未读状态,不增加额外的全局并发锁。 - 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。 -- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩沿用 OpenCode 自动机制,在最新消息位置显示“正在压缩上下文”。 +- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。 ## 兼容标识 diff --git a/src/lib/opencode-session-state.ts b/src/lib/opencode-session-state.ts index 15cc2da..09e5765 100644 --- a/src/lib/opencode-session-state.ts +++ b/src/lib/opencode-session-state.ts @@ -1,4 +1,6 @@ import type { + OpencodeCompactionSource, + OpencodeCompactionTimelineEvent, OpencodeNativePartKind, OpencodeNativeToolState, OpencodeNormalizedSessionMessage, @@ -13,6 +15,31 @@ export interface OpenCodeEventEnvelope { payload: unknown; } +export interface OpencodeCompactionMatch { + id?: string; + nativePartID?: string; + nativeEventID?: string; + runID?: string; + generation?: number; +} + +export interface OpencodePendingCompactionInput extends OpencodeCompactionMatch { + source: OpencodeCompactionSource; + anchorMessageID?: string; + anchorPartID?: string; + startedAt?: number; +} + +export interface OpencodeCompactionCompletion { + nativePartID?: string; + nativeEventID?: string; + anchorMessageID?: string; + anchorPartID?: string; + runID?: string; + generation?: number; + completedAt?: number; +} + const PART_TYPES = new Set([ 'text', 'reasoning', @@ -79,6 +106,30 @@ function getPartId(value: unknown): string | undefined { ?? nonEmptyString(asRecord(record?.part)?.partID); } +function getNativeEventId(value: unknown): string | undefined { + const record = asRecord(value); + const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence) + ? String(record.sequence) + : nonEmptyString(record?.sequence); + return nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence; +} + +function getRunId(value: unknown): string | undefined { + const record = asRecord(value); + return nonEmptyString(record?.runID) + ?? nonEmptyString(record?.runId) + ?? nonEmptyString(asRecord(record?.part)?.runID) + ?? nonEmptyString(asRecord(record?.part)?.runId); +} + +function getGeneration(value: unknown): number | undefined { + const record = asRecord(value); + const generation = record?.generation ?? asRecord(record?.part)?.generation; + return typeof generation === 'number' && Number.isFinite(generation) + ? generation + : undefined; +} + function toMilliseconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value; @@ -217,9 +268,197 @@ function createEmptyState(sessionID: string): OpencodeSessionTranscriptState { messageOrder: [], partsById: {}, partOrderByMessageId: {}, + compactionsById: {}, + compactionOrder: [], }; } +function nextCompactionOrder(state: OpencodeSessionTranscriptState): number { + return state.compactionOrder.reduce((highest, id) => ( + Math.max(highest, state.compactionsById[id]?.order ?? -1) + ), -1) + 1; +} + +function matchesCompaction( + event: OpencodeCompactionTimelineEvent, + match: OpencodeCompactionMatch, +): boolean { + const entries = Object.entries(match).filter(([, value]) => value !== undefined); + return entries.length > 0 && entries.every(([key, value]) => ( + event[key as keyof OpencodeCompactionMatch] === value + )); +} + +function findCompactionId( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch, + runningOnly = false, +): string | undefined { + return [...state.compactionOrder].reverse().find((id) => { + const event = state.compactionsById[id]; + return event + && (!runningOnly || event.status === 'running') + && matchesCompaction(event, match); + }); +} + +function replaceCompaction( + state: OpencodeSessionTranscriptState, + event: OpencodeCompactionTimelineEvent, +): OpencodeSessionTranscriptState { + return { + ...state, + compactionsById: { ...state.compactionsById, [event.id]: event }, + compactionOrder: state.compactionOrder.includes(event.id) + ? state.compactionOrder + : [...state.compactionOrder, event.id], + }; +} + +/** Add a renderer-owned running event before its native Part is available. */ +export function createPendingCompaction( + state: OpencodeSessionTranscriptState, + input: OpencodePendingCompactionInput, +): OpencodeSessionTranscriptState { + const match: OpencodeCompactionMatch = { + ...(input.id ? { id: input.id } : {}), + ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), + ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), + ...(input.runID ? { runID: input.runID } : {}), + ...(input.generation !== undefined ? { generation: input.generation } : {}), + }; + const existingID = findCompactionId(state, match, true); + if (existingID) return state; + const order = nextCompactionOrder(state); + const id = input.id + ?? `compaction:${state.sessionID}:${input.runID ?? input.generation ?? 'pending'}:${order}`; + if (state.compactionsById[id]) return state; + return replaceCompaction(state, { + id, + sessionID: state.sessionID, + source: input.source, + status: 'running', + order, + ...(input.anchorMessageID ? { anchorMessageID: input.anchorMessageID } : {}), + ...(input.anchorPartID ? { anchorPartID: input.anchorPartID } : {}), + ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), + ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), + ...(input.runID ? { runID: input.runID } : {}), + ...(input.generation !== undefined ? { generation: input.generation } : {}), + ...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}), + }); +} + +/** Complete the precisely matched event, or the latest running event if no match is supplied. */ +export function completeCompaction( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch = {}, + completion: OpencodeCompactionCompletion = {}, +): OpencodeSessionTranscriptState { + const hasMatch = Object.values(match).some((value) => value !== undefined); + const id = hasMatch + ? findCompactionId(state, match, true) + : [...state.compactionOrder].reverse().find((candidateID) => ( + state.compactionsById[candidateID]?.status === 'running' + )); + if (!id) return state; + const current = state.compactionsById[id]; + return replaceCompaction(state, { + ...current, + status: 'completed', + ...(completion.nativePartID ? { nativePartID: completion.nativePartID } : {}), + ...(completion.nativeEventID ? { nativeEventID: completion.nativeEventID } : {}), + ...(completion.anchorMessageID ? { anchorMessageID: completion.anchorMessageID } : {}), + ...(completion.anchorPartID ? { anchorPartID: completion.anchorPartID } : {}), + ...(completion.runID ? { runID: completion.runID } : {}), + ...(completion.generation !== undefined ? { generation: completion.generation } : {}), + ...(completion.completedAt !== undefined ? { completedAt: completion.completedAt } : {}), + }); +} + +/** Remove a failed/cancelled event without relying on its mutable native correlation. */ +export function removeCompaction( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch, +): OpencodeSessionTranscriptState { + const id = findCompactionId(state, match); + if (!id) return state; + const compactionsById = { ...state.compactionsById }; + delete compactionsById[id]; + return { + ...state, + compactionsById, + compactionOrder: state.compactionOrder.filter((candidateID) => candidateID !== id), + }; +} + +function compactionSource(part: OpencodeNormalizedSessionPart): OpencodeCompactionSource { + const raw = part.raw ?? {}; + return raw.source === 'manual' || raw.auto === false ? 'manual' : 'automatic'; +} + +function upsertCompactionPart( + state: OpencodeSessionTranscriptState, + part: OpencodeNormalizedSessionPart, + nativeEventID?: string, +): OpencodeSessionTranscriptState { + if (part.type !== 'compaction') return state; + const raw = part.raw ?? {}; + const runID = getRunId(raw); + const generation = getGeneration(raw); + const source = compactionSource(part); + const correlatedID = findCompactionId(state, { nativePartID: part.id }); + let id = correlatedID; + if (!id && source === 'manual') { + id = [...state.compactionOrder].reverse().find((candidateID) => { + const candidate = state.compactionsById[candidateID]; + return candidate?.source === 'manual' + && candidate.status === 'running' + && !candidate.nativePartID + && (runID === undefined || candidate.runID === undefined || candidate.runID === runID) + && (generation === undefined + || candidate.generation === undefined + || candidate.generation === generation); + }); + } + if (!id) { + id = `compaction:${state.sessionID}:part:${part.id}`; + } + let next = state; + if (!correlatedID) { + for (const candidateID of state.compactionOrder) { + if (candidateID === id) continue; + if (state.compactionsById[candidateID]?.status !== 'running') continue; + next = completeCompaction(next, { id: candidateID }); + } + } + const existing = next.compactionsById[id]; + const endedAt = part.time?.end; + return replaceCompaction(next, { + id, + sessionID: state.sessionID, + source: existing?.source ?? source, + status: endedAt !== undefined ? 'completed' : existing?.status ?? 'running', + order: existing?.order ?? nextCompactionOrder(next), + anchorMessageID: part.messageID, + anchorPartID: part.id, + nativePartID: part.id, + ...(nativeEventID ? { nativeEventID } : existing?.nativeEventID + ? { nativeEventID: existing.nativeEventID } + : {}), + ...(runID ? { runID } : existing?.runID ? { runID: existing.runID } : {}), + ...(generation !== undefined ? { generation } : existing?.generation !== undefined + ? { generation: existing.generation } + : {}), + ...(part.time?.start !== undefined ? { startedAt: part.time.start } : existing?.startedAt !== undefined + ? { startedAt: existing.startedAt } + : {}), + ...(endedAt !== undefined ? { completedAt: endedAt } : existing?.completedAt !== undefined + ? { completedAt: existing.completedAt } + : {}), + }); +} + function ensureMessage( state: OpencodeSessionTranscriptState, input: unknown, @@ -255,10 +494,11 @@ function ensureMessage( function upsertPart( state: OpencodeSessionTranscriptState, part: OpencodeNormalizedSessionPart, + nativeEventID?: string, ): OpencodeSessionTranscriptState { const message = state.messagesById[part.messageID]; const currentOrder = state.partOrderByMessageId[part.messageID] ?? []; - return { + const next = { ...state, partsById: { ...state.partsById, @@ -282,6 +522,7 @@ function upsertPart( : [...currentOrder, part.id], }, }; + return upsertCompactionPart(next, part, nativeEventID); } function removePart( @@ -293,7 +534,7 @@ function removePart( const message = state.messagesById[part.messageID]; const nextParts = { ...state.partsById }; delete nextParts[partID]; - return { + const next = { ...state, partsById: nextParts, messagesById: message @@ -311,6 +552,9 @@ function removePart( .filter((id) => id !== partID), }, }; + return part.type === 'compaction' + ? removeCompaction(next, { nativePartID: partID }) + : next; } function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined { @@ -411,6 +655,7 @@ export function hydrateOpenCodeSession( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', + current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { const state = createEmptyState(sessionID); if (!Array.isArray(messages)) return { ...state, status }; @@ -426,6 +671,69 @@ export function hydrateOpenCodeSession( if (part) next = upsertPart(next, part); } } + const snapshotOrder = [...next.compactionOrder]; + snapshotOrder.forEach((id, index) => { + const snapshotEvent = next.compactionsById[id]; + const existingID = snapshotEvent.nativePartID + ? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID }) + : undefined; + const pendingID = !existingID && snapshotEvent.source === 'manual' + ? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => { + const candidate = current?.compactionsById[candidateID]; + return candidate?.source === 'manual' + && candidate.status === 'running' + && !candidate.nativePartID + && (snapshotEvent.runID === undefined + || candidate.runID === undefined + || candidate.runID === snapshotEvent.runID) + && (snapshotEvent.generation === undefined + || candidate.generation === undefined + || candidate.generation === snapshotEvent.generation); + }) + : undefined; + const preservedID = existingID ?? pendingID; + const existing = preservedID ? current?.compactionsById[preservedID] : undefined; + const idToUse = preservedID ?? id; + const isLatestActive = status !== 'idle' && index === snapshotOrder.length - 1; + const completed = existing?.status === 'completed' + || snapshotEvent.completedAt !== undefined + || !isLatestActive; + if (idToUse !== id) { + const compactionsById = { ...next.compactionsById }; + delete compactionsById[id]; + next = { + ...next, + compactionsById, + compactionOrder: next.compactionOrder.map((candidateID) => ( + candidateID === id ? idToUse : candidateID + )), + }; + } + next = replaceCompaction(next, { + ...snapshotEvent, + ...existing, + id: idToUse, + status: completed ? 'completed' : 'running', + order: index, + anchorMessageID: snapshotEvent.anchorMessageID, + anchorPartID: snapshotEvent.anchorPartID, + nativePartID: snapshotEvent.nativePartID, + ...(snapshotEvent.nativeEventID ? { nativeEventID: snapshotEvent.nativeEventID } : {}), + ...(snapshotEvent.runID ? { runID: snapshotEvent.runID } : {}), + ...(snapshotEvent.generation !== undefined ? { generation: snapshotEvent.generation } : {}), + ...(snapshotEvent.startedAt !== undefined ? { startedAt: snapshotEvent.startedAt } : {}), + ...(snapshotEvent.completedAt !== undefined ? { completedAt: snapshotEvent.completedAt } : {}), + }); + }); + + if (status !== 'idle' && current) { + for (const id of current.compactionOrder) { + const pending = current.compactionsById[id]; + if (!pending || pending.status !== 'running' || pending.nativePartID) continue; + if (next.compactionsById[id]) continue; + next = replaceCompaction(next, { ...pending, order: nextCompactionOrder(next) }); + } + } return { ...next, status }; } @@ -434,8 +742,9 @@ export function hydrateSessionSnapshot( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', + current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { - return hydrateOpenCodeSession(sessionID, messages, status); + return hydrateOpenCodeSession(sessionID, messages, status, current); } export function reduceOpenCodeEvent( @@ -480,7 +789,7 @@ export function reduceOpenCodeEvent( const messageID = part?.messageID || getMessageId(event.payload); if (!part || !messageID) return state; const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID); - return upsertPart(withMessage, { ...part, messageID }); + return upsertPart(withMessage, { ...part, messageID }, getNativeEventId(event.payload)); } if (event.type === 'session.status') { @@ -491,14 +800,42 @@ export function reduceOpenCodeEvent( status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status, }; } - if (event.type === 'session.idle' || event.type === 'session.compacted') { - return { ...state, status: 'idle' }; + if (event.type === 'session.idle') { + return { ...completeCompaction(state), status: 'idle' }; + } + if (event.type === 'session.compacted') { + const payload = asRecord(event.payload); + const match: OpencodeCompactionMatch = { + ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), + ...(getGeneration(event.payload) !== undefined + ? { generation: getGeneration(event.payload) } + : {}), + }; + const completedAt = toMilliseconds( + asRecord(payload?.time)?.completed + ?? asRecord(payload?.time)?.end + ?? payload?.completedAt + ?? payload?.timestamp, + ); + return completeCompaction(state, match, { + ...(getNativeEventId(event.payload) ? { nativeEventID: getNativeEventId(event.payload) } : {}), + ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), + ...(getGeneration(event.payload) !== undefined + ? { generation: getGeneration(event.payload) } + : {}), + ...(completedAt !== undefined ? { completedAt } : {}), + }); } if (event.type === 'session.error') { const payload = asRecord(event.payload); const error = payload?.error; + let next = state; + for (const id of state.compactionOrder) { + if (next.compactionsById[id]?.status !== 'running') continue; + next = removeCompaction(next, { id }); + } return { - ...state, + ...next, status: 'idle', lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000), }; @@ -519,3 +856,13 @@ export function getOrderedSessionParts( export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number { return state ? Object.keys(state.partsById).length : 0; } + +export function getOrderedSessionCompactions( + state: OpencodeSessionTranscriptState | undefined, +): OpencodeCompactionTimelineEvent[] { + if (!state) return []; + return state.compactionOrder + .map((id) => state.compactionsById[id]) + .filter((event): event is OpencodeCompactionTimelineEvent => Boolean(event)) + .sort((left, right) => left.order - right.order); +} diff --git a/src/pages/Chat/ContextCompactionTimelineItem.tsx b/src/pages/Chat/ContextCompactionTimelineItem.tsx new file mode 100644 index 0000000..25929f6 --- /dev/null +++ b/src/pages/Chat/ContextCompactionTimelineItem.tsx @@ -0,0 +1,39 @@ +import { Sparkles } from 'lucide-react'; +import type { OpencodeCompactionTimelineEvent } from '@/types/opencode'; +import { cn } from '@/lib/utils'; + +function getCompactionLabel(event: OpencodeCompactionTimelineEvent): string { + if (event.source === 'manual') { + return event.status === 'running' ? '正在压缩上下文' : '已压缩上下文'; + } + return event.status === 'running' ? '正在优化对话' : '已优化对话'; +} + +export function ContextCompactionTimelineItem({ + event, + announce, +}: { + event: OpencodeCompactionTimelineEvent; + announce: boolean; +}) { + const running = event.status === 'running'; + const liveRegionProps = announce + ? { role: 'status' as const, 'aria-live': 'polite' as const, 'aria-atomic': true } + : {}; + + return ( +
+
+ ); +} diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index a377386..be4c775 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -16,6 +16,7 @@ import { } from './ChatCommandDialogs'; import { ChatCommandPalette } from './ChatCommandPalette'; import { ChatMessage } from './ChatMessage'; +import { ContextCompactionTimelineItem } from './ContextCompactionTimelineItem'; import { ComposerAttachmentCard } from './ComposerAttachmentCard'; import { ExecutionGraphCard } from './ExecutionGraphCard'; import { GameAssetBrowser } from './GameAssetBrowser'; @@ -48,6 +49,7 @@ import { } from './message-utils'; import { deriveTaskSteps, type TaskStep } from './task-visualization'; import { + buildSessionTranscriptTimeline, projectAssistantTextWithNativeParts, } from './session-transcript-view-model'; import { @@ -821,7 +823,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const sessionTranscript = useOpencodeStore((state) => ( selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined )); - const compactingSessionIds = useOpencodeStore((state) => state.compactingSessionIds); const sessionMessages = useOpencodeStore((state) => state.sessionMessages); const streamingMessage = useOpencodeStore((state) => state.streamingMessage); const streamingTools = useOpencodeStore((state) => state.streamingTools); @@ -939,6 +940,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const messageElementsRef = useRef(new Map()); const undoOriginatingDraftRef = useRef(''); const transcriptShouldStickRef = useRef(true); + const seenCompactionIdsBySessionRef = useRef(new Map>()); + const lastCompactionAnnouncementSessionRef = useRef(null); const composerAttachmentInputRef = useRef(null); const composerStopArmTimerRef = useRef(null); const userModelConfigSyncRef = useRef | null>(null); @@ -1081,6 +1084,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro selectedSessionRunning, ); }, [selectedSessionRunning, sessionMessages, streamingMessage]); + const transcriptTimeline = useMemo(() => buildSessionTranscriptTimeline( + visibleTranscriptMessages, + sessionTranscript, + ), [sessionTranscript, visibleTranscriptMessages]); + const transcriptCompactions = useMemo(() => transcriptTimeline.flatMap((item) => ( + item.type === 'compaction' ? [item.event] : [] + )), [transcriptTimeline]); + const compactionTimelineVersion = transcriptCompactions + .map((event) => `${event.id}:${event.status}`) + .join('|'); + const [liveCompactionIds, setLiveCompactionIds] = useState>( + () => new Set(), + ); const undoableUserMessage = findUndoableUserMessage( visibleTranscriptMessages, currentSession?.revert?.messageID, @@ -1363,6 +1379,31 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro } }, [selectedSessionId]); + useEffect(() => { + if (!selectedSessionId) { + lastCompactionAnnouncementSessionRef.current = null; + setLiveCompactionIds(new Set()); + return; + } + + const seen = seenCompactionIdsBySessionRef.current.get(selectedSessionId) ?? new Set(); + const sessionChanged = lastCompactionAnnouncementSessionRef.current !== selectedSessionId; + lastCompactionAnnouncementSessionRef.current = selectedSessionId; + const newRunningIds = sessionChanged ? [] : transcriptCompactions + .filter((event) => event.status === 'running' && !seen.has(event.id)) + .map((event) => event.id); + for (const event of transcriptCompactions) seen.add(event.id); + seenCompactionIdsBySessionRef.current.set(selectedSessionId, seen); + + if (sessionChanged) { + setLiveCompactionIds(new Set()); + return; + } + + if (newRunningIds.length === 0) return; + setLiveCompactionIds((current) => new Set([...current, ...newRunningIds])); + }, [selectedSessionId, transcriptCompactions]); + useEffect(() => () => { if (composerStopArmTimerRef.current !== null) { window.clearTimeout(composerStopArmTimerRef.current); @@ -1413,7 +1454,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro setHasUnreadTranscript(false); return; } - if (visibleTranscriptMessages.length > 0) { + if (visibleTranscriptMessages.length > 0 || transcriptCompactions.length > 0) { setHasUnreadTranscript(true); } }, [ @@ -1422,6 +1463,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro awaitingFirstResponse, visiblePendingQuestions.length, visibleTranscriptMessages, + compactionTimelineVersion, + transcriptCompactions.length, ]); const handleTranscriptScroll = useCallback(() => { @@ -2610,8 +2653,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro onScroll={handleTranscriptScroll} >
- {selectedSessionId && compactingSessionIds[selectedSessionId] ? : null} - {visibleTranscriptMessages.length === 0 ? ( + {transcriptTimeline.length === 0 ? ( awaitingFirstResponse ? (
@@ -2623,7 +2665,17 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro ) ) : (
- {visibleTranscriptMessages.map((message, index) => { + {transcriptTimeline.map((timelineItem) => { + if (timelineItem.type === 'compaction') { + return ( + + ); + } + const { message, messageIndex: index } = timelineItem; const startingExecutionRun = executionRunByStartIndex.get(index); const messageExecutionRun = getExecutionRunForIndex(executionRuns, index); const liveStreaming = Boolean( @@ -3635,20 +3687,6 @@ function AssistantWaitingPlaceholder({ elapsedSeconds, assistantAvatarSrc, assis ); } -function CompactionNotice({ assistantAvatarSrc, assistantAvatarAlt }: { assistantAvatarSrc?: string; assistantAvatarAlt?: string }) { - return ( -
-
- {assistantAvatarSrc ? {assistantAvatarAlt} : } -
-
- - 正在压缩上下文 -
-
- ); -} - type PendingQuestionCardProps = { request: OpencodeQuestionRequest; onReply: (requestId: string, answers: string[][]) => Promise; diff --git a/src/pages/Chat/session-transcript-view-model.ts b/src/pages/Chat/session-transcript-view-model.ts index 11069b0..d627f67 100644 --- a/src/pages/Chat/session-transcript-view-model.ts +++ b/src/pages/Chat/session-transcript-view-model.ts @@ -1,9 +1,11 @@ import type { RawMessage } from '@/types/chat'; import type { + OpencodeCompactionTimelineEvent, OpencodeNativeToolState, OpencodeSessionExecutionState, OpencodeSessionTranscriptState, } from '@/types/opencode'; +import { getOrderedSessionCompactions } from '@/lib/opencode-session-state'; import { extractThinkingSegments, extractToolUse, @@ -60,6 +62,19 @@ export interface SessionTranscriptViewModel { nestedSubtasks: SessionTranscriptNestedSubtask[]; } +export type SessionTranscriptTimelineItem = + | { + type: 'message'; + id: string; + message: RawMessage; + messageIndex: number; + } + | { + type: 'compaction'; + id: string; + event: OpencodeCompactionTimelineEvent; + }; + export interface BuildSessionTranscriptViewModelInput { messages: RawMessage[]; nativeTranscript?: OpencodeSessionTranscriptState; @@ -76,6 +91,64 @@ function stableMessageId(message: RawMessage, index: number): string { return message.id?.trim() || `${message.role}-${index}`; } +function getCompactionInsertionIndex( + event: OpencodeCompactionTimelineEvent, + messages: RawMessage[], + nativeTranscript: OpencodeSessionTranscriptState, +): number { + const anchoredPart = event.anchorPartID + ? nativeTranscript.partsById[event.anchorPartID] + : undefined; + const anchorMessageID = event.anchorMessageID ?? anchoredPart?.messageID; + if (!anchorMessageID) return messages.length; + + const visibleMessageIndex = messages.findIndex( + (message) => message.id?.trim() === anchorMessageID, + ); + if (visibleMessageIndex >= 0) return visibleMessageIndex + 1; + + const nativeAnchorIndex = nativeTranscript.messageOrder.indexOf(anchorMessageID); + if (nativeAnchorIndex < 0) return messages.length; + for (let index = 0; index < messages.length; index += 1) { + const messageID = messages[index]?.id?.trim(); + if (!messageID) continue; + const nativeMessageIndex = nativeTranscript.messageOrder.indexOf(messageID); + if (nativeMessageIndex > nativeAnchorIndex) return index; + } + return messages.length; +} + +export function buildSessionTranscriptTimeline( + messages: RawMessage[], + nativeTranscript: OpencodeSessionTranscriptState | undefined, +): SessionTranscriptTimelineItem[] { + const messageItems: SessionTranscriptTimelineItem[] = messages.map((message, messageIndex) => ({ + type: 'message', + id: stableMessageId(message, messageIndex), + message, + messageIndex, + })); + if (!nativeTranscript) return messageItems; + + const compactionsByInsertionIndex = new Map(); + for (const event of getOrderedSessionCompactions(nativeTranscript)) { + const insertionIndex = getCompactionInsertionIndex(event, messages, nativeTranscript); + const events = compactionsByInsertionIndex.get(insertionIndex) ?? []; + events.push(event); + compactionsByInsertionIndex.set(insertionIndex, events); + } + + const timeline: SessionTranscriptTimelineItem[] = []; + for (let index = 0; index <= messages.length; index += 1) { + for (const event of compactionsByInsertionIndex.get(index) ?? []) { + timeline.push({ type: 'compaction', id: `compaction:${event.id}`, event }); + } + const messageItem = messageItems[index]; + if (messageItem) timeline.push(messageItem); + } + return timeline; +} + function nativePartStatus( part: OpencodeSessionTranscriptState['partsById'][string], ): SessionTranscriptTraceItem['status'] { diff --git a/src/stores/opencode.ts b/src/stores/opencode.ts index 15132f6..affbb1c 100644 --- a/src/stores/opencode.ts +++ b/src/stores/opencode.ts @@ -13,7 +13,10 @@ import { removeStreamingPartFromMessage, } from '@/lib/opencode-message'; import { + completeCompaction, + createPendingCompaction, hydrateOpenCodeSession, + removeCompaction, reduceOpenCodeEvent, type OpenCodeEventEnvelope, } from '@/lib/opencode-session-state'; @@ -162,7 +165,6 @@ interface OpencodeState { sessionStatuses: OpencodeSessionStatusMap; sessionTranscriptBySessionId: Record; projectEventStreamState: 'closed' | 'connecting' | 'connected' | 'reconnecting'; - compactingSessionIds: Record; sessionMessages: RawMessage[]; sessionMessagesBySessionId: Record; streamingMessage: RawMessage | null; @@ -587,8 +589,12 @@ function getRuntimeStatusPatch( status: OpencodeStatus, ): Pick< OpencodeState, - 'status' | 'runtimeGeneration' | 'commandsLoading' | 'commandsError' -> { + | 'status' + | 'runtimeGeneration' + | 'commandsLoading' + | 'commandsError' + | 'sessionTranscriptBySessionId' + > { const identityChanged = runtimeIdentity(state.status) !== runtimeIdentity(status); return { status, @@ -599,6 +605,9 @@ function getRuntimeStatusPatch( ), commandsLoading: identityChanged ? false : state.commandsLoading, commandsError: identityChanged ? null : state.commandsError, + sessionTranscriptBySessionId: identityChanged + ? removeRunningCompactionsFromTranscripts(state.sessionTranscriptBySessionId) + : state.sessionTranscriptBySessionId, }; } @@ -1712,7 +1721,6 @@ function resetProjectScopedState(sessions: OpencodeSession[] = []) { sessionStatuses: {}, sessionTranscriptBySessionId: {}, projectEventStreamState: 'closed' as const, - compactingSessionIds: {}, sessionMessages: [], sessionMessagesBySessionId: {}, streamingMessage: null, @@ -1877,6 +1885,85 @@ function getSessionTranscriptPatch( }; } +function getOrCreateSessionTranscript( + state: OpencodeState, + sessionId: string, +): OpencodeSessionTranscriptState { + return state.sessionTranscriptBySessionId[sessionId] + ?? hydrateOpenCodeSession( + sessionId, + [], + state.sessionStatuses[sessionId]?.type ?? 'idle', + ); +} + +function getCompactionEventIdentity(payload: Record): string | undefined { + if (typeof payload.eventID === 'string' && payload.eventID.trim()) return payload.eventID; + if (typeof payload.eventId === 'string' && payload.eventId.trim()) return payload.eventId; + if (typeof payload.sequence === 'string' && payload.sequence.trim()) return payload.sequence; + return typeof payload.sequence === 'number' && Number.isFinite(payload.sequence) + ? String(payload.sequence) + : undefined; +} + +function withCompactionRunIdentity( + payload: Record, + runToken: number, + generation: number, + source: 'manual' | 'automatic', +): Record { + const part = payload.part; + if (!part || typeof part !== 'object' || Array.isArray(part)) return payload; + const partRecord = part as Record; + if (partRecord.type !== 'compaction') return payload; + return { + ...payload, + part: { + ...partRecord, + runID: String(runToken), + generation, + ...(source === 'manual' ? { auto: false } : {}), + }, + }; +} + +function removeRunningCompactionsForRun( + transcript: OpencodeSessionTranscriptState, + runToken: number, + generation?: number, +): OpencodeSessionTranscriptState { + const runID = String(runToken); + let next = transcript; + for (const id of transcript.compactionOrder) { + const event = next.compactionsById[id]; + if ( + event?.status === 'running' + && event.runID === runID + && (generation === undefined || event.generation === generation) + ) { + next = removeCompaction(next, { id }); + } + } + return next; +} + +function removeRunningCompactionsFromTranscripts( + transcripts: Record, +): Record { + let changed = false; + const next: Record = {}; + for (const [sessionId, transcript] of Object.entries(transcripts)) { + let cleaned = transcript; + for (const id of transcript.compactionOrder) { + if (cleaned.compactionsById[id]?.status !== 'running') continue; + cleaned = removeCompaction(cleaned, { id }); + } + changed ||= cleaned !== transcript; + next[sessionId] = cleaned; + } + return changed ? next : transcripts; +} + function getSessionStreamingEventPatch( state: OpencodeState, sessionId: string, @@ -1907,19 +1994,9 @@ function getSessionStreamingEventPatch( const streamingTools = nextToolStatus ? mergeStreamingToolStatuses(currentTools, nextToolStatus) : currentTools; - const part = payload.part; - const compacting = Boolean( - part - && typeof part === 'object' - && !Array.isArray(part) - && (part as Record).type === 'compaction', - ); return { ...transcriptPatch, ...getSessionStreamingPatch(state, sessionId, streamingMessage, streamingTools), - compactingSessionIds: compacting - ? { ...state.compactingSessionIds, [sessionId]: true } - : state.compactingSessionIds, }; } @@ -2009,7 +2086,12 @@ function getHydratedSessionTranscriptPatch( return { sessionTranscriptBySessionId: { ...state.sessionTranscriptBySessionId, - [sessionId]: hydrateOpenCodeSession(sessionId, rawMessages, status), + [sessionId]: hydrateOpenCodeSession( + sessionId, + rawMessages, + status, + state.sessionTranscriptBySessionId[sessionId], + ), }, }; } @@ -2105,19 +2187,18 @@ function applyProjectEventToStore( sessionStatuses: { ...state.sessionStatuses, [sessionId]: status }, }; } - if (type === 'session.idle' || type === 'session.compacted') { + if (type === 'session.idle') { return { ...transcriptPatch, sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), ...(active ? {} : clearSessionStreamingPatch(state, sessionId)), }; } + if (type === 'session.compacted') return transcriptPatch; if (type === 'session.error') { return { ...transcriptPatch, sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), ...(active ? {} : clearSessionStreamingPatch(state, sessionId)), }; } @@ -2328,7 +2409,6 @@ function finishSessionRunSuccessfully( ...statuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2411,6 +2491,7 @@ async function runSessionSubmission( ? setMessageDeliveryStatus(submission.optimisticUserMessage, 'sending') : undefined; const baseline = createSessionRunBaseline(getSessionMessagesForState(get(), sessionId)); + const runGeneration = get().runtimeGeneration; const runToken = beginSessionRun(sessionId); const promptId = submission.promptId; closeSessionEventSource(sessionId); @@ -2435,6 +2516,15 @@ async function runSessionSubmission( runId: runToken, promptId, }); + const transcript = submission.kind === 'compact' + ? createPendingCompaction(getOrCreateSessionTranscript(state, sessionId), { + id: promptId, + source: 'manual', + runID: String(runToken), + generation: runGeneration, + startedAt: Date.now(), + }) + : undefined; return { ...getSessionMessagePatch(stateForMessagePatch, sessionId, sessionMessages), ...clearSessionStreamingPatch(state, sessionId), @@ -2447,6 +2537,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: { type: 'busy' }, }, + ...(transcript + ? { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: transcript, + }, + } + : {}), }; }); @@ -2497,19 +2595,35 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), })); }); registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => { const payload = JSON.parse((event as MessageEvent).data) as Record; if (!isSessionRunCurrent(sessionId, runToken)) return; + if (get().runtimeGeneration !== runGeneration) return; if (getOpencodeEventSessionId(payload) !== sessionId) return; flushSessionRunStreamBatch(set, sessionId, runToken); - set((state) => ({ - ...getSessionTranscriptPatch(state, 'session.compacted', payload), - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), - })); + set((state) => { + const transcript = completeCompaction( + getOrCreateSessionTranscript(state, sessionId), + { runID: String(runToken), generation: runGeneration }, + { + nativeEventID: getCompactionEventIdentity(payload), + runID: String(runToken), + generation: runGeneration, + completedAt: Date.now(), + }, + ); + return transcript === state.sessionTranscriptBySessionId[sessionId] + ? {} + : { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: transcript, + }, + }; + }); }); registerSessionRunEventListener(sessionId, source, 'session.error', (event) => { @@ -2556,7 +2670,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2590,7 +2711,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2657,8 +2785,16 @@ async function runSessionSubmission( }); registerSessionRunEventListener(sessionId, source, 'message.part.updated', (event) => { - const payload = JSON.parse((event as MessageEvent).data) as Record; + const rawPayload = JSON.parse((event as MessageEvent).data) as Record; if (!isSessionRunCurrent(sessionId, runToken)) return; + const payload = get().runtimeGeneration === runGeneration + ? withCompactionRunIdentity( + rawPayload, + runToken, + runGeneration, + submission.kind === 'compact' ? 'manual' : 'automatic', + ) + : rawPayload; if (getOpencodeEventSessionId(payload) !== sessionId) return; queueSessionRunStreamEvent(set, sessionId, runToken, 'message.part.updated', payload); }); @@ -2744,8 +2880,13 @@ async function runSessionSubmission( const now = Date.now(); const runListenerOwnsStream = hasSessionRunEventListener(sessionId); + const observedRunState = getSessionRunStateForSession(get(), sessionId); const eventStatus = runListenerOwnsStream - ? get().sessionStatuses[sessionId] + ? observedRunState.runId === runToken + && observedRunState.phase === 'idle' + && observedRunState.terminalReason === 'completed' + ? IDLE_SESSION_STATUS + : get().sessionStatuses[sessionId] : undefined; const shouldPollStatus = now >= nextStatusFallbackPollAt || eventStatus?.type === 'idle'; @@ -2797,9 +2938,13 @@ async function runSessionSubmission( // A missing HTTP status is common while the runtime is transitioning. // Prefer a newly observed assistant response over the optimistic/event // busy marker, otherwise a completed run can remain busy forever. - const polledStatus = shouldPollStatus - ? httpStatus ?? inferredStatus - : eventStatus ?? status ?? inferredStatus; + const polledStatus = submission.kind === 'compact' + ? shouldPollStatus + ? httpStatus ?? eventStatus ?? status + : eventStatus ?? status + : shouldPollStatus + ? httpStatus ?? inferredStatus + : eventStatus ?? status ?? inferredStatus; status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus; const currentMessages = getSessionMessagesForState(get(), sessionId); messages = assistantAborted || status.type === 'idle' @@ -2848,6 +2993,14 @@ async function runSessionSubmission( ...statuses, [sessionId]: IDLE_SESSION_STATUS, }, + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2945,7 +3098,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2987,7 +3147,6 @@ export const useOpencodeStore = create((set, get) => ({ sessionRunStates: {}, pendingQuestions: [], pendingPermissions: [], - compactingSessionIds: {}, sessionTodos: [], sessionTodosBySessionId: {}, sessionTodosLoading: false, @@ -3397,7 +3556,6 @@ export const useOpencodeStore = create((set, get) => ({ sessionStatuses: Object.fromEntries( Object.entries(state.sessionStatuses).filter(([key]) => key !== sessionId), ), - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), selectedSessionId: deletingSelected ? null : selectedSessionId, sessionMessages: deletingSelected ? [] : state.sessionMessages, sessionMessagesBySessionId: withoutKey(state.sessionMessagesBySessionId, sessionId), @@ -3437,6 +3595,7 @@ export const useOpencodeStore = create((set, get) => ({ const abortedRunId = getSessionRunStateForSession(get(), sessionId).runId ?? activeSessionRunTokens.get(sessionId) ?? null; + const abortedRunGeneration = get().runtimeGeneration; invalidateSessionRun(sessionId); closeSessionEventSource(sessionId); set((state) => { @@ -3456,7 +3615,18 @@ export const useOpencodeStore = create((set, get) => ({ ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + ...(abortedRunId === null + ? {} + : { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + abortedRunId, + abortedRunGeneration, + ), + }, + }), sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: true, @@ -4075,7 +4245,7 @@ export const useOpencodeStore = create((set, get) => ({ try { return await runSessionSubmission(set, get, sessionId, { kind: 'compact', - completion: 'post-success', + completion: 'observed', promptId: `compact-${sessionId}-${Date.now()}`, post: () => hostApiFetch( `/api/opencode/sessions/${encodeURIComponent(sessionId)}/summarize`, diff --git a/src/styles/globals.css b/src/styles/globals.css index 14e562c..c3a813e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -737,6 +737,31 @@ html[data-ui-language='ru'] { will-change: opacity, transform; } +@keyframes context-compaction-shimmer { + from { + background-position: 100% 50%; + } + + to { + background-position: -100% 50%; + } +} + +.context-compaction-shimmer { + color: transparent; + background-image: linear-gradient( + 100deg, + hsl(var(--muted-foreground) / 0.58) 25%, + hsl(var(--foreground) / 0.78) 50%, + hsl(var(--muted-foreground) / 0.58) 75% + ); + background-position: 100% 50%; + background-size: 200% 100%; + background-clip: text; + animation: context-compaction-shimmer 2.4s linear infinite; + -webkit-background-clip: text; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -770,4 +795,10 @@ html[data-ui-language='ru'] { transform: scaleY(1); } + .context-compaction-shimmer { + color: hsl(var(--muted-foreground)); + background-image: none; + animation: none; + } + } diff --git a/src/types/opencode.ts b/src/types/opencode.ts index 1cb2944..74f087f 100644 --- a/src/types/opencode.ts +++ b/src/types/opencode.ts @@ -293,6 +293,30 @@ export interface OpencodeNormalizedSessionMessage { partIDs: string[]; } +export type OpencodeCompactionSource = 'manual' | 'automatic'; + +export type OpencodeCompactionStatus = 'running' | 'completed'; + +/** + * A renderer-owned compaction timeline item. `id` is the immutable UI + * identity; native Part/event ids are correlation keys and may arrive later. + */ +export interface OpencodeCompactionTimelineEvent { + id: string; + sessionID: string; + source: OpencodeCompactionSource; + status: OpencodeCompactionStatus; + order: number; + anchorMessageID?: string; + anchorPartID?: string; + nativePartID?: string; + nativeEventID?: string; + runID?: string; + generation?: number; + startedAt?: number; + completedAt?: number; +} + export interface OpencodeSessionTranscriptState { sessionID: string; status: OpencodeSessionExecutionState; @@ -300,6 +324,8 @@ export interface OpencodeSessionTranscriptState { messageOrder: string[]; partsById: Record; partOrderByMessageId: Record; + compactionsById: Record; + compactionOrder: string[]; lastError?: string; lastEventKey?: string; lastDeltaSignature?: string; diff --git a/tests/e2e/opencode-slash-commands.spec.ts b/tests/e2e/opencode-slash-commands.spec.ts index 66125ea..a587560 100644 --- a/tests/e2e/opencode-slash-commands.spec.ts +++ b/tests/e2e/opencode-slash-commands.spec.ts @@ -7,6 +7,16 @@ interface CapturedSlashRequest { body?: Record; } +type SlashCommandHostOptions = { + holdSummarize?: boolean; + seedTranscript?: boolean; +}; + +type SlashRuntimeEvent = { + type: 'message.part.updated' | 'session.compacted'; + payload: Record; +}; + async function readCapturedRequests( electronApp: ElectronApplication, ): Promise { @@ -30,6 +40,103 @@ async function readCapturedRequests( return requests.filter((request) => request.method !== 'GET'); } +async function readSlashRuntimeState( + page: import('@playwright/test').Page, + electronApp: ElectronApplication, +): Promise<{ clientCount: number; summarizePending: boolean }> { + const clientCount = await page.evaluate(() => { + type ControlledEventSource = { readyState: number }; + const sources = (globalThis as typeof globalThis & { + __niancodeSlashE2ESources?: Set; + }).__niancodeSlashE2ESources; + return [...(sources ?? [])].filter((source) => source.readyState !== 2).length; + }); + const summarizePending = await electronApp.evaluate(() => { + type MainState = { + summarizeRelease?: () => void; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + return Boolean(state?.summarizeRelease); + }); + return { clientCount, summarizePending }; +} + +async function emitSlashRuntimeEvent( + page: import('@playwright/test').Page, + electronApp: ElectronApplication, + event: SlashRuntimeEvent, +): Promise { + await electronApp.evaluate((value) => { + type MainState = { + messages?: Array>; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + if (value.type === 'session.compacted' && state.messages) { + const assistant = state.messages.find((message) => ( + (message.info as Record | undefined)?.id + === 'msg_slash_e2e_assistant' + )); + const parts = Array.isArray(assistant?.parts) ? assistant.parts : []; + if (assistant && !parts.some((part) => ( + (part as Record)?.id === 'part_slash_e2e_compaction' + ))) { + assistant.parts = [ + ...parts, + { + id: 'part_slash_e2e_compaction', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'compaction', + time: { + start: 1_753_190_402_000, + end: 1_753_190_403_000, + }, + }, + ]; + } + } + }, event); + await page.evaluate((value) => { + type ControlledEventSource = { + readyState: number; + dispatch: (type: string, payload: Record) => void; + }; + const sources = (globalThis as typeof globalThis & { + __niancodeSlashE2ESources?: Set; + }).__niancodeSlashE2ESources; + if (!sources || sources.size === 0) { + throw new Error('Slash E2E renderer event stream is unavailable'); + } + for (const source of sources) { + if (source.readyState !== 2) source.dispatch(value.type, value.payload); + } + }, event); +} + +async function releaseSlashSummarize( + electronApp: ElectronApplication, +): Promise { + await electronApp.evaluate(() => { + type MainState = { + summarizeRelease?: () => void; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const release = mainGlobal.__niancodeSlashE2EState?.summarizeRelease; + if (!release) { + throw new Error('Slash E2E summarize request is not pending'); + } + release(); + }); +} + async function setSlashCommandFailure( electronApp: ElectronApplication, message: string | null, @@ -56,6 +163,7 @@ async function setSlashCommandFailure( async function installSlashCommandHost( electronApp: ElectronApplication, + options: SlashCommandHostOptions = {}, ): Promise { await electronApp.evaluate(async () => { const { ipcMain } = process.mainModule!.require( @@ -69,15 +177,13 @@ async function installSlashCommandHost( type MainState = { captured: MainCapturedSlashRequest[]; commandFailure: string | null; + holdSummarize: boolean; + messages: Array>; + summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; - const state: MainState = { - captured: [], - commandFailure: null, - }; - mainGlobal.__niancodeSlashE2EState = state; const project = { id: 'prj_slash_e2e', path: 'D:/e2e/slash', @@ -91,6 +197,13 @@ async function installSlashCommandHost( title: 'Slash E2E', agent: 'game-development', }; + const state: MainState = { + captured: [], + commandFailure: null, + holdSummarize: false, + messages: [], + }; + mainGlobal.__niancodeSlashE2EState = state; const agent = { id: 'game-development', avatarId: 'avatar-01', @@ -253,7 +366,7 @@ async function installSlashCommandHost( === '/api/opencode/sessions/ses_slash_e2e/messages' && method === 'GET' ) { - return respond({ messages: [] }); + return respond({ messages: state.messages }); } if ( path @@ -290,6 +403,12 @@ async function installSlashCommandHost( === '/api/opencode/sessions/ses_slash_e2e/summarize' && method === 'POST' ) { + if (state.holdSummarize) { + await new Promise((resolve) => { + state.summarizeRelease = resolve; + }); + state.summarizeRelease = undefined; + } return respond({ success: true }, 202); } if ( @@ -312,14 +431,135 @@ async function installSlashCommandHost( ); }); }); + if (options.holdSummarize || options.seedTranscript) { + await configureSlashCompactionHost(electronApp); + } +} + +async function configureSlashCompactionHost( + electronApp: ElectronApplication, +): Promise { + await electronApp.evaluate(() => { + type MainState = { + holdSummarize: boolean; + messages: Array>; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + if (!state) { + throw new Error('Slash E2E Main state is unavailable'); + } + state.holdSummarize = true; + state.messages = [ + { + info: { + id: 'msg_slash_e2e_user', + sessionID: 'ses_slash_e2e', + role: 'user', + time: { created: 1_753_190_400_000 }, + }, + parts: [{ + id: 'part_slash_e2e_user', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_user', + type: 'text', + text: '已有的时间线消息', + }], + }, + { + info: { + id: 'msg_slash_e2e_assistant', + sessionID: 'ses_slash_e2e', + role: 'assistant', + time: { created: 1_753_190_401_000 }, + }, + parts: [{ + id: 'part_slash_e2e_assistant', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'text', + text: '已有的助手回复', + }], + }, + ]; + }); +} + +async function installSlashRendererEventSource( + page: import('@playwright/test').Page, +): Promise { + await page.addInitScript(() => { + // The fixture's Main IPC stub does not own the Host API SSE server. Keep + // this E2E at the renderer/store event boundary instead of relying on a + // real OpenCode process or an unrelated localhost listener. + type EventListener = (event: MessageEvent) => void; + type ControlledEventSource = { + readyState: number; + dispatch: (type: string, payload: Record) => void; + close: () => void; + }; + const sources = new Set(); + class SlashEventSource implements ControlledEventSource { + static readonly OPEN = 1; + static readonly CLOSED = 2; + readonly url: string; + readyState = SlashEventSource.OPEN; + onopen: (() => void) | null = null; + onerror: ((event: Event) => void) | null = null; + private readonly listeners = new Map>(); + + constructor(url: string) { + this.url = url; + sources.add(this); + } + + addEventListener(type: string, listener: EventListener): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners.get(type)?.delete(listener); + } + + close(): void { + this.readyState = SlashEventSource.CLOSED; + sources.delete(this); + } + + dispatch(type: string, payload: Record): void { + const event = new MessageEvent('message', { + data: JSON.stringify(payload), + }); + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + Object.defineProperty(window, 'EventSource', { + configurable: true, + writable: true, + value: SlashEventSource, + }); + Object.defineProperty(globalThis, '__niancodeSlashE2ESources', { + configurable: true, + value: sources, + }); + }); } test.describe('OpenCode slash commands', () => { test.afterEach(async ({ electronApp }) => { await electronApp.evaluate(async () => { - const mainGlobal = globalThis as typeof globalThis & { - __niancodeSlashE2EState?: unknown; + type MainState = { + summarizeRelease?: () => void; }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + state?.summarizeRelease?.(); delete mainGlobal.__niancodeSlashE2EState; }); }); @@ -402,6 +642,115 @@ test.describe('OpenCode slash commands', () => { }); }); + test('keeps manual compaction running at its timeline position, then completes it from native events', async ({ + electronApp, + page, + }) => { + await completeSetup(page); + await installSlashRendererEventSource(page); + await installSlashCommandHost(electronApp, { + holdSummarize: true, + seedTranscript: true, + }); + await page.reload(); + await page.getByTestId('sidebar-module-switcher-trigger').click(); + await page.getByTestId('sidebar-module-programming').click(); + await expect(page).toHaveURL(/\/opencode-chat$/); + await page.getByTestId('project-agent-chat-game-development').click(); + + const transcript = page.getByTestId('opencode-transcript-scroll'); + const existingAssistant = transcript.locator( + '[data-chat-message-id="msg_slash_e2e_assistant"]', + ); + await expect(existingAssistant).toBeVisible(); + const composer = page.getByRole('textbox'); + await expect(composer).toBeVisible(); + await composer.fill('/compact'); + await composer.press('Enter'); + await expect(composer).toHaveValue('/compact'); + await composer.press('Enter'); + + await expect.poll(async () => ( + await readCapturedRequests(electronApp) + ).some((request) => request.path.endsWith('/summarize'))).toBe(true); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).summarizePending).toBe(true); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).clientCount).toBeGreaterThan(0); + + const compactionItem = transcript.getByTestId( + 'opencode-compaction-timeline-item', + ); + await expect(compactionItem).toHaveCount(1); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-source', + 'manual', + ); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'running', + ); + await expect(compactionItem).toContainText('正在压缩上下文'); + await expect(compactionItem.locator('span')).toHaveClass( + /context-compaction-shimmer/, + ); + const [compactionTop, assistantTop] = await Promise.all([ + compactionItem.evaluate((element) => element.getBoundingClientRect().top), + existingAssistant.evaluate((element) => element.getBoundingClientRect().top), + ]); + expect(compactionTop).toBeGreaterThan(assistantTop); + const compactionId = await compactionItem.getAttribute('data-compaction-id'); + expect(compactionId).toBeTruthy(); + + await emitSlashRuntimeEvent(page, electronApp, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + eventID: 'slash-compaction-part-updated', + part: { + id: 'part_slash_e2e_compaction', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'compaction', + source: 'manual', + time: { start: 1_753_190_402_000 }, + }, + }, + }); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'running', + ); + + await emitSlashRuntimeEvent(page, electronApp, { + type: 'session.compacted', + payload: { + sessionID: 'ses_slash_e2e', + eventID: 'slash-session-compacted', + }, + }); + await expect(compactionItem).toHaveCount(1); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'completed', + ); + await expect(compactionItem).toContainText('已压缩上下文'); + await expect(compactionItem.locator('span')).not.toHaveClass( + /context-compaction-shimmer/, + ); + expect(await compactionItem.getAttribute('data-compaction-id')) + .toBe(compactionId); + + await releaseSlashSummarize(electronApp); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).summarizePending).toBe(false); + await expect(compactionItem).toBeVisible(); + }); + test('keeps the project and conversation rails at the minimum width without covering the chat canvas', async ({ electronApp, page, diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 3161218..e9b32aa 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; import { OpencodeChatPanel } from '@/pages/Chat/OpencodeChatPanel'; +import { buildSessionTranscriptTimeline } from '@/pages/Chat/session-transcript-view-model'; import { useAuthStore } from '@/stores/auth'; import { useOpencodeStore } from '@/stores/opencode'; import { usePartnerProfilesStore } from '@/stores/partner-profiles'; @@ -14,7 +15,12 @@ import { } from '@/stores/chat-view-preferences'; import type { RawMessage } from '@/types/chat'; import type { ProcessedImageAttachment } from '@/lib/image-attachment-compression'; -import type { OpencodeProject, OpencodeSession } from '@/types/opencode'; +import type { + OpencodeCompactionTimelineEvent, + OpencodeProject, + OpencodeSession, + OpencodeSessionTranscriptState, +} from '@/types/opencode'; import { createProjectConfig, type ProjectAgentConfig } from '../../shared/project-config'; import { createProjectConversationState, @@ -380,6 +386,38 @@ function createDeferred() { return { promise, resolve }; } +function createCompactionTranscript( + sessionID: string, + messageIDs: string[], + compactions: OpencodeCompactionTimelineEvent[], +): OpencodeSessionTranscriptState { + return { + sessionID, + status: compactions.some((event) => event.status === 'running') ? 'busy' : 'idle', + messagesById: Object.fromEntries(messageIDs.map((messageID) => [messageID, { + id: messageID, + sessionID, + role: 'assistant', + partIDs: [`part_${messageID}`], + }])), + messageOrder: messageIDs, + partsById: Object.fromEntries(messageIDs.map((messageID) => [`part_${messageID}`, { + id: `part_${messageID}`, + sessionID, + messageID, + type: 'text' as const, + rawType: 'text', + text: messageID, + }])), + partOrderByMessageId: Object.fromEntries(messageIDs.map((messageID) => [ + messageID, + [`part_${messageID}`], + ])), + compactionsById: Object.fromEntries(compactions.map((event) => [event.id, event])), + compactionOrder: compactions.map((event) => event.id), + }; +} + function installCompressionChatHost(postResponse: Promise<{ success: boolean; error?: string; @@ -748,6 +786,7 @@ describe('OpencodeChatPanel', () => { sessionStatuses: {}, sessionMessages: [], sessionMessagesBySessionId: {}, + sessionTranscriptBySessionId: {}, streamingMessage: null, streamingMessagesBySessionId: {}, streamingTools: [], @@ -2809,6 +2848,267 @@ describe('OpencodeChatPanel', () => { await waitFor(() => expect(transcriptScroll.scrollTop).toBe(1400)); }); + it('inserts persistent compaction items at native message anchors with source-specific labels', async () => { + const messages: RawMessage[] = [ + { id: 'msg_first', role: 'user', content: 'First prompt' }, + { id: 'msg_second', role: 'assistant', content: 'Second answer' }, + ]; + installRunningSlashProject({ commands: [], messages }); + const automatic: OpencodeCompactionTimelineEvent = { + id: 'compact_auto', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorPartID: 'part_msg_first', + }; + const manual: OpencodeCompactionTimelineEvent = { + id: 'compact_manual', + sessionID: 'ses_slash', + source: 'manual', + status: 'completed', + order: 1, + anchorMessageID: 'msg_second', + }; + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript( + 'ses_slash', + ['msg_first', 'msg_second'], + [automatic, manual], + ), + }, + }); + + render(); + + const automaticItem = await screen.findByText('已优化对话'); + const manualItem = screen.getByText('已压缩上下文'); + const firstMessage = document.querySelector('[data-chat-message-id="msg_first"]'); + const secondMessage = document.querySelector('[data-chat-message-id="msg_second"]'); + expect(firstMessage).not.toBeNull(); + expect(secondMessage).not.toBeNull(); + expect(firstMessage!.compareDocumentPosition(automaticItem) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(automaticItem.compareDocumentPosition(secondMessage!) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(secondMessage!.compareDocumentPosition(manualItem) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(screen.queryByTestId('opencode-compaction-notice')).not.toBeInTheDocument(); + for (const item of screen.getAllByTestId('opencode-compaction-timeline-item')) { + expect(item).not.toHaveAttribute('role'); + expect(item).not.toHaveAttribute('aria-live'); + expect(item).not.toHaveClass('border', 'shadow-soft'); + expect(item.querySelector('.animate-spin')).toBeNull(); + } + }); + + it('orders a compaction by its native Part anchor when the anchor message is not visible', () => { + const event: OpencodeCompactionTimelineEvent = { + id: 'compact_hidden_anchor', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorPartID: 'part_msg_compaction', + }; + const transcript = createCompactionTranscript( + 'ses_slash', + ['msg_before', 'msg_compaction', 'msg_after'], + [event], + ); + const timeline = buildSessionTranscriptTimeline([ + { id: 'msg_before', role: 'user', content: 'Before' }, + { id: 'msg_after', role: 'assistant', content: 'After' }, + ], transcript); + + expect(timeline.map((item) => item.id)).toEqual([ + 'msg_before', + 'compaction:compact_hidden_anchor', + 'msg_after', + ]); + }); + + it('announces a live compaction transition and keeps the completed item in place', async () => { + const messages: RawMessage[] = [ + { id: 'msg_prompt', role: 'user', content: 'Keep the context concise' }, + ]; + installRunningSlashProject({ commands: [], messages }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], []), + }, + }); + render(); + await screen.findAllByText('Keep the context concise'); + + const running: OpencodeCompactionTimelineEvent = { + id: 'compact_live', + sessionID: 'ses_slash', + source: 'automatic', + status: 'running', + order: 0, + anchorMessageID: 'msg_prompt', + }; + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], [running]), + }, + }); + }); + + const liveItem = (await screen.findByText('正在优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + )!; + expect(liveItem).toHaveAttribute('role', 'status'); + expect(liveItem).toHaveAttribute('aria-live', 'polite'); + expect(liveItem.querySelector('.context-compaction-shimmer')).not.toBeNull(); + + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], [{ + ...running, + status: 'completed', + completedAt: 2, + }]), + }, + sessionStatuses: { ses_slash: { type: 'idle' } }, + }); + }); + + const completedItem = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + )!; + expect(completedItem).toHaveAttribute('role', 'status'); + expect(completedItem).toBe(liveItem); + expect(completedItem).toHaveAttribute('data-compaction-id', 'compact_live'); + expect(completedItem.querySelector('.context-compaction-shimmer')).toBeNull(); + expect(screen.queryByText('正在优化对话')).not.toBeInTheDocument(); + }); + + it('does not expose hydrated or session-switched compactions as live regions', async () => { + const sessionAEvents: OpencodeCompactionTimelineEvent[] = [{ + id: 'compact_a', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorMessageID: 'msg_a', + }]; + const sessionBEvents: OpencodeCompactionTimelineEvent[] = [{ + id: 'compact_b', + sessionID: 'ses_other', + source: 'manual', + status: 'running', + order: 0, + anchorMessageID: 'msg_b', + }]; + const messagesBySession: Record = { + ses_slash: [{ id: 'msg_a', role: 'user', content: 'Session A' }], + ses_other: [{ id: 'msg_b', role: 'user', content: 'Session B' }], + }; + installRunningSlashProject({ + commands: [], + messagesBySession, + extraSessions: [{ id: 'ses_other', title: 'Other', agent: 'game-development' }], + }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_a'], sessionAEvents), + ses_other: createCompactionTranscript('ses_other', ['msg_b'], sessionBEvents), + }, + }); + render(); + + const historicalA = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(historicalA).not.toHaveAttribute('role'); + + act(() => { + useOpencodeStore.setState({ + selectedSessionId: 'ses_other', + sessionMessages: messagesBySession.ses_other, + }); + }); + const historicalB = (await screen.findByText('正在压缩上下文')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(historicalB).not.toHaveAttribute('role'); + expect(historicalB).not.toHaveAttribute('aria-live'); + expect(screen.queryByText('已优化对话')).not.toBeInTheDocument(); + + act(() => { + useOpencodeStore.setState({ + selectedSessionId: 'ses_slash', + sessionMessages: messagesBySession.ses_slash, + }); + }); + const restoredA = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(restoredA).not.toHaveAttribute('role'); + expect(screen.queryByText('正在压缩上下文')).not.toBeInTheDocument(); + }); + + it('preserves scroll position when compacting while scrolled up and follows a tailed transcript', async () => { + const messages: RawMessage[] = [ + { id: 'msg_scroll', role: 'assistant', content: 'Scrollable answer' }, + ]; + installRunningSlashProject({ commands: [], messages }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], []), + }, + }); + render(); + await screen.findAllByText('Scrollable answer'); + + const transcriptScroll = screen.getByTestId('opencode-transcript-scroll'); + Object.defineProperty(transcriptScroll, 'scrollHeight', { value: 1000, configurable: true }); + Object.defineProperty(transcriptScroll, 'clientHeight', { value: 200, configurable: true }); + transcriptScroll.scrollTop = 120; + fireEvent.scroll(transcriptScroll); + const running: OpencodeCompactionTimelineEvent = { + id: 'compact_scroll', + sessionID: 'ses_slash', + source: 'manual', + status: 'running', + order: 0, + anchorMessageID: 'msg_scroll', + }; + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], [running]), + }, + }); + }); + + await screen.findByText('正在压缩上下文'); + expect(transcriptScroll.scrollTop).toBe(120); + expect(screen.getByRole('button', { name: '跳到最新消息' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '跳到最新消息' })); + expect(transcriptScroll.scrollTop).toBe(1000); + Object.defineProperty(transcriptScroll, 'scrollHeight', { value: 1400, configurable: true }); + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], [{ + ...running, + status: 'completed', + }]), + }, + }); + }); + + await screen.findByText('已压缩上下文'); + await waitFor(() => expect(transcriptScroll.scrollTop).toBe(1400)); + }); + it('turns the composer send button into a stop button while the session is busy', async () => { const activeProject = { id: 'prj_1', diff --git a/tests/unit/opencode-session-state.test.ts b/tests/unit/opencode-session-state.test.ts index 8e7b5c2..9724294 100644 --- a/tests/unit/opencode-session-state.test.ts +++ b/tests/unit/opencode-session-state.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + completeCompaction, + createPendingCompaction, getOrderedSessionParts, + getOrderedSessionCompactions, hydrateOpenCodeSession, reduceOpenCodeEvent, + removeCompaction, } from '@/lib/opencode-session-state'; describe('OpenCode native session state', () => { @@ -171,4 +175,261 @@ describe('OpenCode native session state', () => { }); expect(getOrderedSessionParts(secondRealChunk, 'msg_1')[0]?.text).toBe('xx'); }); + + it('hydrates historical compaction Parts as completed timeline events in transcript order', () => { + const state = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [ + { id: 'compact_1', type: 'compaction', auto: false }, + { id: 'compact_2', type: 'compaction', auto: true }, + ], + }]); + + expect(getOrderedSessionCompactions(state)).toEqual([ + expect.objectContaining({ + id: 'compaction:ses_1:part:compact_1', + source: 'manual', + status: 'completed', + order: 0, + anchorMessageID: 'msg_1', + anchorPartID: 'compact_1', + nativePartID: 'compact_1', + }), + expect.objectContaining({ + source: 'automatic', + status: 'completed', + order: 1, + nativePartID: 'compact_2', + }), + ]); + }); + + it('allows only the latest compaction in an active snapshot to remain running', () => { + const state = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [ + { id: 'compact_1', type: 'compaction', auto: true }, + { id: 'compact_2', type: 'compaction', auto: true }, + ], + }], 'busy'); + + expect(getOrderedSessionCompactions(state).map((event) => event.status)) + .toEqual(['completed', 'running']); + }); + + it('does not downgrade a completed compaction during busy history hydration', () => { + const snapshot = [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'compact_1', type: 'compaction', auto: true }], + }]; + const running = hydrateOpenCodeSession('ses_1', snapshot, 'busy'); + const completed = reduceOpenCodeEvent(running, { + type: 'session.compacted', + payload: { sessionID: 'ses_1', eventID: 'evt_compacted_1' }, + }); + + const rehydrated = hydrateOpenCodeSession('ses_1', snapshot, 'busy', completed); + + expect(getOrderedSessionCompactions(rehydrated)).toEqual([ + expect.objectContaining({ + nativePartID: 'compact_1', + status: 'completed', + }), + ]); + }); + + it('merges a manual pending event with its native Part without changing the UI id', () => { + const empty = hydrateOpenCodeSession('ses_1', [], 'busy'); + const pending = createPendingCompaction(empty, { + id: 'ui_compact_1', + source: 'manual', + runID: 'run_1', + generation: 3, + startedAt: 1_700_000_000_000, + }); + const withNativePart = reduceOpenCodeEvent(pending, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + eventID: 'evt_part_1', + messageID: 'msg_1', + part: { + id: 'native_compact_1', + type: 'compaction', + auto: false, + runID: 'run_1', + generation: 3, + }, + }, + }); + + expect(getOrderedSessionCompactions(withNativePart)).toEqual([ + expect.objectContaining({ + id: 'ui_compact_1', + nativePartID: 'native_compact_1', + nativeEventID: 'evt_part_1', + anchorMessageID: 'msg_1', + status: 'running', + }), + ]); + + const replayed = reduceOpenCodeEvent(withNativePart, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + eventID: 'evt_part_2', + messageID: 'msg_1', + part: { id: 'native_compact_1', type: 'compaction', auto: false }, + }, + }); + expect(getOrderedSessionCompactions(replayed)).toHaveLength(1); + expect(getOrderedSessionCompactions(replayed)[0]?.id).toBe('ui_compact_1'); + }); + + it('preserves unmatched running pending events across active hydration only', () => { + const current = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 }, + ); + + const active = hydrateOpenCodeSession('ses_1', [], 'busy', current); + expect(getOrderedSessionCompactions(active).map((event) => event.id)).toEqual(['ui_pending']); + + const historical = hydrateOpenCodeSession('ses_1', [], 'idle', current); + expect(getOrderedSessionCompactions(historical)).toEqual([]); + }); + + it('merges an active snapshot Part into the current manual pending UI identity', () => { + const current = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 }, + ); + const hydrated = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'native_compact_1', type: 'compaction', auto: false }], + }], 'busy', current); + + expect(getOrderedSessionCompactions(hydrated)).toEqual([ + expect.objectContaining({ + id: 'ui_pending', + nativePartID: 'native_compact_1', + status: 'running', + runID: 'run_1', + generation: 1, + }), + ]); + }); + + it('closes an older running native compaction when a later one arrives', () => { + let state = hydrateOpenCodeSession('ses_1', [], 'busy'); + for (const [messageID, partID] of [['msg_1', 'compact_1'], ['msg_2', 'compact_2']]) { + state = reduceOpenCodeEvent(state, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + messageID, + part: { id: partID, type: 'compaction', auto: true }, + }, + })!; + } + + expect(getOrderedSessionCompactions(state).map((event) => event.status)) + .toEqual(['completed', 'running']); + }); + + it('completes and removes only the compaction matching all supplied correlation keys', () => { + let state = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_1', source: 'manual', runID: 'run_1', generation: 1 }, + ); + state = createPendingCompaction(state, { + id: 'ui_2', + source: 'manual', + runID: 'run_2', + generation: 2, + nativePartID: 'native_2', + }); + + const wrongGeneration = completeCompaction( + state, + { runID: 'run_2', generation: 1, nativePartID: 'native_2' }, + ); + expect(wrongGeneration).toBe(state); + + state = completeCompaction( + state, + { runID: 'run_2', generation: 2, nativePartID: 'native_2' }, + { nativeEventID: 'evt_complete_2', completedAt: 1_700_000_000_100 }, + ); + expect(state.compactionsById.ui_2).toEqual(expect.objectContaining({ + status: 'completed', + nativeEventID: 'evt_complete_2', + completedAt: 1_700_000_000_100, + })); + expect(state.compactionsById.ui_1?.status).toBe('running'); + + const untouched = removeCompaction(state, { runID: 'run_1', generation: 2 }); + expect(untouched).toBe(state); + state = removeCompaction(state, { runID: 'run_1', generation: 1 }); + expect(getOrderedSessionCompactions(state).map((event) => event.id)).toEqual(['ui_2']); + }); + + it('completes compaction on session.compacted without marking the transcript idle', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_1', source: 'automatic', runID: 'run_1', generation: 4 }, + ); + const compacted = reduceOpenCodeEvent(busy, { + type: 'session.compacted', + payload: { + sessionID: 'ses_1', + eventID: 'evt_complete_1', + runID: 'run_1', + generation: 4, + }, + }); + + expect(compacted?.status).toBe('busy'); + expect(compacted?.compactionsById.ui_compact_1).toEqual(expect.objectContaining({ + status: 'completed', + nativeEventID: 'evt_complete_1', + })); + + const idle = reduceOpenCodeEvent(compacted, { + type: 'session.idle', + payload: { sessionID: 'ses_1' }, + }); + expect(idle?.status).toBe('idle'); + }); + + it('uses session.idle as a successful completion fallback', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_idle', source: 'manual', runID: 'run_idle', generation: 5 }, + ); + + const idle = reduceOpenCodeEvent(busy, { + type: 'session.idle', + payload: { sessionID: 'ses_1' }, + }); + + expect(idle?.status).toBe('idle'); + expect(idle?.compactionsById.ui_compact_idle?.status).toBe('completed'); + }); + + it('removes unfinished compactions on a fatal session error', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_error', source: 'automatic', runID: 'run_error', generation: 6 }, + ); + + const failed = reduceOpenCodeEvent(busy, { + type: 'session.error', + payload: { sessionID: 'ses_1', error: 'provider failed' }, + }); + + expect(failed?.status).toBe('idle'); + expect(failed?.compactionOrder).toEqual([]); + expect(failed?.compactionsById.ui_compact_error).toBeUndefined(); + }); }); diff --git a/tests/unit/opencode-store.test.ts b/tests/unit/opencode-store.test.ts index 68e4105..8ec16a9 100644 --- a/tests/unit/opencode-store.test.ts +++ b/tests/unit/opencode-store.test.ts @@ -4890,12 +4890,285 @@ describe('opencode store', () => { expect(createHostEventSourceMock).toHaveBeenCalledWith('/api/opencode/events'); expect(source.close).not.toHaveBeenCalled(); expect(hostApiFetchMock.mock.calls.map(([path]) => path)) - .not.toContain('/api/opencode/sessions/status'); + .toContain('/api/opencode/sessions/status'); source.emit('session.idle', { sessionID: 'ses_1' }); expect(source.close).not.toHaveBeenCalled(); }); + it('keeps one manual compaction identity and releases queued prompts only after idle', async () => { + const compactSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle'); + const promptSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle'); + createHostEventSourceMock + .mockReturnValueOnce(compactSource) + .mockReturnValueOnce(promptSource); + let promptPosted = false; + let compactOutcome: 'resolved' | 'rejected' | null = null; + useOpencodeStore.setState({ + runtimeGeneration: 7, + selectedSessionId: 'ses_compact_lifecycle', + sessions: [{ id: 'ses_compact_lifecycle' }], + sessionStatuses: { ses_compact_lifecycle: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return { success: true }; + if (path.endsWith('/messages') && init?.method === 'POST') { + promptPosted = true; + return { success: true }; + } + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + if (path.endsWith('/messages')) { + return { + messages: promptPosted + ? [ + { id: 'msg_queued_user', role: 'user', content: 'After compact' }, + { id: 'msg_queued_assistant', role: 'assistant', content: 'Done' }, + ] + : [], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_lifecycle'); + const observedCompact = compacting.then( + (value) => { + compactOutcome = 'resolved'; + return value; + }, + (error: unknown) => { + compactOutcome = 'rejected'; + throw error; + }, + ); + await vi.waitFor(() => { + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle; + expect(transcript?.compactionOrder).toHaveLength(1); + expect(transcript?.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + source: 'manual', + status: 'running', + runID: expect.any(String), + generation: 7, + }); + }); + const pendingTranscript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle!; + const immutableID = pendingTranscript.compactionOrder[0]!; + + await useOpencodeStore.getState().sendSessionMessage( + 'ses_compact_lifecycle', + 'After compact', + ); + compactSource.emit('message.part.updated', { + eventID: 'evt_compact_part', + part: { + id: 'prt_compact_manual', + sessionID: 'ses_compact_lifecycle', + messageID: 'msg_compact_manual', + type: 'compaction', + auto: false, + }, + }); + compactSource.emit('session.compacted', { + eventID: 'evt_compacted', + sessionID: 'ses_compact_lifecycle', + }); + + const compactedTranscript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle!; + expect(compactedTranscript.compactionOrder).toEqual([immutableID]); + expect(compactedTranscript.compactionsById[immutableID]).toMatchObject({ + status: 'completed', + source: 'manual', + nativePartID: 'prt_compact_manual', + nativeEventID: 'evt_compacted', + }); + expect(compactOutcome).toBeNull(); + expect(promptPosted).toBe(false); + expect(useOpencodeStore.getState().queuedSessionPrompts.ses_compact_lifecycle) + .toHaveLength(1); + + compactSource.emit('session.idle', { sessionID: 'ses_compact_lifecycle' }); + await observedCompact; + await vi.waitFor(() => expect(promptPosted).toBe(true)); + expect(compactOutcome).toBe('resolved'); + }); + + it('removes an unfinished manual compaction when its POST fails', async () => { + const post = createDeferred<{ success: boolean }>(); + createHostEventSourceMock.mockReturnValue( + new MockEventSource('/api/opencode/events?sessionId=ses_compact_post_failure'), + ); + useOpencodeStore.setState({ + selectedSessionId: 'ses_compact_post_failure', + sessions: [{ id: 'ses_compact_post_failure' }], + sessionStatuses: { ses_compact_post_failure: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_post_failure'); + const outcome = compacting.catch((error: unknown) => error); + await vi.waitFor(() => expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_post_failure?.compactionOrder).toHaveLength(1)); + post.reject(new Error('compact POST failed')); + + await expect(outcome).resolves.toEqual(expect.objectContaining({ message: 'compact POST failed' })); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_post_failure?.compactionOrder).toEqual([]); + }); + + it('removes only the current unfinished compaction on a fatal session error', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_compact_fatal'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + selectedSessionId: 'ses_compact_fatal', + sessions: [{ id: 'ses_compact_fatal' }], + sessionStatuses: { ses_compact_fatal: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_fatal'); + await vi.waitFor(() => expect(createHostEventSourceMock).toHaveBeenCalled()); + source.emit('session.error', { + sessionID: 'ses_compact_fatal', + error: { name: 'APIError', message: 'fatal compaction failure' }, + }); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_fatal?.compactionOrder).toEqual([]); + post.resolve({ success: true }); + + await expect(compacting).rejects.toThrow('fatal compaction failure'); + }); + + it('records automatic compaction parts once and does not treat compacted as idle', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_auto_compact'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + runtimeGeneration: 5, + selectedSessionId: 'ses_auto_compact', + sessions: [{ id: 'ses_auto_compact' }], + sessionStatuses: { ses_auto_compact: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/messages') && init?.method === 'POST') return await post.promise; + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + if (path.endsWith('/messages')) { + return { + messages: [ + { id: 'msg_auto_user', role: 'user', content: 'Trigger auto compact' }, + { id: 'msg_auto_assistant', role: 'assistant', content: 'Done' }, + ], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const prompting = useOpencodeStore.getState().sendSessionMessage( + 'ses_auto_compact', + 'Trigger auto compact', + ); + await vi.waitFor(() => expect(createHostEventSourceMock).toHaveBeenCalled()); + const partPayload = { + eventID: 'evt_auto_part', + part: { + id: 'prt_auto_compact', + sessionID: 'ses_auto_compact', + messageID: 'msg_auto_compact', + type: 'compaction', + auto: true, + }, + }; + source.emit('message.part.updated', partPayload); + source.emit('message.part.updated', partPayload); + source.emit('session.compacted', { + eventID: 'evt_auto_compacted', + sessionID: 'ses_auto_compact', + }); + + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_auto_compact!; + expect(transcript.compactionOrder).toHaveLength(1); + expect(transcript.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + source: 'automatic', + status: 'completed', + nativePartID: 'prt_auto_compact', + runID: expect.any(String), + generation: 5, + }); + expect(useOpencodeStore.getState().sendingSessionIds.ses_auto_compact).toBe(true); + + source.emit('session.idle', { sessionID: 'ses_auto_compact' }); + post.resolve({ success: true }); + await prompting; + }); + + it('preserves a pending compaction identity through hydration and removes it on abort', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_compact_hydrate'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + runtimeGeneration: 3, + selectedSessionId: 'ses_compact_hydrate', + sessions: [{ id: 'ses_compact_hydrate' }], + sessionStatuses: { ses_compact_hydrate: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + if (path.endsWith('/abort') && init?.method === 'POST') return { success: true }; + if (path.endsWith('/messages')) { + return { + messages: [{ + info: { + id: 'msg_compact_snapshot', + role: 'assistant', + sessionID: 'ses_compact_hydrate', + }, + parts: [{ + id: 'prt_compact_snapshot', + sessionID: 'ses_compact_hydrate', + messageID: 'msg_compact_snapshot', + type: 'compaction', + auto: false, + }], + }], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_hydrate'); + await vi.waitFor(() => expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate?.compactionOrder).toHaveLength(1)); + const originalID = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate!.compactionOrder[0]!; + + await useOpencodeStore.getState().loadSessionMessages('ses_compact_hydrate'); + const hydrated = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate!; + expect(hydrated.compactionOrder).toEqual([originalID]); + expect(hydrated.compactionsById[originalID]).toMatchObject({ + status: 'running', + source: 'manual', + nativePartID: 'prt_compact_snapshot', + generation: 3, + }); + + await useOpencodeStore.getState().abortSession('ses_compact_hydrate'); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate?.compactionOrder).toEqual([]); + post.resolve({ success: true }); + await compacting; + }); + it('preserves local path and preview attachment metadata in compact final history', async () => { const source = new MockEventSource( '/api/opencode/events?sessionId=ses_compact_attachments', @@ -4953,6 +5226,9 @@ describe('opencode store', () => { }], }; } + if (path === '/api/opencode/sessions/status') { + return { statuses: { ses_compact_attachments: { type: 'idle' } } }; + } throw new Error(`Unexpected request: ${path}`); });