mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test: consolidate auto-reply fixtures (#118086)
This commit is contained in:
committed by
GitHub
parent
184c7a00fa
commit
1f7e2f0dc6
@@ -495,7 +495,6 @@ src/agents/tools/web-fetch.ts
|
||||
src/agents/transcript-redact.test.ts
|
||||
src/agents/workspace.ts
|
||||
src/agents/worktrees/service.ts
|
||||
src/auto-reply/command-control.test.ts
|
||||
src/auto-reply/inbound.test.ts
|
||||
src/auto-reply/reply/abort.test.ts
|
||||
src/auto-reply/reply/agent-runner-memory.test.ts
|
||||
@@ -504,7 +503,6 @@ src/auto-reply/reply/agent-runner-payloads.test.ts
|
||||
src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
|
||||
src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
|
||||
src/auto-reply/reply/commands-acp.test.ts
|
||||
src/auto-reply/reply/commands-approve.test.ts
|
||||
src/auto-reply/reply/commands-models.ts
|
||||
src/auto-reply/reply/commands-status.test.ts
|
||||
src/auto-reply/reply/directive-handling.model.test.ts
|
||||
|
||||
@@ -158,37 +158,37 @@ describe("resolveCommandAuthorization", () => {
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("uses explicit owner allowlist when allowFrom is wildcard", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "uses explicit owner allowlist when allowFrom is wildcard",
|
||||
channelConfig: { allowFrom: ["*"] },
|
||||
},
|
||||
{
|
||||
name: "uses explicit owner allowlist when allowFrom is empty",
|
||||
channelConfig: {},
|
||||
},
|
||||
])("$name", ({ channelConfig }) => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["whatsapp:+15551234567"] },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
channels: { whatsapp: channelConfig },
|
||||
} as OpenClawConfig;
|
||||
const resolveSender = (senderId: string) =>
|
||||
resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: `whatsapp:${senderId}`,
|
||||
SenderE164: senderId,
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const ownerCtx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+15551234567",
|
||||
SenderE164: "+15551234567",
|
||||
} as MsgContext;
|
||||
const ownerAuth = resolveCommandAuthorization({
|
||||
ctx: ownerCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const ownerAuth = resolveSender("+15551234567");
|
||||
expect(ownerAuth.senderIsOwner).toBe(true);
|
||||
expect(ownerAuth.isAuthorizedSender).toBe(true);
|
||||
|
||||
const otherCtx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+19995551234",
|
||||
SenderE164: "+19995551234",
|
||||
} as MsgContext;
|
||||
const otherAuth = resolveCommandAuthorization({
|
||||
ctx: otherCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const otherAuth = resolveSender("+19995551234");
|
||||
expect(otherAuth.senderIsOwner).toBe(false);
|
||||
expect(otherAuth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
@@ -278,39 +278,6 @@ describe("resolveCommandAuthorization", () => {
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
|
||||
it("uses explicit owner allowlist when allowFrom is empty", () => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["whatsapp:+15551234567"] },
|
||||
channels: { whatsapp: {} },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const ownerAuth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+15551234567",
|
||||
SenderE164: "+15551234567",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(ownerAuth.senderIsOwner).toBe(true);
|
||||
expect(ownerAuth.isAuthorizedSender).toBe(true);
|
||||
|
||||
const otherAuth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+19995551234",
|
||||
SenderE164: "+19995551234",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(otherAuth.senderIsOwner).toBe(false);
|
||||
expect(otherAuth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
|
||||
it("uses context owner candidates for command authorization without granting owner status", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
@@ -389,86 +356,52 @@ describe("resolveCommandAuthorization", () => {
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply channel-prefixed owner wildcards to webchat command contexts", () => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["discord:*"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "does not apply channel-prefixed owner wildcards to webchat command contexts",
|
||||
owner: "discord:*",
|
||||
provider: "webchat",
|
||||
expectedProvider: undefined,
|
||||
expectedOwner: false,
|
||||
},
|
||||
{
|
||||
name: "does not apply channel-prefixed owner identities to webchat command contexts",
|
||||
owner: "discord:123456789012345678",
|
||||
provider: "webchat",
|
||||
expectedProvider: undefined,
|
||||
expectedOwner: false,
|
||||
},
|
||||
{
|
||||
name: "applies channel-prefixed owner identities to matching providers",
|
||||
owner: "discord:123456789012345678",
|
||||
provider: "discord",
|
||||
expectedProvider: "discord",
|
||||
expectedOwner: true,
|
||||
},
|
||||
{
|
||||
name: "does not apply channel-prefixed owner wildcards to mismatched providers",
|
||||
owner: "telegram:*",
|
||||
provider: "discord",
|
||||
expectedProvider: "discord",
|
||||
expectedOwner: false,
|
||||
},
|
||||
] as const)("$name", ({ owner, provider, expectedProvider, expectedOwner }) => {
|
||||
const webchat = provider === "webchat";
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
OriginatingChannel: "webchat",
|
||||
Provider: provider,
|
||||
Surface: provider,
|
||||
...(webchat
|
||||
? { OriginatingChannel: "webchat", GatewayClientScopes: ["operator.write"] }
|
||||
: { From: "discord:123456789012345678" }),
|
||||
SenderId: "123456789012345678",
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
cfg: { commands: { ownerAllowFrom: [owner] } } as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBeUndefined();
|
||||
expect(auth.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("does not apply channel-prefixed owner identities to webchat command contexts", () => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["discord:123456789012345678"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
OriginatingChannel: "webchat",
|
||||
SenderId: "123456789012345678",
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBeUndefined();
|
||||
expect(auth.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("applies channel-prefixed owner identities to matching providers", () => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["discord:123456789012345678"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
From: "discord:123456789012345678",
|
||||
SenderId: "123456789012345678",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBe("discord");
|
||||
expect(auth.senderIsOwner).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply channel-prefixed owner wildcards to mismatched providers", () => {
|
||||
const cfg = {
|
||||
commands: { ownerAllowFrom: ["telegram:*"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
From: "discord:123456789012345678",
|
||||
SenderId: "123456789012345678",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBe("discord");
|
||||
expect(auth.senderIsOwner).toBe(false);
|
||||
expect(auth.providerId).toBe(expectedProvider);
|
||||
expect(auth.senderIsOwner).toBe(expectedOwner);
|
||||
});
|
||||
|
||||
it("preserves external channel command auth in mixed webchat contexts", () => {
|
||||
@@ -721,78 +654,56 @@ describe("resolveCommandAuthorization", () => {
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat conversation ids in From as sender identities", () => {
|
||||
const cfg = {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
discord: ["channel:123456789012345678"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
it.each([
|
||||
{
|
||||
name: "does not treat conversation ids in From as sender identities",
|
||||
provider: "discord",
|
||||
chatType: "channel",
|
||||
from: "discord:channel:123456789012345678",
|
||||
senderId: "999999999999999999",
|
||||
senderE164: undefined,
|
||||
allowFrom: { discord: ["channel:123456789012345678"] },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "still falls back to From for direct messages when sender fields are absent",
|
||||
provider: "discord",
|
||||
chatType: "direct",
|
||||
from: "discord:123456789012345678",
|
||||
senderId: " ",
|
||||
senderE164: " ",
|
||||
allowFrom: { discord: ["123456789012345678"] },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "does not fall back to conversation-shaped From when chat type is missing",
|
||||
provider: "whatsapp",
|
||||
chatType: undefined,
|
||||
from: "demo:group:room-1",
|
||||
senderId: " ",
|
||||
senderE164: " ",
|
||||
allowFrom: { "*": ["demo:group:room-1"] },
|
||||
expected: false,
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
({ provider, chatType, from, senderId, senderE164, allowFrom, expected }) => {
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: provider,
|
||||
Surface: provider,
|
||||
ChatType: chatType,
|
||||
From: from,
|
||||
SenderId: senderId,
|
||||
SenderE164: senderE164,
|
||||
} as MsgContext,
|
||||
cfg: { commands: { allowFrom } } as unknown as OpenClawConfig,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "channel",
|
||||
From: "discord:channel:123456789012345678",
|
||||
SenderId: "999999999999999999",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
|
||||
it("still falls back to From for direct messages when sender fields are absent", () => {
|
||||
const cfg = {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
discord: ["123456789012345678"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "direct",
|
||||
From: "discord:123456789012345678",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fall back to conversation-shaped From when chat type is missing", () => {
|
||||
const cfg = {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
"*": ["demo:group:room-1"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "demo:group:room-1",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
expect(auth.isAuthorizedSender).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes Discord commands.allowFrom prefixes and mentions", () => {
|
||||
const cfg = {
|
||||
@@ -835,121 +746,80 @@ describe("resolveCommandAuthorization", () => {
|
||||
|
||||
expect(deniedAuth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
it("fails closed when provider inference hits unresolved SecretRef allowlists", () => {
|
||||
registerAllowFromPlugins(
|
||||
createThrowingAllowFromPlugin(
|
||||
"telegram",
|
||||
"channels.telegram.botToken: unresolved SecretRef",
|
||||
),
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["123"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
SenderId: "123",
|
||||
} as MsgContext,
|
||||
cfg,
|
||||
it.each([
|
||||
{
|
||||
name: "fails closed when provider inference hits unresolved SecretRef allowlists",
|
||||
failingProvider: "telegram",
|
||||
allowKey: "telegram",
|
||||
channelMode: "configured",
|
||||
validTelegram: false,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBe("telegram");
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves provider resolution errors when inferred fallback allowFrom is empty", () => {
|
||||
registerAllowFromPlugins(
|
||||
createThrowingAllowFromPlugin(
|
||||
"telegram",
|
||||
"channels.telegram.botToken: unresolved SecretRef",
|
||||
),
|
||||
);
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
SenderId: "123",
|
||||
} as MsgContext,
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["123"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedProvider: "telegram",
|
||||
expectedAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "preserves provider resolution errors when inferred fallback allowFrom is empty",
|
||||
failingProvider: "telegram",
|
||||
allowKey: "telegram",
|
||||
channelMode: "empty",
|
||||
validTelegram: false,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBeUndefined();
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed for global commands.allowFrom when inference errors drop every provider", () => {
|
||||
registerAllowFromPlugins(
|
||||
createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"),
|
||||
);
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
SenderId: "123",
|
||||
} as MsgContext,
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
"*": ["123"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
slack: {},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedProvider: undefined,
|
||||
expectedAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "fails closed for global commands.allowFrom when inference errors drop every provider",
|
||||
failingProvider: "slack",
|
||||
allowKey: "*",
|
||||
channelMode: "empty",
|
||||
validTelegram: false,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBeUndefined();
|
||||
expect(auth.isAuthorizedSender).toBe(false);
|
||||
});
|
||||
it("does not let an unrelated provider resolution error poison inferred commands.allowFrom", () => {
|
||||
registerAllowFromPlugins(
|
||||
createAllowFromPlugin("telegram", () => ["123"]),
|
||||
createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"),
|
||||
);
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: {
|
||||
SenderId: "123",
|
||||
} as MsgContext,
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["123"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedProvider: undefined,
|
||||
expectedAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "does not let an unrelated provider resolution error poison inferred commands.allowFrom",
|
||||
failingProvider: "slack",
|
||||
allowKey: "telegram",
|
||||
channelMode: "configured",
|
||||
validTelegram: true,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
expectedProvider: "telegram",
|
||||
expectedAuthorized: true,
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
({
|
||||
failingProvider,
|
||||
allowKey,
|
||||
channelMode,
|
||||
validTelegram,
|
||||
commandAuthorized,
|
||||
expectedProvider,
|
||||
expectedAuthorized,
|
||||
}) => {
|
||||
registerAllowFromPlugins(
|
||||
...(validTelegram ? [createAllowFromPlugin("telegram", () => ["123"])] : []),
|
||||
createThrowingAllowFromPlugin(
|
||||
failingProvider,
|
||||
`channels.${failingProvider}.${failingProvider === "telegram" ? "botToken" : "token"}: unresolved SecretRef`,
|
||||
),
|
||||
);
|
||||
const channelId = validTelegram ? "telegram" : failingProvider;
|
||||
const channelConfig = channelMode === "configured" ? { allowFrom: ["123"] } : {};
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx: { SenderId: "123" } as MsgContext,
|
||||
cfg: {
|
||||
commands: { allowFrom: { [allowKey]: ["123"] } },
|
||||
channels: { [channelId]: channelConfig },
|
||||
} as OpenClawConfig,
|
||||
commandAuthorized,
|
||||
});
|
||||
|
||||
expect(auth.providerId).toBe("telegram");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
expect(auth.providerId).toBe(expectedProvider);
|
||||
expect(auth.isAuthorizedSender).toBe(expectedAuthorized);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves default-account allowFrom on SecretRef fallback", () => {
|
||||
registerAllowFromPlugins(
|
||||
@@ -1034,84 +904,66 @@ describe("resolveCommandAuthorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("grants senderIsOwner for internal channel with operator.admin scope", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const ctx = {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
} as MsgContext;
|
||||
it.each([
|
||||
{
|
||||
name: "grants senderIsOwner for internal channel with operator.admin scope",
|
||||
provider: "webchat",
|
||||
scope: "operator.admin",
|
||||
expectedOwner: true,
|
||||
},
|
||||
{
|
||||
name: "does not grant senderIsOwner for internal channel without admin scope",
|
||||
provider: "webchat",
|
||||
scope: "operator.approvals",
|
||||
expectedOwner: false,
|
||||
},
|
||||
{
|
||||
name: "does not grant senderIsOwner for external channel even with admin scope",
|
||||
provider: "telegram",
|
||||
scope: "operator.admin",
|
||||
expectedOwner: false,
|
||||
},
|
||||
] as const)("$name", ({ provider, scope, expectedOwner }) => {
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
ctx: {
|
||||
Provider: provider,
|
||||
Surface: provider,
|
||||
...(provider === "telegram" ? { From: "telegram:12345" } : {}),
|
||||
GatewayClientScopes: [scope],
|
||||
} as MsgContext,
|
||||
cfg: {} as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(auth.senderIsOwner).toBe(true);
|
||||
});
|
||||
|
||||
it("does not grant senderIsOwner for internal channel without admin scope", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const ctx = {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.approvals"],
|
||||
} as MsgContext;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(auth.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("does not grant senderIsOwner for external channel even with admin scope", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const ctx = {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:12345",
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
} as MsgContext;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(auth.senderIsOwner).toBe(false);
|
||||
expect(auth.senderIsOwner).toBe(expectedOwner);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control command parsing", () => {
|
||||
function expectCases(
|
||||
parse: (value: string) => unknown,
|
||||
cases: readonly (readonly [string, unknown])[],
|
||||
) {
|
||||
cases.forEach(([value, expected]) => expect(parse(value)).toEqual(expected));
|
||||
}
|
||||
|
||||
it("requires slash for send policy", () => {
|
||||
expect(parseSendPolicyCommand("/send on")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "allow",
|
||||
});
|
||||
expect(parseSendPolicyCommand("/send: on")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "allow",
|
||||
});
|
||||
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
|
||||
expect(parseSendPolicyCommand("/send:")).toEqual({ hasCommand: true });
|
||||
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
|
||||
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
|
||||
expectCases(parseSendPolicyCommand, [
|
||||
["/send on", { hasCommand: true, mode: "allow" }],
|
||||
["/send: on", { hasCommand: true, mode: "allow" }],
|
||||
["/send", { hasCommand: true }],
|
||||
["/send:", { hasCommand: true }],
|
||||
["send on", { hasCommand: false }],
|
||||
["send", { hasCommand: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires slash for activation", () => {
|
||||
expect(parseActivationCommand("/activation mention")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "mention",
|
||||
});
|
||||
expect(parseActivationCommand("/activation: mention")).toEqual({
|
||||
hasCommand: true,
|
||||
mode: "mention",
|
||||
});
|
||||
expect(parseActivationCommand("/activation:")).toEqual({
|
||||
hasCommand: true,
|
||||
});
|
||||
expect(parseActivationCommand("activation mention")).toEqual({
|
||||
hasCommand: false,
|
||||
});
|
||||
expectCases(parseActivationCommand, [
|
||||
["/activation mention", { hasCommand: true, mode: "mention" }],
|
||||
["/activation: mention", { hasCommand: true, mode: "mention" }],
|
||||
["/activation:", { hasCommand: true }],
|
||||
["activation mention", { hasCommand: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats bare commands as non-control", () => {
|
||||
@@ -1195,40 +1047,31 @@ describe("control command parsing", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
function expectCommandAfterMetadata(label: string, json: string, command: string) {
|
||||
expect(hasControlCommand([label, "```json", json, "```", "", command].join("\n"))).toBe(true);
|
||||
}
|
||||
|
||||
it("detects commands wrapped in inbound metadata blocks", () => {
|
||||
const metaWrapped = [
|
||||
expectCommandAfterMetadata(
|
||||
markInboundContextLabel("Conversation info:"),
|
||||
"```json",
|
||||
'{"message_id":"msg-abc","chat_id":"chat-123"}',
|
||||
"```",
|
||||
"",
|
||||
"/model spark",
|
||||
].join("\n");
|
||||
expect(hasControlCommand(metaWrapped)).toBe(true);
|
||||
);
|
||||
});
|
||||
|
||||
it("detects /new command after metadata prefix", () => {
|
||||
const metaWrapped = [
|
||||
expectCommandAfterMetadata(
|
||||
markInboundContextLabel("Sender:"),
|
||||
"```json",
|
||||
'{"name":"Alice","id":"user-1"}',
|
||||
"```",
|
||||
"",
|
||||
"/new spark",
|
||||
].join("\n");
|
||||
expect(hasControlCommand(metaWrapped)).toBe(true);
|
||||
);
|
||||
});
|
||||
|
||||
it("detects /status command after timestamp + metadata prefix", () => {
|
||||
const metaWrapped = [
|
||||
expectCommandAfterMetadata(
|
||||
`[Wed 2026-03-11 23:51 PDT] ${markInboundContextLabel("Conversation info:")}`,
|
||||
"```json",
|
||||
'{"chat_id":"chat-123"}',
|
||||
"```",
|
||||
"",
|
||||
"/status",
|
||||
].join("\n");
|
||||
expect(hasControlCommand(metaWrapped)).toBe(true);
|
||||
);
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -87,6 +87,50 @@ function dispatchWithDeliveries(
|
||||
});
|
||||
}
|
||||
|
||||
type DispatcherOptions = NonNullable<Parameters<typeof dispatchWithDeliveries>[2]>;
|
||||
|
||||
async function runDelayedOlderFinalRace(
|
||||
createNewerOptions: (deliveries: Delivery[]) => DispatcherOptions = () => ({}),
|
||||
olderOptions: DispatcherOptions = {},
|
||||
) {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ ...olderOptions, beforeDeliver },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
createNewerOptions(deliveries),
|
||||
);
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
|
||||
return { beforeDeliver, deliveries, newerResult, olderResult: await olderDispatch };
|
||||
}
|
||||
|
||||
describe("foreground reply freshness", () => {
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
@@ -493,48 +537,14 @@ describe("foreground reply freshness", () => {
|
||||
});
|
||||
|
||||
it("keeps an older foreground final when a newer visible delivery fails", async () => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
{
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
() => ({
|
||||
deliver: async () => {
|
||||
throw new Error("delivery failed");
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(newerResult).toEqual({
|
||||
queuedFinal: false,
|
||||
@@ -567,49 +577,15 @@ describe("foreground reply freshness", () => {
|
||||
}),
|
||||
},
|
||||
])("suppresses an older foreground final after $label", async ({ createError }) => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
{
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
(raceDeliveries) => ({
|
||||
deliver: async (payload, info) => {
|
||||
deliveries.push({ kind: info.kind, text: payload.text });
|
||||
raceDeliveries.push({ kind: info.kind, text: payload.text });
|
||||
throw createError();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(newerResult).toEqual({
|
||||
queuedFinal: false,
|
||||
@@ -624,46 +600,12 @@ describe("foreground reply freshness", () => {
|
||||
});
|
||||
|
||||
it("keeps an older foreground final when a newer adapter reports non-visible delivery", async () => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
{
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
() => ({
|
||||
deliver: async () => ({ visibleReplySent: false }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(newerResult).toEqual({
|
||||
queuedFinal: true,
|
||||
@@ -677,47 +619,13 @@ describe("foreground reply freshness", () => {
|
||||
});
|
||||
|
||||
it("suppresses an older foreground final when a newer settled hook reports visible delivery", async () => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
{
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
() => ({
|
||||
deliver: async () => ({ visibleReplySent: false }),
|
||||
onSettled: async () => ({ visibleReplySent: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(newerResult).toEqual({
|
||||
queuedFinal: true,
|
||||
@@ -731,44 +639,12 @@ describe("foreground reply freshness", () => {
|
||||
});
|
||||
|
||||
it("still runs stale generic settled hooks after a newer visible reply", async () => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
const olderSettled = vi.fn();
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
},
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
() => ({}),
|
||||
{ onSettled: olderSettled },
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver, onSettled: olderSettled },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(olderSettled).toHaveBeenCalledTimes(1);
|
||||
expect(newerResult).toEqual({
|
||||
@@ -783,47 +659,16 @@ describe("foreground reply freshness", () => {
|
||||
});
|
||||
|
||||
it("suppresses an older fresh settled delivery after a newer visible reply", async () => {
|
||||
const deliveries: Delivery[] = [];
|
||||
const beforeDeliverStarted = createDeferred<void>();
|
||||
const releaseBeforeDeliver = createDeferred<ReplyPayload | null>();
|
||||
const beforeDeliver = vi.fn(() => {
|
||||
beforeDeliverStarted.resolve();
|
||||
return releaseBeforeDeliver.promise;
|
||||
});
|
||||
const olderFreshDelivery = vi.fn(() => {
|
||||
deliveries.push({ kind: "final", text: "old settled fallback" });
|
||||
return { visibleReplySent: true };
|
||||
});
|
||||
|
||||
hoisted.dispatchReplyFromConfigMock.mockImplementation(
|
||||
async (params: DispatchReplyFromConfigParams) => {
|
||||
if (params.ctx.MessageSid === "old-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "old final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
if (params.ctx.MessageSid === "new-message") {
|
||||
params.dispatcher.sendFinalReply({ text: "new final" });
|
||||
return queuedFinalResult();
|
||||
}
|
||||
throw new Error(`unexpected test message ${params.ctx.MessageSid ?? "<missing>"}`);
|
||||
const { beforeDeliver, deliveries, newerResult, olderResult } = await runDelayedOlderFinalRace(
|
||||
() => ({}),
|
||||
{
|
||||
onFreshSettledDelivery: olderFreshDelivery,
|
||||
},
|
||||
);
|
||||
|
||||
const olderDispatch = dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "old-message" }),
|
||||
deliveries,
|
||||
{ beforeDeliver, onFreshSettledDelivery: olderFreshDelivery },
|
||||
);
|
||||
await beforeDeliverStarted.promise;
|
||||
|
||||
const newerResult = await dispatchWithDeliveries(
|
||||
buildForegroundCtx({ MessageSid: "new-message" }),
|
||||
deliveries,
|
||||
);
|
||||
|
||||
releaseBeforeDeliver.resolve({ text: "old rewritten final" });
|
||||
const olderResult = await olderDispatch;
|
||||
|
||||
expect(beforeDeliver).toHaveBeenCalledTimes(1);
|
||||
expect(olderFreshDelivery).not.toHaveBeenCalled();
|
||||
expect(newerResult).toEqual({
|
||||
|
||||
@@ -484,83 +484,101 @@ describe("handleApproveCommand", () => {
|
||||
expect(result?.reply?.text).toContain("Usage: /approve");
|
||||
});
|
||||
|
||||
it("submits approval", async () => {
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
const result = await handleApproveCommand(
|
||||
buildApproveParams(
|
||||
"/approve abc allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
{ SenderId: "123" },
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc" });
|
||||
});
|
||||
|
||||
it("accepts bare approve text for Slack-style manual approvals", async () => {
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
const result = await handleApproveCommand(
|
||||
buildApproveParams(
|
||||
"approve abc allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: { slack: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
SenderId: "U123",
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc" });
|
||||
});
|
||||
|
||||
it("accepts Telegram /approve from configured approvers even when chat access is otherwise blocked", async () => {
|
||||
const params = buildApproveParams("/approve abc12345 allow-once", createTelegramApproveCfg(), {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
params.command.isAuthorizedSender = false;
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
|
||||
});
|
||||
|
||||
it("accepts forwarded Telegram plugin approvals from approvers when native delivery is disabled", async () => {
|
||||
const params = buildApproveParams(
|
||||
"/approve plugin:abc12345 allow-once",
|
||||
createTelegramApproveCfg({ enabled: false, approvers: ["123"], target: "dm" }),
|
||||
{
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = false;
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({
|
||||
it.each([
|
||||
{
|
||||
name: "submits approval",
|
||||
commandBody: "/approve abc allow-once",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { SenderId: "123" },
|
||||
authorized: true,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc",
|
||||
},
|
||||
{
|
||||
name: "accepts bare approve text for Slack-style manual approvals",
|
||||
commandBody: "approve abc allow-once",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { slack: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { Provider: "slack", Surface: "slack", SenderId: "U123" },
|
||||
authorized: true,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc",
|
||||
},
|
||||
{
|
||||
name: "accepts Telegram /approve from configured approvers even when chat access is otherwise blocked",
|
||||
commandBody: "/approve abc12345 allow-once",
|
||||
cfg: createTelegramApproveCfg(),
|
||||
ctx: { Provider: "telegram", Surface: "telegram", SenderId: "123" },
|
||||
authorized: false,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc12345",
|
||||
},
|
||||
{
|
||||
name: "accepts forwarded Telegram plugin approvals from approvers when native delivery is disabled",
|
||||
commandBody: "/approve plugin:abc12345 allow-once",
|
||||
cfg: createTelegramApproveCfg({ enabled: false, approvers: ["123"], target: "dm" }),
|
||||
ctx: { Provider: "telegram", Surface: "telegram", SenderId: "123" },
|
||||
authorized: false,
|
||||
method: "plugin.approval.resolve",
|
||||
id: "plugin:abc12345",
|
||||
});
|
||||
},
|
||||
{
|
||||
name: "accepts Signal /approve from configured approvers even when chat access is otherwise blocked",
|
||||
commandBody: "/approve abc12345 allow-once",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { signal: { allowFrom: ["+15551230000"] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { Provider: "signal", Surface: "signal", SenderId: "+15551230000" },
|
||||
authorized: false,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc12345",
|
||||
},
|
||||
{
|
||||
name: "keeps same-chat /approve available to authorized senders when helper approvers are empty",
|
||||
commandBody: "/approve abc12345 allow-once",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { signal: { allowFrom: [] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { Provider: "signal", Surface: "signal", SenderId: "+15551239999" },
|
||||
authorized: true,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc12345",
|
||||
},
|
||||
{
|
||||
name: "accepts Telegram /approve from exec target recipients when native approvals are disabled",
|
||||
commandBody: "/approve abc12345 allow-once",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { Provider: "telegram", Surface: "telegram", SenderId: "123" },
|
||||
authorized: false,
|
||||
method: "exec.approval.resolve",
|
||||
id: "abc12345",
|
||||
},
|
||||
] as const)("$name", async ({ commandBody, cfg, ctx, authorized, method, id }) => {
|
||||
const params = buildApproveParams(commandBody, cfg, ctx);
|
||||
params.command.isAuthorizedSender = authorized;
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method, id });
|
||||
});
|
||||
|
||||
it("honors the configured default account for omitted-account /approve auth", async () => {
|
||||
@@ -605,80 +623,49 @@ describe("handleApproveCommand", () => {
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
|
||||
});
|
||||
|
||||
it("accepts Signal /approve from configured approvers even when chat access is otherwise blocked", async () => {
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
SenderId: "+15551230000",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = false;
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
|
||||
});
|
||||
|
||||
it("does not treat implicit default approval auth as a bypass for unauthorized senders", async () => {
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
SenderId: "123",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = false;
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply).toBeUndefined();
|
||||
expect(resolveApprovalOverGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat implicit same-chat approval auth as a bypass for unauthorized senders", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
it.each([
|
||||
{
|
||||
name: "does not treat implicit default approval auth as a bypass for unauthorized senders",
|
||||
cfg: { commands: { text: true } } as OpenClawConfig,
|
||||
ctx: { Provider: "webchat", Surface: "webchat", SenderId: "123" },
|
||||
setup: undefined,
|
||||
},
|
||||
{
|
||||
name: "does not treat implicit same-chat approval auth as a bypass for unauthorized senders",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { slack: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
SenderId: "U123",
|
||||
},
|
||||
);
|
||||
ctx: { Provider: "slack", Surface: "slack", SenderId: "U123" },
|
||||
setup: () =>
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
|
||||
approvalCapability: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "does not allow empty helper approvers to bypass unauthorized sender checks",
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
channels: { signal: { allowFrom: [] } },
|
||||
} as OpenClawConfig,
|
||||
ctx: { Provider: "signal", Surface: "signal", SenderId: "+15551239999" },
|
||||
setup: undefined,
|
||||
},
|
||||
] as const)("$name", async ({ cfg, ctx, setup }) => {
|
||||
setup?.();
|
||||
const params = buildApproveParams("/approve abc12345 allow-once", cfg, ctx);
|
||||
params.command.isAuthorizedSender = false;
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
@@ -687,90 +674,6 @@ describe("handleApproveCommand", () => {
|
||||
expect(resolveApprovalOverGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow empty helper approvers to bypass unauthorized sender checks", async () => {
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
SenderId: "+15551239999",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = false;
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply).toBeUndefined();
|
||||
expect(resolveApprovalOverGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps same-chat /approve available to authorized senders when helper approvers are empty", async () => {
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
SenderId: "+15551239999",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = true;
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
|
||||
});
|
||||
|
||||
it("accepts Telegram /approve from exec target recipients when native approvals are disabled", async () => {
|
||||
const params = buildApproveParams(
|
||||
"/approve abc12345 allow-once",
|
||||
{
|
||||
commands: { text: true },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
},
|
||||
);
|
||||
params.command.isAuthorizedSender = false;
|
||||
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleApproveCommand(params, true);
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("Approval allow-once submitted");
|
||||
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
|
||||
});
|
||||
|
||||
it("requires configured Discord approvers for exec approvals", async () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
@@ -1098,4 +1001,3 @@ describe("handleApproveCommand", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -57,6 +57,36 @@ beforeAll(globalBeforeAll0);
|
||||
describe("dispatchReplyFromConfig", () => {
|
||||
beforeEach(describe0BeforeEach0);
|
||||
|
||||
function createActiveSlackThread(userId: string) {
|
||||
setNoAbort();
|
||||
const sessionKey = `agent:main:slack:direct:${userId}`;
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
return {
|
||||
activeOperation,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
createCtx: (overrides: Partial<MsgContext> = {}) =>
|
||||
buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: `user:${userId}`,
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("loads a registry handle before reading inbound hook state", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
@@ -730,17 +760,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("lets a different Slack DM routed thread reach reply resolution while another thread is active", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U1";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
const { activeOperation, createCtx, sessionId, sessionKey } = createActiveSlackThread("U1");
|
||||
const dispatcher = createDispatcher();
|
||||
let inBandMutationRan = false;
|
||||
const rotatedSessionId = "rotated-session";
|
||||
@@ -779,16 +799,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
try {
|
||||
const resultPromise = dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U1",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
BodyForAgent: "second top-level DM",
|
||||
}),
|
||||
ctx: createCtx({ BodyForAgent: "second top-level DM" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
@@ -819,17 +830,12 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("releases a Slack bypass lease when the competing routed thread changes during admission", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U2";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const originalOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
const {
|
||||
activeOperation: originalOperation,
|
||||
createCtx,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
originalOperation.setPhase("running");
|
||||
sessionKey,
|
||||
} = createActiveSlackThread("U2");
|
||||
let replacementOperation: ReturnType<typeof createReplyOperation> | undefined;
|
||||
let releaseMutation: () => void = () => {};
|
||||
const mutationGate = new Promise<void>((resolve) => {
|
||||
@@ -870,16 +876,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
try {
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U2",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
BodyForAgent: "same routed thread after replacement",
|
||||
}),
|
||||
ctx: createCtx({ BodyForAgent: "same routed thread after replacement" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher: createDispatcher(),
|
||||
replyResolver,
|
||||
@@ -900,17 +897,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("holds a Slack bypass lease until an abort-insensitive resolver settles", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U3";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
const { activeOperation, createCtx, sessionId, sessionKey } = createActiveSlackThread("U3");
|
||||
let releaseResolver: () => void = () => {};
|
||||
const resolverGate = new Promise<void>((resolve) => {
|
||||
releaseResolver = resolve;
|
||||
@@ -927,16 +914,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
const dispatcher = createDispatcher();
|
||||
const dispatch = dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U3",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
BodyForAgent: "abort-insensitive routed thread",
|
||||
}),
|
||||
ctx: createCtx({ BodyForAgent: "abort-insensitive routed thread" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
@@ -983,17 +961,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("bounds Slack bypass lease cleanup when dispatcher idle never settles", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U4";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
const { activeOperation, createCtx, sessionId, sessionKey } = createActiveSlackThread("U4");
|
||||
const dispatcher = createDispatcher();
|
||||
dispatcher.waitForIdle = vi.fn(async () => await new Promise<void>(() => {}));
|
||||
dispatcher.resolveFollowupAdmissionBarrierTimeoutPolicy = () => ({
|
||||
@@ -1003,16 +971,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
try {
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U4",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
BodyForAgent: "hung delivery barrier",
|
||||
}),
|
||||
ctx: createCtx({ BodyForAgent: "hung delivery barrier" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () => undefined,
|
||||
@@ -1035,17 +994,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("holds a Slack bypass lease until queued delivery settles before revalidation", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U5";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
const { activeOperation, createCtx, sessionId, sessionKey } = createActiveSlackThread("U5");
|
||||
let releaseDelivery: () => void = () => {};
|
||||
const deliveryGate = new Promise<void>((resolve) => {
|
||||
releaseDelivery = resolve;
|
||||
@@ -1099,16 +1048,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
try {
|
||||
const dispatch = dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U5",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
BodyForAgent: "hold queued delivery",
|
||||
}),
|
||||
ctx: createCtx({ BodyForAgent: "hold queued delivery" }),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
@@ -1142,17 +1082,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("runs ACP tail dispatch inside a borrowed Slack lifecycle admission", async () => {
|
||||
setNoAbort();
|
||||
const sessionKey = "agent:main:slack:direct:U6";
|
||||
const sessionId = "active-session";
|
||||
sessionStoreMocks.currentEntry = { sessionId, updatedAt: Date.now() };
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
resetTriggered: false,
|
||||
routeThreadId: "500.000",
|
||||
});
|
||||
activeOperation.setPhase("running");
|
||||
const { activeOperation, createCtx, sessionId, sessionKey } = createActiveSlackThread("U6");
|
||||
let initiatingAdmissionExcluded = false;
|
||||
let mutationRan = false;
|
||||
hookMocks.runner.hasHooks.mockImplementation(
|
||||
@@ -1185,14 +1115,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
try {
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U6",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
MessageThreadId: "501.000",
|
||||
ctx: createCtx({
|
||||
BodyForAgent: "run tail after reset",
|
||||
AcpDispatchTailAfterReset: true,
|
||||
}),
|
||||
|
||||
@@ -524,355 +524,189 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Codex direct source delivery message-tool-only when config is unset", async () => {
|
||||
type HarnessDeliveryCase = {
|
||||
name: string;
|
||||
harnessId?: string;
|
||||
supportsProvider?: string;
|
||||
currentEntry: typeof sessionStoreMocks.currentEntry;
|
||||
ctx: Partial<MsgContext>;
|
||||
cfg: OpenClawConfig;
|
||||
replyOptions?: GetReplyOptions;
|
||||
expectedMode: "automatic" | "message_tool_only";
|
||||
text: string;
|
||||
};
|
||||
|
||||
async function runHarnessDeliveryCase(testCase: HarnessDeliveryCase) {
|
||||
setNoAbort();
|
||||
const harnessId = testCase.harnessId ?? "codex";
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
id: harnessId,
|
||||
label: harnessId === "codex" ? "Codex" : "Custom",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: () => ({ supported: true, priority: 100 }),
|
||||
supports: (ctx) =>
|
||||
!testCase.supportsProvider || ctx.provider === testCase.supportsProvider
|
||||
? { supported: true, priority: harnessId === "codex" ? 100 : 200 }
|
||||
: { supported: false, reason: `${testCase.supportsProvider} provider only` },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
sessionStoreMocks.currentEntry = structuredClone(testCase.currentEntry);
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
return { text: "private final reply" } satisfies ReplyPayload;
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe(testCase.expectedMode);
|
||||
return { text: testCase.text } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
SessionKey: "agent:main:main",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
ctx: buildTestCtx(testCase.ctx),
|
||||
cfg: testCase.cfg,
|
||||
dispatcher,
|
||||
replyOptions: testCase.replyOptions,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(result.queuedFinal).toBe(testCase.expectedMode === "automatic");
|
||||
if (testCase.expectedMode === "automatic") {
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe(testCase.text);
|
||||
} else {
|
||||
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
it("keeps locked supervised Codex delivery defaults across outer model overrides", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "catalog-adopted-session",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
modelSelectionLocked: true,
|
||||
pluginExtensions: {
|
||||
codex: {
|
||||
supervision: {
|
||||
sourceThreadId: "019f-codex-thread",
|
||||
modelLocked: true,
|
||||
},
|
||||
const directCtx = {
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
SessionKey: "agent:main:main",
|
||||
} satisfies Partial<MsgContext>;
|
||||
const telegramDirectCtx = {
|
||||
...directCtx,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
} satisfies Partial<MsgContext>;
|
||||
const codexEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
sendPolicy: "allow",
|
||||
} as const;
|
||||
const cachedCodexEntry = {
|
||||
...codexEntry,
|
||||
modelProvider: "codex",
|
||||
model: "gpt-5.5",
|
||||
} as const;
|
||||
const channelModelConfig = {
|
||||
channels: { modelByChannel: { telegram: { "*": "anthropic/claude-sonnet-4.6" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "keeps Codex direct source delivery message-tool-only when config is unset",
|
||||
currentEntry: codexEntry,
|
||||
ctx: directCtx,
|
||||
cfg: emptyConfig,
|
||||
expectedMode: "message_tool_only",
|
||||
text: "private final reply",
|
||||
},
|
||||
{
|
||||
name: "keeps locked supervised Codex delivery defaults across outer model overrides",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: {
|
||||
...codexEntry,
|
||||
sessionId: "catalog-adopted-session",
|
||||
modelSelectionLocked: true,
|
||||
pluginExtensions: {
|
||||
codex: { supervision: { sourceThreadId: "019f-codex-thread", modelLocked: true } },
|
||||
},
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4.6",
|
||||
},
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4.6",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
return { text: "private supervised reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
SessionKey: "agent:main:main",
|
||||
}),
|
||||
ctx: directCtx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Codex direct source delivery defaults before a session entry exists", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: () => ({ supported: true, priority: 100 }),
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = undefined;
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
return { text: "private first reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
}),
|
||||
expectedMode: "message_tool_only",
|
||||
text: "private supervised reply",
|
||||
},
|
||||
{
|
||||
name: "uses Codex direct source delivery defaults before a session entry exists",
|
||||
currentEntry: undefined,
|
||||
ctx: telegramDirectCtx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses channel model overrides before Codex first-turn direct source delivery defaults", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = undefined;
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible channel-model reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
}),
|
||||
expectedMode: "message_tool_only",
|
||||
text: "private first reply",
|
||||
},
|
||||
{
|
||||
name: "uses channel model overrides before Codex first-turn direct source delivery defaults",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: undefined,
|
||||
ctx: telegramDirectCtx,
|
||||
cfg: channelModelConfig,
|
||||
expectedMode: "automatic",
|
||||
text: "visible channel-model reply",
|
||||
},
|
||||
{
|
||||
name: "uses channel model overrides before cached Codex runtime defaults",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: { ...cachedCodexEntry, channel: "telegram" },
|
||||
ctx: telegramDirectCtx,
|
||||
cfg: channelModelConfig,
|
||||
expectedMode: "automatic",
|
||||
text: "visible existing-channel-model reply",
|
||||
},
|
||||
{
|
||||
name: "uses configured defaults before cached Codex runtime metadata",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: cachedCodexEntry,
|
||||
ctx: telegramDirectCtx,
|
||||
cfg: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
"*": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4.6" } } },
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible channel-model reply");
|
||||
});
|
||||
|
||||
it("uses channel model overrides before cached Codex runtime defaults", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
modelProvider: "codex",
|
||||
model: "gpt-5.5",
|
||||
channel: "telegram",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible existing-channel-model reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
}),
|
||||
cfg: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
"*": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible existing-channel-model reply");
|
||||
});
|
||||
|
||||
it("uses configured defaults before cached Codex runtime metadata", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
modelProvider: "codex",
|
||||
model: "gpt-5.5",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible configured-default reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
}),
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4.6" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible configured-default reply");
|
||||
});
|
||||
|
||||
it("lets config restore automatic Codex direct source delivery", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: () => ({ supported: true, priority: 100 }),
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible final reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
SessionKey: "agent:main:main",
|
||||
}),
|
||||
expectedMode: "automatic",
|
||||
text: "visible configured-default reply",
|
||||
},
|
||||
{
|
||||
name: "lets config restore automatic Codex direct source delivery",
|
||||
currentEntry: codexEntry,
|
||||
ctx: directCtx,
|
||||
cfg: { messages: { visibleReplies: "automatic" } } as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible final reply");
|
||||
});
|
||||
|
||||
it("honors model overrides before cached Codex direct source delivery defaults", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
agentRuntimeOverride: "codex",
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4.6",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible switched-model reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
SessionKey: "agent:main:main",
|
||||
}),
|
||||
expectedMode: "automatic",
|
||||
text: "visible final reply",
|
||||
},
|
||||
{
|
||||
name: "honors model overrides before cached Codex direct source delivery defaults",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: {
|
||||
...codexEntry,
|
||||
agentRuntimeOverride: "codex",
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4.6",
|
||||
},
|
||||
ctx: directCtx,
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible switched-model reply");
|
||||
});
|
||||
expectedMode: "automatic",
|
||||
text: "visible switched-model reply",
|
||||
},
|
||||
{
|
||||
name: "honors heartbeat model overrides before Codex direct source delivery defaults",
|
||||
supportsProvider: "codex",
|
||||
currentEntry: codexEntry,
|
||||
ctx: telegramDirectCtx,
|
||||
cfg: emptyConfig,
|
||||
replyOptions: { isHeartbeat: true, heartbeatModelOverride: "anthropic/claude-sonnet-4.6" },
|
||||
expectedMode: "automatic",
|
||||
text: "visible heartbeat-model reply",
|
||||
},
|
||||
{
|
||||
name: "preserves non-Codex harness direct source delivery defaults",
|
||||
harnessId: "custom",
|
||||
supportsProvider: "custom",
|
||||
currentEntry: { ...codexEntry, agentHarnessId: "custom" },
|
||||
ctx: { ...directCtx, Provider: "custom" },
|
||||
cfg: emptyConfig,
|
||||
expectedMode: "message_tool_only",
|
||||
text: "private final reply",
|
||||
},
|
||||
] satisfies HarnessDeliveryCase[])("$name", runHarnessDeliveryCase);
|
||||
|
||||
it("honors parent model overrides before Codex direct source delivery defaults", async () => {
|
||||
setNoAbort();
|
||||
@@ -943,137 +777,60 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
||||
sessionStoreMocks.loadSessionStoreEntry.mockImplementation(defaultLoadSessionStoreEntry);
|
||||
});
|
||||
|
||||
it("honors heartbeat model overrides before Codex direct source delivery defaults", async () => {
|
||||
async function expectAutomaticDelivery(params: {
|
||||
ctx: Partial<MsgContext>;
|
||||
cfg: OpenClawConfig;
|
||||
text: string;
|
||||
replyOptions?: GetReplyOptions;
|
||||
checkTyping?: boolean;
|
||||
}) {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "codex"
|
||||
? { supported: true, priority: 100 }
|
||||
: { supported: false, reason: "codex provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "codex",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible heartbeat-model reply" } satisfies ReplyPayload;
|
||||
if (params.checkTyping) {
|
||||
expect(opts?.suppressTyping).toBe(false);
|
||||
}
|
||||
return { text: params.text } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SessionKey: "agent:main:telegram:direct:U1",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
ctx: buildTestCtx(params.ctx),
|
||||
cfg: params.cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
isHeartbeat: true,
|
||||
heartbeatModelOverride: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
replyOptions: params.replyOptions,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible heartbeat-model reply");
|
||||
});
|
||||
|
||||
it("preserves non-Codex harness direct source delivery defaults", async () => {
|
||||
setNoAbort();
|
||||
registerAgentHarness({
|
||||
id: "custom",
|
||||
label: "Custom",
|
||||
deliveryDefaults: { visibleReplies: "message_tool" },
|
||||
supports: (ctx) =>
|
||||
ctx.provider === "custom"
|
||||
? { supported: true, priority: 200 }
|
||||
: { supported: false, reason: "custom provider only" },
|
||||
runAttempt: vi.fn(async () => ({}) as never),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: 0,
|
||||
agentHarnessId: "custom",
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
return { text: "private final reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
ChatType: "direct",
|
||||
CommandSource: undefined,
|
||||
Provider: "custom",
|
||||
SessionKey: "agent:main:main",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(false);
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe(params.text);
|
||||
}
|
||||
|
||||
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "visible fallback" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
await expectAutomaticDelivery({
|
||||
ctx: {
|
||||
ChatType: "channel",
|
||||
SessionKey: "test:discord:channel:C1",
|
||||
}),
|
||||
},
|
||||
cfg: {
|
||||
messages: {
|
||||
groupChat: { visibleReplies: "message_tool" },
|
||||
},
|
||||
tools: { allow: ["read"] },
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
text: "visible fallback",
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible fallback");
|
||||
});
|
||||
|
||||
it("falls back to automatic group/channel delivery when group tools remove the message tool", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "group policy fallback" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
await expectAutomaticDelivery({
|
||||
ctx: {
|
||||
ChatType: "channel",
|
||||
From: "discord:channel:C1",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:main:discord:channel:C1",
|
||||
}),
|
||||
},
|
||||
cfg: {
|
||||
messages: {
|
||||
groupChat: { visibleReplies: "message_tool" },
|
||||
@@ -1086,90 +843,49 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
text: "group policy fallback",
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("group policy fallback");
|
||||
});
|
||||
|
||||
it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "requested fallback" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
await expectAutomaticDelivery({
|
||||
ctx: {
|
||||
ChatType: "channel",
|
||||
SessionKey: "test:discord:channel:C1",
|
||||
}),
|
||||
},
|
||||
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
replyOptions: {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
},
|
||||
text: "requested fallback",
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("requested fallback");
|
||||
});
|
||||
|
||||
it("keeps native command replies visible in group/channel events", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
expect(opts?.suppressTyping).toBe(false);
|
||||
return { text: "status reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
await expectAutomaticDelivery({
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
CommandSource: "native",
|
||||
CommandAuthorized: true,
|
||||
WasMentioned: true,
|
||||
SessionKey: "test:telegram:group:G1",
|
||||
}),
|
||||
},
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
text: "status reply",
|
||||
checkTyping: true,
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("status reply");
|
||||
});
|
||||
|
||||
it("keeps default group/channel source delivery automatic", async () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
return { text: "final reply" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
await expectAutomaticDelivery({
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
SessionKey: "test:telegram:group:G1",
|
||||
}),
|
||||
},
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
text: "final reply",
|
||||
});
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(result.queuedFinal).toBe(true);
|
||||
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("final reply");
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -304,92 +304,63 @@ describe("admitFollowupTurn", () => {
|
||||
expect(state.refreshGoal).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it("restores the item when persisted state changes generation after admission", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 };
|
||||
const replacementEntry: SessionEntry = { sessionId: "replacement-session", updatedAt: 2 };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.loadEntry.mockReturnValue(replacementEntry);
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the item when persisted lifecycle revision changes after admission", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "restores the item when persisted state changes generation after admission",
|
||||
mode: "persisted-session",
|
||||
},
|
||||
{
|
||||
name: "restores the item when persisted lifecycle revision changes after admission",
|
||||
mode: "persisted-revision",
|
||||
},
|
||||
{
|
||||
name: "restores the item when an in-memory generation changes while admission awaits",
|
||||
mode: "memory",
|
||||
},
|
||||
{
|
||||
name: "restores the item when the admitted persisted generation disappears",
|
||||
mode: "disappeared",
|
||||
},
|
||||
] as const)("$name", async ({ mode }) => {
|
||||
const operation = createOperation();
|
||||
const hasRevision = mode === "persisted-revision" || mode === "memory";
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "admitted",
|
||||
...(hasRevision ? { lifecycleRevision: "admitted" } : {}),
|
||||
updatedAt: 1,
|
||||
};
|
||||
const replacementEntry: SessionEntry = {
|
||||
...initialEntry,
|
||||
lifecycleRevision: "replacement",
|
||||
updatedAt: 2,
|
||||
};
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.loadEntry.mockReturnValue(replacementEntry);
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the item when an in-memory generation changes while admission awaits", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "admitted",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session",
|
||||
lifecycleRevision: "replacement",
|
||||
...(mode === "persisted-revision" ? initialEntry : {}),
|
||||
sessionId: mode === "persisted-revision" ? initialEntry.sessionId : "replacement-session",
|
||||
lifecycleRevision: hasRevision ? "replacement" : undefined,
|
||||
updatedAt: 2,
|
||||
};
|
||||
const sessionStore = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
const onQueuedFollowupAdmitted = vi.fn(async () => {
|
||||
sessionStore.main = replacementEntry;
|
||||
});
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({
|
||||
sessionEntry: initialEntry,
|
||||
sessionStore,
|
||||
opts: { onQueuedFollowupAdmitted },
|
||||
if (mode === "memory") {
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({
|
||||
sessionEntry: initialEntry,
|
||||
sessionStore,
|
||||
opts: {
|
||||
onQueuedFollowupAdmitted: vi.fn(async () => {
|
||||
sessionStore.main = replacementEntry;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the item when the admitted persisted generation disappears", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.loadEntry.mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
} else {
|
||||
state.loadEntry.mockReturnValue(mode === "disappeared" ? undefined : replacementEntry);
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
}
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -667,82 +638,80 @@ describe("admitFollowupTurn", () => {
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the item when preflight adoption races a replacement generation", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "restores the item when preflight adoption races a replacement generation",
|
||||
outcome: "rotated",
|
||||
mutation: "replace",
|
||||
loadPersisted: true,
|
||||
error: "Follow-up session generation changed",
|
||||
checksFailureText: true,
|
||||
},
|
||||
{
|
||||
name: "restores the item when a no-op preflight observes a replacement generation",
|
||||
outcome: "initial",
|
||||
mutation: "replace",
|
||||
loadPersisted: true,
|
||||
error: "Follow-up session generation changed",
|
||||
checksFailureText: false,
|
||||
},
|
||||
{
|
||||
name: "restores the item when a successful preflight observes in-memory deletion",
|
||||
outcome: "initial",
|
||||
mutation: "delete",
|
||||
loadPersisted: false,
|
||||
error: "Follow-up session generation changed",
|
||||
checksFailureText: false,
|
||||
},
|
||||
{
|
||||
name: "restores the item when a failing preflight observes a replacement generation",
|
||||
outcome: "failure",
|
||||
mutation: "replace",
|
||||
loadPersisted: false,
|
||||
error: "Follow-up session generation changed after reply admission",
|
||||
checksFailureText: true,
|
||||
},
|
||||
{
|
||||
name: "restores the item when a failing preflight observes in-memory deletion",
|
||||
outcome: "failure",
|
||||
mutation: "delete",
|
||||
loadPersisted: false,
|
||||
error: "Follow-up session generation changed",
|
||||
checksFailureText: true,
|
||||
},
|
||||
] as const)("$name", async ({ outcome, mutation, loadPersisted, error, checksFailureText }) => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "initial",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const rotatedEntry: SessionEntry = {
|
||||
sessionId: "compacted-session",
|
||||
lifecycleRevision: "compacted",
|
||||
updatedAt: 2,
|
||||
};
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session",
|
||||
lifecycleRevision: "replacement",
|
||||
updatedAt: 3,
|
||||
};
|
||||
const sessionStore = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.loadEntry.mockReturnValue(initialEntry);
|
||||
state.preflight.mockImplementation(async () => {
|
||||
sessionStore.main = replacementEntry;
|
||||
return rotatedEntry;
|
||||
});
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.buildPreflightFailureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the item when a no-op preflight observes a replacement generation", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "initial",
|
||||
lifecycleRevision: outcome === "failure" ? "admitted" : "initial",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session",
|
||||
lifecycleRevision: "replacement",
|
||||
updatedAt: 2,
|
||||
};
|
||||
const sessionStore = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.loadEntry.mockReturnValue(initialEntry);
|
||||
state.preflight.mockImplementation(async () => {
|
||||
sessionStore.main = replacementEntry;
|
||||
return initialEntry;
|
||||
});
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the item when a successful preflight observes in-memory deletion", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "initial",
|
||||
updatedAt: 1,
|
||||
updatedAt: outcome === "rotated" ? 3 : 2,
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
if (loadPersisted) {
|
||||
state.loadEntry.mockReturnValue(initialEntry);
|
||||
}
|
||||
state.preflight.mockImplementation(async () => {
|
||||
delete sessionStore.main;
|
||||
return initialEntry;
|
||||
if (mutation === "replace") {
|
||||
sessionStore.main = replacementEntry;
|
||||
} else {
|
||||
delete sessionStore.main;
|
||||
}
|
||||
if (outcome === "failure") {
|
||||
throw new Error("preflight failed");
|
||||
}
|
||||
return outcome === "rotated"
|
||||
? ({
|
||||
sessionId: "compacted-session",
|
||||
lifecycleRevision: "compacted",
|
||||
updatedAt: 2,
|
||||
} satisfies SessionEntry)
|
||||
: initialEntry;
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -750,8 +719,11 @@ describe("admitFollowupTurn", () => {
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed");
|
||||
).rejects.toThrow(error);
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
if (checksFailureText) {
|
||||
expect(state.buildPreflightFailureText).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes send policy and goal context after preflight rotates the generation", async () => {
|
||||
@@ -1029,59 +1001,6 @@ describe("admitFollowupTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the item when a failing preflight observes a replacement generation", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "admitted",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session",
|
||||
lifecycleRevision: "replacement",
|
||||
updatedAt: 2,
|
||||
};
|
||||
const sessionStore = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.preflight.mockImplementation(async () => {
|
||||
sessionStore.main = replacementEntry;
|
||||
throw new Error("preflight failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed after reply admission");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.buildPreflightFailureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the item when a failing preflight observes in-memory deletion", async () => {
|
||||
const operation = createOperation();
|
||||
const initialEntry: SessionEntry = {
|
||||
sessionId: "queued-session",
|
||||
lifecycleRevision: "admitted",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = { main: initialEntry };
|
||||
state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry });
|
||||
state.preflight.mockImplementation(async () => {
|
||||
delete sessionStore.main;
|
||||
throw new Error("preflight failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
admitFollowupTurn({
|
||||
queued: createRun(),
|
||||
defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }),
|
||||
}),
|
||||
).rejects.toThrow("Follow-up session generation changed");
|
||||
expect(operation.complete).toHaveBeenCalledOnce();
|
||||
expect(state.buildPreflightFailureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses admitted verbosity when formatting a preflight failure", async () => {
|
||||
const operation = createOperation();
|
||||
const admittedEntry: SessionEntry = {
|
||||
|
||||
Reference in New Issue
Block a user