Files
backnotprop__plannotator/apps/hook/server/codex-session.test.ts
Michael Ramos 26ca4e0275 Single-source skills (core/extra), replace Claude Code commands with skills, de-hardcode installers (#850)
* 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.
2026-06-05 09:23:45 -07:00

517 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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("Ill 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",
});
});
});