Files
Orkas/test/renderer/queue-draft.test.ts
wyt cd5f78273e feat: refresh desktop agent workflows
Improve agent orchestration, local integrations, persisted result handling, and renderer continuity.\n\nExpand deterministic coverage for packaged resources, recovery paths, and open desktop behavior.
2026-08-25 15:33:30 +08:00

1328 lines
46 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as vm from 'node:vm';
function loadQueueDraft(stored = new Map<string, string>()) {
const source = fs.readFileSync(
path.join(__dirname, '../../src/renderer/modules/queue-draft.js'),
'utf8',
);
const input = {
value: '',
focus: vi.fn(),
setSelectionRange: vi.fn(),
};
const attachmentsByCid = new Map<string, any[]>();
const recipientsByCid = new Map<string, any>();
const queueEditRecipientsByCid = new Map<string, any>();
const quotesByCid = new Map<string, any[]>();
const activeTurnsByCid = new Map<string, any[]>();
const context: any = {
Map,
Set,
Date,
Math,
JSON,
Promise,
encodeURIComponent,
clearTimeout,
setTimeout,
currentCid: '',
messageQueues: new Map(),
localStorage: {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => { stored.set(key, value); },
removeItem: (key: string) => { stored.delete(key); },
},
document: {
getElementById: (id: string) => (id === 'chat-input' ? input : null),
},
_QUEUE_KEY: (cid: string) => `queue:${cid}`,
_DRAFT_KEY: (cid: string) => `draft:${cid}`,
_updateConvSidebarBadge: vi.fn(),
_updateConvSendUI: vi.fn(),
escapeHtml: (value: unknown) => String(value ?? ''),
formatChatUseLabel: (value: any) => value?.name || value?.id || '',
formatChatUseTextForDisplay: (value: string) => value,
apiFetch: vi.fn(),
uiAlert: vi.fn(),
t: (key: string, params: any = {}) => (
key === 'chat.queue_send_now_failed'
? `Could not send: ${params.msg || ''}`
: key
),
_renderOrClaimPersistedUserMessage: vi.fn(),
_queueSendNowActiveTurns: (cid: string) => activeTurnsByCid.get(cid) || [],
_quotesByCid: quotesByCid,
_getQuotes: vi.fn((cid: string) => quotesByCid.get(cid) || []),
_renderQuotePreview: vi.fn(),
_forgetCidRecipient: vi.fn(),
isConvPending: vi.fn(() => false),
applyRecipientPrefix: (content: string) => content,
sendInConversation: vi.fn(),
_trackChatSendResult: vi.fn(),
autoGrow: vi.fn(),
setChatUseSelection: vi.fn(),
syncChatRichComposerFromTextarea: vi.fn(),
_chatAttachList: (cid: string) => attachmentsByCid.get(cid) || [],
_chatAttachSet: (cid: string, items: any[]) => {
attachmentsByCid.set(cid, items);
if (typeof context._persistQueueComposerEditState === 'function') {
context._persistQueueComposerEditState(cid);
}
},
_chatAttachExtOf: (name: string) => {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
},
_chatAttachKindFromExt: (ext: string) => (
['.png', '.jpg', '.jpeg'].includes(ext) ? 'image' : 'text'
),
_chatMediaUrl: (cid: string, name: string) => `chat-media://${cid}/${name}`,
getChatRecipient: vi.fn(() => (
queueEditRecipientsByCid.get(context.currentCid)
|| recipientsByCid.get(context.currentCid)
|| { kind: 'commander', id: '', name: '', resetFloor: false }
)),
_setQueueEditRecipient: vi.fn((cid: string, recipient: any) => {
queueEditRecipientsByCid.set(cid, recipient || { kind: 'commander', id: '', name: '' });
}),
_clearQueueEditRecipient: vi.fn((cid: string) => {
queueEditRecipientsByCid.delete(cid);
}),
getChatUseSelections: vi.fn(() => []),
_normalizeChatUseSelection: (value: any) => value || null,
_normalizeChatUseSelections: (value: any) => (
(Array.isArray(value) ? value : [value]).filter(Boolean)
),
_chatUseSelectionsFromText: vi.fn(() => []),
_chatUseTokenFor: (selection: any) => `[${selection.kind}:${selection.id}]`,
_normaliseRecipientSnapshot: (recipient: any) => recipient ? { ...recipient } : null,
_referenceSnapshotsForQuotes: (references: any[]) => references.map((reference) => ({
source_cid: reference.sourceCid || reference.source_cid,
source_title: reference.sourceTitle || reference.source_title || '',
source_msg_id: reference.msgId || reference.source_msg_id,
from_actor: reference.fromActor || reference.from_actor || '',
text: reference.text || '',
})),
};
vm.createContext(context);
vm.runInContext(source, context, { filename: 'queue-draft.js' });
return {
context,
input,
stored,
attachmentsByCid,
recipientsByCid,
queueEditRecipientsByCid,
quotesByCid,
activeTurnsByCid,
};
}
afterEach(() => {
vi.useRealTimers();
});
describe('conversation draft ownership', () => {
it('snapshots separate drafts before a fast conversation switch', () => {
vi.useFakeTimers();
const { context, input, stored } = loadQueueDraft();
context._getQuotes = vi.fn((cid: string) => [{ msg_id: `quote-${cid}` }]);
context.currentCid = 'conversation-a';
input.value = 'draft for A';
context._saveDraft('conversation-a');
context.currentCid = 'conversation-b';
input.value = 'draft for B';
context._saveDraft('conversation-b');
vi.advanceTimersByTime(180);
expect(JSON.parse(stored.get('draft:conversation-a')!)).toEqual({
text: 'draft for A',
references: [{ msg_id: 'quote-conversation-a' }],
});
expect(JSON.parse(stored.get('draft:conversation-b')!)).toEqual({
text: 'draft for B',
references: [{ msg_id: 'quote-conversation-b' }],
});
});
it('does not let a pending debounce resurrect a deleted conversation draft', () => {
vi.useFakeTimers();
const { context, input, stored } = loadQueueDraft();
input.value = 'delete me';
context._saveDraft('conversation-a');
input.value = 'keep me';
context._saveDraft('conversation-b');
context._forgetConvLocal('conversation-a');
vi.advanceTimersByTime(180);
expect(stored.has('draft:conversation-a')).toBe(false);
expect(JSON.parse(stored.get('draft:conversation-b')!)).toEqual({
text: 'keep me',
});
});
});
describe('queued message dispatch ownership', () => {
it('closes a deleted queued send as cancelled without exposing a conversation id', () => {
const { context } = loadQueueDraft();
context.messageQueues.set('conversation-a', [{
id: 'q-cancel',
content: 'cancel this queued request',
extra: { attachments: ['brief.pdf'] },
model_telemetry: { provider: 'custom', model: 'custom' },
}]);
context.removeQueuedMessage('conversation-a', 'q-cancel');
expect(context._trackChatSendResult).toHaveBeenCalledTimes(1);
expect(context._trackChatSendResult).toHaveBeenCalledWith('cancelled', expect.objectContaining({
source_view: 'conversation',
content_length: 'cancel this queued request'.length,
attachment_count: 1,
}));
expect(context._trackChatSendResult.mock.calls[0][1]).not.toHaveProperty('duration_ms');
expect(context._trackChatSendResult.mock.calls[0][1]).not.toHaveProperty('conversation_id');
});
it('edits in the composer, blocks dispatch, and preserves the queue position', () => {
vi.useFakeTimers();
const { context, input, stored } = loadQueueDraft();
context.currentCid = 'conversation-a';
input.value = 'separate draft';
context._saveDraft('conversation-a');
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'first', direct: true },
{ id: 'q2', content: 'second', direct: true },
{ id: 'q3', content: 'third', direct: true },
]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q2' } });
expect(input.value).toBe('second');
expect(context._isQueueItemEditing('conversation-a')).toBe(true);
expect(context.messageQueues.get('conversation-a').map((item: any) => item.id))
.toEqual(['q1', 'q2', 'q3']);
expect(JSON.parse(stored.get('queue:conversation-a')!)[1].composer_edit)
.toBeTruthy();
context._dispatchNextQueued('conversation-a');
expect(context.sendInConversation).not.toHaveBeenCalled();
input.value = 'second, revised';
context._saveDraft('conversation-a');
expect(context._commitQueueItemEdit('conversation-a', input.value)).toBe(true);
vi.advanceTimersByTime(180);
const queue = context.messageQueues.get('conversation-a');
expect(queue.map((item: any) => [item.id, item.content])).toEqual([
['q1', 'first'],
['q2', 'second, revised'],
['q3', 'third'],
]);
expect(input.value).toBe('separate draft');
expect(JSON.parse(stored.get('draft:conversation-a')!)).toEqual({
text: 'separate draft',
});
// Unlocking resumes the parked queue, but the durable head remains until
// the send controller confirms that it actually started.
expect(context.sendInConversation).toHaveBeenCalledTimes(1);
expect(context.sendInConversation.mock.calls[0].slice(0, 2))
.toEqual(['conversation-a', 'first']);
});
it('restores and commits every queued composer sidecar without losing the displaced draft', () => {
const {
context,
input,
attachmentsByCid,
recipientsByCid,
queueEditRecipientsByCid,
quotesByCid,
} = loadQueueDraft();
const draftQuote = {
sourceCid: 'draft-source',
msgId: 'draft-msg',
fromActor: 'user',
text: 'draft reference',
};
const queuedReference = {
source_cid: 'source-chat',
source_title: 'Source chat',
source_msg_id: 'source-msg',
from_actor: 'research-agent',
from_name: 'Research Agent',
source_ts: '2026-07-31T00:00:00.000Z',
text: 'queued reference',
attachments: ['reference.pdf'],
produced: ['/workspace/report.md'],
};
const queuedUses = [
{ kind: 'skill', id: 'deep-research', name: 'Deep Research' },
{ kind: 'connector', id: 'notion', name: 'Notion' },
];
const queuedRecipient = {
kind: 'agent',
id: 'agent-reviewer',
name: 'Reviewer',
resetFloor: false,
};
const draftRecipient = {
kind: 'agent',
id: 'agent-draft',
name: 'Draft Agent',
resetFloor: false,
};
context.currentCid = 'conversation-a';
input.value = 'unrelated composer draft';
quotesByCid.set('conversation-a', [draftQuote]);
attachmentsByCid.set('conversation-a', [
{
name: 'draft-notes.txt',
displayName: 'Draft notes.txt',
kind: 'text',
bytes: 12,
status: 'ready',
},
]);
recipientsByCid.set('conversation-a', draftRecipient);
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'first', direct: true },
{
id: 'q2',
content: 'queued body',
recipient: queuedRecipient,
extra: {
attachments: ['brief.pdf', 'chart.png'],
references: [queuedReference],
use_selections: queuedUses,
},
attachment_items: [
{
name: 'brief.pdf',
displayName: 'Original brief.pdf',
kind: 'pdf',
bytes: 2048,
},
{
name: 'chart.png',
displayName: 'Chart.png',
kind: 'image',
bytes: 4096,
},
],
},
]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q2' } });
expect(input.value).toContain('[skill:deep-research]');
expect(input.value).toContain('[connector:notion]');
expect(input.value).toContain('queued body');
expect(attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'brief.pdf',
displayName: 'Original brief.pdf',
kind: 'pdf',
reused: true,
}),
expect.objectContaining({
name: 'chart.png',
displayName: 'Chart.png',
kind: 'image',
dataUrl: 'chat-media://conversation-a/chart.png',
reused: true,
}),
]);
expect(quotesByCid.get('conversation-a')).toEqual([
expect.objectContaining({
sourceCid: 'source-chat',
msgId: 'source-msg',
fromActor: 'research-agent',
fromName: 'Research Agent',
text: 'queued reference',
}),
]);
expect(queueEditRecipientsByCid.get('conversation-a')).toEqual(queuedRecipient);
const revisedReference = {
sourceCid: 'revised-source',
msgId: 'revised-msg',
fromActor: 'user',
text: 'revised reference',
};
const revisedRecipient = {
kind: 'agent',
id: 'agent-final',
name: 'Final Agent',
resetFloor: false,
};
const revisedUses = [
{ kind: 'connector', id: 'github', name: 'GitHub' },
];
input.value = '[connector:github] revised body';
quotesByCid.set('conversation-a', [revisedReference]);
queueEditRecipientsByCid.set('conversation-a', revisedRecipient);
context.getChatUseSelections = vi.fn(() => revisedUses);
context._chatAttachSet('conversation-a', [
{
name: 'final.docx',
displayName: 'Final.docx',
kind: 'docx',
bytes: 8192,
status: 'ready',
reused: true,
},
]);
expect(context._commitQueueItemEdit('conversation-a', input.value)).toBe(true);
const edited = context.messageQueues.get('conversation-a')[1];
expect(edited.content).toBe('[connector:github] revised body');
expect(edited.recipient).toEqual(revisedRecipient);
expect(edited.extra).toEqual({
attachments: ['final.docx'],
references: [{
source_cid: 'revised-source',
source_title: '',
source_msg_id: 'revised-msg',
from_actor: 'user',
text: 'revised reference',
}],
use_selections: revisedUses,
});
expect(edited.attachment_items).toEqual([
expect.objectContaining({
name: 'final.docx',
displayName: 'Final.docx',
bytes: 8192,
}),
]);
expect(input.value).toBe('unrelated composer draft');
expect(attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'draft-notes.txt',
displayName: 'Draft notes.txt',
}),
]);
expect(quotesByCid.get('conversation-a')).toEqual([draftQuote]);
expect(queueEditRecipientsByCid.has('conversation-a')).toBe(false);
expect(recipientsByCid.get('conversation-a')).toEqual(draftRecipient);
expect(context.sendInConversation).toHaveBeenCalledTimes(1);
expect(context.sendInConversation.mock.calls[0].slice(0, 2))
.toEqual(['conversation-a', 'first']);
});
it('keeps an empty composer edit locked instead of dropping or sending it', () => {
const { context, input } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'keep this', direct: true },
]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q1' } });
input.value = ' ';
expect(context._commitQueueItemEdit('conversation-a', input.value)).toBe(false);
context._dispatchNextQueued('conversation-a');
expect(context._isQueueItemEditing('conversation-a')).toBe(true);
expect(context.messageQueues.get('conversation-a')[0].content).toBe('keep this');
expect(context.sendInConversation).not.toHaveBeenCalled();
});
it('does not resurrect a legacy Skill sidecar removed from the composer edit', () => {
const { context, input } = loadQueueDraft();
context.currentCid = 'conversation-a';
context._chatUseSelectionsFromText = (text: string) => (
text.includes('[connector:notion]')
? [{ kind: 'connector', id: 'notion', name: 'Notion' }]
: []
);
context.messageQueues.set('conversation-a', [{
id: 'q1',
content: '[connector:notion] legacy queued body',
direct: true,
use: { kind: 'skill', id: 'legacy-skill', name: 'Legacy Skill' },
skill: 'Legacy Skill',
}]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q1' } });
expect(input.value).toBe(
'[skill:legacy-skill] [connector:notion] legacy queued body',
);
input.value = 'plain revised body';
context.getChatUseSelections = vi.fn(() => []);
expect(context._commitQueueItemEdit('conversation-a', input.value)).toBe(true);
const edited = context.messageQueues.get('conversation-a')[0];
expect(edited.content).toBe('plain revised body');
expect(edited.use).toBeNull();
expect(edited.skill).toBe('');
expect(edited.extra).toBeUndefined();
});
it('cancels back to the original message and displaced draft before resuming', () => {
const {
context,
input,
stored,
attachmentsByCid,
recipientsByCid,
queueEditRecipientsByCid,
quotesByCid,
} = loadQueueDraft();
const quote = {
sourceCid: 'draft-source',
msgId: 'quote-1',
fromActor: 'user',
text: 'preserve this reference',
};
const draftRecipient = {
kind: 'agent',
id: 'draft-agent',
name: 'Draft Agent',
resetFloor: false,
};
const queuedRecipient = {
kind: 'agent',
id: 'queued-agent',
name: 'Queued Agent',
resetFloor: false,
};
context.currentCid = 'conversation-a';
quotesByCid.set('conversation-a', [quote]);
attachmentsByCid.set('conversation-a', [{
name: 'draft.txt',
displayName: 'Draft.txt',
kind: 'text',
bytes: 10,
status: 'ready',
}]);
recipientsByCid.set('conversation-a', draftRecipient);
input.value = 'unrelated composer draft';
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'first', direct: true },
{
id: 'q2',
content: 'keep original second',
direct: true,
recipient: queuedRecipient,
extra: {
attachments: ['queued.pdf'],
references: [{
source_cid: 'queued-source',
source_msg_id: 'queued-msg',
from_actor: 'researcher',
text: 'queued reference',
}],
use_selections: [{
kind: 'skill',
id: 'queued-skill',
name: 'Queued Skill',
}],
},
attachment_items: [{
name: 'queued.pdf',
displayName: 'Queued.pdf',
kind: 'pdf',
bytes: 20,
}],
},
]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q2' } });
input.value = 'discard this edit';
context._chatAttachSet('conversation-a', []);
quotesByCid.set('conversation-a', []);
queueEditRecipientsByCid.set('conversation-a', {
kind: 'commander',
id: '',
name: '',
resetFloor: true,
});
expect(context._cancelQueueItemEdit('conversation-a')).toBe(true);
expect(context.messageQueues.get('conversation-a').map((item: any) => ({
id: item.id,
content: item.content,
editing: !!item.composer_edit,
}))).toEqual([
{ id: 'q1', content: 'first', editing: false },
{ id: 'q2', content: 'keep original second', editing: false },
]);
const original = context.messageQueues.get('conversation-a')[1];
expect(original.recipient).toEqual(queuedRecipient);
expect(original.extra).toEqual({
attachments: ['queued.pdf'],
references: [{
source_cid: 'queued-source',
source_msg_id: 'queued-msg',
from_actor: 'researcher',
text: 'queued reference',
}],
use_selections: [{
kind: 'skill',
id: 'queued-skill',
name: 'Queued Skill',
}],
});
expect(original.attachment_items).toEqual([{
name: 'queued.pdf',
displayName: 'Queued.pdf',
kind: 'pdf',
bytes: 20,
}]);
expect(input.value).toBe('unrelated composer draft');
expect(JSON.parse(stored.get('draft:conversation-a')!)).toEqual({
text: 'unrelated composer draft',
references: [quote],
});
expect(attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'draft.txt',
displayName: 'Draft.txt',
}),
]);
expect(quotesByCid.get('conversation-a')).toEqual([quote]);
expect(queueEditRecipientsByCid.has('conversation-a')).toBe(false);
expect(recipientsByCid.get('conversation-a')).toEqual(draftRecipient);
expect(context.sendInConversation).toHaveBeenCalledTimes(1);
expect(context.sendInConversation.mock.calls[0].slice(0, 2))
.toEqual(['conversation-a', 'first']);
});
it('deletes the composer-owned queued message without sending and restores every displaced sidecar', () => {
const {
context,
input,
stored,
attachmentsByCid,
recipientsByCid,
queueEditRecipientsByCid,
quotesByCid,
} = loadQueueDraft();
const draftQuote = {
sourceCid: 'draft-source',
msgId: 'draft-msg',
fromActor: 'user',
text: 'draft reference',
};
const draftRecipient = {
kind: 'agent',
id: 'draft-agent',
name: 'Draft Agent',
resetFloor: false,
};
context.currentCid = 'conversation-a';
input.value = '[skill:draft-skill] displaced draft';
quotesByCid.set('conversation-a', [draftQuote]);
attachmentsByCid.set('conversation-a', [{
name: 'draft.png',
displayName: 'Draft.png',
kind: 'image',
bytes: 128,
dataUrl: 'blob:stale-preview',
status: 'ready',
}]);
recipientsByCid.set('conversation-a', draftRecipient);
context.messageQueues.set('conversation-a', [{
id: 'q1',
content: '[connector:notion] never send this',
direct: true,
recipient: {
kind: 'agent',
id: 'queued-agent',
name: 'Queued Agent',
resetFloor: false,
},
extra: {
attachments: ['queued.pdf'],
references: [{
source_cid: 'queued-source',
source_msg_id: 'queued-msg',
from_actor: 'reviewer',
text: 'queued reference',
}],
use_selections: [{ kind: 'connector', id: 'notion', name: 'Notion' }],
},
attachment_items: [{
name: 'queued.pdf',
displayName: 'Queued.pdf',
kind: 'pdf',
bytes: 512,
}],
}]);
context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q1' } });
input.value = 'edited content that must also be discarded';
context._chatAttachSet('conversation-a', []);
quotesByCid.set('conversation-a', []);
queueEditRecipientsByCid.set('conversation-a', {
kind: 'commander', id: '', name: '', resetFloor: true,
});
expect(context._deleteQueueItemEdit('conversation-a')).toBe(true);
expect(context.messageQueues.get('conversation-a')).toEqual([]);
expect(stored.has('queue:conversation-a')).toBe(false);
expect(input.value).toBe('[skill:draft-skill] displaced draft');
expect(JSON.parse(stored.get('draft:conversation-a')!)).toEqual({
text: '[skill:draft-skill] displaced draft',
references: [draftQuote],
});
expect(attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'draft.png',
displayName: 'Draft.png',
dataUrl: 'chat-media://conversation-a/draft.png',
}),
]);
expect(quotesByCid.get('conversation-a')).toEqual([draftQuote]);
expect(queueEditRecipientsByCid.has('conversation-a')).toBe(false);
expect(recipientsByCid.get('conversation-a')).toEqual(draftRecipient);
expect(context.sendInConversation).not.toHaveBeenCalled();
// Negative boundary: without an active queue edit, delete is a no-op and
// cannot accidentally submit or clear the restored composer.
expect(context._deleteQueueItemEdit('conversation-a')).toBe(false);
expect(input.value).toBe('[skill:draft-skill] displaced draft');
expect(context.sendInConversation).not.toHaveBeenCalled();
});
it('recovers a durable composer edit after reload without draining it', () => {
vi.useFakeTimers();
const stored = new Map<string, string>();
const first = loadQueueDraft(stored);
first.context.currentCid = 'conversation-a';
first.input.value = 'draft displaced before reload';
first.attachmentsByCid.set('conversation-a', [{
name: 'draft-image.png',
displayName: 'Draft image.png',
kind: 'image',
bytes: 256,
dataUrl: 'blob:preview-that-cannot-survive-reload',
status: 'ready',
}]);
first.context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'first', direct: true },
{
id: 'q2',
content: 'second',
direct: true,
recipient: {
kind: 'agent',
id: 'agent-after-reload',
name: 'Reload Agent',
resetFloor: false,
},
extra: {
attachments: ['reload.png'],
references: [{
source_cid: 'reload-source',
source_msg_id: 'reload-msg',
from_actor: 'user',
text: 'reference after reload',
}],
},
attachment_items: [{
name: 'reload.png',
displayName: 'Reload.png',
kind: 'image',
bytes: 512,
}],
},
{ id: 'q3', content: 'third', direct: true },
]);
first.context._startQueueItemEdit('conversation-a', { dataset: { qid: 'q2' } });
first.input.value = 'second, edited before reload';
first.context._saveDraft('conversation-a');
vi.advanceTimersByTime(180);
const reloaded = loadQueueDraft(stored);
reloaded.context.currentCid = 'conversation-a';
reloaded.context._restoreDraft('conversation-a');
expect(reloaded.input.value).toBe('second, edited before reload');
expect(reloaded.context._isQueueItemEditing('conversation-a')).toBe(true);
expect(reloaded.attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'reload.png',
displayName: 'Reload.png',
dataUrl: 'chat-media://conversation-a/reload.png',
reused: true,
}),
]);
expect(reloaded.quotesByCid.get('conversation-a')).toEqual([
expect.objectContaining({
sourceCid: 'reload-source',
msgId: 'reload-msg',
text: 'reference after reload',
}),
]);
expect(reloaded.queueEditRecipientsByCid.get('conversation-a')).toEqual({
kind: 'agent',
id: 'agent-after-reload',
name: 'Reload Agent',
resetFloor: false,
});
reloaded.context._dispatchNextQueued('conversation-a');
expect(reloaded.context.sendInConversation).not.toHaveBeenCalled();
expect(reloaded.context._commitQueueItemEdit(
'conversation-a',
reloaded.input.value,
)).toBe(true);
expect(reloaded.context.messageQueues.get('conversation-a').map((item: any) => [
item.id,
item.content,
])).toEqual([
['q1', 'first'],
['q2', 'second, edited before reload'],
['q3', 'third'],
]);
expect(reloaded.input.value).toBe('draft displaced before reload');
expect(reloaded.attachmentsByCid.get('conversation-a')).toEqual([
expect.objectContaining({
name: 'draft-image.png',
displayName: 'Draft image.png',
dataUrl: 'chat-media://conversation-a/draft-image.png',
}),
]);
expect(reloaded.context.sendInConversation).toHaveBeenCalledTimes(1);
expect(reloaded.context.sendInConversation.mock.calls[0].slice(0, 2))
.toEqual(['conversation-a', 'first']);
});
it('persists attachment references and sends them with the queued message', () => {
const { context, stored } = loadQueueDraft();
context.currentCid = 'conversation-a';
context._chatModelTelemetryContext = () => ({
provider: 'deepseek',
model: 'deepseek-v4-pro',
});
context.enqueueMessage('conversation-a', 'review these files', null, {
direct: true,
extra: { attachments: ['brief.pdf', 'chart.png'] },
});
expect(JSON.parse(stored.get('queue:conversation-a')!)[0].extra.attachments)
.toEqual(['brief.pdf', 'chart.png']);
expect(JSON.parse(stored.get('queue:conversation-a')!)[0].model_telemetry)
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' });
context._dispatchNextQueued('conversation-a');
expect(context.sendInConversation).toHaveBeenCalledWith(
'conversation-a',
'review these files',
{ attachments: ['brief.pdf', 'chart.png'] },
expect.objectContaining({
from_queue: true,
background: false,
content_length: 'review these files'.length,
provider: 'deepseek',
model: 'deepseek-v4-pro',
}),
);
const options = context.sendInConversation.mock.calls[0][3];
options.onStarted();
expect(context.messageQueues.get('conversation-a')).toHaveLength(0);
});
it('does not expose the active-turn action while idle; normal FIFO drain remains unchanged', () => {
const { context } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.messageQueues.set('conversation-a', [
{
id: 'q2',
content: 'send this later',
recipient: { kind: 'commander', id: '', name: '' },
},
]);
expect(context.sendQueuedMessageNow('conversation-a', 'q2')).toBe(false);
expect(context.sendInConversation).not.toHaveBeenCalled();
context._dispatchNextQueued('conversation-a');
expect(context.sendInConversation).toHaveBeenCalledWith(
'conversation-a',
'send this later',
undefined,
expect.objectContaining({ from_queue: true, background: false }),
);
});
it.each([
{
label: 'plain text',
actor: 'commander',
item: {
content: 'plain update',
recipient: { kind: 'commander', id: '', name: '' },
},
expected: { content: 'plain update' },
},
{
label: 'a file-free quoted reference',
actor: 'commander',
item: {
content: 'consider the quote',
recipient: { kind: 'commander', id: '', name: '' },
extra: {
references: [{
source_cid: 'source-cid',
source_msg_id: 'source-1',
text: 'quoted text only',
}],
},
},
expected: {
content: 'consider the quote',
references: [{
source_cid: 'source-cid',
source_msg_id: 'source-1',
text: 'quoted text only',
}],
},
},
{
label: 'text addressed to the same active Agent',
actor: 'reviewer',
item: {
content: 'review this now',
direct: false,
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
},
expected: { content: '@reviewer review this now' },
},
])('sends $label into a matching steerable active turn', async ({ actor, item, expected }) => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{ actor, turn_id: 'turn-live', steerable: true }]);
context.applyRecipientPrefix = vi.fn((content: string, _target: string, options: any) => (
options?.recipientSnapshot?.kind === 'agent'
? `@${options.recipientSnapshot.id} ${content}`
: content
));
context.apiFetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
ok: true,
msg: { id: 'user-now', from: 'user', text: expected.content, ts: '2026-08-01T12:00:00.000Z' },
}),
}));
context.messageQueues.set('conversation-a', [{ id: 'q-rich', ...item }]);
expect(context.sendQueuedMessageNow('conversation-a', 'q-rich')).toBe(true);
await vi.waitFor(() => expect(context.apiFetch).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(context.messageQueues.get('conversation-a')).toEqual([]));
expect(JSON.parse(context.apiFetch.mock.calls[0][1].body)).toEqual({
...expected,
steer_active_turn: true,
});
});
it.each([
{
label: 'an attachment',
item: {
content: 'use this file',
recipient: { kind: 'commander', id: '', name: '' },
extra: { attachments: ['brief.pdf'] },
},
expected: {
content: 'use this file',
attachments: ['brief.pdf'],
},
},
{
label: 'a referenced attachment',
item: {
content: 'use the quoted file',
recipient: { kind: 'commander', id: '', name: '' },
extra: { references: [{ source_msg_id: 'source-1', attachments: ['source.pdf'] }] },
},
expected: {
content: 'use the quoted file',
references: [{ source_msg_id: 'source-1', attachments: ['source.pdf'] }],
},
},
{
label: 'a referenced produced file',
item: {
content: 'use the quoted output',
recipient: { kind: 'commander', id: '', name: '' },
extra: { references: [{ source_msg_id: 'source-1', produced: ['/workspace/report.md'] }] },
},
expected: {
content: 'use the quoted output',
references: [{ source_msg_id: 'source-1', produced: ['/workspace/report.md'] }],
},
},
{
label: 'a source-aware Skill',
item: {
content: 'use Review',
recipient: { kind: 'commander', id: '', name: '' },
extra: {
use_selections: [{ kind: 'skill', id: 'review', name: 'Review', source: 'global' }],
},
},
expected: {
content: 'use Review',
use_selections: [{ kind: 'skill', id: 'review', name: 'Review', source: 'global' }],
},
},
{
label: 'a Connector',
item: {
content: 'use Notion',
recipient: { kind: 'commander', id: '', name: '' },
extra: { use_selections: [{ kind: 'connector', id: 'notion', name: 'Notion' }] },
},
expected: {
content: 'use Notion',
use_selections: [{ kind: 'connector', id: 'notion', name: 'Notion' }],
},
},
])('sends $label into the matching rich-steer turn', async ({ item, expected }) => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{
actor: 'commander', turn_id: 'turn-live', steerable: true,
}]);
context.apiFetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
ok: true,
msg: { id: 'user-rich', from: 'user', text: item.content, ts: '2026-08-01T12:00:00.000Z' },
}),
}));
context.messageQueues.set('conversation-a', [{ id: 'q-rich', ...item }]);
expect(context._canSendQueueItemIntoActiveTurn('conversation-a', context.messageQueues.get('conversation-a')[0]))
.toBe(true);
expect(context.sendQueuedMessageNow('conversation-a', 'q-rich')).toBe(true);
await vi.waitFor(() => expect(context.apiFetch).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(context.messageQueues.get('conversation-a')).toEqual([]));
expect(JSON.parse(context.apiFetch.mock.calls[0][1].body)).toEqual({
...expected,
steer_active_turn: true,
});
});
it.each([
{
label: 'a different active Agent',
active: { actor: 'writer', turn_id: 'turn-live', steerable: true },
},
{
label: 'a CLI-backed active Agent',
active: { actor: 'reviewer', turn_id: 'turn-live', steerable: false },
},
])('hides active-turn send for $label', ({ active }) => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [active]);
const item = {
id: 'q-agent',
content: 'review this now',
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
};
context.messageQueues.set('conversation-a', [item]);
expect(context._canSendQueueItemIntoActiveTurn('conversation-a', item)).toBe(false);
expect(context.sendQueuedMessageNow('conversation-a', 'q-agent')).toBe(false);
expect(context.apiFetch).not.toHaveBeenCalled();
});
it('renders Send only on rows that can affect the matching active turn', () => {
const { context, activeTurnsByCid } = loadQueueDraft();
const panel: any = { style: {} };
const count: any = { textContent: '' };
const list: any = {
innerHTML: '',
querySelectorAll: () => [],
};
context.document.getElementById = (id: string) => ({
'chat-input': { value: '', focus: vi.fn(), setSelectionRange: vi.fn() },
'chat-queue': panel,
'chat-queue-list': list,
'chat-queue-count': count,
} as Record<string, any>)[id] || null;
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{
actor: 'reviewer', turn_id: 'turn-live', steerable: true,
}]);
context.messageQueues.set('conversation-a', [
{
id: 'q-text-same-agent',
content: 'can steer',
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
},
{
id: 'q-text-other-agent',
content: 'cannot steer this turn',
recipient: { kind: 'agent', id: 'writer', name: 'Writer' },
},
{
id: 'q-attachment-same-agent',
content: 'can steer with rich context',
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
extra: { attachments: ['brief.pdf'] },
},
]);
context.renderMessageQueue('conversation-a');
expect((list.innerHTML.match(/data-act="send"/g) || [])).toHaveLength(2);
expect(list.innerHTML).toContain('data-qid="q-text-same-agent"');
expect(list.innerHTML).toContain('data-qid="q-attachment-same-agent"');
expect(count.textContent).toBe('3');
});
it('persists a text row into the matching active Agent and renders the authoritative message', async () => {
const { context, stored, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{
actor: 'reviewer', turn_id: 'turn-live', steerable: true,
}]);
context.applyRecipientPrefix = vi.fn((content: string, _target: string, options: any) => (
options?.recipientSnapshot?.kind === 'agent'
? `@${options.recipientSnapshot.id} ${content}`
: content
));
const persisted = {
id: 'user-now',
from: 'user',
text: '@reviewer add this constraint',
ts: '2026-08-01T12:00:00.000Z',
};
context.apiFetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true, msg: persisted }),
}));
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'leave first queued', direct: true },
{
id: 'q2',
content: 'add this constraint',
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
},
]);
expect(context.sendQueuedMessageNow('conversation-a', 'q2')).toBe(true);
await vi.waitFor(() => expect(context.apiFetch).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(context.messageQueues.get('conversation-a').map((item: any) => item.id))
.toEqual(['q1']));
expect(context.apiFetch).toHaveBeenCalledWith(
'/api/conversations/conversation-a/send',
expect.objectContaining({ method: 'POST' }),
);
const body = JSON.parse(context.apiFetch.mock.calls[0][1].body);
expect(body).toEqual({
content: '@reviewer add this constraint',
steer_active_turn: true,
});
expect(context._renderOrClaimPersistedUserMessage)
.toHaveBeenCalledWith('conversation-a', persisted);
expect(context.sendInConversation).not.toHaveBeenCalled();
expect(context._trackChatSendResult).toHaveBeenCalledTimes(1);
expect(context._trackChatSendResult).toHaveBeenCalledWith('success', expect.objectContaining({
source_view: 'conversation',
content_length: 'add this constraint'.length,
attachment_count: 0,
}));
expect(JSON.parse(stored.get('queue:conversation-a')!)).toEqual([
{ id: 'q1', content: 'leave first queued', direct: true },
]);
});
it('keeps an active-run send-now row durable when persistence fails', async () => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{
actor: 'commander', turn_id: 'turn-live', steerable: true,
}]);
context.apiFetch = vi.fn(async () => ({
ok: false,
status: 503,
json: async () => ({ ok: false, error: 'temporarily unavailable' }),
}));
context.messageQueues.set('conversation-a', [
{
id: 'q1',
content: 'do not lose me',
recipient: { kind: 'commander', id: '', name: '' },
},
]);
expect(context.sendQueuedMessageNow('conversation-a', 'q1')).toBe(true);
await vi.waitFor(() => expect(context.uiAlert).toHaveBeenCalledTimes(1));
expect(context.messageQueues.get('conversation-a')).toEqual([
{
id: 'q1',
content: 'do not lose me',
recipient: { kind: 'commander', id: '', name: '' },
},
]);
expect(context._renderOrClaimPersistedUserMessage).not.toHaveBeenCalled();
expect(context._trackChatSendResult).not.toHaveBeenCalled();
});
it('keeps a previously eligible row queued when its active turn changes before click', () => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
const item = {
id: 'q-raced',
content: 'apply this to the live review',
recipient: { kind: 'agent', id: 'reviewer', name: 'Reviewer' },
};
context.messageQueues.set('conversation-a', [item]);
activeTurnsByCid.set('conversation-a', [{
actor: 'reviewer', turn_id: 'turn-before-click', steerable: true,
}]);
expect(context._canSendQueueItemIntoActiveTurn('conversation-a', item)).toBe(true);
activeTurnsByCid.set('conversation-a', [{
actor: 'writer', turn_id: 'turn-at-click', steerable: true,
}]);
expect(context.sendQueuedMessageNow('conversation-a', 'q-raced')).toBe(false);
expect(context.apiFetch).not.toHaveBeenCalled();
expect(context.messageQueues.get('conversation-a')).toEqual([item]);
});
it('does not let queued metadata downgrade a successful Send now authorization', async () => {
const { context, activeTurnsByCid } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.isConvPending = vi.fn(() => true);
activeTurnsByCid.set('conversation-a', [{
actor: 'commander', turn_id: 'turn-live', steerable: true,
}]);
context.apiFetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
ok: true,
msg: { id: 'user-now', from: 'user', text: 'apply now', ts: '2026-08-01T12:00:00.000Z' },
}),
}));
context.messageQueues.set('conversation-a', [{
id: 'q-stale-control',
content: 'apply now',
recipient: { kind: 'commander', id: '', name: '' },
extra: { steer_active_turn: false },
}]);
expect(context.sendQueuedMessageNow('conversation-a', 'q-stale-control')).toBe(true);
await vi.waitFor(() => expect(context.apiFetch).toHaveBeenCalledTimes(1));
expect(JSON.parse(context.apiFetch.mock.calls[0][1].body)).toEqual({
content: 'apply now',
steer_active_turn: true,
});
});
it('dispatches an idle background queue to its owning conversation', () => {
const { context } = loadQueueDraft();
context.currentCid = 'conversation-b';
context.messageQueues.set('conversation-a', [
{
id: 'q1',
content: 'continue in the background',
direct: true,
extra: { attachments: ['background.pdf'] },
},
]);
context._dispatchNextQueued('conversation-a');
expect(context.sendInConversation).toHaveBeenCalledWith(
'conversation-a',
'continue in the background',
{ attachments: ['background.pdf'] },
expect.objectContaining({
from_queue: true,
background: true,
}),
);
expect(context.currentCid).toBe('conversation-b');
expect(context.messageQueues.get('conversation-a')).toHaveLength(1);
const options = context.sendInConversation.mock.calls[0][3];
options.onStarted();
expect(context.messageQueues.get('conversation-a')).toHaveLength(0);
});
it('coalesces repeated drains while the queue head is still in preflight', async () => {
const { context } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.messageQueues.set('conversation-a', [
{ id: 'q1', content: 'send exactly once', direct: true },
]);
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
context.sendInConversation = vi.fn(() => pending);
context._dispatchNextQueued('conversation-a');
context._dispatchNextQueued('conversation-a');
expect(context.sendInConversation).toHaveBeenCalledTimes(1);
expect(context.messageQueues.get('conversation-a')).toHaveLength(1);
const options = context.sendInConversation.mock.calls[0][3];
options.onStarted();
expect(context.messageQueues.get('conversation-a')).toHaveLength(0);
release();
await pending;
});
it('keeps a preflight-rejected item retryable without duplicating it', async () => {
const { context } = loadQueueDraft();
context.currentCid = 'conversation-a';
context.messageQueues.set('conversation-a', [
{
id: 'q1',
content: 'retry after setup',
direct: true,
extra: { attachments: ['brief.pdf'] },
},
]);
context.sendInConversation = vi.fn(async () => ({ started: false }));
context._dispatchNextQueued('conversation-a');
await vi.waitFor(() => expect(context.sendInConversation).toHaveBeenCalledTimes(1));
expect(context.messageQueues.get('conversation-a')).toHaveLength(1);
context._dispatchNextQueued('conversation-a');
await vi.waitFor(() => expect(context.sendInConversation).toHaveBeenCalledTimes(2));
expect(context.messageQueues.get('conversation-a')).toHaveLength(1);
expect(context.sendInConversation.mock.calls[1][2])
.toEqual({ attachments: ['brief.pdf'] });
});
});