|
1 | 1 | // Verifies plugin text transforms rewrite prompts and streamed assistant output. |
2 | | -import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; |
| 2 | +import { runAgentLoop, type AgentEvent, type StreamFn } from "openclaw/plugin-sdk/agent-core"; |
3 | 3 | import { |
4 | 4 | createAssistantMessageEventStream, |
5 | 5 | type AssistantMessage, |
6 | 6 | type Context, |
7 | 7 | type Model, |
| 8 | + type ToolCall, |
8 | 9 | } from "openclaw/plugin-sdk/llm"; |
| 10 | +import { Type } from "typebox"; |
9 | 11 | import { describe, expect, it } from "vitest"; |
10 | 12 | import { |
11 | 13 | applyPluginTextReplacements, |
12 | 14 | mergePluginTextTransforms, |
| 15 | + transformPluginMessageText, |
13 | 16 | wrapStreamFnTextTransforms, |
14 | 17 | } from "./plugin-text-transforms.js"; |
15 | 18 |
|
@@ -40,6 +43,14 @@ function makeAssistantMessage(text: string): AssistantMessage { |
40 | 43 | }; |
41 | 44 | } |
42 | 45 |
|
| 46 | +function makeAssistantToolMessage(toolCall: ToolCall): AssistantMessage { |
| 47 | + return { |
| 48 | + ...makeAssistantMessage("unused"), |
| 49 | + content: [toolCall], |
| 50 | + stopReason: "toolUse", |
| 51 | + }; |
| 52 | +} |
| 53 | + |
43 | 54 | describe("plugin text transforms", () => { |
44 | 55 | it("merges registered transform groups in order", () => { |
45 | 56 | const merged = mergePluginTextTransforms( |
@@ -180,4 +191,180 @@ describe("plugin text transforms", () => { |
180 | 191 | expect(firstEvent?.delta).toBe("red basket on the left shelf"); |
181 | 192 | expect(result.content).toEqual([{ type: "text", text: "final red basket on the left shelf" }]); |
182 | 193 | }); |
| 194 | + |
| 195 | + it("wraps streamed tool call deltas and argument strings with outbound replacements", async () => { |
| 196 | + const streamedToolCall: ToolCall = { |
| 197 | + type: "toolCall", |
| 198 | + id: "call_[MASKED]", |
| 199 | + name: "send_[MASKED]", |
| 200 | + arguments: { |
| 201 | + text: "Message for [MASKED]", |
| 202 | + nested: { title: "[MASKED] follow-up", count: 2 }, |
| 203 | + recipients: ["[MASKED]", "ops", true], |
| 204 | + }, |
| 205 | + partialArgs: '{"text":"Message for [MASKED]"}', |
| 206 | + partialJson: '{"text":"Message for [MASKED]"}', |
| 207 | + } as ToolCall; |
| 208 | + const finalToolCall: ToolCall = { |
| 209 | + ...streamedToolCall, |
| 210 | + arguments: { |
| 211 | + text: "Final message for [MASKED]", |
| 212 | + nested: { title: "[MASKED] final", enabled: false }, |
| 213 | + }, |
| 214 | + partialArgs: '{"text":"Final message for [MASKED]"}', |
| 215 | + partialJson: '{"text":"Final message for [MASKED]"}', |
| 216 | + } as ToolCall; |
| 217 | + const partial = makeAssistantToolMessage(streamedToolCall); |
| 218 | + const finalMessage = makeAssistantToolMessage(finalToolCall); |
| 219 | + const baseStreamFn: StreamFn = () => { |
| 220 | + const stream = createAssistantMessageEventStream(); |
| 221 | + queueMicrotask(() => { |
| 222 | + stream.push({ |
| 223 | + type: "toolcall_delta", |
| 224 | + contentIndex: 0, |
| 225 | + delta: '{"text":"Message for [MASKED]"}', |
| 226 | + partial, |
| 227 | + }); |
| 228 | + stream.push({ |
| 229 | + type: "toolcall_end", |
| 230 | + contentIndex: 0, |
| 231 | + toolCall: streamedToolCall, |
| 232 | + partial, |
| 233 | + }); |
| 234 | + stream.push({ |
| 235 | + type: "done", |
| 236 | + reason: "toolUse", |
| 237 | + message: finalMessage, |
| 238 | + }); |
| 239 | + stream.end(); |
| 240 | + }); |
| 241 | + return stream; |
| 242 | + }; |
| 243 | + |
| 244 | + const wrapped = wrapStreamFnTextTransforms({ |
| 245 | + streamFn: baseStreamFn, |
| 246 | + output: [{ from: /\[MASKED\]/g, to: "John Smith" }], |
| 247 | + }); |
| 248 | + const stream = await Promise.resolve(wrapped(model, { messages: [] } as Context, undefined)); |
| 249 | + const events = []; |
| 250 | + for await (const event of stream) { |
| 251 | + events.push(event); |
| 252 | + } |
| 253 | + const result = await stream.result(); |
| 254 | + |
| 255 | + const delta = events[0] as { type?: string; delta?: string; partial?: AssistantMessage }; |
| 256 | + expect(delta.type).toBe("toolcall_delta"); |
| 257 | + expect(delta.delta).toBe('{"text":"Message for John Smith"}'); |
| 258 | + expect(delta.partial?.content[0]).toMatchObject({ |
| 259 | + id: "call_[MASKED]", |
| 260 | + name: "send_[MASKED]", |
| 261 | + partialArgs: '{"text":"Message for John Smith"}', |
| 262 | + partialJson: '{"text":"Message for John Smith"}', |
| 263 | + arguments: { |
| 264 | + text: "Message for John Smith", |
| 265 | + nested: { title: "John Smith follow-up", count: 2 }, |
| 266 | + recipients: ["John Smith", "ops", true], |
| 267 | + }, |
| 268 | + }); |
| 269 | + |
| 270 | + const end = events[1] as { type?: string; toolCall?: ToolCall; partial?: AssistantMessage }; |
| 271 | + expect(end.type).toBe("toolcall_end"); |
| 272 | + expect(end.toolCall).toMatchObject({ |
| 273 | + id: "call_[MASKED]", |
| 274 | + name: "send_[MASKED]", |
| 275 | + partialArgs: '{"text":"Message for John Smith"}', |
| 276 | + partialJson: '{"text":"Message for John Smith"}', |
| 277 | + arguments: { |
| 278 | + text: "Message for John Smith", |
| 279 | + nested: { title: "John Smith follow-up", count: 2 }, |
| 280 | + recipients: ["John Smith", "ops", true], |
| 281 | + }, |
| 282 | + }); |
| 283 | + expect(result.content[0]).toMatchObject({ |
| 284 | + id: "call_[MASKED]", |
| 285 | + name: "send_[MASKED]", |
| 286 | + partialArgs: '{"text":"Final message for John Smith"}', |
| 287 | + partialJson: '{"text":"Final message for John Smith"}', |
| 288 | + arguments: { |
| 289 | + text: "Final message for John Smith", |
| 290 | + nested: { title: "John Smith final", enabled: false }, |
| 291 | + }, |
| 292 | + }); |
| 293 | + }); |
| 294 | + |
| 295 | + it("rewrites finalized tool call arguments before agent tool execution", async () => { |
| 296 | + const output = [{ from: /cat/g, to: "black cat" }]; |
| 297 | + const capturedEvents: AgentEvent[] = []; |
| 298 | + const executedArgs: unknown[] = []; |
| 299 | + let turn = 0; |
| 300 | + const baseStreamFn: StreamFn = () => { |
| 301 | + turn += 1; |
| 302 | + const stream = createAssistantMessageEventStream(); |
| 303 | + queueMicrotask(() => { |
| 304 | + const message = |
| 305 | + turn === 1 |
| 306 | + ? makeAssistantToolMessage({ |
| 307 | + type: "toolCall", |
| 308 | + id: "call_read", |
| 309 | + name: "read", |
| 310 | + arguments: { path: "cat.txt" }, |
| 311 | + partialArgs: '{"path":"cat.txt"}', |
| 312 | + } as ToolCall) |
| 313 | + : makeAssistantMessage("done"); |
| 314 | + stream.push({ |
| 315 | + type: "done", |
| 316 | + reason: message.stopReason === "toolUse" ? "toolUse" : "stop", |
| 317 | + message, |
| 318 | + }); |
| 319 | + stream.end(); |
| 320 | + }); |
| 321 | + return stream; |
| 322 | + }; |
| 323 | + const wrapped = wrapStreamFnTextTransforms({ |
| 324 | + streamFn: baseStreamFn, |
| 325 | + output, |
| 326 | + transformFinalResult: false, |
| 327 | + }); |
| 328 | + |
| 329 | + await runAgentLoop( |
| 330 | + [{ role: "user", content: "read it", timestamp: 1 }], |
| 331 | + { |
| 332 | + systemPrompt: "", |
| 333 | + messages: [], |
| 334 | + tools: [ |
| 335 | + { |
| 336 | + name: "read", |
| 337 | + label: "read", |
| 338 | + description: "read", |
| 339 | + parameters: Type.Object({ path: Type.String() }, { additionalProperties: false }), |
| 340 | + execute: async (_toolCallId, args) => { |
| 341 | + executedArgs.push(args); |
| 342 | + return { |
| 343 | + content: [{ type: "text", text: "ok" }], |
| 344 | + details: args, |
| 345 | + terminate: true, |
| 346 | + }; |
| 347 | + }, |
| 348 | + }, |
| 349 | + ], |
| 350 | + }, |
| 351 | + { |
| 352 | + model, |
| 353 | + convertToLlm: (messages) => messages as never, |
| 354 | + transformAssistantMessage: (message) => transformPluginMessageText(message, output), |
| 355 | + }, |
| 356 | + (event) => { |
| 357 | + capturedEvents.push(event); |
| 358 | + }, |
| 359 | + undefined, |
| 360 | + wrapped, |
| 361 | + ); |
| 362 | + |
| 363 | + const toolStart = capturedEvents.find( |
| 364 | + (event): event is Extract<AgentEvent, { type: "tool_execution_start" }> => |
| 365 | + event.type === "tool_execution_start", |
| 366 | + ); |
| 367 | + expect(toolStart?.args).toEqual({ path: "black cat.txt" }); |
| 368 | + expect(executedArgs).toEqual([{ path: "black cat.txt" }]); |
| 369 | + }); |
183 | 370 | }); |
0 commit comments