Z ZPI 文档 v0.3.8
介绍页

源码

packages/agent/test/streaming-tools.test.ts

typescript632 行查看原文
import type { AgentEvent, AgentOptions, AgentTool, ToolExecutionMetadata } from "@zpi-dev/agent";
import { Agent } from "@zpi-dev/agent";
import type { AssistantMessage, ToolCall } from "@zpi-dev/ai";
import { createAssistantMessageEventStream, emptyAssistant } from "@zpi-dev/ai";
import { Type } from "typebox";
import { expect, it } from "vitest";
import { deferred, fakeModel } from "../../../tests/fake-server.ts";

const model = fakeModel("http://localhost");
const safe: ToolExecutionMetadata = {
  readOnly: true,
  concurrentSafe: true,
  destructive: false,
  needsApproval: false,
  requiresUserInteraction: false,
  sideEffectScope: "none",
};
const call = (id = "read", name = "read"): ToolCall => ({ type: "toolCall", id, name, arguments: {} });
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
const tool: AgentTool = {
  name: "read",
  label: "Read",
  description: "read",
  parameters: Type.Object({}),
  metadata: safe,
  async execute(id) {
    return result(id);
  },
};

function fixture(tools: AgentTool[] = [tool], options: Partial<AgentOptions> = {}) {
  const stream = createAssistantMessageEventStream();
  const message = emptyAssistant(model);
  stream.push({ type: "start", partial: message });
  let requests = 0;
  const agent = new Agent({
    initialState: { model, tools },
    ...options,
    streamFn: () => {
      if (++requests === 1) return stream;
      const next = createAssistantMessageEventStream();
      const output = emptyAssistant(model);
      output.content = [{ type: "text", text: "finished" }];
      next.push({ type: "start", partial: output });
      next.end(output);
      return next;
    },
  });
  const events: AgentEvent[] = [];
  agent.subscribe((event) => {
    events.push(event);
  });
  function close(c: ToolCall) {
    const index = message.content.findIndex((block) => block.type === "toolCall" && block.id === c.id);
    if (index < 0) message.content.push(c);
    stream.push({
      type: "toolcall_end",
      contentIndex: index < 0 ? message.content.length - 1 : index,
      toolCall: c,
      partial: message,
    });
  }
  function finish() {
    message.stopReason = "toolUse";
    stream.end(message);
  }
  return { agent, stream, message, events, close, finish };
}

it("executes a closed safe call while the model is still streaming, deduplicates it and reuses its result", async () => {
  let executions = 0;
  const ended = deferred();
  const f = fixture([
    {
      ...tool,
      async execute() {
        executions++;
        return result("early");
      },
    },
  ]);
  f.agent.subscribe((event) => {
    if (event.type === "tool_execution_end") ended.resolve();
  });
  const run = f.agent.prompt("read");
  f.close(call());
  f.close(call());
  await ended.promise;
  expect(f.agent.state.isStreaming).toBe(true);
  expect(f.events.filter((e) => e.type === "message_end" && e.message.role === "assistant")).toHaveLength(0);
  // A later model delta cannot mutate the closed input consumed by the tool.
  f.message.content.push({ type: "text", text: "model tail" });
  f.stream.push({ type: "text_delta", contentIndex: 1, delta: "model tail", partial: f.message });
  f.finish();
  await run;
  expect(executions).toBe(1);
  expect(f.agent.state.messages.find((m) => m.role === "toolResult")).toMatchObject({
    content: [{ text: "early" }],
  });
});

it("does not infer completion from parsable argument deltas", async () => {
  let executions = 0;
  const received = deferred();
  const f = fixture([
    {
      ...tool,
      async execute() {
        executions++;
        return result("read");
      },
    },
  ]);
  f.agent.subscribe((event) => {
    if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_delta")
      received.resolve();
  });
  const run = f.agent.prompt("read");
  f.message.content = [call()];
  f.stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "{}", partial: f.message });
  await received.promise;
  expect(executions).toBe(0);
  f.close(call());
  f.finish();
  await run;
  expect(executions).toBe(1);
});

it("rejects duplicate declarations without persisting or executing them twice", async () => {
  let executions = 0;
  const f = fixture([
    {
      ...tool,
      metadata: undefined,
      async execute() {
        executions++;
        return result("executed");
      },
    },
  ]);
  const run = f.agent.prompt("read");
  f.message.content = [call(), call()];
  f.finish();
  await run;
  const assistant = f.agent.state.messages.find((m) => m.role === "assistant");
  expect(assistant).toMatchObject({
    stopReason: "error",
    errorMessage: expect.stringContaining("duplicate tool call"),
  });
  expect(assistant?.role === "assistant" && assistant.content).toHaveLength(1);
  expect(executions).toBe(0);
  expect(f.agent.state.messages.filter((m) => m.role === "toolResult")).toHaveLength(1);
});

for (const [name, override] of [
  ["missing metadata", { metadata: undefined }],
  ["not read-only", { metadata: { ...safe, readOnly: false } }],
  ["not concurrent-safe", { metadata: { ...safe, concurrentSafe: false } }],
  ["destructive", { metadata: { ...safe, destructive: true } }],
  ["approval", { metadata: { ...safe, needsApproval: true } }],
  ["interaction", { metadata: { ...safe, requiresUserInteraction: true } }],
  ["entry interaction", { requiresUserInteraction: true }],
  ["side effects", { metadata: { ...safe, sideEffectScope: "workspace" as const } }],
  ["permission override", { permission: { sideEffectScope: "network" as const } }],
] as const) {
  it(`defers ${name} tools until the response ends`, async () => {
    let executions = 0;
    const received = deferred();
    const f = fixture([
      {
        ...tool,
        ...override,
        async execute() {
          executions++;
          return result("read");
        },
      },
    ]);
    f.agent.subscribe((event) => {
      if (event.type === "message_update") received.resolve();
    });
    const run = f.agent.prompt("read");
    f.close(call());
    await received.promise;
    expect(executions).toBe(0);
    f.finish();
    await run;
    expect(executions).toBe(1);
  });
}

it("honors the streaming off switch", async () => {
  let executions = 0;
  const received = deferred();
  const f = fixture(
    [
      {
        ...tool,
        async execute() {
          executions++;
          return result("read");
        },
      },
    ],
    { streamingToolExecution: "off" },
  );
  f.agent.subscribe((event) => {
    if (event.type === "message_update") received.resolve();
  });
  const run = f.agent.prompt("read");
  f.close(call());
  await received.promise;
  expect(executions).toBe(0);
  f.finish();
  await run;
  expect(executions).toBe(1);
});

it("uses ZCode's entry interaction and permission overrides for streaming admission", async () => {
  const ended = deferred();
  const f = fixture([
    {
      ...tool,
      metadata: { ...safe, requiresUserInteraction: true, sideEffectScope: "workspace" },
      requiresUserInteraction: false,
      permission: { sideEffectScope: "none" },
    },
  ]);
  f.agent.subscribe((event) => {
    if (event.type === "tool_execution_end") ended.resolve();
  });
  const run = f.agent.prompt("read");
  f.close(call());
  await ended.promise;
  expect(f.agent.state.isStreaming).toBe(true);
  f.finish();
  await run;
});

it("does not execute a blocked streamed call or run its after hook", async () => {
  let executed = 0,
    after = 0;
  const ended = deferred();
  const f = fixture(
    [
      {
        ...tool,
        async execute() {
          executed++;
          return result("read");
        },
      },
    ],
    {
      beforeToolCall: async () => ({ block: true, reason: "blocked" }),
      afterToolCall: async () => {
        after++;
        return undefined;
      },
    },
  );
  f.agent.subscribe((event) => {
    if (event.type === "tool_execution_end") ended.resolve();
  });
  const run = f.agent.prompt("read");
  f.close(call());
  await ended.promise;
  f.finish();
  await run;
  expect(executed).toBe(0);
  expect(after).toBe(0);
  expect(f.agent.state.messages.find((m) => m.role === "toolResult")).toMatchObject({
    isError: true,
    content: [{ text: "blocked" }],
  });
});

it("treats a streamed tool listener failure as fatal and cancels model generation", async () => {
  const fault = deferred();
  const f = fixture();
  f.agent.subscribe((event) => {
    if (event.type === "tool_execution_start") {
      fault.resolve();
      throw new Error("sink failed");
    }
  });
  const run = f.agent.prompt("read");
  f.close(call());
  await fault.promise;
  expect(f.agent.signal?.aborted).toBe(true);
  f.finish();
  await expect(run).rejects.toThrow("sink failed");
  expect(f.agent.state.isStreaming).toBe(false);
  expect(f.agent.state.pendingToolCalls.size).toBe(0);
});

it("pairs every streamed call on cancellation without executing deferred writes", async () => {
  const started = deferred(),
    gate = deferred();
  let reads = 0,
    writes = 0;
  const f = fixture([
    {
      ...tool,
      async execute() {
        if (++reads === 2) started.resolve();
        await gate.promise;
        return result("read");
      },
    },
    {
      ...tool,
      name: "write",
      metadata: undefined,
      async execute() {
        writes++;
        return result("write");
      },
    },
  ]);
  const run = f.agent.prompt("read twice and write");
  f.close(call("first"));
  f.close(call("second"));
  f.close(call("write", "write"));
  await started.promise;
  f.agent.abort();
  f.message.stopReason = "aborted";
  f.stream.end(f.message);
  await run;
  expect(writes).toBe(0);
  expect(f.agent.state.messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId)).toEqual([
    "first",
    "second",
    "write",
  ]);
  expect(f.agent.state.pendingToolCalls.size).toBe(0);
  gate.resolve();
});

it("recovery retains completed reads and fails unexecuted writes without replaying either", async () => {
  let reads = 0,
    writes = 0;
  const ended = deferred();
  const f = fixture([
    {
      ...tool,
      async execute() {
        reads++;
        return result("completed read");
      },
    },
    {
      ...tool,
      name: "write",
      metadata: undefined,
      async execute() {
        writes++;
        return result("write");
      },
    },
  ]);
  f.agent.subscribe((event) => {
    if (event.type === "tool_execution_end") ended.resolve();
  });
  const run = f.agent.prompt("read and write");
  f.close(call());
  await ended.promise;
  f.close(call("write", "write"));
  const recovered: AssistantMessage = { ...emptyAssistant(model), content: [call(), call("write", "write")] };
  f.stream.push({ type: "reset", partial: recovered });
  f.stream.push({ type: "reset", partial: recovered });
  f.stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: call(), partial: recovered });
  f.stream.push({
    type: "toolcall_end",
    contentIndex: 1,
    toolCall: call("write", "write"),
    partial: recovered,
  });
  recovered.stopReason = "toolUse";
  f.stream.end(recovered);
  await run;
  expect(reads).toBe(1);
  expect(writes).toBe(0);
  expect(
    f.events.filter((event) => event.type === "tool_execution_start" && event.toolCallId === "write"),
  ).toHaveLength(1);
  const results = f.agent.state.messages.filter((m) => m.role === "toolResult");
  expect(results).toHaveLength(2);
  expect(results[0]).toMatchObject({ content: [{ text: "completed read" }], isError: false });
  expect(results[1]).toMatchObject({ isError: true, details: { reason: "not_executed" } });
});

for (const duringDrain of [false, true]) {
  it(`bounds cancellation of a noncooperative streamed tool ${duringDrain ? "during drain" : "during generation"} and ignores late events`, async () => {
    const started = deferred(),
      gate = deferred();
    let toolSignal: AbortSignal | undefined;
    const f = fixture([
      {
        ...tool,
        async execute(_, __, signal, update) {
          toolSignal = signal;
          started.resolve();
          await gate.promise;
          update?.(result("late"));
          return result("late");
        },
      },
    ]);
    const run = f.agent.prompt("read");
    f.close(call());
    await started.promise;
    if (duringDrain) f.finish();
    f.agent.abort();
    if (!duringDrain) {
      f.message.stopReason = "aborted";
      f.stream.end(f.message);
    }
    await run;
    expect(toolSignal?.aborted).toBe(true);
    expect(f.agent.state.messages.find((m) => m.role === "toolResult")).toMatchObject({
      isError: true,
      details: { reason: "unknown_execution_state" },
    });
    const count = f.events.length;
    gate.resolve();
    await new Promise((resolve) => setTimeout(resolve, 0));
    expect(f.events).toHaveLength(count);
    expect(f.agent.state.pendingToolCalls.size).toBe(0);
  });
}

for (const mode of ["agent", "tool"] as const) {
  it(`honors ${mode} sequential execution for streaming-safe tools`, async () => {
    const started = deferred(),
      release = deferred();
    const order: string[] = [];
    const f = fixture(
      [
        {
          ...tool,
          executionMode: mode === "tool" ? "sequential" : undefined,
          async execute(id) {
            order.push(`execute:${id}`);
            if (id === "first") {
              started.resolve();
              await release.promise;
            }
            return result(id);
          },
        },
      ],
      mode === "agent" ? { toolExecution: "sequential" } : {},
    );
    const run = f.agent.prompt("read twice");
    f.close(call("first"));
    f.close(call("second"));
    f.finish();
    await started.promise;
    expect(order).toEqual(["execute:first"]);
    expect(f.events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
    release.resolve();
    await run;
    expect(order).toEqual(["execute:first", "execute:second"]);
  });
}

for (const mode of ["agent", "tool"] as const) {
  it(`cancels deferred ${mode} sequential calls without starting the rest of the batch`, async () => {
    const started = deferred(),
      release = deferred();
    const executed: string[] = [];
    const f = fixture(
      [
        {
          ...tool,
          executionMode: mode === "tool" ? "sequential" : undefined,
          async execute(id) {
            executed.push(id);
            started.resolve();
            await release.promise;
            return result(id);
          },
        },
      ],
      mode === "agent" ? { toolExecution: "sequential" } : {},
    );
    const run = f.agent.prompt("read twice");
    f.close(call("first"));
    f.close(call("second"));
    f.finish();
    await started.promise;
    f.agent.abort();
    release.resolve();
    await run;
    expect(executed).toEqual(["first"]);
    expect(f.agent.state.messages.filter((message) => message.role === "toolResult")).toMatchObject([
      { toolCallId: "first", isError: false },
      { toolCallId: "second", isError: true },
    ]);
  });
}

it("defers safe streaming calls when a sequential tool is available but never called", async () => {
  const executed: string[] = [];
  const f = fixture([
    {
      ...tool,
      async execute(id) {
        executed.push(id);
        return result(id);
      },
    },
    { ...tool, name: "serial", executionMode: "sequential" },
  ]);
  const run = f.agent.prompt("read once");
  f.close(call("read"));
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(executed).toEqual([]);
  f.finish();
  await run;
  expect(executed).toEqual(["read"]);
});

it("preserves declaration order when a sequential tool arrives after deferred write and safe read calls", async () => {
  const order: string[] = [];
  const recording = (name: string): AgentTool => ({
    ...tool,
    name,
    metadata: name === "read" ? safe : undefined,
    executionMode: name === "serial" ? "sequential" : undefined,
    async execute(id) {
      order.push(name);
      return result(id);
    },
  });
  const f = fixture([recording("write"), recording("read"), recording("serial")]);
  const run = f.agent.prompt("write, read, then serial");
  f.close(call("write", "write"));
  f.close(call("read"));
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(order).toEqual([]);
  f.close(call("serial", "serial"));
  f.finish();
  await run;
  expect(order).toEqual(["write", "read", "serial"]);
});

it("does not stream later safe tools past a sequential tool in the same batch", async () => {
  const started = deferred(),
    release = deferred();
  const order: string[] = [];
  const f = fixture([
    {
      ...tool,
      executionMode: "sequential",
      async execute(id) {
        order.push(id);
        started.resolve();
        await release.promise;
        return result(id);
      },
    },
    {
      ...tool,
      name: "inspect",
      async execute(id) {
        order.push(id);
        return result(id);
      },
    },
  ]);
  const run = f.agent.prompt("read then inspect");
  f.close(call("first"));
  f.close(call("second", "inspect"));
  f.finish();
  await started.promise;
  expect(order).toEqual(["first"]);
  release.resolve();
  await run;
  expect(order).toEqual(["first", "second"]);
});

for (const sequential of [false, true]) {
  it(`matches Pi's ${sequential ? "tool-level sequential override" : "ordered preparation and parallel batch"}`, async () => {
    const first = deferred(),
      second = deferred(),
      release = deferred();
    const order: string[] = [];
    const f = fixture(
      [
        {
          ...tool,
          metadata: undefined,
          executionMode: sequential ? "sequential" : undefined,
          async execute(id) {
            order.push(`execute:${id}`);
            if (id === "first") {
              first.resolve();
              await release.promise;
            } else second.resolve();
            return result(id);
          },
        },
      ],
      {
        beforeToolCall: async ({ toolCall }) => {
          order.push(`prepare:${toolCall.id}`);
          return undefined;
        },
      },
    );
    const run = f.agent.prompt("read twice");
    f.close(call("first"));
    f.close(call("second"));
    f.finish();
    await first.promise;
    if (sequential) {
      expect(order).toEqual(["prepare:first", "execute:first"]);
    } else {
      await second.promise;
      expect(order).toEqual(["prepare:first", "prepare:second", "execute:first", "execute:second"]);
    }
    release.resolve();
    await run;
    expect(f.agent.state.messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId)).toEqual([
      "first",
      "second",
    ]);
  });
}