实现 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,