Files
nexu-io__open-design/apps/web/tests/runtime/strategy-question-continuation.test.ts
lefarcen ea58bd7526 fix(web): stop replaying OD Next turns parked on the user (#8250)
* fix: preserve ChatPanel history and clarification state

* test(daemon): align artifact replay assertions with durable history

* fix(web): stop replaying OD Next turns parked on the user

A clarification turn ends with its Run succeeded while the strategy task
stays open (`clarification_required`, `plan_ready`). The recovery probe in
`attachRecoverableRuns` only bailed out for terminal tasks, so it cleared
the finished row and replayed the Run from event 0. A conversation refresh
landing mid-replay adopted the persisted transcript, the rest of the replay
was appended behind it, and the replay's terminal PUT stored the duplicate:
the question form card rendered with raw form JSON under it.

A succeeded Run whose task holds no active Run has nothing left to follow;
the daemon claims any automatic successor in the same transaction as the
verdict, which a probe would see as a different `activeRunId`. Seal such a
Run without clearing or replaying it. Rows that still need transcript
recovery keep replaying.

Refs OPEND-3230
2026-09-17 16:42:23 +00:00

182 lines
5.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { ChatRunStatusResponse, StrategyTaskProjectionV2 } from '@open-design/contracts';
import {
resolveQuestionFormStrategyTaskExecutionId,
strategyBlockedMessageFields,
strategySettledMessageFields,
strategyTaskParkedOnSucceededRun,
} from '../../src/runtime/strategy-question-continuation';
describe('question-form strategy continuation handle recovery', () => {
it('recovers the task handle from status during the same-render projection race', async () => {
const fetchRunStatus = vi.fn(async () => ({
strategyTask: { taskExecutionId: 'task-1' },
} as ChatRunStatusResponse));
await expect(resolveQuestionFormStrategyTaskExecutionId({
sourceRunId: 'run-1',
fetchRunStatus,
})).resolves.toBe('task-1');
expect(fetchRunStatus).toHaveBeenCalledWith('run-1');
});
it('keeps an ordinary question form ordinary when status has no task', async () => {
const fetchRunStatus = vi.fn(async () => ({ status: 'succeeded' } as ChatRunStatusResponse));
await expect(resolveQuestionFormStrategyTaskExecutionId({
sourceRunId: 'run-ordinary',
fetchRunStatus,
})).resolves.toBeUndefined();
});
it('does not reject or block submission when status recovery fails', async () => {
const fetchRunStatus = vi.fn(async () => {
throw new Error('daemon unavailable');
});
const submit = vi.fn();
const taskExecutionId = await resolveQuestionFormStrategyTaskExecutionId({
sourceRunId: 'run-unavailable',
fetchRunStatus,
});
submit(taskExecutionId);
expect(taskExecutionId).toBeUndefined();
expect(submit).toHaveBeenCalledWith(undefined);
});
});
function blockedProjection(
overrides: Partial<StrategyTaskProjectionV2> = {},
): StrategyTaskProjectionV2 {
return {
taskExecutionId: 'task-1',
strategy: {
id: 'od-next-strategy',
version: '2.0.0',
packageHash: 'a'.repeat(64),
snapshotId: 'snapshot-1',
},
inputStage: 'request',
outcome: 'blocked',
route: 'full_plan',
executionMode: null,
activeRunId: 'run-1',
terminal: true,
...overrides,
} as StrategyTaskProjectionV2;
}
describe('strategyBlockedMessageFields', () => {
it('derives message termination fields from a blocked terminal projection', () => {
expect(strategyBlockedMessageFields(blockedProjection({
blockedContext: {
reasonCodes: ['od_next_machine_protocol_missing'],
visibleText: ' 这轮回复没有携带机器协议块。 ',
},
}))).toEqual({
strategyTaskBlocked: true,
strategyTaskBlockedText: '这轮回复没有携带机器协议块。',
});
});
it('keeps the blocked flag with a null text when the gate left no visible text', () => {
expect(strategyBlockedMessageFields(blockedProjection({
blockedContext: {
reasonCodes: ['od_next_native_session_continuity_unproven'],
visibleText: null,
},
}))).toEqual({ strategyTaskBlocked: true, strategyTaskBlockedText: null });
expect(strategyBlockedMessageFields(blockedProjection())).toEqual({
strategyTaskBlocked: true,
strategyTaskBlockedText: null,
});
});
it('returns null for non-blocked or absent projections', () => {
expect(strategyBlockedMessageFields(undefined)).toBeNull();
expect(strategyBlockedMessageFields(blockedProjection({
outcome: 'completed',
}))).toBeNull();
expect(strategyBlockedMessageFields(blockedProjection({
outcome: 'running',
terminal: false,
}))).toBeNull();
});
});
describe('strategySettledMessageFields', () => {
it('stamps a delivered flag for a completed task', () => {
expect(strategySettledMessageFields(blockedProjection({
outcome: 'completed',
terminal: true,
blockedContext: undefined,
}))).toEqual({ strategyTaskDelivered: true });
});
it('keeps the blocked stamp taking precedence', () => {
expect(strategySettledMessageFields(blockedProjection())).toMatchObject({
strategyTaskBlocked: true,
});
});
it('stamps nothing while the task is still running', () => {
expect(strategySettledMessageFields(blockedProjection({
outcome: 'running',
terminal: false,
blockedContext: undefined,
}))).toBeNull();
expect(strategySettledMessageFields(undefined)).toBeNull();
});
});
describe('strategyTaskParkedOnSucceededRun', () => {
const parked = (overrides: Partial<StrategyTaskProjectionV2> = {}) => blockedProjection({
outcome: 'clarification_required',
terminal: false,
blockedContext: undefined,
...overrides,
});
it.each(['clarification_required', 'plan_ready'] as const)(
'is true when a succeeded Run left its task waiting on the user (%s)',
(outcome) => {
expect(strategyTaskParkedOnSucceededRun(
{ status: 'succeeded', strategyTask: parked({ outcome }) },
'run-1',
)).toBe(true);
},
);
it('is false while the task still runs, even on the same Run', () => {
expect(strategyTaskParkedOnSucceededRun(
{ status: 'succeeded', strategyTask: parked({ outcome: 'running' }) },
'run-1',
)).toBe(false);
});
it('is false when the task has moved on to another Run', () => {
expect(strategyTaskParkedOnSucceededRun(
{ status: 'succeeded', strategyTask: parked({ activeRunId: 'run-2' }) },
'run-1',
)).toBe(false);
});
it('is false while the Run itself has not succeeded', () => {
for (const status of ['queued', 'running', 'failed', 'canceled'] as const) {
expect(strategyTaskParkedOnSucceededRun(
{ status, strategyTask: parked() },
'run-1',
)).toBe(false);
}
});
it('leaves terminal tasks and task-less Runs to their own checks', () => {
expect(strategyTaskParkedOnSucceededRun(
{ status: 'succeeded', strategyTask: blockedProjection() },
'run-1',
)).toBe(false);
expect(strategyTaskParkedOnSucceededRun({ status: 'succeeded' }, 'run-1')).toBe(false);
});
});