mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix stable turn duration across conversation switches (#1219)
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user