diff --git a/src/index.js b/src/index.js index 6b51122..23d9082 100644 --- a/src/index.js +++ b/src/index.js @@ -775,6 +775,24 @@ function getModePromptFragment(agentId, operatingTeamMode, worktree) { return readResolvedFile(fragmentPath, worktree); } +function extractPromptResultText(runResult) { + const candidateParts = runResult?.data?.parts || runResult?.parts || []; + if (Array.isArray(candidateParts) && candidateParts.length > 0) { + const partsText = extractTextParts(candidateParts); + if (partsText) return partsText; + } + + const text = runResult?.data?.text || runResult?.text || runResult?.data?.message || runResult?.message; + if (typeof text === "string" && text.trim()) return text.trim(); + + if (runResult?.data && Object.keys(runResult.data).length > 0) return JSON.stringify(runResult.data, null, 2); + const resultWithoutEmptyData = runResult && Object.fromEntries( + Object.entries(runResult).filter(([key, value]) => key !== "data" || (value && Object.keys(value).length > 0)) + ); + if (resultWithoutEmptyData && Object.keys(resultWithoutEmptyData).length > 0) return JSON.stringify(resultWithoutEmptyData, null, 2); + return "No final text was returned by the Workflow Runner session."; +} + export default async function NomadWorksPlugin(input) { const worktree = path.resolve(input.worktree || process.cwd()); const debugDir = generatedAgentsDir(worktree); @@ -820,8 +838,10 @@ export default async function NomadWorksPlugin(input) { if (debug) console.log(`[NomadFlow] Workflow Runner session ${sessionId} returned control.`); - // Capture final message and notify PMA - const finalMessage = runResult.data.parts.map(p => p.text).join("\n"); + // Capture final message and notify PMA. OpenCode client versions differ + // in the exact response shape, so extract defensively instead of + // assuming data.parts exists. + const finalMessage = extractPromptResultText(runResult); if (debug) console.log(`[NomadFlow] Attempting to notify PMA session ${pmaSessionId} of completion...`); await client.session.promptAsync({ diff --git a/tests/plugin.test.js b/tests/plugin.test.js new file mode 100644 index 0000000..1559192 --- /dev/null +++ b/tests/plugin.test.js @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { jest } from "@jest/globals"; + +import NomadWorksPlugin from "../src/index.js"; + +function createTestEnv(configText) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nomadworks-plugin-test-")); + fs.mkdirSync(path.join(root, ".nomadworks"), { recursive: true }); + fs.writeFileSync(path.join(root, ".nomadworks", "nomadworks.yaml"), configText, "utf8"); + return root; +} + +async function waitForMockCall(mockFn) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (mockFn.mock.calls.length > 0) return; + await new Promise(resolve => setImmediate(resolve)); + } + throw new Error("Timed out waiting for expected mock call"); +} + +describe("NomadWorks workflow runner monitoring", () => { + test("workflow runner monitor handles prompt results without data.parts", async () => { + const worktree = createTestEnv([ + "enabled: true", + "team_mode: full", + "features:", + " debug_dumps: false", + "" + ].join("\n")); + const promptAsync = jest.fn().mockResolvedValue({ data: true }); + const client = { + session: { + create: jest.fn().mockResolvedValue({ data: { id: "runner-no-parts" } }), + prompt: jest.fn().mockResolvedValue({ data: {} }), + promptAsync + } + }; + const plugin = await NomadWorksPlugin({ worktree, options: {}, client }); + + const result = await plugin.tool.nomadflow_run_workflow.execute( + { task_path: "missing-task.md", instructions: "Audit only." }, + { worktree, sessionId: "pma-session" } + ); + await waitForMockCall(promptAsync); + + expect(result).toContain("SUCCESS: Workflow Runner session started"); + expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ + path: { id: "pma-session" }, + body: expect.objectContaining({ + parts: [expect.objectContaining({ + text: expect.stringContaining("No final text was returned by the Workflow Runner session.") + })] + }) + })); + }); + + test("workflow runner monitor falls back when prompt result parts are empty", async () => { + const worktree = createTestEnv([ + "enabled: true", + "team_mode: full", + "features:", + " debug_dumps: false", + "" + ].join("\n")); + const promptAsync = jest.fn().mockResolvedValue({ data: true }); + const client = { + session: { + create: jest.fn().mockResolvedValue({ data: { id: "runner-empty-parts" } }), + prompt: jest.fn().mockResolvedValue({ + data: { + parts: [{ type: "text", text: " " }], + text: "fallback final summary" + } + }), + promptAsync + } + }; + const plugin = await NomadWorksPlugin({ worktree, options: {}, client }); + + const result = await plugin.tool.nomadflow_run_workflow.execute( + { task_path: "missing-task.md", instructions: "Audit only." }, + { worktree, sessionId: "pma-session" } + ); + await waitForMockCall(promptAsync); + + expect(result).toContain("SUCCESS: Workflow Runner session started"); + expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ + path: { id: "pma-session" }, + body: expect.objectContaining({ + parts: [expect.objectContaining({ + text: expect.stringContaining("fallback final summary") + })] + }) + })); + }); +});