mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
26ca4e0275
* feat: single-source skills into core/extra, replace Claude commands with skills, de-hardcode installers
- apps/skills/core/{review,annotate,last,archive}: single authoritative
source for the always-installed skills (archive is new); all carry
disable-model-invocation + agents/openai.yaml sidecars
- apps/skills/extra/{compound,setup-goal,visual-explainer}: no longer
default-installed (except Kiro); installers print an
`npx skills add backnotprop/plannotator/apps/skills/extra` suggestion
- Claude Code: apps/hook/commands/ deleted, command heredocs removed;
core skills in ~/.claude/skills are the slash commands now
- Installers: OpenCode/Gemini command files copied from an extended
sparse checkout instead of heredocs; install.cmd gains the previously
missing OpenCode command install; aggressive cleanup of legacy
~/.claude/commands and ~/.codex/skills artifacts
- Codex: core skills install to ~/.agents/skills (official path);
~/.codex/skills install removed
- Pi: extension no longer bundles skills; #670 settings filter removed
* fix: review findings — old-tag soft guards, cmd replace-not-merge, plugin-update hint, frontmatter test
- install.sh: a --version tag predating apps/skills/core no longer aborts
the whole copy subshell (which also skipped OpenCode/Gemini commands);
core skills now soft-skip with an accurate message, matching ps1/cmd
- install.sh: subshell failure message no longer claims "git required"
when git was present (clone/network errors get their own wording)
- install.cmd: pre-remove skill dirs before xcopy so upgrades replace
rather than merge (stale files from renamed/deleted skill files no
longer linger; parity with sh/ps1)
- all installers + docs: tell upgraders to run /plugin marketplace update
so the plugin's old namespaced plannotator:* commands disappear (#817)
- install.test.ts: assert every core SKILL.md sets
disable-model-invocation: true — the load-bearing line that keeps core
skills out of Pi's system prompt (#842 regression guard)
* test: pin old-tag soft-guard behavior, dedupe core-skill list in tests
* fix: interrogation review findings — cross-installer diagnostic parity
- install.ps1/install.cmd: emit the "predates the core/extra skill
layout" diagnostic on old pinned tags instead of silently skipping
core skills (parity with install.sh)
- install.ps1: clone/network failure no longer claims "git required"
(git was already verified present); the outer catch now reports the
actual exception
- install.sh: "Installed OpenCode/Gemini commands" echoes are guarded
on the copy actually having a source, so old pinned tags don't print
false success (ps1/cmd already gated this way)
- AGENTS.md: opencode-plugin commands/ comment now reflects all four
command stubs
- install.test.ts: shared test asserts the soft-skip diagnostic exists
in all three installers and pins ps1's honest failure wording
* fix: respect CODEX_HOME for Codex home directory (#852)
Codex stores config and state under $CODEX_HOME when set, falling back
to ~/.codex (developers.openai.com/codex/config-advanced). Plannotator
hardcoded ~/.codex in two places:
- runtime: codex-session.ts scanned ~/.codex/sessions for rollout
files, so `plannotator last` failed with "No rendered assistant
message found" when CODEX_HOME pointed elsewhere. Now resolved the
same way copilot-session.ts handles COPILOT_HOME and session-log.ts
handles CLAUDE_CONFIG_DIR.
- installers: detection, config.toml/hooks.json paths, manual-setup
instructions, and the stale-skills cleanup now derive from
CODEX_HOME in all three scripts.
Tests: codex-session.test.ts covers rollout discovery under a
CODEX_HOME temp dir; install.test.ts asserts all three installers
respect the variable and that the fallback is the only hardcoded
~/.codex path left in install.sh.
* fix: hard-fail skill install, guard command cleanup, one-time extras migration
External review triage on PR #850 surfaced two real installer issues:
P1 — commands deleted before replacement: the Claude command cleanup
ran before the git-gated skill install, so a missing git, a failed
clone, or an old pinned tag deleted the user's slash commands and
installed nothing (a regression — the old installer needed no git).
Now:
- missing git is a hard failure before anything is touched ("install
git, then run this installer again")
- a failed fetch is a hard failure ("something went wrong — run the
installer again") instead of a silent skip
- the legacy command cleanup runs AFTER the install and only removes a
command file when its same-name replacement skill exists on disk
- old pinned tags keep the soft-skip (no deletion happens, commands
survive, CI e2e against old tags stays green)
P2 — recurring extras deletion: the extras cleanup ran on every
invocation, deleting copies users reinstalled via the suggested
`npx skills add` (the copies are byte-identical, so only provenance
can tell them apart). The cleanup is now a one-time migration recorded
in a migrations ledger under the Plannotator data dir
(<PLANNOTATOR_DATA_DIR|~/.plannotator>/migrations/), the same
record-what-you-did pattern package managers use.
All three installers (sh/ps1/cmd) updated in parity; tests pin the
guard condition, the ledger gating, and the hard-fail messages.
* test: tripwire — install.cmd must never contain /dev/null redirects
* fix: every skill sets disable-model-invocation — no exceptions
Maintainer rule: all Plannotator skills are user-invoked, never
model-auto-invoked. setup-goal (missing since #665) and the three Kiro
skills now carry the flag. The frontmatter test scans every SKILL.md in
apps/skills/core, apps/skills/extra, and apps/kiro-cli/skills
dynamically — with a floor of 10 — so a future skill cannot ship
without it.
* docs: git is a hard installer requirement; clarify post-gate sections complete on re-run
* docs: align ps1/cmd comments with hard-fail semantics
* feat: guided install — extras opt-in via skills CLI, model-invocation picker
Interactive terminals get a two-question wizard on first run:
1. Install the extra skills? Yes delegates to `npx skills add
backnotprop/plannotator/apps/skills/extra` (its UI picks the agents),
wired to /dev/tty so piped curl|bash installs still work. Skipped
when extras already exist on disk.
2. Make any skills callable by the model? Yes opens a space-toggle
checkbox (sh/ps1) or numbered toggles (cmd), listing all skills if
extras were chosen, core-only otherwise. Chosen skills get
disable-model-invocation stripped from their INSTALLED copies and the
Codex sidecar's allow_implicit_invocation flipped — re-applied every
run since installs replace skill folders. Repo sources stay locked.
Answers persist to <data dir>/install-prefs (shared format across all
three installers) and re-runs reuse them silently; --reconfigure
re-opens the wizard. Automation is untouched: no terminal means no
prompts and today's defaults; --extras/--no-extras/--model-invocable/
--non-interactive give scripts explicit control.
* fix: self-review of guided install — cmd pipe expansion bug, flag/wizard interplay
- install.cmd: the checkbox preselection used `echo !var! | findstr` —
each side of a cmd pipe runs in a child WITHOUT delayed expansion, so
the saved choices passed through as literal !var! text and
preselection never matched. Replaced with a substring-replace
containment test (no pipe).
- all three: a wizard question whose answer was already provided by a
CLI flag (--extras/--no-extras/--model-invocable) is no longer asked
and then silently overridden — the flag pre-answers it.
- install.cmd: unknown-option usage line now lists the wizard flags.
* feat: guided install question 3 — install Glimpse (native window)
glimpseui (third-party npm package, PR #840) gives Plannotator a native
WebView window instead of a browser tab; the runtime already
auto-detects it on PATH, so a global install is all that's needed.
- Wizard asks "Install Glimpse?" (default yes) after the skills
questions; skipped when glimpseui is already on PATH
- Yes runs `npm install -g glimpseui` (bun fallback on sh/ps1; printed
instruction when neither exists) — wizard or explicit flag only,
silent re-runs never install software
- --glimpse / --no-glimpse flags for automation; choice persisted to
install-prefs like the others
- docs + tests updated (glimpse detection, install command, flags, and
persist-condition assertions across all three installers)
* fix: self-review of Glimpse question — cmd bun fallback, stale usage text
* fix: merge-window hardening — guard Codex cleanup, remove old-installer junk dirs
plannotator.ai serves install.sh live from main (public/ symlink,
deployed on push), while the script fetches repo files at the LATEST
RELEASE TAG. Between merging the core/extra restructure and cutting the
release that ships it, the live script runs against the old-layout tag.
Two hazards in that window:
1. The Codex stale-skill cleanup removed working ~/.codex/skills with
no successor installed (core skills soft-skip on old tags). Now the
cleanup runs AFTER the install and removes a core skill only once
its replacement exists in ~/.agents/skills — same guard the Claude
command cleanup uses. The compound/setup-goal stale copies stay
unconditional (never Codex's to begin with).
2. The reverse combo (cached OLD script + NEW release tag) wholesale-
copies apps/skills/* and leaves junk core/ and extra/ directory
copies in ~/.claude/skills. Never valid skill names — all three
installers now remove them on every run.
* fix: glimpseui is a devDependency — consumers never use it from node_modules
PR #840 added glimpseui to dependencies in @plannotator/server and
@plannotator/pi-extension, but nothing imports it: both runtimes detect
the CLI on PATH (Bun.which / a manual PATH walk) and spawn it. The dep
only ever mattered in repo development, where `bun run` prepends
node_modules/.bin to PATH. For consumers it was inert download weight —
OpenCode plugin installs and `pi install` pulled a third-party package
that could never be detected (Pi's loader does not expose
node_modules/.bin; verified). Moved to devDependencies in both: dev
flows keep working, published packages stop shipping it. The sanctioned
end-user path is the guided installer's global `npm install -g
glimpseui`.
* fix: clean stale plugin command files from the installed plugin checkout (#817)
The installer already manages hooks.json inside
~/.claude/plugins/marketplaces/plannotator/apps/hook/, so the earlier
"don't reach into plugin storage" rationale for leaving the old
namespaced plannotator:* command files there was inconsistent. All
three installers now remove them — same replacement-skill guard as the
bare ~/.claude/commands cleanup — making the #817 duplicate menu
entries die on a single installer run + restart instead of waiting for
/plugin marketplace update. Hints/docs reworded accordingly.
* Revert "fix: clean stale plugin command files from the installed plugin checkout (#817)"
This reverts commit 3df37da92c.
* fix: Windows CI — assert the new no-commands contract; gate cmd wizard on a real console
The cmd e2e CI step still asserted the OLD contract (installer writes
~/.claude/commands/plannotator-*.md with the ! prefix). Commands are
dead; the step now guards the NEW contract: a fresh install must write
NO plannotator command files. The Gemini TOML assertions stay — they
now verify the verbatim checkout copy delivers intact files (the tag
the e2e pins, v0.17.1, contains apps/gemini/commands).
The failure also exposed that install.cmd ran the wizard on redirected
stdin (set /p falls through to defaults at EOF) — and Q3's default
being yes meant CI silently ran `npm install -g glimpseui`. cmd now
probes for a real console via `timeout /t 0` (errors when stdin is
redirected), matching sh's /dev/tty and ps1's IsInputRedirected gates:
no console, no wizard, no wizard-only installs.
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
/**
|
||
* Codex Session Parser Tests
|
||
*
|
||
* Run: bun test apps/hook/server/codex-session.test.ts
|
||
*
|
||
* Uses synthetic JSONL fixtures matching the real Codex rollout format.
|
||
*/
|
||
|
||
import { describe, expect, test, afterEach } from "bun:test";
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session";
|
||
|
||
// --- Fixture Helpers ---
|
||
|
||
function rolloutLine(type: string, payload: Record<string, unknown>): string {
|
||
return JSON.stringify({
|
||
timestamp: new Date().toISOString(),
|
||
type,
|
||
payload,
|
||
});
|
||
}
|
||
|
||
function assistantMessage(text: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "assistant",
|
||
content: [{ type: "output_text", text }],
|
||
});
|
||
}
|
||
|
||
function userMessage(text: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "user",
|
||
content: [{ type: "input_text", text }],
|
||
});
|
||
}
|
||
|
||
function developerMessage(text: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "developer",
|
||
content: [{ type: "input_text", text }],
|
||
});
|
||
}
|
||
|
||
function functionCall(name: string, args: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "function_call",
|
||
name,
|
||
arguments: args,
|
||
call_id: `call_${crypto.randomUUID().slice(0, 12)}`,
|
||
});
|
||
}
|
||
|
||
function functionOutput(callId: string, output: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "function_call_output",
|
||
call_id: callId,
|
||
output,
|
||
});
|
||
}
|
||
|
||
function sessionMeta(): string {
|
||
return rolloutLine("session_meta", {
|
||
id: crypto.randomUUID(),
|
||
cwd: "/tmp/test",
|
||
model_provider: "openai",
|
||
});
|
||
}
|
||
|
||
function turnContext(): string {
|
||
return rolloutLine("turn_context", {
|
||
cwd: "/tmp/test",
|
||
model: "o3",
|
||
});
|
||
}
|
||
|
||
function eventMsg(type: string): string {
|
||
return JSON.stringify({
|
||
timestamp: new Date().toISOString(),
|
||
type: "event_msg",
|
||
payload: { type },
|
||
});
|
||
}
|
||
|
||
function turnStarted(turnId: string): string {
|
||
return JSON.stringify({
|
||
timestamp: new Date().toISOString(),
|
||
type: "event_msg",
|
||
payload: {
|
||
type: "task_started",
|
||
turn_id: turnId,
|
||
},
|
||
});
|
||
}
|
||
|
||
function turnCompleted(turnId: string): string {
|
||
return JSON.stringify({
|
||
timestamp: new Date().toISOString(),
|
||
type: "event_msg",
|
||
payload: {
|
||
type: "task_complete",
|
||
turn_id: turnId,
|
||
},
|
||
});
|
||
}
|
||
|
||
function completedPlanItem(text: string, turnId: string): string {
|
||
return JSON.stringify({
|
||
timestamp: new Date().toISOString(),
|
||
type: "event_msg",
|
||
payload: {
|
||
type: "item_completed",
|
||
turn_id: turnId,
|
||
item: {
|
||
type: "Plan",
|
||
id: `plan_${crypto.randomUUID().slice(0, 12)}`,
|
||
text,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function hookPrompt(text: string): string {
|
||
return rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "user",
|
||
content: [
|
||
{
|
||
type: "input_text",
|
||
text: `<hook_prompt hook_run_id="${crypto.randomUUID()}">${text}</hook_prompt>`,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
function buildRollout(...lines: string[]): string {
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// --- Temp file helpers ---
|
||
|
||
let tempFiles: string[] = [];
|
||
|
||
function writeTempRollout(content: string): string {
|
||
const dir = mkdtempSync(join(tmpdir(), "plannotator-codex-test-"));
|
||
const path = join(dir, "rollout.jsonl");
|
||
writeFileSync(path, content);
|
||
tempFiles.push(dir);
|
||
return path;
|
||
}
|
||
|
||
afterEach(() => {
|
||
for (const dir of tempFiles.splice(0)) {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// --- Tests ---
|
||
|
||
describe("findCodexRolloutByThreadId", () => {
|
||
test("respects CODEX_HOME for session discovery (#852)", () => {
|
||
const home = mkdtempSync(join(tmpdir(), "plannotator-codex-home-"));
|
||
tempFiles.push(home);
|
||
const threadId = "0196f8a2-aaaa-bbbb-cccc-1234567890ab";
|
||
const dayDir = join(home, "sessions", "2026", "06", "04");
|
||
mkdirSync(dayDir, { recursive: true });
|
||
const rollout = join(dayDir, `rollout-2026-06-04T10-00-00-${threadId}.jsonl`);
|
||
writeFileSync(rollout, buildRollout(sessionMeta(), assistantMessage("hi")));
|
||
|
||
const prev = process.env.CODEX_HOME;
|
||
process.env.CODEX_HOME = home;
|
||
try {
|
||
expect(findCodexRolloutByThreadId(threadId)).toBe(rollout);
|
||
expect(findCodexRolloutByThreadId("no-such-thread")).toBeNull();
|
||
} finally {
|
||
if (prev === undefined) delete process.env.CODEX_HOME;
|
||
else process.env.CODEX_HOME = prev;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("getLastCodexMessage", () => {
|
||
test("finds last assistant message", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
userMessage("Hello"),
|
||
assistantMessage("Hi there!"),
|
||
userMessage("Thanks"),
|
||
assistantMessage("You're welcome.")
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("You're welcome.");
|
||
});
|
||
|
||
test("skips function_call entries", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
userMessage("Fix the bug"),
|
||
assistantMessage("Let me look into that."),
|
||
functionCall("exec_command", '{"cmd":"ls"}'),
|
||
functionOutput("call_123", "file1.ts\nfile2.ts"),
|
||
assistantMessage("Found the issue.")
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Found the issue.");
|
||
});
|
||
|
||
test("skips developer and user messages", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
developerMessage("System instructions..."),
|
||
userMessage("Do something"),
|
||
assistantMessage("The actual response"),
|
||
developerMessage("More instructions"),
|
||
userMessage("Another user message")
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("The actual response");
|
||
});
|
||
|
||
test("extracts multiple output_text blocks", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "output_text", text: "First part." },
|
||
{ type: "output_text", text: "Second part." },
|
||
],
|
||
})
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("First part.\nSecond part.");
|
||
});
|
||
|
||
test("ignores non-output assistant text blocks", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
assistantMessage("Renderable response"),
|
||
rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "assistant",
|
||
content: [{ type: "refusal", text: "Hidden refusal text" }],
|
||
})
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Renderable response");
|
||
});
|
||
|
||
test("skips event_msg and turn_context entries", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnContext(),
|
||
userMessage("Hello"),
|
||
assistantMessage("Response here"),
|
||
eventMsg("task_started"),
|
||
turnContext(),
|
||
eventMsg("token_count")
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Response here");
|
||
});
|
||
|
||
test("skips assistant messages with empty text", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
assistantMessage("Good response"),
|
||
rolloutLine("response_item", {
|
||
type: "message",
|
||
role: "assistant",
|
||
content: [{ type: "output_text", text: " " }],
|
||
})
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Good response");
|
||
});
|
||
|
||
test("returns null when no assistant messages exist", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
developerMessage("Instructions"),
|
||
userMessage("Hello"),
|
||
functionCall("exec_command", '{"cmd":"pwd"}')
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("returns null for empty file", () => {
|
||
const path = writeTempRollout("");
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("skips malformed JSON lines", () => {
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
assistantMessage("Valid message"),
|
||
"not valid json",
|
||
"{broken"
|
||
)
|
||
);
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Valid message");
|
||
});
|
||
|
||
test("can ignore assistant messages from the active Codex turn", () => {
|
||
const previousTurnId = "turn-previous";
|
||
const activeTurnId = "turn-active";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(previousTurnId),
|
||
userMessage("Explain the thing"),
|
||
assistantMessage("Substantive final answer"),
|
||
turnCompleted(previousTurnId),
|
||
turnStarted(activeTurnId),
|
||
userMessage("[$plannotator-last]"),
|
||
assistantMessage("I’ll open Plannotator on my last response.")
|
||
)
|
||
);
|
||
|
||
const result = getLastCodexMessage(path, { beforeActiveTurn: true });
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Substantive final answer");
|
||
});
|
||
|
||
test("keeps default latest-message behavior inside an active turn", () => {
|
||
const turnId = "turn-active";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
assistantMessage("Previous answer"),
|
||
turnStarted(turnId),
|
||
assistantMessage("Current status update")
|
||
)
|
||
);
|
||
|
||
const result = getLastCodexMessage(path);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.text).toBe("Current status update");
|
||
});
|
||
});
|
||
|
||
describe("getLatestCodexPlan", () => {
|
||
test("prefers the latest persisted plan item for the current turn", () => {
|
||
const turnId = "turn-plan-item";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
assistantMessage("<proposed_plan>\nFallback text\n</proposed_plan>"),
|
||
completedPlanItem("Authoritative plan item", turnId)
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, { turnId });
|
||
expect(result).toEqual({
|
||
text: "Authoritative plan item",
|
||
source: "plan-item",
|
||
});
|
||
});
|
||
|
||
test("falls back to raw proposed_plan blocks for plan-only assistant replies", () => {
|
||
const turnId = "turn-plan-only";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
assistantMessage("<proposed_plan>\n- First\n- Second\n</proposed_plan>")
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, { turnId });
|
||
expect(result).toEqual({
|
||
text: "- First\n- Second",
|
||
source: "assistant-message",
|
||
});
|
||
});
|
||
|
||
test("extracts plan blocks surrounded by assistant prose", () => {
|
||
const turnId = "turn-prose";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
assistantMessage(
|
||
[
|
||
"Here is the plan I recommend.",
|
||
"",
|
||
"<proposed_plan>",
|
||
"1. Inspect hook payloads",
|
||
"2. Launch Plannotator",
|
||
"</proposed_plan>",
|
||
"",
|
||
"I can revise it if needed.",
|
||
].join("\n")
|
||
)
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, { turnId });
|
||
expect(result).toEqual({
|
||
text: "1. Inspect hook payloads\n2. Launch Plannotator",
|
||
source: "assistant-message",
|
||
});
|
||
});
|
||
|
||
test("ignores plans from older turns when the current turn has none", () => {
|
||
const oldTurnId = "turn-old";
|
||
const currentTurnId = "turn-current";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(oldTurnId),
|
||
completedPlanItem("Old plan", oldTurnId),
|
||
turnCompleted(oldTurnId),
|
||
turnStarted(currentTurnId),
|
||
assistantMessage("Just answering a regular question.")
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, { turnId: currentTurnId });
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("returns null when Stop re-entry has no revised plan after the hook prompt", () => {
|
||
const turnId = "turn-stop-no-revision";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
completedPlanItem("Original plan", turnId),
|
||
hookPrompt("Please revise the plan."),
|
||
assistantMessage("I will think through the feedback.")
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, {
|
||
turnId,
|
||
stopHookActive: true,
|
||
});
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("returns null when Stop re-entry repeats the same plan", () => {
|
||
const turnId = "turn-stop-duplicate";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
completedPlanItem("Original plan", turnId),
|
||
hookPrompt("Please revise the plan."),
|
||
completedPlanItem("Original plan", turnId)
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, {
|
||
turnId,
|
||
stopHookActive: true,
|
||
});
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
test("returns the revised plan after a denied Stop review", () => {
|
||
const turnId = "turn-stop-revised";
|
||
const path = writeTempRollout(
|
||
buildRollout(
|
||
sessionMeta(),
|
||
turnStarted(turnId),
|
||
completedPlanItem("Original plan", turnId),
|
||
hookPrompt("Please revise the plan."),
|
||
assistantMessage("<proposed_plan>\nRevised fallback plan\n</proposed_plan>"),
|
||
completedPlanItem("Revised authoritative plan", turnId)
|
||
)
|
||
);
|
||
|
||
const result = getLatestCodexPlan(path, {
|
||
turnId,
|
||
stopHookActive: true,
|
||
});
|
||
expect(result).toEqual({
|
||
text: "Revised authoritative plan",
|
||
source: "plan-item",
|
||
});
|
||
});
|
||
});
|