| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|
|
| |
| vi.hoisted(() => { |
| process.env.OPENCLAW_TEST_FAST = "1"; |
| }); |
|
|
| import { |
| expectsSubagentFollowup, |
| isLikelyInterimCronMessage, |
| readDescendantSubagentFallbackReply, |
| waitForDescendantSubagentSummary, |
| } from "./subagent-followup.js"; |
|
|
| vi.mock("../../agents/subagent-registry.js", () => ({ |
| listDescendantRunsForRequester: vi.fn().mockReturnValue([]), |
| })); |
|
|
| vi.mock("../../agents/tools/agent-step.js", () => ({ |
| readLatestAssistantReply: vi.fn().mockResolvedValue(undefined), |
| })); |
|
|
| vi.mock("../../gateway/call.js", () => ({ |
| callGateway: vi.fn().mockResolvedValue({ status: "ok" }), |
| })); |
|
|
| const { listDescendantRunsForRequester } = await import("../../agents/subagent-registry.js"); |
| const { readLatestAssistantReply } = await import("../../agents/tools/agent-step.js"); |
| const { callGateway } = await import("../../gateway/call.js"); |
|
|
| async function resolveAfterAdvancingTimers<T>(promise: Promise<T>, advanceMs = 100): Promise<T> { |
| await vi.advanceTimersByTimeAsync(advanceMs); |
| return promise; |
| } |
|
|
| function createDescendantRun(params?: { |
| runId?: string; |
| childSessionKey?: string; |
| task?: string; |
| cleanup?: "keep" | "delete"; |
| endedAt?: number; |
| frozenResultText?: string | null; |
| }) { |
| return { |
| runId: params?.runId ?? "run-1", |
| childSessionKey: params?.childSessionKey ?? "child-1", |
| requesterSessionKey: "test-session", |
| requesterDisplayKey: "test-session", |
| task: params?.task ?? "task-1", |
| cleanup: params?.cleanup ?? "keep", |
| createdAt: 1000, |
| endedAt: params?.endedAt ?? 2000, |
| ...(params?.frozenResultText === undefined |
| ? {} |
| : { frozenResultText: params.frozenResultText }), |
| }; |
| } |
|
|
| describe("isLikelyInterimCronMessage", () => { |
| it("detects 'on it' as interim", () => { |
| expect(isLikelyInterimCronMessage("on it")).toBe(true); |
| }); |
| it("detects subagent-related interim text", () => { |
| expect(isLikelyInterimCronMessage("spawned a subagent, it'll auto-announce when done")).toBe( |
| true, |
| ); |
| }); |
| it("rejects substantive content", () => { |
| expect(isLikelyInterimCronMessage("Here are your results: revenue was $5000 this month")).toBe( |
| false, |
| ); |
| }); |
| it("does not treat empty as interim (empty = NO_REPLY was stripped)", () => { |
| expect(isLikelyInterimCronMessage("")).toBe(false); |
| }); |
|
|
| it("does not treat whitespace-only as interim", () => { |
| expect(isLikelyInterimCronMessage(" ")).toBe(false); |
| }); |
| }); |
|
|
| describe("expectsSubagentFollowup", () => { |
| it("returns true for subagent spawn hints", () => { |
| expect(expectsSubagentFollowup("subagent spawned")).toBe(true); |
| expect(expectsSubagentFollowup("spawned a subagent")).toBe(true); |
| expect(expectsSubagentFollowup("it'll auto-announce when done")).toBe(true); |
| expect(expectsSubagentFollowup("both subagents are running")).toBe(true); |
| }); |
| it("returns false for plain interim text", () => { |
| expect(expectsSubagentFollowup("on it")).toBe(false); |
| expect(expectsSubagentFollowup("working on it")).toBe(false); |
| }); |
| it("returns false for empty string", () => { |
| expect(expectsSubagentFollowup("")).toBe(false); |
| }); |
| }); |
|
|
| describe("readDescendantSubagentFallbackReply", () => { |
| const runStartedAt = 1000; |
|
|
| it("returns undefined when no descendants exist", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBeUndefined(); |
| }); |
|
|
| it("reads reply from child session transcript", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([createDescendantRun()]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("child output text"); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBe("child output text"); |
| }); |
|
|
| it("falls back to frozenResultText when session transcript unavailable", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| createDescendantRun({ |
| cleanup: "delete", |
| frozenResultText: "frozen child output", |
| }), |
| ]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBe("frozen child output"); |
| }); |
|
|
| it("prefers session transcript over frozenResultText", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| createDescendantRun({ frozenResultText: "frozen text" }), |
| ]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("live transcript text"); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBe("live transcript text"); |
| }); |
|
|
| it("joins replies from multiple descendants", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| createDescendantRun({ frozenResultText: "first child output" }), |
| createDescendantRun({ |
| runId: "run-2", |
| childSessionKey: "child-2", |
| task: "task-2", |
| endedAt: 3000, |
| frozenResultText: "second child output", |
| }), |
| ]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBe("first child output\n\nsecond child output"); |
| }); |
|
|
| it("skips SILENT_REPLY_TOKEN descendants", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| createDescendantRun(), |
| createDescendantRun({ |
| runId: "run-2", |
| childSessionKey: "child-2", |
| task: "task-2", |
| endedAt: 3000, |
| frozenResultText: "useful output", |
| }), |
| ]); |
| vi.mocked(readLatestAssistantReply).mockImplementation(async (params) => { |
| if (params.sessionKey === "child-1") { |
| return "NO_REPLY"; |
| } |
| return undefined; |
| }); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBe("useful output"); |
| }); |
|
|
| it("returns undefined when frozenResultText is null", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| createDescendantRun({ |
| cleanup: "delete", |
| frozenResultText: null, |
| }), |
| ]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBeUndefined(); |
| }); |
|
|
| it("ignores descendants that ended before run started", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([ |
| { |
| runId: "run-1", |
| childSessionKey: "child-1", |
| requesterSessionKey: "test-session", |
| requesterDisplayKey: "test-session", |
| task: "task-1", |
| cleanup: "keep", |
| createdAt: 500, |
| endedAt: 900, |
| frozenResultText: "stale output from previous run", |
| }, |
| ]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); |
| const result = await readDescendantSubagentFallbackReply({ |
| sessionKey: "test-session", |
| runStartedAt, |
| }); |
| expect(result).toBeUndefined(); |
| }); |
| }); |
|
|
| describe("waitForDescendantSubagentSummary", () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| vi.useRealTimers(); |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); |
| vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| it("returns initialReply immediately when no active descendants and observedActiveDescendants=false", async () => { |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); |
| const result = await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "on it", |
| timeoutMs: 100, |
| observedActiveDescendants: false, |
| }); |
| expect(result).toBe("on it"); |
| expect(callGateway).not.toHaveBeenCalled(); |
| }); |
|
|
| it("awaits active descendants via agent.wait and returns synthesis after grace period", async () => { |
| |
| vi.mocked(listDescendantRunsForRequester) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-abc", |
| childSessionKey: "child-session", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "morning briefing", |
| cleanup: "keep", |
| createdAt: 1000, |
| |
| }, |
| ]) |
| .mockReturnValue([]); |
|
|
| vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("Morning briefing complete!"); |
|
|
| const result = await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "on it", |
| timeoutMs: 30_000, |
| observedActiveDescendants: true, |
| }); |
|
|
| expect(result).toBe("Morning briefing complete!"); |
| |
| expect(callGateway).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| method: "agent.wait", |
| params: expect.objectContaining({ runId: "run-abc" }), |
| }), |
| ); |
| }); |
|
|
| it("returns undefined when descendants finish but only interim text remains after grace period", async () => { |
| vi.useFakeTimers(); |
| |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); |
| |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("on it"); |
|
|
| const resultPromise = waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "on it", |
| timeoutMs: 100, |
| observedActiveDescendants: true, |
| }); |
|
|
| const result = await resolveAfterAdvancingTimers(resultPromise); |
|
|
| expect(result).toBeUndefined(); |
| }); |
|
|
| it("returns synthesis even if initial reply was undefined", async () => { |
| vi.mocked(listDescendantRunsForRequester) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-xyz", |
| childSessionKey: "child-2", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "report", |
| cleanup: "keep", |
| createdAt: 1000, |
| }, |
| ]) |
| .mockReturnValue([]); |
|
|
| vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("Report generated successfully."); |
|
|
| const result = await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: undefined, |
| timeoutMs: 30_000, |
| observedActiveDescendants: true, |
| }); |
|
|
| expect(result).toBe("Report generated successfully."); |
| }); |
|
|
| it("uses agent.wait for each active run when multiple descendants exist", async () => { |
| vi.mocked(listDescendantRunsForRequester) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-1", |
| childSessionKey: "child-1", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "task-1", |
| cleanup: "keep", |
| createdAt: 1000, |
| }, |
| { |
| runId: "run-2", |
| childSessionKey: "child-2", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "task-2", |
| cleanup: "keep", |
| createdAt: 1000, |
| }, |
| ]) |
| .mockReturnValue([]); |
|
|
| vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("All tasks complete."); |
|
|
| await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "spawned a subagent", |
| timeoutMs: 30_000, |
| observedActiveDescendants: true, |
| }); |
|
|
| |
| const waitCalls = vi |
| .mocked(callGateway) |
| .mock.calls.filter((c) => (c[0] as { method?: string }).method === "agent.wait"); |
| expect(waitCalls).toHaveLength(2); |
| const runIds = waitCalls.map((c) => (c[0] as { params: { runId: string } }).params.runId); |
| expect(runIds).toContain("run-1"); |
| expect(runIds).toContain("run-2"); |
| }); |
|
|
| it("waits for newly discovered active descendants after the first wait round", async () => { |
| vi.mocked(listDescendantRunsForRequester) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-1", |
| childSessionKey: "child-1", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "task-1", |
| cleanup: "keep", |
| createdAt: 1000, |
| }, |
| ]) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-2", |
| childSessionKey: "child-2", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "task-2", |
| cleanup: "keep", |
| createdAt: 1001, |
| }, |
| ]) |
| .mockReturnValue([]); |
|
|
| vi.mocked(callGateway).mockResolvedValue({ status: "ok" }); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("Nested descendant work complete."); |
|
|
| const result = await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "spawned a subagent", |
| timeoutMs: 30_000, |
| observedActiveDescendants: true, |
| }); |
|
|
| expect(result).toBe("Nested descendant work complete."); |
| const waitedRunIds = vi |
| .mocked(callGateway) |
| .mock.calls.filter((c) => (c[0] as { method?: string }).method === "agent.wait") |
| .map((c) => (c[0] as { params: { runId: string } }).params.runId); |
| expect(waitedRunIds).toEqual(["run-1", "run-2"]); |
| }); |
|
|
| it("handles agent.wait errors gracefully and still reads the synthesis", async () => { |
| vi.mocked(listDescendantRunsForRequester) |
| .mockReturnValueOnce([ |
| { |
| runId: "run-err", |
| childSessionKey: "child-err", |
| requesterSessionKey: "cron-session", |
| requesterDisplayKey: "cron-session", |
| task: "task-err", |
| cleanup: "keep", |
| createdAt: 1000, |
| }, |
| ]) |
| .mockReturnValue([]); |
|
|
| vi.mocked(callGateway).mockRejectedValue(new Error("gateway unavailable")); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("Completed despite gateway error."); |
|
|
| const result = await waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "on it", |
| timeoutMs: 30_000, |
| observedActiveDescendants: true, |
| }); |
|
|
| expect(result).toBe("Completed despite gateway error."); |
| }); |
|
|
| it("skips NO_REPLY synthesis and returns undefined", async () => { |
| vi.useFakeTimers(); |
| vi.mocked(listDescendantRunsForRequester).mockReturnValue([]); |
| vi.mocked(readLatestAssistantReply).mockResolvedValue("NO_REPLY"); |
|
|
| const resultPromise = waitForDescendantSubagentSummary({ |
| sessionKey: "cron-session", |
| initialReply: "on it", |
| timeoutMs: 100, |
| observedActiveDescendants: true, |
| }); |
|
|
| const result = await resolveAfterAdvancingTimers(resultPromise); |
|
|
| expect(result).toBeUndefined(); |
| }); |
| }); |
|
|