Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/user-questions.test.ts

typescript379 行查看原文
import { Agent, type AgentTool } from "@zpi-dev/agent";
import { createAssistantMessageEventStream, emptyAssistant } from "@zpi-dev/ai";
import { UserQuestions } from "@zpi-dev/builtin-plugins/questions";
import { Type } from "typebox";
import { expect, it, vi } from "vitest";
import { chunk, deferred, done, fakeModel, send } from "../../../tests/fake-server.ts";
import { projectEvent } from "../src/main/projection.ts";
import { SessionHost } from "../src/main/session-host.ts";
import { cleanup, fixture } from "./helpers/host-fixture.ts";

const params = {
  questions: [
    {
      header: "方案",
      question: "使用哪种方案?",
      multiSelect: false,
      options: [
        { label: "A", description: "第一种" },
        { label: "B", description: "第二种" },
      ],
    },
  ],
};

it.each(["__proto__", "constructor", "toString"])(
  "preserves answers keyed by %s as plain data",
  async (question) => {
    const broker = new UserQuestions(() => {});
    const execute = broker.tool("s").execute as unknown as (
      ...args: unknown[]
    ) => Promise<{ details: { answers: Record<string, string> } }>;
    const pending = execute("q", { questions: [{ ...params.questions[0], question }] }, undefined);
    broker.respond("s", "q", { answers: Object.fromEntries([[question, "A"]]) });
    const { details } = await pending;
    expect(Object.hasOwn(details.answers, question)).toBe(true);
    expect(details.answers[question]).toBe("A");
    const event = projectEvent(
      {
        type: "tool_execution_end",
        toolCallId: "q",
        toolName: "AskUserQuestion",
        result: { content: [{ type: "text", text: "plain result" }], details },
        isError: false,
      },
      "m",
    );
    expect(event?.type).toBe("tool_end");
    if (event?.type === "tool_end") {
      expect(Object.hasOwn(event.questionAnswers?.answers ?? {}, question)).toBe(true);
      expect(event.questionAnswers?.answers[question]).toBe("A");
    }
  },
);

it("projects bounded question answers without forwarding malformed values or arbitrary tool details", () => {
  const event = projectEvent(
    {
      type: "tool_execution_end",
      toolCallId: "q",
      toolName: "AskUserQuestion",
      result: {
        content: [{ type: "text", text: "result" }],
        details: {
          answers: { good: "A", bad: { toString: 1 }, long: "x".repeat(16001) },
          arbitrary: "private",
        },
      },
      isError: false,
    },
    "m",
  );
  expect(event).toMatchObject({ questionAnswers: { answers: { good: "A" } } });
  expect(JSON.stringify(event)).not.toContain("private");
});

it("waits for an explicit answer, rejects stale replies, and returns skipped questions without inventing a preference", async () => {
  const changes: unknown[] = [];
  const broker = new UserQuestions((_, request) => changes.push(request));
  const tool = broker.tool("s");
  const execute = tool.execute as (...args: unknown[]) => Promise<{ content: { text: string }[] }>;
  const pending = execute("q", params, undefined);
  expect(broker.get("s")?.id).toBe("q");
  expect(() => broker.respond("s", "old", { answers: {} })).toThrow("提问已结束");
  expect(() => broker.respond("s", "q", { answers: { unknown: "A" } })).toThrow("无效回答");
  broker.respond("s", "q", { answers: {} });
  expect((await pending).content[0].text).toContain("did not provide answers");
  expect(broker.get("s")).toBeNull();
  expect(changes).toHaveLength(2);
});

it("cancels a pending question when the run is stopped", async () => {
  const broker = new UserQuestions(() => {});
  const controller = new AbortController();
  const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
  const pending = execute("q", params, controller.signal);
  controller.abort();
  await expect(pending).rejects.toThrow("cancelled");
  expect(broker.get("s")).toBeNull();
});

it("shows authoritative countdown state after a minute and continues unanswered questions at five minutes", async () => {
  vi.useFakeTimers();
  vi.setSystemTime(0);
  try {
    const broker = new UserQuestions(() => {});
    const execute = broker.tool("s").execute as (
      ...args: unknown[]
    ) => Promise<{ content: { text: string }[] }>;
    const pending = execute("q", params, undefined);
    expect(broker.get("s")?.autoResolution).toEqual({
      phase: "hiddenGrace",
      startedAt: 0,
      visibleAt: 60_000,
      deadlineAt: 300_000,
    });
    await vi.advanceTimersByTimeAsync(59_000);
    expect(broker.get("s")?.autoResolution?.phase).toBe("hiddenGrace");
    await vi.advanceTimersByTimeAsync(1000);
    expect(broker.get("s")?.autoResolution?.phase).toBe("visibleCountdown");
    await vi.advanceTimersByTimeAsync(120_000);
    expect(broker.get("s")?.autoResolution?.phase).toBe("visibleCountdown");
    await vi.advanceTimersByTimeAsync(120_000);
    expect((await pending).content[0].text).toContain("within five minutes");
    expect(broker.get("s")).toBeNull();
    expect(vi.getTimerCount()).toBe(0);
  } finally {
    vi.useRealTimers();
  }
});

it.each([0, 60_000])("permanently stops question timing after user interaction at %d ms", async (delay) => {
  vi.useFakeTimers();
  try {
    const broker = new UserQuestions(() => {});
    const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
    const pending = execute("q", params, undefined);
    await vi.advanceTimersByTimeAsync(delay);
    broker.snooze("s", "old");
    expect(broker.get("s")?.autoResolution?.phase).not.toBe("snoozed");
    broker.snooze("s", "q");
    broker.snooze("s", "q");
    expect(broker.get("s")?.autoResolution?.phase).toBe("snoozed");
    expect(vi.getTimerCount()).toBe(0);
    await vi.advanceTimersByTimeAsync(300_000);
    expect(broker.get("s")?.id).toBe("q");
    broker.respond("s", "q", { answers: {} });
    await pending;
  } finally {
    vi.useRealTimers();
  }
});

it("snoozing one question does not disable automatic continuation for other questions", async () => {
  vi.useFakeTimers();
  try {
    const broker = new UserQuestions(() => {});
    const execute = (id: string) =>
      (broker.tool(id).execute as (...args: unknown[]) => Promise<unknown>)("q", params, undefined);
    const first = execute("first");
    broker.snooze("first", "q");
    expect(broker.get("first")?.autoResolution?.phase).toBe("snoozed");
    const second = execute("second");
    expect(broker.get("second")?.autoResolution?.phase).toBe("hiddenGrace");
    const third = execute("third");
    await vi.advanceTimersByTimeAsync(300_000);
    await Promise.all([second, third]);
    expect(broker.get("first")).not.toBeNull();
    expect(broker.get("second")).toBeNull();
    broker.respond("first", "q", { answers: {} });
    await first;
    expect(vi.getTimerCount()).toBe(0);
  } finally {
    vi.useRealTimers();
  }
});

it("snapshots the plugin for new sessions, resumes with answers, and restores the choice after restart", async () => {
  const f = await fixture((_, response, index) => {
    if (index === 0) {
      send(
        response,
        chunk({
          tool_calls: [
            {
              index: 0,
              id: "ask",
              type: "function",
              function: { name: "AskUserQuestion", arguments: JSON.stringify(params) },
            },
          ],
        }),
      );
      done(response, "tool_calls");
    } else {
      send(response, chunk({ content: "done" }));
      done(response);
    }
  });
  f.settings.setPluginEnabled("zpi.questions", true);
  const session = await f.host.createSession(f.a.id);
  await f.host.startRun({ sessionId: session.id, text: "choose" });
  const running = f.host.activeRuns.get(session.id)?.done;
  await expect.poll(() => f.host.getSessionSnapshot(session.id).view.question?.id).toBe("ask");
  f.settings.setPluginEnabled("zpi.questions", false);
  f.host.plugins.questions.respond(session.id, "ask", { answers: { "使用哪种方案?": "B" } });
  await running;
  expect(JSON.stringify(f.server.requests[1].messages)).toContain("B");
  expect(f.host.getSessionSnapshot(session.id).view.question).toBeNull();
  const questionBlock = () =>
    f.host
      .getSessionSnapshot(session.id)
      .view.runs[0].orderedBlocks.find((block) => block.type === "tool" && block.name === "AskUserQuestion");
  expect(questionBlock()).toMatchObject({ questionAnswers: { answers: { "使用哪种方案?": "B" } } });
  const next = await f.host.createSession(f.a.id);
  await f.host.startRun({ sessionId: next.id, text: "new" });
  await f.host.activeRuns.get(next.id)?.done;
  expect(JSON.stringify(f.server.requests.at(-1)?.tools)).not.toContain("AskUserQuestion");
  await f.host.close();
  const restored = new SessionHost(f.dir, f.settings, f.dir, undefined, undefined);
  await restored.init();
  cleanup.push(() => restored.close());
  expect(restored.getSessionSnapshot(session.id).view.runs[0].orderedBlocks).toContainEqual(
    expect.objectContaining({ questionAnswers: { answers: { "使用哪种方案?": "B" } } }),
  );
  await restored.startRun({ sessionId: session.id, text: "continue" });
  await restored.activeRuns.get(session.id)?.done;
  expect(JSON.stringify(f.server.requests.at(-1)?.tools)).toContain("AskUserQuestion");
});

it("shows a same-batch question while an earlier long-running tool is still executing", async () => {
  const broker = new UserQuestions(() => {});
  const started = deferred();
  const release = deferred();
  const model = fakeModel("http://localhost");
  let requests = 0;
  const agent = new Agent({
    initialState: {
      model,
      tools: [
        {
          name: "longTask",
          label: "longTask",
          description: "longTask",
          parameters: Type.Object({}),
          execute: async () => {
            started.resolve();
            await release.promise;
            return { content: [{ type: "text", text: "done" }], details: undefined };
          },
        },
        broker.tool("s") as unknown as AgentTool,
      ],
    },
    streamFn: () => {
      const stream = createAssistantMessageEventStream();
      const message = emptyAssistant(model);
      message.content =
        requests++ === 0
          ? [
              { type: "toolCall", id: "long", name: "longTask", arguments: {} },
              { type: "toolCall", id: "q", name: "AskUserQuestion", arguments: params },
            ]
          : [{ type: "text", text: "finished" }];
      message.stopReason = requests === 1 ? "toolUse" : "stop";
      stream.push({ type: "start", partial: message });
      stream.end(message);
      return stream;
    },
  });
  const run = agent.prompt("choose while working");
  try {
    await started.promise;
    await expect.poll(() => broker.get("s")?.id).toBe("q");
    broker.respond("s", "q", { answers: { "使用哪种方案?": "A" } });
    release.resolve();
    await run;
    expect(requests).toBe(2);
  } finally {
    release.resolve();
    agent.abort();
    await run;
  }
});

it("queues simultaneous questions and gives each head a full answer window", async () => {
  vi.useFakeTimers();
  vi.setSystemTime(0);
  try {
    const changes: (string | null)[] = [];
    const broker = new UserQuestions((_, request) => changes.push(request?.id ?? null));
    const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
    const first = execute("first", params, undefined);
    const second = execute("second", params, undefined);
    expect(changes).toEqual(["first"]);
    expect(() => broker.respond("s", "second", { answers: {} })).toThrow("提问已结束");
    await vi.advanceTimersByTimeAsync(300_000);
    await first;
    expect(broker.get("s")).toMatchObject({
      id: "second",
      autoResolution: { startedAt: 300_000, deadlineAt: 600_000 },
    });
    expect(changes).toEqual(["first", "first", "second"]);
    await vi.advanceTimersByTimeAsync(299_999);
    expect(broker.get("s")?.id).toBe("second");
    broker.respond("s", "second", { answers: {} });
    await second;
    expect(broker.get("s")).toBeNull();
    expect(vi.getTimerCount()).toBe(0);
  } finally {
    vi.useRealTimers();
  }
});

it("removes cancelled queued questions without disturbing the visible question", async () => {
  const changes: (string | null)[] = [];
  const broker = new UserQuestions((_, request) => changes.push(request?.id ?? null));
  const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
  const controller = new AbortController();
  const first = execute("first", params, undefined);
  const second = execute("second", params, controller.signal);
  const third = execute("third", params, undefined);
  controller.abort();
  await expect(second).rejects.toThrow("cancelled");
  expect(broker.get("s")?.id).toBe("first");
  expect(changes).toEqual(["first"]);
  broker.respond("s", "first", { answers: {} });
  await first;
  expect(broker.get("s")?.id).toBe("third");
  expect(() => broker.respond("s", "first", { answers: {} })).toThrow("提问已结束");
  broker.respond("s", "third", { answers: {} });
  await third;
  expect(changes).toEqual(["first", "third", null]);
});

it("cancels a run's entire question queue without displaying already-aborted questions", async () => {
  const changes: (string | null)[] = [];
  const broker = new UserQuestions((_, request) => changes.push(request?.id ?? null));
  const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
  const controller = new AbortController();
  const results = Promise.allSettled([
    execute("first", params, controller.signal),
    execute("second", params, controller.signal),
    execute("third", params, controller.signal),
  ]);
  controller.abort();
  expect((await results).every((result) => result.status === "rejected")).toBe(true);
  expect(changes).toEqual(["first", null]);
  expect(broker.get("s")).toBeNull();
});

it("advances after cancelling the head and keeps snooze scoped to the active question", async () => {
  vi.useFakeTimers();
  vi.setSystemTime(0);
  try {
    const broker = new UserQuestions(() => {});
    const execute = broker.tool("s").execute as (...args: unknown[]) => Promise<unknown>;
    const controller = new AbortController();
    const first = execute("first", params, controller.signal);
    const second = execute("second", params, undefined);
    broker.snooze("s", "second");
    expect(broker.get("s")?.autoResolution?.phase).toBe("hiddenGrace");
    broker.snooze("s", "first");
    await vi.advanceTimersByTimeAsync(400_000);
    expect(broker.get("s")?.id).toBe("first");
    controller.abort();
    await expect(first).rejects.toThrow("cancelled");
    expect(broker.get("s")).toMatchObject({
      id: "second",
      autoResolution: { phase: "hiddenGrace", startedAt: 400_000, deadlineAt: 700_000 },
    });
    await vi.advanceTimersByTimeAsync(300_000);
    await second;
    expect(broker.get("s")).toBeNull();
    expect(vi.getTimerCount()).toBe(0);
  } finally {
    vi.useRealTimers();
  }
});