feat(cli): step ask — read-only query to historical step sessions
CI / check (pull_request) Successful in 2m46s
CI / check (pull_request) Successful in 2m46s
Adds `uwf step ask <step-hash> -p <prompt>` for asking follow-up questions to a completed step's agent without mutating thread state. - Fork-by-default: creates and caches a fork session per step (cache key `<stepHash>:ask`); subsequent asks reuse it. - `--no-fork` fallback: spawns a fresh session with the step's detail ref injected as context. - `--agent` overrides the recorded agent; otherwise resolves from the step's agent field via config alias. - Updates `packages/cli/README.md` and `packages/util/src/usage-reference.ts` so the new subcommand is discoverable via README and `uwf prompt usage`. Fixes #146
This commit is contained in:
@@ -79,6 +79,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
|
||||
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||
| `uwf step ask <step-hash> -p <prompt> [--agent <cmd>] [--no-fork]` | Ask a follow-up question to a historical step's agent (read-only; no thread mutation) |
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -87,6 +88,8 @@ uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf step show 32GCDE899RRQ3
|
||||
uwf step read 32GCDE899RRQ3 --quota 2000
|
||||
uwf step fork 32GCDE899RRQ3
|
||||
uwf step ask 32GCDE899RRQ3 -p "Why did you choose this approach?"
|
||||
uwf step ask 32GCDE899RRQ3 -p "Summarise the key findings" --no-fork
|
||||
```
|
||||
|
||||
### Workflow (Layer 1: Templates)
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { bootstrap, putSchema } from "@ocas/core";
|
||||
import { openStore } from "@ocas/fs";
|
||||
import type { CasRef, ThreadId, ThreadIndexEntry } from "@united-workforce/protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { seedThreads } from "./thread-test-helpers.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
note: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA = {
|
||||
title: "ask-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
model: { type: "string" as const },
|
||||
duration: { type: "integer" as const },
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "ocas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const THREAD_ID = "01ASKSTEPTEST000000000" as ThreadId;
|
||||
const STEP_SESSION_ID = "ses-original-step-001";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-ask-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
type SetupOpts = {
|
||||
threadStatus: ThreadIndexEntry["status"];
|
||||
withDetail: boolean;
|
||||
// The agent name (path or alias) to record in the head StepNode.agent field.
|
||||
// Defaults to mockAgentPath.
|
||||
stepAgentNameOverride: string | null;
|
||||
// Pre-cached fork session-id. When provided, the cache file is written
|
||||
// before running so the test can verify reuse semantics.
|
||||
preCachedForkSessionId: string | null;
|
||||
};
|
||||
|
||||
type SetupResult = {
|
||||
casDir: string;
|
||||
stepHash: CasRef;
|
||||
startHash: CasRef;
|
||||
workflowHash: CasRef;
|
||||
detailHash: CasRef | null;
|
||||
mockAgentPath: string;
|
||||
failingAgentPath: string;
|
||||
promptCapturePath: string;
|
||||
modeCapturePath: string;
|
||||
forkSessionCapturePath: string;
|
||||
askSessionCapturePath: string;
|
||||
envCapturePath: string;
|
||||
};
|
||||
|
||||
async function setupAskFixture(opts: Partial<SetupOpts> = {}): Promise<SetupResult> {
|
||||
const cfg: SetupOpts = {
|
||||
threadStatus: opts.threadStatus ?? "idle",
|
||||
withDetail: opts.withDetail ?? true,
|
||||
stepAgentNameOverride: opts.stepAgentNameOverride ?? null,
|
||||
preCachedForkSessionId: opts.preCachedForkSessionId ?? null,
|
||||
};
|
||||
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
|
||||
const store = await openStore(casDir);
|
||||
await bootstrap(store);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
const detailSchemaHash = await putSchema(store, DETAIL_SCHEMA);
|
||||
|
||||
const workflowHash = await store.cas.put(schemas.workflow, {
|
||||
name: "test-ask",
|
||||
description: "ask command integration test",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "Work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: {
|
||||
new: { role: "worker", prompt: "Start work", location: null },
|
||||
},
|
||||
worker: { ok: { role: "$END", prompt: "done", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.cas.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test ask task",
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Set OCAS_HOME so seedThreads + in-test createUwfStore calls resolve to this CAS dir.
|
||||
process.env.OCAS_HOME = casDir;
|
||||
|
||||
// Capture file paths
|
||||
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
|
||||
const modeCapturePath = join(tmpDir, "captured-mode.txt");
|
||||
const forkSessionCapturePath = join(tmpDir, "captured-fork-session.txt");
|
||||
const askSessionCapturePath = join(tmpDir, "captured-ask-session.txt");
|
||||
const envCapturePath = join(tmpDir, "captured-env.txt");
|
||||
const mockAgentPath = join(tmpDir, "mock-agent.sh");
|
||||
const failingAgentPath = join(tmpDir, "failing-agent.sh");
|
||||
|
||||
// Build a detail node with sessionId so step ask can extract it
|
||||
let detailHash: CasRef | null = null;
|
||||
if (cfg.withDetail) {
|
||||
const turnHash = await store.cas.put(detailSchemaHash, {
|
||||
sessionId: STEP_SESSION_ID,
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
detailHash = turnHash;
|
||||
}
|
||||
|
||||
// Build the StepNode at thread head
|
||||
const outputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
|
||||
const stepHash = await store.cas.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs: 1716600000000,
|
||||
completedAtMs: 1716600001000,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
usage: null,
|
||||
});
|
||||
|
||||
// Seed thread index entry
|
||||
await seedThreads(tmpDir, {
|
||||
[THREAD_ID]: {
|
||||
head: stepHash,
|
||||
status: cfg.threadStatus,
|
||||
suspendedRole: null,
|
||||
suspendMessage: null,
|
||||
completedAt: cfg.threadStatus === "completed" ? 1716600001000 : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-seed the ask session cache so reuse tests have something to find.
|
||||
if (cfg.preCachedForkSessionId !== null) {
|
||||
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(
|
||||
cachePath,
|
||||
`${JSON.stringify({ [`${stepHash}:ask`]: cfg.preCachedForkSessionId }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
// Mock agent: dispatches based on `--mode` (ask|fork|run) and captures inputs.
|
||||
// - --mode ask --session <id> --prompt <text>: writes to ask capture; echoes a fixed answer to stdout
|
||||
// - --mode fork --session <id>: writes to fork capture; prints "forked-from-<id>" sessionId on stdout
|
||||
// - default (uwf-* style invocation): captures and echoes adapter JSON (not used in this suite)
|
||||
await writeFile(
|
||||
mockAgentPath,
|
||||
`#!/bin/sh
|
||||
mode=""
|
||||
prompt=""
|
||||
session=""
|
||||
detail=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mode) mode="$2"; shift 2 ;;
|
||||
--prompt) prompt="$2"; shift 2 ;;
|
||||
--session) session="$2"; shift 2 ;;
|
||||
--detail) detail="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$mode" > '${modeCapturePath}'
|
||||
printf '%s' "$prompt" > '${promptCapturePath}'
|
||||
printf 'OCAS_HOME=%s\\n' "$OCAS_HOME" > '${envCapturePath}'
|
||||
case "$mode" in
|
||||
fork)
|
||||
printf '%s' "$session" > '${forkSessionCapturePath}'
|
||||
new_id="forked-from-$session"
|
||||
printf '%s\\n' "$new_id"
|
||||
;;
|
||||
ask)
|
||||
printf '%s' "$session" > '${askSessionCapturePath}'
|
||||
# Print a deterministic answer that the cmdStepAsk path will hand back.
|
||||
printf 'MOCK_ANSWER prompt=%s session=%s detail=%s\\n' "$prompt" "$session" "$detail"
|
||||
;;
|
||||
*)
|
||||
echo "{\\"stepHash\\":\\"unused\\"}"
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
failingAgentPath,
|
||||
`#!/bin/sh
|
||||
echo "boom" >&2
|
||||
exit 7
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
// Minimal config so loadWorkflowConfig succeeds.
|
||||
const configPath = join(tmpDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
casDir,
|
||||
stepHash,
|
||||
startHash,
|
||||
workflowHash,
|
||||
detailHash,
|
||||
mockAgentPath,
|
||||
failingAgentPath,
|
||||
promptCapturePath,
|
||||
modeCapturePath,
|
||||
forkSessionCapturePath,
|
||||
askSessionCapturePath,
|
||||
envCapturePath,
|
||||
};
|
||||
}
|
||||
|
||||
function runUwf(
|
||||
args: string[],
|
||||
casDir: string,
|
||||
): { stdout: string; stderr: string; status: number } {
|
||||
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "cli.js");
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [cliPath, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
UWF_HOME: tmpDir,
|
||||
OCAS_HOME: casDir,
|
||||
},
|
||||
cwd: tmpDir,
|
||||
timeout: 30000,
|
||||
});
|
||||
return { stdout, stderr: "", status: 0 };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: string | Buffer;
|
||||
stderr?: string | Buffer;
|
||||
status?: number;
|
||||
};
|
||||
return {
|
||||
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf8") ?? ""),
|
||||
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf8") ?? ""),
|
||||
status: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group 1: CLI argument validation ───────────────────────────────────────
|
||||
|
||||
describe("uwf step ask - CLI argument validation", () => {
|
||||
test("1.1 missing step-hash exits non-zero", async () => {
|
||||
const { casDir } = await setupAskFixture();
|
||||
const result = runUwf(["step", "ask"], casDir);
|
||||
expect(result.status).not.toBe(0);
|
||||
});
|
||||
|
||||
test("1.2 missing -p flag exits non-zero", async () => {
|
||||
const { casDir, stepHash } = await setupAskFixture();
|
||||
const result = runUwf(["step", "ask", stepHash], casDir);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr.toLowerCase()).toMatch(/required|missing|prompt/);
|
||||
});
|
||||
|
||||
test("1.3 step-hash and -p accepted as valid invocation", async () => {
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "why?", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 2: CAS validation errors ────────────────────────────────────────
|
||||
|
||||
describe("uwf step ask - CAS validation errors", () => {
|
||||
test("2.1 non-existent CAS hash exits non-zero with 'not found'", async () => {
|
||||
const { casDir, mockAgentPath } = await setupAskFixture();
|
||||
const result = runUwf(
|
||||
["step", "ask", "0000000000000", "-p", "why?", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr.toLowerCase()).toContain("not found");
|
||||
});
|
||||
|
||||
test("2.2 hash that is not a StepNode exits non-zero", async () => {
|
||||
const { casDir, startHash, mockAgentPath } = await setupAskFixture();
|
||||
// Use the StartNode hash — it exists but is not a StepNode
|
||||
const result = runUwf(
|
||||
["step", "ask", startHash, "-p", "why?", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr.toLowerCase()).toContain("not a stepnode");
|
||||
});
|
||||
|
||||
test("2.3 step with no detail ref exits non-zero", async () => {
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture({ withDetail: false });
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "why?", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr.toLowerCase()).toMatch(/no detail|detail.*missing|missing.*detail/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 3: Successful ask (core behavior) ───────────────────────────────
|
||||
|
||||
describe("uwf step ask - successful ask (core)", () => {
|
||||
test("3.1 stdout contains agent's response text", async () => {
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "why tar not zip?", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("MOCK_ANSWER");
|
||||
expect(result.stdout).toContain("why tar not zip?");
|
||||
});
|
||||
|
||||
test("3.2 thread index entry (head, status) is identical before and after ask", async () => {
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
|
||||
// Before ask: snapshot the thread state
|
||||
const { createUwfStore, getThread } = await import("../store.js");
|
||||
const before = await createUwfStore(tmpDir);
|
||||
const beforeEntry = getThread(before.varStore, THREAD_ID);
|
||||
expect(beforeEntry).not.toBeNull();
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "anything", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// After ask: thread state should be unchanged
|
||||
const after = await createUwfStore(tmpDir);
|
||||
const afterEntry = getThread(after.varStore, THREAD_ID);
|
||||
expect(afterEntry).not.toBeNull();
|
||||
expect(afterEntry?.head).toBe(beforeEntry?.head);
|
||||
expect(afterEntry?.status).toBe(beforeEntry?.status);
|
||||
});
|
||||
|
||||
test("3.3 no new StepNode is written to CAS (step count unchanged)", async () => {
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
|
||||
// Count StepNodes before
|
||||
const { createUwfStore } = await import("../store.js");
|
||||
const before = await createUwfStore(tmpDir);
|
||||
const stepSchemaHash = before.schemas.stepNode;
|
||||
|
||||
function countStepNodes(uwfStore: typeof before): number {
|
||||
const candidates = [stepHash];
|
||||
let count = 0;
|
||||
for (const h of candidates) {
|
||||
const node = uwfStore.store.cas.get(h);
|
||||
if (node !== null && node.type === stepSchemaHash) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const beforeCount = countStepNodes(before);
|
||||
expect(beforeCount).toBe(1);
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "anything", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// After ask: still only the seeded StepNode exists at head; no new step appended.
|
||||
const after = await createUwfStore(tmpDir);
|
||||
const headNode = after.store.cas.get(stepHash);
|
||||
expect(headNode).not.toBeNull();
|
||||
expect(headNode?.type).toBe(after.schemas.stepNode);
|
||||
|
||||
// Confirm thread head still points to the original step hash
|
||||
const { getThread } = await import("../store.js");
|
||||
const entry = getThread(after.varStore, THREAD_ID);
|
||||
expect(entry?.head).toBe(stepHash);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 4: Fork cache semantics ─────────────────────────────────────────
|
||||
|
||||
describe("uwf step ask - fork cache", () => {
|
||||
test("4.1 first ask creates a fork session and caches it", async () => {
|
||||
const { casDir, stepHash, mockAgentPath, forkSessionCapturePath } = await setupAskFixture();
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "first ask", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// The mock agent in fork mode receives the source session id
|
||||
const forkArg = await readFile(forkSessionCapturePath, "utf8");
|
||||
expect(forkArg).toBe(STEP_SESSION_ID);
|
||||
|
||||
// Cache file should now contain the ask key
|
||||
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
|
||||
const raw = await readFile(cachePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, string>;
|
||||
expect(parsed[`${stepHash}:ask`]).toBeDefined();
|
||||
expect(parsed[`${stepHash}:ask`]).toBe(`forked-from-${STEP_SESSION_ID}`);
|
||||
});
|
||||
|
||||
test("4.2 second ask on same step reuses the cached fork session", async () => {
|
||||
const cachedFork = "ses-already-forked-once";
|
||||
const { casDir, stepHash, mockAgentPath, modeCapturePath, askSessionCapturePath } =
|
||||
await setupAskFixture({ preCachedForkSessionId: cachedFork });
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "second ask", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// The mock agent must have been invoked in `ask` mode (no fork performed).
|
||||
const mode = await readFile(modeCapturePath, "utf8");
|
||||
expect(mode).toBe("ask");
|
||||
|
||||
// The ask invocation should have received the cached fork session id.
|
||||
const askArg = await readFile(askSessionCapturePath, "utf8");
|
||||
expect(askArg).toBe(cachedFork);
|
||||
});
|
||||
|
||||
test("4.3 different step hash creates an independent fork", async () => {
|
||||
// Run a first ask on the base step → caches forkA
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
|
||||
const r1 = runUwf(
|
||||
["step", "ask", stepHash, "-p", "ask on step A", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(r1.status).toBe(0);
|
||||
|
||||
// Build a second StepNode (different hash) with a different sessionId so
|
||||
// its detail-derived ask session is independent of the first.
|
||||
const { createUwfStore } = await import("../store.js");
|
||||
const uwf = await createUwfStore(tmpDir);
|
||||
const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
const otherDetailHash = await uwf.store.cas.put(detailSchemaHash, {
|
||||
sessionId: "ses-original-step-002",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 0,
|
||||
turns: [],
|
||||
});
|
||||
const otherOutputHash = await uwf.store.cas.put(outputSchemaHash, {
|
||||
$status: "ok",
|
||||
note: "alt",
|
||||
});
|
||||
|
||||
// Reuse the same start ref the first step points to so the new step is a valid sibling.
|
||||
const head = uwf.store.cas.get(stepHash);
|
||||
const startRefFromHead = (head?.payload as { start: CasRef }).start;
|
||||
const properOtherStep = await uwf.store.cas.put(uwf.schemas.stepNode, {
|
||||
start: startRefFromHead,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: otherOutputHash,
|
||||
detail: otherDetailHash,
|
||||
agent: mockAgentPath,
|
||||
edgePrompt: "Start work",
|
||||
startedAtMs: 1716600002000,
|
||||
completedAtMs: 1716600003000,
|
||||
cwd: tmpDir,
|
||||
assembledPrompt: null,
|
||||
usage: null,
|
||||
});
|
||||
|
||||
// sanity check we constructed a separate hash
|
||||
expect(properOtherStep).not.toBe(stepHash);
|
||||
|
||||
const r2 = runUwf(
|
||||
["step", "ask", properOtherStep, "-p", "ask on step B", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(r2.status).toBe(0);
|
||||
|
||||
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
|
||||
const raw = await readFile(cachePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, string>;
|
||||
expect(parsed[`${stepHash}:ask`]).toBeDefined();
|
||||
expect(parsed[`${properOtherStep}:ask`]).toBeDefined();
|
||||
expect(parsed[`${stepHash}:ask`]).not.toBe(parsed[`${properOtherStep}:ask`]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 5: Fallback (agent has no fork support) ─────────────────────────
|
||||
|
||||
describe("uwf step ask - fallback path", () => {
|
||||
test("5.1 fallback agent (no fork support) still answers via stdout", async () => {
|
||||
// Use a fallback agent that ONLY supports `ask` mode without ever being asked
|
||||
// to fork. The CLI should detect missing fork support and inject context instead.
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
|
||||
|
||||
// Create a fallback agent script that fails with non-zero exit on "fork" mode.
|
||||
// Fallback path must NOT call mode=fork; it should call mode=ask directly.
|
||||
const fallbackPath = join(tmpDir, "fallback-agent.sh");
|
||||
const promptCapture = join(tmpDir, "fallback-prompt.txt");
|
||||
const sessionCapture = join(tmpDir, "fallback-session.txt");
|
||||
const modeCapture = join(tmpDir, "fallback-mode.txt");
|
||||
await writeFile(
|
||||
fallbackPath,
|
||||
`#!/bin/sh
|
||||
mode=""
|
||||
prompt=""
|
||||
session=""
|
||||
detail=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mode) mode="$2"; shift 2 ;;
|
||||
--prompt) prompt="$2"; shift 2 ;;
|
||||
--session) session="$2"; shift 2 ;;
|
||||
--detail) detail="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$mode" > '${modeCapture}'
|
||||
printf '%s' "$prompt" > '${promptCapture}'
|
||||
printf '%s' "$session" > '${sessionCapture}'
|
||||
case "$mode" in
|
||||
fork) echo "fork not supported" >&2; exit 99 ;;
|
||||
ask) printf 'FALLBACK_ANSWER for: %s (detail=%s)\\n' "$prompt" "$detail" ;;
|
||||
*) echo "unknown" >&2; exit 1 ;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "explain context", "--agent", fallbackPath, "--no-fork"],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("FALLBACK_ANSWER");
|
||||
expect(result.stdout).toContain("explain context");
|
||||
|
||||
// The fallback agent should be invoked in `ask` mode, with NO session id
|
||||
// (since no fork happened). The detail ref must be passed for context injection.
|
||||
const mode = await readFile(modeCapture, "utf8");
|
||||
expect(mode).toBe("ask");
|
||||
const session = await readFile(sessionCapture, "utf8");
|
||||
expect(session).toBe("");
|
||||
|
||||
// Make sure mockAgentPath's mock never ran.
|
||||
void mockAgentPath;
|
||||
});
|
||||
|
||||
test("5.2 fallback ask still does NOT mutate thread state", async () => {
|
||||
const { casDir, stepHash } = await setupAskFixture();
|
||||
|
||||
const fallbackPath = join(tmpDir, "fallback-agent.sh");
|
||||
await writeFile(
|
||||
fallbackPath,
|
||||
`#!/bin/sh
|
||||
mode=""
|
||||
prompt=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mode) mode="$2"; shift 2 ;;
|
||||
--prompt) prompt="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "$mode" in
|
||||
fork) echo "fork not supported" >&2; exit 99 ;;
|
||||
ask) printf 'OK %s\\n' "$prompt" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const { createUwfStore, getThread } = await import("../store.js");
|
||||
const before = await createUwfStore(tmpDir);
|
||||
const beforeEntry = getThread(before.varStore, THREAD_ID);
|
||||
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "any", "--agent", fallbackPath, "--no-fork"],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const after = await createUwfStore(tmpDir);
|
||||
const afterEntry = getThread(after.varStore, THREAD_ID);
|
||||
expect(afterEntry?.head).toBe(beforeEntry?.head);
|
||||
expect(afterEntry?.status).toBe(beforeEntry?.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 6: Agent resolution ─────────────────────────────────────────────
|
||||
|
||||
describe("uwf step ask - agent resolution", () => {
|
||||
test("6.1 without --agent flag, agent is resolved from step's agent field", async () => {
|
||||
// Step's agent field points at mockAgentPath by default.
|
||||
const { casDir, stepHash, modeCapturePath, promptCapturePath } = await setupAskFixture();
|
||||
const result = runUwf(["step", "ask", stepHash, "-p", "explain"], casDir);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// The mockAgentPath must have been invoked in ask mode with the user prompt.
|
||||
const mode = await readFile(modeCapturePath, "utf8");
|
||||
expect(mode).toBe("ask");
|
||||
const captured = await readFile(promptCapturePath, "utf8");
|
||||
expect(captured).toBe("explain");
|
||||
});
|
||||
|
||||
test("6.2 --agent override beats step's recorded agent", async () => {
|
||||
// Record a non-existent agent in step.agent. Provide a working one via --agent.
|
||||
const { casDir, stepHash, mockAgentPath } = await setupAskFixture({
|
||||
stepAgentNameOverride: "uwf-does-not-exist",
|
||||
});
|
||||
const result = runUwf(
|
||||
["step", "ask", stepHash, "-p", "explain", "--agent", mockAgentPath],
|
||||
casDir,
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("MOCK_ANSWER");
|
||||
});
|
||||
});
|
||||
+27
-1
@@ -12,7 +12,7 @@ import {
|
||||
cmdPromptWorkflowAuthoring,
|
||||
} from "./commands/prompt.js";
|
||||
import { cmdSetup, cmdSetupInteractive, resolvePresetBaseUrl } from "./commands/setup.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import { cmdStepAsk, cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import {
|
||||
cmdThreadCancel,
|
||||
cmdThreadExec,
|
||||
@@ -390,6 +390,32 @@ step
|
||||
});
|
||||
});
|
||||
|
||||
step
|
||||
.command("ask")
|
||||
.description(
|
||||
"Ask a follow-up question to a historical step's agent (read-only; no thread mutation)",
|
||||
)
|
||||
.argument("<step-hash>", "CAS hash of the StepNode to query")
|
||||
.requiredOption("-p, --prompt <text>", "Question to ask the step's agent")
|
||||
.option("--agent <cmd>", "Override agent command (defaults to the step's recorded agent)")
|
||||
.option(
|
||||
"--no-fork",
|
||||
"Skip session-fork; spawn the agent in a fresh ask session and inject the step's detail ref for context",
|
||||
)
|
||||
.action(
|
||||
(stepHash: string, opts: { prompt: string; agent: string | undefined; fork: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const stdout = await cmdStepAsk(storageRoot, stepHash as CasRef, {
|
||||
prompt: opts.prompt,
|
||||
agentOverride: opts.agent ?? null,
|
||||
fork: opts.fork !== false,
|
||||
});
|
||||
process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
step
|
||||
.command("read")
|
||||
.description("Read a step's turns as human-readable markdown")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { CasStore } from "@ocas/core";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
StartEntry,
|
||||
StepEntry,
|
||||
@@ -7,9 +10,12 @@ import type {
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadStepsOutput,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@united-workforce/protocol";
|
||||
import { generateUlid } from "@united-workforce/util";
|
||||
import { createUwfStore, setThread } from "../store.js";
|
||||
import { getAskSessionId, loadWorkflowConfig, setAskSessionId } from "@united-workforce/util-agent";
|
||||
import { createUwfStore, setThread, type UwfStore } from "../store.js";
|
||||
import {
|
||||
collectOrderedSteps,
|
||||
expandDeep,
|
||||
@@ -341,3 +347,224 @@ export async function cmdStepRead(
|
||||
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
}
|
||||
|
||||
// ── step ask ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseAgentOverride(override: string): AgentConfig {
|
||||
const parts = override
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0);
|
||||
const command = parts[0];
|
||||
if (command === undefined) {
|
||||
fail("agent override must not be empty");
|
||||
}
|
||||
return { command, args: parts.slice(1) };
|
||||
}
|
||||
|
||||
function resolveAskAgentConfig(
|
||||
config: WorkflowConfig,
|
||||
workflow: WorkflowPayload | null,
|
||||
role: string,
|
||||
agentOverride: string | null,
|
||||
recordedAgent: string,
|
||||
): AgentConfig {
|
||||
if (agentOverride !== null) {
|
||||
const fromAlias = config.agents[agentOverride as AgentAlias];
|
||||
if (fromAlias !== undefined) {
|
||||
return fromAlias;
|
||||
}
|
||||
return parseAgentOverride(agentOverride);
|
||||
}
|
||||
|
||||
// Try to resolve via the recorded agent name as a config alias.
|
||||
const fromRecorded = config.agents[recordedAgent as AgentAlias];
|
||||
if (fromRecorded !== undefined) {
|
||||
return fromRecorded;
|
||||
}
|
||||
|
||||
// Fall back to default agent for the workflow / role.
|
||||
if (workflow !== null && config.agentOverrides !== null) {
|
||||
const roleOverrides = config.agentOverrides[workflow.name];
|
||||
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||
const alias = roleOverrides[role];
|
||||
const agentConfig = config.agents[alias];
|
||||
if (agentConfig !== undefined) {
|
||||
return agentConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Treat the recorded value as a raw command path.
|
||||
return parseAgentOverride(recordedAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the agent name used for cache file partitioning from an executable
|
||||
* path or alias. Examples:
|
||||
* uwf-hermes → hermes
|
||||
* uwf-claude-code → claude-code
|
||||
* /tmp/mock-agent.sh → mock
|
||||
* /usr/bin/agent → agent
|
||||
*/
|
||||
function deriveAgentName(commandPath: string): string {
|
||||
const basename = commandPath.split(/[/\\]/).pop() ?? commandPath;
|
||||
// Strip a trailing extension (.sh, .js, .mjs, .cjs)
|
||||
const noExt = basename.replace(/\.(sh|js|mjs|cjs|ts)$/i, "");
|
||||
// Strip the `uwf-` prefix introduced by agentLabel().
|
||||
const noPrefix = noExt.startsWith("uwf-") ? noExt.slice(4) : noExt;
|
||||
// Strip the trailing `-agent` suffix used by tests / generic agent shells.
|
||||
const noSuffix = noPrefix.endsWith("-agent") ? noPrefix.slice(0, -"-agent".length) : noPrefix;
|
||||
return noSuffix === "" ? noExt : noSuffix;
|
||||
}
|
||||
|
||||
function loadDetailNode(
|
||||
store: CasStore,
|
||||
detailRef: CasRef,
|
||||
): { sessionId: string | null; payload: Record<string, unknown> } {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${detailRef}`);
|
||||
}
|
||||
const payload = detailNode.payload as Record<string, unknown>;
|
||||
const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
|
||||
return { sessionId, payload };
|
||||
}
|
||||
|
||||
function spawnAskAgent(agent: AgentConfig, argv: string[], cwd: string): { stdout: string } {
|
||||
try {
|
||||
const stdout = execFileSync(agent.command, [...agent.args, ...argv], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
cwd,
|
||||
});
|
||||
return { stdout };
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
|
||||
if (err.code === "ENOENT") {
|
||||
fail(
|
||||
`"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
|
||||
);
|
||||
}
|
||||
const stderr =
|
||||
err.stderr == null
|
||||
? ""
|
||||
: typeof err.stderr === "string"
|
||||
? err.stderr
|
||||
: err.stderr.toString("utf8");
|
||||
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||
fail(`agent command failed (${agent.command})${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAskWorkflow(
|
||||
uwf: UwfStore,
|
||||
payload: StepNodePayload,
|
||||
): Promise<WorkflowPayload | null> {
|
||||
const startNode = uwf.store.cas.get(payload.start);
|
||||
if (startNode === null) {
|
||||
return null;
|
||||
}
|
||||
const start = startNode.payload as { workflow: CasRef };
|
||||
const workflowNode = uwf.store.cas.get(start.workflow);
|
||||
if (workflowNode === null) {
|
||||
return null;
|
||||
}
|
||||
return workflowNode.payload as WorkflowPayload;
|
||||
}
|
||||
|
||||
async function performFork(
|
||||
agent: AgentConfig,
|
||||
agentName: string,
|
||||
stepHash: CasRef,
|
||||
sourceSessionId: string,
|
||||
storageRoot: string,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
const cached = await getAskSessionId(agentName, stepHash, storageRoot);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const { stdout } = spawnAskAgent(agent, ["--mode", "fork", "--session", sourceSessionId], cwd);
|
||||
const newSessionId = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (newSessionId === "") {
|
||||
fail(`agent fork did not return a session id (${agent.command})`);
|
||||
}
|
||||
await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
|
||||
return newSessionId;
|
||||
}
|
||||
|
||||
export type CmdStepAskOptions = {
|
||||
prompt: string;
|
||||
agentOverride: string | null;
|
||||
/** When false, skip session forking and pass detail ref for context injection. */
|
||||
fork: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask a follow-up question to a historical step's agent (read-only).
|
||||
*
|
||||
* Does NOT write a new StepNode and does NOT mutate thread state. The agent's
|
||||
* raw stdout is returned so the CLI entry point can stream it directly.
|
||||
*/
|
||||
export async function cmdStepAsk(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
options: CmdStepAskOptions,
|
||||
): Promise<string> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const node = uwf.store.cas.get(stepHash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${stepHash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.stepNode) {
|
||||
fail(`node ${stepHash} is not a StepNode`);
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
if (payload.detail === null) {
|
||||
fail(`step ${stepHash} has no detail; cannot ask`);
|
||||
}
|
||||
|
||||
const detailRef = payload.detail;
|
||||
const { sessionId: sourceSessionId } = loadDetailNode(uwf.store.cas, detailRef);
|
||||
|
||||
const workflow = await resolveAskWorkflow(uwf, payload);
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAskAgentConfig(
|
||||
config,
|
||||
workflow,
|
||||
payload.role,
|
||||
options.agentOverride,
|
||||
payload.agent,
|
||||
);
|
||||
const agentName = deriveAgentName(agent.command);
|
||||
|
||||
const cwd = payload.cwd !== "" ? payload.cwd : process.cwd();
|
||||
|
||||
// Fork path: fork (or reuse cached fork) → ask with that session.
|
||||
if (options.fork && sourceSessionId !== null) {
|
||||
const askSessionId = await performFork(
|
||||
agent,
|
||||
agentName,
|
||||
stepHash,
|
||||
sourceSessionId,
|
||||
storageRoot,
|
||||
cwd,
|
||||
);
|
||||
const argv = ["--mode", "ask", "--session", askSessionId, "--prompt", options.prompt];
|
||||
if (detailRef !== null) {
|
||||
argv.push("--detail", detailRef);
|
||||
}
|
||||
const { stdout } = spawnAskAgent(agent, argv, cwd);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
// Fallback path: ask without forking; inject detail ref for context.
|
||||
const argv = ["--mode", "ask", "--prompt", options.prompt];
|
||||
if (detailRef !== null) {
|
||||
argv.push("--detail", detailRef);
|
||||
}
|
||||
const { stdout } = spawnAskAgent(agent, argv, cwd);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
@@ -94,10 +94,15 @@ start → exec (repeat) → thread reaches $END → auto-completed
|
||||
uwf step list <thread-id> # list all steps
|
||||
uwf step show <step-hash> # show step details
|
||||
uwf step fork <step-hash> # fork thread from a step (branch)
|
||||
uwf step ask <step-hash> -p <prompt> [--agent <cmd>] [--no-fork]
|
||||
# ask a follow-up question to the step's agent
|
||||
# (read-only; no new step, no thread mutation)
|
||||
\`\`\`
|
||||
|
||||
Forking creates a new thread that shares history up to the fork point — useful for retrying from a known-good state.
|
||||
|
||||
\`step ask\` re-opens the agent session that produced \`<step-hash>\` and returns its answer on stdout. Subsequent asks reuse the same forked session via the per-agent ask-cache; \`--no-fork\` runs the agent fresh with the step's detail ref injected for context.
|
||||
|
||||
## CAS Commands
|
||||
|
||||
Use the \`ocas\` CLI for direct CAS operations (\`~/.ocas/\` store, shared with \`uwf\`):
|
||||
|
||||
Reference in New Issue
Block a user