mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:21:36 +00:00
test(agents): dedupe agent and cron test scaffolds
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user