Menu
popagent
publicLatest 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");
});
});