Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
98 changes: 98 additions & 0 deletions tests/plugin.test.js
Original file line number Diff line number Diff line change
@@ -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")
})]
})
}));
});
});