Files
qwen-code/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx
T

2719 lines
91 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DaemonSessionActions } from '@qwen-code/web-shell/daemon-react-sdk';
import {
DaemonHttpError,
type DaemonTranscriptStore,
} from '@qwen-code/sdk/daemon';
import { getTranslator } from '../i18n';
import {
useQueuedPrompts,
type UseQueuedPromptsResult,
} from './useQueuedPrompts';
const sdk = vi.hoisted(() => ({
pendingEvents: [] as Array<{
type:
| 'pending_prompt_started'
| 'pending_prompt_completed'
| 'turn_complete'
| 'turn_error';
originatorClientId?: string;
data: {
sessionId: string;
promptId?: string;
text?: string;
state?: string;
stopReason?: string;
};
}>,
batches: [] as Array<{
sessionId: string;
messages: readonly string[];
messageIds?: readonly string[];
originatorClientId?: string;
}>,
consume: vi.fn(),
ownerVersion: 0,
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
consumePendingPromptEvents: vi.fn(),
getPendingPromptEvents: () => sdk.pendingEvents,
getPendingPromptVersion: () => 0,
subscribePendingPromptEvents: () => () => {},
subscribePendingPromptVersion: () => () => {},
useDaemonMidTurnInjected: () => ({
batches: sdk.batches,
consume: sdk.consume,
}),
useDaemonSessionOwnerGuard: () => ({
capture: () => {
const ownerVersion = sdk.ownerVersion;
return { isCurrent: () => sdk.ownerVersion === ownerVersion };
},
}),
}));
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const t = getTranslator('zh-CN');
let container: HTMLElement;
let root: Root;
let latest: UseQueuedPromptsResult;
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, reject, resolve };
}
function mount(
streamingState: 'idle' | 'waiting' | 'responding' | 'thinking',
sessionActions: DaemonSessionActions,
canMutateMidTurn = true,
connected = false,
writeBlocked = false,
holdQueuedPromptsLocally = false,
// `null` = the workspace has not resolved yet (an explicit `undefined`
// argument would take the default).
workspaceCwd: string | null = '/workspace',
canQueryPendingPrompts = true,
getPromptDispatchError?: (text: string) => string | undefined,
) {
const editor = {
getText: vi.fn(() => ''),
setText: vi.fn(),
focus: vi.fn(),
restoreImages: vi.fn(),
restoreInputAnnotations: vi.fn(),
};
const store = {
appendLocalUserMessage: vi.fn(),
dispatch: vi.fn(),
} as unknown as DaemonTranscriptStore;
const reportError = vi.fn();
function Harness({
state,
activeSessionId,
blocked,
hold,
cwd,
stopped,
}: {
state: typeof streamingState;
activeSessionId: string;
blocked: boolean;
hold: boolean;
cwd: string | null;
stopped: boolean;
}) {
latest = useQueuedPrompts({
getPromptDispatchError,
connected,
writeBlocked: blocked || stopped,
runtimeStopped: stopped,
sessionId: activeSessionId,
workspaceCwd: cwd ?? undefined,
clientId: 'client-1',
canMutateMidTurn,
// This suite pins the legacy local-fallback lifecycle.
canQueryMidTurn: false,
canQueryPendingPrompts,
canInjectMidTurnMedia: false,
streamingState: state,
holdQueuedPromptsLocally: hold,
sessionActions,
store,
editorRef: { current: editor as never },
reportError,
t,
});
return null;
}
let activeSessionId = 'session-1';
let blocked = writeBlocked;
let held = holdQueuedPromptsLocally;
let cwd = workspaceCwd;
let stopped = false;
const render = (
state: typeof streamingState,
nextSessionId = activeSessionId,
replaceOwner = false,
nextWriteBlocked = blocked,
nextHold = held,
nextCwd: string | null = cwd,
nextStopped = stopped,
) => {
if (replaceOwner) sdk.ownerVersion += 1;
activeSessionId = nextSessionId;
blocked = nextWriteBlocked;
held = nextHold;
cwd = nextCwd;
stopped = nextStopped;
act(() =>
root.render(
<Harness
state={state}
activeSessionId={activeSessionId}
blocked={blocked}
hold={held}
cwd={cwd}
stopped={stopped}
/>,
),
);
};
render(streamingState);
return { editor, render, reportError, store };
}
function createActions() {
const pendingSubmit = deferred<{
promptId: string;
removedAfterAbort?: true;
}>();
return {
actions: {
enqueueMidTurnMessage: vi.fn(),
removeMidTurnMessage: vi.fn(),
removePendingPrompt: vi.fn(),
getPendingPrompts: vi.fn().mockResolvedValue({ pendingPrompts: [] }),
submitPrompt: vi.fn(() => pendingSubmit.promise),
sendPrompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
sendMidTurnMessagesNow: vi.fn(),
} as unknown as DaemonSessionActions,
pendingSubmit,
};
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
sdk.batches = [];
sdk.pendingEvents = [];
sdk.consume.mockReset();
sdk.ownerVersion = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
describe('useQueuedPrompts default mid-turn insertion', () => {
it('reports a streaming fallback policy block without restoring the message', async () => {
const { actions } = createActions();
const policy = { error: undefined as string | undefined };
vi.mocked(actions.sendPrompt).mockImplementation(async (_text, options) => {
options?.beforeDispatch?.();
return { stopReason: 'end_turn' };
});
const { render, editor, reportError } = mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
() => policy.error,
);
act(() => latest.enqueuePrompt('queued before policy changed'));
expect(latest.queuedTexts).toEqual(['queued before policy changed']);
policy.error = 'Sending is now disabled by host policy';
await act(async () => render('idle'));
expect(reportError).toHaveBeenCalledWith(
expect.objectContaining({ message: policy.error }),
t('queue.queueFailed'),
);
expect(editor.setText).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledTimes(1);
});
it('does not resurrect a cancelled streaming fallback message or report an error', async () => {
const { actions } = createActions();
const preflight = deferred<void>();
vi.mocked(actions.sendPrompt).mockImplementation(async (_text, options) => {
await preflight.promise;
options?.beforeDispatch?.();
return { stopReason: 'end_turn' };
});
const { render, editor, reportError } = mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
);
act(() => latest.enqueuePrompt('cancel this queued message'));
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledTimes(1);
expect(latest.queuedPrompts[0]?.serverState).toBe('submitting');
await act(async () => latest.removeQueuedPrompt(1));
expect(latest.queuedPrompts).toEqual([]);
await act(async () => {
preflight.resolve();
await preflight.promise;
});
expect(editor.setText).not.toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledTimes(1);
});
it('keeps the local queue usable without requesting unsupported native queue routes', async () => {
const { actions } = createActions();
const { render } = mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
);
await act(async () => {});
expect(actions.getPendingPrompts).not.toHaveBeenCalled();
act(() => latest.enqueuePrompt('follow-up question'));
expect(actions.sendPrompt).not.toHaveBeenCalled();
expect(latest.queuedTexts).toEqual(['follow-up question']);
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledWith(
'follow-up question',
expect.objectContaining({ beforeDispatch: expect.any(Function) }),
);
expect(actions.getPendingPrompts).not.toHaveBeenCalled();
});
it('waits for a local streaming turn to finish before sending the next queued message', async () => {
const { actions } = createActions();
const first = deferred<{ stopReason: string }>();
vi.mocked(actions.sendPrompt).mockReturnValueOnce(first.promise as never);
const { render } = mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
);
act(() => {
latest.enqueuePrompt('first');
latest.enqueuePrompt('second');
});
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledTimes(1);
expect(vi.mocked(actions.sendPrompt).mock.calls[0]?.[0]).toBe('first');
await act(async () => {
first.resolve({ stopReason: 'end_turn' });
await first.promise;
});
expect(
vi.mocked(actions.sendPrompt).mock.calls.map(([text]) => text),
).toEqual(['first', 'second']);
expect(latest.queuedTexts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(actions.getPendingPrompts).not.toHaveBeenCalled();
});
it('restores a locally queued message after a send fails before dispatch without replaying it', async () => {
const { actions } = createActions();
vi.mocked(actions.sendPrompt).mockRejectedValueOnce(
new Error('offline before dispatch'),
);
const { render, editor, reportError } = mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
);
act(() => {
latest.enqueuePrompt('retry manually');
});
await act(async () => render('idle'));
expect(editor.setText).toHaveBeenCalledWith('retry manually');
expect(reportError).toHaveBeenCalled();
await act(async () => render('idle'));
expect(actions.sendPrompt).toHaveBeenCalledTimes(1);
});
it.each([undefined, ' original question\n'])(
'keeps an optional original submission separate from the queued payload: %s',
async (submittedPrompt) => {
const { actions } = createActions();
const { render } = mount(
'responding',
actions,
false,
false,
false,
true,
);
act(() =>
latest.enqueuePrompt(
'host-expanded payload',
undefined,
undefined,
undefined,
undefined,
undefined,
submittedPrompt,
),
);
await act(async () => render('idle', 'session-1', false, false, false));
expect(actions.submitPrompt).toHaveBeenCalledWith(
'host-expanded payload',
expect.objectContaining({ sessionId: 'session-1' }),
);
const options = vi.mocked(actions.submitPrompt).mock.calls[0]?.[1];
if (submittedPrompt === undefined) {
expect(options).not.toHaveProperty('submittedPrompt');
} else {
expect(options).toHaveProperty('submittedPrompt', submittedPrompt);
}
},
);
it('holds Goal follow-ups locally until an explicit insert', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'inserted-1',
});
mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('wait for explicit insert'));
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'wait for explicit insert' },
]);
await act(async () => latest.insertQueuedPrompt(1));
// An explicit insert is deliberately uncancellable: it carries no abort
// signal so an owner rotation cannot kill a send the user asked for.
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledWith(
'wait for explicit insert',
expect.not.objectContaining({ signal: expect.anything() }),
);
expect(latest.queuedPrompts).toMatchObject([
{
text: 'wait for explicit insert',
midTurnState: 'queued',
midTurnMessageId: 'inserted-1',
},
]);
});
it('sends a daemon-owned mid-turn row now and reports a failure', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'inserted-1',
});
const sendMidTurnMessagesNow = vi.mocked(actions.sendMidTurnMessagesNow);
sendMidTurnMessagesNow
.mockResolvedValueOnce({ requested: true })
.mockRejectedValueOnce(new Error('daemon 500'))
.mockResolvedValue({ requested: true });
const { reportError } = mount(
'responding',
actions,
true,
false,
false,
true,
);
act(() => latest.enqueuePrompt('send this now'));
// Still held locally: nothing the daemon could deliver yet.
await act(async () => latest.sendQueuedPromptNow(1));
expect(sendMidTurnMessagesNow).not.toHaveBeenCalled();
await act(async () => latest.insertQueuedPrompt(1));
// Inserting only queues it; sending now is the user's separate choice.
expect(sendMidTurnMessagesNow).not.toHaveBeenCalled();
await act(async () => latest.sendQueuedPromptNow(1));
expect(sendMidTurnMessagesNow).toHaveBeenCalledWith({
sessionId: 'session-1',
});
expect(reportError).not.toHaveBeenCalled();
await act(async () => latest.sendQueuedPromptNow(1));
expect(reportError).toHaveBeenCalledWith(
expect.any(Error),
t('queue.sendNowFailed'),
);
// A failed request does not block the next click.
await act(async () => latest.sendQueuedPromptNow(1));
expect(sendMidTurnMessagesNow).toHaveBeenCalledTimes(3);
});
it('does not query the mid-turn queue after send-now without that capability', async () => {
const { actions } = createActions();
const getMidTurnMessages = vi.fn();
Object.assign(actions, { getMidTurnMessages });
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'inserted-1',
});
vi.mocked(actions.sendMidTurnMessagesNow).mockResolvedValue({
requested: false,
});
const { reportError } = mount(
'responding',
actions,
true,
false,
false,
true,
);
act(() => latest.enqueuePrompt('send this now'));
await act(async () => latest.insertQueuedPrompt(1));
await act(async () => latest.sendQueuedPromptNow(1));
expect(actions.sendMidTurnMessagesNow).toHaveBeenCalledTimes(1);
expect(getMidTurnMessages).not.toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
});
it('inserts a message typed during a turn without asking to send it now', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
});
mount('responding', actions);
await act(async () => {
latest.enqueuePrompt('steer later');
});
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledWith(
'steer later',
expect.anything(),
);
expect(actions.sendMidTurnMessagesNow).not.toHaveBeenCalled();
// An older daemon answered without a message id: nothing to send now.
expect(latest.queuedPrompts).toMatchObject([
{ midTurnState: 'queued', midTurnMessageId: undefined },
]);
await act(async () =>
latest.sendQueuedPromptNow(latest.queuedPrompts[0]!.id),
);
expect(actions.sendMidTurnMessagesNow).not.toHaveBeenCalled();
});
it('does not insert a held prompt between turns', async () => {
const { actions } = createActions();
mount('idle', actions, true, false, false, true);
act(() => latest.enqueuePrompt('wait for a running turn'));
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'wait for a running turn' },
]);
});
it('does not insert a held prompt with input annotations', async () => {
const { actions } = createActions();
mount('responding', actions, true, false, false, true);
act(() =>
latest.enqueuePrompt(
'inspect this file',
undefined,
undefined,
undefined,
[
{
type: 'reference',
start: 8,
end: 17,
text: 'this file',
reference: {
id: 'file-1',
kind: 'data-table',
label: 'File',
value: '/tmp/a.ts',
serialized: 'this file',
},
},
],
),
);
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toHaveLength(1);
});
it('does not insert a held prompt carrying images', async () => {
// `enqueueMidTurnMessage` transmits text only, so inserting an image-bearing
// row would silently drop the attachment. The display hides Insert for this
// shape; the hook guard is the backstop on the public API.
const { actions } = createActions();
mount('responding', actions, true, false, false, true);
act(() =>
latest.enqueuePrompt('look at this', [
{ data: 'abc', media_type: 'image/png' },
]),
);
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'look at this', images: [{ media_type: 'image/png' }] },
]);
});
it('does not insert a held slash command', async () => {
// A command injected mid-turn arrives as literal text the daemon never
// executes, so it must stay queued for the ordinary path.
const { actions } = createActions();
mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('/compact'));
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([{ text: '/compact' }]);
});
it('does not insert a held prompt with file attachments', async () => {
const { actions } = createActions();
mount('responding', actions, true, false, false, true);
act(() =>
latest.enqueuePrompt('inspect this file', undefined, [
{
name: 'a.ts',
media_type: 'text/typescript',
text: 'export {};',
},
]),
);
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'inspect this file', files: [{ name: 'a.ts' }] },
]);
});
it('preserves an accepted explicit insert across a session switch', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('stay with session one'));
let insertPromise: Promise<void> | undefined;
act(() => {
insertPromise = latest.insertQueuedPrompt(1);
});
render('idle', 'session-2', true, false, true);
await act(async () => {
admission.resolve({ accepted: true, messageId: 'mid-1' });
await insertPromise;
});
render('responding', 'session-1', true, false, true);
expect(latest.queuedPrompts).toMatchObject([
{
text: 'stay with session one',
midTurnState: 'queued',
midTurnMessageId: 'mid-1',
isInserting: false,
},
]);
});
it('locks edit and clear while an explicit insert is in flight', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { editor } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('in flight'));
act(() => {
void latest.insertQueuedPrompt(1);
});
await act(async () => latest.editQueuedPrompt(1));
let consumed = false;
let cleared = false;
act(() => {
consumed = latest.editLastQueuedPrompt();
cleared = latest.clearQueuedPrompts();
});
expect(consumed).toBe(true);
expect(cleared).toBe(false);
expect(editor.setText).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'in flight', isInserting: true },
]);
});
it('keeps an explicit insert in flight when the turn becomes idle', () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
new Promise(() => undefined),
);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('in flight'));
act(() => {
void latest.insertQueuedPrompt(1);
});
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
?.signal;
render('idle', 'session-1', false, false, true);
// Nothing can cancel an explicit insert: it is issued without a signal.
expect(signal).toBeUndefined();
});
it('does not resubmit a legacy explicit insert accepted as the turn becomes idle', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render, reportError } = mount(
'responding',
actions,
true,
false,
false,
true,
);
act(() => latest.enqueuePrompt('submit after settle'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
render('idle', 'session-1', false, false, false);
await act(async () => {
admission.resolve({ accepted: true, messageId: 'accepted-once' });
await insertion;
});
expect(reportError).not.toHaveBeenCalled();
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('resubmits a legacy explicit insert after an idle transport failure', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render, reportError } = mount(
'responding',
actions,
true,
false,
false,
true,
);
act(() => latest.enqueuePrompt('recover after failure'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
render('idle', 'session-1', false, false, false);
await act(async () => {
admission.reject(new Error('connection lost'));
await insertion;
});
expect(reportError).toHaveBeenCalledOnce();
expect(actions.submitPrompt).toHaveBeenCalledWith(
'recover after failure',
expect.objectContaining({ sessionId: 'session-1' }),
);
expect(actions.submitPrompt).toHaveBeenCalledOnce();
expect(latest.queuedPrompts).toMatchObject([
{
text: 'recover after failure',
serverState: 'submitting',
isInserting: false,
},
]);
});
it('silently holds an explicit insert rejected while a Goal remains active', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render, reportError } = mount(
'responding',
actions,
true,
false,
false,
true,
);
act(() => latest.enqueuePrompt('keep held'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
render('idle', 'session-1', false, false, true);
await act(async () => {
admission.resolve({ accepted: false });
await insertion;
});
expect(reportError).toHaveBeenCalledOnce();
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([{ text: 'keep held' }]);
expect(latest.queuedPrompts[0]?.midTurnState).toBeUndefined();
expect(latest.queuedPrompts[0]?.isInserting).toBe(false);
});
it('lets an explicit insert settle into its source-session stash', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
(_message, opts) => {
opts?.signal?.addEventListener(
'abort',
() => admission.reject(new DOMException('aborted', 'AbortError')),
{ once: true },
);
return admission.promise;
},
);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('insert once'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
?.signal;
render('idle', 'session-2', true, false, true);
await act(async () => {
admission.resolve({ accepted: true, messageId: 'inserted-once' });
await insertion;
});
render('responding', 'session-1', true, false, true);
expect(signal).toBeUndefined();
expect(latest.queuedPrompts).toMatchObject([
{
text: 'insert once',
midTurnState: 'queued',
midTurnMessageId: 'inserted-once',
},
]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('settles an explicit insert into the stash a cwd relocation moved', async () => {
// The workspace half of the owner key resolves mid-insert, which relocates
// the whole stash onto the new key and DELETES the old one. Settling
// through the key captured when the insert started would write nothing:
// the row would come back from the stash still `isInserting`, and every
// release/edit/delete/clear path skips such a row — bricked until reload.
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount(
'responding',
actions,
true,
false,
false,
true,
null,
);
act(() => latest.enqueuePrompt('insert once'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
// cwd resolves for the SAME session: the stash relocates.
render('responding', 'session-1', false, false, true, '/workspace');
// Then the user leaves, so the settle lands with the row stashed.
render('responding', 'session-2', false, false, true, '/workspace');
await act(async () => {
admission.resolve({ accepted: true, messageId: 'inserted-once' });
await insertion;
});
render('responding', 'session-1', false, false, true, '/workspace');
expect(latest.queuedPrompts).toMatchObject([
{
text: 'insert once',
midTurnState: 'queued',
midTurnMessageId: 'inserted-once',
isInserting: false,
},
]);
});
it('keeps one explicit insert in flight across an A-to-B-to-A switch', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
(_message, opts) => {
opts?.signal?.addEventListener(
'abort',
() => admission.reject(new DOMException('aborted', 'AbortError')),
{ once: true },
);
return admission.promise;
},
);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('insert exactly once'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
?.signal;
render('responding', 'session-2', true, false, true);
render('responding', 'session-1', true, false, true);
expect(latest.queuedPrompts).toMatchObject([
{ text: 'insert exactly once', isInserting: true },
]);
await act(async () => latest.insertQueuedPrompt(1));
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledTimes(1);
render('idle', 'session-1', false, false, true);
expect(signal).toBeUndefined();
await act(async () => {
admission.resolve({ accepted: true, messageId: 'inserted-once' });
await insertion;
});
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledTimes(1);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('submits locally held Goal follow-ups after the Goal stops', () => {
const { actions } = createActions();
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('run after goal'));
expect(actions.submitPrompt).not.toHaveBeenCalled();
render('idle', 'session-1', false, false, false);
expect(actions.submitPrompt).toHaveBeenCalledWith(
'run after goal',
expect.objectContaining({ optimisticUserMessage: false }),
);
});
it('releases held Goal follow-ups one at a time, in queue order', async () => {
// A prompt carrying media waits for its uploads before its admission POST,
// so releasing the whole batch at once lets a later plain prompt overtake
// it and land in the daemon's queue first.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'second' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first with media'));
act(() => latest.enqueuePrompt('second plain'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
});
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media', 'second plain']);
});
it('stops the release chain when the Goal re-activates mid-drain', async () => {
// The chain is built synchronously when the hold lifts, but each link runs
// only after the previous admission settles. Resuming the Goal inside that
// window must stop the remaining links — otherwise the queue keeps draining
// into an active Goal after the user changed their mind.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'second' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first'));
act(() => latest.enqueuePrompt('second'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first']);
// Goal resumed while the first admission is still in flight.
render('idle', 'session-1', false, false, true);
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
});
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first']);
// The unsent row goes back to held, so the next inactive transition
// re-drains it rather than stranding it as 'submitting'.
expect(latest.queuedPrompts).toMatchObject([
{ text: 'second', serverState: undefined },
]);
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first', 'second']);
});
it('stashes the undrained release chain when the session changes mid-drain', async () => {
// The chain marks the whole batch `submitting` up front and then releases
// it serially, so a switch inside that window used to orphan every row it
// had not reached: the stash only saved `serverState === undefined` rows,
// and the remaining links POSTed against the wrong session and were
// swallowed by the chain's own `.catch`. Both prompts were gone for good.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'second' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first with media'));
act(() => latest.enqueuePrompt('second plain'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
// The user switches away while the first admission is still in flight.
render('idle', 'session-2', false, false, false);
expect(latest.queuedPrompts).toEqual([]);
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
});
// The second link must not POST into session-2.
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
// Coming back, the row the chain never reached is in session-1's queue
// again and the fresh drain releases it -- to session-1, in order.
render('idle', 'session-1', false, false, false);
expect(latest.queuedPrompts).toMatchObject([{ text: 'second plain' }]);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media', 'second plain']);
});
it('holds a prompt typed mid-drain behind the release chain', async () => {
// The chain preserves order only inside the batch it drains. A prompt typed
// while it is still in flight used to POST immediately -- overtaking the
// older rows it was typed after, and, while link 1's uploads were still
// running, even starting the turn ahead of link 1 itself.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'later' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first with media'));
act(() => latest.enqueuePrompt('second plain'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
// Typed inside the drain window: it must queue behind the chain, not race
// it. The row is still stamped `submitting` -- it is spoken for, just not
// POSTed yet.
act(() => latest.enqueuePrompt('typed during drain'));
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
for (let i = 0; i < 10; i += 1) await Promise.resolve();
});
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media', 'second plain', 'typed during drain']);
});
it('stashes a chain link that bails before the owner change commits', async () => {
// The owner token is replaced in the render body while the stash is a
// passive effect flushed after commit. A link firing in that window used
// to delete its id from the unreleased set before the owner check, so the
// stash -- which saves a stamped row only while its id is still there --
// discarded a prompt the chain never POSTed.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'second' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first with media'));
act(() => latest.enqueuePrompt('second plain'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
// Token replaced, stash not flushed yet -- link 2 runs inside the window.
sdk.ownerVersion += 1;
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
await Promise.resolve();
});
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
// The commit that follows must still find the row stashable.
render('idle', 'session-2', false, false, false);
render('idle', 'session-1', false, false, false);
expect(latest.queuedPrompts).toMatchObject([{ text: 'second plain' }]);
});
it('drops the undrained release chain when the queue is cleared mid-drain', async () => {
// Before the serial chain, every `submitting` row had its abort controller
// created synchronously with the stamp, so clearing the queue aborted it.
// The chain defers submission past the stamp, so the links it has not
// fired yet are reachable only through the row's absence from the queue.
const { actions } = createActions();
const firstAdmission = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstAdmission.promise as never)
.mockResolvedValue({ promptId: 'second' } as never);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first with media'));
act(() => latest.enqueuePrompt('second plain'));
render('idle', 'session-1', false, false, false);
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
act(() => {
latest.clearQueuedPrompts();
});
await act(async () => {
firstAdmission.resolve({ promptId: 'first' });
await Promise.resolve();
});
expect(
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
).toEqual(['first with media']);
expect(latest.queuedPrompts).toEqual([]);
});
it('keeps locally held Goal follow-ups isolated across session switches', () => {
const { actions } = createActions();
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('stay with session one'));
render('responding', 'session-2', true, false, true);
expect(latest.queuedPrompts).toEqual([]);
act(() => latest.enqueuePrompt('stay with session two'));
render('responding', 'session-1', true, false, true);
expect(latest.queuedPrompts).toMatchObject([
{ text: 'stay with session one' },
]);
render('responding', 'session-2', true, false, true);
expect(latest.queuedPrompts).toMatchObject([
{ text: 'stay with session two' },
]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
});
it('restores an unaccepted mid-turn prompt when its owner is replaced', () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
new Promise(() => undefined),
);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('belongs to the source attachment'));
expect(latest.queuedPrompts).toHaveLength(1);
render('responding', 'session-1', true);
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith(
'belongs to the source attachment',
);
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
?.signal;
expect(signal?.aborted).toBe(true);
});
it('queues and submits an image-only prompt without using mid-turn text insertion', () => {
const { actions } = createActions();
mount('responding', actions);
const images = [{ data: 'Ym1w', media_type: 'image/bmp' }];
act(() => latest.enqueuePrompt('', images));
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(actions.submitPrompt).toHaveBeenCalledWith(
'',
expect.objectContaining({
images,
optimisticUserMessage: false,
}),
);
expect(latest.queuedPrompts).toMatchObject([
{
text: '',
images,
payloadCompleteness: 'complete',
serverState: 'submitting',
},
]);
});
it('materializes daemon-only queue rows as summary-only payloads', async () => {
const { actions } = createActions();
vi.mocked(actions.getPendingPrompts).mockResolvedValue({
pendingPrompts: [
{
promptId: 'server-image',
text: '[image]',
state: 'queued',
},
],
});
const { editor } = mount('responding', actions, true, true);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(latest.queuedPrompts).toMatchObject([
{
text: '[image]',
serverPromptId: 'server-image',
serverState: 'queued',
payloadCompleteness: 'summary-only',
},
]);
await act(async () => latest.editQueuedPrompt(1));
expect(editor.setText).not.toHaveBeenCalled();
expect(editor.restoreImages).not.toHaveBeenCalled();
});
it.each([undefined, 'client-1'])(
'retains unmatched started-event identity with originator %s',
(originatorClientId) => {
const { actions } = createActions();
const { render, store } = mount('responding', actions);
expect(latest.queuedPrompts).toEqual([]);
sdk.pendingEvents = [
{
type: 'pending_prompt_started',
originatorClientId,
data: {
sessionId: 'session-1',
promptId: 'server-unmatched',
text: 'started without a local queue row',
},
},
];
render('responding');
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
'started without a local queue row',
undefined,
{ promptId: 'server-unmatched' },
);
},
);
it('buffers an image-only started event until its exact response binds', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('responding', actions);
const images = [{ data: 'Ym1w', media_type: 'image/bmp' }];
act(() => latest.enqueuePrompt('', images));
sdk.pendingEvents = [
{
type: 'pending_prompt_started',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-image',
text: '[image]',
},
},
];
render('responding');
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
await act(async () => {
pendingSubmit.resolve({ promptId: 'server-image' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
'',
[{ data: 'Ym1w', mimeType: 'image/bmp' }],
{ promptId: 'server-image' },
undefined,
);
expect(latest.queuedPrompts).toMatchObject([
{
text: '',
images,
serverPromptId: 'server-image',
serverState: 'queued',
},
]);
});
it('keeps another prompt started buffer when one admission is unknown', async () => {
const firstSubmit = deferred<{ promptId: string }>();
const secondSubmit = deferred<{ promptId: string }>();
const { actions } = createActions();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(firstSubmit.promise)
.mockReturnValueOnce(secondSubmit.promise);
const { render, store } = mount('responding', actions);
const firstImages = [{ data: 'Zmlyc3Q=', media_type: 'image/png' }];
const secondImages = [{ data: 'c2Vjb25k', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('', firstImages));
act(() => latest.enqueuePrompt('', secondImages));
sdk.pendingEvents = [
{
type: 'pending_prompt_started',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-second',
text: '[image]',
},
},
];
render('responding');
await act(async () => {
firstSubmit.reject(new TypeError('response lost'));
await Promise.resolve();
secondSubmit.resolve({ promptId: 'server-second' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
'',
[{ data: 'c2Vjb25k', mimeType: 'image/png' }],
{ promptId: 'server-second' },
undefined,
);
});
it('binds an image prompt when its terminal event precedes admission response', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('responding', actions);
const images = [{ data: 'dGVybWluYWw=', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('', images));
sdk.pendingEvents = [
{
type: 'pending_prompt_started',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-terminal',
text: '[image]',
},
},
{
type: 'turn_complete',
data: {
sessionId: 'session-1',
promptId: 'server-terminal',
},
},
];
render('responding');
await act(async () => {
pendingSubmit.resolve({ promptId: 'server-terminal' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
'',
[{ data: 'dGVybWluYWw=', mimeType: 'image/png' }],
{ promptId: 'server-terminal' },
undefined,
);
});
it('does not append text twice when its terminal precedes admission response', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('idle', actions);
act(() => latest.enqueuePrompt('run once'));
sdk.pendingEvents = [
{
type: 'pending_prompt_started',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-text-terminal',
text: 'run once',
},
},
{
type: 'turn_complete',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-text-terminal',
},
},
];
render('idle');
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
await act(async () => {
pendingSubmit.resolve({ promptId: 'server-text-terminal' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).toHaveBeenCalledOnce();
});
it('does not append a prompt removed before its admission response', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('responding', actions);
act(() =>
latest.enqueuePrompt('', [
{ data: 'cmVtb3ZlZA==', media_type: 'image/png' },
]),
);
sdk.pendingEvents = [
{
type: 'pending_prompt_completed',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-removed',
state: 'removed',
},
},
{
type: 'turn_complete',
originatorClientId: 'client-1',
data: {
sessionId: 'session-1',
promptId: 'server-removed',
stopReason: 'cancelled',
},
},
];
render('idle');
await act(async () => {
pendingSubmit.resolve({ promptId: 'server-removed' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
});
it('does not create a duplicate summary while an image response is pending', async () => {
const { actions } = createActions();
const refresh = deferred<{
pendingPrompts: Array<{
promptId: string;
text: string;
state: 'queued';
}>;
}>();
vi.mocked(actions.getPendingPrompts).mockReturnValue(refresh.promise);
mount('responding', actions, true, true);
const images = [{ data: 'Ym1w', media_type: 'image/bmp' }];
act(() => latest.enqueuePrompt('', images));
await act(async () => {
refresh.resolve({
pendingPrompts: [
{ promptId: 'server-image', text: '[image]', state: 'queued' },
],
});
await Promise.resolve();
});
expect(latest.queuedPrompts).toHaveLength(1);
expect(latest.queuedPrompts[0]).toMatchObject({
text: '',
images,
serverState: 'submitting',
payloadCompleteness: 'complete',
});
});
it('refreshes deferred server summaries after an image response binds exactly', async () => {
const { actions, pendingSubmit } = createActions();
const initialRefresh = deferred<{
pendingPrompts: Array<{
promptId: string;
text: string;
state: 'queued';
}>;
}>();
vi.mocked(actions.getPendingPrompts)
.mockReturnValueOnce(initialRefresh.promise)
.mockResolvedValue({
pendingPrompts: [
{ promptId: 'server-image', text: '[image]', state: 'queued' },
{ promptId: 'server-other', text: 'other', state: 'queued' },
],
});
mount('responding', actions, true, true);
const images = [{ data: 'Ym1w', media_type: 'image/bmp' }];
act(() => latest.enqueuePrompt('', images));
await act(async () => {
initialRefresh.resolve({
pendingPrompts: [
{ promptId: 'server-image', text: '[image]', state: 'queued' },
{ promptId: 'server-other', text: 'other', state: 'queued' },
],
});
await Promise.resolve();
});
expect(latest.queuedPrompts).toHaveLength(1);
await act(async () => {
pendingSubmit.resolve({ promptId: 'server-image' });
await Promise.resolve();
await Promise.resolve();
});
expect(actions.getPendingPrompts).toHaveBeenCalledTimes(2);
expect(latest.queuedPrompts).toMatchObject([
{
text: '',
images,
serverPromptId: 'server-image',
payloadCompleteness: 'complete',
},
{
text: 'other',
serverPromptId: 'server-other',
payloadCompleteness: 'summary-only',
},
]);
});
it('ignores an old submit response after an S1 to S2 to S1 owner change', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('responding', actions);
act(() =>
latest.enqueuePrompt('', [{ data: 'b2xk', media_type: 'image/png' }]),
);
render('responding', 'session-2');
render('responding', 'session-1');
await act(async () => {
pendingSubmit.resolve({ promptId: 'old-server-prompt' });
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
expect(actions.removePendingPrompt).not.toHaveBeenCalled();
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
});
it('fences an old submit before the replacement owner rerenders', async () => {
const { actions, pendingSubmit } = createActions();
const { render, store } = mount('responding', actions);
act(() =>
latest.enqueuePrompt('', [{ data: 'b2xk', media_type: 'image/png' }]),
);
sdk.ownerVersion += 1;
await act(async () => {
pendingSubmit.resolve({ promptId: 'old-server-prompt' });
await Promise.resolve();
});
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
render('responding', 'session-1');
expect(latest.queuedPrompts).toEqual([]);
});
it('ignores an old refresh after an S1 to S2 to S1 owner change', async () => {
const { actions } = createActions();
const oldRefresh = deferred<{
pendingPrompts: Array<{
promptId: string;
text: string;
state: 'queued';
}>;
}>();
vi.mocked(actions.getPendingPrompts)
.mockReturnValueOnce(oldRefresh.promise)
.mockResolvedValue({ pendingPrompts: [] });
const { render } = mount('responding', actions, true, true);
render('responding', 'session-2');
render('responding', 'session-1');
await act(async () => {
oldRefresh.resolve({
pendingPrompts: [
{ promptId: 'stale-prompt', text: 'stale', state: 'queued' },
],
});
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
});
it('restores image-only payloads when submission never starts', async () => {
const { actions, pendingSubmit } = createActions();
const { editor } = mount('responding', actions);
const images = [{ data: 'cG5n', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('', images));
await act(async () => {
pendingSubmit.reject(new DaemonHttpError(413, undefined, 'Too large'));
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).not.toHaveBeenCalled();
expect(editor.restoreImages).toHaveBeenCalledWith(images);
});
it('restores image-only payloads alongside an existing draft', async () => {
const { actions, pendingSubmit } = createActions();
const { editor } = mount('responding', actions);
vi.mocked(editor.getText).mockReturnValue('current draft');
const images = [{ data: 'cG5n', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('', images));
await act(async () => {
pendingSubmit.reject(new DaemonHttpError(413, undefined, 'Too large'));
await Promise.resolve();
});
expect(editor.setText).not.toHaveBeenCalled();
expect(editor.restoreImages).toHaveBeenCalledWith(images);
});
it('does not duplicate attachments when restored text is already present', async () => {
const { actions, pendingSubmit } = createActions();
const { editor } = mount('responding', actions);
vi.mocked(editor.getText).mockReturnValue('describe');
const images = [{ data: 'cG5n', media_type: 'image/png' }];
const inputAnnotations = [
{
type: 'reference' as const,
start: 0,
end: 8,
text: 'describe',
reference: { id: 'file:describe', kind: 'file', value: 'describe' },
},
];
act(() =>
latest.enqueuePrompt(
'describe',
images,
undefined,
undefined,
inputAnnotations,
),
);
await act(async () => {
pendingSubmit.reject(new DaemonHttpError(413, undefined, 'Too large'));
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).not.toHaveBeenCalled();
expect(editor.restoreImages).not.toHaveBeenCalled();
expect(editor.restoreInputAnnotations).not.toHaveBeenCalled();
});
it('drops a dispatched payload and refreshes from the backend', async () => {
const { actions, pendingSubmit } = createActions();
const { editor, reportError } = mount('responding', actions, true, true);
const images = [{ data: 'cG5n', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('', images));
vi.mocked(actions.submitPrompt).mock.calls[0]?.[1]?.onAdmissionStarted?.();
await act(async () => {
pendingSubmit.reject(new TypeError('network disconnected'));
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.restoreImages).not.toHaveBeenCalled();
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
expect(actions.getPendingPrompts).toHaveBeenCalledTimes(2);
expect(reportError).toHaveBeenCalledWith(
expect.any(TypeError),
t('queue.queueFailed'),
);
});
it('restores queued input annotations when submission never starts', async () => {
const { actions, pendingSubmit } = createActions();
const { editor } = mount('responding', actions);
const inputAnnotations = [
{
type: 'reference' as const,
start: 0,
end: 8,
text: '@file.ts',
reference: { id: 'file:file.ts', kind: 'file', value: 'file.ts' },
},
];
act(() =>
latest.enqueuePrompt(
'@file.ts\n\nfix it',
undefined,
undefined,
undefined,
inputAnnotations,
),
);
await act(async () => {
pendingSubmit.reject(new TypeError('response lost'));
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith('@file.ts\n\nfix it');
expect(editor.restoreInputAnnotations).toHaveBeenCalledWith(
inputAnnotations,
);
});
it('keeps an accepted message queued until its injection event', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-1',
});
const { render } = mount('responding', actions);
act(() => {
latest.enqueuePrompt('补充信息');
});
await act(async () => {});
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledWith(
'补充信息',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{
text: '补充信息',
midTurnState: 'queued',
midTurnMessageId: 'mid-1',
},
]);
sdk.batches = [
{
sessionId: 'session-1',
originatorClientId: 'client-1',
messages: ['补充信息'],
messageIds: ['mid-1'],
},
];
render('idle');
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(sdk.consume).toHaveBeenCalledWith(sdk.batches);
});
it('does not resend when injection arrives before admission resolves', async () => {
const { actions } = createActions();
const admission = deferred<{
accepted: boolean;
messageId?: string;
}>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('提前注入'));
sdk.batches = [
{
sessionId: 'session-1',
originatorClientId: 'client-1',
messages: ['提前注入'],
messageIds: ['mid-early'],
},
];
render('responding');
expect(latest.queuedPrompts).toEqual([]);
await act(async () =>
admission.resolve({ accepted: true, messageId: 'mid-early' }),
);
render('idle');
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('does not resend an explicit insert when its echo beats the admission ack', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('explicit early injection'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
sdk.batches = [
{
sessionId: 'session-1',
originatorClientId: 'client-1',
messages: ['explicit early injection'],
},
];
render('responding');
expect(latest.queuedPrompts).toEqual([]);
await act(async () => {
admission.resolve({ accepted: true, messageId: 'mid-early' });
await insertion;
});
render('idle', 'session-1', false, false, false);
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('falls back to one ordinary submission when mid-turn admission fails', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: false,
});
mount('responding', actions);
act(() => {
latest.enqueuePrompt('下一步');
});
await act(async () => {});
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
expect(actions.submitPrompt).toHaveBeenCalledWith(
'下一步',
expect.objectContaining({ optimisticUserMessage: false }),
);
expect(latest.queuedPrompts).toMatchObject([
{ text: '下一步', serverState: 'submitting' },
]);
});
it('holds an idle admission rejection while a Goal is active', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('wait for the Goal'));
render('idle', 'session-1', false, false, true);
await act(async () => admission.resolve({ accepted: false }));
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([{ text: 'wait for the Goal' }]);
expect(latest.queuedPrompts[0]).not.toHaveProperty('serverState');
render('idle', 'session-1', false, false, false);
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
});
it('does not resubmit a legacy explicit insert accepted after idle', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions, true, false, false, true);
act(() => latest.enqueuePrompt('do not lose me'));
let insertion!: Promise<void>;
act(() => {
insertion = latest.insertQueuedPrompt(1);
});
render('idle', 'session-1', false, false, true);
await act(async () => {
admission.resolve({ accepted: true, messageId: 'legacy-accepted' });
await insertion;
});
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
render('idle', 'session-1', false, false, false);
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('does not hold a legacy insert accepted before Goal hold', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'accepted-before-idle',
});
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('already accepted'));
await act(async () => {});
expect(latest.queuedPrompts).toMatchObject([
{ midTurnMessageId: 'accepted-before-idle', midTurnState: 'queued' },
]);
render('idle', 'session-1', false, false, true);
expect(latest.queuedPrompts).toEqual([]);
render('idle', 'session-1', false, false, false);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('freezes mid-turn fallback while a session switch is preparing', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('留在当前会话'));
render('responding', 'session-1', false, true);
await act(async () => admission.resolve({ accepted: false }));
render('idle', 'session-1', false, true);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: '留在当前会话', midTurnState: undefined },
]);
render('idle', 'session-1', false, false);
expect(actions.submitPrompt).toHaveBeenCalledOnce();
});
it('does not resubmit an accepted legacy message when the running turn ends', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-2',
});
const { render } = mount('thinking', actions);
act(() => {
latest.enqueuePrompt('继续处理');
});
await act(async () => {});
render('idle');
await act(async () => {});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('does not resubmit a late accepted legacy admission at idle', async () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean }>();
let signal: AbortSignal | undefined;
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
(_message, options) => {
signal = options?.signal;
return admission.promise;
},
);
const { render } = mount('responding', actions);
act(() => {
latest.enqueuePrompt('不要重复');
});
render('idle');
expect(signal?.aborted).toBe(false);
await act(async () =>
admission.resolve({ accepted: true, messageId: 'mid-late' }),
);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('resubmits a legacy admission after a transport failure', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockRejectedValue(
new Error('connection lost'),
);
mount('responding', actions);
act(() => latest.enqueuePrompt('do not lose me'));
await act(async () => {});
expect(actions.submitPrompt).toHaveBeenCalledWith(
'do not lose me',
expect.objectContaining({ sessionId: 'session-1' }),
);
expect(actions.submitPrompt).toHaveBeenCalledOnce();
expect(latest.queuedPrompts).toMatchObject([
{ text: 'do not lose me', serverState: 'submitting' },
]);
});
it('deletes an accepted message from the daemon queue', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-delete',
});
vi.mocked(actions.removeMidTurnMessage).mockResolvedValue({
removed: true,
});
mount('responding', actions);
act(() => latest.enqueuePrompt('删除我'));
await act(async () => {});
await act(async () => latest.removeQueuedPrompt(1));
expect(actions.removeMidTurnMessage).toHaveBeenCalledWith('mid-delete', {
sessionId: 'session-1',
});
expect(latest.queuedPrompts).toEqual([]);
});
it('edits by removing the daemon message before restoring the composer', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-edit',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { editor } = mount('responding', actions);
act(() => latest.enqueuePrompt('修改我'));
await act(async () => {});
let editPromise!: Promise<void>;
act(() => {
editPromise = latest.editQueuedPrompt(1);
});
await act(async () => {});
// Restoration must WAIT for the daemon removal: handing the composer back
// while the message is still queued would let the user resubmit a message
// that remains in the mid-turn queue.
expect(editor.setText).not.toHaveBeenCalled();
expect(actions.removeMidTurnMessage).toHaveBeenCalledWith('mid-edit', {
sessionId: 'session-1',
});
await act(async () => {
removal.resolve({ removed: true });
await editPromise;
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith('修改我');
expect(editor.focus).toHaveBeenCalled();
});
it('restores an edited prompt after a same-id attachment replacement', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-edit',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('修改后保留'));
await act(async () => {});
let editPromise!: Promise<void>;
act(() => {
editPromise = latest.editQueuedPrompt(1);
});
render('responding', 'session-1', true);
await act(async () => {
removal.resolve({ removed: true });
await editPromise;
});
expect(editor.setText).toHaveBeenCalledWith('修改后保留');
expect(editor.setText).toHaveBeenCalledOnce();
expect(editor.focus).toHaveBeenCalled();
});
it('restores an edited prompt when switching to a different session', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-cross-session-edit',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('切换后保留'));
await act(async () => {});
let editPromise!: Promise<void>;
act(() => {
editPromise = latest.editQueuedPrompt(1);
});
render('responding', 'session-2', true);
await act(async () => {
removal.resolve({ removed: true });
await editPromise;
});
expect(editor.setText).toHaveBeenCalledWith('切换后保留');
expect(editor.setText).toHaveBeenCalledOnce();
expect(latest.queuedPrompts).toEqual([]);
});
it('does not restore an edited prompt when cross-session removal loses', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-cross-session-edit-lost',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('仍在服务端'));
await act(async () => {});
let editPromise!: Promise<void>;
act(() => {
editPromise = latest.editQueuedPrompt(1);
});
render('responding', 'session-2', true);
expect(editor.setText).not.toHaveBeenCalled();
await act(async () => {
removal.resolve({ removed: false });
await editPromise;
});
expect(editor.setText).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('keeps the row when removal loses the race with drain or idle', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-race',
});
vi.mocked(actions.removeMidTurnMessage).mockResolvedValue({
removed: false,
});
const { render, reportError } = mount('responding', actions);
act(() => latest.enqueuePrompt('竞态消息'));
await act(async () => {});
await act(async () => latest.removeQueuedPrompt(1));
// An active-turn rejection parks the row with a `delete` failed-action flag
// (cleared of the in-flight marker) so the idle pass drops it without
// resending.
expect(latest.queuedPrompts).toMatchObject([
{
text: '竞态消息',
midTurnState: 'queued',
isRemoving: false,
midTurnFailedAction: 'delete',
},
]);
expect(reportError).toHaveBeenCalled();
render('idle');
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('restores a failed active-turn edit at idle without resending', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-active-edit',
});
vi.mocked(actions.removeMidTurnMessage).mockResolvedValue({
removed: false,
});
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('稍后编辑'));
await act(async () => {});
await act(async () => latest.editQueuedPrompt(1));
expect(editor.setText).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ midTurnFailedAction: 'edit' },
]);
render('idle');
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith('稍后编辑');
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('waits for a pending delete before handling the turn becoming idle', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-idle-delete',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('删除竞态'));
await act(async () => {});
act(() => latest.removeQueuedPrompt(1));
render('idle');
expect(actions.submitPrompt).not.toHaveBeenCalled();
await act(async () => removal.resolve({ removed: true }));
expect(latest.queuedPrompts).toEqual([]);
expect(actions.submitPrompt).not.toHaveBeenCalled();
});
it('does not resend after a pending delete loses the idle race', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-idle-fallback',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('保留竞态'));
await act(async () => {});
act(() => latest.removeQueuedPrompt(1));
render('idle');
await act(async () => removal.resolve({ removed: false }));
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('restores an edit locally without resending when removal loses the idle race', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-idle-edit',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('编辑竞态'));
await act(async () => {});
let editPromise!: Promise<void>;
act(() => {
editPromise = latest.editQueuedPrompt(1);
});
render('idle');
await act(async () => {
removal.resolve({ removed: false });
await editPromise;
});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith('编辑竞态');
});
it('does not resend a deleted message after an idle transport failure', async () => {
const { actions } = createActions();
const removal = deferred<{ removed: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-idle-error',
});
vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
const { render } = mount('responding', actions);
act(() => latest.enqueuePrompt('删除失败竞态'));
await act(async () => {});
act(() => latest.removeQueuedPrompt(1));
render('idle');
await act(async () => removal.reject(new Error('network failed')));
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toEqual([]);
});
it('keeps idle, image, and command submissions on the ordinary path', () => {
const { actions } = createActions();
const { render } = mount('idle', actions);
act(() => latest.enqueuePrompt('普通消息'));
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
render('responding');
act(() => latest.enqueuePrompt('图片', [{ data: 'x', media_type: 'x' }]));
act(() => latest.enqueuePrompt('/help'));
act(() =>
latest.enqueuePrompt('@file.ts fix', undefined, undefined, undefined, [
{
type: 'reference',
start: 0,
end: 7,
text: '@file.ts',
reference: { id: 'ref-1' },
},
]),
);
expect(actions.submitPrompt).toHaveBeenCalledTimes(4);
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
});
it('retains mid-turn rows and drops in-flight pending admissions on clear', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-1',
});
const { render } = mount('idle', actions);
act(() => latest.enqueuePrompt('普通排队'));
render('responding');
act(() => latest.enqueuePrompt('中途消息'));
await act(async () => {});
expect(latest.queuedPrompts).toHaveLength(2);
act(() => latest.clearQueuedPrompts());
expect(latest.queuedPrompts).toMatchObject([
{ text: '中途消息', midTurnState: 'queued', midTurnMessageId: 'mid-1' },
]);
expect(actions.removeMidTurnMessage).not.toHaveBeenCalled();
});
it('cancels only the selected in-flight submission and does not resurrect its late admission', async () => {
const { actions, pendingSubmit } = createActions();
const sibling = deferred<{ promptId: string }>();
vi.mocked(actions.submitPrompt)
.mockReturnValueOnce(pendingSubmit.promise)
.mockReturnValueOnce(sibling.promise);
vi.mocked(actions.getPendingPrompts).mockResolvedValue({
pendingPrompts: [
{
promptId: 'kept-admission',
text: 'keep this admission',
state: 'queued',
},
],
});
const { editor, reportError } = mount('responding', actions);
const images = [{ data: 'eA==', media_type: 'image/png' }];
act(() => latest.enqueuePrompt('cancel this admission', images));
act(() => latest.enqueuePrompt('keep this admission', images));
const calls = vi.mocked(actions.submitPrompt).mock.calls;
act(() => latest.removeQueuedPrompt(1));
expect(calls[0]?.[1]?.signal?.aborted).toBe(true);
expect(calls[1]?.[1]?.signal?.aborted).toBe(false);
expect(latest.queuedPrompts.map((prompt) => prompt.text)).toEqual([
'keep this admission',
]);
await act(async () => {
pendingSubmit.resolve({
promptId: 'removed-admission',
removedAfterAbort: true,
});
sibling.resolve({ promptId: 'kept-admission' });
});
expect(latest.queuedPrompts).toMatchObject([
{ text: 'keep this admission', serverPromptId: 'kept-admission' },
]);
expect(editor.setText).not.toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
});
it('does not restore a deleted draft when cancellation aborts before admission', async () => {
const { actions, pendingSubmit } = createActions();
const { editor, reportError } = mount('idle', actions);
act(() => latest.enqueuePrompt('cancel before upload completes'));
act(() => latest.removeQueuedPrompt(1));
await act(async () => {
pendingSubmit.reject(new DOMException('Aborted', 'AbortError'));
});
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).not.toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
});
it('does not dispatch a deleted row waiting for an earlier release-chain admission', async () => {
const { actions, pendingSubmit } = createActions();
const { render } = mount('idle', actions, true, false, false, true);
act(() => latest.enqueuePrompt('first held prompt'));
act(() => latest.enqueuePrompt('second held prompt'));
render('idle', undefined, false, false, false);
await act(async () => {});
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
expect(latest.queuedPrompts[1]?.serverState).toBe('submitting');
act(() => latest.removeQueuedPrompt(2));
await act(async () =>
pendingSubmit.resolve({ promptId: 'first-admission' }),
);
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
expect(latest.queuedPrompts.some((prompt) => prompt.id === 2)).toBe(false);
});
it('clears a submitting row after the server confirms abort cleanup', async () => {
const { actions, pendingSubmit } = createActions();
mount('responding', actions);
act(() =>
latest.enqueuePrompt('clear me', [
{ data: 'eA==', media_type: 'image/png' },
]),
);
act(() => latest.clearQueuedPrompts());
expect(latest.queuedPrompts).toEqual([]);
await act(async () => {
pendingSubmit.resolve({
promptId: 'server-cleared',
removedAfterAbort: true,
});
await Promise.resolve();
});
expect(latest.queuedPrompts).toEqual([]);
});
it('edits the last mid-turn row via editLastQueuedPrompt', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-1',
});
vi.mocked(actions.removeMidTurnMessage).mockResolvedValue({
removed: true,
});
const { editor } = mount('responding', actions);
act(() => latest.enqueuePrompt('编辑最后'));
await act(async () => {});
act(() => latest.editLastQueuedPrompt());
await act(async () => {});
expect(actions.removeMidTurnMessage).toHaveBeenCalledWith('mid-1', {
sessionId: 'session-1',
});
expect(editor.setText).toHaveBeenCalledWith('编辑最后');
expect(latest.queuedPrompts).toEqual([]);
});
it('consumes the keypress without editing while mid-turn is submitting', () => {
const { actions } = createActions();
const admission = deferred<{ accepted: boolean; messageId?: string }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
mount('responding', actions);
act(() => latest.enqueuePrompt('正在提交'));
const consumed = latest.editLastQueuedPrompt();
expect(consumed).toBe(true);
expect(actions.removeMidTurnMessage).not.toHaveBeenCalled();
});
it('does not send a mid-turn delete when the daemon lacks the mutation capability', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
accepted: true,
messageId: 'mid-1',
});
const { editor } = mount('responding', actions, false);
act(() => latest.enqueuePrompt('无能力'));
await act(async () => {});
// The keyboard path consumes the keypress but must not hit a route the
// daemon doesn't advertise (an older daemon answers the DELETE with a 404).
const consumed = latest.editLastQueuedPrompt();
await act(async () => {});
expect(consumed).toBe(true);
expect(actions.removeMidTurnMessage).not.toHaveBeenCalled();
expect(editor.setText).not.toHaveBeenCalled();
expect(latest.queuedPrompts).toMatchObject([
{ text: '无能力', midTurnState: 'queued' },
]);
});
});
describe('useQueuedPrompts writer-blocked recovery', () => {
it('holds a rejected mid-turn draft until the writer fence clears', async () => {
const { actions } = createActions();
const enqueue = deferred<{ accepted: boolean }>();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValueOnce(
enqueue.promise,
);
const { render } = mount('responding', actions, true, false, false);
act(() => latest.enqueuePrompt('queued follow-up'));
render('idle', 'session-1', false, true);
await act(async () => enqueue.resolve({ accepted: false }));
expect(actions.submitPrompt).not.toHaveBeenCalled();
render('idle', 'session-1', false, true);
expect(actions.submitPrompt).not.toHaveBeenCalled();
render('idle', 'session-1', true, false);
expect(actions.submitPrompt).toHaveBeenCalledOnce();
});
});
it('does not resubmit locally held prompts after an explicit runtime stop and resume', () => {
const { actions } = createActions();
const { render } = mount('idle', actions, true, true, false, true);
act(() => latest.enqueuePrompt('do not silently rerun'));
expect(latest.queuedPrompts).toHaveLength(1);
render('idle', 'session-1', true, false, true, '/workspace', true);
expect(latest.queuedPrompts).toHaveLength(0);
render('idle', 'session-1', true, false, false, '/workspace', false);
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
expect(actions.removePendingPrompt).not.toHaveBeenCalled();
});
it('runtime stop fences sibling session stash in same workspace', async () => {
const { actions } = createActions();
const { editor, render } = mount(
'responding',
actions,
true,
true,
false,
true,
);
act(() => latest.enqueuePrompt('sibling text from before runtime stop'));
render('responding', 'session-2', true, false, true);
expect(latest.queuedPrompts).toEqual([]);
render('idle', 'session-2', true, false, true, '/workspace', true);
render('idle', 'session-2', true, false, false, '/workspace', false);
render('idle', 'session-1', true, false, false, '/workspace', false);
await act(async () => {
await Promise.resolve();
});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(editor.setText).toHaveBeenCalledWith(
'sibling text from before runtime stop',
);
});
it('runtime stop returns unaccepted mid-turn text to composer', () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
new Promise(() => undefined),
);
const { editor, render } = mount('responding', actions);
act(() => latest.enqueuePrompt('unaccepted before runtime stop'));
expect(latest.queuedPrompts).toHaveLength(1);
render('responding', 'session-1', true, false, false, '/workspace', true);
expect(latest.queuedPrompts).toEqual([]);
expect(editor.setText).toHaveBeenCalledWith('unaccepted before runtime stop');
});
it('preserves another workspace held queue when switching into a stopped workspace', async () => {
const { actions } = createActions();
const { render } = mount('responding', actions, true, true, false, true);
act(() => latest.enqueuePrompt('unaffected workspace draft'));
render('idle', 'session-2', true, false, false, '/other', true);
render('idle', 'session-1', true, false, false, '/workspace', false);
await act(async () => {});
expect(actions.submitPrompt).toHaveBeenCalledOnce();
});
it('runtime stop fences a stash written before the workspace cwd resolved', async () => {
const { actions } = createActions();
const { editor, render } = mount(
'idle',
actions,
true,
true,
false,
true,
null,
);
act(() => latest.enqueuePrompt('queued before stop, cwd unresolved'));
expect(latest.queuedPrompts).toHaveLength(1);
// Switch away while the cwd is still unresolved: the stash keys as
// `\u0000session-1`.
render('idle', 'session-2', true, false, true, null);
expect(latest.queuedPrompts).toHaveLength(0);
// The cwd resolves and the stop is observed; the unresolved-cwd stash
// belongs to the stopped workspace and must be fenced with it.
render('idle', 'session-2', true, false, true, '/workspace', true);
render('idle', 'session-2', true, false, true, '/workspace', false);
render('idle', 'session-1', true, false, false, '/workspace', false);
await act(async () => {
await Promise.resolve();
});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(editor.setText).toHaveBeenCalledWith(
'queued before stop, cwd unresolved',
);
});
it('runtime stop fences the live queue stashed while the workspace cwd is unresolved', async () => {
const { actions } = createActions();
const { editor, render } = mount(
'idle',
actions,
true,
true,
false,
true,
null,
);
act(() => latest.enqueuePrompt('queued before cwd resolved'));
// The cwd resolves and the stop is observed in the same owner change: the
// departure stash is written under the unresolved-cwd key.
render('idle', 'session-1', false, false, true, '/workspace', true);
expect(latest.queuedPrompts).toEqual([]);
render('idle', 'session-1', false, false, false, '/workspace', false);
await act(async () => {
await Promise.resolve();
});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(editor.setText).toHaveBeenCalledWith('queued before cwd resolved');
});
describe('upstream streaming-only transport reproduction', () => {
it('does not probe an unsupported native pending endpoint', async () => {
const { actions } = createActions();
vi.mocked(actions.getPendingPrompts).mockRejectedValue(
new Error('native pending prompts unsupported'),
);
mount(
'responding',
actions,
false,
true,
false,
false,
'/workspace',
false,
);
await act(async () => {
await Promise.resolve();
});
expect(actions.getPendingPrompts).not.toHaveBeenCalled();
});
it('releases a local message through the supported streaming prompt action', async () => {
const { actions } = createActions();
actions.sendPrompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' });
vi.mocked(actions.submitPrompt).mockRejectedValue(
new DaemonHttpError(404, {}, 'native queue unsupported'),
);
const { render } = mount(
'responding',
actions,
false,
true,
false,
true,
'/workspace',
false,
);
act(() => latest.enqueuePrompt('queued question'));
await act(async () => {
render('idle', 'session-1', false, false, false);
await Promise.resolve();
});
expect(actions.submitPrompt).not.toHaveBeenCalled();
expect(actions.sendPrompt).toHaveBeenCalledWith(
'queued question',
expect.anything(),
);
});
});