draftIdResolved.test.ts4.0 KBView on GitHub /**
* The draft card is streamed before the provider draft exists, so it goes out with no draftId.
* `draftIdResolved` supplies the real one.
*
* This is not cosmetic. The composer reads its draftId off this row and sends it on every
* autosave. The tool used to fill the gap with the literal string 'pending', Gmail 404'd it, and
* the driver answered a 404 by creating a second draft — so one agent draft duplicated on every
* keystroke pause. See apps/server/src/docs/bug-draft-saving-duplicates-2026-09-01.md.
*/
import { draftIdResolvedResponseProcessor } from '@/modules/cedar-os/src/store/agentConnection/responseProcessors/draftIdResolvedResponseProcessor';
import type { CustomStructuredResponseType } from '@/modules/cedar-os/src/store/agentConnection/AgentConnectionTypes';
type StoreMessage = { id: string; type: string; draftId?: string; threadId?: string };
function makeStore(messages: StoreMessage[]) {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
return {
updates,
store: {
mainThreadId: 'chat-1',
threadMap: { 'chat-1': { messages } },
updateMessage: (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
},
},
};
}
const event = (over: Record<string, unknown> = {}) => ({
type: 'draftIdResolved' as const,
draftId: 'r133312423496136259',
threadId: '19ff5536cb5ab3f0',
conversationId: 'conv-1',
...over,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const run = (obj: any, store: any) =>
draftIdResolvedResponseProcessor.execute(obj, store, 'chat-1');
describe('draftIdResolvedResponseProcessor', () => {
it('stamps the real draft id onto the card that was streamed without one', () => {
const { store, updates } = makeStore([
{ id: 'm1', type: 'draftMessage', threadId: '19ff5536cb5ab3f0' },
]);
run(event(), store);
expect(updates).toHaveLength(1);
expect(updates[0].id).toBe('m1');
expect(updates[0].patch.draftId).toBe('r133312423496136259');
});
it("patches a card left holding the pre-fix 'pending' stand-in", () => {
// A card streamed by an older server build must still end up pointing at the real draft.
const { store, updates } = makeStore([
{ id: 'm1', type: 'draftMessage', draftId: 'pending', threadId: '19ff5536cb5ab3f0' },
]);
run(event(), store);
expect(updates[0].patch.draftId).toBe('r133312423496136259');
});
it('never overwrites a card that already carries a real draft id', () => {
const { store, updates } = makeStore([
{ id: 'm1', type: 'draftMessage', draftId: 'r-already-real', threadId: '19ff5536cb5ab3f0' },
]);
run(event(), store);
expect(updates).toHaveLength(0);
});
it('picks the newest unidentified draft card when a thread holds several', () => {
const { store, updates } = makeStore([
{ id: 'm1', type: 'draftMessage', draftId: 'r-old', threadId: '19ff5536cb5ab3f0' },
{ id: 'm2', type: 'draftMessage', threadId: '19ff5536cb5ab3f0' },
]);
run(event(), store);
expect(updates[0].id).toBe('m2');
});
it('ignores draft cards belonging to another mail thread', () => {
const { store, updates } = makeStore([
{ id: 'm1', type: 'draftMessage', threadId: 'some-other-thread' },
]);
run(event(), store);
expect(updates).toHaveLength(0);
});
it('ignores non-draft messages', () => {
const { store, updates } = makeStore([{ id: 'm1', type: 'text' }]);
run(event(), store);
expect(updates).toHaveLength(0);
});
it('rejects a malformed event rather than patching with an empty id', () => {
const validate = draftIdResolvedResponseProcessor.validate;
const payload = <P extends object>(fields: P): CustomStructuredResponseType<string, P> => ({
type: 'draftIdResolved',
...fields,
});
expect(validate?.(payload({ draftId: '' }))).toBe(false);
expect(validate?.(payload({}))).toBe(false);
expect(validate?.(event())).toBe(true);
});
});