diff --git a/packages/agent/src/adapters/claude/session/rtk.ts b/packages/agent/src/adapters/claude/session/rtk.ts index c609d7ee4d..0b7d256826 100644 --- a/packages/agent/src/adapters/claude/session/rtk.ts +++ b/packages/agent/src/adapters/claude/session/rtk.ts @@ -15,7 +15,8 @@ import { gitSubcommand } from "../git-command"; // Commands RTK compresses faithfully and that have no side effects, so wrapping // them changes only how much output reaches the model, never what runs. -const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); +// Exported so the instruction-level Codex guidance advertises the same set. +export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // Git subcommands whose output is worth compressing and that RTK handles // faithfully. The criterion is compressible output, NOT read-only: RTK never @@ -23,7 +24,8 @@ const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // `git reflog expire`) still executes its write — its output is just shorter. // Excludes commit/push: negligible output to compress, and the cloud // signed-commit guard keys on a leading `git` token that `rtk git …` would hide. -const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ +// Exported so the instruction-level Codex guidance advertises the same set. +export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ "status", "diff", "log", @@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ // wrapping only its head would change the meaning of the rest. const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; -function shQuote(value: string): string { +// Exported so the instruction-level Codex guidance quotes the prefix the same way. +export function shQuote(value: string): string { if (/^[\w./-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } diff --git a/packages/agent/src/adapters/rtk-guidance.test.ts b/packages/agent/src/adapters/rtk-guidance.test.ts new file mode 100644 index 0000000000..1664ca1a1c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.test.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, +} from "./claude/session/rtk"; +import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance"; + +describe("rtk guidance for codex", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-")); + binary = path.join(dir, "rtk"); + fs.writeFileSync(binary, "#!/bin/sh\n"); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe("buildRtkGuidance", () => { + // The guidance must advertise exactly the Claude hook's eligibility sets, + // so the token-usage cohorts stay comparable across adapters. + test("advertises every command the Claude hook rewrites", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + for (const command of RTK_PLAIN_COMMANDS) { + expect(guidance).toContain(command); + } + for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) { + expect(guidance).toContain(sub); + } + }); + + test("uses the resolved binary path in the examples", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + expect(guidance).toContain("`/usr/local/bin/rtk git status`"); + }); + + // A desktop install can resolve a path with spaces; unquoted it would + // split into multiple shell tokens and every guided command would fail. + test("shell-quotes a binary path containing spaces", () => { + const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk"); + expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`"); + expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`"); + }); + + // Parity with the Claude hook's exclusion: prefixing commit/push would + // hide the leading `git` token from the cloud signed-commit guard. + test("forbids prefixing git commit and git push", () => { + const guidance = buildRtkGuidance("rtk"); + expect(guidance).toContain("Never prefix `git commit`, `git push`"); + }); + }); + + describe("appendRtkGuidanceForCodex", () => { + test("appends guidance when rtk is on PATH", () => { + const result = appendRtkGuidanceForCodex("base instructions", { + PATH: dir, + }); + expect(result.startsWith("base instructions\n\n")).toBe(true); + expect(result).toContain("rtk command-output compression"); + expect(result).toContain(binary); + }); + + // POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must + // silence the guidance too, which is why the gate is resolveRtkPrefix + // rather than detectRtkBinary. + test.each([["0"], ["false"]])( + "returns instructions unchanged when POSTHOG_RTK is %s", + (value) => { + expect( + appendRtkGuidanceForCodex("base instructions", { + POSTHOG_RTK: value, + PATH: dir, + }), + ).toBe("base instructions"); + }, + ); + + test("returns instructions unchanged when rtk is not installed", () => { + expect( + appendRtkGuidanceForCodex("base instructions", { + PATH: "/nonexistent", + }), + ).toBe("base instructions"); + }); + + test("does not leave a leading separator when instructions are empty", () => { + const result = appendRtkGuidanceForCodex("", { PATH: dir }); + expect(result.startsWith("## rtk")).toBe(true); + }); + }); +}); diff --git a/packages/agent/src/adapters/rtk-guidance.ts b/packages/agent/src/adapters/rtk-guidance.ts new file mode 100644 index 0000000000..962913a32c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.ts @@ -0,0 +1,59 @@ +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, + resolveRtkPrefix, + shQuote, +} from "./claude/session/rtk"; + +/** + * Instruction-level RTK integration for Codex sessions. + * + * The Claude adapter routes eligible commands through RTK deterministically + * with a PreToolUse hook that rewrites the Bash input. Codex executes shell + * commands internally over JSON-RPC and its app-server protocol has no + * command-rewrite channel — the adapter can only approve or deny — so the + * only integration point is the developer instructions: tell the model to + * prefix eligible commands itself. + * + * The advertised command set and rules mirror the Claude hook exactly + * (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only, + * never commit/push), so token-usage cohorts stay comparable across adapters. + */ +export function buildRtkGuidance(rtkPrefix: string): string { + // Same quoting as the Claude rewrite hook: a resolved path containing + // spaces must stay one shell token in the commands the model copies. + const prefix = shQuote(rtkPrefix); + const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `"); + const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", "); + + return `## rtk command-output compression + +\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`: + +- \`${plainCommands}\` +- these git subcommands: ${gitSubcommands} + +Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`. + +Rules: +- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads. +- Never prefix \`git commit\`, \`git push\`, or any other command not listed above. +- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`; +} + +/** + * Appends the RTK guidance to Codex developer instructions when an RTK binary + * is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the + * per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the + * guidance along with everything else. + */ +export function appendRtkGuidanceForCodex( + instructions: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const rtkPrefix = resolveRtkPrefix(env); + if (!rtkPrefix) return instructions; + return [instructions, buildRtkGuidance(rtkPrefix)] + .filter(Boolean) + .join("\n\n"); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 7de8e1eb78..55a88fe26d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -43,6 +43,7 @@ import { classifyAgentError, isPromptTooLongError, } from "../adapters/error-classification"; +import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME, SIGNED_MERGE_QUALIFIED_TOOL_NAME, @@ -2941,9 +2942,11 @@ export class AgentServer { private buildCodexInstructions( systemPrompt: string | { append: string }, ): string { - return typeof systemPrompt === "string" - ? systemPrompt - : systemPrompt.append; + const instructions = + typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append; + // Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is + // adopted through the developer instructions instead. + return appendRtkGuidanceForCodex(instructions); } /**