Ctrl K
ComunidadGuías…

server/chat-apps.e2e.test.ts

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

Archivo original ↗Preguntar a KiBot
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";

import { launchVerificationServer } from "../scripts/control-omb.ts";
import { BOT_HTML_CSP } from "../shared/app-frame/csp.ts";
import { CSP_META_PREFIX } from "../shared/app-frame/document.ts";
import { APP_SAMPLES } from "../shared/app-samples.ts";
import { removeTempDir } from "./testing/cleanup.ts";

it("shows, replaces and serves a bot's page, and carries its context and its message to the engine", async () => {
  const gates = mkdtempSync(join(tmpdir(), "omb-chat-apps-"));
  const gate = join(gates, "finish");
  const fixture = await launchVerificationServer({ FAKE_CLAUDE_MODE: "slow", FAKE_CLAUDE_SLOW_FINISH_GATE: gate, FAKE_CLAUDE_VERSION: "2.1.270" });
  const evidence: unknown[] = [];
  const api = async (method: string, path: string, body?: unknown, expected = 200, token?: string) => {
    const response = await fetch(fixture.info.url + path, {
      method, headers: { "content-type": "application/json", origin: fixture.info.url, ...(token ? { authorization: `Bearer ${token}` } : {}) },
      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
    });
    const result = await response.json() as any;
    evidence.push({ method, path, status: response.status, result: path.endsWith("/app") ? { ...result, srcdoc: `${String(result.srcdoc).slice(0, 200)}…` } : result });
    expect(response.status, JSON.stringify(result).slice(0, 400)).toBe(expected);
    return result;
  };
  const dump = () => JSON.parse(readFileSync(fixture.fixtureDumpPath, "utf8"));
  const turnPrompt = (): string => {
    const content = dump().prompt?.message?.content;
    return Array.isArray(content) ? content.map((block: { text?: string }) => block.text ?? "").join("\n") : String(content ?? "");
  };
  try {
    const bot = (await api("POST", "/api/bots", { name: "Scout" }, 201)).bot;
    const settledTurn = () => expect.poll(async () => (await api("GET", "/api/bots?messages=0")).bots.find((candidate: any) => candidate.id === bot.id).busy,
      { timeout: 15_000 }).toBe(false);
    const transcript = async (): Promise<any[]> => (await api("GET", `/api/threads/${bot.threadId}/messages`)).messages;
    const card = (body: unknown, expected: number, token: string) => api("POST", "/api/internal/options-card", body, expected, token);

    await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, text: "Count my coffees", sendId: "apps-e2e-send-000000001" }, 202);
    let token = "";
    await expect.poll(() => {
      try { token = dump().mcpConfig.mcpServers.agents.env.OMB_COMMS_TOKEN; } catch { return false; }
      return Boolean(token);
    }, { timeout: 15_000 }).toBe(true);
    expect(await card({ tool: "show_html", html: "", title: "Empty" }, 400, token)).toMatchObject({ error: expect.stringMatching(/^html:/) });
    const shown = await card({ tool: "show_html", ...APP_SAMPLES.counter }, 201, token);
    expect(shown).toEqual({ messageId: expect.any(String) });
    expect(await card({ tool: "update_html", app_id: shown.messageId, html: APP_SAMPLES.counter!.html.replace("Cups of coffee today", "Cups today"), title: "Coffee" }, 201, token))
      .toEqual({ messageId: shown.messageId });
    expect(await card({ tool: "update_html", app_id: "no-such-message", html: "<p>x</p>" }, 404, token)).toEqual({ error: "No page with that id in this conversation." });
    writeFileSync(gate, "finish");
    await settledTurn();

    const row = (await transcript()).find((message) => message.id === shown.messageId);
    expect(row).toMatchObject({ role: "bot", kind: "app", text: expect.stringMatching(/^\[App\] Coffee\n/), app: { v: 1, source: "html", title: "Coffee", rev: 1, border: true, height: 200 } });
    expect(row).not.toHaveProperty("from");

    const doc = await api("GET", `/api/threads/${bot.threadId}/messages/${shown.messageId}/app`);
    expect(doc).toMatchObject({ v: 1, messageId: shown.messageId, rev: 1, csp: BOT_HTML_CSP, can: { serverTools: false, message: true } });
    expect(String(doc.srcdoc).startsWith(`${CSP_META_PREFIX}${BOT_HTML_CSP}">`)).toBe(true);
    expect(String(doc.srcdoc)).toContain("Cups today");
    expect(await api("POST", `/api/bots/${bot.id}/apps/${shown.messageId}/rpc`, { threadId: bot.threadId, method: "tools/list" }, 404)).toEqual({ error: "That app has no server to call." });

    expect(await api("POST", `/api/bots/${bot.id}/apps/${shown.messageId}/context`, { threadId: bot.threadId, content: [{ type: "text", text: "The counter shows 3 cups." }] })).toEqual({ ok: true });
    const sent = await api("POST", `/api/bots/${bot.id}/messages`, {
      threadId: bot.threadId, sendId: "apps-e2e-send-000000002", appMessage: { messageId: shown.messageId, text: "I have had 3 cups of coffee today." },
    }, 202);
    expect(sent.message).toMatchObject({ role: "user", text: "I have had 3 cups of coffee today.", replyToId: shown.messageId, fromApp: { messageId: shown.messageId, title: "Coffee" } });
    await expect.poll(turnPrompt, { timeout: 15_000 }).toContain("sent from an app shown in this chat");
    const prompt = turnPrompt();
    evidence.push({ prompt });
    expect(prompt).toContain([
      "--- app context ---",
      "Coffee (a page you made with show_html), as the person sees it now:",
      "The counter shows 3 cups.",
      "--- end app context ---",
      "",
      "The current message was sent from an app shown in this chat after the person pressed something in it: Coffee (a page you made with show_html).",
    ].join("\n"));
    expect(prompt).toContain("--- app ---\nCoffee\n--- end app ---\nCurrent message:\nI have had 3 cups of coffee today.");
    writeFileSync(gate, "finish");
    await settledTurn();

    // The context was handed over once: the next message carries none.
    await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, sendId: "apps-e2e-send-000000003", text: "Thanks" }, 202);
    await expect.poll(turnPrompt, { timeout: 15_000 }).toContain("Thanks");
    expect(turnPrompt()).not.toContain("--- app context ---");
    writeFileSync(gate, "finish");
    await settledTurn();

    const after = await transcript();
    expect(after.find((message) => message.id === shown.messageId).app.context).toMatchObject({ text: "The counter shows 3 cups.", delivered: true });
    const exported = await (await fetch(`${fixture.info.url}/api/threads/${bot.threadId}/export`, { headers: { origin: fixture.info.url } })).text();
    expect(exported).toContain("[App] Coffee");
  } finally {
    const evidencePath = `${fixture.info.logPath}.chat-apps.json`;
    writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
    console.info(JSON.stringify({ evidencePath, logPath: fixture.info.logPath }));
    await fixture.close();
    await removeTempDir(gates);
  }
}, 90_000);