|
1 | | -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 1 | +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; |
| 3 | +import * as channelPluginsModule from "../../channels/plugins/index.js"; |
3 | 4 | import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; |
4 | 5 | import type { CliDeps } from "../../cli/outbound-send-deps.js"; |
5 | 6 | import type { OpenClawConfig } from "../../config/config.js"; |
| 7 | +import type { SessionEntry } from "../../config/sessions.js"; |
| 8 | +import * as agentDeliveryModule from "../../infra/outbound/agent-delivery.js"; |
| 9 | +import type { AgentDeliveryPlan } from "../../infra/outbound/agent-delivery.js"; |
| 10 | +import * as deliverModule from "../../infra/outbound/deliver.js"; |
| 11 | +import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; |
6 | 12 | import { setActivePluginRegistry } from "../../plugins/runtime.js"; |
| 13 | +import type { RuntimeEnv } from "../../runtime.js"; |
7 | 14 | import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; |
| 15 | +import * as messageChannelModule from "../../utils/message-channel.js"; |
8 | 16 | import { deliverAgentCommandResult, normalizeAgentCommandReplyPayloads } from "./delivery.js"; |
9 | 17 | import type { AgentCommandOpts } from "./types.js"; |
10 | 18 |
|
@@ -264,3 +272,367 @@ describe("normalizeAgentCommandReplyPayloads", () => { |
264 | 272 | ]); |
265 | 273 | }); |
266 | 274 | }); |
| 275 | + |
| 276 | +// --------------------------------------------------------------------------- |
| 277 | +// deliveryStatus tracking tests (PR #53961) |
| 278 | +// Uses spyOn approach for delivery mocking — separate from upstream normalize tests. |
| 279 | +// --------------------------------------------------------------------------- |
| 280 | + |
| 281 | +// --------------------------------------------------------------------------- |
| 282 | +// Spies (vi.mock has module-resolution issues in forks pool when transitive |
| 283 | +// dependencies are pre-loaded by test/setup.ts — vi.spyOn is reliable). |
| 284 | +// --------------------------------------------------------------------------- |
| 285 | + |
| 286 | +const deliverSpy = vi.spyOn(deliverModule, "deliverOutboundPayloads"); |
| 287 | +const deliveryPlanSpy = vi.spyOn(agentDeliveryModule, "resolveAgentDeliveryPlan"); |
| 288 | +const outboundTargetSpy = vi.spyOn(agentDeliveryModule, "resolveAgentOutboundTarget"); |
| 289 | +const channelPluginSpy = vi.spyOn(channelPluginsModule, "getChannelPlugin"); |
| 290 | +const isInternalSpy = vi.spyOn(messageChannelModule, "isInternalMessageChannel"); |
| 291 | + |
| 292 | +afterAll(() => { |
| 293 | + vi.restoreAllMocks(); |
| 294 | +}); |
| 295 | + |
| 296 | +// --------------------------------------------------------------------------- |
| 297 | +// Helpers |
| 298 | +// --------------------------------------------------------------------------- |
| 299 | + |
| 300 | +function createRuntime(): RuntimeEnv & { |
| 301 | + log: ReturnType<typeof vi.fn>; |
| 302 | + error: ReturnType<typeof vi.fn>; |
| 303 | +} { |
| 304 | + return { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv & { |
| 305 | + log: ReturnType<typeof vi.fn>; |
| 306 | + error: ReturnType<typeof vi.fn>; |
| 307 | + }; |
| 308 | +} |
| 309 | + |
| 310 | +/** Set up spies for a standard successful Discord delivery. */ |
| 311 | +function setupSuccessfulDelivery() { |
| 312 | + deliverSpy.mockResolvedValue([ |
| 313 | + { channel: "discord", messageId: "msg-1" } as OutboundDeliveryResult, |
| 314 | + ]); |
| 315 | + deliveryPlanSpy.mockReturnValue({ |
| 316 | + baseDelivery: {} as unknown as AgentDeliveryPlan["baseDelivery"], |
| 317 | + resolvedChannel: "discord", |
| 318 | + resolvedTo: "channel:123456", |
| 319 | + resolvedAccountId: "bot-1", |
| 320 | + }); |
| 321 | + outboundTargetSpy.mockReturnValue({ |
| 322 | + resolvedTarget: { |
| 323 | + ok: true as const, |
| 324 | + to: "channel:123456", |
| 325 | + } as ReturnType<typeof agentDeliveryModule.resolveAgentOutboundTarget>["resolvedTarget"], |
| 326 | + resolvedTo: "channel:123456", |
| 327 | + targetMode: "explicit" as const, |
| 328 | + }); |
| 329 | + channelPluginSpy.mockReturnValue({ name: "discord" } as unknown as ReturnType< |
| 330 | + typeof channelPluginsModule.getChannelPlugin |
| 331 | + >); |
| 332 | + isInternalSpy.mockReturnValue(false); |
| 333 | +} |
| 334 | + |
| 335 | +async function runDelivery( |
| 336 | + opts: Record<string, unknown>, |
| 337 | + overrides?: { runtime?: ReturnType<typeof createRuntime> }, |
| 338 | +) { |
| 339 | + const runtime = overrides?.runtime ?? createRuntime(); |
| 340 | + const result = await deliverAgentCommandResult({ |
| 341 | + cfg: {} as unknown as OpenClawConfig, |
| 342 | + deps: {} as unknown as CliDeps, |
| 343 | + runtime, |
| 344 | + opts: opts as unknown as AgentCommandOpts, |
| 345 | + outboundSession: { key: "agent:main:discord:direct:12345" }, |
| 346 | + sessionEntry: { |
| 347 | + lastChannel: "discord", |
| 348 | + lastTo: "channel:123456", |
| 349 | + } as unknown as SessionEntry, |
| 350 | + result: { payloads: [{ text: "hello" }], meta: { durationMs: 1 } }, |
| 351 | + payloads: [{ text: "hello" }], |
| 352 | + }); |
| 353 | + return { runtime, result }; |
| 354 | +} |
| 355 | + |
| 356 | +function logMessages(runtime: ReturnType<typeof createRuntime>): string[] { |
| 357 | + return runtime.log.mock.calls.map((c: unknown[]) => String(c[0])); |
| 358 | +} |
| 359 | + |
| 360 | +// --------------------------------------------------------------------------- |
| 361 | +// Tests |
| 362 | +// --------------------------------------------------------------------------- |
| 363 | + |
| 364 | +describe("deliverAgentCommandResult — delivery status tracking", () => { |
| 365 | + beforeEach(() => { |
| 366 | + // Clear call counts/results but keep spies attached (vi.restoreAllMocks |
| 367 | + // would disconnect them from the module exports). |
| 368 | + deliverSpy.mockReset(); |
| 369 | + deliveryPlanSpy.mockReset(); |
| 370 | + outboundTargetSpy.mockReset(); |
| 371 | + channelPluginSpy.mockReset(); |
| 372 | + isInternalSpy.mockReset(); |
| 373 | + setupSuccessfulDelivery(); |
| 374 | + }); |
| 375 | + |
| 376 | + it("returns deliveryStatus.succeeded=true on successful delivery", async () => { |
| 377 | + const { result, runtime } = await runDelivery({ |
| 378 | + message: "hello", |
| 379 | + deliver: true, |
| 380 | + channel: "discord", |
| 381 | + to: "channel:123456", |
| 382 | + }); |
| 383 | + |
| 384 | + expect(deliverSpy).toHaveBeenCalledOnce(); |
| 385 | + expect(result.deliveryStatus).toEqual({ |
| 386 | + requested: true, |
| 387 | + attempted: true, |
| 388 | + succeeded: true, |
| 389 | + }); |
| 390 | + // No warning log on success |
| 391 | + expect(logMessages(runtime).some((msg) => msg.includes("[delivery]"))).toBe(false); |
| 392 | + }); |
| 393 | + |
| 394 | + it("returns no deliveryStatus when deliver is false", async () => { |
| 395 | + const { result } = await runDelivery({ |
| 396 | + message: "hello", |
| 397 | + deliver: false, |
| 398 | + }); |
| 399 | + |
| 400 | + expect(deliverSpy).not.toHaveBeenCalled(); |
| 401 | + expect(result.deliveryStatus).toBeUndefined(); |
| 402 | + }); |
| 403 | + |
| 404 | + it("logs warning and returns succeeded=false when delivery target is missing", async () => { |
| 405 | + outboundTargetSpy.mockReturnValue({ |
| 406 | + resolvedTarget: null, |
| 407 | + resolvedTo: undefined, |
| 408 | + targetMode: "implicit" as const, |
| 409 | + }); |
| 410 | + |
| 411 | + const { result, runtime } = await runDelivery({ |
| 412 | + message: "hello", |
| 413 | + deliver: true, |
| 414 | + channel: "discord", |
| 415 | + }); |
| 416 | + |
| 417 | + expect(deliverSpy).not.toHaveBeenCalled(); |
| 418 | + expect(result.deliveryStatus).toEqual({ |
| 419 | + requested: true, |
| 420 | + attempted: false, |
| 421 | + succeeded: false, |
| 422 | + }); |
| 423 | + expect( |
| 424 | + logMessages(runtime).some((msg) => |
| 425 | + msg.includes("[delivery] delivery requested but not completed"), |
| 426 | + ), |
| 427 | + ).toBe(true); |
| 428 | + }); |
| 429 | + |
| 430 | + it("logs warning and returns succeeded=false when deliverOutboundPayloads returns empty", async () => { |
| 431 | + deliverSpy.mockResolvedValue([]); |
| 432 | + |
| 433 | + const { result, runtime } = await runDelivery({ |
| 434 | + message: "hello", |
| 435 | + deliver: true, |
| 436 | + channel: "discord", |
| 437 | + to: "channel:123456", |
| 438 | + }); |
| 439 | + |
| 440 | + expect(deliverSpy).toHaveBeenCalledOnce(); |
| 441 | + expect(result.deliveryStatus).toEqual({ |
| 442 | + requested: true, |
| 443 | + attempted: true, |
| 444 | + succeeded: false, |
| 445 | + }); |
| 446 | + expect(logMessages(runtime).some((msg) => msg.includes("delivery returned zero results"))).toBe( |
| 447 | + true, |
| 448 | + ); |
| 449 | + }); |
| 450 | + |
| 451 | + it("catches thrown error in bestEffort mode without re-throwing", async () => { |
| 452 | + deliverSpy.mockRejectedValue(new Error("Discord API timeout")); |
| 453 | + |
| 454 | + const { result, runtime } = await runDelivery({ |
| 455 | + message: "hello", |
| 456 | + deliver: true, |
| 457 | + bestEffortDeliver: true, |
| 458 | + channel: "discord", |
| 459 | + to: "channel:123456", |
| 460 | + }); |
| 461 | + |
| 462 | + expect(result.deliveryStatus).toEqual({ |
| 463 | + requested: true, |
| 464 | + attempted: true, |
| 465 | + succeeded: false, |
| 466 | + error: true, |
| 467 | + }); |
| 468 | + // Error should be logged via logDeliveryError -> runtime.error or runtime.log |
| 469 | + const allOutput = [...runtime.error.mock.calls, ...runtime.log.mock.calls].map((c) => |
| 470 | + String(c[0]), |
| 471 | + ); |
| 472 | + expect(allOutput.some((msg) => msg.includes("Discord API timeout"))).toBe(true); |
| 473 | + // Structured log should report "threw an error", not "zero results" |
| 474 | + expect(allOutput.some((msg) => msg.includes("delivery threw an error"))).toBe(true); |
| 475 | + }); |
| 476 | + |
| 477 | + it("re-throws error when bestEffort is false", async () => { |
| 478 | + deliverSpy.mockRejectedValue(new Error("Discord API timeout")); |
| 479 | + |
| 480 | + await expect( |
| 481 | + runDelivery({ |
| 482 | + message: "hello", |
| 483 | + deliver: true, |
| 484 | + bestEffortDeliver: false, |
| 485 | + channel: "discord", |
| 486 | + to: "channel:123456", |
| 487 | + }), |
| 488 | + ).rejects.toThrow("Discord API timeout"); |
| 489 | + }); |
| 490 | + |
| 491 | + it("returns deliveryStatus on early return when deliver=true but no payloads", async () => { |
| 492 | + const runtime = createRuntime(); |
| 493 | + const result = await deliverAgentCommandResult({ |
| 494 | + cfg: {} as unknown as OpenClawConfig, |
| 495 | + deps: {} as unknown as CliDeps, |
| 496 | + runtime, |
| 497 | + opts: { |
| 498 | + message: "hello", |
| 499 | + deliver: true, |
| 500 | + channel: "discord", |
| 501 | + to: "channel:123456", |
| 502 | + } as unknown as AgentCommandOpts, |
| 503 | + outboundSession: undefined, |
| 504 | + sessionEntry: undefined, |
| 505 | + result: { payloads: [], meta: { durationMs: 1 } }, |
| 506 | + payloads: [], |
| 507 | + }); |
| 508 | + |
| 509 | + expect(result.deliveryStatus).toEqual({ |
| 510 | + requested: true, |
| 511 | + attempted: false, |
| 512 | + succeeded: false, |
| 513 | + }); |
| 514 | + expect(logMessages(runtime)).toContain("No reply from agent."); |
| 515 | + }); |
| 516 | + |
| 517 | + it("returns no deliveryStatus on early return when deliver=false and no payloads", async () => { |
| 518 | + const runtime = createRuntime(); |
| 519 | + const result = await deliverAgentCommandResult({ |
| 520 | + cfg: {} as unknown as OpenClawConfig, |
| 521 | + deps: {} as unknown as CliDeps, |
| 522 | + runtime, |
| 523 | + opts: { |
| 524 | + message: "hello", |
| 525 | + deliver: false, |
| 526 | + } as unknown as AgentCommandOpts, |
| 527 | + outboundSession: undefined, |
| 528 | + sessionEntry: undefined, |
| 529 | + result: { payloads: [], meta: { durationMs: 1 } }, |
| 530 | + payloads: [], |
| 531 | + }); |
| 532 | + |
| 533 | + expect(result.deliveryStatus).toBeUndefined(); |
| 534 | + }); |
| 535 | + |
| 536 | + it("returns succeeded=true with hadPartialFailure when onError fires but results exist", async () => { |
| 537 | + deliverSpy.mockImplementation(async (opts) => { |
| 538 | + // Simulate partial failure: onError fires for one payload, but results still returned |
| 539 | + opts.onError?.(new Error("Payload 2 failed"), { text: "hello" } as never); |
| 540 | + return [{ channel: "discord", messageId: "msg-1" } as OutboundDeliveryResult]; |
| 541 | + }); |
| 542 | + |
| 543 | + const { result, runtime } = await runDelivery({ |
| 544 | + message: "hello", |
| 545 | + deliver: true, |
| 546 | + channel: "discord", |
| 547 | + to: "channel:123456", |
| 548 | + }); |
| 549 | + |
| 550 | + expect(result.deliveryStatus).toEqual({ |
| 551 | + requested: true, |
| 552 | + attempted: true, |
| 553 | + succeeded: true, |
| 554 | + hadPartialFailure: true, |
| 555 | + }); |
| 556 | + // No [delivery] warning — succeeded is true |
| 557 | + expect(logMessages(runtime).some((msg) => msg.includes("[delivery]"))).toBe(false); |
| 558 | + }); |
| 559 | + |
| 560 | + it("logs warning when channel resolves to internal", async () => { |
| 561 | + isInternalSpy.mockReturnValue(true); |
| 562 | + deliveryPlanSpy.mockReturnValue({ |
| 563 | + baseDelivery: {} as unknown as AgentDeliveryPlan["baseDelivery"], |
| 564 | + resolvedChannel: "__internal__", |
| 565 | + resolvedTo: undefined, |
| 566 | + }); |
| 567 | + |
| 568 | + const { result, runtime } = await runDelivery({ |
| 569 | + message: "hello", |
| 570 | + deliver: true, |
| 571 | + bestEffortDeliver: true, |
| 572 | + }); |
| 573 | + |
| 574 | + expect(deliverSpy).not.toHaveBeenCalled(); |
| 575 | + expect(result.deliveryStatus).toEqual({ |
| 576 | + requested: true, |
| 577 | + attempted: false, |
| 578 | + succeeded: false, |
| 579 | + error: true, |
| 580 | + }); |
| 581 | + expect(logMessages(runtime).some((msg) => msg.includes("channel resolved to internal"))).toBe( |
| 582 | + true, |
| 583 | + ); |
| 584 | + }); |
| 585 | + |
| 586 | + it("includes deliveryStatus in JSON output when deliver=true", async () => { |
| 587 | + const { result, runtime } = await runDelivery({ |
| 588 | + message: "hello", |
| 589 | + deliver: true, |
| 590 | + json: true, |
| 591 | + channel: "discord", |
| 592 | + to: "channel:123456", |
| 593 | + }); |
| 594 | + |
| 595 | + expect(result.deliveryStatus).toEqual({ |
| 596 | + requested: true, |
| 597 | + attempted: true, |
| 598 | + succeeded: true, |
| 599 | + }); |
| 600 | + // JSON output should include deliveryStatus |
| 601 | + const jsonOutput = logMessages(runtime).find((msg) => msg.startsWith("{")); |
| 602 | + expect(jsonOutput).toBeDefined(); |
| 603 | + const parsed = JSON.parse(jsonOutput!); |
| 604 | + expect(parsed.deliveryStatus).toEqual({ |
| 605 | + requested: true, |
| 606 | + attempted: true, |
| 607 | + succeeded: true, |
| 608 | + }); |
| 609 | + }); |
| 610 | + |
| 611 | + it("omits deliveryStatus from JSON output when deliver=false", async () => { |
| 612 | + const runtime = createRuntime(); |
| 613 | + isInternalSpy.mockReturnValue(false); |
| 614 | + channelPluginSpy.mockReturnValue({ name: "discord" } as unknown as ReturnType< |
| 615 | + typeof channelPluginsModule.getChannelPlugin |
| 616 | + >); |
| 617 | + await deliverAgentCommandResult({ |
| 618 | + cfg: {} as unknown as OpenClawConfig, |
| 619 | + deps: {} as unknown as CliDeps, |
| 620 | + runtime, |
| 621 | + opts: { |
| 622 | + message: "hello", |
| 623 | + deliver: false, |
| 624 | + json: true, |
| 625 | + channel: "discord", |
| 626 | + } as unknown as AgentCommandOpts, |
| 627 | + outboundSession: undefined, |
| 628 | + sessionEntry: undefined, |
| 629 | + result: { payloads: [{ text: "hello" }], meta: { durationMs: 1 } }, |
| 630 | + payloads: [{ text: "hello" }], |
| 631 | + }); |
| 632 | + |
| 633 | + const jsonOutput = logMessages(runtime).find((msg) => msg.startsWith("{")); |
| 634 | + expect(jsonOutput).toBeDefined(); |
| 635 | + const parsed = JSON.parse(jsonOutput!); |
| 636 | + expect(parsed.deliveryStatus).toBeUndefined(); |
| 637 | + }); |
| 638 | +}); |
0 commit comments