resolveMessageCitations.test.ts3.3 KBView on GitHub
/**
 * Which sources a `[N]` in an answer resolves against.
 *
 * The load-bearing case is the last one: web-source numbering restarts every turn, so a single
 * thread-wide list would relabel every badge already on screen the moment a second search ran.
 */
import { resolveCitationsByMessage } from '@/store/messages/resolveMessageCitations';
import type { Citation, Message } from '@/store/messages/MessageTypes';

const web = (index: number, host: string): Citation => ({
  kind: 'web',
  index,
  url: `https://${host}/a`,
  siteName: host,
});

const msg = (id: string, over: Partial<Message> = {}): Message =>
  ({ id, role: 'assistant', type: 'text', content: '', ...over }) as Message;

describe('resolveCitationsByMessage', () => {
  it('gives a message the sources that existed when it was written', () => {
    const sources = [web(1, 'ashbyhq.com'), web(2, 'indexventures.com')];
    const map = resolveCitationsByMessage([
      msg('u1', { role: 'user' }),
      msg('tr1', { role: 'bot', type: 'tool-result', citations: sources }),
      msg('a1'),
    ]);

    expect(map.get('a1')).toBe(sources);
  });

  it('leaves a message before any search with nothing to resolve', () => {
    const map = resolveCitationsByMessage([
      msg('a0'),
      msg('tr1', { role: 'bot', type: 'tool-result', citations: [web(1, 'ashbyhq.com')] }),
    ]);

    expect(map.get('a0')).toBeUndefined();
  });

  it('does not let a later turn renumber the badges in an earlier one', () => {
    const turnOne = [web(1, 'ashbyhq.com'), web(2, 'indexventures.com')];
    const turnTwo = [web(1, 'crunchbase.com')];
    const map = resolveCitationsByMessage([
      msg('tr1', { role: 'bot', type: 'tool-result', citations: turnOne }),
      msg('a1'),
      msg('u2', { role: 'user' }),
      msg('tr2', { role: 'bot', type: 'tool-result', citations: turnTwo }),
      msg('a2'),
    ]);

    // `[2]` in a1 is still indexventures — turn two's list only reaches turn two's answer.
    expect(map.get('a1')).toBe(turnOne);
    expect(map.get('a2')).toBe(turnTwo);
  });

  it('a later turn that ran no search inherits NOTHING', () => {
    const turnOne = [web(1, 'ashbyhq.com')];
    const map = resolveCitationsByMessage([
      msg('tr1', { role: 'bot', type: 'tool-result', citations: turnOne }),
      msg('a1'),
      msg('u2', { role: 'user' }),
      // No tool result this turn — the model just writes prose, and "option [1]" in it is
      // a list marker, not a source. Carrying turn one's list here made it a live pill
      // pointing at an unrelated page.
      msg('a2'),
    ]);

    expect(map.get('a1')).toBe(turnOne);
    expect(map.get('a2')).toBeUndefined();
  });

  it("never maps the user's own message — `[1]` in something a person typed is not a citation", () => {
    const map = resolveCitationsByMessage([
      msg('tr1', { role: 'bot', type: 'tool-result', citations: [web(1, 'ashbyhq.com')] }),
      msg('u2', { role: 'user' }),
    ]);

    expect(map.has('u2')).toBe(false);
  });

  it('a message carrying its own citations keeps them', () => {
    const own = [web(1, 'crunchbase.com')];
    const map = resolveCitationsByMessage([
      msg('tr1', { role: 'bot', type: 'tool-result', citations: [web(1, 'ashbyhq.com')] }),
      msg('cited', { type: 'cited-text', citations: own }),
    ]);

    expect(map.get('cited')).toBe(own);
  });
});