fix: suppress commands for revision handoff sends

This commit is contained in:
Shakker
2026-06-04 01:09:39 +01:00
committed by Shakker
parent 4bcae169e2
commit 0059f5c24a
6 changed files with 140 additions and 5 deletions

View File

@@ -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({

View File

@@ -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 },

View File

@@ -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",

View File

@@ -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).`,

View File

@@ -186,6 +186,7 @@ async function forwardSkillWorkshopRevisionToChatSend(
message: params.instructions,
deliver: false,
systemProvenanceReceipt: buildRevisionAgentInstruction(params.proposal),
suppressCommandInterpretation: true,
idempotencyKey: params.idempotencyKey,
};
await chatSend({

View File

@@ -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<GatewayRequestContext["loadGatewayModelCatalog"]>(),
logGateway: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
agentRunSeq: new Map<string, number>(),
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<string, unknown> }
)?.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 {