fix(gateway): harden agent provider routing
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||
import { GATEWAY_AGENT_PROVIDER_VALUES } from "../../utils/message-provider.js";
|
||||
|
||||
const NonEmptyString = Type.String({ minLength: 1 });
|
||||
const SessionLabelString = Type.String({
|
||||
@@ -7,6 +8,10 @@ const SessionLabelString = Type.String({
|
||||
maxLength: SESSION_LABEL_MAX_LENGTH,
|
||||
});
|
||||
|
||||
const AgentProviderSchema = Type.Union(
|
||||
GATEWAY_AGENT_PROVIDER_VALUES.map((provider) => Type.Literal(provider)),
|
||||
);
|
||||
|
||||
export const PresenceEntrySchema = Type.Object(
|
||||
{
|
||||
host: Type.Optional(NonEmptyString),
|
||||
@@ -225,7 +230,7 @@ export const AgentParamsSchema = Type.Object(
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
deliver: Type.Optional(Type.Boolean()),
|
||||
provider: Type.Optional(Type.String()),
|
||||
provider: Type.Optional(AgentProviderSchema),
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
|
||||
@@ -12,7 +12,12 @@ import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { normalizeMessageProvider } from "../../utils/message-provider.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_PROVIDER,
|
||||
isDeliverableMessageProvider,
|
||||
isGatewayMessageProvider,
|
||||
normalizeMessageProvider,
|
||||
} from "../../utils/message-provider.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
type AgentWaitParams,
|
||||
@@ -161,21 +166,18 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
if (requestedProvider === "last") {
|
||||
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
|
||||
// so VoiceWake and CLI callers don't get stuck with deliver=false.
|
||||
if (lastProvider && lastProvider !== "webchat") return lastProvider;
|
||||
return wantsDelivery ? "whatsapp" : "webchat";
|
||||
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
|
||||
return lastProvider;
|
||||
}
|
||||
return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER;
|
||||
}
|
||||
if (
|
||||
requestedProvider === "whatsapp" ||
|
||||
requestedProvider === "telegram" ||
|
||||
requestedProvider === "discord" ||
|
||||
requestedProvider === "signal" ||
|
||||
requestedProvider === "imessage" ||
|
||||
requestedProvider === "webchat"
|
||||
) {
|
||||
return requestedProvider;
|
||||
|
||||
if (isGatewayMessageProvider(requestedProvider)) return requestedProvider;
|
||||
|
||||
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
|
||||
return lastProvider;
|
||||
}
|
||||
if (lastProvider && lastProvider !== "webchat") return lastProvider;
|
||||
return wantsDelivery ? "whatsapp" : "webchat";
|
||||
return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER;
|
||||
})();
|
||||
|
||||
const resolvedTo = (() => {
|
||||
@@ -184,13 +186,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
? request.to.trim()
|
||||
: undefined;
|
||||
if (explicit) return explicit;
|
||||
if (
|
||||
resolvedProvider === "whatsapp" ||
|
||||
resolvedProvider === "telegram" ||
|
||||
resolvedProvider === "discord" ||
|
||||
resolvedProvider === "signal" ||
|
||||
resolvedProvider === "imessage"
|
||||
) {
|
||||
if (isDeliverableMessageProvider(resolvedProvider)) {
|
||||
return lastTo || undefined;
|
||||
}
|
||||
return undefined;
|
||||
@@ -225,7 +221,9 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
return allowFrom[0];
|
||||
})();
|
||||
|
||||
const deliver = request.deliver === true && resolvedProvider !== "webchat";
|
||||
const deliver =
|
||||
request.deliver === true &&
|
||||
resolvedProvider !== INTERNAL_MESSAGE_PROVIDER;
|
||||
|
||||
const accepted = {
|
||||
runId,
|
||||
|
||||
@@ -286,6 +286,50 @@ describe("gateway server agent", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel slack", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-slack",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "slack",
|
||||
lastTo: "channel:slack-123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
provider: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-slack",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expectProviders(call, "slack");
|
||||
expect(call.to).toBe("channel:slack-123");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-slack");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel signal", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
@@ -330,6 +374,125 @@ describe("gateway server agent", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent routes main last-channel msteams", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-teams",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "msteams",
|
||||
lastTo: "conversation:teams-123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
provider: "last",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-last-msteams",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expectProviders(call, "msteams");
|
||||
expect(call.to).toBe("conversation:teams-123");
|
||||
expect(call.deliver).toBe(true);
|
||||
expect(call.bestEffortDeliver).toBe(true);
|
||||
expect(call.sessionId).toBe("sess-teams");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent accepts provider aliases (imsg/teams)", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-alias",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "imessage",
|
||||
lastTo: "chat_id:123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const resIMessage = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
provider: "imsg",
|
||||
deliver: true,
|
||||
idempotencyKey: "idem-agent-imsg",
|
||||
});
|
||||
expect(resIMessage.ok).toBe(true);
|
||||
|
||||
const resTeams = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
provider: "teams",
|
||||
to: "conversation:teams-abc",
|
||||
deliver: false,
|
||||
idempotencyKey: "idem-agent-teams",
|
||||
});
|
||||
expect(resTeams.ok).toBe(true);
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const lastIMessageCall = spy.mock.calls.at(-2)?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expectProviders(lastIMessageCall, "imessage");
|
||||
expect(lastIMessageCall.to).toBe("chat_id:123");
|
||||
|
||||
const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expectProviders(lastTeamsCall, "msteams");
|
||||
expect(lastTeamsCall.to).toBe("conversation:teams-abc");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent rejects unknown provider", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey: "main",
|
||||
provider: "sms",
|
||||
idempotencyKey: "idem-agent-bad-provider",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.code).toBe("INVALID_REQUEST");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent ignores webchat last-channel for routing", async () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
|
||||
Reference in New Issue
Block a user