Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/pi-latest.yml
Original file line number Diff line number Diff line change
@@ -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"
62 changes: 58 additions & 4 deletions .pi/extensions/fm-branch-supervision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ToolExecutionComponent>[5],
root,
);
stockCallShowsArgs = probe.render(4096).join("\n").includes("fm_probe");
} catch {
stockCallShowsArgs = false;
}
return stockCallShowsArgs;
};
const formatOutcomesToolCall = (
title: string,
args: unknown,
theme: Parameters<NonNullable<ToolDefinition["renderCall"]>>[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<string, unknown>)
: [["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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
21 changes: 14 additions & 7 deletions tests/fm-calm-pi-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<div id="messages">([\s\S]*?)<\/main>/)?.[1];
const tree = dom.match(/<div[^>]*id="tree-container"[^>]*>([\s\S]*?)<div[^>]*id="tree-status"/)?.[1];
if (!messages || !tree) process.exit(1);
if (!/<div class="user-message"[^>]*>[\s\S]*Show a deterministic tool example\./.test(messages)) process.exit(1);
if (!/<div class="assistant-message"[^>]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) process.exit(1);
if (messages.includes('<div class="hook-message"')) process.exit(1);
if (messages.includes("[firstmate-synthetic-input]")) process.exit(1);
if (!messages || !tree) bail("check 1");
if (!/<div class="user-message"[^>]*>[\s\S]*Show a deterministic tool example\./.test(messages)) bail("check 2");
if (!/<div class="assistant-message"[^>]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) bail("check 3");
if (messages.includes('<div class="hook-message"')) bail("check 4");
// Pi >= 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('<div class="hook-message', at);
if (start === -1 || !messages.startsWith('<div class="hook-message hook-message-hidden"', start)) bail("check 5");
}
for (const current of ["CURRENT_WATCHER_E2E", "CURRENT_TURN_END_E2E", "CURRENT_AWAY_E2E", "CURRENT_FROM_FIRSTMATE_E2E", "CURRENT_LAUNCH_BRIEF_E2E"]) {
if (!messages.includes(current)) process.exit(1);
if (!messages.includes(current)) bail(`missing ${current}`);
}
if (!tree.includes("firstmate-synthetic-input") || !tree.includes("/tmp/probe.status")) process.exit(1);
if (!tree.includes("firstmate-synthetic-input") || !tree.includes("/tmp/probe.status")) bail("check 6");
JS
# Calm returns the transcript to its own presentation once the export has been
# rendered. That repaint runs on the macrotask right after Pi prints the export
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-spawn-compact-adviser-disable-remote.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ HERDR_STATE="$TMP_ROOT/remote-herdr.state"
CLAIMS="$TMP_ROOT/claims"
mkdir -p "$PARENT/data" "$PARENT/state" "$PARENT/config" "$PARENT/projects" \
"$REMOTE_ROOT" "$CLAIMS" "$PROBEBIN" "$TMP_ROOT/pane-home"
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; 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.
Expand Down
Loading