test: tighten subagent spawn hook assertions

This commit is contained in:
Peter Steinberger
2026-05-10 20:27:29 +01:00
parent c6a6685b79
commit a4b34d68fb

View File

@@ -54,6 +54,19 @@ function findGatewayRequest(method: string): GatewayRequest | undefined {
return getGatewayRequests().find((request) => request.method === method);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toBeTypeOf("object");
expect(value, label).not.toBeNull();
return value as Record<string, unknown>;
}
function expectFields(value: unknown, expected: Record<string, unknown>, label = "object"): void {
const record = requireRecord(value, label);
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
}
}
function setConfig(next: Record<string, unknown>) {
hoisted.configOverride = createSubagentSpawnTestConfig(undefined, next);
}
@@ -132,10 +145,14 @@ function expectThreadBindFailureCleanup(
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();
const deleteCall = findGatewayRequest("sessions.delete");
expect(deleteCall?.params).toMatchObject({
key: result.childSessionKey,
emitLifecycleHooks: false,
});
expectFields(
deleteCall?.params,
{
key: result.childSessionKey,
emitLifecycleHooks: false,
},
"delete params",
);
}
beforeAll(async () => {
@@ -215,7 +232,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expect(result).toMatchObject({ status: "accepted", runId: "run-1" });
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledWith(
{
@@ -242,25 +259,37 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
Record<string, unknown>,
Record<string, unknown>,
];
expect(event).toMatchObject({
runId: "run-1",
agentId: "main",
label: "research",
mode: "session",
requester: {
expectFields(
event,
{
runId: "run-1",
agentId: "main",
label: "research",
mode: "session",
threadRequested: true,
},
"spawned event",
);
expectFields(
event.requester,
{
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: 456,
},
threadRequested: true,
});
"spawned requester",
);
expect(event.childSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/));
expect(ctx).toMatchObject({
runId: "run-1",
requesterSessionKey: "main",
childSessionKey: event.childSessionKey,
});
expectFields(
ctx,
{
runId: "run-1",
requesterSessionKey: "main",
childSessionKey: event.childSessionKey,
},
"spawned context",
);
});
it("emits subagent_spawned with threadRequested=false when not requested", async () => {
@@ -269,20 +298,28 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
agentTo: "channel:123",
});
expect(result).toMatchObject({ status: "accepted", runId: "run-1" });
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
const [event] = (hookRunnerMocks.runSubagentSpawned.mock.calls[0] ?? []) as unknown as [
Record<string, unknown>,
];
expect(event).toMatchObject({
mode: "run",
threadRequested: false,
requester: {
expectFields(
event,
{
mode: "run",
threadRequested: false,
},
"spawned event",
);
expectFields(
event.requester,
{
channel: "discord",
to: "channel:123",
},
});
"spawned requester",
);
});
it("respects explicit mode=run when thread binding is requested", async () => {
@@ -294,13 +331,17 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expect(result).toMatchObject({ status: "accepted", runId: "run-1", mode: "run" });
expectFields(result, { status: "accepted", runId: "run-1", mode: "run" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
const event = getSpawnedEventCall();
expect(event).toMatchObject({
mode: "run",
threadRequested: true,
});
expectFields(
event,
{
mode: "run",
threadRequested: true,
},
"spawned event",
);
});
it("returns error when thread binding cannot be created", async () => {
@@ -377,26 +418,34 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expect(result).toMatchObject({ status: "error" });
expect(result.status).toBe("error");
expect(hookRunnerMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
const [event] = (hookRunnerMocks.runSubagentEnded.mock.calls[0] ?? []) as unknown as [
Record<string, unknown>,
];
expect(event).toMatchObject({
targetSessionKey: expect.stringMatching(/^agent:main:subagent:/),
accountId: "work",
targetKind: "subagent",
reason: "spawn-failed",
sendFarewell: true,
outcome: "error",
error: "Session failed to start",
});
expect(event.targetSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/));
expectFields(
event,
{
accountId: "work",
targetKind: "subagent",
reason: "spawn-failed",
sendFarewell: true,
outcome: "error",
error: "Session failed to start",
},
"ended event",
);
const deleteCall = findGatewayRequest("sessions.delete");
expect(deleteCall?.params).toMatchObject({
key: event.targetSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
});
expectFields(
deleteCall?.params,
{
key: event.targetSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
"delete params",
);
});
it("falls back to sessions.delete cleanup when subagent_ended hook is unavailable", async () => {
@@ -411,15 +460,19 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expect(result).toMatchObject({ status: "error" });
expect(result.status).toBe("error");
expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
const methods = getGatewayMethods();
expect(methods).toContain("sessions.delete");
const deleteCall = findGatewayRequest("sessions.delete");
expect(deleteCall?.params).toMatchObject({
deleteTranscript: true,
emitLifecycleHooks: true,
});
expectFields(
deleteCall?.params,
{
deleteTranscript: true,
emitLifecycleHooks: true,
},
"delete params",
);
});
it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
@@ -464,10 +517,14 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
expect(methods).toContain("sessions.delete");
expect(methods).not.toContain("agent");
const deleteCall = findGatewayRequest("sessions.delete");
expect(deleteCall?.params).toMatchObject({
key: result.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: true,
});
expectFields(
deleteCall?.params,
{
key: result.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: true,
},
"delete params",
);
});
});