Ctrl K
ComunidadGuías…

lib/assistant-turns.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from "vitest";
import {
  buildAssistantBlocks,
  copyableMessageIds,
  type AssistantTurn,
} from "./assistant-turns";
import type { AssistantMessage, AssistantToolCall } from "./assistant-types";

/**
 * Reading the Numo thread in ROUNDS. What's locked in here is the promise
 * to the reader: as Numo works he sees the work, and at the end
 * he sees THE ANSWER — never all the way around a block, and never text that jumps from place to place as the stream progresses.
 */

function msg(
  id: string,
  role: AssistantMessage["role"],
  content: string | null,
  extra: Partial<AssistantMessage> = {},
): AssistantMessage {
  return {
    id,
    conversation_id: "c1",
    role,
    content,
    tool_calls: null,
    tool_call_id: null,
    tool_name: null,
    metadata: {},
    created_at: "2026-07-26T10:00:00.000Z",
    ...extra,
  };
}

function call(name: string, id = name): AssistantToolCall {
  return { id, type: "function", function: { name, arguments: "{}" } };
}

const turns = (blocks: ReturnType<typeof buildAssistantBlocks>) =>
  blocks.filter((b): b is AssistantTurn => b.kind === "turn");

describe("buildAssistantBlocks", () => {
  it("laisse une réponse directe telle quelle (aucun accordéon à ouvrir)", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "salut"),
      msg("a1", "assistant", "Bonjour !"),
    ]);
    expect(blocks.map((b) => b.kind)).toEqual(["message", "message"]);
  });

  it("keeps reasoning as work while leaving the direct answer outside", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "explain this"),
      msg("a1", "assistant", "Here is the answer.", {
        metadata: {
          reasoning: { text: "I checked the context.", durationMs: 900 },
        },
      }),
    ]);
    const [turn] = turns(blocks);

    expect(blocks.map((block) => block.kind)).toEqual(["message", "turn"]);
    expect(turn.work).toEqual([]);
    expect(turn.summary?.id).toBe("a1");
  });

  it("replie le travail du tour et garde la réponse finale dehors", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "crée un ticket"),
      msg("a1", "assistant", "Je m'en occupe.", { tool_calls: [call("create_issue")] }),
      msg("t1", "tool", "{}", { tool_call_id: "create_issue" }),
      msg("a2", "assistant", "C'est créé : MIN-42."),
    ]);
    const [turn] = turns(blocks);
    expect(turn.work.map((m) => m.id)).toEqual(["a1"]);
    expect(turn.summary?.id).toBe("a2");
    expect(turn.active).toBe(false);
  });

  it("garde les messages utilisateur visibles et sépare les tours", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "un"),
      msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
      msg("a2", "assistant", "Voilà."),
      msg("u2", "user", "deux"),
      msg("a3", "assistant", null, { tool_calls: [call("list_issues", "c2")] }),
      msg("a4", "assistant", "Voilà aussi."),
    ]);
    expect(blocks.map((b) => (b.kind === "message" ? b.message.id : "turn"))).toEqual([
      "u1",
      "turn",
      "u2",
      "turn",
    ]);
  });

  it("chronomètre le tour de l'envoi de l'utilisateur à la réponse", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "vas-y", { created_at: "2026-07-26T10:00:00.000Z" }),
      msg("a1", "assistant", null, {
        tool_calls: [call("list_issues")],
        created_at: "2026-07-26T10:00:04.000Z",
      }),
      msg("a2", "assistant", "Fini.", { created_at: "2026-07-26T10:00:09.000Z" }),
    ]);
    const [turn] = turns(blocks);
    expect(turn.startedAt).toBe("2026-07-26T10:00:00.000Z");
    expect(turn.endedAt).toBe("2026-07-26T10:00:09.000Z");
  });

  it("laisse le chrono courir sur le tour actif, réponse en cours déjà dehors", () => {
    // The final text has just been received but the loop is still turning: there remains
    // UNDER the accordion, exactly where it was displayed in streaming — otherwise it
    // would jump into the rollout and then come out at the end of the turn.
    const blocks = buildAssistantBlocks(
      [
        msg("u1", "user", "vas-y"),
        msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
        msg("a2", "assistant", "Voilà le résultat."),
      ],
      { active: true },
    );
    const [turn] = turns(blocks);
    expect(turn.active).toBe(true);
    expect(turn.endedAt).toBeNull();
    expect(turn.work.map((m) => m.id)).toEqual(["a1"]);
    expect(turn.summary?.id).toBe("a2");
  });

  it("renvoie la narration dans le déroulé dès que le tour repart", () => {
    // A round in flight (tool gone, or text being written): the text received
    // just before is no longer the tail of the turn, it cannot remain displayed under
    // a more recent job than him.
    const blocks = buildAssistantBlocks(
      [
        msg("u1", "user", "vas-y"),
        msg("a1", "assistant", "Je regarde les tickets…", {
          tool_calls: [call("list_issues")],
        }),
      ],
      { active: true, pendingWork: true },
    );
    const [turn] = turns(blocks);
    expect(turn.work.map((m) => m.id)).toEqual(["a1"]);
    expect(turn.summary).toBeNull();
  });

  it("ouvre un tour actif même sans rien produit encore (chrono du travail en cours)", () => {
    const blocks = buildAssistantBlocks([msg("u1", "user", "vas-y")], {
      active: true,
    });
    const [turn] = turns(blocks);
    expect(turn.work).toEqual([]);
    expect(turn.summary).toBeNull();
  });

  it("ne réutilise pas l'id du message qui ouvre le tour comme clé", () => {
    // The user message is rendered as a block BROTHER of the round: two React children of
    // same key, and React duplicates or omits nodes.
    const blocks = buildAssistantBlocks(
      [msg("u1", "user", "vas-y"), msg("a1", "assistant", "Voilà.")],
      { active: true, pendingWork: true },
    );
    const keys = blocks.map((b) => (b.kind === "message" ? b.message.id : b.key));
    expect(new Set(keys).size).toBe(keys.length);
  });

  it("garde la même clé du tour actif au tour terminé (fermeture animée)", () => {
    const history = [
      msg("u1", "user", "vas-y"),
      msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
    ];
    const during = turns(buildAssistantBlocks(history, { active: true }))[0];
    const after = turns(
      buildAssistantBlocks([...history, msg("a2", "assistant", "Fini.")]),
    )[0];
    expect(during.key).toBe(after.key);
  });

  it("keeps an ask_user round going: the answer rides inside the same turn", () => {
    // The question is a pause mid-work, not its end: no second accordion, the
    // round keeps its key (hence its WorkAccordion instance) across the pause.
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "range mes tickets"),
      msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
      msg("a2", "assistant", null, { tool_calls: [call("ask_user")] }),
      msg("u2", "user", "ok pour les doublons"),
      msg("a3", "assistant", "C'est rangé."),
    ]);
    expect(blocks.map((b) => (b.kind === "message" ? b.message.id : "turn"))).toEqual([
      "u1",
      "turn",
    ]);
    const [turn] = turns(blocks);
    expect(turn.key).toBe("turn-u1");
    expect(turn.work.map((m) => m.id)).toEqual(["a1", "a2"]);
    expect(turn.summary?.id).toBe("a3");
  });

  it("keeps the pending question in the work of the open round", () => {
    // While Numo waits for the answers (active), the ask_user message stays
    // folded IN the accordion instead of surfacing as a pseudo-response.
    const blocks = buildAssistantBlocks(
      [
        msg("u1", "user", "range mes tickets"),
        msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
        msg("a2", "assistant", null, { tool_calls: [call("ask_user")] }),
      ],
      { active: true },
    );
    const [turn] = turns(blocks);
    expect(turn.active).toBe(true);
    expect(turn.work.map((m) => m.id)).toEqual(["a1", "a2"]);
    expect(turn.summary).toBeNull();
  });

  it("opens a fresh turn for a message that is not an ask_user answer", () => {
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "un"),
      msg("a1", "assistant", null, { tool_calls: [call("ask_user")] }),
      msg("u2", "user", "voilà ma réponse"),
      msg("a2", "assistant", "Merci !"),
      msg("u3", "user", "autre chose"),
    ]);
    expect(blocks.map((b) => (b.kind === "message" ? b.message.id : "turn"))).toEqual([
      "u1",
      "turn",
      "u3",
    ]);
    const [first] = turns(blocks);
    expect(first.summary?.id).toBe("a2");
  });

  it("traite une proposition d'amorce comme la fin du tour", () => {
    // MIN-173: the check card lives on this message. Folded in the unfolded state,
    // she would be hidden behind a closed accordion - but it is she who is
    // waits, and nothing exists until it has been validated.
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "aide-moi à démarrer ce projet"),
      msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
      msg("a2", "assistant", null, { tool_calls: [call("propose_backlog")] }),
    ]);
    const [turn] = turns(blocks);
    expect(turn.work.map((m) => m.id)).toEqual(["a1"]);
    expect(turn.summary?.id).toBe("a2");
  });

  it("affiche tel quel un tour interrompu avant sa réponse", () => {
    // Sending canceled during a tool: there is no response to put forward,
    // we do not hide the little work produced behind a closed accordion.
    const blocks = buildAssistantBlocks([
      msg("u1", "user", "vas-y"),
      msg("a1", "assistant", null, { tool_calls: [call("list_issues")] }),
    ]);
    expect(blocks.map((b) => (b.kind === "message" ? b.message.id : "turn"))).toEqual([
      "u1",
      "turn",
    ]);
    expect(turns(blocks)[0].work.map((m) => m.id)).toEqual(["a1"]);
  });

  it("ignore les messages tool et system", () => {
    const blocks = buildAssistantBlocks([
      msg("s1", "system", "prompt"),
      msg("u1", "user", "salut"),
      msg("t1", "tool", "{}", { tool_call_id: "x" }),
      msg("a1", "assistant", "Bonjour !"),
    ]);
    expect(blocks.map((b) => (b.kind === "message" ? b.message.id : "turn"))).toEqual([
      "u1",
      "a1",
    ]);
  });

  it("ne produit rien sur une conversation vide", () => {
    expect(buildAssistantBlocks([])).toEqual([]);
  });
});

/**
 * The “Copy” button follows THE REPLY, not the messages. What is folded in
 * the accordion is intermediate work: we do not take it.
 */
describe("copyableMessageIds", () => {
  const ids = (messages: AssistantMessage[], active = false) =>
    copyableMessageIds(
      buildAssistantBlocks(messages, { active, pendingWork: active }),
    );

  it("ne marque que la réponse du tour, jamais le travail replié", () => {
    const got = ids([
      msg("u1", "user", "crée un ticket"),
      msg("a1", "assistant", "Je m'en occupe.", {
        tool_calls: [call("create_issue")],
      }),
      msg("t1", "tool", "{}", { tool_call_id: "create_issue" }),
      msg("a2", "assistant", "C'est créé : MIN-42."),
    ]);
    expect([...got]).toEqual(["a2"]);
  });

  it("marque la réponse directe, qui n'a rien à replier", () => {
    const got = ids([
      msg("u1", "user", "salut"),
      msg("a1", "assistant", "Bonjour !"),
    ]);
    expect([...got]).toEqual(["a1"]);
  });

  it("ne marque rien tant que le tour travaille", () => {
    // Narration already written, but a tool left: this text is work
    // in progress, not a response — it doesn't have a copy button.
    const got = ids(
      [
        msg("u1", "user", "vas-y"),
        msg("a1", "assistant", "Je regarde les tickets…"),
      ],
      true,
    );
    expect([...got]).toEqual([]);
  });

  it("ne marque pas non plus la queue d'un tour actif entre deux rounds", () => {
    // Round finished, tools running: nothing is in flight anymore, so
    // the narration finds itself in a position of response — provisionally. Without this
    // safeguard, the button was placed in turn under EACH last message
    // throughout Numo's work.
    const got = copyableMessageIds(
      buildAssistantBlocks(
        [
          msg("u1", "user", "vas-y"),
          msg("a1", "assistant", "Je regarde les tickets…", {
            tool_calls: [call("list_issues")],
          }),
        ],
        { active: true, pendingWork: false },
      ),
    );
    expect([...got]).toEqual([]);
  });

  it("marque la réponse une fois le tour terminé", () => {
    const got = ids([
      msg("u1", "user", "vas-y"),
      msg("a1", "assistant", "Je regarde…", { tool_calls: [call("list_issues")] }),
      msg("a2", "assistant", "Voilà."),
    ]);
    expect([...got]).toEqual(["a2"]);
  });

  it("marks the response of a round that went through a question", () => {
    // The ask_user pause and the absorbed answer are work, not takeaways —
    // only the final response gets the copy button.
    const got = ids([
      msg("u1", "user", "range mes tickets"),
      msg("a1", "assistant", null, { tool_calls: [call("ask_user")] }),
      msg("u2", "user", "vas-y"),
      msg("a2", "assistant", "C'est rangé."),
    ]);
    expect([...got]).toEqual(["a2"]);
  });

  it("ne marque jamais un message utilisateur", () => {
    const got = ids([msg("u1", "user", "salut")]);
    expect([...got]).toEqual([]);
  });
});