From af64b762459cd1f30e00e7512e16deb8b5ea7dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 15 May 2026 22:47:25 +0200 Subject: [PATCH 1/2] fix: harden workflow runner monitoring Extract workflow runner prompt results defensively so monitor notifications survive OpenCode client response shape changes. --- src/index.js | 23 ++++++++++++++++++-- tests/plugin.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/plugin.test.js diff --git a/src/index.js b/src/index.js index 6b51122..b346656 100644 --- a/src/index.js +++ b/src/index.js @@ -775,6 +775,23 @@ 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) { + return candidateParts.map(part => part?.text || "").filter(Boolean).join("\n").trim(); + } + + 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 +837,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..ddeecbb --- /dev/null +++ b/tests/plugin.test.js @@ -0,0 +1,50 @@ +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; +} + +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 new Promise(resolve => setTimeout(resolve, 10)); + + 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.") + })] + }) + })); + }); +}); From 1d3af4f6aa3011c5c3c904075755a2fa203b62d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 00:07:58 +0200 Subject: [PATCH 2/2] fix: harden workflow monitor result extraction Reuse text-part extraction semantics before falling back to other prompt result fields, and replace the brittle monitor test delay with a poll for the notification call. --- src/index.js | 3 ++- tests/plugin.test.js | 50 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index b346656..23d9082 100644 --- a/src/index.js +++ b/src/index.js @@ -778,7 +778,8 @@ function getModePromptFragment(agentId, operatingTeamMode, worktree) { function extractPromptResultText(runResult) { const candidateParts = runResult?.data?.parts || runResult?.parts || []; if (Array.isArray(candidateParts) && candidateParts.length > 0) { - return candidateParts.map(part => part?.text || "").filter(Boolean).join("\n").trim(); + const partsText = extractTextParts(candidateParts); + if (partsText) return partsText; } const text = runResult?.data?.text || runResult?.text || runResult?.data?.message || runResult?.message; diff --git a/tests/plugin.test.js b/tests/plugin.test.js index ddeecbb..1559192 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -12,6 +12,14 @@ function createTestEnv(configText) { 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([ @@ -35,7 +43,7 @@ describe("NomadWorks workflow runner monitoring", () => { { task_path: "missing-task.md", instructions: "Audit only." }, { worktree, sessionId: "pma-session" } ); - await new Promise(resolve => setTimeout(resolve, 10)); + await waitForMockCall(promptAsync); expect(result).toContain("SUCCESS: Workflow Runner session started"); expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ @@ -47,4 +55,44 @@ describe("NomadWorks workflow runner monitoring", () => { }) })); }); + + 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") + })] + }) + })); + }); });