|
5 | 5 | type AssistantMessage, |
6 | 6 | type Context, |
7 | 7 | type Model, |
| 8 | + type ToolCall, |
8 | 9 | } from "openclaw/plugin-sdk/llm"; |
9 | 10 | import { describe, expect, it } from "vitest"; |
10 | 11 | import { |
@@ -40,6 +41,14 @@ function makeAssistantMessage(text: string): AssistantMessage { |
40 | 41 | }; |
41 | 42 | } |
42 | 43 |
|
| 44 | +function makeAssistantToolMessage(toolCall: ToolCall): AssistantMessage { |
| 45 | + return { |
| 46 | + ...makeAssistantMessage(""), |
| 47 | + content: [toolCall], |
| 48 | + stopReason: "toolUse", |
| 49 | + }; |
| 50 | +} |
| 51 | + |
43 | 52 | describe("plugin text transforms", () => { |
44 | 53 | it("merges registered transform groups in order", () => { |
45 | 54 | const merged = mergePluginTextTransforms( |
@@ -180,4 +189,91 @@ describe("plugin text transforms", () => { |
180 | 189 | expect(firstEvent?.delta).toBe("red basket on the left shelf"); |
181 | 190 | expect(result.content).toEqual([{ type: "text", text: "final red basket on the left shelf" }]); |
182 | 191 | }); |
| 192 | + |
| 193 | + it("applies output replacements to structured tool-call arguments", async () => { |
| 194 | + const partialToolCall: ToolCall = { |
| 195 | + type: "toolCall", |
| 196 | + id: "call-1", |
| 197 | + name: "search", |
| 198 | + arguments: { |
| 199 | + query: "[MASKED]", |
| 200 | + nested: { note: "ask [MASKED] again" }, |
| 201 | + entries: ["[MASKED]", 7], |
| 202 | + }, |
| 203 | + }; |
| 204 | + const finalToolCall: ToolCall = { |
| 205 | + type: "toolCall", |
| 206 | + id: "call-2", |
| 207 | + name: "send_msg", |
| 208 | + arguments: { text: "[MASKED]" }, |
| 209 | + }; |
| 210 | + const partial = makeAssistantToolMessage(partialToolCall); |
| 211 | + const baseStreamFn: StreamFn = (_model, _context) => { |
| 212 | + const stream = createAssistantMessageEventStream(); |
| 213 | + queueMicrotask(() => { |
| 214 | + stream.push({ |
| 215 | + type: "toolcall_delta", |
| 216 | + contentIndex: 0, |
| 217 | + delta: '{"query":"[MASKED]"}', |
| 218 | + partial, |
| 219 | + }); |
| 220 | + stream.push({ |
| 221 | + type: "toolcall_end", |
| 222 | + contentIndex: 0, |
| 223 | + toolCall: partialToolCall, |
| 224 | + partial, |
| 225 | + }); |
| 226 | + stream.push({ |
| 227 | + type: "done", |
| 228 | + reason: "toolUse", |
| 229 | + message: makeAssistantToolMessage(finalToolCall), |
| 230 | + }); |
| 231 | + stream.end(); |
| 232 | + }); |
| 233 | + return stream; |
| 234 | + }; |
| 235 | + |
| 236 | + const wrapped = wrapStreamFnTextTransforms({ |
| 237 | + streamFn: baseStreamFn, |
| 238 | + output: [{ from: /\[MASKED\]/g, to: "John" }], |
| 239 | + }); |
| 240 | + const stream = await Promise.resolve(wrapped(model, {} as Context, undefined)); |
| 241 | + const events = []; |
| 242 | + for await (const event of stream) { |
| 243 | + events.push(event); |
| 244 | + } |
| 245 | + |
| 246 | + const deltaEvent = events.find((event) => event.type === "toolcall_delta") as |
| 247 | + | { delta?: string; partial?: AssistantMessage } |
| 248 | + | undefined; |
| 249 | + // Raw JSON fragments are provider bytes and may split a replacement token. |
| 250 | + // Structured arguments are the safe, canonical transform surface. |
| 251 | + expect(deltaEvent?.delta).toBe('{"query":"[MASKED]"}'); |
| 252 | + expect(deltaEvent?.partial?.content[0]).toMatchObject({ |
| 253 | + name: "search", |
| 254 | + arguments: { |
| 255 | + query: "John", |
| 256 | + nested: { note: "ask John again" }, |
| 257 | + entries: ["John", 7], |
| 258 | + }, |
| 259 | + }); |
| 260 | + |
| 261 | + const endEvent = events.find((event) => event.type === "toolcall_end") as |
| 262 | + | { toolCall?: { name?: string; arguments?: Record<string, unknown> } } |
| 263 | + | undefined; |
| 264 | + // Tool name is preserved — only arguments are transformed to avoid |
| 265 | + // breaking tool routing by renaming a registered tool identifier. |
| 266 | + expect(endEvent?.toolCall?.name).toBe("search"); |
| 267 | + expect(endEvent?.toolCall?.arguments).toEqual({ |
| 268 | + query: "John", |
| 269 | + nested: { note: "ask John again" }, |
| 270 | + entries: ["John", 7], |
| 271 | + }); |
| 272 | + |
| 273 | + const result = await stream.result(); |
| 274 | + expect(result.content[0]).toMatchObject({ |
| 275 | + name: "send_msg", |
| 276 | + arguments: { text: "John" }, |
| 277 | + }); |
| 278 | + }); |
183 | 279 | }); |
0 commit comments