diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index f9d9d65eb5ab..fe7ceb0f4c67 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -122,6 +122,17 @@ describe("lazy protocol validators", () => { expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false); }); + it("validates chat sends that suppress command interpretation", () => { + expect( + validateChatSendParams({ + sessionKey: "agent:main", + message: "/reset examples", + suppressCommandInterpretation: true, + idempotencyKey: "chat-run-1", + }), + ).toBe(true); + }); + it("validates Skill Workshop revision request params", () => { expect( protocol.validateSkillsProposalRequestRevisionParams({ diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index 234c9d28e360..90816032a98b 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -91,6 +91,7 @@ export const ChatSendParamsSchema = Type.Object( timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), systemInputProvenance: Type.Optional(InputProvenanceSchema), systemProvenanceReceipt: Type.Optional(Type.String()), + suppressCommandInterpretation: Type.Optional(Type.Boolean()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index bfe175bdec9f..5382712d38d4 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -3075,8 +3075,10 @@ export const chatHandlers: GatewayRequestHandlers = { timeoutMs?: number; systemInputProvenance?: InputProvenance; systemProvenanceReceipt?: string; + suppressCommandInterpretation?: boolean; idempotencyKey: string; }; + const suppressCommandInterpretation = p.suppressCommandInterpretation === true; const explicitOriginResult = normalizeExplicitChatSendOrigin({ originatingChannel: p.originatingChannel, originatingTo: p.originatingTo, @@ -3088,7 +3090,10 @@ export const chatHandlers: GatewayRequestHandlers = { return; } if ( - (p.systemInputProvenance || p.systemProvenanceReceipt || explicitOriginResult.value) && + (p.systemInputProvenance || + p.systemProvenanceReceipt || + suppressCommandInterpretation || + explicitOriginResult.value) && !canInjectSystemProvenance(client) ) { respond( @@ -3096,7 +3101,7 @@ export const chatHandlers: GatewayRequestHandlers = { undefined, errorShape( ErrorCodes.INVALID_REQUEST, - p.systemInputProvenance || p.systemProvenanceReceipt + p.systemInputProvenance || p.systemProvenanceReceipt || suppressCommandInterpretation ? "system provenance fields require admin scope" : "originating route fields require admin scope", ), @@ -3124,7 +3129,7 @@ export const chatHandlers: GatewayRequestHandlers = { systemInputProvenance || systemProvenanceReceipt ? JSON.stringify([systemProvenanceReceipt ?? null, systemInputProvenance ?? null]) : undefined; - const stopCommand = isChatStopCommandText(inboundMessage); + const stopCommand = !suppressCommandInterpretation && isChatStopCommandText(inboundMessage); const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments); const rawMessage = inboundMessage.trim(); if (!rawMessage && normalizedAttachments.length === 0) { @@ -3493,7 +3498,8 @@ export const chatHandlers: GatewayRequestHandlers = { p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), ); const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; - const commandSource = trimmedMessage.startsWith("/") ? "text" : undefined; + const commandSource = + !suppressCommandInterpretation && trimmedMessage.startsWith("/") ? "text" : undefined; const messageForAgent = systemProvenanceReceipt ? [systemProvenanceReceipt, parsedMessage].filter(Boolean).join("\n\n") : parsedMessage; @@ -3527,7 +3533,7 @@ export const chatHandlers: GatewayRequestHandlers = { MessageThreadId: messageThreadId, ChatType: "direct", ...(commandSource ? { CommandSource: commandSource } : {}), - CommandAuthorized: true, + CommandAuthorized: !suppressCommandInterpretation, CommandTurn: commandSource ? { kind: "text-slash", diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index a139654d3631..801e13eee3a2 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -237,6 +237,7 @@ describe("skills proposal gateway handlers", () => { idempotencyKey: "revision-run-1", message: "Make the support files 5", sessionKey: "agent:main:session:skill-workshop", + suppressCommandInterpretation: true, }); expect(String(forwarded.params?.systemProvenanceReceipt)).toContain( `Revise Skill Workshop proposal \`${created.record.id}\` (support-file-sampler).`, diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index a0140fb7a828..b5b3426544c8 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -186,6 +186,7 @@ async function forwardSkillWorkshopRevisionToChatSend( message: params.instructions, deliver: false, systemProvenanceReceipt: buildRevisionAgentInstruction(params.proposal), + suppressCommandInterpretation: true, idempotencyKey: params.idempotencyKey, }; await chatSend({ diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index a2b08c6763a7..89bb37edd73e 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -970,6 +970,121 @@ describe("gateway server chat", () => { } }); + test("chat.send can suppress command interpretation for slash-prefixed system turns", async () => { + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + try { + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; + const context = { + loadGatewayModelCatalog: vi.fn(), + logGateway: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + agentRunSeq: new Map(), + chatAbortControllers: new Map(), + chatAbortedRuns: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + chatDeltaLastBroadcastText: new Map(), + agentDeltaSentAt: new Map(), + bufferedAgentEvents: new Map(), + clearChatRunState: vi.fn(), + addChatRun: vi.fn(), + removeChatRun: vi.fn(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + registerToolEventRecipient: vi.fn(), + dedupe: new Map(), + } as unknown as GatewayRequestContext; + dispatchInboundMessageMock.mockResolvedValue({}); + + const { chatHandlers } = await import("./server-methods/chat.js"); + await chatHandlers["chat.send"]({ + req: { + type: "req", + id: "suppressed-command", + method: "chat.send", + params: { + sessionKey: "main", + message: "/reset examples", + suppressCommandInterpretation: true, + idempotencyKey: "idem-suppressed-command", + }, + }, + params: { + sessionKey: "main", + message: "/reset examples", + suppressCommandInterpretation: true, + idempotencyKey: "idem-suppressed-command", + }, + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + scopes: ["operator.write", "operator.admin"], + }, + } as never, + isWebchatConnect: () => true, + respond: ((ok, payload, error) => { + responses.push({ id: "suppressed-command", ok, payload, error }); + }) as RespondFn, + context, + }); + + expect(responses).toEqual([ + { + id: "suppressed-command", + ok: true, + payload: expect.objectContaining({ + runId: "idem-suppressed-command", + status: "started", + }), + error: undefined, + }, + ]); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + const dispatchContext = ( + dispatchInboundMessageMock.mock.calls[0]?.[0] as { ctx?: Record } + )?.ctx; + expect(dispatchContext).toMatchObject({ + Body: "/reset examples", + BodyForCommands: "/reset examples", + CommandAuthorized: false, + CommandTurn: { + kind: "normal", + source: "message", + authorized: false, + body: "/reset examples", + }, + RawBody: "/reset examples", + }); + expect(dispatchContext).not.toHaveProperty("CommandSource"); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(1); + }, FAST_WAIT_OPTS); + } finally { + dispatchInboundMessageMock.mockReset(); + testState.sessionStorePath = undefined; + clearConfigCache(); + await fs.rm(sessionDir, { recursive: true, force: true }); + } + }); + test("chat.send starts the next WebChat turn after the prior internal run finishes", async () => { const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); try {