Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoju 1d174ee5c9 fix(agent-kit): separate session cache per agent
Each agent now maintains its own session cache file instead of sharing
a single agent-sessions.json. This prevents session ID conflicts when
multiple agents operate on the same thread+role pair.

Changes:
- getCachePath() now takes agentName parameter
- getCachedSessionId/setCachedSessionId require agentName as first param
- Cache files named <agent>-sessions.json (e.g., hermes-sessions.json)
- Agent wrappers inject their agent name into cache calls
- Add comprehensive tests for session cache isolation
- Handle malformed JSON gracefully (treat as empty cache)

Fixes #461
2026-05-24 09:16:06 +00:00
xiaoju 6e3b32ca34 Merge pull request 'fix(cli): replace markdown headings with XML tags in thread read output' (#460) from fix/459-xml-tag-isolation into main 2026-05-24 08:44:47 +00:00
xiaoju 932bbe5c41 fix(cli): replace markdown headings with XML tags in thread read output
Changed uwf thread read to wrap role prompts and agent outputs in XML tags
(<prompt> and <output>) instead of markdown headings (### Prompt, ### Content).
This prevents Claude Code from treating step outputs as structural headings.

- Updated formatStepPrompt to use <prompt>...</prompt> tags
- Updated formatStepContent to use <output>...</output> tags
- Added comprehensive test suite in thread-read-xml-tags.test.ts
- Updated existing tests to verify XML tag behavior

Fixes #459

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 08:04:34 +00:00
xiaomo 9440b9af82 Merge pull request 'chore: fix biome noExcessiveCognitiveComplexity warnings' (#458) from fix/444-biome-complexity-warnings into main 2026-05-24 07:30:41 +00:00
9 changed files with 992 additions and 35 deletions
@@ -0,0 +1,683 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-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: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read XML tag isolation ─────────────────────────────────────────────
describe("thread read XML tag isolation", () => {
test("scenario 1: wraps output in XML tags instead of heading", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to...",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue #459",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content:
"---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
});
const threadId = "01JTEST0000000000000001" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
// Should not have ### Content heading
expect(markdown).not.toContain("### Content");
// Should preserve markdown headings inside output tags
expect(markdown).toContain("# Analysis Complete");
expect(markdown).toContain("## Issue Summary");
});
test("scenario 2: wraps prompt in XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to analyze and plan.",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "---\nstatus: ready\n---\n\nContent here...",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
});
const threadId = "01JTEST0000000000000002" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap prompt in XML tags
expect(markdown).toContain("<prompt>");
expect(markdown).toContain("</prompt>");
expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan.");
// Should not have ### Prompt heading
expect(markdown).not.toContain("### Prompt");
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
});
test("scenario 3: same role repeated does not show prompt twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer agent.",
capabilities: [],
procedure: "Write content.",
output: "Summarize writing.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2 });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should only show prompt tags once
const promptCount = (markdown.match(/<prompt>/g) ?? []).length;
expect(promptCount).toBe(1);
});
test("scenario 4: step with no detail shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do work.",
output: "Summarize work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
// Step header should still be displayed
expect(markdown).toContain("## Step 1: worker");
// Prompt should still be shown
expect(markdown).toContain("<prompt>");
});
test("scenario 5: empty content shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// A detail ref that doesn't exist → extractLastAssistantContent returns null
const missingDetailRef = "missingdetail0" as CasRef;
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: missingDetailRef,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
});
test("scenario 6: thread read with --start flag shows task section", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000006" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
// Should include task section
expect(markdown).toContain("# Thread");
expect(markdown).toContain("## Task");
expect(markdown).toContain("Initial prompt");
// Prompts should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 7: thread read with --before parameter", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleB: {
description: "Role B",
goal: "Goal for roleB",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleC: {
description: "Role C",
goal: "Goal for roleC",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "roleB",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step2 as CasRef,
role: "roleC",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000007" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step3 });
const markdown = await cmdThreadRead(
tmpDir,
threadId,
THREAD_READ_DEFAULT_QUOTA,
step2 as CasRef,
false,
);
// Should only show roleA
expect(markdown).toContain("roleA");
expect(markdown).not.toContain("roleB");
expect(markdown).not.toContain("roleC");
// Should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 9: special characters in content are preserved", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer.",
capabilities: [],
procedure: "Write content.",
output: "Summarize.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Content with <special> & characters > like <this>",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000008" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Special characters should be preserved as-is
expect(markdown).toContain("Content with <special> & characters > like <this>");
});
test("scenario 10: quota limit with XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const steps: CasRef[] = [];
let prev: CasRef | null = null;
for (let i = 0; i < 5; i++) {
const step = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
})) as CasRef;
steps.push(step);
prev = step;
}
const threadId = "01JTEST0000000000000009" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! });
// Use very small quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should have skip hint
expect(markdown).toContain("earlier step");
// Should have XML tags for displayed steps
if (markdown.includes("<prompt>")) {
expect(markdown).toContain("</prompt>");
}
});
});
@@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => {
});
});
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
// ── cmdThreadRead: <output> section ──────────────────────────────────────────
describe("cmdThreadRead ### Content section", () => {
test("includes ### Content before ### Output when detail has assistant turns", async () => {
describe("cmdThreadRead <output> section", () => {
test("includes <output> tags when detail has assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
@@ -264,12 +264,13 @@ describe("cmdThreadRead ### Content section", () => {
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("### Content");
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
expect(markdown).toContain("The assistant response text");
expect(markdown).not.toContain("### Output");
expect(markdown).not.toContain("### Content");
});
test("omits ### Content when detail has no matching assistant turns", async () => {
test("omits <output> tags when detail has no matching assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
@@ -308,8 +309,9 @@ describe("cmdThreadRead ### Content section", () => {
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
expect(markdown).not.toContain("### Content");
expect(markdown).not.toContain("### Output");
});
});
@@ -384,9 +386,9 @@ describe("cmdThreadStepDetails", () => {
});
});
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
// ── cmdThreadRead: <prompt> deduplication ───────────────────────────────────
describe("cmdThreadRead ### Prompt deduplication", () => {
describe("cmdThreadRead <prompt> deduplication", () => {
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
const roleMap: Record<string, unknown> = {};
for (const r of [...new Set(roles)]) {
@@ -434,36 +436,36 @@ describe("cmdThreadRead ### Prompt deduplication", () => {
return stepHash;
}
test("same consecutive role shows ### Prompt once", async () => {
test("same consecutive role shows <prompt> once", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(1);
});
test("different consecutive roles each show ### Prompt", async () => {
test("different consecutive roles each show <prompt>", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(2);
});
test("non-consecutive same role shows ### Prompt twice", async () => {
test("non-consecutive same role shows <prompt> twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(2);
});
});
+2 -2
View File
@@ -665,14 +665,14 @@ function formatStepPrompt(
): string {
if (!roleDef || shownPromptRoles.has(role)) return "";
shownPromptRoles.add(role);
return ["", "", "### Prompt", "", roleDef.goal].join("\n");
return ["", "", "<prompt>", roleDef.goal, "</prompt>"].join("\n");
}
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
if (!item.payload.detail) return "";
const content = extractLastAssistantContent(uwf, item.payload.detail);
if (content === null) return "";
return ["", "", "### Content", "", content].join("\n");
return ["", "", "<output>", content, "</output>"].join("\n");
}
function formatStartSection(options: {
@@ -146,13 +146,13 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
if (!ctx.isFirstVisit) {
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
if (cachedSessionId !== null) {
try {
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
return result;
} catch (err) {
@@ -169,7 +169,7 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
return result;
}
@@ -1,5 +1,22 @@
// Re-export session cache from the shared agent-kit package.
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
// Re-export session cache from the shared agent-kit package with agent name injected.
import {
getCachedSessionId as getCachedSessionIdBase,
setCachedSessionId as setCachedSessionIdBase,
} from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
return getCachedSessionIdBase("hermes", threadId, role);
}
export async function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
return setCachedSessionIdBase("hermes", threadId, role, sessionId);
}
export function isResumeDisabled(): boolean {
// Hermes ACP session/resume is broken: _restore fails for custom providers
@@ -0,0 +1,247 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getCachedSessionId, getCachePath, setCachedSessionId } from "../src/session-cache.js";
import { resolveStorageRoot } from "../src/storage.js";
describe("session-cache", () => {
let originalStorageRoot: string;
let testStorageRoot: string;
beforeEach(async () => {
// Create a temporary test storage root
originalStorageRoot = resolveStorageRoot();
testStorageRoot = join(originalStorageRoot, "test-cache", `test-${Date.now()}`);
await mkdir(testStorageRoot, { recursive: true });
// Override the storage root for testing
process.env.WORKFLOW_STORAGE_ROOT = testStorageRoot;
});
afterEach(async () => {
// Clean up test storage root
await rm(testStorageRoot, { recursive: true, force: true });
delete process.env.WORKFLOW_STORAGE_ROOT;
});
describe("getCachePath", () => {
test("returns agent-specific file path", () => {
const path = getCachePath("claude-code");
expect(path).toMatch(/\/cache\/claude-code-sessions\.json$/);
});
test("returns different paths for different agents", () => {
const pathClaudeCode = getCachePath("claude-code");
const pathHermes = getCachePath("hermes");
expect(pathClaudeCode).not.toBe(pathHermes);
expect(pathClaudeCode).toMatch(/claude-code-sessions\.json$/);
expect(pathHermes).toMatch(/hermes-sessions\.json$/);
});
test("handles agent names with special characters", () => {
const path1 = getCachePath("my-agent");
const path2 = getCachePath("my_agent");
expect(path1).toMatch(/my-agent-sessions\.json$/);
expect(path2).toMatch(/my_agent-sessions\.json$/);
});
});
describe("session isolation", () => {
const threadId = "01234567890123456789012345" as ThreadId;
const role = "developer";
test("sessions are isolated per agent", async () => {
// Cache different session IDs for each agent
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
// Each agent should retrieve its own session ID
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
expect(sessionCC).toBe("session-cc-001");
expect(sessionHermes).toBe("session-hermes-001");
});
test("updating one agent's cache does not affect another", async () => {
// Set initial sessions for both agents
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
// Update claude-code's session
await setCachedSessionId("claude-code", threadId, role, "session-cc-002");
// Hermes's session should remain unchanged
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
expect(sessionHermes).toBe("session-hermes-001");
// Claude-code should have the new session
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
expect(sessionCC).toBe("session-cc-002");
});
test("missing session returns null for specific agent", async () => {
const session = await getCachedSessionId("claude-code", threadId, role);
expect(session).toBeNull();
});
test("empty session ID is treated as missing", async () => {
await setCachedSessionId("claude-code", threadId, role, "");
const session = await getCachedSessionId("claude-code", threadId, role);
expect(session).toBeNull();
});
});
describe("file system operations", () => {
const threadId = "01234567890123456789012345" as ThreadId;
const role = "developer";
test("cache directory is created if missing", async () => {
const cachePath = getCachePath("claude-code");
const cacheDir = dirname(cachePath);
// Ensure cache dir doesn't exist
await rm(cacheDir, { recursive: true, force: true });
// Write a session
await setCachedSessionId("claude-code", threadId, role, "session-001");
// Cache directory should be created
const stats = await stat(cacheDir);
expect(stats.isDirectory()).toBe(true);
});
test("multiple agents create separate cache files", async () => {
// Cache sessions for multiple agents
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
// Separate cache files should exist
const pathCC = getCachePath("claude-code");
const pathHermes = getCachePath("hermes");
const contentCC = JSON.parse(await readFile(pathCC, "utf8")) as Record<string, string>;
const contentHermes = JSON.parse(await readFile(pathHermes, "utf8")) as Record<
string,
string
>;
expect(contentCC).toHaveProperty(`${threadId}:${role}`, "session-cc-001");
expect(contentHermes).toHaveProperty(`${threadId}:${role}`, "session-hermes-001");
});
test("atomic writes prevent partial reads", async () => {
// Write a session
await setCachedSessionId("claude-code", threadId, role, "session-001");
// The final file should exist (no .tmp files left behind)
const cachePath = getCachePath("claude-code");
const dir = dirname(cachePath);
const files = await readdir(dir);
expect(files).toContain("claude-code-sessions.json");
expect(files.every((f) => !f.endsWith(".tmp"))).toBe(true);
});
});
describe("legacy migration", () => {
const threadId = "01234567890123456789012345" as ThreadId;
const role = "developer";
test("old agent-sessions.json is ignored", async () => {
// Create old agent-sessions.json file
const oldCachePath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
await mkdir(dirname(oldCachePath), { recursive: true });
await writeFile(
oldCachePath,
JSON.stringify({
"01234567890123456789012345:developer": "old-session-001",
}),
"utf8",
);
// Query with the new per-agent cache
const session = await getCachedSessionId("claude-code", threadId, role);
// Should return null (old cache is ignored)
expect(session).toBeNull();
});
test("new per-agent cache takes precedence", async () => {
// Create both old and new cache files
const oldPath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
await mkdir(dirname(oldPath), { recursive: true });
await writeFile(
oldPath,
JSON.stringify({
[`${threadId}:${role}`]: "old-session",
}),
"utf8",
);
await setCachedSessionId("claude-code", threadId, role, "new-session");
// The new per-agent cache value should be returned
const session = await getCachedSessionId("claude-code", threadId, role);
expect(session).toBe("new-session");
});
});
describe("error handling", () => {
const threadId = "01234567890123456789012345" as ThreadId;
const role = "developer";
test("invalid JSON in cache file returns empty cache", async () => {
// Create a corrupted cache file
const cachePath = getCachePath("claude-code");
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, "{ invalid json }", "utf8");
// Should return null (treating corrupted cache as empty)
const session = await getCachedSessionId("claude-code", threadId, role);
expect(session).toBeNull();
});
test("non-object JSON in cache file returns empty cache", async () => {
// Create a cache file with non-object JSON
const cachePath = getCachePath("claude-code");
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, JSON.stringify(["not", "an", "object"]), "utf8");
// Should return null
const session = await getCachedSessionId("claude-code", threadId, role);
expect(session).toBeNull();
});
test("cache entries with non-string values are ignored", async () => {
// Create a cache file with mixed types
const cachePath = getCachePath("claude-code");
const cacheData = {
"thread1:role1": "valid-session",
"thread2:role2": 12345, // number
"thread3:role3": null, // null
"thread4:role4": "", // empty string
};
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, JSON.stringify(cacheData), "utf8");
// Valid string entries should be returned
const session1 = await getCachedSessionId("claude-code", "thread1" as ThreadId, "role1");
expect(session1).toBe("valid-session");
// Invalid entries should return null
const session2 = await getCachedSessionId("claude-code", "thread2" as ThreadId, "role2");
const session3 = await getCachedSessionId("claude-code", "thread3" as ThreadId, "role3");
const session4 = await getCachedSessionId("claude-code", "thread4" as ThreadId, "role4");
expect(session2).toBeNull();
expect(session3).toBeNull();
expect(session4).toBeNull(); // empty string is treated as missing
});
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ export {
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type {
AgentContext,
@@ -8,8 +8,8 @@ import { resolveStorageRoot } from "./storage.js";
type SessionCache = Record<string, string>;
function getCachePath(): string {
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
export function getCachePath(agentName: string): string {
return join(resolveStorageRoot(), "cache", `${agentName}-sessions.json`);
}
function cacheKey(threadId: ThreadId, role: string): string {
@@ -20,8 +20,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readCache(): Promise<SessionCache> {
const path = getCachePath();
async function readCache(agentName: string): Promise<SessionCache> {
const path = getCachePath(agentName);
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
@@ -40,36 +40,45 @@ async function readCache(): Promise<SessionCache> {
if (err.code === "ENOENT") {
return {};
}
// Treat JSON parse errors as empty cache
if (err.name === "SyntaxError") {
return {};
}
throw e;
}
}
async function writeCache(cache: SessionCache): Promise<void> {
const path = getCachePath();
async function writeCache(agentName: string, cache: SessionCache): Promise<void> {
const path = getCachePath(agentName);
const dir = dirname(path);
await mkdir(dir, { recursive: true });
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
// This is a safety net for future parallel execution.
const tmpPath = join(dir, `.agent-sessions.${randomBytes(4).toString("hex")}.tmp`);
const tmpPath = join(dir, `.${agentName}-sessions.${randomBytes(4).toString("hex")}.tmp`);
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
await rename(tmpPath, path);
}
/** Read the cached session ID for a thread+role pair. */
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
const cache = await readCache();
export async function getCachedSessionId(
agentName: string,
threadId: ThreadId,
role: string,
): Promise<string | null> {
const cache = await readCache(agentName);
const sessionId = cache[cacheKey(threadId, role)];
return sessionId ?? null;
}
/** Write the session ID for a thread+role pair into the cache. */
export async function setCachedSessionId(
agentName: string,
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
const cache = await readCache();
const cache = await readCache(agentName);
cache[cacheKey(threadId, role)] = sessionId;
await writeCache(cache);
await writeCache(agentName, cache);
}
@@ -1,7 +1,6 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
// biome-ignore lint/style/noDefaultExport: Vitest loads config from default export.
export default defineConfig({
test: {
environment: "node",