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
7 changes: 6 additions & 1 deletion docs/guides/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,15 @@ Starts a `workflow_runner` session for a complex task.

- Only available in `full` team mode.
- Used for `complex` implementation tasks.
- The runner executes in a separate session and reports completion back to PMA.
- The runner executes in a separate OpenCode session and reports completion back to PMA.
- PMA-created runner sessions are created with `parentID` set to the current PMA session ID, so OpenCode can show the runner under the PMA session in the session tree.
- `parentID` is organizational metadata only. It does **not** inherit parent session context, transcript history, tools, permissions, or active prompts; all required task context must still be included in the workflow instructions and task file.
- Runtime completion relay and concurrency control continue to use NomadWorks' in-memory `activeWorkflows` registry, which tracks PMA session ID, task path, and workflow track for active runner sessions.
- The runner is expected to orchestrate the lifecycle by validating task readiness, delegating implementation and verification work to specialists, and driving the task to delivery or a hard blocker.
- For implementation tasks, the runner must create or append a Workflow Execution Plan in the task file after Pre-Task Sync and before implementation starts.
- The runner must not directly edit product source code, tests, application configuration, or implementation files.
- When a hard blocker is reached, the runner should end its run and return a final summary starting with `HARD BLOCKER:` so the plugin relays it back to the PMA session.
- If the runner delegates to specialist sub-sessions, those sub-sessions are not automatically parented to PMA by `nomadflow_run_workflow` and do not receive context through the PMA runner `parentID`.

## `nomadflow_prompt_workflow`

Expand All @@ -114,3 +118,4 @@ Sends a follow-up prompt to an existing `workflow_runner` session.

- Only available in `full` team mode.
- Useful for bounce-backs, clarifications, and resumed runner work.
- If the session is not already tracked in `activeWorkflows`, this tool resumes monitoring for completion relay to the current PMA session.
102 changes: 99 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,99 @@ const LEGACY_NOMADWORKS_DIRNAME = ".codenomad";

const activeWorkflows = new Map(); // sessionId -> { pmaSessionId, taskPath, track }

function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}

export function extractWorkflowSessionId(sessionResult) {
const candidates = [
sessionResult?.data?.id,
sessionResult?.data?.sessionID,
sessionResult?.data?.sessionId,
sessionResult?.data?.session?.id,
sessionResult?.data?.session?.ID,
sessionResult?.data?.info?.sessionID,
sessionResult?.data?.info?.id,
sessionResult?.id,
sessionResult?.sessionID,
sessionResult?.sessionId,
sessionResult?.session?.id
];

const sessionId = candidates.find(isNonEmptyString);
if (sessionId) return sessionId.trim();

throw new Error("OpenCode session.create response missing session ID. Expected data.id, data.session.id, data.sessionID, or equivalent top-level ID.");
}

function textFromParts(parts) {
if (!Array.isArray(parts)) return "";

return parts
.map(part => {
if (isNonEmptyString(part?.text)) return part.text;
if (isNonEmptyString(part?.content)) return part.content;
if (isNonEmptyString(part?.input)) return part.input;
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}

function textFromMessageList(messages) {
if (!Array.isArray(messages)) return "";

const assistantMessages = messages.filter(message => message?.role === "assistant" || message?.info?.role === "assistant");
const message = assistantMessages.at(-1) || messages.at(-1);
return textFromParts(message?.parts) || (isNonEmptyString(message?.text) ? message.text.trim() : "");
}

export function extractWorkflowFinalMessage(runResult) {
const partSources = [
runResult?.data?.parts,
runResult?.parts,
runResult?.data?.message?.parts,
runResult?.message?.parts,
runResult?.data?.response?.parts,
runResult?.response?.parts,
runResult?.data?.result?.parts,
runResult?.result?.parts,
runResult?.data?.output?.parts,
runResult?.output?.parts
];

for (const parts of partSources) {
const text = textFromParts(parts);
if (text) return text;
}

const messageSources = [
runResult?.data?.messages,
runResult?.messages,
runResult?.data?.result?.messages,
runResult?.result?.messages
];

for (const messages of messageSources) {
const text = textFromMessageList(messages);
if (text) return text;
}

const textSources = [
runResult?.data?.text,
runResult?.text,
runResult?.data?.content,
runResult?.content,
runResult?.data?.output,
runResult?.output
];
const text = textSources.find(isNonEmptyString);
if (text) return text.trim();

throw new Error("OpenCode session.prompt response missing final message text. Expected text in parts, messages, text/content, or output fields.");
}

function nomadworksDir(worktree) {
return path.join(worktree, NOMADWORKS_DIRNAME);
}
Expand Down Expand Up @@ -821,7 +914,7 @@ 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");
const finalMessage = extractWorkflowFinalMessage(runResult);
if (debug) console.log(`[NomadFlow] Attempting to notify PMA session ${pmaSessionId} of completion...`);

await client.session.promptAsync({
Expand Down Expand Up @@ -1157,9 +1250,12 @@ export default async function NomadWorksPlugin(input) {
try {
// 1. Create a new session
const sessionResult = await client.session.create({
body: { title: `Workflow Run: ${path.basename(args.task_path)}` }
body: {
title: `Workflow Run: ${path.basename(args.task_path)}`,
parentID: pmaSessionId
}
});
const sessionId = sessionResult.data.id;
const sessionId = extractWorkflowSessionId(sessionResult);

activeWorkflows.set(sessionId, { pmaSessionId, taskPath: args.task_path, track: workflowTrack });

Expand Down
59 changes: 59 additions & 0 deletions tests/validate.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { nomadworks_validate_logic } from "../src/validate_logic.js";
import NomadWorksPlugin, { extractWorkflowFinalMessage, extractWorkflowSessionId } from "../src/index.js";
import { jest } from "@jest/globals";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
Expand All @@ -23,6 +25,21 @@ function createTestEnv(structure) {
return root;
}

function createFullTeamPluginEnv() {
return createTestEnv({
".nomadworks": {
"nomadworks.yaml": "enabled: true\nteam_mode: full\nfeatures:\n debug_dumps: false\nagents:\n"
},
"tasks": {
"workflow.md": "---\ntrack: implementation\n---\n# Workflow Task"
}
});
}

function nextTick() {
return new Promise(resolve => setImmediate(resolve));
}

describe("nomadworks_validate", () => {
test("Passes on valid hierarchical structure", async () => {
const root = createTestEnv({
Expand Down Expand Up @@ -161,3 +178,45 @@ describe("nomadworks_validate", () => {
expect(result.ok).toBe(true); // Should ignore .png and .txt
});
});

describe("workflow runner session parenting and response extraction", () => {
test("nomadflow_run_workflow creates child session with parentID", async () => {
const root = createFullTeamPluginEnv();
const create = jest.fn(async () => ({ data: { id: "runner-session" } }));
const prompt = jest.fn(async () => ({ data: { parts: [{ type: "text", text: "done" }] } }));
const promptAsync = jest.fn(async () => ({}));

const plugin = await NomadWorksPlugin({
worktree: root,
client: { session: { create, prompt, promptAsync } }
});

const result = await plugin.tool.nomadflow_run_workflow.execute(
{ task_path: "tasks/workflow.md", instructions: "Run task" },
{ worktree: root, sessionId: "pma-session" }
);
await nextTick();

expect(result).toContain("SUCCESS: Workflow Runner session started. ID: runner-session");
expect(create).toHaveBeenCalledWith({
body: {
title: "Workflow Run: workflow.md",
parentID: "pma-session"
}
});
});

test("extractWorkflowSessionId supports guarded session response shapes", () => {
expect(extractWorkflowSessionId({ data: { id: "data-id" } })).toBe("data-id");
expect(extractWorkflowSessionId({ data: { session: { id: "nested-id" } } })).toBe("nested-id");
expect(extractWorkflowSessionId({ sessionID: "top-session-id" })).toBe("top-session-id");
expect(() => extractWorkflowSessionId({ data: {} })).toThrow(/missing session ID/);
});

test("extractWorkflowFinalMessage supports missing data.parts alternatives", () => {
expect(extractWorkflowFinalMessage({ data: { message: { parts: [{ text: "nested parts" }] } } })).toBe("nested parts");
expect(extractWorkflowFinalMessage({ data: { messages: [{ role: "assistant", parts: [{ text: "assistant message" }] }] } })).toBe("assistant message");
expect(extractWorkflowFinalMessage({ data: { text: "plain text" } })).toBe("plain text");
expect(() => extractWorkflowFinalMessage({ data: {} })).toThrow(/missing final message text/);
});
});