Compare commits

..

11 Commits

Author SHA1 Message Date
xiaoju 69ec8c2c5e release: v0.1.2
CI / check (pull_request) Successful in 3m6s
2026-06-07 15:44:00 +08:00
xingyue 81aa282c92 Merge pull request 'chore: release prep — proman bump + protocol 0.1.1 align' (#152) from release/next into main
CI / check (push) Successful in 2m56s
2026-06-07 07:41:37 +00:00
xingyue a620defbcf chore: bump versions via proman (protocol 0.1.1 align npm + session-resume fix)
CI / check (pull_request) Successful in 3m19s
2026-06-07 15:35:15 +08:00
scottwei 439891f6b6 Merge pull request 'revert: undo #150 release bump (changeset + version bump 不应由依赖升级触发)' (#151) from revert/150-release-bump into main
CI / check (push) Successful in 3m40s
Reviewed-on: #151
Reviewed-by: scottwei <shazhou.ww@gmail.com>
2026-06-07 07:33:54 +00:00
xingyue df244c52e8 Revert "Merge pull request 'chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3' (#150) from release/bump-ocas-proman into main"
CI / check (pull_request) Successful in 3m45s
This reverts commit 9d0c6df62c, reversing
changes made to 00d960daba.
2026-06-07 15:25:31 +08:00
xiaomo cb6e0d6a11 Merge pull request 'chore: add changeset for session resume fix (#139)' (#141) from chore/139-changeset into main
CI / check (push) Successful in 3m36s
2026-06-07 07:20:36 +00:00
xiaomo 9d0c6df62c Merge pull request 'chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3' (#150) from release/bump-ocas-proman into main
CI / check (push) Successful in 3m1s
2026-06-07 07:18:31 +00:00
xingyue 0f5bb1f191 chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3
CI / check (pull_request) Successful in 2m35s
Published:
- @united-workforce/protocol@0.1.1
- @united-workforce/util-agent@0.1.2
- @united-workforce/agent-builtin@0.1.3
- @united-workforce/agent-claude-code@0.1.4
- @united-workforce/agent-hermes@0.1.5
- @united-workforce/agent-mock@0.1.3
- @united-workforce/cli@0.3.1
- @united-workforce/eval@0.1.6
2026-06-07 15:06:43 +08:00
xiaomo 00d960daba Merge pull request 'chore: bump @ocas/* to ^0.4.0 and @shazhou/proman to ^0.6.3' (#149) from chore/bump-ocas-proman into main
CI / check (push) Successful in 3m7s
2026-06-07 06:57:42 +00:00
xingyue 3a26285872 chore: bump @ocas/* to ^0.4.0 and @shazhou/proman to ^0.6.3
CI / check (pull_request) Successful in 3m28s
2026-06-07 14:12:03 +08:00
xiaoju 13c0812944 chore: add changeset for session resume fix (#139)
CI / check (pull_request) Successful in 2m4s
2026-06-07 03:03:55 +00:00
17 changed files with 77 additions and 776 deletions
-11
View File
@@ -1,11 +0,0 @@
---
"@united-workforce/cli": minor
---
feat(cli): add `uwf thread poke` command
New subcommand `uwf thread poke <thread-id> -p <prompt>` re-runs the head step's
agent with a supplementary prompt, replacing the head step's output. Unlike
`thread resume`, poke skips the moderator and rewrites the new step's `prev`
pointer so the new head replaces (not appends to) the old head. Works on idle
and suspended threads. Resolves issue #144 (Phase 1).
+1 -1
View File
@@ -21,7 +21,7 @@
"@agentclientprotocol/sdk": "^0.22.1",
"@biomejs/biome": "^2.4.14",
"@changesets/cli": "^2.31.0",
"@shazhou/proman": "^0.5.1",
"@shazhou/proman": "^0.6.3",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@united-workforce/agent-hermes": "workspace:*",
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
},
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.4 — 2026-06-07
- fix: decouple session resume from isFirstVisit guard
When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both adapters now always check the session cache regardless of isFirstVisit. When resuming after a frontmatter-only failure (isFirstVisit + cache hit), a minimal correction prompt is sent via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-claude-code",
"version": "0.1.3",
"version": "0.1.4",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+6
View File
@@ -1,5 +1,11 @@
# @united-workforce/agent-hermes
## 0.1.5 — 2026-06-07
- fix: decouple session resume from isFirstVisit guard
When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both adapters now always check the session cache regardless of isFirstVisit. When resuming after a frontmatter-only failure (isFirstVisit + cache hit), a minimal correction prompt is sent via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt.
## 0.1.1
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-hermes",
"version": "0.1.4",
"version": "0.1.5",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^",
+2 -2
View File
@@ -11,8 +11,8 @@
"uwf": "./dist/cli.js"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/fs": "^0.3.0",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^",
@@ -1,549 +0,0 @@
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 { putSchema } from "@ocas/core";
import { openStore } from "@ocas/fs";
import type {
CasRef,
StepNodePayload,
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 THREAD_ID = "01POKESTEPTEST00000000" as ThreadId;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-poke-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
type SetupResult = {
casDir: string;
oldStepHash: CasRef;
oldStepPrev: CasRef | null;
oldStepCompletedAtMs: number;
startHash: CasRef;
workflowHash: CasRef;
mockAgentPath: string;
failingAgentPath: string;
promptCapturePath: string;
envCapturePath: string;
};
type SetupOpts = {
threadStatus: ThreadIndexEntry["status"];
multipleSteps: boolean;
newCompletedAtMs: number;
newStatus: string;
// The agent name to record in the head StepNode.agent field. Defaults to mockAgentPath.
stepAgentNameOverride: string | null;
// Whether to seed an actual head StepNode (false → only StartNode is the head).
withHeadStep: boolean;
};
async function setupThread(opts: Partial<SetupOpts> = {}): Promise<SetupResult> {
const cfg: SetupOpts = {
threadStatus: opts.threadStatus ?? "idle",
multipleSteps: opts.multipleSteps ?? false,
newCompletedAtMs: opts.newCompletedAtMs ?? 1716600005000,
newStatus: opts.newStatus ?? "ok",
stepAgentNameOverride: opts.stepAgentNameOverride ?? null,
withHeadStep: opts.withHeadStep ?? true,
};
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = await openStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.cas.put(schemas.workflow, {
name: "test-poke",
description: "poke command integration test",
roles: {
worker: {
description: "Worker role",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
reviewer: {
description: "Reviewer role",
goal: "Review",
capabilities: [],
procedure: "review",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: {
new: { role: "worker", prompt: "Start work", location: null },
resume: { role: "worker", prompt: "Resume the work", location: null },
},
worker: {
ok: { role: "reviewer", prompt: "Review the work", location: null },
needs_input: {
role: "$SUSPEND",
prompt: "Please clarify",
location: null,
},
},
reviewer: { done: { role: "$END", prompt: "Done", location: null } },
},
});
const startHash = await store.cas.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test poke task",
cwd: tmpDir,
});
process.env.OCAS_HOME = casDir;
// Paths for mock agent and capture files (set early so we can use mockAgentPath as the recorded agent name)
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
const envCapturePath = join(tmpDir, "captured-env.txt");
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const failingAgentPath = join(tmpDir, "failing-agent.sh");
// Build head StepNode chain
let oldStepPrev: CasRef | null = null;
if (cfg.multipleSteps) {
// First step: prev=null
const firstOutputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
const firstDetailHash = await store.cas.put(schemas.text, "first detail");
const firstStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: firstOutputHash,
detail: firstDetailHash,
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600000000,
completedAtMs: 1716600001000,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
oldStepPrev = firstStepHash;
}
let oldStepHash: CasRef = startHash;
const oldStepCompletedAtMs = 1716600002000;
if (cfg.withHeadStep) {
const outputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
const detailHash = await store.cas.put(schemas.text, "head step detail");
oldStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: oldStepPrev,
role: "worker",
output: outputHash,
detail: detailHash,
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600001500,
completedAtMs: oldStepCompletedAtMs,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
}
// Seed thread index entry. For "running" we let the test create the marker separately.
await seedThreads(tmpDir, {
[THREAD_ID]: {
head: oldStepHash,
status: cfg.threadStatus,
suspendedRole: cfg.threadStatus === "suspended" ? "worker" : null,
suspendMessage: cfg.threadStatus === "suspended" ? "Please clarify" : null,
completedAt:
cfg.threadStatus === "completed" || cfg.threadStatus === "cancelled"
? oldStepCompletedAtMs
: null,
},
});
// Mock agent always emits a stepNode keyed off the current thread head (which we
// observe through OCAS_HOME). The script writes prompt/env captures and then prints
// an adapter JSON that references a pre-built stepHash.
// We pre-build the agent's stepHash with prev=oldStepHash (normal append behaviour).
const newOutputHash = await store.cas.put(outputSchemaHash, {
$status: cfg.newStatus,
note: "poked output",
});
const newDetailHash = await store.cas.put(schemas.text, "poked detail");
const agentStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: cfg.withHeadStep ? oldStepHash : null,
role: "worker",
output: newOutputHash,
detail: newDetailHash,
agent: "mock-agent-output",
edgePrompt: "poke prompt placeholder",
startedAtMs: cfg.newCompletedAtMs - 100,
completedAtMs: cfg.newCompletedAtMs,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
const adapterJson = JSON.stringify({
stepHash: agentStepHash,
detailHash: newDetailHash,
role: "worker",
frontmatter: { $status: cfg.newStatus, note: "poked output" },
body: "",
startedAtMs: cfg.newCompletedAtMs - 100,
completedAtMs: cfg.newCompletedAtMs,
usage: null,
});
await writeFile(
mockAgentPath,
`#!/bin/sh
prompt=""
while [ $# -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
prompt="$2"
shift 2
else
shift
fi
done
printf '%s' "$prompt" > '${promptCapturePath}'
printf 'OCAS_HOME=%s\\n' "$OCAS_HOME" > '${envCapturePath}'
echo '${adapterJson}'
`,
{ mode: 0o755 },
);
await writeFile(
failingAgentPath,
`#!/bin/sh
echo "boom" >&2
exit 7
`,
{ mode: 0o755 },
);
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
return {
casDir,
oldStepHash,
oldStepPrev,
oldStepCompletedAtMs,
startHash,
workflowHash,
mockAgentPath,
failingAgentPath,
promptCapturePath,
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 thread poke - CLI argument validation", () => {
test("1.1 missing -p flag exits non-zero", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/required|missing|prompt/);
});
test("1.2 -p without --agent succeeds", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "do it again"], casDir);
expect(result.status).toBe(0);
});
test("1.3 -p with --agent succeeds", async () => {
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "do it again", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
});
});
// ── Group 2: Guard errors ──────────────────────────────────────────────────
describe("uwf thread poke - guard errors", () => {
test("2.1 thread not found", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", "01NOSUCHTHREAD0000000A", "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/not found|not active/);
});
test("2.2 thread running rejects poke", async () => {
const { casDir, workflowHash } = await setupThread();
// Create background marker to simulate running
const { createMarker } = await import("../background/index.js");
await createMarker(tmpDir, {
thread: THREAD_ID,
workflow: workflowHash,
pid: process.pid,
startedAt: Date.now(),
});
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("already executing");
});
test("2.3 completed thread rejects poke", async () => {
const { casDir } = await setupThread({ threadStatus: "completed" });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/cannot be poked|completed/);
});
test("2.4 cancelled thread rejects poke", async () => {
const { casDir } = await setupThread({ threadStatus: "cancelled" });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/cannot be poked|cancelled/);
});
test("2.5 thread head is StartNode (no StepNode) rejects poke", async () => {
const { casDir } = await setupThread({ withHeadStep: false });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/no step|cannot be poked/);
});
});
// ── Group 3: Success happy path ────────────────────────────────────────────
describe("uwf thread poke - success", () => {
test("3.1, 3.4 idle thread → new head differs from old, thread index updated", async () => {
const { casDir, oldStepHash, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.head).not.toBe(oldStepHash);
const { createUwfStore, getThread } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const entry = getThread(uwf.varStore, THREAD_ID);
expect(entry?.head).toBe(cliOutput.head);
});
test("3.2 new step's prev equals old head's prev (replace, not append)", async () => {
const { casDir, oldStepPrev, mockAgentPath } = await setupThread({ multipleSteps: true });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
expect(node).not.toBeNull();
expect(node?.type).toBe(uwf.schemas.stepNode);
const payload = node?.payload as StepNodePayload;
expect(payload.prev).toBe(oldStepPrev);
});
test("3.2b new step's prev is null when old head was the first step", async () => {
// multipleSteps:false means oldHead.prev = null
const { casDir, mockAgentPath } = await setupThread({ multipleSteps: false });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
const payload = node?.payload as StepNodePayload;
expect(payload.prev).toBeNull();
});
test("3.3 new step's completedAtMs is later than old", async () => {
const { casDir, oldStepCompletedAtMs, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
const payload = node?.payload as StepNodePayload;
expect(payload.completedAtMs).toBeGreaterThan(oldStepCompletedAtMs);
});
test("3.5 status remains idle after poke (no completion/suspend)", async () => {
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.status).toBe("idle");
expect(cliOutput.done).toBe(false);
expect(cliOutput.suspendedRole).toBeNull();
expect(cliOutput.suspendMessage).toBeNull();
});
test("3.6 currentRole unchanged after poke (no moderator re-route)", async () => {
// Before poke: idle thread with worker step having $status=ok → moderator would route to reviewer.
// After poke (mock returns same $status=ok), moderator routing remains the same.
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.currentRole).toBe("reviewer");
});
});
// ── Group 4: Agent resolution ──────────────────────────────────────────────
describe("uwf thread poke - agent resolution", () => {
test("4.1 without --agent, agent command read from head step's agent field", async () => {
// Head step's agent field points at mockAgentPath (default in setupThread)
const { casDir, promptCapturePath } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "redo"], casDir);
expect(result.status).toBe(0);
const captured = await readFile(promptCapturePath, "utf8");
expect(captured).toBe("redo");
});
test("4.2 with --agent, explicit override is used", async () => {
// Head step records "uwf-mock" (which is not a real binary). Override with mockAgentPath.
const { casDir, mockAgentPath } = await setupThread({ stepAgentNameOverride: "uwf-mock" });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
});
});
// ── Group 5: Prompt passthrough ────────────────────────────────────────────
describe("uwf thread poke - prompt passthrough", () => {
test("5.1 -p value is passed to agent as --prompt", async () => {
const { casDir, mockAgentPath, promptCapturePath } = await setupThread();
const supplement = "Use the REST API instead.";
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", supplement, "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const captured = await readFile(promptCapturePath, "utf8");
expect(captured).toBe(supplement);
});
});
// ── Group 6: Edge cases ────────────────────────────────────────────────────
describe("uwf thread poke - edge cases", () => {
test("6.1 poke succeeds on suspended thread", async () => {
const { casDir, oldStepHash, mockAgentPath } = await setupThread({
threadStatus: "suspended",
});
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.head).not.toBe(oldStepHash);
expect(cliOutput.status).toBe("idle");
expect(cliOutput.suspendedRole).toBeNull();
expect(cliOutput.suspendMessage).toBeNull();
});
test("6.2 agent failure leaves thread head unchanged", async () => {
const { casDir, oldStepHash, failingAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", failingAgentPath],
casDir,
);
expect(result.status).not.toBe(0);
const { createUwfStore, getThread } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const entry = getThread(uwf.varStore, THREAD_ID);
expect(entry?.head).toBe(oldStepHash);
});
});
-21
View File
@@ -17,7 +17,6 @@ import {
cmdThreadCancel,
cmdThreadExec,
cmdThreadList,
cmdThreadPoke,
cmdThreadRead,
cmdThreadResume,
cmdThreadShow,
@@ -291,26 +290,6 @@ thread
});
});
thread
.command("poke")
.description("Re-run the head step's agent with a supplementary prompt (replaces head step)")
.argument("<thread-id>", "Thread ULID")
.requiredOption("-p, --prompt <text>", "Supplementary prompt for the agent")
.option("--agent <cmd>", "Override agent command (defaults to head step's agent)")
.action((threadId: string, opts: { prompt: string; agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadPoke(
storageRoot,
threadId as ThreadId,
opts.prompt,
agentOverride,
);
writeOutput(result);
});
});
thread
.command("stop")
.description("Stop background execution of a thread (keep thread active)")
-142
View File
@@ -199,7 +199,6 @@ const PL_THREAD_ARCHIVED = "F4D8Q2K5";
const PL_STEP_ERROR = "B8T5N1V6";
const PL_BACKGROUND_START = "X7Q4W9M2";
const PL_THREAD_RESUME = "K2R7M4N8";
const PL_THREAD_POKE = "P4Q9R3X7";
type ResumeStepConfig = {
role: string;
@@ -1136,147 +1135,6 @@ export async function cmdThreadResume(
});
}
/**
* Validate that a thread can be poked. Returns the existing entry and the head StepNode payload.
* Fails (process exit) when the thread is missing, running, completed, cancelled, or has no
* StepNode at its head.
*/
async function validatePokePreconditions(
storageRoot: string,
uwf: UwfStore,
threadId: ThreadId,
): Promise<{ entry: ThreadIndexEntry; oldHead: CasRef; oldHeadPayload: StepNodePayload }> {
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
}
const entry = getThread(uwf.varStore, threadId);
if (entry === null) {
fail(`thread not active: ${threadId}`);
}
if (entry.status === "completed" || entry.status === "cancelled") {
fail(`thread cannot be poked: ${threadId} (status: ${entry.status})`);
}
const oldHead = entry.head;
const oldHeadNode = uwf.store.cas.get(oldHead);
if (oldHeadNode === null) {
fail(`CAS node not found: ${oldHead}`);
}
if (oldHeadNode.type !== uwf.schemas.stepNode) {
fail("thread cannot be poked: no step to replace (head is StartNode)");
}
return { entry, oldHead, oldHeadPayload: oldHeadNode.payload as StepNodePayload };
}
/**
* Resolve the next role from the post-poke chain state, used for the StepOutput.currentRole field.
* Returns null when the next role is $END, evaluation fails, or the result is a suspend.
*/
function resolveCurrentRoleFromChain(
uwfAfter: UwfStore,
workflow: WorkflowPayload,
replacedHash: CasRef,
): string | null {
const chainAfter = walkChain(uwfAfter, replacedHash);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwfAfter, chainAfter);
const afterResult = evaluate(workflow.graph, lastRole, lastOutput);
if (!afterResult.ok || isSuspendResult(afterResult.value)) {
return null;
}
if (afterResult.value.role === END_ROLE) {
return null;
}
return afterResult.value.role;
}
/**
* Poke a thread: re-run the agent on the head step with a supplementary prompt,
* replacing the head step's output. The new step's `prev` points to the OLD head's
* `prev` — semantically replacing (not appending to) the head. The moderator is NOT
* re-evaluated for routing; the role of the head step is re-used.
*/
export async function cmdThreadPoke(
storageRoot: string,
threadId: ThreadId,
prompt: string,
agentOverride: string | null,
): Promise<StepOutput> {
const uwf = await createUwfStore(storageRoot);
const { entry, oldHeadPayload } = await validatePokePreconditions(storageRoot, uwf, threadId);
const chain = walkChain(uwf, entry.head);
const workflowHash = chain.start.workflow;
const threadCwd = chain.start.cwd;
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
// Resolve the agent: --agent override wins; otherwise read from old head step's `agent` field.
const config = await loadWorkflowConfig(storageRoot);
const workflow = loadWorkflowPayload(uwf, workflowHash);
const role = oldHeadPayload.role;
const agent =
agentOverride !== null
? resolveAgentConfig(config, workflow, role, agentOverride)
: parseAgentOverride(oldHeadPayload.agent);
const effectiveCwd = oldHeadPayload.cwd !== "" ? oldHeadPayload.cwd : threadCwd;
plog.log(PL_THREAD_POKE, `poke role=${role} agent=${agent.command}`, null);
plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
args: [...agent.args, threadId, role].join(" "),
});
loadDotenv({ path: getEnvPath(storageRoot) });
// Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
// the active thread head). After the agent returns, we rewrite that node's prev so
// that the new head replaces the old head instead of appending after it.
const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
const agentStepHash = agentResult.stepHash as CasRef;
plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
const uwfAfter = await createUwfStore(storageRoot);
const agentNode = uwfAfter.store.cas.get(agentStepHash);
if (agentNode === null || agentNode.type !== uwfAfter.schemas.stepNode) {
failStep(plog, `agent returned hash that is not a StepNode: ${agentStepHash}`);
}
const agentPayload = agentNode.payload as StepNodePayload;
// Rewrite the new step so that its `prev` points to the OLD head's prev (replace semantics).
const replacedPayload: StepNodePayload = {
...agentPayload,
prev: oldHeadPayload.prev,
};
const replacedHash = await uwfAfter.store.cas.put(uwfAfter.schemas.stepNode, replacedPayload);
const replacedNode = uwfAfter.store.cas.get(replacedHash);
if (replacedNode === null || !validate(uwfAfter.store, replacedNode)) {
failStep(plog, "rewritten StepNode failed schema validation");
}
// Update thread head to the replaced step. Status becomes idle (no moderator re-route).
setThread(uwfAfter.varStore, threadId, updateThreadHead(entry, replacedHash));
return {
workflow: workflowHash,
thread: threadId,
head: replacedHash,
status: "idle",
currentRole: resolveCurrentRoleFromChain(uwfAfter, workflow, replacedHash),
suspendedRole: null,
suspendMessage: null,
done: false,
background: null,
};
}
export function validateCount(count: number): void {
if (count < 1 || !Number.isInteger(count)) {
throw new Error(`--count must be a positive integer, got: ${count}`);
+2 -2
View File
@@ -22,8 +22,8 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/fs": "^0.3.0",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"commander": "^14.0.3",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/protocol",
"version": "0.1.0",
"version": "0.1.1",
"files": [
"src",
"dist",
@@ -18,8 +18,8 @@
"test:ci": "vitest run src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/fs": "^0.3.0"
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0"
},
"devDependencies": {
"typescript": "^5.8.3"
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.2 — 2026-06-07
- fix: decouple session resume from isFirstVisit guard
When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both adapters now always check the session cache regardless of isFirstVisit. When resuming after a frontmatter-only failure (isFirstVisit + cache hit), a minimal correction prompt is sent via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/util-agent",
"version": "0.1.1",
"version": "0.1.2",
"files": [
"src",
"dist",
@@ -18,8 +18,8 @@
"test:ci": "vitest run __tests__/ src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/fs": "^0.3.0",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"dotenv": "^16.6.1",
+38 -36
View File
@@ -18,8 +18,8 @@ importers:
specifier: ^2.31.0
version: 2.31.0(@types/node@25.9.1)
'@shazhou/proman':
specifier: ^0.5.1
version: 0.5.1(@biomejs/biome@2.4.16)(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))
specifier: ^0.6.3
version: 0.6.3(@biomejs/biome@2.4.16)(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))
'@types/node':
specifier: ^25.7.0
version: 25.9.1
@@ -45,8 +45,8 @@ importers:
packages/agent-builtin:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/util':
specifier: workspace:^
version: link:../util
@@ -61,8 +61,8 @@ importers:
packages/agent-claude-code:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -80,8 +80,8 @@ importers:
packages/agent-hermes:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -99,8 +99,8 @@ importers:
packages/agent-mock:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -121,11 +121,11 @@ importers:
packages/cli:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -231,11 +231,11 @@ importers:
packages/eval:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -256,11 +256,11 @@ importers:
packages/protocol:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
devDependencies:
typescript:
specifier: ^5.8.3
@@ -275,11 +275,11 @@ importers:
packages/util-agent:
dependencies:
'@ocas/core':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.3.0
version: 0.3.0
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -892,11 +892,13 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@ocas/core@0.3.0':
resolution: {integrity: sha512-ejDDZbmQkTj2GoJg+cNjXa3eHlQGybW3PrUZlwERBvBFjjnYBLHOG7AQQYM48bI52UiqucafgZjPEYk9SZd6AQ==}
'@ocas/core@0.4.0':
resolution: {integrity: sha512-6JvHd3nr5GncMOBNaZTf9ZTWou/txONTfZbkrblmgqL/H+YuRj1FfeFY+b1ndUlfwR7AuJ6bvoSxR5RP+AbC0w==}
engines: {node: '>=22.5.0'}
'@ocas/fs@0.3.0':
resolution: {integrity: sha512-/6/nICYVJWXeWx2LcPoHHJAFoqXpJoAtvhLKLS0zpkwtsZX3g0D9X6J5soHCV1QS+BOWybuOJ0+W3cB1FBRkZA==}
'@ocas/fs@0.4.0':
resolution: {integrity: sha512-AQG6dk1YCL1qpSszUWUgEY+LQhYbTv5hXYrs3J2pHAi2/lY615O2cTgjwEeh6JTcrqHsFwiDsDdKIKMpADchZA==}
engines: {node: '>=22.5.0'}
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
@@ -1152,8 +1154,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shazhou/proman@0.5.1':
resolution: {integrity: sha512-GmFUvd8SAOUW/eaDIEh31pVKSE3XhbgHOZ5vSpX4xS+F8Zl6lAfhgVCjcjRK8w5d43tsH47CVorwyxQcRaJFfA==}
'@shazhou/proman@0.6.3':
resolution: {integrity: sha512-KguWl1xHrWXx1YWYrWj47v4NRbaQuKCm7Hd7T8dzrqnkM8UL8em3R9rC7GeDzI8YDDfriFeLTX+xb03UHkhTDA==}
hasBin: true
peerDependencies:
'@biomejs/biome': ^2.0.0
@@ -3896,16 +3898,16 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@ocas/core@0.3.0':
'@ocas/core@0.4.0':
dependencies:
ajv: 8.20.0
cborg: 4.5.8
liquidjs: 10.27.0
xxhash-wasm: 1.1.0
'@ocas/fs@0.3.0':
'@ocas/fs@0.4.0':
dependencies:
'@ocas/core': 0.3.0
'@ocas/core': 0.4.0
cborg: 4.5.8
'@open-draft/deferred-promise@2.2.0': {}
@@ -4049,7 +4051,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@shazhou/proman@0.5.1(@biomejs/biome@2.4.16)(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))':
'@shazhou/proman@0.6.3(@biomejs/biome@2.4.16)(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))':
dependencies:
'@biomejs/biome': 2.4.16
typescript: 5.9.3