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);
  });
});