实现 Codex 风格上下文压缩时间线交互
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user