AkurAI Build
Menu

popagent

public

Latest change 52d0ea54b801ea610cbf7def763cfcec95ba82da - Fix delegated subagent context ending on a dangling assistant message by AkurAI Build

import { describe, expect, test } from "bun:test";
import type { DelegationCompleteContext, DelegationStartContext, MessageFilterContext } from "@mastra/core/agent";
import { RequestContext } from "@mastra/core/request-context";
import { createDelegationHooks } from "./hook-lifecycle";
import { HookRuntime, type HookEvent, type HookRunRecord } from "./hooks";

const delegationSettings = {
  delegationContextMessages: 5,
  delegationResultCharacters: 2_000,
  delegationFailureFeedback: "Specialist {{agentId}} failed: {{error}}. Continue with another approach; do not treat the delegation as completed.",
  delegationResultTruncationMarker: "\n\n[Configured result truncated]",
};

const base = (eventName: HookEvent["eventName"], detail: Record<string, unknown> = {}): HookEvent => ({
  schemaVersion: 1,
  eventId: crypto.randomUUID(),
  eventName,
  occurredAt: new Date().toISOString(),
  sessionId: "session-1",
  turnId: "turn-1",
  model: "cx/gpt-5.6-sol",
  detail,
});

describe("HookRuntime", () => {
  test("applies matching mutations sequentially", async () => {
    const seen: unknown[] = [];
    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          PreToolUse: [
            { id: "normalize", type: "http", url: "https://hooks.example/normalize", matcher: ["getTime"] },
            { id: "audit", type: "http", url: "https://hooks.example/audit", matcher: ["getTime"] },
          ],
        },
      },
      transport: async (handler, event) => {
        seen.push(event.detail);
        return handler.id === "normalize"
          ? { schemaVersion: 1, outcome: "modify", reason: "UTC only", replacement: { zone: "UTC" } }
          : { schemaVersion: 1, outcome: "pass" };
      },
    });

    const result = await runtime.dispatch(base("PreToolUse", { tool: { name: "getTime", input: {} } }));

    expect(seen[1]).toEqual({ tool: { name: "getTime", input: { zone: "UTC" } } });
    expect(result).toEqual({ replacement: { zone: "UTC" }, additionalContext: [] });
  });

  test("stops at denial and records completed runs", async () => {
    const records: HookRunRecord[] = [];
    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          UserPromptSubmit: [
            { id: "policy", type: "http", url: "https://hooks.example/policy" },
            { id: "unreached", type: "http", url: "https://hooks.example/unreached" },
          ],
        },
      },
      transport: async () => ({ schemaVersion: 1, outcome: "deny", reason: "blocked by policy" }),
      audit: async (record) => {
        records.push(record);
      },
    });

    await expect(runtime.dispatch(base("UserPromptSubmit", { prompt: "secret" }))).rejects.toEqual(
      expect.objectContaining({ name: "HookBlockedError", reason: "blocked by policy" }),
    );
    expect(records.map((record) => record.status)).toEqual(["denied"]);
  });

  test("fails open for observational events and closed for gates", async () => {
    const transport = async () => { throw new Error("offline"); };
    const handler = { id: "offline", type: "http" as const, url: "https://hooks.example/offline" };
    const open = new HookRuntime({ config: { version: 1, hooks: { PostToolUse: [handler] } }, transport });
    const closed = new HookRuntime({ config: { version: 1, hooks: { PreToolUse: [handler] } }, transport });

    await expect(open.dispatch(base("PostToolUse"))).resolves.toEqual({ additionalContext: [] });
    await expect(closed.dispatch(base("PreToolUse"))).rejects.toEqual(
      expect.objectContaining({ reason: "Hook offline failed: offline" }),
    );
  });

  test("keeps audit persistence outside hook failure policy", async () => {
    const audit = async () => { throw new Error("audit database unavailable"); };
    const observational = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          PostToolUse: [{ id: "observe", type: "http", url: "https://hooks.example/observe" }],
        },
      },
      transport: async () => { throw new Error("handler offline"); },
      audit,
    });
    const gate = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          PreToolUse: [{ id: "gate", type: "http", url: "https://hooks.example/gate" }],
        },
      },
      transport: async () => { throw new Error("handler offline"); },
      audit,
    });

    await expect(observational.dispatch(base("PostToolUse"))).resolves.toEqual({
      additionalContext: [],
    });
    await expect(gate.dispatch(base("PreToolUse"))).rejects.toEqual(
      expect.objectContaining({ reason: "Hook gate failed: handler offline" }),
    );
  });

  test("routes malformed results through each event's failure mode", async () => {
    const records: HookRunRecord[] = [];
    const open = new HookRuntime({
      config: {
        version: 1,
        hooks: { SessionStart: [{ id: "bad", type: "http", url: "https://hooks.example/bad" }] },
      },
      transport: async () => ({ schemaVersion: 1, outcome: "deny", reason: "invalid here" }),
      audit: async (record) => { records.push(record); },
    });
    const closed = new HookRuntime({
      config: {
        version: 1,
        hooks: { PreToolUse: [{ id: "bad", type: "http", url: "https://hooks.example/bad" }] },
      },
      transport: async () => ({ schemaVersion: 1, outcome: "continue", reason: "invalid here", additionalContext: "retry" }),
    });

    await expect(open.dispatch(base("SessionStart"))).resolves.toEqual({ additionalContext: [] });
    expect(records).toContainEqual(expect.objectContaining({ status: "failed-open" }));
    await expect(closed.dispatch(base("PreToolUse", { tool: { name: "getTime", input: {} } }))).rejects.toEqual(
      expect.objectContaining({ name: "HookBlockedError" }),
    );
  });

  test("rejects closed observational hooks and unsupported post-tool mutation", async () => {
    expect(() => new HookRuntime({
      config: {
        version: 1,
        hooks: {
          SessionEnd: [{
            id: "closed",
            type: "http",
            url: "https://hooks.example/closed",
            failureMode: "closed",
          }],
        },
      },
    })).toThrow("SessionEnd hooks are observational and must fail open");

    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: { PostToolUse: [{ id: "replace", type: "http", url: "https://hooks.example/replace" }] },
      },
      transport: async () => ({
        schemaVersion: 1,
        outcome: "modify",
        reason: "replace",
        replacement: "changed",
      }),
    });
    await expect(runtime.dispatch(base("PostToolUse", {
      tool: { name: "getTime", output: "original" },
    }))).resolves.toEqual({ additionalContext: [] });
  });

  test("enforces UTF-8 field budgets", async () => {
    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          UserPromptSubmit: [{
            id: "context",
            type: "http",
            url: "https://hooks.example/context",
            failureMode: "open",
          }],
        },
      },
      transport: async () => ({
        schemaVersion: 1,
        outcome: "pass",
        additionalContext: "🙂".repeat(4_097),
      }),
    });

    await expect(runtime.dispatch(base("UserPromptSubmit", { prompt: "hello" }))).resolves.toEqual({
      additionalContext: [],
    });
  });

  test("enforces one aggregate context budget across handlers", async () => {
    const records: HookRunRecord[] = [];
    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          UserPromptSubmit: [
            {
              id: "first",
              type: "http",
              url: "https://hooks.example/first",
              failureMode: "open",
            },
            {
              id: "second",
              type: "http",
              url: "https://hooks.example/second",
              failureMode: "open",
            },
          ],
        },
      },
      transport: async (handler) => ({
        schemaVersion: 1,
        outcome: "pass",
        additionalContext: handler.id.repeat(1_800),
      }),
      audit: async (record) => { records.push(record); },
    });

    await expect(runtime.dispatch(base("UserPromptSubmit", { prompt: "hello" }))).resolves.toEqual({
      additionalContext: ["first".repeat(1_800)],
    });
    expect(records.map((record) => record.status)).toEqual(["passed", "failed-open"]);
    expect(records[1]?.error).toBe("Combined additionalContext exceeds 16384 UTF-8 bytes");
  });

  test("stops reading HTTP responses beyond 64 KiB", async () => {
    const originalFetch = globalThis.fetch;
    const records: HookRunRecord[] = [];
    globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
      start(controller) {
        controller.enqueue(new Uint8Array(40_000));
        controller.enqueue(new Uint8Array(30_000));
        controller.close();
      },
    }))) as unknown as typeof fetch;
    try {
      const runtime = new HookRuntime({
        config: {
          version: 1,
          hooks: { PostToolUse: [{ id: "large", type: "http", url: "https://hooks.example/large" }] },
        },
        audit: async (record) => { records.push(record); },
      });
      await expect(runtime.dispatch(base("PostToolUse"))).resolves.toEqual({ additionalContext: [] });
      expect(records).toContainEqual(expect.objectContaining({
        status: "failed-open",
        error: "response exceeds 65536 bytes",
      }));
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  test("gates and audits specialist delegation", async () => {
    const events: HookEvent[] = [];
    const runtime = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          SubagentStart: [{
            id: "scope-research",
            type: "http",
            url: "https://hooks.example/research",
            matcher: ["researcher"],
          }],
          SubagentStop: [{
            id: "audit-research",
            type: "http",
            url: "https://hooks.example/audit",
            matcher: ["researcher"],
          }],
        },
      },
      transport: async (_handler, event) => {
        events.push(event);
        return event.eventName === "SubagentStart"
          ? { schemaVersion: 1, outcome: "modify", reason: "scope", replacement: "bounded prompt" }
          : { schemaVersion: 1, outcome: "pass" };
      },
    });
    const lifecycle = createDelegationHooks(runtime, {
      sessionId: "session-1",
      turnId: "turn-1",
      model: "auto/fast",
    }, delegationSettings);
    const start: DelegationStartContext = {
      primitiveId: "researcher",
      primitiveType: "agent",
      prompt: "original prompt",
      params: {},
      iteration: 2,
      runId: "run-1",
      parentAgentId: "orchistrator",
      parentAgentName: "orchistrator",
      toolCallId: "tool-1",
      messages: [],
      requestContext: new RequestContext(),
    };

    expect(await lifecycle.onDelegationStart?.(start)).toEqual({
      proceed: true,
      modifiedPrompt: "bounded prompt",
    });
    const messages = Array.from({ length: delegationSettings.delegationContextMessages + 3 }, (_, index) => ({
      id: `message-${index}`,
      role: "user" as const,
      createdAt: new Date(index),
      content: { format: 2 as const, parts: [{ type: "text" as const, text: String(index) }] },
    }));
    expect(await lifecycle.messageFilter?.({
      ...start,
      messages,
    } satisfies MessageFilterContext)).toEqual(messages.slice(-delegationSettings.delegationContextMessages));
    const trailingAssistant = [
      ...messages.slice(0, -2),
      { id: "assistant-a", role: "assistant" as const, createdAt: new Date(90), content: { format: 2 as const, parts: [{ type: "text" as const, text: "using read" }] } },
      { id: "assistant-b", role: "assistant" as const, createdAt: new Date(91), content: { format: 2 as const, parts: [{ type: "text" as const, text: "checking" }] } },
    ];
    expect(await lifecycle.messageFilter?.({
      ...start,
      messages: trailingAssistant,
    } satisfies MessageFilterContext)).toEqual(trailingAssistant.slice(-delegationSettings.delegationContextMessages, -2));
    await lifecycle.onDelegationComplete?.({
      primitiveId: "researcher",
      primitiveType: "agent",
      prompt: "bounded prompt",
      result: { text: "findings" },
      duration: 42,
      success: true,
      iteration: 2,
      runId: "run-1",
      toolCallId: "tool-1",
      parentAgentId: "orchistrator",
      parentAgentName: "orchistrator",
      messages: [],
      bail: () => undefined,
    } satisfies DelegationCompleteContext);

    expect(events.map((event) => event.eventName)).toEqual(["SubagentStart", "SubagentStop"]);
    expect(events[1]?.detail).toEqual(expect.objectContaining({
      agent: { id: "researcher", type: "agent", parentId: "orchistrator" },
      success: true,
      durationMs: 42,
      response: "findings",
    }));

    expect(lifecycle.includeSubAgentToolResultsInModelContext).toBe(false);
    const oversized = "x".repeat(delegationSettings.delegationResultCharacters + 1_000);
    const bounded = await lifecycle.onDelegationComplete?.({
      primitiveId: "researcher",
      primitiveType: "agent",
      prompt: "bounded prompt",
      result: { text: oversized },
      duration: 42,
      success: true,
      iteration: 2,
      runId: "run-1",
      toolCallId: "tool-2",
      parentAgentId: "orchistrator",
      parentAgentName: "orchistrator",
      messages: [],
      bail: () => undefined,
    } satisfies DelegationCompleteContext);
    expect(bounded?.resultText).toHaveLength(delegationSettings.delegationResultCharacters);
    expect(bounded?.resultText).toEndWith(delegationSettings.delegationResultTruncationMarker.trim());
    expect(events[2]?.detail.response).toBe(bounded?.resultText);
    const failure = new Error("provider capacity exhausted");
    const failed = await lifecycle.onDelegationComplete?.({
      primitiveId: "researcher",
      primitiveType: "agent",
      prompt: "bounded prompt",
      result: { text: "" },
      duration: 7,
      success: false,
      error: failure,
      iteration: 2,
      runId: "run-1",
      toolCallId: "tool-3",
      parentAgentId: "orchistrator",
      parentAgentName: "orchistrator",
      messages: [],
      bail: () => undefined,
    } satisfies DelegationCompleteContext);
    expect(failed?.feedback).toContain("Specialist researcher failed: provider capacity exhausted");
    expect(failed?.feedback).toContain("do not treat the delegation as completed");
    expect(events[3]?.detail).toEqual(expect.objectContaining({
      success: false,
      error: { name: "Error", message: "provider capacity exhausted" },
    }));

    const denied = new HookRuntime({
      config: {
        version: 1,
        hooks: {
          SubagentStart: [{ id: "deny", type: "http", url: "https://hooks.example/deny" }],
        },
      },
      transport: async () => ({ schemaVersion: 1, outcome: "deny", reason: "review required" }),
    });
    expect(await createDelegationHooks(denied, {
      sessionId: "session-1",
      turnId: "turn-1",
      model: "auto/fast",
    }, delegationSettings).onDelegationStart?.(start)).toEqual({
      proceed: false,
      rejectionReason: "review required",
    });
  });

  test("rejects invalid configuration versions", () => {
    expect(() => HookRuntime.fromJSON('{"version":2,"hooks":{}}')).toThrow("Invalid hook configuration");
  });
});