diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f84ad0607a8..51222f62189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,10 +112,9 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version - # Pinned to the Pi release the fleet runs and these tests were last green - # on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails - # fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility - # with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests). + # Pinned to the Pi release the fleet runs, so the required check does not + # move under a PR. Drift against the latest public Pi is watched by the + # separate, non-blocking pi-latest workflow. - name: Install the Pi package for the Pi extension tests run: | set -eu @@ -220,10 +219,9 @@ jobs: # The Pi extension tests read the installed Pi package's own types and # runtime, so without it they gate-skip and pass silently. It is a public # npm package and needs no credential, so CI can hold the real thing. - # Pinned to the Pi release the fleet runs and these tests were last green - # on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails - # fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility - # with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests). + # Pinned to the Pi release the fleet runs, so the required check does not + # move under a PR. Drift against the latest public Pi is watched by the + # separate, non-blocking pi-latest workflow. - name: Install the Pi package for the Pi extension tests run: | set -eu diff --git a/.github/workflows/pi-latest.yml b/.github/workflows/pi-latest.yml new file mode 100644 index 00000000000..e75bd579029 --- /dev/null +++ b/.github/workflows/pi-latest.yml @@ -0,0 +1,40 @@ +name: Pi latest compatibility + +# Early warning, not a gate: the required CI pins the Pi release the fleet runs +# (see ci.yml). This workflow runs the Pi extension tests against the latest +# public Pi so stock-rendering drift is seen here, daily, instead of turning +# every PR red. It never runs on pull requests, so it cannot block a merge. +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + pi-latest: + name: Pi extension tests on latest Pi + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Require tmux + run: | + set -eu + command -v tmux >/dev/null || { echo "::error::tmux is required"; exit 1; } + - name: Install latest Pi and TypeScript + run: | + set -eu + npm install -g @earendil-works/pi-coding-agent@latest typescript + npm ls -g --depth 0 @earendil-works/pi-coding-agent + - name: Run Pi extension tests against latest Pi + run: | + set -u + rc=0 + for t in fm-pi-primary-types fm-pi-branch-extension fm-calm-pi-extension; do + echo "::group::$t" + bash "tests/$t.test.sh" || { echo "::error::$t failed against latest Pi"; rc=1; } + echo "::endgroup::" + done + exit "$rc" diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index f8a5136012a..ebf360c9609 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -2150,6 +2150,60 @@ ${context.command} return stockOutcomesPreviewLines ?? undefined; }; + // Pi 0.99 made the stock generic call header carry the tool arguments + // (`name key=value`, or `key: value` lines when expanded); Pi 0.87 shows only + // the title. Probe once so this shell keeps matching whatever the installed + // stock renderer does, and mirror Pi's format only when it shows arguments. + let stockCallShowsArgs: boolean | undefined; + const getStockCallShowsArgs = (): boolean => { + if (stockCallShowsArgs !== undefined) return stockCallShowsArgs; + try { + const probeDefinition: ToolDefinition = { + name: "fm_call_args_probe", + label: "Call args probe", + description: "Call args probe", + parameters: Type.Object({}), + execute: async () => ({ content: [], details: undefined }), + }; + const probe = new ToolExecutionComponent( + probeDefinition.name, + "fm-call-args-probe", + { fm_probe: 1 }, + { showImages: false }, + probeDefinition, + { requestRender() {} } as ConstructorParameters[5], + root, + ); + stockCallShowsArgs = probe.render(4096).join("\n").includes("fm_probe"); + } catch { + stockCallShowsArgs = false; + } + return stockCallShowsArgs; + }; + const formatOutcomesToolCall = ( + title: string, + args: unknown, + theme: Parameters>[1], + expanded: boolean, + ): string => { + const header = theme.fg("toolTitle", theme.bold(title)); + if (!getStockCallShowsArgs() || args == null) return header; + const entries: Array<[string, unknown]> = typeof args === "object" && !Array.isArray(args) + ? Object.entries(args as Record) + : [["args", args]]; + if (entries.length === 0) return header; + if (expanded) { + const lines = entries.map(([key, value]) => { + const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value)); + return ` ${key}: ${text.replace(/\t/g, " ").replace(/\r/g, "").split("\n").join("\n ")}`; + }); + return `${header}\n${theme.fg("muted", lines.join("\n"))}`; + } + const pairs = entries.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`).join(" "); + const preview = pairs.length > 100 ? `${pairs.slice(0, 97)}...` : pairs; + return `${header} ${theme.fg("muted", preview)}`; + }; + type OutcomesToolShellState = { shell?: Box; call?: Text; @@ -2184,11 +2238,11 @@ ${context.command} recent: Type.Optional(Type.Number({ description: "How many most-recent outcomes to read (default 20)" })), }), renderShell: "self", - renderCall: (_args, theme, context) => { + renderCall: (args, theme, context) => { if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering"); if (calmHides("assistant-tool-call")) return new Container(); const shellState = context.state as OutcomesToolShellState; - shellState.call = new Text(theme.fg("toolTitle", theme.bold("fm_branch_outcomes")), 0, 0); + shellState.call = new Text(formatOutcomesToolCall("fm_branch_outcomes", args, theme, context.expanded), 0, 0); return refreshOutcomesToolShell(shellState, theme, context); }, renderResult: (result, options, theme, context) => { @@ -2246,11 +2300,11 @@ ${context.command} through: Type.Number({ description: "The highest outcome sequence number this conversation has processed" }), }), renderShell: "self", - renderCall: (_args, theme, context) => { + renderCall: (args, theme, context) => { if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering"); if (calmHides("assistant-tool-call")) return new Container(); const shellState = context.state as OutcomesToolShellState; - shellState.call = new Text(theme.fg("toolTitle", theme.bold("fm_branch_processed")), 0, 0); + shellState.call = new Text(formatOutcomesToolCall("fm_branch_processed", args, theme, context.expanded), 0, 0); return refreshOutcomesToolShell(shellState, theme, context); }, renderResult: (result, _options, theme, context) => { diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 5fde98777da..40959e6c33a 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -4383,18 +4383,25 @@ JS chrome_report=$(render_export_dom "$chrome" "$export_file" "$export_dom" "$version") \ || fail "could not render calm-mode HTML export DOM: $chrome_report" node - "$export_dom" <<'JS' || fail "rendered export DOM violated the Calm conversation boundary" +// Name the failed assertion so a drift in Pi's export shape is diagnosable from a CI log. +const bail = (what) => { console.error(`export DOM assertion failed: ${what}`); process.exit(1); }; const dom = require("node:fs").readFileSync(process.argv[2], "utf8"); const messages = dom.match(/
([\s\S]*?)<\/main>/)?.[1]; const tree = dom.match(/]*id="tree-container"[^>]*>([\s\S]*?)]*id="tree-status"/)?.[1]; -if (!messages || !tree) process.exit(1); -if (!/
]*>[\s\S]*Show a deterministic tool example\./.test(messages)) process.exit(1); -if (!/
]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) process.exit(1); -if (messages.includes('
]*>[\s\S]*Show a deterministic tool example\./.test(messages)) bail("check 2"); +if (!/
]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) bail("check 3"); +if (messages.includes('
= 0.99 keeps hidden custom messages in the export DOM, hidden by CSS; older Pi omits them. +const marker = "[firstmate-synthetic-input]"; +for (let at = messages.indexOf(marker); at !== -1; at = messages.indexOf(marker, at + 1)) { + const start = messages.lastIndexOf('
/dev/null 2>&1 || true; if [ -f "$TMP_ROOT/remote-jobs/worker.pid" ]; then kill "$(cat "$TMP_ROOT/remote-jobs/worker.pid")" 2>/dev/null || true; fi; rm -rf -- "$TMP_ROOT"' EXIT +trap 'FM_HOME="$PARENT" FM_PROCEVENT_CLAIM_ROOT="$CLAIMS" "$ROOT/bin/fm-procevent.sh" sweep-home >/dev/null 2>&1 || true; if [ -f "$TMP_ROOT/remote-jobs/worker.pid" ]; then kill "$(cat "$TMP_ROOT/remote-jobs/worker.pid")" 2>/dev/null || true; fi; chmod -R u+rwX -- "$TMP_ROOT" 2>/dev/null || true; rm -rf -- "$TMP_ROOT"' EXIT # A synthetic value the remote launch must override rather than inherit, so a # launch that only forwarded the ambient environment cannot pass as a floor.