实现 Codex 风格上下文压缩时间线交互

This commit is contained in:
2026-08-15 08:43:33 +08:00
parent 953b0f491e
commit fd9b5b46a9
13 changed files with 2044 additions and 73 deletions

View File

@@ -7,6 +7,16 @@ interface CapturedSlashRequest {
body?: Record<string, unknown>;
}
type SlashCommandHostOptions = {
holdSummarize?: boolean;
seedTranscript?: boolean;
};
type SlashRuntimeEvent = {
type: 'message.part.updated' | 'session.compacted';
payload: Record<string, unknown>;
};
async function readCapturedRequests(
electronApp: ElectronApplication,
): Promise<CapturedSlashRequest[]> {
@@ -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<ControlledEventSource>;
}).__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<void> {
await electronApp.evaluate((value) => {
type MainState = {
messages?: Array<Record<string, unknown>>;
};
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<string, unknown> | undefined)?.id
=== 'msg_slash_e2e_assistant'
));
const parts = Array.isArray(assistant?.parts) ? assistant.parts : [];
if (assistant && !parts.some((part) => (
(part as Record<string, unknown>)?.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<string, unknown>) => void;
};
const sources = (globalThis as typeof globalThis & {
__niancodeSlashE2ESources?: Set<ControlledEventSource>;
}).__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<void> {
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<void> {
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<Record<string, unknown>>;
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<void>((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<void> {
await electronApp.evaluate(() => {
type MainState = {
holdSummarize: boolean;
messages: Array<Record<string, unknown>>;
};
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<void> {
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<string>) => void;
type ControlledEventSource = {
readyState: number;
dispatch: (type: string, payload: Record<string, unknown>) => void;
close: () => void;
};
const sources = new Set<ControlledEventSource>();
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<string, Set<EventListener>>();
constructor(url: string) {
this.url = url;
sources.add(this);
}
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
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<string, unknown>): 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,

View File

@@ -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<T>() {
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(<OpencodeChatPanel variant="main" />);
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(<OpencodeChatPanel variant="main" />);
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<string, RawMessage[]> = {
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(<OpencodeChatPanel variant="main" />);
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(<OpencodeChatPanel variant="main" />);
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',

View File

@@ -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();
});
});

View File

@@ -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}`);
});