实现 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,
|
||||
|
||||
Reference in New Issue
Block a user