fix stable turn duration across conversation switches (#1219)

This commit is contained in:
paisley
2026-08-03 17:56:14 +08:00
committed by GitHub
parent 164489da97
commit 8508c0cd8c
5 changed files with 129 additions and 9 deletions
@@ -118,6 +118,7 @@ expectedUserBehavior:
- Standard ACP resource_link and URI-backed resource content renders as paperclip attachment cards.
- Canonical assistant `__openclaw.media` facts render as attachment cards even when visible prose only mentions the output path.
- Explicit assistant OpenClaw MEDIA directives omitted by ACP are recovered for live completions and historical session loads without displaying the raw directive.
- Completed turn durations use transcript timing for both live completion and historical reload so navigating between conversations does not change the displayed value.
- MEDIA recovery remains aligned when the triggering ACP user turn contains structured resources, images, or no text.
- Attachment rows render after assistant prose and preserve declaration order.
- User image attachments render as thumbnails with a filename overlay on hover.
@@ -172,6 +173,7 @@ acceptance:
- Arbitrary prose paths do not become attachments.
- Existing local references outside the workspace can be previewed or opened after exact session/generation validation and per-operation Main re-resolution.
- Live and historical paths deduplicate and reject stale session or generation results.
- A completed live turn is reconciled to the same transcript-derived duration used after session navigation.
- Native ACP resources take precedence over transcript compatibility evidence.
- Attachment access remains bound to Main-owned session, generation, target revalidation, and outgoing-record authority on every operation.
- Attachment rows use semantic controls with safe accessible labels, keyboard activation, and disabled unavailable states.
@@ -200,6 +202,7 @@ Standard ACP resource content is the preferred attachment source. The OpenClaw t
| --- | --- |
| Standard ACP resources render actionable cards | `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-chat-components.test.tsx`, `tests/e2e/chat-acp-attachments.spec.ts` |
| Canonical persisted OpenClaw media facts, explicit `MEDIA:` recovery, and hidden raw directives | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts` |
| Stable completed-turn duration across live completion and session navigation | `tests/unit/acp-chat-store.test.ts`, `tests/unit/acp-turn-timings.test.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts` |
| Explicit parser grammar rejects fenced, wrapped, inline, malformed, unknown-scheme, and overlong values | `tests/unit/acp-media-attachments.test.ts`, `acp-compatibility-content-safety` |
| Transcript suffix alignment uses normalized user text and occurrence from the tail without guessing | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `acp-chat-state-and-history` |
| Attached and attachment-only user turns use binary-free structured prompt projection | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `acp-chat-state-and-history` |
+1 -3
View File
@@ -74,9 +74,7 @@ export async function fetchOpenClawTranscriptSupplement(
const [historyResult, timingResult] = await Promise.allSettled([
hostApi.sessions.history({ sessionKey: input.sessionKey, limit: 1000 }),
input.liveUserMessageId
? Promise.resolve(null)
: hostApi.sessions.turnTimings({ sessionKey: input.sessionKey, limit: 1000 }),
hostApi.sessions.turnTimings({ sessionKey: input.sessionKey, limit: 1000 }),
]);
const response = historyResult.status === 'fulfilled' ? historyResult.value : null;
const timingResponse = timingResult.status === 'fulfilled' ? timingResult.value : null;
+15 -6
View File
@@ -701,12 +701,21 @@ async function runTranscriptSupplement(operation: TranscriptSupplementOperation)
});
if (!result || !isCurrent()) return;
if (!operation.liveUserMessageId && result.turnTimings.length > 0) {
useAcpChatSessionStore.setState((current) => (
isCurrentTranscriptSupplement(current, operation)
? { turnTimingsByUserMessageId: alignHistoricalTurnTimings(current.timeline, result.turnTimings) }
: {}
));
if (result.turnTimings.length > 0) {
useAcpChatSessionStore.setState((current) => {
if (!isCurrentTranscriptSupplement(current, operation)) return {};
const transcriptTimings = alignHistoricalTurnTimings(current.timeline, result.turnTimings);
if (!operation.liveUserMessageId) {
return { turnTimingsByUserMessageId: transcriptTimings };
}
if (!transcriptTimings[operation.liveUserMessageId]) return {};
return {
turnTimingsByUserMessageId: {
...current.turnTimingsByUserMessageId,
...transcriptTimings,
},
};
});
}
for (const start of result.imageGeneration.starts) {
@@ -219,6 +219,46 @@ async function installAcpPromptSuccessMock(app: ElectronApplication) {
});
}
async function installAcpPromptTimingMock(
app: ElectronApplication,
timing: { normalizedUserText: string; durationMs: number },
) {
await app.evaluate(async ({ app: _app }, transcriptTiming) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: {
id?: string;
module?: string;
action?: string;
}) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: {
id?: string;
module?: string;
action?: string;
}) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
if (request?.module === 'sessions' && request.action === 'turnTimings') {
return {
id: request.id,
ok: true,
data: {
success: true,
timings: [{
...transcriptTiming,
userOccurrenceFromTail: 1,
}],
},
};
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, timing);
}
async function installAcpPromptFailureMock(app: ElectronApplication, error: string) {
await app.evaluate(async ({ app: _app }, promptError) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
@@ -373,6 +413,34 @@ test.describe('ClawX ACP inline timeline', () => {
}
});
test('reconciles a completed live turn to transcript timing', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const prompt = 'Keep this live duration stable';
try {
await installAcpChatMocks(app);
await installAcpPromptTimingMock(app, {
normalizedUserText: prompt,
durationMs: 6_400,
});
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-input').fill(prompt);
await page.getByTestId('chat-composer-send').click();
await emitAcpSessionUpdates(app, [{
sessionUpdate: 'agent_message_chunk',
messageId: 'timed-live-assistant',
content: { type: 'text', text: 'Live turn measured' },
}]);
await expect(page.getByText('Live turn measured')).toBeVisible();
await expect(page.getByTestId('acp-turn-duration')).toHaveText('Took 6 sec');
} finally {
await closeElectronApp(app);
}
});
test('commits a long historical replay without exposing partial assistant text', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const paragraphChunks = Array.from({ length: 12 }, (_, index) => `Paragraph ${index + 1}.\n\n`);
+42
View File
@@ -610,6 +610,48 @@ describe('ACP Chat store', () => {
now.mockRestore();
});
it('reconciles a completed live turn to the transcript duration used after navigation', async () => {
const prompt = createDeferred<{ success: boolean; generation: number }>();
hostApiMock.sendAcpPrompt.mockReturnValueOnce(prompt.promise);
hostApiMock.sessionTurnTimings.mockResolvedValue({
success: true,
timings: [{
normalizedUserText: 'Keep this duration stable',
userOccurrenceFromTail: 1,
durationMs: 2_400,
}],
});
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const { useAcpChatSessionStore } = await importStore();
await useAcpChatSessionStore.getState().loadSession({
sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo',
});
const sending = useAcpChatSessionStore.getState().sendPrompt({
sessionKey: 'agent:pi:s1',
cwd: '/repo',
message: 'Keep this duration stable',
messageId: 'user-live',
});
await vi.waitFor(() => expect(hostApiMock.sendAcpPrompt).toHaveBeenCalledTimes(1));
now.mockReturnValue(4_600);
prompt.resolve({ success: true, generation: 1 });
await expect(sending).resolves.toBe(true);
await vi.waitFor(() => expect(
useAcpChatSessionStore.getState().turnTimingsByUserMessageId['user-live'],
).toEqual({
source: 'transcript',
status: 'complete',
durationMs: 2_400,
}));
expect(hostApiMock.sessionTurnTimings).toHaveBeenCalledWith({
sessionKey: 'agent:pi:s1',
limit: 1000,
});
now.mockRestore();
});
it('keeps an in-flight timeline updated while another session is active and restores it on return', async () => {
const prompt = createDeferred<{ success: boolean; generation: number }>();
hostApiMock.loadAcpSession