openclaw / src /gateway /server-methods /agent.abort-integration.test-utils.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
78d0c37 verified
Raw History Blame Contribute Delete
91.1 kB
// Imported by agent.test.ts to keep its mocked suite in one Vitest module graph.
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { registerExecApprovalFollowupRuntimeHandoff } from "../../agents/bash-tools.exec-approval-followup-state.js";
import { createSubagentRunRecord } from "../../agents/subagent-test-fixtures.test-helpers.js";
import {
getSubagentRunByChildSessionKey,
registerSubagentRun,
} from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
import { enqueueSwarmRun, releaseSwarmRun } from "../../agents/subagents/swarm/swarm-scheduler.js";
import { testing as swarmSchedulerTesting } from "../../agents/subagents/swarm/swarm-scheduler.test-support.js";
import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js";
import { resolveSessionStorePathCore } from "../../config/sessions/paths.js";
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js";
import * as agentHandlerHelpers from "../agent-turn/agent-handler-helpers.js";
import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js";
import { prepareAgentRunDispatch } from "../agent-turn/agent-run-admission-phase.js";
import { createAgentTurnIo } from "../agent-turn/io.js";
import { resolveAgentRunExpiresAtMs } from "../chat-abort.js";
import type { GatewaySessionRow } from "../session-utils.js";
import {
applyGatewaySubagentRegistryTestDeps,
getAgentTestMocks,
operatorWriteCliClient,
makeContext,
waitForAssertion,
requireValue,
expectRecordFields,
mockCallArg,
expectRespondError,
flushScheduledDispatchStep,
mockMainSessionEntry,
buildExistingMainStoreEntry,
backendGatewayClient,
mockSessionResetSuccess,
invokeAgent,
describe1BeforeEach0,
describe1AfterEach1,
prime,
} from "./agent.test-harness.js";
import {
handleChatAbortRequest,
handleChatAbortRequestWithLifecycle,
} from "./chat-abort-handler.js";
import { chatHandlers } from "./chat.js";
import type { GatewayRequestContext } from "./types.js";
const mocks = getAgentTestMocks();
function expectReactivationFailure(respond: ReturnType<typeof vi.fn>, runId: string): void {
expect(mocks.replaceSubagentRunAfterSteer).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith(
false,
{ runId, status: "error", summary: "reactivate boom" },
{ code: "UNAVAILABLE", message: "reactivate boom" },
{ runId, error: "reactivate boom" },
);
}
describe("gateway agent handler chat.abort integration", () => {
beforeEach(describe1BeforeEach0);
afterEach(describe1AfterEach1);
it("registers an abort controller into chatAbortControllers for an agent run", async () => {
prime();
const pending = new Promise(() => {});
mocks.agentCommand.mockReturnValueOnce(pending);
const context = makeContext();
const runId = "idem-abort-register";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{
context,
reqId: runId,
client: { ...operatorWriteCliClient(), connId: "conn-1" },
},
);
const entry = context.chatAbortControllers.get(runId);
const abortEntry = requireValue(entry, "chat abort entry missing");
expect(abortEntry.sessionKey).toBe("agent:main:main");
expect(abortEntry.sessionId).toBe("existing-session-id");
expect(abortEntry.ownerConnId).toBe("conn-1");
expect(abortEntry.controller.signal.aborted).toBe(false);
expect(abortEntry.expiresAtMs - abortEntry.startedAtMs).toBeGreaterThan(24 * 60 * 60_000);
});
it("keeps selected-global goals on agent session change events", async () => {
const goal = {
schemaVersion: 1,
id: "goal-work-global",
objective: "Finish work global task",
status: "active",
createdAt: 1,
updatedAt: 2,
tokenStart: 0,
tokensUsed: 5,
continuationTurns: 0,
} satisfies NonNullable<GatewaySessionRow["goal"]>;
mocks.listAgentIds.mockReturnValue(["main", "work"]);
mocks.resolveExplicitAgentSessionKey.mockReturnValue("global");
mocks.loadSessionEntry.mockReturnValue({
cfg: { agents: { list: [{ id: "main" }, { id: "work" }] }, session: { scope: "global" } },
storePath: "/tmp/sessions.json",
entry: {
sessionId: "global-session-id",
updatedAt: Date.now(),
},
canonicalKey: "global",
});
mocks.loadGatewaySessionRow.mockReturnValue({
key: "global",
sessionId: "global-session-id",
kind: "global",
updatedAt: Date.now(),
goal,
});
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockReturnValue(new Promise(() => {}));
const context = makeContext();
context.getSessionEventSubscriberConnIds = () => new Set(["conn-1"]);
const runId = "idem-agent-global-goal-event";
await invokeAgent(
{
message: "hi",
agentId: "work",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
await waitForAssertion(() => {
expect(mocks.loadGatewaySessionRow).toHaveBeenCalledWith("global", { agentId: "work" });
expect(context.addChatRun).toHaveBeenCalledWith(
runId,
expect.objectContaining({ sessionKey: "global", agentId: "work" }),
);
expect(context.chatAbortControllers.get(runId)?.agentId).toBe("work");
expect(context.broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({
sessionKey: "global",
agentId: "work",
goal: expect.objectContaining({ id: "goal-work-global" }),
}),
new Set(["conn-1"]),
{ agentId: "work", dropIfSlow: true, sessionKeys: ["global"] },
);
});
});
it("yields after the accepted ack before dispatching heavy agent work", async () => {
prime();
mocks.agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
const context = makeContext();
const respond = vi.fn();
const runId = "idem-yield-before-dispatch";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await pending;
expect(mockCallArg(respond)).toBe(true);
const acceptedPayload = expectRecordFields(mockCallArg(respond, 0, 1), {
runId,
status: "accepted",
});
expect(acceptedPayload).not.toHaveProperty("dedupeKeys");
expect(acceptedPayload).not.toHaveProperty("ownerConnId");
expect(acceptedPayload).not.toHaveProperty("ownerDeviceId");
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "accepted",
dedupeKeys: [`agent:${runId}`],
});
expect(mockCallArg(respond, 0, 2)).toBeUndefined();
expect(mockCallArg(respond, 0, 3)).toEqual({ runId });
expect(mocks.agentCommand).not.toHaveBeenCalled();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(mocks.agentCommand).not.toHaveBeenCalled();
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalledTimes(1));
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
});
it("does not dispatch when chat.abort lands during the accepted ack yield", async () => {
prime();
mocks.agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
const context = makeContext();
const respond = vi.fn();
const runId = "idem-abort-before-dispatch";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => {
expect(respond).toHaveBeenCalled();
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
expectRecordFields(mockCallArg(respond, 0, 1), {
runId,
sessionKey: "agent:main:main",
status: "accepted",
});
expect(context.chatAbortControllers.has(runId)).toBe(true);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expect(context.chatAbortControllers.has(runId)).toBe(false);
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "timeout",
summary: "aborted",
stopReason: "rpc",
timeoutPhase: "queue",
providerStarted: false,
});
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
timeoutPhase: "queue",
providerStarted: false,
});
});
it("preserves stop-command reason when /stop lands during the accepted ack yield", async () => {
prime();
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-stop-before-dispatch";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
expectRecordFields(mockCallArg(respond, 0, 1), {
runId,
sessionKey: "agent:main:main",
status: "accepted",
});
expect(context.chatAbortControllers.has(runId)).toBe(true);
const stopRespond = vi.fn();
await expectDefined(
chatHandlers["chat.send"],
'chatHandlers["chat.send"] test invariant',
)({
params: {
sessionKey: "agent:main:main",
message: "/stop",
idempotencyKey: "idem-stop-command-before-dispatch",
},
respond: stopRespond as never,
context,
req: { type: "req", id: "stop-req", method: "chat.send" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(stopRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expect(context.chatAbortControllers.has(runId)).toBe(false);
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "timeout",
summary: "aborted",
stopReason: "stop",
});
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "stop",
});
});
it("does not dispatch when chat.abort lands during pre-accept setup", async () => {
prime();
const requestedSessionKey = "agent:main:legacy-main";
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
return await updater(store);
});
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-abort-before-registration";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: requestedSessionKey,
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expect(context.chatAbortControllers.has(runId)).toBe(false);
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: requestedSessionKey,
status: "accepted",
});
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: requestedSessionKey, runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: requestedSessionKey,
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseSessionWrite?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("keeps selected-global alias scope when aborting during pre-accept setup", async () => {
mocks.listAgentIds.mockReturnValue(["main", "work"]);
mocks.loadConfigReturn = {
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
session: { scope: "global" },
};
mocks.loadSessionEntry.mockReturnValue({
cfg: mocks.loadConfigReturn,
storePath: "/tmp/sessions.json",
entry: {
sessionId: "global-work-session-id",
updatedAt: Date.now(),
},
canonicalKey: "global",
});
const requestedSessionKey = "agent:work:main";
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
global: {
sessionId: "global-work-session-id",
updatedAt: Date.now(),
},
};
return await updater(store);
});
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-selected-global-alias-abort-before-registration";
const pending = invokeAgent(
{
message: "hi",
agentId: "work",
sessionKey: requestedSessionKey,
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expect(context.chatAbortControllers.has(runId)).toBe(false);
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "global",
agentId: "work",
status: "accepted",
});
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "global", agentId: "work", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-selected-global-alias-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "global",
agentId: "work",
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseSessionWrite?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("does not dispatch when a stop command lands during pre-accept setup", async () => {
prime();
const requestedSessionKey = "agent:main:legacy-main";
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
return await updater(store);
});
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-stop-before-registration";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: requestedSessionKey,
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expect(context.chatAbortControllers.has(runId)).toBe(false);
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: requestedSessionKey,
status: "accepted",
});
const stopRespond = vi.fn();
await expectDefined(
chatHandlers["chat.send"],
'chatHandlers["chat.send"] test invariant',
)({
params: {
sessionKey: requestedSessionKey,
message: "/stop",
idempotencyKey: "idem-stop-command-before-registration",
},
respond: stopRespond as never,
context,
req: { type: "req", id: "stop-req", method: "chat.send" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(stopRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: requestedSessionKey,
status: "timeout",
summary: "aborted",
stopReason: "stop",
});
releaseSessionWrite?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "stop",
});
});
it("does not dispatch when session-level chat.abort lands during pre-accept setup", async () => {
prime();
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
return await updater(store);
});
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-session-level-abort-before-registration";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expect(context.chatAbortControllers.has(runId)).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main" },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseSessionWrite?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("does not dispatch when chat.abort lands during slow attachment setup", async () => {
mockMainSessionEntry({
sessionId: "existing-session-id",
model: "vision-model",
modelProvider: "test",
providerOverride: "test",
modelOverride: "vision-model",
});
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
let releaseCatalog: (() => void) | undefined;
const context = {
...makeContext(),
loadGatewayModelCatalog: vi.fn(
async () =>
await new Promise((resolve) => {
releaseCatalog = () =>
resolve([
{
id: "vision-model",
name: "vision-model",
provider: "test",
input: ["image"],
},
]);
}),
),
} as unknown as GatewayRequestContext;
const respond = vi.fn();
const runId = "idem-abort-during-attachment-setup";
const pending = invokeAgent(
{
message: "inspect this",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "pixel.png",
content: Buffer.from("not really a png").toString("base64"),
},
],
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() =>
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "accepted",
}),
);
expect(context.chatAbortControllers.has(runId)).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseCatalog?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("does not recreate a session deleted during slow attachment setup", async () => {
const sessionKey = "agent:main:main";
const persistedEntry = {
sessionId: "existing-session-id",
updatedAt: Date.now(),
model: "vision-model",
modelProvider: "test",
providerOverride: "test",
modelOverride: "vision-model",
};
let deleted = false;
mocks.loadSessionEntry.mockImplementation(() => ({
cfg: {},
storePath: "/tmp/sessions.json",
entry: deleted ? undefined : persistedEntry,
canonicalKey: sessionKey,
}));
mocks.updateSessionStore.mockClear();
mocks.agentCommand.mockClear();
let releaseCatalog: (() => void) | undefined;
const context = {
...makeContext(),
loadGatewayModelCatalog: vi.fn(
async () =>
await new Promise((resolve) => {
releaseCatalog = () =>
resolve([
{
id: "vision-model",
name: "vision-model",
provider: "test",
input: ["image"],
},
]);
}),
),
} as unknown as GatewayRequestContext;
const respond = vi.fn();
const runId = "idem-delete-during-attachment-setup";
const pending = invokeAgent(
{
message: "inspect this",
agentId: "main",
sessionKey,
idempotencyKey: runId,
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "pixel.png",
content: Buffer.from("not really a png").toString("base64"),
},
],
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(context.loadGatewayModelCatalog).toHaveBeenCalled());
deleted = true;
releaseCatalog?.();
await pending;
expectRespondError(respond, {
message: `Session "${sessionKey}" was deleted while starting work. Retry.`,
});
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
it("does not dispatch into a session reset during slow attachment setup", async () => {
const sessionKey = "agent:main:main";
const persistedEntry = {
sessionId: "existing-session-id",
updatedAt: Date.now(),
model: "vision-model",
modelProvider: "test",
providerOverride: "test",
modelOverride: "vision-model",
};
let currentEntry = persistedEntry;
mocks.loadSessionEntry.mockImplementation(() => ({
cfg: {},
storePath: "/tmp/sessions.json",
entry: currentEntry,
canonicalKey: sessionKey,
}));
mocks.updateSessionStore.mockClear();
mocks.agentCommand.mockClear();
let releaseCatalog: (() => void) | undefined;
const context = {
...makeContext(),
loadGatewayModelCatalog: vi.fn(
async () =>
await new Promise((resolve) => {
releaseCatalog = () =>
resolve([
{
id: "vision-model",
name: "vision-model",
provider: "test",
input: ["image"],
},
]);
}),
),
} as unknown as GatewayRequestContext;
const respond = vi.fn();
const runId = "idem-reset-during-attachment-setup";
const pending = invokeAgent(
{
message: "inspect this",
agentId: "main",
sessionKey,
idempotencyKey: runId,
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "pixel.png",
content: Buffer.from("not really a png").toString("base64"),
},
],
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(context.loadGatewayModelCatalog).toHaveBeenCalled());
currentEntry = { ...persistedEntry, sessionId: "reset-session-id" };
releaseCatalog?.();
await pending;
expectRespondError(respond, {
message: `Session "${sessionKey}" changed while starting work. Retry.`,
});
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
it("keeps selected-global agent scope while aborting during attachment setup", async () => {
mocks.listAgentIds.mockReturnValue(["main", "work"]);
mocks.loadSessionEntry.mockReturnValue({
cfg: {},
agentId: "work",
storePath: "/tmp/sessions.json",
entry: {
sessionId: "work-global-session-id",
updatedAt: Date.now(),
modelProvider: "test",
model: "vision-model",
providerOverride: "test",
modelOverride: "vision-model",
},
canonicalKey: "global",
});
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
let releaseCatalog: (() => void) | undefined;
const context = {
...makeContext(),
loadGatewayModelCatalog: vi.fn(
async () =>
await new Promise((resolve) => {
releaseCatalog = () =>
resolve([
{
id: "vision-model",
name: "vision-model",
provider: "test",
input: ["image"],
},
]);
}),
),
} as unknown as GatewayRequestContext;
const respond = vi.fn();
const runId = "idem-selected-global-abort-during-attachment-setup";
const pending = invokeAgent(
{
message: "inspect this",
agentId: "work",
sessionKey: "global",
idempotencyKey: runId,
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "pixel.png",
content: Buffer.from("not really a png").toString("base64"),
},
],
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() =>
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "global",
agentId: "work",
status: "accepted",
}),
);
await waitForAssertion(() => expect(context.loadGatewayModelCatalog).toHaveBeenCalled());
expect(context.loadGatewayModelCatalog).toHaveBeenCalledWith({
agentId: "work",
readOnly: true,
});
expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", {
agentId: "work",
clone: false,
projection: "list",
});
expect(context.chatAbortControllers.has(runId)).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "global", agentId: "work", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-selected-global-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "global",
agentId: "work",
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseCatalog?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("does not dispatch when chat.abort lands before voice wake reroutes the session", async () => {
let releaseRouting: (() => void) | undefined;
mocks.loadVoiceWakeRoutingConfig.mockImplementation(
async () =>
await new Promise((resolve) => {
releaseRouting = () =>
resolve({
version: 1,
defaultTarget: { mode: "current" },
routes: [],
updatedAtMs: 0,
});
}),
);
mocks.resolveVoiceWakeRouteByTrigger.mockReturnValue({ sessionKey: "agent:main:voice" });
mocks.loadSessionEntry.mockImplementation((sessionKey: string) => ({
cfg: {},
storePath: "/tmp/sessions.json",
entry: {
sessionId: sessionKey === "agent:main:voice" ? "voice-session-id" : "main-session-id",
updatedAt: Date.now(),
},
canonicalKey: sessionKey === "agent:main:voice" ? "agent:main:voice" : "agent:main:main",
}));
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-abort-before-voice-route";
const pending = invokeAgent(
{
message: "wake up",
sessionKey: "agent:main:main",
voiceWakeTrigger: "robot wake",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() =>
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "accepted",
}),
);
expect(context.chatAbortControllers.has(runId)).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "timeout",
summary: "aborted",
stopReason: "rpc",
});
releaseRouting?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(context.chatAbortControllers.has(runId)).toBe(false);
const finalResponse = respond.mock.calls.find(
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
);
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
runId,
status: "timeout",
stopReason: "rpc",
});
});
it("does not register or dispatch agent work prepared across a gateway restart", async () => {
mocks.registerAgentRunContext.mockClear();
let releaseRouting: (() => void) | undefined;
mocks.loadVoiceWakeRoutingConfig.mockImplementation(
async () =>
await new Promise((resolve) => {
releaseRouting = () =>
resolve({
version: 1,
defaultTarget: { mode: "current" },
routes: [],
updatedAtMs: 0,
});
}),
);
mocks.resolveVoiceWakeRouteByTrigger.mockReturnValue({ sessionKey: "agent:main:voice" });
mocks.loadSessionEntry.mockImplementation((sessionKey: string) => ({
cfg: {},
storePath: "/tmp/sessions.json",
entry: {
sessionId: sessionKey === "agent:main:voice" ? "voice-session-id" : "main-session-id",
updatedAt: Date.now(),
},
canonicalKey: sessionKey === "agent:main:voice" ? "agent:main:voice" : "agent:main:main",
}));
mocks.updateSessionStore.mockResolvedValue(undefined);
const context = makeContext();
const respond = vi.fn();
const runId = "idem-restart-during-voice-route";
const pending = invokeAgent(
{
message: "wake up",
sessionKey: "agent:main:main",
voiceWakeTrigger: "robot wake",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(releaseRouting).toBeTypeOf("function"));
mocks.lifecycleGeneration = "post-restart-generation";
releaseRouting?.();
await pending;
expect(context.chatAbortControllers.has(runId)).toBe(false);
expect(mocks.registerAgentRunContext).not.toHaveBeenCalled();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:voice",
status: "timeout",
stopReason: "restart",
timeoutPhase: "queue",
providerStarted: false,
});
});
it("does not mutate a session after losing lifecycle ownership while waiting for its store", async () => {
prime();
let releaseSessionWrite: (() => void) | undefined;
let updaterCompleted = false;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
const result = await updater(store);
updaterCompleted = true;
return result;
});
const context = makeContext();
const respond = vi.fn();
const runId = "idem-restart-during-session-write";
const pending = invokeAgent(
{
message: "hi",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, respond, reqId: runId, flushDispatch: false },
);
await waitForAssertion(() => expect(releaseSessionWrite).toBeTypeOf("function"));
mocks.lifecycleGeneration = "post-restart-generation";
releaseSessionWrite?.();
await pending;
expect(updaterCompleted).toBe(false);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "timeout",
stopReason: "restart",
});
});
it("does not acknowledge a bare reset that loses lifecycle ownership", async () => {
prime();
let releaseReset: (() => void) | undefined;
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { assertCurrent?: () => void }) => {
await new Promise<void>((resolve) => {
releaseReset = resolve;
});
opts.assertCurrent?.();
return {
ok: true,
key: "agent:main:main",
entry: { sessionId: "reset-session-id" },
};
},
);
const context = makeContext();
const respond = vi.fn();
const runId = "idem-restart-during-bare-reset";
const pending = invokeAgent(
{
message: "/reset",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{
context,
respond,
reqId: runId,
client: operatorWriteCliClient(["operator.admin"]),
},
);
await waitForAssertion(() => expect(releaseReset).toBeTypeOf("function"));
mocks.lifecycleGeneration = "post-restart-generation";
releaseReset?.();
await pending;
expect(mocks.agentCommand).not.toHaveBeenCalled();
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "timeout",
stopReason: "restart",
});
expect(
respond.mock.calls.some(
(call: unknown[]) => (call[1] as { result?: unknown } | undefined)?.result !== undefined,
),
).toBe(false);
});
it("acknowledges a committed reset when restart prevents its follow-up", async () => {
prime();
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { onCommitted?: (commit: { key: string; sessionId: string }) => void }) => {
opts.onCommitted?.({
key: "agent:main:main",
sessionId: "reset-session-id",
});
mocks.lifecycleGeneration = "post-restart-generation";
return {
ok: true,
key: "agent:main:main",
entry: { sessionId: "reset-session-id" },
};
},
);
const context = makeContext();
const respond = await invokeAgent(
{
message: "/reset check status",
sessionKey: "agent:main:main",
idempotencyKey: "idem-restart-after-reset-commit",
},
{
context,
reqId: "restart-after-reset-commit",
client: operatorWriteCliClient(["operator.admin"]),
},
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(mockCallArg(respond)).toBe(true);
const result = expectRecordFields(mockCallArg(respond, 0, 1), {
status: "ok",
summary: "completed",
}).result as { payloads?: Array<{ text?: string }> };
expect(result.payloads?.[0]?.text).toContain("Session reset.");
expect(result.payloads?.[0]?.text).toContain("send the follow-up message again");
expectRecordFields(context.dedupe.get("agent:idem-restart-after-reset-commit")?.payload, {
status: "ok",
summary: "completed",
});
});
it("acknowledges a committed bare reset after lifecycle rotation", async () => {
prime();
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { onCommitted?: (commit: { key: string; sessionId: string }) => void }) => {
opts.onCommitted?.({
key: "agent:main:main",
sessionId: "reset-session-id",
});
mocks.lifecycleGeneration = "post-restart-generation";
return {
ok: true,
key: "agent:main:main",
entry: { sessionId: "reset-session-id" },
};
},
);
const context = makeContext();
const respond = await invokeAgent(
{
message: "/reset",
sessionKey: "agent:main:main",
idempotencyKey: "idem-restart-after-bare-reset",
},
{
context,
reqId: "restart-after-bare-reset",
client: operatorWriteCliClient(["operator.admin"]),
},
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(mockCallArg(respond)).toBe(true);
const result = expectRecordFields(mockCallArg(respond, 0, 1), {
status: "ok",
summary: "completed",
}).result as { payloads?: Array<{ text?: string }> };
expect(result.payloads?.[0]?.text).toBe("✅ Session reset.");
expectRecordFields(context.dedupe.get("agent:idem-restart-after-bare-reset")?.payload, {
status: "ok",
summary: "completed",
});
});
it("acknowledges a committed reset when post-commit work fails after rotation", async () => {
prime();
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { onCommitted?: (commit: { key: string; sessionId: string }) => void }) => {
opts.onCommitted?.({
key: "agent:main:main",
sessionId: "reset-session-id",
});
mocks.lifecycleGeneration = "post-restart-generation";
throw new Error("post-commit cleanup failed");
},
);
const context = makeContext();
const respond = await invokeAgent(
{
message: "/reset",
sessionKey: "agent:main:main",
idempotencyKey: "idem-post-commit-reset-failure",
},
{
context,
reqId: "post-commit-reset-failure",
client: operatorWriteCliClient(["operator.admin"]),
},
);
expect(mockCallArg(respond)).toBe(true);
const result = expectRecordFields(mockCallArg(respond, 0, 1), {
status: "ok",
summary: "completed",
}).result as { payloads?: Array<{ text?: string }> };
expect(result.payloads?.[0]?.text).toBe("✅ Session reset.");
expectRecordFields(context.dedupe.get("agent:idem-post-commit-reset-failure")?.payload, {
status: "ok",
summary: "completed",
});
});
it("acknowledges a committed reset when restart wins during later session persistence", async () => {
prime();
mockSessionResetSuccess({ reason: "reset" });
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
const result = await updater(store);
mocks.lifecycleGeneration = "post-restart-generation";
return result;
});
const context = makeContext();
const respond = await invokeAgent(
{
message: "/reset check status",
sessionKey: "agent:main:main",
idempotencyKey: "idem-restart-after-reset-later",
},
{
context,
reqId: "restart-after-reset-later",
client: operatorWriteCliClient(["operator.admin"]),
},
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(mockCallArg(respond)).toBe(true);
const result = expectRecordFields(mockCallArg(respond, 0, 1), {
status: "ok",
summary: "completed",
}).result as { payloads?: Array<{ text?: string }> };
expect(result.payloads?.[0]?.text).toContain("send the follow-up message again");
expectRecordFields(context.dedupe.get("agent:idem-restart-after-reset-later")?.payload, {
status: "ok",
summary: "completed",
});
});
it("rejects unauthorized chat.abort during pre-accept setup", async () => {
prime();
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
"agent:main:main": buildExistingMainStoreEntry(),
};
return await updater(store);
});
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
const context = makeContext();
const respond = vi.fn();
const runId = "idem-abort-before-registration-unauthorized";
const pending = invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{
context,
respond,
reqId: runId,
flushDispatch: false,
client: { ...operatorWriteCliClient(), connId: "owner-conn" },
},
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expect(context.chatAbortControllers.has(runId)).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: { ...operatorWriteCliClient(), connId: "other-conn" },
isWebchatConnect: () => false,
});
expect(mockCallArg(abortRespond, 0, 0)).toBe(false);
expectRecordFields(mockCallArg(abortRespond, 0, 2), {
code: "INVALID_REQUEST",
message: "unauthorized",
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
sessionKey: "agent:main:main",
status: "accepted",
});
releaseSessionWrite?.();
await pending;
await flushScheduledDispatchStep();
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
expect(context.chatAbortControllers.has(runId)).toBe(true);
});
it("updates exec approval followup aliases when chat.abort lands during pre-accept setup", async () => {
const bashElevated = {
enabled: true,
allowed: true,
defaultLevel: "on" as const,
};
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
approvalId: "req-elevated-preaccept-abort",
sessionKey: "agent:main:telegram:direct:123",
bashElevated,
});
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
approvalId: "req-elevated-preaccept-abort",
sessionKey: "agent:main:telegram:direct:123",
bashElevated,
});
if (!firstRegistration || !secondRegistration) {
throw new Error("expected runtime handoff ids");
}
mockMainSessionEntry({
sessionId: "existing-session-id",
lastChannel: "telegram",
lastTo: "123",
});
let releaseSessionWrite: (() => void) | undefined;
let sessionWriteCalls = 0;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
sessionWriteCalls += 1;
if (sessionWriteCalls === 1) {
await new Promise<void>((resolve) => {
releaseSessionWrite = resolve;
});
}
const store = {
"agent:main:main": buildExistingMainStoreEntry({
lastChannel: "telegram",
lastTo: "123",
}),
};
return await updater(store);
});
mocks.agentCommand.mockImplementation(() => new Promise(() => {}));
const context = makeContext();
const runId = firstRegistration.idempotencyKey;
const aliasKey = "agent:exec-approval-followup:req-elevated-preaccept-abort";
const pending = invokeAgent(
{
message: "exec followup",
sessionKey: "agent:main:telegram:direct:123",
channel: "telegram",
idempotencyKey: runId,
internalRuntimeHandoffId: firstRegistration.handoffId,
},
{
reqId: "exec-followup-preaccept-abort-1",
client: backendGatewayClient(),
context,
flushDispatch: false,
},
);
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
expectRecordFields(context.dedupe.get(aliasKey)?.payload, {
runId,
sessionKey: "agent:main:telegram:direct:123",
status: "accepted",
});
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:telegram:direct:123", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: backendGatewayClient(),
isWebchatConnect: () => false,
});
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "timeout",
stopReason: "rpc",
});
expectRecordFields(context.dedupe.get(aliasKey)?.payload, {
runId,
status: "timeout",
stopReason: "rpc",
});
releaseSessionWrite?.();
await pending;
const retryRespond = await invokeAgent(
{
message: "exec followup duplicate",
sessionKey: "agent:main:telegram:direct:123",
channel: "telegram",
idempotencyKey: secondRegistration.idempotencyKey,
internalRuntimeHandoffId: secondRegistration.handoffId,
},
{
reqId: "exec-followup-preaccept-abort-2",
client: backendGatewayClient(),
context,
flushDispatch: false,
},
);
expect(mockCallArg(retryRespond, 0, 1)).toMatchObject({
runId,
status: "timeout",
stopReason: "rpc",
});
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
it("starts the registered timeout only after dispatch admission resolves", async () => {
prime();
const context = makeContext();
const runId = "idem-dispatch-admission-timeout-start";
const sessionId = "existing-session-id";
let nowMs = 1_000_000;
const dateNow = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
const { promise: acquireStarted, resolve: markAcquireStarted } = createDeferred();
const { promise: acquireReleased, resolve: releaseAcquire } = createDeferred();
type AdmittedRunAbort = Parameters<
Parameters<typeof prepareAgentRunDispatch>[0]["setAdmittedRunAbort"]
>[0];
let registration: AdmittedRunAbort | undefined;
try {
const pending = prepareAgentRunDispatch({
promptedAt: nowMs,
request: {
message: "wait for dispatch admission",
timeout: 120,
idempotencyKey: runId,
},
cfg: {},
sessionEntry: { sessionId, updatedAt: nowMs },
resolvedSessionKey: "agent:main:main",
activeSessionAgentId: "main",
delivery: {} as never,
allowModelOverride: false,
lifecycleGeneration: "test-generation",
getAdmittedSessionId: () => sessionId,
suppressVisibleSessionEffects: false,
isOneShotModelRun: false,
isRestartRecoveryResumeRun: false,
runId,
agentDedupeKeys: [`agent:${runId}`],
context,
client: null,
io: createAgentTurnIo(vi.fn()),
abortForLifecycleRotation: () => false,
acquireGatewayWorkAdmission: async () => {
markAcquireStarted();
await acquireReleased;
},
assertGatewayWorkAdmissionAllowed: () => {},
hasGatewayAdmissionOutcome: () => false,
respondToGatewayAdmissionOutcome: () => true,
admissionAgentId: () => "main",
getGatewayWorkAdmission: () => undefined,
setAdmittedRunAbort: (value: AdmittedRunAbort) => {
registration = value;
},
getAdmittedRunAbort: () => registration,
markAgentRunAccepted: () => {},
} as unknown as Parameters<typeof prepareAgentRunDispatch>[0]);
await acquireStarted;
expect(registration).toBeUndefined();
nowMs += 90_000;
releaseAcquire();
await pending;
const abortEntry = requireValue(registration?.entry, "chat abort entry missing");
expect(abortEntry.startedAtMs).toBe(nowMs);
expect(abortEntry.expiresAtMs).toBe(
resolveAgentRunExpiresAtMs({ now: nowMs, timeoutMs: 120_000 }),
);
} finally {
releaseAcquire();
dateNow.mockRestore();
}
});
it("uses the explicit no-timeout agent expiry instead of the chat 24h cap", async () => {
prime();
let onExecutionStarted: (() => void) | undefined;
mocks.agentCommand.mockImplementation(
(opts: { onExecutionStarted?: () => void }) =>
new Promise(() => {
onExecutionStarted = opts.onExecutionStarted;
}),
);
const context = makeContext();
const respond = vi.fn();
const runId = "idem-abort-no-timeout";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
timeout: 0,
},
{ context, respond, reqId: runId },
);
const entry = context.chatAbortControllers.get(runId);
const abortEntry = requireValue(entry, "chat abort entry missing");
expect(abortEntry.expiresAtMs - abortEntry.startedAtMs).toBeGreaterThan(24 * 60 * 60_000);
const startedAtMs = abortEntry.startedAtMs;
const executionStartedAtMs = Date.now() + 60_000;
const dateNow = vi.spyOn(Date, "now").mockReturnValue(executionStartedAtMs);
try {
requireValue(onExecutionStarted, "execution-start callback missing")();
expect(abortEntry.startedAtMs).toBe(startedAtMs);
expect(abortEntry.expiresAtMs - executionStartedAtMs).toBeGreaterThan(24 * 60 * 60_000);
} finally {
dateNow.mockRestore();
}
});
it("starts and rearms the agent timeout at the actual gateway execution boundaries", async () => {
prime();
const sessionKey = "agent:main:main";
const sessionId = "existing-session-id";
const runId = "idem-agent-execution-timeout-boundaries";
let nowMs = 1_000_000;
const dateNow = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
let releaseMutation = () => {};
const { promise: mutationStarted, resolve: markMutationStarted } = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: "/tmp/sessions.json",
identities: [sessionKey, sessionId],
run: async () => {
markMutationStarted();
await new Promise<void>((resolve) => {
releaseMutation = resolve;
});
},
});
await mutationStarted;
let onExecutionStarted: (() => void) | undefined;
mocks.agentCommand.mockImplementation(
(opts: { onExecutionStarted?: () => void }) =>
new Promise(() => {
onExecutionStarted = opts.onExecutionStarted;
}),
);
const context = makeContext();
const respond = vi.fn();
const admissionStartedAtMs = nowMs;
const request = invokeAgent(
{
message: "wait for admission, then preserve a full execution timeout",
agentId: "main",
sessionKey,
idempotencyKey: runId,
timeout: 120,
},
{ context, respond, reqId: runId },
);
try {
await waitForAssertion(() => expect(context.dedupe.has(`agent:${runId}`)).toBe(true));
expect(context.chatAbortControllers.has(runId)).toBe(false);
nowMs += 90_000;
releaseMutation();
await mutation;
await request;
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalledTimes(1));
const abortEntry = requireValue(
context.chatAbortControllers.get(runId),
"chat abort entry missing",
);
expect(abortEntry.startedAtMs).toBe(nowMs);
expect(abortEntry.startedAtMs).toBeGreaterThan(admissionStartedAtMs);
expect(abortEntry.expiresAtMs).toBe(
resolveAgentRunExpiresAtMs({ now: abortEntry.startedAtMs, timeoutMs: 120_000 }),
);
nowMs += 120_000;
const executionStartedAtMs = nowMs;
const executionStarted = requireValue(onExecutionStarted, "execution-start callback missing");
executionStarted();
expect(abortEntry.startedAtMs).toBe(admissionStartedAtMs + 90_000);
expect(abortEntry.expiresAtMs).toBe(
resolveAgentRunExpiresAtMs({ now: executionStartedAtMs, timeoutMs: 120_000 }),
);
const firstExecutionExpiryMs = abortEntry.expiresAtMs;
nowMs += 120_000;
executionStarted();
expect(abortEntry.startedAtMs).toBe(admissionStartedAtMs + 90_000);
expect(abortEntry.expiresAtMs).toBe(firstExecutionExpiryMs);
} finally {
releaseMutation();
await mutation;
dateNow.mockRestore();
}
});
it("keeps an elapsed queue deadline terminal before the maintenance sweep", async () => {
prime();
const runId = "idem-agent-expired-queue-deadline";
let nowMs = 1_000_000;
const dateNow = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
let onExecutionStarted: (() => void) | undefined;
mocks.agentCommand.mockImplementation(
(opts: { onExecutionStarted?: () => void }) =>
new Promise(() => {
onExecutionStarted = opts.onExecutionStarted;
}),
);
const context = makeContext();
try {
await invokeAgent(
{
message: "do not revive an expired queue deadline",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
timeout: 120,
},
{ context, respond: vi.fn(), reqId: runId },
);
const abortEntry = requireValue(
context.chatAbortControllers.get(runId),
"chat abort entry missing",
);
const startedAtMs = abortEntry.startedAtMs;
const queueExpiresAtMs = abortEntry.expiresAtMs;
nowMs = queueExpiresAtMs + 1;
const executionStarted = requireValue(onExecutionStarted, "execution-start callback missing");
executionStarted();
expect(abortEntry.startedAtMs).toBe(startedAtMs);
expect(abortEntry.expiresAtMs).toBe(queueExpiresAtMs);
expect(abortEntry.controller.signal.aborted).toBe(false);
nowMs = queueExpiresAtMs - 1;
executionStarted();
expect(abortEntry.expiresAtMs).toBe(queueExpiresAtMs);
} finally {
dateNow.mockRestore();
}
});
it("sets the maintenance expiry to the configured agent timeout, not the 24h chat default", async () => {
prime();
const pending = new Promise(() => {});
mocks.agentCommand.mockReturnValueOnce(pending);
mocks.loadConfigReturn = {
agents: { defaults: { timeoutSeconds: 48 * 60 * 60 } },
};
const context = makeContext();
const runId = "idem-abort-expires";
const before = Date.now();
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
mocks.loadConfigReturn = {};
const entry = context.chatAbortControllers.get(runId);
const abortEntry = requireValue(entry, "chat abort entry missing");
// 48h configured timeout must not be silently truncated to the 24h
// chat.send default cap baked into resolveChatRunExpiresAtMs. Assert
// at least 25h to leave headroom above the 24h cap; the expected
// value is ~48h.
const TWENTY_FIVE_HOURS_MS = 25 * 60 * 60 * 1_000;
expect(abortEntry.expiresAtMs - before).toBeGreaterThan(TWENTY_FIVE_HOURS_MS);
});
it("chat.abort by runId aborts the agent run's signal and removes the entry", async () => {
prime();
const pending = new Promise(() => {});
let capturedSignal: AbortSignal | undefined;
mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return pending;
});
const context = makeContext();
const runId = "idem-abort-run";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
expect(context.chatAbortControllers.has(runId)).toBe(true);
expect(capturedSignal?.aborted).toBe(false);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expect(mockCallArg(abortRespond)).toBe(true);
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expect(capturedSignal?.aborted).toBe(true);
expect(context.chatAbortControllers.has(runId)).toBe(false);
});
it.each([
{ name: "announcing", expectsCompletionMessage: true, collect: false },
{ name: "nonannouncing", expectsCompletionMessage: false, collect: false },
{ name: "unspecified completion", expectsCompletionMessage: undefined, collect: false },
{ name: "collector", expectsCompletionMessage: false, collect: true },
{
name: "collector partial persistence error",
expectsCompletionMessage: false,
collect: true,
releaseOnParent: true,
partialFailure: true,
},
{
name: "collector session cascade partial persistence error",
expectsCompletionMessage: false,
collect: true,
releaseOnParent: true,
partialFailure: true,
cascade: true,
},
{
name: "collector parent signal",
expectsCompletionMessage: false,
collect: true,
releaseOnParent: true,
},
])(
"chat.abort by runId kills only registered children of its non-admin owner: $name",
async ({ expectsCompletionMessage, collect, releaseOnParent, partialFailure, cascade }) => {
prime();
applyGatewaySubagentRegistryTestDeps({
persistSubagentRunsToDisk: () => {},
persistSubagentRunsToDiskOrThrow: () => {},
callGateway: async () => await new Promise(() => {}),
});
const pending = new Promise(() => {});
let capturedSignal: AbortSignal | undefined;
mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return pending;
});
const context = makeContext();
const client = {
...operatorWriteCliClient(["operator.read", "operator.write"]),
connId: "owner-conn",
};
const runId = "idem-abort-owned-subagents";
const ownedChildSessionKey = "agent:main:subagent:owned-by-aborted-turn";
const queuedChildSessionKey = "agent:main:subagent:queued-by-aborted-turn";
const unrelatedChildSessionKey = "agent:main:subagent:owned-by-other-turn";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId, client },
);
for (const [childSessionKey, requesterTurnRunId, queued] of [
[ownedChildSessionKey, runId, false],
...(collect ? [[queuedChildSessionKey, runId, true] as const] : []),
[unrelatedChildSessionKey, "other-parent-turn", false],
] as const) {
registerSubagentRun({
runId: childSessionKey,
childSessionKey,
controllerSessionKey:
cascade && requesterTurnRunId !== runId ? "agent:main:other" : "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterAgentId: "main",
requesterTurnRunId,
task: requesterTurnRunId,
cleanup: "keep",
expectsCompletionMessage,
collect,
queued,
});
}
const queuedDispatch = vi.fn(async () => {});
const unselectedDispatch = vi.fn(async () => {});
let childOperation: ReturnType<typeof createReplyOperation> | undefined;
if (collect) {
const sessionId = "active-collector-session";
await replaceSessionEntry(
{
storePath: resolveSessionStorePathCore(undefined, { agentId: "main" }),
sessionKey: ownedChildSessionKey,
},
{ sessionId, updatedAt: Date.now() },
);
childOperation = createReplyOperation({
sessionKey: ownedChildSessionKey,
sessionId,
resetTriggered: false,
});
childOperation.attachBackend({
kind: "embedded",
isStreaming: () => true,
cancel: () => {
if (!releaseOnParent) {
expect(releaseSwarmRun(ownedChildSessionKey)).toBe(true);
}
},
});
if (releaseOnParent) {
capturedSignal?.addEventListener(
"abort",
() => {
expect(releaseSwarmRun(ownedChildSessionKey)).toBe(true);
},
{ once: true },
);
}
enqueueSwarmRun({
groupId: "abort-owned-collectors",
runId: queuedChildSessionKey,
maxConcurrent: 1,
activeRunIds: [ownedChildSessionKey],
start: queuedDispatch,
onStartFailure: () => true,
});
enqueueSwarmRun({
groupId: "abort-owned-collectors",
runId: unrelatedChildSessionKey,
maxConcurrent: 1,
activeRunIds: [],
start: unselectedDispatch,
onStartFailure: () => true,
});
}
try {
const foreignRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: foreignRespond as never,
context,
req: { type: "req", id: "foreign-abort-req", method: "chat.abort" },
client: { ...client, connId: "foreign-conn" },
isWebchatConnect: () => false,
});
expect(mockCallArg(foreignRespond)).toBe(false);
expect(capturedSignal?.aborted).toBe(false);
expect(getSubagentRunByChildSessionKey(ownedChildSessionKey)?.execution.status).toBe(
"running",
);
if (collect) {
expect(getSubagentRunByChildSessionKey(queuedChildSessionKey)?.execution.status).toBe(
"queued",
);
expect(queuedDispatch).not.toHaveBeenCalled();
}
const abortRespond = vi.fn();
if (partialFailure) {
context.chatRunState.getOrCreate(runId).buffer = "partial";
mocks.loadSessionEntry.mockImplementationOnce(() => {
throw new Error("partial persistence failed");
});
}
const abort = handleChatAbortRequestWithLifecycle(
{
params: { sessionKey: "agent:main:main", ...(cascade ? {} : { runId }) },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client,
isWebchatConnect: () => false,
},
cascade ? { cascadeDescendants: true } : {},
);
if (partialFailure) {
await expect(abort).rejects.toThrow("partial persistence failed");
} else {
await abort;
expect(mockCallArg(abortRespond)).toBe(true);
expectRecordFields(mockCallArg(abortRespond, 0, 1), { aborted: true, runIds: [runId] });
}
expect(capturedSignal?.aborted).toBe(true);
expect(getSubagentRunByChildSessionKey(ownedChildSessionKey)).toMatchObject({
endedReason: "subagent-killed",
killReconciliation: { suppressTaskDelivery: true },
});
expect(
getSubagentRunByChildSessionKey(unrelatedChildSessionKey)?.execution.endedAt,
).toBeUndefined();
if (collect) {
expect(getSubagentRunByChildSessionKey(queuedChildSessionKey)).toMatchObject({
execution: { status: "terminal" },
endedReason: "subagent-killed",
});
expect(childOperation?.abortSignal.aborted).toBe(true);
await Promise.resolve();
expect(queuedDispatch).not.toHaveBeenCalled();
await waitForAssertion(() => expect(unselectedDispatch).toHaveBeenCalledOnce());
}
} finally {
childOperation?.complete();
swarmSchedulerTesting.reset();
}
},
);
it("starts the next abort fixture without previously registered child runs", () => {
expect(getSubagentRunByChildSessionKey("agent:main:subagent:owned-by-other-turn")).toBeNull();
});
it("chat.abort by runId allows the owner connection to use a stale session key", async () => {
prime();
const pending = new Promise(() => {});
let capturedSignal: AbortSignal | undefined;
mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return pending;
});
const context = makeContext();
const runId = "idem-abort-stale-session-key";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{
context,
reqId: runId,
client: { ...operatorWriteCliClient(), connId: "owner-conn" },
},
);
const active = requireValue(context.chatAbortControllers.get(runId), "active run missing");
context.chatAbortControllers.set(runId, {
...active,
sessionKey: "agent:main:canonical",
});
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: { ...operatorWriteCliClient(), connId: "owner-conn" },
isWebchatConnect: () => false,
});
expect(mockCallArg(abortRespond)).toBe(true);
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expect(capturedSignal?.aborted).toBe(true);
expect(context.chatAbortControllers.has(runId)).toBe(false);
});
it("keeps the sessions.abort wait snapshot after late agent completion", async () => {
prime();
let capturedSignal: AbortSignal | undefined;
let resolveRun:
| ((value: { payloads: Array<{ text: string }>; meta: { durationMs: number } }) => void)
| undefined;
mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return new Promise((resolve) => {
resolveRun = resolve;
});
});
const context = makeContext();
const runId = "idem-abort-snapshot-wins";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main", runId },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expect(capturedSignal?.aborted).toBe(true);
setGatewayDedupeEntry({
dedupe: context.dedupe,
key: `agent:${runId}`,
entry: {
ts: 100,
ok: true,
payload: {
runId,
status: "timeout",
stopReason: "rpc",
endedAt: 100,
},
},
});
resolveRun?.({ payloads: [{ text: "late ok" }], meta: { durationMs: 1 } });
await waitForAssertion(() => {
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "timeout",
stopReason: "rpc",
endedAt: 100,
});
});
});
it("chat.abort without runId aborts the active agent run for the sessionKey", async () => {
prime();
let capturedSignal: AbortSignal | undefined;
mocks.agentCommand.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return new Promise(() => {});
});
const context = makeContext();
const runId = "idem-abort-session";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
const abortRespond = vi.fn();
await handleChatAbortRequest({
params: { sessionKey: "agent:main:main" },
respond: abortRespond as never,
context,
req: { type: "req", id: "abort-req", method: "chat.abort" },
client: null,
isWebchatConnect: () => false,
});
expect(mockCallArg(abortRespond)).toBe(true);
expectRecordFields(mockCallArg(abortRespond, 0, 1), {
aborted: true,
runIds: [runId],
});
expect(capturedSignal?.aborted).toBe(true);
});
it("removes the chatAbortControllers entry after the run completes successfully", async () => {
prime();
mocks.agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
});
const context = makeContext();
const runId = "idem-abort-cleanup-ok";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
await waitForAssertion(() => {
expect(context.chatAbortControllers.has(runId)).toBe(false);
});
});
it("retains agent RPC registration until terminal persistence settles", async () => {
prime();
let resolveAgent: (value: {
payloads: Array<{ text: string }>;
meta: { durationMs: number };
}) => void = () => undefined;
const agentResult = new Promise<{
payloads: Array<{ text: string }>;
meta: { durationMs: number };
}>((resolve) => {
resolveAgent = resolve;
});
mocks.agentCommand.mockReturnValueOnce(agentResult);
const context = makeContext();
const runId = "idem-abort-cleanup-persisting";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
const entry = requireValue(context.chatAbortControllers.get(runId), "chat abort entry missing");
let resolvePersistence: () => void = () => undefined;
entry.projectSessionTerminalPersistence = new Promise<void>((resolve) => {
resolvePersistence = resolve;
});
resolveAgent({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } });
await waitForAssertion(() => {
expect(context.chatAbortControllers.has(runId)).toBe(true);
});
resolvePersistence();
await waitForAssertion(() => {
expect(context.chatAbortControllers.has(runId)).toBe(false);
});
});
it("removes the chatAbortControllers entry after the run errors", async () => {
prime();
mocks.clearAgentRunContext.mockClear();
mocks.agentCommand.mockRejectedValueOnce(new Error("boom"));
const context = makeContext();
const runId = "idem-abort-cleanup-err";
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId },
);
await waitForAssertion(() => {
expect(context.chatAbortControllers.has(runId)).toBe(false);
expect(mocks.clearAgentRunContext).toHaveBeenCalledWith(runId, "test-generation");
});
});
it("removes the chatAbortControllers entry if pre-dispatch reactivation fails", async () => {
prime("reactivation-session");
mocks.getLatestSubagentRunByChildSessionKey.mockReturnValueOnce(
createSubagentRunRecord({
runId: "previous-run",
childSessionKey: "agent:main:main",
execution: { status: "terminal", endedAt: 3 },
}),
);
mocks.replaceSubagentRunAfterSteer.mockImplementationOnce(() => {
throw new Error("reactivate boom");
});
const context = makeContext();
const runId = "idem-abort-reactivation-fails";
const respond = vi.fn();
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId, respond },
);
expect(context.chatAbortControllers.has(runId)).toBe(false);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expectReactivationFailure(respond, runId);
});
it("restores admitted restart recovery if pre-dispatch reactivation fails", async () => {
const sessionKey = "agent:main:main";
const sessionId = "recovery-session";
const runId = "recovery-reactivation-fails";
const storePath = "/tmp/sessions.json";
const store: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: Date.now() - 10_000,
status: "running",
abortedLastRun: true,
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 1,
chargedAttempts: 1,
reservation: {
runId,
attempt: 1,
lifecycleGeneration: "test-generation",
},
},
},
};
mocks.loadSessionEntry.mockImplementation(() => ({
cfg: {},
storePath,
entry: structuredClone(store[sessionKey]),
canonicalKey: sessionKey,
}));
mocks.updateSessionStore.mockImplementation(async (_path, updater) => await updater(store));
mocks.getLatestSubagentRunByChildSessionKey.mockReturnValueOnce(
createSubagentRunRecord({
runId: "previous-run",
childSessionKey: sessionKey,
execution: { status: "terminal", endedAt: 3 },
}),
);
mocks.replaceSubagentRunAfterSteer.mockImplementationOnce(() => {
throw new Error("reactivate boom");
});
const respond = vi.fn();
await invokeAgent(
{
message: "resume after restart",
agentId: "main",
sessionKey,
sessionId,
expectedExistingSessionId: sessionId,
idempotencyKey: runId,
inputProvenance: {
kind: "internal_system",
sourceSessionKey: sessionKey,
sourceTool: "main_session_restart_recovery",
},
},
{ client: backendGatewayClient(), reqId: runId, respond },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(store[sessionKey]).toMatchObject({
sessionId,
status: "running",
abortedLastRun: true,
mainRestartRecovery: {
chargedAttempts: 1,
},
});
expect(store[sessionKey]?.mainRestartRecovery?.reservation).toBeUndefined();
expectReactivationFailure(respond, runId);
});
it("releases a foreground recovery owner if pre-dispatch reactivation fails", async () => {
const sessionKey = "agent:main:main";
const sessionId = "interrupted-session";
const runId = "foreground-reactivation-fails";
const storePath = "/tmp/sessions.json";
const store: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: Date.now() - 10_000,
status: "running",
abortedLastRun: true,
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 1,
chargedAttempts: 1,
},
},
};
mocks.loadSessionEntry.mockImplementation(() => ({
cfg: {},
storePath,
entry: structuredClone(store[sessionKey]),
canonicalKey: sessionKey,
}));
mocks.updateSessionStore.mockImplementation(async (_path, updater) => await updater(store));
mocks.getLatestSubagentRunByChildSessionKey.mockReturnValueOnce(
createSubagentRunRecord({
runId: "previous-run",
childSessionKey: sessionKey,
execution: { status: "terminal", endedAt: 3 },
}),
);
mocks.replaceSubagentRunAfterSteer.mockImplementationOnce(() => {
throw new Error("reactivate boom");
});
const respond = await invokeAgent(
{
message: "new foreground turn",
agentId: "main",
sessionKey,
sessionId,
idempotencyKey: runId,
},
{ client: backendGatewayClient(), reqId: runId },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(store[sessionKey]?.mainRestartRecovery?.foregroundClaims).toBeUndefined();
expectReactivationFailure(respond, runId);
});
it("releases gateway admission when foreground owner cleanup exhausts retries", async () => {
const sessionKey = "agent:main:main";
const sessionId = "interrupted-session";
const runId = "foreground-release-fails";
const storePath = "/tmp/sessions.json";
const store: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: Date.now() - 10_000,
status: "running",
abortedLastRun: true,
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 1,
chargedAttempts: 1,
},
},
};
mocks.loadSessionEntry.mockImplementation(() => ({
cfg: {},
storePath,
entry: structuredClone(store[sessionKey]),
canonicalKey: sessionKey,
}));
mocks.updateSessionStore.mockImplementation(async (_path, updater) => await updater(store));
mocks.applySessionEntryReplacements.mockRejectedValue(new Error("owner release write failed"));
await expect(
invokeAgent(
{
message: "new foreground turn",
agentId: "main",
sessionKey,
sessionId,
deliver: true,
replyChannel: "telegram",
bestEffortDeliver: false,
idempotencyKey: runId,
},
{
client: backendGatewayClient(),
reqId: runId,
respond: vi.fn(),
flushDispatch: false,
},
),
).rejects.toThrow("owner release write failed");
await expect(
runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
signal: AbortSignal.timeout(100),
run: async () => "released",
}),
).resolves.toBe("released");
});
it("does not dispatch a duplicate agent run when dedupe was evicted but the run is active", async () => {
prime();
mocks.agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
});
const context = makeContext();
const runId = "idem-abort-collision";
const preExisting = {
controller: new AbortController(),
sessionId: "chat-send-session",
sessionKey: "agent:main:main",
startedAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
ownerConnId: "chat-send-conn",
ownerDeviceId: undefined,
};
context.chatAbortControllers.set(runId, preExisting);
context.dedupe.delete(`agent:${runId}`);
mocks.registerAgentRunContext.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId, respond },
);
expect(context.chatAbortControllers.get(runId)).toBe(preExisting);
expect(context.dedupe.has(`agent:${runId}`)).toBe(false);
expect(context.addChatRun).not.toHaveBeenCalled();
expect(mocks.registerAgentRunContext).not.toHaveBeenCalled();
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, { runId, status: "in_flight" }, undefined, {
cached: true,
runId,
});
});
it("does not dispatch a duplicate sessionless run while its reservation is active", async () => {
prime();
let finishRun!: (result: {
payloads: Array<{ text: string }>;
meta: { durationMs: number };
}) => void;
mocks.agentCommand.mockReturnValueOnce(
new Promise((resolve) => {
finishRun = resolve;
}),
);
const context = makeContext();
const runId = "idem-sessionless-active-collision";
const request = {
message: "hi",
agentId: "main",
sessionId: "sessionless-existing-session",
idempotencyKey: runId,
};
await invokeAgent(request, { context, reqId: runId });
expect(context.chatAbortControllers.has(runId)).toBe(false);
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
const duplicateRespond = vi.fn();
await invokeAgent(request, {
context,
reqId: `${runId}-duplicate`,
respond: duplicateRespond,
});
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
expect(duplicateRespond).toHaveBeenCalledWith(
true,
{ runId, status: "in_flight", agentId: "main" },
undefined,
{
cached: true,
runId,
},
);
finishRun({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } });
});
it("keeps a sessionless run from replacing an active projected run", async () => {
prime();
const context = makeContext();
const runId = "idem-sessionless-projected-collision";
const preExisting = {
controller: new AbortController(),
sessionId: "chat-send-session",
sessionKey: "agent:main:main",
startedAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
};
context.chatAbortControllers.set(runId, preExisting);
const respond = vi.fn();
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionId: "sessionless-existing-session",
idempotencyKey: runId,
},
{ context, reqId: runId, respond },
);
expect(context.chatAbortControllers.get(runId)).toBe(preExisting);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, { runId, status: "in_flight" }, undefined, {
cached: true,
runId,
});
});
it("returns in_flight instead of replaying cached accepted agent replies", async () => {
prime();
const dispatchGate = createDeferred();
const dispatchYield = vi
.spyOn(agentHandlerHelpers, "yieldAfterAgentAcceptedAck")
.mockReturnValue(dispatchGate.promise);
mocks.agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
});
const context = makeContext();
const runId = "idem-cached-accepted";
try {
await invokeAgent(
{
message: "hi",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: runId, flushDispatch: false },
);
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
runId,
status: "accepted",
sessionKey: "agent:main:main",
});
const duplicateRespond = vi.fn();
await invokeAgent(
{
message: "hi again",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: runId,
},
{ context, reqId: `${runId}-duplicate`, respond: duplicateRespond },
);
expect(mocks.agentCommand.mock.calls.length).toBe(0);
expect(duplicateRespond).toHaveBeenCalledWith(
true,
{ runId, status: "in_flight", sessionKey: "agent:main:main", agentId: "main" },
undefined,
{ cached: true, runId },
);
} finally {
dispatchGate.resolve();
try {
await waitForAssertion(() => expect(context.chatAbortControllers.has(runId)).toBe(false));
} finally {
dispatchYield.mockRestore();
}
}
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */