Skip to content

Commit 1a237ec

Browse files
feat(gateway): preserve chat inject metadata
1 parent 9159013 commit 1a237ec

16 files changed

Lines changed: 817 additions & 9 deletions

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6470,21 +6470,37 @@ public struct ChatInjectParams: Codable, Sendable {
64706470
public let sessionkey: String
64716471
public let message: String
64726472
public let label: String?
6473+
public let idempotencykey: String?
6474+
public let command: Bool?
6475+
public let interactive: [String: AnyCodable]?
6476+
public let channeldata: [String: AnyCodable]?
64736477

64746478
public init(
64756479
sessionkey: String,
64766480
message: String,
6477-
label: String?)
6481+
label: String?,
6482+
idempotencykey: String?,
6483+
command: Bool?,
6484+
interactive: [String: AnyCodable]?,
6485+
channeldata: [String: AnyCodable]?)
64786486
{
64796487
self.sessionkey = sessionkey
64806488
self.message = message
64816489
self.label = label
6490+
self.idempotencykey = idempotencykey
6491+
self.command = command
6492+
self.interactive = interactive
6493+
self.channeldata = channeldata
64826494
}
64836495

64846496
private enum CodingKeys: String, CodingKey {
64856497
case sessionkey = "sessionKey"
64866498
case message
64876499
case label
6500+
case idempotencykey = "idempotencyKey"
6501+
case command
6502+
case interactive
6503+
case channeldata = "channelData"
64886504
}
64896505
}
64906506

docs/gateway/protocol.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
438438
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
439439
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
440440
- `sessions.get` returns the full stored session row.
441-
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted, and oversized rows can be replaced with placeholders.
441+
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.inject` accepts optional `idempotencyKey`, `command`, `interactive`, and `channelData` metadata for operator-admin UI/status injections; third-party plugins should call it through the narrow `injectChatMessageOverGateway` SDK helper instead of constructing a generic admin Gateway client. The Gateway stores and broadcasts that metadata without starting an agent run or delivering to channels. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted, and oversized rows can be replaced with placeholders.
442442

443443
</Accordion>
444444

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ releases.
545545
| `plugin-sdk/lazy-runtime` | Lazy runtime helpers | `createLazyRuntimeModule`, `createLazyRuntimeMethod`, `createLazyRuntimeMethodBinder`, `createLazyRuntimeNamedExport`, `createLazyRuntimeSurface` |
546546
| `plugin-sdk/process-runtime` | Process helpers | Shared exec helpers |
547547
| `plugin-sdk/cli-runtime` | CLI runtime helpers | Command formatting, waits, version helpers |
548-
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, and channel-status patch helpers |
548+
| `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client, event-loop-ready start helper, channel-status patch helpers, operator approval clients, and narrow chat injection helpers |
549549
| `plugin-sdk/config-runtime` | Deprecated config compatibility shim | Prefer `config-contracts`, `plugin-config-runtime`, `runtime-config-snapshot`, and `config-mutation` |
550550
| `plugin-sdk/telegram-command-config` | Telegram command helpers | Fallback-stable Telegram command validation helpers when the bundled Telegram contract surface is unavailable |
551551
| `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers, and structured approval display path formatting |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ and pairing-path families.
220220
| `plugin-sdk/process-runtime` | Process exec helpers |
221221
| `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers |
222222
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
223-
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, and channel-status patch helpers |
223+
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, channel-status patch helpers, operator approval clients, and narrow chat injection helpers |
224224
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
225225
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
226226
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
171171
- When rendering `chat.history`, the Control UI strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply` or the heartbeat acknowledgement token `HEARTBEAT_OK`.
172172
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
173173
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
174-
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
174+
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). Operator-admin callers can pass an `idempotencyKey` plus `command`, `interactive`, and `channelData` metadata for durable approval/status cards.
175175
- The chat header shows the agent filter before the session picker, and the session picker is scoped by the selected agent. Switching agents shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet.
176176
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
177177
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.

src/gateway/client-callsites.guard.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([
1111
"src/gateway/call.ts",
1212
"src/gateway/gateway-cli-backend.live-helpers.ts",
1313
"src/gateway/operator-approvals-client.ts",
14+
"src/gateway/operator-chat-client.ts",
1415
"src/gateway/probe.ts",
1516
"src/node-host/runner.ts",
1617
"src/tui/gateway-chat.ts",

src/gateway/gateway.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ function nextGatewayId(prefix: string): string {
4141
return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`;
4242
}
4343

44+
function asRecord(value: unknown): Record<string, unknown> | undefined {
45+
return value && typeof value === "object" && !Array.isArray(value)
46+
? (value as Record<string, unknown>)
47+
: undefined;
48+
}
49+
4450
async function createEmptyBundledPluginsDir(tempHome: string): Promise<string> {
4551
const bundledPluginsDir = path.join(tempHome, "openclaw-test-empty-bundled-plugins");
4652
await fs.mkdir(bundledPluginsDir, { recursive: true });
@@ -234,6 +240,154 @@ describe("gateway e2e", () => {
234240
},
235241
);
236242

243+
it(
244+
"preserves chat.inject metadata through live ws broadcast and chat.history",
245+
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
246+
async () => {
247+
const { envSnapshot, tempHome, workspaceDir } = await setupGatewayTempHome({
248+
prefix: "openclaw-gw-chat-inject-home-",
249+
minimalGateway: true,
250+
});
251+
252+
const token = nextGatewayId("chat-inject-token");
253+
process.env.OPENCLAW_GATEWAY_TOKEN = token;
254+
255+
const configDir = path.join(tempHome, ".openclaw");
256+
await fs.mkdir(configDir, { recursive: true });
257+
const configPath = path.join(configDir, "openclaw.json");
258+
const cfg = {
259+
agents: {
260+
defaults: { workspace: workspaceDir },
261+
list: [{ id: "dev", default: true }],
262+
},
263+
gateway: { auth: { token } },
264+
};
265+
266+
const { port, server, client } = await startGatewayWithClient({
267+
cfg,
268+
configPath,
269+
token,
270+
clientDisplayName: "vitest-chat-inject-admin",
271+
});
272+
273+
const liveEvents: Array<{ event?: string; payload?: unknown }> = [];
274+
let watcher: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
275+
try {
276+
watcher = await connectGatewayClient({
277+
url: `ws://127.0.0.1:${port}`,
278+
token,
279+
clientDisplayName: "vitest-chat-inject-watch",
280+
onEvent: (evt) => liveEvents.push(evt),
281+
});
282+
283+
const created = (await client.request("sessions.create", {
284+
agentId: "dev",
285+
label: "AgentKit HITL metadata proof",
286+
})) as {
287+
ok?: unknown;
288+
key?: unknown;
289+
sessionId?: unknown;
290+
};
291+
expect(created.ok).toBe(true);
292+
expect(created.sessionId).toBeTypeOf("string");
293+
const sessionKey = typeof created.key === "string" ? created.key : undefined;
294+
expect(sessionKey).toBeTypeOf("string");
295+
296+
const interactive = {
297+
blocks: [
298+
{
299+
type: "buttons",
300+
buttons: [
301+
{
302+
label: "Verify",
303+
value: "/agentkit approve live-proof allow-once",
304+
style: "primary",
305+
},
306+
],
307+
},
308+
],
309+
};
310+
const channelData = {
311+
agentkit: {
312+
approvalId: "live-proof",
313+
pluginId: "@guardiola31337/agentkit",
314+
state: "pending",
315+
},
316+
};
317+
const idempotencyKey = nextGatewayId("agentkit-approval");
318+
const injectParams = {
319+
sessionKey,
320+
message: "AgentKit verification required.",
321+
command: true,
322+
interactive,
323+
channelData,
324+
idempotencyKey,
325+
};
326+
327+
const injected = (await client.request("chat.inject", injectParams)) as {
328+
ok?: unknown;
329+
messageId?: unknown;
330+
};
331+
expect(injected.ok).toBe(true);
332+
expect(injected.messageId).toBeTypeOf("string");
333+
334+
const duplicate = (await client.request("chat.inject", injectParams)) as {
335+
ok?: unknown;
336+
deduped?: unknown;
337+
};
338+
expect(duplicate).toEqual({ ok: true, deduped: true });
339+
340+
const matchingChatEvents = () =>
341+
liveEvents.filter((evt) => {
342+
if (evt.event !== "chat") {
343+
return false;
344+
}
345+
const payload = asRecord(evt.payload);
346+
const message = asRecord(payload?.message);
347+
return (
348+
payload?.sessionKey === sessionKey &&
349+
payload?.state === "final" &&
350+
message?.idempotencyKey === idempotencyKey
351+
);
352+
});
353+
await expect
354+
.poll(() => matchingChatEvents().length, { timeout: 2_000, interval: 50 })
355+
.toBe(1);
356+
const liveMessage = asRecord(asRecord(matchingChatEvents()[0]?.payload)?.message);
357+
expect(liveMessage?.command).toBe(true);
358+
expect(liveMessage?.interactive).toEqual(interactive);
359+
expect(liveMessage?.channelData).toEqual(channelData);
360+
expect(liveMessage?.idempotencyKey).toBe(idempotencyKey);
361+
362+
const history = (await client.request("chat.history", {
363+
sessionKey,
364+
limit: 10,
365+
})) as { messages?: unknown[] };
366+
const historyMessage = history.messages
367+
?.map((message) => asRecord(message))
368+
.find((message) => message?.idempotencyKey === idempotencyKey);
369+
expect(historyMessage).toBeDefined();
370+
expect(historyMessage?.command).toBe(true);
371+
expect(historyMessage?.interactive).toEqual(interactive);
372+
expect(historyMessage?.channelData).toEqual(channelData);
373+
expect(historyMessage?.idempotencyKey).toBe(idempotencyKey);
374+
} finally {
375+
if (watcher) {
376+
await disconnectGatewayClient(watcher);
377+
}
378+
await disconnectGatewayClient(client);
379+
await server.close({ reason: "chat inject metadata proof complete" });
380+
await fs.rm(tempHome, {
381+
recursive: true,
382+
force: true,
383+
maxRetries: 10,
384+
retryDelay: 50,
385+
});
386+
envSnapshot.restore();
387+
}
388+
},
389+
);
390+
237391
it(
238392
"does not reload workspace plugins when POST /tools/invoke rebuilds tools for the same workspace",
239393
{ timeout: GATEWAY_E2E_TIMEOUT_MS },

0 commit comments

Comments
 (0)