test(agents): dedupe agent and cron test scaffolds

This commit is contained in:
Peter Steinberger
2026-03-02 06:40:42 +00:00
parent 281494ae52
commit 7e29d604ba
38 changed files with 3114 additions and 4486 deletions

View File

@@ -124,6 +124,43 @@ describe("abort detection", () => {
});
}
function enqueueQueuedFollowupRun(params: {
root: string;
cfg: OpenClawConfig;
sessionId: string;
sessionKey: string;
}) {
const followupRun: FollowupRun = {
prompt: "queued",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: path.join(params.root, "agent"),
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageProvider: "telegram",
agentAccountId: "acct",
sessionFile: path.join(params.root, "session.jsonl"),
workspaceDir: path.join(params.root, "workspace"),
config: params.cfg,
provider: "anthropic",
model: "claude-opus-4-5",
timeoutMs: 1000,
blockReplyBreak: "text_end",
},
};
enqueueFollowupRun(
params.sessionKey,
followupRun,
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
"none",
);
}
function expectSessionLaneCleared(sessionKey: string) {
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
}
afterEach(() => {
resetAbortMemoryForTest();
acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" });
@@ -338,31 +375,7 @@ describe("abort detection", () => {
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [sessionKey]: sessionId },
});
const followupRun: FollowupRun = {
prompt: "queued",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: path.join(root, "agent"),
sessionId,
sessionKey,
messageProvider: "telegram",
agentAccountId: "acct",
sessionFile: path.join(root, "session.jsonl"),
workspaceDir: path.join(root, "workspace"),
config: cfg,
provider: "anthropic",
model: "claude-opus-4-5",
timeoutMs: 1000,
blockReplyBreak: "text_end",
},
};
enqueueFollowupRun(
sessionKey,
followupRun,
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
"none",
);
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
const result = await runStopCommand({
@@ -374,7 +387,7 @@ describe("abort detection", () => {
expect(result.handled).toBe(true);
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
expectSessionLaneCleared(sessionKey);
});
it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
@@ -411,31 +424,7 @@ describe("abort detection", () => {
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [sessionKey]: sessionId },
});
const followupRun: FollowupRun = {
prompt: "queued",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: path.join(root, "agent"),
sessionId,
sessionKey,
messageProvider: "telegram",
agentAccountId: "acct",
sessionFile: path.join(root, "session.jsonl"),
workspaceDir: path.join(root, "workspace"),
config: cfg,
provider: "anthropic",
model: "claude-opus-4-5",
timeoutMs: 1000,
blockReplyBreak: "text_end",
},
};
enqueueFollowupRun(
sessionKey,
followupRun,
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
"none",
);
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
acpManagerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
@@ -453,7 +442,7 @@ describe("abort detection", () => {
expect(result.handled).toBe(true);
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
expectSessionLaneCleared(sessionKey);
});
it("persists abort cutoff metadata on /stop when command and target session match", async () => {
@@ -546,7 +535,7 @@ describe("abort detection", () => {
});
expect(result.stoppedSubagents).toBe(1);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${childKey}`);
expectSessionLaneCleared(childKey);
});
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
@@ -601,8 +590,8 @@ describe("abort detection", () => {
// Should stop both depth-1 and depth-2 agents (cascade)
expect(result.stoppedSubagents).toBe(2);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth1Key}`);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
expectSessionLaneCleared(depth1Key);
expectSessionLaneCleared(depth2Key);
});
it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
@@ -660,7 +649,7 @@ describe("abort detection", () => {
// Should skip killing the ended depth-1 run itself, but still kill depth-2.
expect(result.stoppedSubagents).toBe(1);
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
expectSessionLaneCleared(depth2Key);
expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-2", childSessionKey: depth2Key }),
);

View File

@@ -3,17 +3,39 @@ import { prefixSystemMessage } from "../../infra/system-message.js";
import { createAcpReplyProjector } from "./acp-projector.js";
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
type Delivery = { kind: string; text?: string };
function createProjectorHarness(cfgOverrides?: Parameters<typeof createCfg>[0]) {
const deliveries: Delivery[] = [];
const projector = createAcpReplyProjector({
cfg: createCfg(cfgOverrides),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
return { deliveries, projector };
}
function blockDeliveries(deliveries: Delivery[]) {
return deliveries.filter((entry) => entry.kind === "block");
}
function combinedBlockText(deliveries: Delivery[]) {
return blockDeliveries(deliveries)
.map((entry) => entry.text ?? "")
.join("");
}
function expectToolCallSummary(delivery: Delivery | undefined) {
expect(delivery?.kind).toBe("tool");
expect(delivery?.text).toContain("Tool Call");
}
describe("createAcpReplyProjector", () => {
it("coalesces text deltas into bounded block chunks", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
const { deliveries, projector } = createProjectorHarness();
await projector.onEvent({
type: "text_delta",
@@ -29,22 +51,14 @@ describe("createAcpReplyProjector", () => {
});
it("does not suppress identical short text across terminal turn boundaries", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 0,
maxChunkChars: 64,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 0,
maxChunkChars: 64,
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -53,7 +67,7 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
await projector.onEvent({ type: "done", stopReason: "end_turn" });
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "A" },
{ kind: "block", text: "A" },
]);
@@ -62,22 +76,14 @@ describe("createAcpReplyProjector", () => {
it("flushes staggered live text deltas after idle gaps", async () => {
vi.useFakeTimers();
try {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 50,
maxChunkChars: 64,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 50,
maxChunkChars: 64,
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -93,7 +99,7 @@ describe("createAcpReplyProjector", () => {
await vi.advanceTimersByTimeAsync(760);
await projector.flush(false);
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "A" },
{ kind: "block", text: "B" },
{ kind: "block", text: "C" },
@@ -104,22 +110,14 @@ describe("createAcpReplyProjector", () => {
});
it("splits oversized live text by maxChunkChars", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 0,
maxChunkChars: 50,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 0,
maxChunkChars: 50,
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -127,7 +125,7 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
await projector.flush(true);
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "a".repeat(50) },
{ kind: "block", text: "b".repeat(50) },
{ kind: "block", text: "c".repeat(20) },
@@ -137,22 +135,14 @@ describe("createAcpReplyProjector", () => {
it("does not flush short live fragments mid-phrase on idle", async () => {
vi.useFakeTimers();
try {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 100,
maxChunkChars: 256,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 100,
maxChunkChars: 256,
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -184,26 +174,18 @@ describe("createAcpReplyProjector", () => {
});
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 512,
deliveryMode: "final_only",
tagVisibility: {
available_commands_update: true,
tool_call: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 512,
deliveryMode: "final_only",
tagVisibility: {
available_commands_update: true,
tool_call: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -238,32 +220,23 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated (7)"),
});
expect(deliveries[1]?.kind).toBe("tool");
expect(deliveries[1]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[1]);
expect(deliveries[2]).toEqual({ kind: "block", text: "What now?" });
});
it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 512,
deliveryMode: "final_only",
tagVisibility: {
available_commands_update: true,
tool_call: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 512,
deliveryMode: "final_only",
tagVisibility: {
available_commands_update: true,
tool_call: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -288,20 +261,11 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated (7)"),
});
expect(deliveries[1]?.kind).toBe("tool");
expect(deliveries[1]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[1]);
});
it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
const hidden: Array<{ kind: string; text?: string }> = [];
const hiddenProjector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
hidden.push({ kind, text: payload.text });
return true;
},
});
const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness();
await hiddenProjector.onEvent({
type: "status",
text: "usage updated: 10/100",
@@ -311,25 +275,17 @@ describe("createAcpReplyProjector", () => {
});
expect(hidden).toEqual([]);
const shown: Array<{ kind: string; text?: string }> = [];
const shownProjector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 64,
deliveryMode: "live",
tagVisibility: {
usage_update: true,
},
const { deliveries: shown, projector: shownProjector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 64,
deliveryMode: "live",
tagVisibility: {
usage_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
shown.push({ kind, text: payload.text });
return true;
},
});
@@ -362,15 +318,7 @@ describe("createAcpReplyProjector", () => {
});
it("hides available_commands_update by default", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
const { deliveries, projector } = createProjectorHarness();
await projector.onEvent({
type: "status",
text: "available commands updated (7)",
@@ -381,24 +329,16 @@ describe("createAcpReplyProjector", () => {
});
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -436,32 +376,22 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(2);
expect(deliveries[0]?.kind).toBe("tool");
expect(deliveries[0]?.text).toContain("Tool Call");
expect(deliveries[1]?.kind).toBe("tool");
expect(deliveries[1]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[0]);
expectToolCallSummary(deliveries[1]);
});
it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
maxSessionUpdateChars: 48,
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
maxSessionUpdateChars: 48,
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -485,29 +415,21 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(2);
expect(deliveries[0]?.kind).toBe("tool");
expect(deliveries[1]?.kind).toBe("tool");
expectToolCallSummary(deliveries[0]);
expectToolCallSummary(deliveries[1]);
});
it("renders fallback tool labels without leaking call ids as primary label", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -519,33 +441,25 @@ describe("createAcpReplyProjector", () => {
text: "call_ABC123 (in_progress)",
});
expect(deliveries[0]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[0]);
expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
});
it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
repeatSuppression: false,
tagVisibility: {
available_commands_update: true,
tool_call: true,
tool_call_update: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
repeatSuppression: false,
tagVisibility: {
available_commands_update: true,
tool_call: true,
tool_call_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -589,31 +503,23 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated"),
});
expect(deliveries[2]?.text).toContain("Tool Call");
expect(deliveries[3]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[2]);
expectToolCallSummary(deliveries[3]);
expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
});
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
available_commands_update: true,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
available_commands_update: true,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -640,23 +546,15 @@ describe("createAcpReplyProjector", () => {
});
it("truncates oversized turns once and emits one truncation notice", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
maxOutputChars: 5,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
maxOutputChars: 5,
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -681,26 +579,18 @@ describe("createAcpReplyProjector", () => {
});
it("supports tagVisibility overrides for tool updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: false,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
tool_call: true,
tool_call_update: false,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -722,26 +612,18 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(1);
expect(deliveries[0]?.text).toContain("Tool Call");
expectToolCallSummary(deliveries[0]);
});
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -757,34 +639,22 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback. I don't");
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
tool_call: false,
tool_call_update: false,
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
tool_call: false,
tool_call_update: false,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -808,31 +678,19 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback. I don't");
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("supports hiddenBoundarySeparator=space", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
hiddenBoundarySeparator: "space",
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
hiddenBoundarySeparator: "space",
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -848,31 +706,19 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback. I don't");
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("supports hiddenBoundarySeparator=none", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
hiddenBoundarySeparator: "none",
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
hiddenBoundarySeparator: "none",
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -888,30 +734,18 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback.I don't");
expect(combinedBlockText(deliveries)).toBe("fallback.I don't");
});
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -931,30 +765,18 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback.\nI don't");
expect(combinedBlockText(deliveries)).toBe("fallback.\nI don't");
});
it("does not insert boundary separator for hidden non-tool status updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
const { deliveries, projector } = createProjectorHarness({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
@@ -967,10 +789,6 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "B", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("AB");
expect(combinedBlockText(deliveries)).toBe("AB");
});
});

View File

@@ -52,6 +52,22 @@ const hoisted = vi.hoisted(() => {
};
});
function createAcpCommandSessionBindingService() {
const forward =
<A extends unknown[], T>(fn: (...args: A) => T) =>
(...args: A) =>
fn(...args);
return {
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: forward((params: unknown) => hoisted.sessionBindingCapabilitiesMock(params)),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
};
}
vi.mock("../../gateway/call.js", () => ({
callGateway: (args: unknown) => hoisted.callGatewayMock(args),
}));
@@ -79,18 +95,11 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
}),
const patched = { ...actual } as typeof actual & {
getSessionBindingService: () => ReturnType<typeof createAcpCommandSessionBindingService>;
};
patched.getSessionBindingService = () => createAcpCommandSessionBindingService();
return patched;
});
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
@@ -172,6 +181,128 @@ function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
return params;
}
const defaultAcpSessionKey = "agent:codex:acp:s1";
const defaultThreadId = "thread-1";
type AcpSessionIdentity = {
state: "resolved";
source: "status";
acpxSessionId: string;
agentSessionId: string;
lastUpdatedAt: number;
};
function createThreadConversation(conversationId: string = defaultThreadId) {
return {
channel: "discord" as const,
accountId: "default",
conversationId,
parentConversationId: "parent-1",
};
}
function createBoundThreadSession(sessionKey: string = defaultAcpSessionKey) {
return createSessionBinding({
targetSessionKey: sessionKey,
conversation: createThreadConversation(),
});
}
function createAcpSessionEntry(options?: {
sessionKey?: string;
state?: "idle" | "running";
identity?: AcpSessionIdentity;
}) {
const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
return {
sessionKey,
storeSessionKey: sessionKey,
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
...(options?.identity ? { identity: options.identity } : {}),
mode: "persistent",
state: options?.state ?? "idle",
lastActivityAt: Date.now(),
},
};
}
function createSessionBindingCapabilities() {
return {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
};
}
type AcpBindInput = {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
placement: "current" | "child";
metadata?: Record<string, unknown>;
};
function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
const nextConversationId =
input.placement === "child" ? "thread-created" : input.conversation.conversationId;
const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: "parent-1",
},
metadata: { boundBy, webhookId: "wh-1" },
});
}
function expectBoundIntroTextToExclude(match: string): void {
const calls = hoisted.sessionBindingBindMock.mock.calls as Array<
[{ metadata?: { introText?: unknown } }]
>;
const introText = calls
.map((call) => call[0]?.metadata?.introText)
.find((value): value is string => typeof value === "string");
expect((introText ?? "").includes(match)).toBe(false);
}
function mockBoundThreadSession(options?: {
sessionKey?: string;
state?: "idle" | "running";
identity?: AcpSessionIdentity;
}) {
const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createBoundThreadSession(sessionKey),
);
hoisted.readAcpSessionEntryMock.mockReturnValue(
createAcpSessionEntry({
sessionKey,
state: options?.state,
identity: options?.identity,
}),
);
}
function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = createDiscordParams(commandBody, cfg);
params.ctx.MessageThreadId = defaultThreadId;
return params;
}
async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createDiscordParams(commandBody, cfg), true);
}
async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createThreadParams(commandBody, cfg), true);
}
describe("/acp command", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
@@ -195,37 +326,12 @@ describe("/acp command", () => {
storePath: "/tmp/sessions-acp.json",
});
hoisted.loadSessionStoreMock.mockReset().mockReturnValue({});
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingCapabilitiesMock
.mockReset()
.mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingBindMock
.mockReset()
.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
placement: "current" | "child";
metadata?: Record<string, unknown>;
}) =>
createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId:
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
parentConversationId: "parent-1",
},
metadata: {
boundBy:
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
webhookId: "wh-1",
},
}),
);
.mockImplementation(async (input: AcpBindInput) => createAcpThreadBinding(input));
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
@@ -275,14 +381,12 @@ describe("/acp command", () => {
});
it("returns null when the message is not /acp", async () => {
const params = createDiscordParams("/status");
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/status");
expect(result).toBeNull();
});
it("shows help by default", async () => {
const params = createDiscordParams("/acp");
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp");
expect(result?.reply?.text).toContain("ACP commands:");
expect(result?.reply?.text).toContain("/acp spawn");
});
@@ -296,8 +400,7 @@ describe("/acp command", () => {
backendSessionId: "acpx-1",
});
const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd");
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp spawn codex --cwd /home/bob/clawd");
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
@@ -318,15 +421,7 @@ describe("/acp command", () => {
}),
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.not.stringContaining(
"session ids: pending (available after the first reply)",
),
}),
}),
);
expectBoundIntroTextToExclude("session ids: pending (available after the first reply)");
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.patch",
@@ -352,8 +447,7 @@ describe("/acp command", () => {
});
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
const params = createDiscordParams("/acp spawn");
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp spawn");
expect(result?.reply?.text).toContain("ACP target agent is required");
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
@@ -372,8 +466,7 @@ describe("/acp command", () => {
},
} satisfies OpenClawConfig;
const params = createDiscordParams("/acp spawn codex", cfg);
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
@@ -393,38 +486,14 @@ describe("/acp command", () => {
});
it("cancels the ACP session bound to the current thread", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
mockBoundThreadSession({ state: "running" });
const result = await runThreadAcpCommand("/acp cancel", baseCfg);
expect(result?.reply?.text).toContain(
`Cancel requested for ACP session ${defaultAcpSessionKey}`,
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "running",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp cancel", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1");
expect(hoisted.cancelMock).toHaveBeenCalledWith({
handle: expect.objectContaining({
sessionKey: "agent:codex:acp:s1",
sessionKey: defaultAcpSessionKey,
backend: "acpx",
}),
reason: "manual-cancel",
@@ -434,29 +503,19 @@ describe("/acp command", () => {
it("sends steer instructions via ACP runtime", async () => {
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "sessions.resolve") {
return { key: "agent:codex:acp:s1" };
return { key: defaultAcpSessionKey };
}
return { ok: true };
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry());
hoisted.runTurnMock.mockImplementation(async function* () {
yield { type: "text_delta", text: "Applied steering." };
yield { type: "done" };
});
const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging");
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand(
`/acp steer --session ${defaultAcpSessionKey} tighten logging`,
);
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -475,57 +534,23 @@ describe("/acp command", () => {
dispatch: { enabled: false },
},
} satisfies OpenClawConfig;
const params = createDiscordParams("/acp steer tighten logging", cfg);
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp steer tighten logging", cfg);
expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy");
expect(hoisted.runTurnMock).not.toHaveBeenCalled();
});
it("closes an ACP session, unbinds thread targets, and clears metadata", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
mockBoundThreadSession();
hoisted.sessionBindingUnbindMock.mockResolvedValue([
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}) as SessionBindingRecord,
createBoundThreadSession() as SessionBindingRecord,
]);
const params = createDiscordParams("/acp close", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
const result = await runThreadAcpCommand("/acp close", baseCfg);
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetSessionKey: "agent:codex:acp:s1",
targetSessionKey: defaultAcpSessionKey,
reason: "manual",
}),
);
@@ -535,22 +560,10 @@ describe("/acp command", () => {
it("lists ACP sessions from the session store", async () => {
hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) =>
key === "agent:codex:acp:s1"
? [
createSessionBinding({
targetSessionKey: key,
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}) as SessionBindingRecord,
]
: [],
key === defaultAcpSessionKey ? [createBoundThreadSession(key) as SessionBindingRecord] : [],
);
hoisted.loadSessionStoreMock.mockReturnValue({
"agent:codex:acp:s1": {
[defaultAcpSessionKey]: {
sessionId: "sess-1",
updatedAt: Date.now(),
label: "codex-main",
@@ -569,52 +582,27 @@ describe("/acp command", () => {
},
});
const params = createDiscordParams("/acp sessions", baseCfg);
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp sessions", baseCfg);
expect(result?.reply?.text).toContain("ACP sessions:");
expect(result?.reply?.text).toContain("codex-main");
expect(result?.reply?.text).toContain("thread:thread-1");
expect(result?.reply?.text).toContain(`thread:${defaultThreadId}`);
});
it("shows ACP status for the thread-bound ACP session", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
acpxSessionId: "acpx-sid-1",
agentSessionId: "codex-sid-1",
lastUpdatedAt: Date.now(),
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
mockBoundThreadSession({
identity: {
state: "resolved",
source: "status",
acpxSessionId: "acpx-sid-1",
agentSessionId: "codex-sid-1",
lastUpdatedAt: Date.now(),
},
});
const params = createDiscordParams("/acp status", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
const result = await runThreadAcpCommand("/acp status", baseCfg);
expect(result?.reply?.text).toContain("ACP status:");
expect(result?.reply?.text).toContain("session: agent:codex:acp:s1");
expect(result?.reply?.text).toContain(`session: ${defaultAcpSessionKey}`);
expect(result?.reply?.text).toContain("agent session id: codex-sid-1");
expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1");
expect(result?.reply?.text).toContain("capabilities:");
@@ -622,33 +610,8 @@ describe("/acp command", () => {
});
it("updates ACP runtime mode via /acp set-mode", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp set-mode plan", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
mockBoundThreadSession();
const result = await runThreadAcpCommand("/acp set-mode plan", baseCfg);
expect(hoisted.setModeMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -659,33 +622,9 @@ describe("/acp command", () => {
});
it("updates ACP config options and keeps cwd local when using /acp set", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
mockBoundThreadSession();
const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg);
setModelParams.ctx.MessageThreadId = "thread-1";
const setModel = await handleAcpCommand(setModelParams, true);
const setModel = await runThreadAcpCommand("/acp set model gpt-5.3-codex", baseCfg);
expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "model",
@@ -695,74 +634,24 @@ describe("/acp command", () => {
expect(setModel?.reply?.text).toContain("Updated ACP config option");
hoisted.setConfigOptionMock.mockClear();
const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg);
setCwdParams.ctx.MessageThreadId = "thread-1";
const setCwd = await handleAcpCommand(setCwdParams, true);
const setCwd = await runThreadAcpCommand("/acp set cwd /tmp/worktree", baseCfg);
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
expect(setCwd?.reply?.text).toContain("Updated ACP cwd");
});
it("rejects non-absolute cwd values via ACP runtime option validation", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
mockBoundThreadSession();
const params = createDiscordParams("/acp cwd relative/path", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
const result = await runThreadAcpCommand("/acp cwd relative/path", baseCfg);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(result?.reply?.text).toContain("absolute path");
});
it("rejects invalid timeout values before backend config writes", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
mockBoundThreadSession();
const params = createDiscordParams("/acp timeout 10s", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
const result = await runThreadAcpCommand("/acp timeout 10s", baseCfg);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
@@ -777,8 +666,7 @@ describe("/acp command", () => {
);
});
const params = createDiscordParams("/acp doctor", baseCfg);
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp doctor", baseCfg);
expect(result?.reply?.text).toContain("ACP doctor:");
expect(result?.reply?.text).toContain("healthy: no");
@@ -786,8 +674,7 @@ describe("/acp command", () => {
});
it("shows deterministic install instructions via /acp install", async () => {
const params = createDiscordParams("/acp install", baseCfg);
const result = await handleAcpCommand(params, true);
const result = await runDiscordAcpCommand("/acp install", baseCfg);
expect(result?.reply?.text).toContain("ACP install:");
expect(result?.reply?.text).toContain("run:");

View File

@@ -30,6 +30,28 @@ const hoisted = vi.hoisted(() => {
};
});
function buildFocusSessionBindingService() {
const service = {
touch: vi.fn(),
listBySession(targetSessionKey: string) {
return hoisted.sessionBindingListBySessionMock(targetSessionKey);
},
resolveByConversation(ref: unknown) {
return hoisted.sessionBindingResolveByConversationMock(ref);
},
getCapabilities(params: unknown) {
return hoisted.sessionBindingCapabilitiesMock(params);
},
bind(input: unknown) {
return hoisted.sessionBindingBindMock(input);
},
unbind(input: unknown) {
return hoisted.sessionBindingUnbindMock(input);
},
};
return service;
}
vi.mock("../../gateway/call.js", () => ({
callGateway: hoisted.callGatewayMock,
}));
@@ -56,15 +78,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
}),
getSessionBindingService: () => buildFocusSessionBindingService(),
};
});
@@ -217,13 +231,33 @@ function createSessionBindingRecord(
};
}
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
hoisted.sessionBindingCapabilitiesMock.mockReturnValue({
function createSessionBindingCapabilities() {
return {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
placements: ["current", "child"] as const,
};
}
async function runUnfocusAndExpectManualUnbind(initialBindings: FakeBinding[]) {
const fake = createFakeThreadBindingManager(initialBindings);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
}
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null);
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
@@ -256,6 +290,12 @@ async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindin
return { result };
}
async function runAgentsCommandAndText(): Promise<string> {
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
return result?.reply?.text ?? "";
}
describe("/focus, /unfocus, /agents", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
@@ -263,12 +303,9 @@ describe("/focus, /unfocus, /agents", () => {
hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null);
hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex");
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingCapabilitiesMock
.mockReset()
.mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
@@ -340,23 +377,11 @@ describe("/focus, /unfocus, /agents", () => {
});
it("/unfocus removes an active thread binding for the binding owner", async () => {
const fake = createFakeThreadBindingManager([createStoredBinding()]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
await runUnfocusAndExpectManualUnbind([createStoredBinding()]);
});
it("/unfocus also unbinds ACP-focused thread bindings", async () => {
const fake = createFakeThreadBindingManager([
await runUnfocusAndExpectManualUnbind([
createStoredBinding({
targetKind: "acp",
targetSessionKey: "agent:codex:acp:session-1",
@@ -364,18 +389,6 @@ describe("/focus, /unfocus, /agents", () => {
label: "codex-session",
}),
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
@@ -428,9 +441,7 @@ describe("/focus, /unfocus, /agents", () => {
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
const text = result?.reply?.text ?? "";
const text = await runAgentsCommandAndText();
expect(text).toContain("agents:");
expect(text).toContain("thread:thread-1");
@@ -464,9 +475,7 @@ describe("/focus, /unfocus, /agents", () => {
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
const text = result?.reply?.text ?? "";
const text = await runAgentsCommandAndText();
expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
});

View File

@@ -26,21 +26,25 @@ function createDispatcher(): ReplyDispatcher {
};
}
function createCoordinator(onReplyStart?: (...args: unknown[]) => Promise<void>) {
return createAcpDispatchDeliveryCoordinator({
cfg: createAcpTestConfig(),
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
}),
dispatcher: createDispatcher(),
inboundAudio: false,
shouldRouteToOriginating: false,
...(onReplyStart ? { onReplyStart } : {}),
});
}
describe("createAcpDispatchDeliveryCoordinator", () => {
it("starts reply lifecycle only once when called directly and through deliver", async () => {
const onReplyStart = vi.fn(async () => {});
const coordinator = createAcpDispatchDeliveryCoordinator({
cfg: createAcpTestConfig(),
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
}),
dispatcher: createDispatcher(),
inboundAudio: false,
shouldRouteToOriginating: false,
onReplyStart,
});
const coordinator = createCoordinator(onReplyStart);
await coordinator.startReplyLifecycle();
await coordinator.deliver("final", { text: "hello" });
@@ -52,18 +56,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
it("starts reply lifecycle once when deliver triggers first", async () => {
const onReplyStart = vi.fn(async () => {});
const coordinator = createAcpDispatchDeliveryCoordinator({
cfg: createAcpTestConfig(),
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
}),
dispatcher: createDispatcher(),
inboundAudio: false,
shouldRouteToOriginating: false,
onReplyStart,
});
const coordinator = createCoordinator(onReplyStart);
await coordinator.deliver("final", { text: "hello" });
await coordinator.startReplyLifecycle();
@@ -73,18 +66,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
it("does not start reply lifecycle for empty payload delivery", async () => {
const onReplyStart = vi.fn(async () => {});
const coordinator = createAcpDispatchDeliveryCoordinator({
cfg: createAcpTestConfig(),
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
}),
dispatcher: createDispatcher(),
inboundAudio: false,
shouldRouteToOriginating: false,
onReplyStart,
});
const coordinator = createCoordinator(onReplyStart);
await coordinator.deliver("final", {});

View File

@@ -85,6 +85,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
}));
const { tryDispatchAcpReply } = await import("./dispatch-acp.js");
const sessionKey = "agent:codex-acp:session-1";
function createDispatcher(): {
dispatcher: ReplyDispatcher;
@@ -105,7 +106,7 @@ function createDispatcher(): {
function setReadyAcpResolution() {
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey: "agent:codex-acp:session-1",
sessionKey,
meta: createAcpSessionMeta(),
});
}
@@ -124,6 +125,84 @@ function createAcpConfigWithVisibleToolTags(): OpenClawConfig {
});
}
async function runDispatch(params: {
bodyForAgent: string;
cfg?: OpenClawConfig;
dispatcher?: ReplyDispatcher;
shouldRouteToOriginating?: boolean;
onReplyStart?: () => void;
}) {
return tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: sessionKey,
BodyForAgent: params.bodyForAgent,
}),
cfg: params.cfg ?? createAcpTestConfig(),
dispatcher: params.dispatcher ?? createDispatcher().dispatcher,
sessionKey,
inboundAudio: false,
shouldRouteToOriginating: params.shouldRouteToOriginating ?? false,
...(params.shouldRouteToOriginating
? { originatingChannel: "telegram", originatingTo: "telegram:thread-1" }
: {}),
shouldSendToolSummaries: true,
bypassForCommand: false,
...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}),
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
}
async function emitToolLifecycleEvents(
onEvent: (event: unknown) => Promise<void>,
toolCallId: string,
) {
await onEvent({
type: "tool_call",
tag: "tool_call",
toolCallId,
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
});
await onEvent({
type: "tool_call",
tag: "tool_call_update",
toolCallId,
status: "completed",
title: "Run command",
text: "Run command (completed)",
});
await onEvent({ type: "done" });
}
function mockToolLifecycleTurn(toolCallId: string) {
managerMocks.runTurn.mockImplementation(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await emitToolLifecycleEvents(onEvent, toolCallId);
},
);
}
function mockVisibleTextTurn(text = "visible") {
managerMocks.runTurn.mockImplementationOnce(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
await onEvent({ type: "done" });
},
);
}
async function dispatchVisibleTurn(onReplyStart: () => void) {
await runDispatch({
bodyForAgent: "visible",
dispatcher: createDispatcher().dispatcher,
onReplyStart,
});
}
describe("tryDispatchAcpReply", () => {
beforeEach(() => {
managerMocks.resolveSession.mockReset();
@@ -160,24 +239,10 @@ describe("tryDispatchAcpReply", () => {
);
const { dispatcher } = createDispatcher();
const result = await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "reply",
}),
cfg: createAcpTestConfig(),
const result = await runDispatch({
bodyForAgent: "reply",
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: true,
originatingChannel: "telegram",
originatingTo: "telegram:thread-1",
shouldSendToolSummaries: true,
bypassForCommand: false,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(result?.counts.block).toBe(1);
@@ -192,48 +257,15 @@ describe("tryDispatchAcpReply", () => {
it("edits ACP tool lifecycle updates in place when supported", async () => {
setReadyAcpResolution();
managerMocks.runTurn.mockImplementation(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await onEvent({
type: "tool_call",
tag: "tool_call",
toolCallId: "call-1",
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
});
await onEvent({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call-1",
status: "completed",
title: "Run command",
text: "Run command (completed)",
});
await onEvent({ type: "done" });
},
);
mockToolLifecycleTurn("call-1");
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
const { dispatcher } = createDispatcher();
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "run tool",
}),
await runDispatch({
bodyForAgent: "run tool",
cfg: createAcpConfigWithVisibleToolTags(),
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: true,
originatingChannel: "telegram",
originatingTo: "telegram:thread-1",
shouldSendToolSummaries: true,
bypassForCommand: false,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
@@ -249,51 +281,18 @@ describe("tryDispatchAcpReply", () => {
it("falls back to new tool message when edit fails", async () => {
setReadyAcpResolution();
managerMocks.runTurn.mockImplementation(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await onEvent({
type: "tool_call",
tag: "tool_call",
toolCallId: "call-2",
status: "in_progress",
title: "Run command",
text: "Run command (in_progress)",
});
await onEvent({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call-2",
status: "completed",
title: "Run command",
text: "Run command (completed)",
});
await onEvent({ type: "done" });
},
);
mockToolLifecycleTurn("call-2");
routeMocks.routeReply
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
const { dispatcher } = createDispatcher();
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "run tool",
}),
await runDispatch({
bodyForAgent: "run tool",
cfg: createAcpConfigWithVisibleToolTags(),
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: true,
originatingChannel: "telegram",
originatingTo: "telegram:thread-1",
shouldSendToolSummaries: true,
bypassForCommand: false,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1);
@@ -317,50 +316,15 @@ describe("tryDispatchAcpReply", () => {
await onEvent({ type: "done" });
},
);
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "hidden",
}),
cfg: createAcpTestConfig(),
await runDispatch({
bodyForAgent: "hidden",
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
bypassForCommand: false,
onReplyStart,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(onReplyStart).toHaveBeenCalledTimes(1);
managerMocks.runTurn.mockImplementationOnce(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
await onEvent({ type: "done" });
},
);
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "visible",
}),
cfg: createAcpTestConfig(),
dispatcher: createDispatcher().dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
bypassForCommand: false,
onReplyStart,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
mockVisibleTextTurn();
await dispatchVisibleTurn(onReplyStart);
expect(onReplyStart).toHaveBeenCalledTimes(2);
});
@@ -368,31 +332,8 @@ describe("tryDispatchAcpReply", () => {
setReadyAcpResolution();
const onReplyStart = vi.fn();
managerMocks.runTurn.mockImplementationOnce(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
await onEvent({ type: "done" });
},
);
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "visible",
}),
cfg: createAcpTestConfig(),
dispatcher: createDispatcher().dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
bypassForCommand: false,
onReplyStart,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
mockVisibleTextTurn();
await dispatchVisibleTurn(onReplyStart);
expect(onReplyStart).toHaveBeenCalledTimes(1);
});
@@ -402,23 +343,10 @@ describe("tryDispatchAcpReply", () => {
const onReplyStart = vi.fn();
const { dispatcher } = createDispatcher();
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: " ",
}),
cfg: createAcpTestConfig(),
await runDispatch({
bodyForAgent: " ",
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
bypassForCommand: false,
onReplyStart,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(managerMocks.runTurn).not.toHaveBeenCalled();
@@ -432,22 +360,9 @@ describe("tryDispatchAcpReply", () => {
);
const { dispatcher } = createDispatcher();
await tryDispatchAcpReply({
ctx: buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "test",
}),
cfg: createAcpTestConfig(),
await runDispatch({
bodyForAgent: "test",
dispatcher,
sessionKey: "agent:codex-acp:session-1",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
bypassForCommand: false,
recordProcessed: vi.fn(),
markIdle: vi.fn(),
});
expect(managerMocks.runTurn).not.toHaveBeenCalled();

View File

@@ -113,6 +113,10 @@ function mockCompactionRun(params: {
);
}
function createAsyncReplySpy() {
return vi.fn(async () => {});
}
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
@@ -181,92 +185,97 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
}
it("drops payloads already sent via messaging tool", async () => {
const onBlockReply = vi.fn(async () => {});
async function runMessagingCase(params: {
agentResult: Record<string, unknown>;
queued?: FollowupRun;
runnerOverrides?: Partial<{
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath: string;
}>;
}) {
const onBlockReply = createAsyncReplySpy();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["hello world!"],
meta: {},
...params.agentResult,
});
const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides);
await runner(params.queued ?? baseQueuedRun());
return { onBlockReply };
}
const runner = createMessagingDedupeRunner(onBlockReply);
function makeTextReplyDedupeResult(overrides?: Record<string, unknown>) {
return {
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
...overrides,
};
}
await runner(baseQueuedRun());
it("drops payloads already sent via messaging tool", async () => {
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["hello world!"],
},
});
expect(onBlockReply).not.toHaveBeenCalled();
});
it("delivers payloads when not duplicates", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: makeTextReplyDedupeResult(),
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner(baseQueuedRun());
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
it("suppresses replies when a messaging tool sent via the same provider + target", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: {
...makeTextReplyDedupeResult(),
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
},
queued: baseQueuedRun("slack"),
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner(baseQueuedRun("slack"));
expect(onBlockReply).not.toHaveBeenCalled();
});
it("suppresses replies when provider is synthetic but originating channel matches", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: {
...makeTextReplyDedupeResult(),
messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
},
queued: {
...baseQueuedRun("heartbeat"),
originatingChannel: "telegram",
originatingTo: "268300329",
} as FollowupRun,
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner({
...baseQueuedRun("heartbeat"),
originatingChannel: "telegram",
originatingTo: "268300329",
} as FollowupRun);
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not suppress replies for same target when account differs", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: {
...makeTextReplyDedupeResult(),
messagingToolSentTargets: [
{ tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
],
},
queued: {
...baseQueuedRun("heartbeat"),
originatingChannel: "telegram",
originatingTo: "268300329",
originatingAccountId: "personal",
} as FollowupRun,
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner({
...baseQueuedRun("heartbeat"),
originatingChannel: "telegram",
originatingTo: "268300329",
originatingAccountId: "personal",
} as FollowupRun);
expect(routeReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
@@ -278,33 +287,25 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("drops media URL from payload when messaging tool already sent it", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ mediaUrl: "/tmp/img.png" }],
messagingToolSentMediaUrls: ["/tmp/img.png"],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ mediaUrl: "/tmp/img.png" }],
messagingToolSentMediaUrls: ["/tmp/img.png"],
},
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner(baseQueuedRun());
// Media stripped → payload becomes non-renderable → not delivered.
expect(onBlockReply).not.toHaveBeenCalled();
});
it("delivers media payload when not a duplicate", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ mediaUrl: "/tmp/img.png" }],
messagingToolSentMediaUrls: ["/tmp/other.png"],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ mediaUrl: "/tmp/img.png" }],
messagingToolSentMediaUrls: ["/tmp/other.png"],
},
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner(baseQueuedRun());
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
@@ -318,30 +319,28 @@ describe("createFollowupRunner messaging tool dedupe", () => {
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await saveSessionStore(storePath, sessionStore);
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {
agentMeta: {
usage: { input: 1_000, output: 50 },
lastCallUsage: { input: 400, output: 20 },
model: "claude-opus-4-5",
provider: "anthropic",
const { onBlockReply } = await runMessagingCase({
agentResult: {
...makeTextReplyDedupeResult(),
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {
agentMeta: {
usage: { input: 1_000, output: 50 },
lastCallUsage: { input: 400, output: 20 },
model: "claude-opus-4-5",
provider: "anthropic",
},
},
},
runnerOverrides: {
sessionEntry,
sessionStore,
sessionKey,
storePath,
},
queued: baseQueuedRun("slack"),
});
const runner = createMessagingDedupeRunner(onBlockReply, {
sessionEntry,
sessionStore,
sessionKey,
storePath,
});
await runner(baseQueuedRun("slack"));
expect(onBlockReply).not.toHaveBeenCalled();
const store = loadSessionStore(storePath, { skipCache: true });
// totalTokens should reflect the last call usage snapshot, not the accumulated input.
@@ -353,46 +352,36 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("does not fall back to dispatcher when cross-channel origin routing fails", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
meta: {},
});
routeReplyMock.mockResolvedValueOnce({
ok: false,
error: "forced route failure",
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner({
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
} as FollowupRun);
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "hello world!" }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
} as FollowupRun,
});
expect(routeReplyMock).toHaveBeenCalled();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("falls back to dispatcher when same-channel origin routing fails", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
meta: {},
});
routeReplyMock.mockResolvedValueOnce({
ok: false,
error: "outbound adapter unavailable",
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner({
...baseQueuedRun(" Feishu "),
originatingChannel: "FEISHU",
originatingTo: "ou_abc123",
} as FollowupRun);
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "hello world!" }] },
queued: {
...baseQueuedRun(" Feishu "),
originatingChannel: "FEISHU",
originatingTo: "ou_abc123",
} as FollowupRun,
});
expect(routeReplyMock).toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalledTimes(1);
@@ -400,22 +389,17 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("routes followups with originating account/thread metadata", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
meta: {},
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: "hello world!" }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
originatingAccountId: "work",
originatingThreadId: "1739142736.000100",
} as FollowupRun,
});
const runner = createMessagingDedupeRunner(onBlockReply);
await runner({
...baseQueuedRun("webchat"),
originatingChannel: "discord",
originatingTo: "channel:C1",
originatingAccountId: "work",
originatingThreadId: "1739142736.000100",
} as FollowupRun);
expect(routeReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
@@ -429,44 +413,37 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
describe("createFollowupRunner typing cleanup", () => {
it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
async function runTypingCase(agentResult: Record<string, unknown>) {
const typing = createMockTypingController();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "NO_REPLY" }],
meta: {},
...agentResult,
});
const runner = createFollowupRunner({
opts: { onBlockReply: vi.fn(async () => {}) },
opts: { onBlockReply: createAsyncReplySpy() },
typing,
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-5",
});
await runner(baseQueuedRun());
return typing;
}
function expectTypingCleanup(typing: ReturnType<typeof createMockTypingController>) {
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
}
it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] });
expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => {
const typing = createMockTypingController();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [],
meta: {},
});
const runner = createFollowupRunner({
opts: { onBlockReply: vi.fn(async () => {}) },
typing,
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-5",
});
await runner(baseQueuedRun());
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
const typing = await runTypingCase({ payloads: [] });
expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on agent error", async () => {
@@ -482,8 +459,7 @@ describe("createFollowupRunner typing cleanup", () => {
await runner(baseQueuedRun());
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => {
@@ -504,8 +480,7 @@ describe("createFollowupRunner typing cleanup", () => {
await runner(baseQueuedRun());
expect(onBlockReply).toHaveBeenCalled();
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
expectTypingCleanup(typing);
});
});

View File

@@ -105,6 +105,56 @@ function buildNativeResetContext(): MsgContext {
};
}
function createContinueDirectivesResult(resetHookTriggered: boolean) {
return {
kind: "continue" as const,
result: {
commandSource: "/new",
command: {
surface: "telegram",
channel: "telegram",
channelId: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "123",
abortKey: "telegram:slash:123",
rawBodyNormalized: "/new",
commandBodyNormalized: "/new",
from: "telegram:123",
to: "slash:123",
resetHookTriggered,
},
allowTextCommands: true,
skillCommands: [],
directives: {},
cleanedBody: "/new",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
execOverrides: undefined,
blockStreamingEnabled: false,
blockReplyChunking: undefined,
resolvedBlockStreamingBreak: undefined,
provider: "openai",
model: "gpt-4o-mini",
modelState: {
resolveDefaultThinkingLevel: async () => undefined,
},
contextTokens: 0,
inlineStatusRequested: false,
directiveAck: undefined,
perMessageQueueMode: undefined,
perMessageQueueOptions: undefined,
},
};
}
describe("getReplyFromConfig reset-hook fallback", () => {
beforeEach(() => {
mocks.resolveReplyDirectives.mockReset();
@@ -131,53 +181,7 @@ describe("getReplyFromConfig reset-hook fallback", () => {
bodyStripped: "",
});
mocks.resolveReplyDirectives.mockResolvedValue({
kind: "continue",
result: {
commandSource: "/new",
command: {
surface: "telegram",
channel: "telegram",
channelId: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "123",
abortKey: "telegram:slash:123",
rawBodyNormalized: "/new",
commandBodyNormalized: "/new",
from: "telegram:123",
to: "slash:123",
resetHookTriggered: false,
},
allowTextCommands: true,
skillCommands: [],
directives: {},
cleanedBody: "/new",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
execOverrides: undefined,
blockStreamingEnabled: false,
blockReplyChunking: undefined,
resolvedBlockStreamingBreak: undefined,
provider: "openai",
model: "gpt-4o-mini",
modelState: {
resolveDefaultThinkingLevel: async () => undefined,
},
contextTokens: 0,
inlineStatusRequested: false,
directiveAck: undefined,
perMessageQueueMode: undefined,
perMessageQueueOptions: undefined,
},
});
mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(false));
});
it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => {
@@ -196,53 +200,7 @@ describe("getReplyFromConfig reset-hook fallback", () => {
it("does not emit fallback hooks when resetHookTriggered is already set", async () => {
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined });
mocks.resolveReplyDirectives.mockResolvedValue({
kind: "continue",
result: {
commandSource: "/new",
command: {
surface: "telegram",
channel: "telegram",
channelId: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "123",
abortKey: "telegram:slash:123",
rawBodyNormalized: "/new",
commandBodyNormalized: "/new",
from: "telegram:123",
to: "slash:123",
resetHookTriggered: true,
},
allowTextCommands: true,
skillCommands: [],
directives: {},
cleanedBody: "/new",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
execOverrides: undefined,
blockStreamingEnabled: false,
blockReplyChunking: undefined,
resolvedBlockStreamingBreak: undefined,
provider: "openai",
model: "gpt-4o-mini",
modelState: {
resolveDefaultThinkingLevel: async () => undefined,
},
contextTokens: 0,
inlineStatusRequested: false,
directiveAck: undefined,
perMessageQueueMode: undefined,
perMessageQueueOptions: undefined,
},
});
mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(true));
await getReplyFromConfig(buildNativeResetContext(), undefined, {});