Merge remote-tracking branch 'origin/main' into fix/2864-mimo-efforts

# Conflicts:
#	packages/catalog/src/model-thinking.ts
#	packages/catalog/src/models.json
#	packages/catalog/src/variant-collapse.ts
#	packages/catalog/test/variant-collapse.test.ts
#	packages/coding-agent/test/model-registry.test.ts
This commit is contained in:
Alexander Kirilin
2026-06-19 10:29:52 -04:00
641 changed files with 35218 additions and 7721 deletions
-2
View File
@@ -20,8 +20,6 @@
"ignore": [
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
"packages/natives/native/index.js",
// Generated by `packages/coding-agent/scripts/generate-docs-index.ts`.
"packages/coding-agent/src/internal-urls/docs-index.generated.ts",
// Embedded HTML asset shipped as a static template, not a code module.
"packages/coding-agent/src/export/html/template.js",
// Generated/owned upstream — see packages/ai/scripts/generate-models.ts.
+107
View File
@@ -0,0 +1,107 @@
# The list of vouched (or actively denounced) users for this repository.
#
# Only vouched users can open PRs here; unvouched/denounced PRs are
# auto-closed by .github/workflows/vouch-pr.yml (mitchellh/vouch check-pr).
# Issues are intentionally NOT gated here — robomp triages those.
#
# Write-access collaborators and bots are auto-allowed and need no entry.
# A denounced user ("-handle") is always blocked, even if also listed.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: `platform:username` (default platform: github).
# - Denounce by prefixing with minus: `-username` / `-platform:username`.
# - Optional free-text reason after a space following the handle.
#
# Maintainers manage this list by commenting `!vouch` / `!denounce [user]`
# on a discussion (see .github/workflows/vouch-manage.yml).
#
# Seed (2026-06-19): authors with >=2 merged PRs in the prior 6 months.
# Audit found 0 denounce-worthy actors; all reverts were maintainer
# technical rollbacks, not abuse. See the PR/commit history for provenance.
a-glapinski
ak4153
apoc
AsafMah
azais-corentin
basedcorp99
BayLee4
bjin
cagedbird043
cexll
chan1103
chuaaron
ckumar1
daandden
daaximus
danzaio
DarkPhilosophy
DeprecatedLuke
djdembeck
dmarsh-gusto
elikoga
enieuwy
ForeverYoungPp
GratefulDave
H4vC
HabibPro1999
handlecusion
haosenwang1018
hezhiyang2000
infernix
inprealpha
itertea
jchristman
jiwangyihao
kamafozilov
KamijoToma
Kukkerem
ldx
lederniermagicien
loftiskg
lyc-aon
makoMakoGo
masonc15
maximhar
maxvisionai
metaphorics
MikeeI
Mokto
mouyase
muness
nnk97
ogrodev
oldschoola
Parsifa1
phanthh
pidevxplay
qfrtt
ravshansbox
rburketaylor
RensTillmann
riverpilot
romanalexander
RzNmKX
scarthread
segmentationf4u1t
shoucandanghehe
shyndman
sit
slact
smileynet
sundbp
superhedge22
tc97222
tdiant
tsagi2045
turbomolli
usr-bin-roygbiv
vmcall
VoidChecksum
voiys
watzon
WodenJay
wolfiesch
zakhar-kogan
zamorakpds
+44
View File
@@ -0,0 +1,44 @@
name: Vouch (manage)
# Let maintainers vouch/denounce/unvouch by commenting on a Discussion:
# !vouch vouch the discussion author
# !vouch @user [reason] vouch a specific user
# !denounce [@user] [reason]
# !unvouch [@user]
# Only collaborators with admin/maintain/write are honored (triage EXCLUDED;
# upstream's default `roles` includes triage, which we override below).
#
# Commits the VOUCHED.td change back to the default branch using the stock
# GITHUB_TOKEN (no GitHub App needed). NOTE: this works only while the default
# branch is UNPROTECTED — GITHUB_TOKEN cannot bypass branch protection. If you
# protect the branch later, switch back to a GitHub App token on a bypass list.
on:
discussion_comment:
types: [created]
# Serialize writes to VOUCHED.td so concurrent vouches don't clobber.
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write # commit VOUCHED.td
discussions: write # read the comment / acknowledge
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/manage-by-discussion@v1
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
vouch-keyword: "!vouch"
denounce-keyword: "!denounce"
unvouch-keyword: "!unvouch"
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+51
View File
@@ -0,0 +1,51 @@
name: Vouch (PR gate)
# Auto-close PRs from unvouched or denounced users. Issues are left alone
# (robomp triages those). Runs under `pull_request_target` so the token can
# act on fork PRs; this job does NO checkout and runs NO PR code — it only
# reads .github/VOUCHED.td from the base repo and calls the GitHub API.
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
permissions:
contents: read # read VOUCHED.td from the base branch
pull-requests: write # close + comment
issues: write # add the `vouched` label (labels use the Issues API)
concurrency:
group: vouch-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: vouch
uses: mitchellh/vouch/action/check-pr@v1
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
require-vouch: true # block unvouched, not only denounced
# vouched-file: .github/VOUCHED.td (default)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Survivors of the gate (vouched, or auto-allowed collaborators/bots) get
# a FRESH `vouched` label on every (re)open / ready-for-review. robomp
# reviews ONLY on that label event (ROBOMP_PR_REVIEW_TRIGGER=vouched_label),
# so review is always triggered by a just-validated PR, never a stale label.
- name: Label vouched PRs for robomp review
if: ${{ steps.vouch.outputs.status == 'vouched' || steps.vouch.outputs.status == 'allowed' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
gh label create vouched --repo "$REPO" --color 2da44e --description "Passed the vouch gate" --force
# remove+add so a fresh `labeled` event fires even when the label
# persisted across close/reopen (re-adding an existing label emits no
# event). The check above just re-validated, so trust is never stale.
gh pr edit "$PR" --repo "$REPO" --remove-label vouched || true
gh pr edit "$PR" --repo "$REPO" --add-label vouched
+1 -1
View File
@@ -55,9 +55,9 @@ out.html
pi-*.html
# Generated files
packages/coding-agent/src/internal-urls/docs-index.generated.ts
packages/coding-agent/src/export/html/tool-views.generated.js
packages/natives/npm/
packages/coding-agent/src/utils/mupdf-wasm.wasm
/runs/
python/omp-rpc/src/omp_rpc.egg-info/
# parallel-agent worktrees
@@ -0,0 +1,118 @@
---
name: tool-prompt-optimization
description: Optimize the description prompts an AI agent reads to learn its built-in tools (the `.md` files under prompts/tools/). Two halves: (1) measure how much of a prompt is already inferable from the tool's JSON parameter schema + name, to prune redundancy with evidence; (2) house authoring rules for what belongs in a tool prompt vs what stays in code. Use when auditing, trimming, writing, or reviewing tool prompts, deciding what schema field descriptions already cover, or testing schema-vs-prompt overlap before deleting prompt lines.
---
# Tool Prompt Optimization
A tool's description prompt and its parameter schema overlap. Whatever a model can reconstruct from the **schema + tool name + a blank outline** is a *prune candidate* — the schema may already teach it. This skill measures that overlap so you prune with evidence, not vibes. A candidate is never an automatic delete (see caveats — history first).
Core move: give a model only `(name, JSON schema, outline)` and have it predict the prompt body. Lines it predicts reliably are *prune candidates*. Lines it never recovers are *load-bearing* — keep them.
## Run the probe
`scripts/probe.ts` routes through `@oh-my-pi/pi-ai` (`completeSimple`) so model/auth/provider behavior matches production.
```bash
bun .omp/skills/tool-prompt-optimization/scripts/probe.ts \
--schema <file|json> --template <file|text> --name <tool_name>
```
- `--schema` and `--template` are the only required inputs (file path or inline value).
- No `--model` → 3-model panel (`fireworks/kimi-k2.7-code`, `anthropic/claude-opus-4-8`, `openai/gpt-5.5`) × `--samples` (default 3). Needs `FIREWORKS_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`.
- `--model p/id,p/id` overrides the panel; `--samples N`, `--max-tokens`, `--json` tune it.
- Programmatic: `import { probe } from "./scripts/probe.ts"` → `{ prompt, results: [{ model, samples: [{ text, stopReason, usage, error }] }] }`.
### Builtin shortcut (preferred for this repo's tools)
Skip building the two inputs by hand — `scripts/probe-builtin.ts` instantiates the live tool, pulls the EXACT wire schema (`toolWireSchema`) and rendered prompt (`tool.description`), and derives the outline for you:
```bash
bun .omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts --tool <name> [--no-summary] [--show]
```
- `--show` prints the resolved schema + derived outline + real prompt and exits (no API calls) — use it to eyeball inputs before spending tokens.
- `--no-summary` runs the ablation (blank the summary line) directly.
- `--samples` / `--model` / `--max-tokens` / `--json` forward to the panel; output ends with the REAL prompt so you can diff in place.
- It bypasses the settings allowlist via the factory map, so gated tools (`irc`, `github`, …) resolve. If a tool refuses to construct (an availability gate like a missing `gh` CLI), fall back to the manual inputs below.
## Build the two inputs
**Schema** — use the *wire* schema the model actually sees, not a hand-sketch. For this repo's arktype tool schemas:
```ts
import { arkToWireSchema } from "@oh-my-pi/pi-ai"; // or toolWireSchema(tool)
JSON.stringify(arkToWireSchema(toolSchema), null, 2);
```
Include `required` and `additionalProperties: false` — omitting them makes the model infer looser usage than the real tool.
**Template (outline)** — the real `.md`'s structure with bodies blanked: the one-line summary, then each section tag with `...` inside.
```
Structural code search via native ast-grep AST matching.
<instruction>
...
</instruction>
<output>
...
</output>
<critical>
...
</critical>
```
## Interpret results
Bucket every line of the real prompt against the predictions:
- **Prune candidate** — content that is STABLE across samples AND agrees across models AND restates the schema (param names, types, "required", value examples already in a field `description`, clamp ranges already stated). The schema teaches it; the prompt repeats it.
- **Keep** — content no model recovers: defaults and their direction (`gitignore` default true), cross-tool routing/escalation ("NEVER shell out to `find`/`fd` → use this tool", "broad exploration → Task subagent"), exact output format (mtime sort, grouping, `artifact://` truncation), worked anti-patterns, and hard constraints invisible to a type (the AST metavariable grammar, C++ trailing `;`).
A single sample is noise. Only treat overlap that is **stable across samples and models** as a prune *candidate* — and a candidate is not a verdict until its history clears (see caveats). You MUST NOT delete a line on inferability alone.
## Caveats — read before deleting anything
- **`git blame` before cutting — MUST, not SHOULD.** Many prompt lines were added on purpose after a real failure: a model that hallucinated a flag, shelled out, scanned the repo root, fabricated an anchor. They look redundant precisely because they now prevent the mistake. You MUST `git blame` (and read the commit/issue) every line you intend to cut; the history tells you whether it restates the schema or is scar tissue from an incident. Keep scar tissue. Inferability is necessary for pruning, NEVER sufficient.
- **Memorization ≠ inference.** Public repos (this one included) may be in training data, so a model can *recite* `ast-grep.md` it never *inferred*. Tell: predictions naming repo-specific details absent from the schema (exact tool names, internal URI schemes, the `Task` subagent) are memorized, not derived — discount them.
- **The outline leaks.** The summary line and section names are themselves hints. To isolate *schema-alone* inferability, run an ablation: a second pass with no summary line and generic section tags. Content that survives only with the summary present is "summary-inferable", not "schema-inferable".
## Verdict pattern
Per tool: predictions reproduce parameter mechanics and generic usage (already in the schema) but miss defaults, output shape, cross-tool routing, anti-patterns, and domain grammar. Prune the first set (after `git blame` clears each line); keep the second. Self-documenting flag tools (e.g. `find`) prune heavily; DSL/capability tools (e.g. `read`, `ast_grep`) barely at all.
## Tool Prompt Authoring
Tool prompts are not API docs. They teach the agent **when to reach for the tool, what shape its inputs take, and which failure modes are the agent's responsibility**. Everything else — engine internals, recovery heuristics, fallback chains, performance tuning — stays in code.
### Describe surface, not machinery
The agent picks tools from prose, not source. Tell it WHEN and WHY; NEVER HOW the tool works internally.
- `read.md` enumerates every source it covers (file/dir/archive/sqlite/PDF/URL) so the agent stops reaching for `cat`/`curl`/`tar`. It does NOT mention the chunker, the binary sniffer, or the cache layer.
- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." No mention of the LSP wire protocol, server lifecycle, or capability negotiation.
- `ast_edit`: teaches metavariable syntax + workflow ("Loosest existence check: `pat: 'executeBash'` with narrow paths"). Does NOT explain the AST engine, query compilation, or tree-sitter grammar selection.
- `hashline.md` (this repo): teaches the **patch grammar** (anchors, ops, payloads, ranges) and the **edit shapes** that succeed. Hides `tryRecoverHashlineWithCache`, the fuzz factor, the bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`. The agent never learns those names — it just sees "the tool resolved your typo" or "the anchor was stale, re-read".
If the agent's behavior shouldn't change based on a detail, the detail does NOT belong in the prompt. Each sentence MUST shift a decision the agent makes.
### Anatomy of a good tool prompt
1. **One-line purpose.** What problem it solves, in the agent's vocabulary. Not "wraps libfoo with X" — instead "compact, line-anchored edit format".
2. **Input grammar / surface.** Operators, parameters, selectors. Concrete syntax the agent will emit verbatim.
3. **Worked examples.** 3–8 patterns covering the common shapes. Each example IS the explanation — don't narrate it twice.
4. **Failure shapes the agent owns.** Things the agent can fix by changing its input (stale anchors, missing payload prefix, fabricated hash). Skip failures the engine recovers from silently.
5. **Anti-patterns.** WRONG/RIGHT pairs for the mistakes that cost retries. Drawn from real failures, not imagined ones.
6. **`<critical>` recap.** 3–6 lines of the load-bearing rules, in case the agent skips the body.
### What stays out
- Implementation file names, function names, module layout.
- Recovery, retry, normalization, caching, fuzz matching.
- Performance characteristics ("this is O(n)") unless they change the agent's strategy.
- Telemetry, logging, debug flags, env vars the agent cannot set.
- Version history, deprecated parameters, "previously this worked differently".
- Cross-tool plumbing ("this calls `read` under the hood") unless the agent must coordinate them.
@@ -0,0 +1,162 @@
#!/usr/bin/env bun
/**
* Builtin shortcut for the prompt-inference probe.
*
* Instead of hand-writing a tool's JSON schema + outline, point this at a live
* builtin tool name. It resolves the tool (direct constructor for availability-gated
* `github`/`irc`, else the BUILTIN_TOOLS/HIDDEN_TOOLS factory map), pulls the EXACT wire
* schema the model sees (`toolWireSchema`) and the rendered prompt (`tool.description`),
* derives an outline by blanking section bodies, then runs the same `probe()` panel.
*
* Usage:
* bun probe-builtin.ts --tool <name> [--no-summary] [--show]
* --tool <name> builtin tool name (e.g. irc, github, read). Required.
* --no-summary ablation: blank the one-line summary too (isolate schema-alone).
* --show print resolved schema + outline + real prompt and exit (no API calls).
* --samples / --model / --max-tokens / --json forwarded to probe().
*
* The heavy coding-agent import lives here; probe.ts stays pi-ai-only.
*/
import { parseArgs } from "node:util";
import { toolWireSchema } from "@oh-my-pi/pi-ai";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { BUILTIN_TOOLS, GithubTool, HIDDEN_TOOLS, IrcTool, type Tool, type ToolFactory, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { probe } from "./probe.ts";
const OPEN_TAG = /^<[a-z_][\w-]*>$/i;
const CLOSE_TAG = /^<\/[a-z_][\w-]*>$/i;
const MD_HEADER = /^#{1,6}\s/;
/** Keep the summary + every section header/tag; collapse each body run to a single `...`. */
function deriveOutline(description: string, dropSummary: boolean): string {
const lines = description.split("\n");
let i = 0;
const summary: string[] = [];
const isHeader = (l: string): boolean => {
const t = l.trim();
return OPEN_TAG.test(t) || CLOSE_TAG.test(t) || MD_HEADER.test(l);
};
while (i < lines.length && !isHeader(lines[i])) {
summary.push(lines[i]);
i++;
}
const parts: string[] = [dropSummary ? "" : summary.join("\n").trim()];
let pendingBody = false;
for (; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (OPEN_TAG.test(t) || MD_HEADER.test(line)) {
parts.push("", line);
pendingBody = false;
} else if (CLOSE_TAG.test(t)) {
parts.push(line);
pendingBody = false;
} else if (t !== "") {
if (!pendingBody) {
parts.push("...");
pendingBody = true;
}
}
}
return parts.join("\n").trim();
}
async function resolveTool(name: string): Promise<Tool> {
await Settings.init({ inMemory: true, cwd: process.cwd() });
const settings = Settings.isolated({});
// Enriched stub: we only read `.parameters` / `.description`, never `execute`, so the
// availability-gated factories (irc needs a registry + agent id; github needs `gh`) can
// still construct. Factory map bypasses the settings allowlist (`isToolAllowed`).
const session = {
cwd: process.cwd(),
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings,
taskDepth: 0,
getAgentId: () => "Probe",
agentRegistry: {},
} as ToolSession;
// `github`/`irc` map to `*.createIf`, which gates on external availability (gh CLI) or a
// live agent registry. We only read `.parameters`/`.description`, so direct-construct those
// two — keeps the probe gh-independent. Everything else goes through the factory map.
const direct: Record<string, (s: ToolSession) => Tool> = {
github: s => new GithubTool(s),
irc: s => new IrcTool(s),
};
const key = name.toLowerCase();
const directCtor = direct[key];
if (directCtor) return directCtor(session);
const factories: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
const factory = factories[key];
if (!factory) {
const names = Object.keys(factories).sort().join(", ");
throw new Error(`unknown builtin tool "${name}". available: ${names}`);
}
const tool = await factory(session);
if (!tool) {
throw new Error(`tool "${name}" did not construct here — blocked by an availability gate (e.g. ssh, or a memory backend that isn't configured). Fall back to the manual --schema/--template path.`);
}
return tool;
}
async function main(): Promise<void> {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
tool: { type: "string" },
"no-summary": { type: "boolean" },
show: { type: "boolean" },
samples: { type: "string" },
model: { type: "string" },
"max-tokens": { type: "string" },
json: { type: "boolean" },
},
allowPositionals: false,
});
if (!values.tool) {
console.error("usage: bun probe-builtin.ts --tool <name> [--no-summary] [--show] [--samples N] [--model p/id,...] [--max-tokens N] [--json]");
process.exit(2);
}
const tool = await resolveTool(values.tool);
const schema = toolWireSchema(tool);
const realPrompt = tool.description ?? "";
const outline = deriveOutline(realPrompt, Boolean(values["no-summary"]));
if (values.show) {
console.log(`# tool: ${tool.name}\n\n## wire schema\n\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\`\n`);
console.log(`## derived outline\n${outline}\n`);
console.log(`## real prompt (${Buffer.byteLength(realPrompt)} bytes)\n${realPrompt}`);
return;
}
const run = await probe({
schema,
template: outline,
name: tool.name,
samples: values.samples ? Number(values.samples) : undefined,
models: values.model ? values.model.split(",").map(s => s.trim()).filter(Boolean) : undefined,
maxTokens: values["max-tokens"] ? Number(values["max-tokens"]) : undefined,
});
if (values.json) {
console.log(JSON.stringify({ ...run, realPrompt }, null, 2));
return;
}
for (const result of run.results) {
console.log(`\n############ ${result.model} ############`);
result.samples.forEach((s, i) => {
const tag = s.error ? `ERROR: ${s.error}` : s.stopReason;
console.log(`\n----- sample ${i + 1}/${result.samples.length} [${tag}] -----`);
console.log(s.error ? "" : s.text);
});
}
console.log(`\n############ REAL PROMPT (${tool.name}) ############\n${realPrompt}`);
}
if (import.meta.main) {
await main();
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bun
/**
* Schema -> prompt inference probe.
*
* Given a tool's JSON parameter schema + a description-prompt outline ("template"),
* ask one or more models to reconstruct the full description. Whatever they reliably
* predict is inferable from the schema/outline alone — i.e. a candidate to PRUNE from
* the hand-written prompt. Run several samples and several models: trust only content
* that is STABLE across samples AND agrees across models.
*
* Routes through @oh-my-pi/pi-ai (`completeSimple`) rather than raw HTTP so model
* resolution, auth, and provider quirks match production.
* Per-provider env keys (<PROVIDER>_API_KEY) are resolved automatically; temperature is never sent.
* Caller passes two things: a JSON schema and a template. Everything else has defaults.
* With no `--model`, probes a 3-model panel (Fireworks Kimi, Claude Opus, GPT) x 3 samples.
*
* CLI:
* bun probe.ts --schema <file|json> --template <file|text> [options]
* --name <toolName> tool name shown to the model (default "the tool")
* --samples <n> independent samples per model (default 3)
* --model <p/id[,p/id...]> override panel; comma-separated for several
* --max-tokens <n> output cap (default 1200)
* --json emit JSON instead of human-readable blocks
*
* Programmatic: import { probe } from "./probe.ts"
*/
import { parseArgs } from "node:util";
import { completeSimple } from "@oh-my-pi/pi-ai";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
/** Default 3-model panel when the caller does not pin a model. */
const DEFAULT_MODELS = ["fireworks/kimi-k2.7-code", "anthropic/claude-opus-4-8", "openai/gpt-5.5"];
const DEFAULT_SAMPLES = 3;
const SYSTEM_PROMPT = [
"You write the description prompt that an AI coding agent reads to learn one of its built-in tools.",
"You are given ONLY the tool name and its JSON parameter schema, plus a fixed description outline.",
"Fill in the outline: replace every `...` and write the body of every named section, grounded strictly in the schema.",
"Output ONLY the finished description as markdown. No preamble, no commentary, no surrounding code fence.",
].join("\n");
export interface ProbeOptions {
/** JSON Schema for the tool's parameters (object or JSON string). */
schema: unknown;
/** Description outline: a one-line summary + section skeleton (with `...` placeholders). */
template: string;
/** Tool name surfaced to the model. */
name?: string;
/** Independent samples per model. Only content stable across samples is trustworthy. */
samples?: number;
/** `provider/id` list. Defaults to the 3-model panel. */
models?: string[];
maxTokens?: number;
signal?: AbortSignal;
}
export interface ProbeSample {
text: string;
stopReason: AssistantMessage["stopReason"];
usage?: AssistantMessage["usage"];
error?: string;
}
export interface ProbeModelResult {
model: string;
samples: ProbeSample[];
}
export interface ProbeRun {
prompt: string;
results: ProbeModelResult[];
}
function resolveModel(ref: string): Model<Api> {
const slash = ref.indexOf("/");
if (slash === -1) throw new Error(`model must be "provider/id", got: ${ref}`);
// Runtime-validated below: getBundledModel returns undefined for an unknown provider/id.
const provider = ref.slice(0, slash) as GeneratedProvider;
const id = ref.slice(slash + 1);
const model = getBundledModel(provider, id);
if (!model) throw new Error(`unknown bundled model: ${ref}`);
return model;
}
function extractText(content: AssistantMessage["content"]): string {
let out = "";
for (const block of content) {
if (block.type === "text") out += block.text;
}
return out.trim();
}
function buildUserPrompt(name: string, schema: unknown, template: string): string {
const schemaText = typeof schema === "string" ? schema : JSON.stringify(schema, null, 2);
return [
`Tool name: ${name}`,
"",
"JSON parameter schema:",
"```json",
schemaText,
"```",
"",
"Description outline to complete (write the body of every section; output ONLY the finished description):",
"",
template,
].join("\n");
}
export async function probe(opts: ProbeOptions): Promise<ProbeRun> {
const refs = opts.models && opts.models.length > 0 ? opts.models : DEFAULT_MODELS;
const name = opts.name ?? "the tool";
const sampleCount = Math.max(1, opts.samples ?? DEFAULT_SAMPLES);
const userPrompt = buildUserPrompt(name, opts.schema, opts.template);
const drawOne = async (model: Model<Api>): Promise<ProbeSample> => {
try {
const response = await completeSimple(
model,
{
systemPrompt: [SYSTEM_PROMPT],
messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
},
{
maxTokens: opts.maxTokens ?? 1200,
disableReasoning: true,
signal: opts.signal,
},
);
const text = extractText(response.content);
const sample: ProbeSample = { text, stopReason: response.stopReason, usage: response.usage };
if (response.stopReason === "error") sample.error = response.errorMessage ?? "unknown error";
return sample;
} catch (err) {
return { text: "", stopReason: "error", error: err instanceof Error ? err.message : String(err) };
}
};
const results = await Promise.all(
refs.map(async (ref): Promise<ProbeModelResult> => {
let model: Model<Api>;
try {
model = resolveModel(ref);
} catch (err) {
return { model: ref, samples: [{ text: "", stopReason: "error", error: err instanceof Error ? err.message : String(err) }] };
}
const samples = await Promise.all(Array.from({ length: sampleCount }, () => drawOne(model)));
return { model: `${model.provider}/${model.id}`, samples };
}),
);
return { prompt: userPrompt, results };
}
async function resolveInput(value: string): Promise<string> {
const file = Bun.file(value);
if (await file.exists()) return (await file.text()).trim();
return value;
}
function formatUsage(usage: AssistantMessage["usage"] | undefined): string {
if (!usage) return "";
const out = typeof usage.output === "number" ? usage.output : undefined;
return out === undefined ? "" : `, ${out} tok`;
}
async function main(): Promise<void> {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
schema: { type: "string" },
template: { type: "string" },
name: { type: "string" },
samples: { type: "string" },
model: { type: "string" },
"max-tokens": { type: "string" },
json: { type: "boolean" },
},
allowPositionals: false,
});
if (!values.schema || !values.template) {
console.error(
"usage: bun probe.ts --schema <file|json> --template <file|text> [--name N] [--samples 3] [--model p/id,p/id] [--max-tokens 1200] [--json]",
);
process.exit(2);
}
const schemaRaw = await resolveInput(values.schema);
let schema: unknown = schemaRaw;
try {
schema = JSON.parse(schemaRaw);
} catch {
// Leave as raw text — caller may pass a non-JSON schema notation.
}
const template = await resolveInput(values.template);
const run = await probe({
schema,
template,
name: values.name,
samples: values.samples ? Number(values.samples) : undefined,
models: values.model ? values.model.split(",").map(s => s.trim()).filter(Boolean) : undefined,
maxTokens: values["max-tokens"] ? Number(values["max-tokens"]) : undefined,
});
if (values.json) {
console.log(JSON.stringify(run, null, 2));
return;
}
for (const result of run.results) {
console.log(`\n############ ${result.model} ############`);
result.samples.forEach((s, i) => {
const tag = s.error ? `ERROR: ${s.error}` : `${s.stopReason}${formatUsage(s.usage)}`;
console.log(`\n----- sample ${i + 1}/${result.samples.length} [${tag}] -----`);
console.log(s.error ? "" : s.text);
});
}
}
if (import.meta.main) {
await main();
}
+54
View File
@@ -0,0 +1,54 @@
# Contributing to oh-my-pi
Thanks for your interest in contributing. This project uses a lightweight
**vouch** system to decide who can open pull requests. Please read this before
opening a PR.
## TL;DR
- **Issues are open to everyone.** File bugs, feature requests, and questions
freely — they are triaged automatically.
- **Pull requests require a vouch.** A PR whose author is not vouched (or is
denounced) is **closed automatically**. If you are not yet vouched, do **not**
open a PR to get noticed — it will be closed on sight. Start a Discussion and
ask to be vouched first (see below).
## Who can open PRs
A pull request is accepted when its author is any of:
- a repository collaborator (write access or above), or a bot; or
- listed — without a leading `-` — in [`.github/VOUCHED.td`](.github/VOUCHED.td).
Anyone **denounced** (prefixed with `-` in that file) is always blocked.
## Getting vouched
1. Open a [Discussion](../../discussions) (or comment on an existing one)
describing what you'd like to contribute.
2. A maintainer vouches you by commenting **`!vouch`** (vouches the discussion
author) or **`!vouch @your-handle`** on that discussion.
3. Once you appear in `.github/VOUCHED.td`, open your PR — it stays open and is
reviewed.
Maintainers may also `!denounce [@user]` and `!unvouch [@user]`. Only
collaborators with admin/maintain/write can run these commands.
## What happens to your PR
| You are… | Result |
| --- | --- |
| Vouched (or a collaborator) | PR stays open → automated review → human review |
| Not vouched | PR closed with a comment — get vouched, then reopen or open a new PR |
| Denounced | PR closed |
Pushing more commits to an open, vouched PR is fine — it remains vouched.
## The VOUCHED.td file
[`.github/VOUCHED.td`](.github/VOUCHED.td) is the source of truth: one handle per
line, sorted alphabetically, optionally `platform:handle`, with `-` marking a
denouncement and an optional reason after the handle. The format follows
[mitchellh/vouch](https://github.com/mitchellh/vouch); the denouncement list is
intentionally public so other projects can reuse our prior knowledge of bad
actors.
Generated
+6 -6
View File
@@ -244,9 +244,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitvec"
version = "1.0.1"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
@@ -2314,7 +2314,7 @@ dependencies = [
[[package]]
name = "pi-ast"
version = "16.0.7"
version = "16.1.2"
dependencies = [
"anyhow",
"ast-grep-core",
@@ -2384,7 +2384,7 @@ dependencies = [
[[package]]
name = "pi-iso"
version = "16.0.7"
version = "16.1.2"
dependencies = [
"async-trait",
"libc",
@@ -2396,7 +2396,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "16.0.7"
version = "16.1.2"
dependencies = [
"anyhow",
"arboard",
@@ -2444,7 +2444,7 @@ dependencies = [
[[package]]
name = "pi-shell"
version = "16.0.7"
version = "16.1.2"
dependencies = [
"anyhow",
"brush-builtins",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "16.0.7"
version = "16.1.2"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+2
View File
@@ -49,6 +49,7 @@ WORKDIR /pi
# matched path under /pi/ (requires syntax 1.7-labs).
COPY --parents \
package.json bun.lock bunfig.toml \
patches/*.patch \
tsconfig.base.json tsconfig.json \
Cargo.toml Cargo.lock rust-toolchain.toml \
packages/*/package.json \
@@ -169,6 +170,7 @@ WORKDIR /pi
# only re-runs when a package.json / lockfile changes.
COPY --parents \
package.json bun.lock bunfig.toml \
patches/*.patch \
tsconfig.base.json tsconfig.json \
packages/*/package.json \
packages/tsconfig.workspace.json \
+3
View File
@@ -28,6 +28,9 @@ WORKDIR /work
# --filter robomp-web` to hydrate just the dashboard's node_modules.
COPY package.json bun.lock ./
COPY python/robomp/web/package.json ./python/robomp/web/package.json
# Root package.json declares patchedDependencies, so the patch file must be
# present before any root-level `bun install` (even a filtered one).
COPY patches/*.patch ./patches/
RUN bun install --filter robomp-web
COPY --exclude=node_modules --exclude=dist python/robomp/web/ ./python/robomp/web/
RUN bun --cwd=python/robomp/web run build
+38 -13
View File
@@ -133,7 +133,23 @@ Split a job across workers and get typed results back. task fans out into isolat
_[Watch the capture ↗](https://omp.sh/clips/irc.mp4)_
### 06 · Read a pdf on arxiv, why not?
### 06 · A second model, watching every turn.
Pair a reviewer model to the 'advisor' role and it reads every turn the main agent takes, injecting notes inline — a quiet aside, a concern, or a hard blocker. It runs on its own context and its own model, so it catches what the doer rushed past. The main agent sees the note and course-corrects, or tells you why it won't.
![omp TUI: /advisor status shows the advisor running on openai-codex/gpt-5.5; after the main agent scopes a catch to ENOENT instead of swallowing every error, an amber 'Advisor 1 note (concern)' card warns the fix no longer matches the user's literal acceptance criterion.](https://omp.sh/clips/advisor-poster.webp)
_[Watch the capture ↗](https://omp.sh/clips/advisor.mp4)_
### 07 · Hand someone the link, they're in.
/collab puts your live session on a relay and hands back a link — and a QR. A teammate joins from another terminal with omp join, or just opens it in a browser. Share read-write to pair on the same agent, or /collab view for a read-only link anyone can watch but no one can steer. Frames are sealed client-side; the relay never sees your keys.
![omp TUI: /collab view prints 'Collab session started!' with an omp join command, a my.omp.sh browser link, the note 'Anyone with this link can watch the session but cannot prompt the agent', and a large scannable QR code.](https://omp.sh/clips/collab-poster.webp)
_[Watch the capture ↗](https://omp.sh/clips/collab.mp4)_
### 08 · Read a pdf on arxiv, why not?
web_search chains fourteen ranked providers and hands whatever URLs it finds straight to read. Arxiv PDFs, GitHub pages, Stack Overflow threads come back as structured markdown with anchors intact — the same tool surface you use on local files. Cite, follow, quote, never lose where you came from.
@@ -141,45 +157,45 @@ web_search chains fourteen ranked providers and hands whatever URLs it finds str
_[Watch the capture ↗](https://omp.sh/clips/web.mp4)_
### 07 · Unapologetically native. Even on Windows.
### 09 · Unapologetically native. Even on Windows.
Other agents shell out to rg, grep, find, and bash. On many machines those binaries don't exist, and on the ones where they do, every call costs a fork-exec round-trip. omp links the real implementations into the process. ripgrep, glob, find: in-process. brush is the bash, with sessions that survive across calls. The same omp binary runs on macOS, Linux, and Windows — no WSL bridge.
### 08 · Code review with priorities and a verdict
### 10 · Code review with priorities and a verdict
Get a clear verdict on whether the change ships, with every issue ranked P0 through P3 and scored for confidence. /review spawns dedicated reviewer subagents that sweep branches, single commits, or uncommitted work in parallel. You tackle what blocks release first; nothing important hides in a wall of prose.
### 09 · Hashline: edit by content hash
### 11 · Hashline: edit by content hash
Perfect edits, fewer tokens. The model points at anchors instead of retyping the lines it wants to change, so whitespace battles and string-not-found loops just stop happening. Edit a stale file and the anchors diverge — we reject the patch before it corrupts anything. Grok 4 Fast spends 61% fewer output tokens on the same work.
### 10 · GitHub is just another filesystem
### 12 · GitHub is just another filesystem
Other harnesses bolt on gh_issue_view, gh_pr_view, gh_search — each with its own parameters the agent has to learn and you have to debug. We skipped that. read already handles paths; PRs are paths. One interface to teach the model, one surface to keep correct.
### 11 · Hindsight: memory the agent curates
### 13 · Hindsight: memory the agent curates
The agent remembers your codebase between sessions. It writes facts mid-run with retain, pulls them back with recall, and compresses each session into a mental model that loads on the first turn of the next one. Project-scoped by default, so what it learns about this repo stays with this repo.
### 12 · ACP: editor-drivable agent
### 14 · ACP: editor-drivable agent
Run omp inside Zed and you get the same agent you drive from the terminal — reading the buffer you're actually looking at, writing through the editor's save path, spawning shells in the editor's terminal. Destructive tools pause for a permission prompt you can answer once and forget. No bridge, no plugin, no second brain to keep in sync.
### 13 · Inherits what your other tools already wrote
### 15 · Inherits what your other tools already wrote
Every other agent ships an importer and expects you to convert. omp reads the eight formats already on disk in their native shape — Cursor MDC, Cline .clinerules, Codex AGENTS.md, Copilot applyTo, and the rest. No migration script, no YAML-to-TOML port, no "supported subset" footnotes. The config your team wrote last quarter still works tonight.
### 14 · omp commit: atomic splits, validated messages
### 16 · omp commit: atomic splits, validated messages
omp reads the working tree through git_overview, git_file_diff, and git_hunk, then splits unrelated changes into atomic commits ordered by their dependencies. Cycles are rejected before anything is written. Source files score above tests, docs, and configs, so the headline commit is the one that matters. Lock files are excluded from analysis entirely.
### 15 · Read PRs. _Walk skills._ Pull JSON out of subagents.
### 17 · Read PRs. _Walk skills._ Pull JSON out of subagents.
Twelve internal schemes — `pr://`, `issue://`, `agent://`, `skill://`, `rule://`, and the rest — resolve transparently inside every FS-shaped tool the agent already calls. `read pr://1428` returns the same shape as `read src/foo.ts`. `search` walks a diff like a directory. `agent://<id>/findings.0.path` pulls a field out of a subagent's output by path.
![omp TUI reading pr://can1357/oh-my-pi/1063 and then /diff/1, showing hunk headers, added lines, and a [MODIFIED] (+12 -0) summary.](https://omp.sh/captures/pr.webp)
### 16 · Conflict resolution, made easy.
### 18 · Conflict resolution, made easy.
Each merge conflict becomes one URL. The agent writes `@theirs`, `@ours`, or `@base` to `conflict://N` and the file resolves cleanly. Bulk form: `conflict://*`.
@@ -187,7 +203,7 @@ Each merge conflict becomes one URL. The agent writes `@theirs`, `@ours`, or `@b
_[Watch the capture ↗](https://omp.sh/clips/conflict.mp4)_
### 17 · Preview, then accept.
### 19 · Preview, then accept.
`ast_edit` returns a _(proposed)_ card with the replacement count. The change is staged. The agent calls `resolve` with a reason; the TUI turns it into an **Accept** card and the disk move happens — atomic, all or nothing.
@@ -195,7 +211,7 @@ _[Watch the capture ↗](https://omp.sh/clips/conflict.mp4)_
_[Watch the capture ↗](https://omp.sh/clips/codemod.mp4)_
### 18 · Drives a _real browser_. _Or your Slack?_
### 20 · Drives a _real browser_. _Or your Slack?_
Stealth's on by default, so pages see a normal user instead of a headless bot. The same API drives any Electron app in place — point it at Slack and the agent reads your DMs the way it reads the web.
@@ -529,6 +545,15 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
| **[brush-core-vendored](crates/brush-core-vendored)** | Vendored fork of [brush-shell](https://github.com/reubeno/brush) for embedded bash execution |
| **[brush-builtins-vendored](crates/brush-builtins-vendored)** | Vendored bash builtins (cd, echo, test, printf, read, export, etc.) |
## Contributing
Issues are open to everyone. **Pull requests require a vouch** — PRs from
unvouched or denounced authors are closed automatically. If you're not yet
vouched, open a [Discussion](https://github.com/can1357/oh-my-pi/discussions)
and ask a maintainer to `!vouch` you rather than opening a PR (which would be
closed on sight). See **[CONTRIBUTING.md](CONTRIBUTING.md)** and
[`.github/VOUCHED.td`](.github/VOUCHED.td) for the full policy.
---
## License
-1
View File
@@ -60,7 +60,6 @@
"!**/vendor/**/*",
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/docs-index.generated.ts",
"!**/agent_pb.ts",
"!.worktrees/**/*",
"!.wt/**/*"
+39 -86
View File
@@ -21,12 +21,13 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"@oh-my-pi/snapcompact": "catalog:",
"@opentelemetry/api": "catalog:",
},
@@ -38,11 +39,12 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"arktype": "catalog:",
"partial-json": "catalog:",
"zod": "catalog:",
@@ -53,7 +55,7 @@
},
"packages/catalog": {
"name": "@oh-my-pi/pi-catalog",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -67,7 +69,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "16.0.7",
"version": "16.1.2",
"bin": {
"omp": "src/cli.ts",
},
@@ -98,11 +100,12 @@
"arktype": "catalog:",
"chalk": "catalog:",
"diff": "catalog:",
"fflate": "catalog:",
"fast-xml-parser": "catalog:",
"handlebars": "catalog:",
"linkedom": "catalog:",
"lru-cache": "catalog:",
"markit-ai": "catalog:",
"mammoth": "catalog:",
"mupdf": "catalog:",
"puppeteer-core": "catalog:",
"turndown": "catalog:",
"turndown-plugin-gfm": "catalog:",
@@ -134,7 +137,7 @@
},
"packages/hashline": {
"name": "@oh-my-pi/hashline",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"diff": "catalog:",
"lru-cache": "catalog:",
@@ -145,7 +148,7 @@
},
"packages/mnemopi": {
"name": "@oh-my-pi/pi-mnemopi",
"version": "16.0.7",
"version": "16.1.2",
"bin": {
"mnemopi": "src/cli.ts",
},
@@ -171,7 +174,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "16.0.7",
"version": "16.1.2",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -179,11 +182,12 @@
},
"packages/snapcompact": {
"name": "@oh-my-pi/snapcompact",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
@@ -191,7 +195,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "16.0.7",
"version": "16.1.2",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -217,7 +221,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "16.0.7",
"version": "16.1.2",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -233,7 +237,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -274,10 +278,9 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "16.0.7",
"version": "16.1.2",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
"handlebars": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
@@ -288,7 +291,7 @@
},
"packages/wire": {
"name": "@oh-my-pi/pi-wire",
"version": "16.0.7",
"version": "16.1.2",
"devDependencies": {
"@types/bun": "catalog:",
},
@@ -311,7 +314,6 @@
},
"patchedDependencies": {
"@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch",
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch",
},
"catalog": {
"@agentclientprotocol/sdk": "0.25.0",
@@ -325,18 +327,18 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "16.0.7",
"@oh-my-pi/omp-stats": "16.0.7",
"@oh-my-pi/pi-agent-core": "16.0.7",
"@oh-my-pi/pi-ai": "16.0.7",
"@oh-my-pi/pi-catalog": "16.0.7",
"@oh-my-pi/pi-coding-agent": "16.0.7",
"@oh-my-pi/pi-mnemopi": "16.0.7",
"@oh-my-pi/pi-natives": "16.0.7",
"@oh-my-pi/pi-tui": "16.0.7",
"@oh-my-pi/pi-utils": "16.0.7",
"@oh-my-pi/pi-wire": "16.0.7",
"@oh-my-pi/snapcompact": "16.0.7",
"@oh-my-pi/hashline": "16.1.2",
"@oh-my-pi/omp-stats": "16.1.2",
"@oh-my-pi/pi-agent-core": "16.1.2",
"@oh-my-pi/pi-ai": "16.1.2",
"@oh-my-pi/pi-catalog": "16.1.2",
"@oh-my-pi/pi-coding-agent": "16.1.2",
"@oh-my-pi/pi-mnemopi": "16.1.2",
"@oh-my-pi/pi-natives": "16.1.2",
"@oh-my-pi/pi-tui": "16.1.2",
"@oh-my-pi/pi-utils": "16.1.2",
"@oh-my-pi/pi-wire": "16.1.2",
"@oh-my-pi/snapcompact": "16.1.2",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -355,11 +357,11 @@
"@typescript/native-preview": "7.0.0-dev.20260609.1",
"@xterm/headless": "^6.0.0",
"arktype": "^2.2.0",
"beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"date-fns": "^4.4.0",
"diff": "^9.0.0",
"fast-xml-parser": "^5.9.0",
"fastembed": "2.1.0",
"fflate": "0.8.3",
"ghostty-web": "^0.4.0",
@@ -368,8 +370,9 @@
"lint-staged": "^17.0.7",
"lru-cache": "11.5.1",
"lucide-react": "^1.17.0",
"mammoth": "^1.12.0",
"marked": "^18.0.5",
"markit-ai": "0.5.3",
"mupdf": "^1.27.0",
"onnxruntime-node": "1.26.0",
"partial-json": "^0.1.7",
"postcss": "^8.5.15",
@@ -460,8 +463,6 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
@@ -470,7 +471,7 @@
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
@@ -860,10 +861,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@ts-morph/common": ["@ts-morph/common@0.29.0", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
@@ -936,8 +933,6 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="],
"beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
@@ -988,8 +983,6 @@
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -1034,8 +1027,6 @@
"electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="],
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
"emnapi": ["emnapi@1.11.1", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1062,8 +1053,6 @@
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -1080,12 +1069,8 @@
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"file-stream-rotator": ["file-stream-rotator@0.6.1", "", { "dependencies": { "moment": "^2.29.1" } }, "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ=="],
"file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="],
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
@@ -1126,8 +1111,6 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -1210,12 +1193,8 @@
"marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"markit-ai": ["markit-ai@0.5.3", "", { "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "exifr": "^7.1.3", "fast-xml-parser": "^5.5.9", "jszip": "^3.10.1", "mammoth": "^1.9.0", "mupdf": "^1.27.0", "music-metadata": "^11.12.3", "rss-parser": "^3.13.0", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2" }, "bin": { "markit": "dist/main.js" } }, "sha512-h4nhn6a/SNXEdc3kLVtL37TspxjUNCNL0OM7LRWxd389ZByI/B7bjNNgxFdVAT0O+H7ZekSwLdVe/lws1l2AZQ=="],
"matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
"media-typer": ["media-typer@2.0.0", "", {}, "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -1240,8 +1219,6 @@
"mupdf": ["mupdf@1.27.0", "", {}, "sha512-vEPUYwZeu5NgiFLz4e20R7Vp2pNY7szirGEvTxHyQQpQs6ab4DeGdonwT6sH1JZG5EhyHSrojZrZn2/0ee6qZQ=="],
"music-metadata": ["music-metadata@11.13.0", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^2.0.0", "debug": "^4.4.3", "file-type": "^21.3.4", "media-typer": "^2.0.0", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw=="],
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -1322,16 +1299,12 @@
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
@@ -1384,14 +1357,12 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -1404,8 +1375,6 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
"ts-morph": ["ts-morph@28.0.0", "", { "dependencies": { "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g=="],
@@ -1428,8 +1397,6 @@
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -1448,8 +1415,6 @@
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
"win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="],
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
"winston-daily-rotate-file": ["winston-daily-rotate-file@5.0.0", "", { "dependencies": { "file-stream-rotator": "^0.6.1", "object-hash": "^3.0.0", "triple-beam": "^1.4.1", "winston-transport": "^4.7.0" }, "peerDependencies": { "winston": "^3" } }, "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw=="],
@@ -1464,8 +1429,6 @@
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -1514,13 +1477,11 @@
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
"@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
@@ -1560,18 +1521,12 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@huggingface/transformers/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
@@ -1590,8 +1545,6 @@
"fastembed/onnxruntime-node/tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+13 -2
View File
@@ -1,6 +1,17 @@
MIT License
This directory contains a vendored, locally-patched copy of brush-builtins from
the brush shell project (https://github.com/reubeno/brush), used under the MIT
License. It is wired into the workspace via `[patch.crates-io]` so these
modifications can be maintained against the published release (brush-builtins
0.2.0).
Copyright (c) 2024 reuben olinsky
Copyright (c) 2024 reuben olinsky
Local modifications include: implementing previously-stubbed builtins, the
`wait` builtin (PID targeting plus the `-f`/`-n`/`-p` flags), and cancellation
support for the `read` builtin via poll. The remaining logic tracks upstream so
behavior matches the published crate.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+14 -2
View File
@@ -1,6 +1,18 @@
MIT License
This directory contains a vendored, locally-patched copy of brush-core from the
brush shell project (https://github.com/reubeno/brush), used under the MIT
License. It is wired into the workspace via `[patch.crates-io]` so these
modifications can be maintained against the published release (brush-core
0.5.0).
Copyright (c) 2024 reuben olinsky
Copyright (c) 2024 reuben olinsky
Local modifications include: Windows path handling fixes (drive-alias and
rooted-path normalization in globs), an async background-PID fix for the nohup
wrapper, treating `/dev/null` as poll-ready to avoid an infinite poll on macOS,
and indentation/formatting standardization. The remaining logic tracks upstream
so behavior matches the published crate.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -172,7 +172,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
/// `packages/natives/native/index.js` (which derives the name from
/// `package.json#version`).
#[napi(js_name = "__piNativesV16_0_7")]
#[napi(js_name = "__piNativesV16_1_2")]
pub const fn pi_natives_version_sentinel() {}
/// Native module entry point: install crash diagnostics before any tool can
-46
View File
@@ -1,46 +0,0 @@
# Third-party attribution — RTK
Portions of the shell-output minimizer adapt algorithms from **RTK**
(`rtk-ai/rtk`), used under the MIT License, which is compatible with this
workspace's MIT License.
## Ported component
- **Upstream:** [`rtk-ai/rtk`](https://github.com/rtk-ai/rtk) @ commit
`878af7de99e0ba71da2e8fd996f6b52a1836e06c`
- **Upstream path:** `src/cmds/python/pytest_cmd.rs`
- **Local path:** `crates/pi-shell/src/minimizer/filters/python.rs`
- **What was adapted:** the `build_pytest_summary` algorithm — re-implemented
here as the pytest state machine (`filter_pytest`, `pytest_success`,
`is_pytest_*`, `looks_like_pytest_summary_part`). It preserves failures,
errors, and the final summary line; strips header framing, progress dots, and
verbose `PASSED` rows; and falls through unchanged on unknown-state lines
(RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters never
cause data loss.
## License (MIT)
RTK is distributed under the MIT License. A copy of the upstream license text
is reproduced below for the pinned revision above.
```
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
authors.workspace = true
repository.workspace = true
# Portions of the minimizer adapt MIT-licensed algorithms from rtk-ai/rtk.
# See ATTRIBUTION-RTK.md at this crate root (packaged on publish).
# See NOTICE at this crate root (packaged on publish).
[lints]
workspace = true
+35
View File
@@ -0,0 +1,35 @@
This file documents third-party attribution for the shell-output minimizer in
this crate, which adapts an algorithm from RTK (https://github.com/rtk-ai/rtk),
used under the MIT License.
Copyright (c) the RTK authors (rtk-ai/rtk)
Adapted component: the `build_pytest_summary` algorithm from the upstream
`src/cmds/python/pytest_cmd.rs` (pinned at commit
878af7de99e0ba71da2e8fd996f6b52a1836e06c), re-implemented here in
`src/minimizer/filters/python.rs` as the pytest state machine (`filter_pytest`,
`pytest_success`, `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves
failures, errors, and the final summary line; strips header framing, progress
dots, and verbose `PASSED` rows; and falls through unchanged on unknown-state
lines (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters
never cause data loss.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9 -4
View File
@@ -1097,10 +1097,15 @@ strip_lines_matching = [".*"]
}
let out = apply("rails db:migrate", &input, 0, &cfg);
assert!(out.changed);
// The def's max_lines=40 produces ONE truncation marker.
// filter_rake's head_tail marker ("lines omitted") must NOT appear.
assert!(!out.text.contains("lines omitted"), "double truncation detected: {:#?}", out.text);
assert!(out.text.contains("lines truncated"));
// Only the def's max_lines=40 cap should fire; filter_rake's head_tail must
// NOT also condense. Both now emit `[…Nln elided…]`, so a single marker
// proves no double truncation (two would mean both stages fired).
assert_eq!(
out.text.matches("ln elided…]").count(),
1,
"double truncation detected: {:#?}",
out.text
);
}
#[test]
@@ -73,7 +73,7 @@ mod tests {
// 50 head + 20 tail + 1 marker = 71 lines
let line_count = out.text.lines().count();
assert_eq!(line_count, HEAD_LINES + TAIL_LINES + 1, "got {line_count} lines: {out:?}");
assert!(out.text.contains("lines omitted"));
assert!(out.text.contains("ln elided…]"));
// Head anchor preserved.
assert!(out.text.starts_with("00000000: 00000000"));
}
@@ -95,7 +95,7 @@ mod tests {
let context = ctx("strings", "strings /bin/ls", &cfg);
let out = filter(&context, &input, 0);
assert!(out.changed);
assert!(out.text.contains("lines omitted"));
assert!(out.text.contains("ln elided…]"));
}
#[test]
@@ -105,7 +105,7 @@ mod tests {
let context = ctx("od", "od -c /bin/ls", &cfg);
let out = filter(&context, &input, 0);
assert!(out.changed);
assert!(out.text.contains("lines omitted"));
assert!(out.text.contains("ln elided…]"));
}
#[test]
+2 -2
View File
@@ -253,9 +253,9 @@ fn compact_bun_check_output(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32)
}
let omitted = nonzero_exits.len() + diagnostics.len();
if omitted > 40 {
out.push_str("… ");
out.push_str("[…");
out.push_str(&(omitted - 40).to_string());
out.push_str(" diagnostic lines omitted\n");
out.push_str(" diagnostic lines elided…]\n");
}
Some(out)
}
@@ -474,7 +474,7 @@ fn compact_aws_generic(root: &Value) -> Option<String> {
let mut out =
compact_named_rows(&columns.iter().map(String::as_str).collect::<Vec<_>>(), &values);
if rows.len() > MAX_AWS_ROWS {
let _ = writeln!(out, "... +{} more {name}", rows.len() - MAX_AWS_ROWS);
let _ = writeln!(out, "[…{} {name} elided…]", rows.len() - MAX_AWS_ROWS);
}
return Some(out);
}
@@ -559,7 +559,7 @@ fn compact_named_rows(headers: &[&str], rows: &[Vec<String>]) -> String {
out.push('\n');
}
if rows.len() > MAX_AWS_ROWS {
let _ = writeln!(out, "... +{} more rows", rows.len() - MAX_AWS_ROWS);
let _ = writeln!(out, "[…{} rows elided…]", rows.len() - MAX_AWS_ROWS);
}
out
}
@@ -761,9 +761,7 @@ fn compact_aws_dynamodb_items(items: &[&serde_json::Map<String, Value>]) -> Stri
out.push('\n');
}
if items.len() > 40 {
out.push_str("… ");
out.push_str(&(items.len() - 40).to_string());
out.push_str(" item(s) omitted …\n");
let _ = writeln!(out, "[…{} items elided…]", items.len() - 40);
}
let _ = writeln!(out, "{} item(s)", items.len());
out
@@ -1010,7 +1008,7 @@ fn compact_line(line: &str, max_chars: usize) -> String {
let edge = max_chars / 2;
let start: String = chars.iter().take(edge).collect();
let end: String = chars.iter().skip(chars.len() - edge).collect();
format!("{start} … {} chars omitted … {end}", chars.len() - edge * 2)
format!("{start}[…{}ch elided…]{end}", chars.len() - edge * 2)
}
fn looks_like_table(input: &str) -> bool {
@@ -1073,7 +1071,7 @@ fn compact_delimited_table(input: &str, max_rows: usize) -> String {
}
}
if data_rows > max_rows {
out.push(format!("… {} more rows", data_rows - max_rows));
out.push(format!("[…{} rows elided…]", data_rows - max_rows));
}
join_lines(out)
}
@@ -1114,7 +1112,7 @@ fn compact_psql_table(input: &str) -> String {
}
if data_rows > MAX_PSQL_ROWS {
out.push(format!("... +{} more rows", data_rows - MAX_PSQL_ROWS));
out.push(format!("[…{} rows elided…]", data_rows - MAX_PSQL_ROWS));
}
out.extend(row_count_lines);
join_lines(out)
@@ -1152,7 +1150,7 @@ fn compact_psql_expanded(input: &str) -> String {
}
flush_record(&mut out, &mut current, records);
if records > MAX_PSQL_ROWS {
out.push(format!("... +{} more records", records - MAX_PSQL_ROWS));
out.push(format!("[…{} records elided…]", records - MAX_PSQL_ROWS));
}
out.extend(row_count_lines);
join_lines(out)
@@ -745,9 +745,9 @@ fn compact_table(input: &str, visible_rows: usize) -> String {
out.push_str(line.trim_end());
out.push('\n');
}
out.push_str("… ");
out.push_str("[…");
out.push_str(&(lines.len() - 1 - visible_rows).to_string());
out.push_str(" more rows\n");
out.push_str(" rows elided…]\n");
out
}
@@ -908,7 +908,7 @@ mod tests {
let out = filter(&compose_ctx, &input, 0).text;
assert!(out.contains("20 rows"));
assert!(out.contains("svc-0"));
assert!(out.contains("… 8 more rows"));
assert!(out.contains("[…8 rows elided…]"));
}
#[test]
@@ -988,7 +988,7 @@ mod tests {
assert!(out.contains("api-1 | request 0 complete"));
assert!(out.contains("api-1 | WARN cache miss"));
assert!(out.contains("worker | failed to process job"));
assert!(out.contains("omitted"));
assert!(out.contains("elided"));
}
#[test]
@@ -1000,7 +1000,7 @@ mod tests {
}
let out = compact_table(&input, 10);
assert!(out.contains("25 rows"));
assert!(out.contains("… 15 more rows"));
assert!(out.contains("[…15 rows elided…]"));
}
fn ctx<'a>(
@@ -1071,7 +1071,7 @@ mod tests {
assert!(out.contains("25 rows"), "docker --format table output should still compact: {out}");
assert!(
out.contains("… 13 more rows"),
out.contains("[…13 rows elided…]"),
"docker --format table should keep table omission: {out}"
);
}
@@ -1390,7 +1390,7 @@ mod tests {
}
let out = filter(&kubectl_ctx, &input, 0).text;
assert!(
out.contains("lines omitted") || out.contains("lines truncated"),
out.contains("ln elided…]"),
"kubectl apply output should be head/tail condensed: {out}"
);
}
@@ -184,9 +184,9 @@ fn compact_format_json(input: &str) -> Option<String> {
out.push('\n');
}
if rows.len() > 40 {
out.push_str("… ");
out.push_str("[…");
out.push_str(&(rows.len() - 40).to_string());
out.push_str(" more diagnostics\n");
out.push_str(" diagnostics elided…]\n");
}
Some(out)
}
+1 -1
View File
@@ -319,7 +319,7 @@ mod tests {
// First failures stay verbatim with their actionable URLs.
assert!(out.text.contains("X\tcheck0\t1s\thttps://ci.test/0"));
// The output is capped, not 501 lines of raw rows.
assert!(out.text.contains("lines omitted"));
assert!(out.text.contains("ln elided…]"));
assert!(out.text.lines().count() < 60);
assert!(!out.text.contains("https://ci.test/499"));
}
+32 -48
View File
@@ -209,7 +209,7 @@ fn compact_diff_listing(input: &str, mode: DiffListingMode) -> String {
out.push_str(entry);
out.push('\n');
}
let _ = writeln!(out, "… {} files omitted …", entries.len() - DIFF_LISTING_LIMIT);
let _ = writeln!(out, "[…{} files elided…]", entries.len() - DIFF_LISTING_LIMIT);
out
}
@@ -512,9 +512,7 @@ fn format_status_summary(summary: &StatusSummary) -> String {
out.push('\n');
}
if summary.paths.len() > 40 {
out.push_str("… ");
out.push_str(&(summary.paths.len() - 40).to_string());
out.push_str(" paths omitted\n");
let _ = writeln!(out, "[…{} paths elided…]", summary.paths.len() - 40);
}
out
}
@@ -531,9 +529,7 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String {
for entry in entries.iter().take(head) {
push_log_entry(&mut out, entry);
}
out.push_str("… ");
out.push_str(&(entries.len() - head - tail).to_string());
out.push_str(" commits omitted …\n");
let _ = writeln!(out, "[…{} commits elided…]", entries.len() - head - tail);
for entry in entries.iter().skip(entries.len() - tail) {
push_log_entry(&mut out, entry);
}
@@ -566,8 +562,8 @@ struct LogEntry {
subject: String,
body: Vec<String>,
/// Body lines dropped past the per-commit body cap, surfaced as an explicit
/// `[+N lines omitted]` marker instead of being silently lost.
omitted: usize,
/// `[…Nln elided…]` marker instead of being silently lost.
elided: usize,
}
/// Soft cap on rendered subject/body line width. Long commit subjects and body
@@ -587,10 +583,8 @@ fn push_log_entry(out: &mut String, entry: &LogEntry) {
out.push_str(&primitives::truncate_line(line, LOG_LINE_WIDTH));
out.push('\n');
}
if entry.omitted > 0 {
out.push_str(" [+");
out.push_str(&entry.omitted.to_string());
out.push_str(" lines omitted]\n");
if entry.elided > 0 {
let _ = writeln!(out, " […{}ln elided…]", entry.elided);
}
}
@@ -611,7 +605,7 @@ fn parse_log_entries(input: &str) -> Vec<LogEntry> {
hash: short_hash(hash),
subject: subject.to_string(),
body: Vec::new(),
omitted: 0,
elided: 0,
});
continue;
}
@@ -627,12 +621,12 @@ fn parse_log_entries(input: &str) -> Vec<LogEntry> {
entry.subject = trimmed.to_string();
} else if !is_git_trailer(trimmed) {
// Real (non-trailer) body lines past the 3-line cap are tallied so
// `push_log_entry` can emit an explicit `[+N lines omitted]` marker
// `push_log_entry` can emit an explicit `[…Nln elided…]` marker
// rather than dropping them silently.
if entry.body.len() < 3 {
entry.body.push(trimmed.to_string());
} else {
entry.omitted += 1;
entry.elided += 1;
}
}
}
@@ -930,9 +924,7 @@ pub(crate) fn compact_diff_output(input: &str) -> String {
out.push('\n');
}
if files.len() > 20 {
out.push_str("… ");
out.push_str(&(files.len() - 20).to_string());
out.push_str(" files omitted from stat\n");
let _ = writeln!(out, "[…{} files from stat elided…]", files.len() - 20);
}
out.push_str(&format_file_count(files.len()));
out.push_str(" changed, ");
@@ -956,21 +948,15 @@ pub(crate) fn compact_diff_output(input: &str) -> String {
out.push('\n');
}
if hunk.lines.len() > 6 {
out.push_str(" … ");
out.push_str(&(hunk.lines.len() - 6).to_string());
out.push_str(" changed lines omitted\n");
let _ = writeln!(out, " […{} changed lines elided…]", hunk.lines.len() - 6);
}
}
if file.hunks.len() > 8 {
out.push_str(" … ");
out.push_str(&(file.hunks.len() - 8).to_string());
out.push_str(" hunks omitted\n");
let _ = writeln!(out, " […{} hunks elided…]", file.hunks.len() - 8);
}
}
if files.len() > 12 {
out.push_str("\n… ");
out.push_str(&(files.len() - 12).to_string());
out.push_str(" files omitted from changes\n");
let _ = writeln!(out, "\n[…{} files from changes elided…]", files.len() - 12);
}
out
}
@@ -1311,7 +1297,7 @@ fn condense_diff_stat(input: &str) -> String {
out.push('\n');
}
if entries.len() > 20 {
let _ = writeln!(out, "… {} files omitted …", entries.len() - 20);
let _ = writeln!(out, "[…{} files elided…]", entries.len() - 20);
}
out
}
@@ -1528,7 +1514,7 @@ fn condense_worktree(input: &str) -> String {
///
/// Listing-shaped lines (`<abs-path> <hash> [<branch>]`, plus the `(bare)` and
/// `(detached HEAD)` variants) get a leading `$HOME` abbreviated to `~` and are
/// capped with an omitted-count marker. Any non-listing output (`add`'s
/// capped with an elided-count marker. Any non-listing output (`add`'s
/// "Preparing worktree…"/"HEAD is now at …" confirmations, errors) is left to
/// `condense_noisy_output`/passthrough so its meaning is preserved.
fn condense_worktree_with_home(input: &str, home: &str) -> String {
@@ -1554,9 +1540,7 @@ fn condense_worktree_with_home(input: &str, home: &str) -> String {
out.push('\n');
}
if entries.len() > WORKTREE_LIMIT {
out.push_str("… ");
out.push_str(&(entries.len() - WORKTREE_LIMIT).to_string());
out.push_str(" worktrees omitted …\n");
let _ = writeln!(out, "[…{} worktrees elided…]", entries.len() - WORKTREE_LIMIT);
}
out
}
@@ -1920,7 +1904,7 @@ mod tests {
input.push('\n');
}
let out = filter(&ctx, &input, 0);
assert!(out.text.contains("… 22 commits omitted …"));
assert!(out.text.contains("[…22 commits elided…]"));
assert!(out.text.contains("abcdef1 message 0"));
assert!(!out.text.contains("message 47"));
assert!(out.text.contains("abcdef1 message 69"));
@@ -1983,7 +1967,7 @@ mod tests {
}
#[test]
fn log_body_over_cap_shows_omitted_marker() {
fn log_body_over_cap_shows_elided_marker() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("log"), "git log", &cfg);
// Subject + 5 body lines: 3 kept, 2 past the cap surfaced as a marker.
@@ -1995,7 +1979,7 @@ mod tests {
assert!(out.text.contains("body line one"));
assert!(out.text.contains("body line three"));
assert!(!out.text.contains("body line four"), "{:?}", out.text);
assert!(out.text.contains("[+2 lines omitted]"), "{:?}", out.text);
assert!(out.text.contains("[…2ln elided…]"), "{:?}", out.text);
}
#[test]
@@ -2062,7 +2046,7 @@ mod tests {
assert!(out.text.contains("src/file-0.rs\n"));
assert!(out.text.contains("src/file-19.rs\n"));
assert!(!out.text.contains("src/file-20.rs\n"));
assert!(out.text.contains("… 6 files omitted …"));
assert!(out.text.contains("[…6 files elided…]"));
}
#[test]
@@ -2091,7 +2075,7 @@ mod tests {
assert!(out.text.contains("R100\told-0.rs\tnew-0.rs\n"));
assert!(out.text.contains("M\tpath-1.rs\n"));
assert!(!out.text.contains("path-20.rs\n"));
assert!(out.text.contains("… 4 files omitted …"));
assert!(out.text.contains("[…4 files elided…]"));
}
#[test]
@@ -2139,7 +2123,7 @@ mod tests {
assert!(out.text.contains("1\t0\tsrc/file-0.rs\n"));
assert!(out.text.contains("20\t5\tsrc/file-19.rs\n"));
assert!(!out.text.contains("src/file-20.rs\n"));
assert!(out.text.contains("… 2 files omitted …"));
assert!(out.text.contains("[…2 files elided…]"));
}
#[test]
@@ -2695,13 +2679,13 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details.
assert!(out.text.contains("fatal:"), "error header must survive: {:?}", out.text);
assert!(out.text.contains("badref"), "offending ref must survive: {:?}", out.text);
assert!(!out.text.contains("commits omitted"), "must not fabricate commit listing on error");
assert!(!out.text.contains("commits elided"), "must not fabricate commit listing on error");
}
#[test]
fn log_oneline_short_run_emits_all_entries() {
// A short log that fits within head+tail should emit all entries
// without any "omitted" line, and each entry should carry the
// without any "elided" line, and each entry should carry the
// 7-char short hash followed by the subject.
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("log"), "git log -5", &cfg);
@@ -2712,7 +2696,7 @@ commit abcdef1234567890\nAuthor: A <a@x.com>\nDate: today\n feat: first\ncomm
let out = filter(&ctx, input, 0);
assert!(!out.text.contains("commits omitted"));
assert!(!out.text.contains("commits elided"));
assert!(out.text.contains("abcdef1 feat: first"), "{:?}", out.text);
assert!(out.text.contains("1111111 fix: second"), "{:?}", out.text);
assert!(out.text.contains("2222222 chore: third"), "{:?}", out.text);
@@ -2780,8 +2764,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma
assert!(out.contains("(bare)"));
assert!(out.contains("(detached HEAD)"));
assert!(!out.contains("/home/alice"), "no absolute $HOME paths should survive: {out:?}");
// 22 entries → capped at 20 with a 2-omitted marker.
assert!(out.contains("… 2 worktrees omitted …"), "{out:?}");
// 22 entries → capped at 20 with a 2-elided marker.
assert!(out.contains("[…2 worktrees elided…]"), "{out:?}");
assert_eq!(out.lines().filter(|l| l.starts_with('~')).count(), 20);
}
@@ -2799,7 +2783,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma
#[test]
fn worktree_porcelain_passthrough() {
// `git worktree list --porcelain` emits machine-readable records;
// the minimizer must not insert "… N lines omitted …" markers.
// the minimizer must not insert "[…Nln elided…]" markers.
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("worktree"), "git worktree list --porcelain", &cfg);
let input = "worktree /home/user/project\nHEAD abc1234def\nbranch \
@@ -2825,7 +2809,7 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma
fn worktree_non_porcelain_still_condensed() {
// Normal (non-porcelain) listing should still go through the condenser.
// Build 22 listing-shaped entries so the cap (20) is exceeded; the condenser
// would insert an "… N worktrees omitted …" marker that a passthrough would
// would insert an "[…N worktrees elided…]" marker that a passthrough would
// never emit.
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("worktree"), "git worktree list", &cfg);
@@ -2835,8 +2819,8 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma
}
let out = filter(&ctx, &input, 0);
assert!(
out.text.contains("worktrees omitted"),
"non-porcelain output over the cap must show omitted marker; got: {:?}",
out.text.contains("worktrees elided"),
"non-porcelain output over the cap must show elided marker; got: {:?}",
out.text
);
}
@@ -211,11 +211,11 @@ fn filter_release_list(input: &str) -> Option<String> {
// Append omission marker when there are more releases than shown.
let omitted = total.map_or(0, |t| t.saturating_sub(count));
if omitted > 0 {
let _ = writeln!(filtered, "… {omitted} releases omitted …");
let _ = writeln!(filtered, "[…{omitted} releases elided…]");
} else if has_more {
// Total not parsed from preamble but a 21st row was observed; signal
// truncation.
filtered.push_str("… releases omitted (showing first 20) …\n");
filtered.push_str("[…releases elided…]\n");
}
Some(filtered)
@@ -661,8 +661,8 @@ section_end:1711234600:build_script[0K
let out = filter(&ctx, &input, 0);
assert!(out.changed);
// Output must signal that releases were omitted.
assert!(out.text.contains("omitted"), "expected omission marker, got: {}", out.text);
// Output must signal that releases were elided.
assert!(out.text.contains("elided"), "expected omission marker, got: {}", out.text);
// First release present, 21st not shown verbatim in the list.
assert!(out.text.contains("Release 1"));
assert!(!out.text.contains("Release 21 ["));
+18 -2
View File
@@ -341,9 +341,9 @@ fn summarize_golangci_json(line: &str) -> Option<String> {
out.push_str(")\n");
}
if issues.len() > 40 {
out.push_str("… ");
out.push_str("[…");
out.push_str(&(issues.len() - 40).to_string());
out.push_str(" more issues\n");
out.push_str(" issues elided…]\n");
}
Some(out)
}
@@ -506,6 +506,22 @@ mod tests {
let out = filter_golangci_lint(input);
assert!(out.contains("golangci-lint: 1 issues"));
assert!(out.contains("main.go:7:2: unreachable code (govet)"));
// Match up-to-40 limits, testing elison formatting
let mut many_issues = r#"{"Issues":["#.to_string();
for i in 0..42 {
if i > 0 {
many_issues.push(',');
}
let _ = write!(
many_issues,
r#"{{"FromLinter":"govet","Text":"err {i}","Pos":{{"Filename":"main.go","Line":{i},"Column":2}}}}"#
);
}
many_issues.push_str("]}");
let out_many = filter_golangci_lint(&many_issues);
assert!(out_many.contains("golangci-lint: 42 issues"));
assert!(out_many.contains("[…2 issues elided…]"));
}
#[test]
+8 -9
View File
@@ -91,9 +91,7 @@ fn compact_log(input: &str) -> String {
}
if omitted_entries > 0 {
out.push_str("… ");
out.push_str(&omitted_entries.to_string());
out.push_str(" entries omitted …\n");
let _ = writeln!(out, "[…{omitted_entries} entries elided…]");
}
primitives::head_tail_lines(&out, 80, 24)
@@ -172,13 +170,13 @@ fn dense_sync_summary(input: &str, exit_code: i32) -> Option<String> {
// head_tail_lines bound that compact_noisy_command (the fallback) applies.
// Without a cap every name lands on one unbounded line, defeating the
// minimizer's bounding guarantee. Show the first DELETED_NAME_CAP names and
// summarize the rest as `+N more` (the count above stays exact).
// summarize the rest as `[…N names elided…]` (the count above stays exact).
const DELETED_NAME_CAP: usize = 20;
let shown = deleted_names.len().min(DELETED_NAME_CAP);
let names = deleted_names[..shown].join(", ");
let _ = write!(summary, " ({names}");
if deleted_names.len() > DELETED_NAME_CAP {
let _ = write!(summary, ", +{} more", deleted_names.len() - DELETED_NAME_CAP);
let _ = write!(summary, " […{} names elided…]", deleted_names.len() - DELETED_NAME_CAP);
}
summary.push(')');
}
@@ -344,7 +342,7 @@ mod tests {
assert!(out.changed);
assert!(out.text.contains("abc1230"));
assert!(out.text.contains("entries omitted"));
assert!(out.text.contains("entries elided"));
assert!(!out.text.contains("user@example.com"));
}
@@ -439,7 +437,8 @@ mod tests {
fn sync_dense_summary_caps_deleted_name_list() {
// A long-lived stack cleanup can delete hundreds of merged branches at
// once. The dense summary must stay bounded: cap the inline name list and
// summarize the remainder as `+N more`, never emit one unbounded line.
// summarize the remainder as `[…N names elided…]`, never emit one unbounded
// line.
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("sync"), &cfg);
let mut input = String::from("Synced with remote\n");
@@ -452,9 +451,9 @@ mod tests {
assert!(out.changed);
// Exact deleted count is preserved.
assert!(out.text.contains("ok sync: 1 synced, 500 deleted"));
// First names stay visible; the rest collapse to a `+N more` marker.
// First names stay visible; the rest collapse to a `[…N names elided…]` marker.
assert!(out.text.contains("feat/merged-0"));
assert!(out.text.contains("+480 more"));
assert!(out.text.contains("[…480 names elided…]"));
// Bounded: a single short line, not 500 names concatenated.
assert!(!out.text.contains("feat/merged-499"));
let longest = out.text.lines().map(str::len).max().unwrap_or(0);
@@ -219,9 +219,9 @@ fn filter_prettier(input: &str, exit_code: i32) -> String {
push_line(&mut out, error);
}
if errors.len() > 20 {
out.push_str("... +");
out.push_str("[…");
out.push_str(&(errors.len() - 20).to_string());
out.push_str(" more errors\n");
out.push_str(" errors elided…]\n");
}
return out;
}
@@ -391,9 +391,9 @@ fn push_file_list(out: &mut String, files: &[String], limit: usize) {
push_line(out, file);
}
if files.len() > limit {
out.push_str("... +");
out.push_str("[…");
out.push_str(&(files.len() - limit).to_string());
out.push_str(" more files\n");
out.push_str(" files elided…]\n");
}
}
@@ -404,6 +404,8 @@ fn push_line(out: &mut String, line: &str) {
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
use super::*;
use crate::minimizer::MinimizerConfig;
@@ -532,4 +534,29 @@ mod tests {
assert!(out.contains("P3009"));
assert!(!out.contains("Datasource"));
}
#[test]
fn prettier_errors_truncation_is_canonical() {
let mut input = String::new();
for i in 1..=25 {
let _ = writeln!(input, "error in file{i}.js: line {i}");
}
let out = filter_prettier(&input, 1);
assert!(out.contains("[…5 errors elided…]\n"));
assert!(!out.contains("more errors"));
assert!(!out.contains("... +"));
}
#[test]
fn file_list_truncation_is_canonical() {
let mut files = Vec::new();
for i in 1..=35 {
files.push(format!("src/file{i}.tsx"));
}
let mut out = String::new();
push_file_list(&mut out, &files, 30);
assert!(out.contains("[…5 files elided…]\n"));
assert!(!out.contains("more files"));
assert!(!out.contains("... +"));
}
}
+11 -11
View File
@@ -606,7 +606,7 @@ impl<'a> SurefireBlock<'a> {
/// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of a
/// failing test run; on builds with hundreds of failures this can be large. Cap
/// entries at [`max_mvn_failing_classes`] and emit `\n… +N more failures\n`
/// entries at [`max_mvn_failing_classes`] and emit `\n[…N failures elided…]\n`
/// immediately before the `Tests run:` aggregate when entries were dropped.
struct FailuresSummaryCap {
cap: usize,
@@ -648,14 +648,14 @@ impl FailuresSummaryCap {
}
}
/// Pre-emit the `… +N more failures` tail when the aggregate
/// Pre-emit the `[…N failures elided…]` tail when the aggregate
/// `[ERROR] Tests run:` line is about to be written, then close the summary.
fn handle_aggregate(&mut self, line: &str, out: &mut String) {
if !self.in_summary || !AGG.is_match(line) {
return;
}
if self.dropped > 0 {
let _ = write!(out, "\n… +{} more failures\n", self.dropped);
let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped);
}
self.in_summary = false;
self.emitted = 0;
@@ -665,7 +665,7 @@ impl FailuresSummaryCap {
/// End-of-stream tail emission for cases where the AGG line never arrives.
fn finish(&self, out: &mut String) {
if self.in_summary && self.dropped > 0 {
let _ = write!(out, "\n… +{} more failures\n", self.dropped);
let _ = write!(out, "\n[…{} failures elided…]\n", self.dropped);
}
}
}
@@ -744,7 +744,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String {
block.finish(&mut out);
summary.finish(&mut out);
if dropped_failing > 0 {
let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n");
let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n");
}
out
}
@@ -903,7 +903,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String {
block.finish(&mut out);
summary.finish(&mut out);
if dropped_failing > 0 {
let _ = write!(out, "\n… +{dropped_failing} more failing test classes\n");
let _ = write!(out, "\n[…{dropped_failing} failing test classes elided…]\n");
}
out
}
@@ -1602,7 +1602,7 @@ fn filter_gradle_dependencies(input: &str) -> String {
let _ = writeln!(result, " {dep}");
}
if deps.len() > cap {
let _ = writeln!(result, " ... +{} more", deps.len() - cap);
let _ = writeln!(result, " […{} dependencies elided…]", deps.len() - cap);
}
}
@@ -1994,7 +1994,7 @@ mod tests {
"capped class second per-test block dropped (re-arm inherits drop); got:\n{o}"
);
assert!(
o.contains("… +1 more failing test classes"),
o.contains("[…1 failing test classes elided…]"),
"tail counts one class, not one per failure; got:\n{o}"
);
}
@@ -2214,7 +2214,7 @@ mod tests {
"Fail{n} exception dropped; got:\n{o}"
);
}
assert!(o.contains("… +2 more failing test classes"), "tail emitted; got:\n{o}");
assert!(o.contains("[…2 failing test classes elided…]"), "tail emitted; got:\n{o}");
}
#[test]
@@ -2235,7 +2235,7 @@ mod tests {
"Fail{n} dropped under cap=0; got:\n{o}"
);
}
assert!(o.contains("+5 more failing test classes"), "tail counts all 5; got:\n{o}");
assert!(o.contains("[…5 failing test classes elided…]"), "tail counts all 5; got:\n{o}");
}
#[test]
@@ -2256,7 +2256,7 @@ mod tests {
for n in 4..=5 {
assert!(!o.contains(&format!("ClassA.test{n}:25")), "entry {n} dropped; got:\n{o}");
}
let tail_idx = o.find("… +2 more failures").expect("tail must appear");
let tail_idx = o.find("[…2 failures elided…]").expect("tail must appear");
let agg_idx = o
.find("[ERROR] Tests run: 100")
.expect("aggregate must appear");
@@ -540,9 +540,9 @@ pub fn group_diagnostics(input: &str) -> String {
out.push('\n');
}
if entries.len() > 12 {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(entries.len() - 12).to_string());
out.push_str(" more\n");
out.push_str(" diagnostics elided…]\n");
}
}
@@ -551,9 +551,9 @@ pub fn group_diagnostics(input: &str) -> String {
out.push('\n');
}
if ungrouped.len() > 40 {
out.push_str("… ");
out.push_str("[…");
out.push_str(&(ungrouped.len() - 40).to_string());
out.push_str(" ungrouped lines omitted\n");
out.push_str(" ungrouped lines elided…]\n");
}
out
}
@@ -701,7 +701,7 @@ mod tests {
}
let out = group_diagnostics(&input);
assert!(out.contains("src/main.rs (20 diagnostics)"));
assert!(out.contains("… 8 more"));
assert!(out.contains(" […8 diagnostics elided…]"));
}
#[test]
@@ -173,16 +173,16 @@ fn compact_grep_grouped(
out.push('\n');
}
if matches.len() > 4 {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(matches.len() - 4).to_string());
out.push_str(" more in file\n");
out.push_str(" matches in file elided…]\n");
}
}
let omitted_files = grouped.len().saturating_sub(shown_files);
let omitted_matches = match_count.saturating_sub(shown_matches);
if omitted_files > 0 || omitted_matches > 0 {
out.push_str("\n… ");
out.push_str("\n[…");
out.push_str(&omitted_matches.to_string());
out.push_str(" matches");
if omitted_files > 0 {
@@ -190,7 +190,7 @@ fn compact_grep_grouped(
out.push_str(&omitted_files.to_string());
out.push_str(" files");
}
out.push_str(" omitted\n");
out.push_str(" elided…]\n");
}
for line in ungrouped {
out.push_str(line);
@@ -365,14 +365,14 @@ fn compact_find_output_inner(input: &str, paths: &[&str]) -> String {
push_wrapped_names(&mut out, names, 4, 24);
}
if grouped.len() > 16 {
out.push_str("\n… ");
out.push_str("\n[…");
out.push_str(&(grouped.len() - 16).to_string());
out.push_str(" dirs omitted\n");
out.push_str(" dirs elided…]\n");
}
if skipped_noise > 0 {
out.push_str("… ");
out.push_str("[…");
out.push_str(&skipped_noise.to_string());
out.push_str(" noisy paths omitted\n");
out.push_str(" noisy paths elided…]\n");
}
out
}
@@ -407,9 +407,9 @@ fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_n
}
out.push('\n');
if names.len() > max_names {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(names.len() - max_names).to_string());
out.push_str(" more\n");
out.push_str(" names elided…]\n");
}
}
@@ -453,9 +453,9 @@ fn compact_ls_output(input: &str) -> Option<String> {
}
let shown = dir_count.min(12) + file_count.min(36);
if entries.len() > shown {
out.push_str("… ");
out.push_str("[…");
out.push_str(&(entries.len() - shown).to_string());
out.push_str(" entries omitted\n");
out.push_str(" entries elided…]\n");
}
out.push('\n');
out.push_str(&file_count.to_string());
@@ -622,9 +622,9 @@ fn summarize_cargo_toml(input: &str) -> Option<String> {
out.push('\n');
}
if dependencies.len() > 15 {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(dependencies.len() - 15).to_string());
out.push_str(" more\n");
out.push_str(" dependencies elided…]\n");
}
Some(out)
}
@@ -705,9 +705,9 @@ fn summarize_package_json(input: &str) -> Option<String> {
out.push('\n');
}
if deps.len() > 15 {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(deps.len() - 15).to_string());
out.push_str(" more\n");
out.push_str(" dependencies elided…]\n");
}
Some(out)
}
@@ -752,9 +752,9 @@ fn summarize_go_mod(input: &str) -> Option<String> {
out.push('\n');
}
if deps.len() > 15 {
out.push_str(" … ");
out.push_str(" […");
out.push_str(&(deps.len() - 15).to_string());
out.push_str(" more\n");
out.push_str(" dependencies elided…]\n");
}
Some(out)
}
@@ -1265,7 +1265,7 @@ mod tests {
let out = filter(&ctx, &input, 0);
assert!(out.text.starts_with("grep: 20 matches in 20 files"));
assert!(out.text.contains("17: pub fn run(...) -> Result<()> {"));
assert!(out.text.contains("matches in 8 files omitted"));
assert!(out.text.contains("matches in 8 files elided…]"));
}
#[test]
@@ -1447,7 +1447,7 @@ mod tests {
let out = filter(&ctx, &input, 0);
assert!(out.text.starts_with("find: 31 paths in 3 dirs"));
assert!(out.text.contains("src/module0/ file0.rs file1.rs"));
assert!(out.text.contains("1 noisy paths omitted"));
assert!(out.text.contains("[…1 noisy paths elided…]"));
assert!(!out.text.contains("target/debug"));
}
@@ -1488,7 +1488,7 @@ mod tests {
out.text
.contains("Filesystem 1K-blocks Used Available Use% Mounted on")
);
assert!(out.text.contains("… 13 lines omitted …"));
assert!(out.text.contains("[…13ln elided…]"));
assert!(out.text.contains("/dev/disk35"));
}
+11 -11
View File
@@ -251,7 +251,7 @@ fn compact_package_tree_output(input: &str) -> String {
out.push_str(line);
out.push('\n');
}
let _ = writeln!(out, "… {} package entries omitted …", lines.len() - PACKAGE_TREE_HEAD_LINES);
let _ = writeln!(out, "[…{} package entries elided…]", lines.len() - PACKAGE_TREE_HEAD_LINES);
out
}
@@ -292,7 +292,7 @@ fn summarize_package_rows(rows: Vec<String>) -> Option<String> {
out.push('\n');
}
if rows.len() > PACKAGE_TREE_HEAD_LINES {
let _ = writeln!(out, "… {} package entries omitted …", rows.len() - PACKAGE_TREE_HEAD_LINES);
let _ = writeln!(out, "[…{} package entries elided…]", rows.len() - PACKAGE_TREE_HEAD_LINES);
}
Some(out)
}
@@ -793,7 +793,7 @@ mod tests {
assert!(out.text.contains("├── dep000@1.0.0"));
assert!(out.text.contains("├── dep078@1.0.0"));
assert!(!out.text.contains("├── dep089@1.0.0"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -808,7 +808,7 @@ mod tests {
let out = filter(&context, &input, 0);
assert!(out.text.starts_with("package tree/list: 91 entries\n"));
assert!(out.text.contains("dep000"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -825,7 +825,7 @@ mod tests {
assert!(out.text.starts_with("package tree/list: 92 entries\n"));
assert!(out.text.contains("react 19.0.0"));
assert!(out.text.contains("└─ dependent000"));
assert!(out.text.contains("… 12 package entries omitted …"));
assert!(out.text.contains("[…12 package entries elided…]"));
}
#[test]
@@ -889,7 +889,7 @@ mod tests {
assert!(out.text.contains("pkg000 1.0.0"));
assert!(out.text.contains("pkg078 1.0.78"));
assert!(!out.text.contains("pkg089 1.0.89"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -905,7 +905,7 @@ mod tests {
assert!(out.text.starts_with("package tree/list: 91 entries\n"));
assert!(out.text.contains("project v1.0.0"));
assert!(out.text.contains("pkg000"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -920,7 +920,7 @@ mod tests {
let out = filter(&context, &input, 0);
assert!(out.text.starts_with("package tree/list: 91 entries\n"));
assert!(out.text.contains("requests 2.32.0"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -951,7 +951,7 @@ mod tests {
let out = filter(&context, &input, 0);
assert!(out.text.starts_with("package tree/list: 91 entries\n"));
assert!(out.text.contains("pkg000==1.0.0"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -966,7 +966,7 @@ mod tests {
let out = filter(&context, &input, 0);
assert!(out.text.starts_with("package tree/list: 91 entries\n"));
assert!(out.text.contains("dep000==2.0.0"));
assert!(out.text.contains("… 11 package entries omitted …"));
assert!(out.text.contains("[…11 package entries elided…]"));
}
#[test]
@@ -996,7 +996,7 @@ mod tests {
let out = filter(&context, &input, 0);
assert!(out.text.starts_with("package tree/list: 92 entries\n"));
assert!(out.text.contains("pkg000"));
assert!(out.text.contains("… 12 package entries omitted …"));
assert!(out.text.contains("[…12 package entries elided…]"));
}
#[test]
+14 -14
View File
@@ -2,7 +2,7 @@
//!
//! Ported from rtk-ai/rtk@878af7de99e0ba71da2e8fd996f6b52a1836e06c
//! Path: `src/cmds/python/pytest_cmd.rs`
//! License: MIT (compatible with workspace MIT). See `ATTRIBUTION-RTK.md` at
//! License: MIT (compatible with workspace MIT). See `NOTICE` at
//! the `pi-shell` crate root.
//!
//! The pytest state machine (`filter_pytest`, `pytest_success`,
@@ -20,9 +20,9 @@ use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
/// sections). Mirrors RTK's `MAX_PYTEST_FAILURES` (== `CAP_WARNINGS` == 10),
/// re-derived for the minimizer's streaming, line-based renderer: once this
/// many traceback blocks have been emitted, further blocks are suppressed and
/// a single `+N more failures` overflow marker stands in for the remainder.
/// The compact `FAILED …` short-summary one-liners are NOT capped here — they
/// name every failed test cheaply and stay intact.
/// a single `[…N failures elided…]` overflow marker stands in for the
/// remainder. The compact `FAILED …` short-summary one-liners are NOT capped
/// here — they name every failed test cheaply and stay intact.
///
/// The `=== ERRORS ===` section (collection / fixture-setup errors) is capped
/// by a SEPARATE counter (see `filter_pytest`): pytest renders ERRORS *before*
@@ -172,15 +172,15 @@ fn filter_pytest(input: &str, exit_code: i32) -> String {
// overflow markers so the compact output reads top-to-bottom.
let error_overflow = error_blocks.saturating_sub(MAX_PYTEST_FAILURES);
if error_overflow > 0 {
out.push_str("… +");
out.push_str("[…");
out.push_str(&error_overflow.to_string());
out.push_str(" more errors\n");
out.push_str(" errors elided…]\n");
}
let overflow = failure_blocks.saturating_sub(MAX_PYTEST_FAILURES);
if overflow > 0 {
out.push_str("… +");
out.push_str("[…");
out.push_str(&overflow.to_string());
out.push_str(" more failures\n");
out.push_str(" failures elided…]\n");
}
if has_content(&out) {
@@ -575,7 +575,7 @@ mod tests {
assert!(out.contains("test_case_0"), "got: {out}");
assert!(out.contains("test_case_9"), "got: {out}");
// The overflow marker accounts for the remaining 2 verbose blocks.
assert!(out.contains("… +2 more failures"), "got: {out}");
assert!(out.contains("[…2 failures elided…]"), "got: {out}");
// The traceback bodies for the capped blocks are gone, but the compact
// short-summary one-liners for every test survive uncapped.
let assert_false_lines = out.matches("E assert False").count();
@@ -664,17 +664,17 @@ mod tests {
let assert_lines = out.matches("E assert 0 == 100").count();
assert_eq!(assert_lines, 5, "all 5 assertion bodies must survive: {out}");
// 5 < cap, so there is NO failure overflow stealing real tracebacks.
assert!(!out.contains("more failures"), "no failures should be capped: {out}");
assert!(!out.contains("failures elided"), "no failures should be capped: {out}");
// Collection errors are still surfaced (under their own cap of 10).
assert!(out.contains("ERROR collecting tests/test_imp_0.py"), "got: {out}");
assert!(!out.contains("more errors"), "9 errors < cap, no error overflow: {out}");
assert!(!out.contains("errors elided"), "9 errors < cap, no error overflow: {out}");
assert!(out.contains("pytest: 5 failed, 9 errors"), "got: {out}");
}
#[test]
fn pytest_caps_error_blocks_independently_of_failures() {
// 12 collection-error banners (ERRORS section) exceed the cap and yield
// their OWN `+N more errors` marker, while the 2 real failure tracebacks
// their OWN `[…N errors elided…]` marker, while the 2 real failure tracebacks
// in the FAILURES section are untouched (their counter is separate).
let mut input = String::from(
"============================= test session starts \
@@ -706,9 +706,9 @@ mod tests {
let out = filter_pytest(&input, 1);
// Error overflow marker fires for the 2 capped collection banners…
assert!(out.contains("… +2 more errors"), "got: {out}");
assert!(out.contains("[…2 errors elided…]"), "got: {out}");
// …but NOT a failure overflow — both real tracebacks render in full.
assert!(!out.contains("more failures"), "failures must be uncapped here: {out}");
assert!(!out.contains("failures elided"), "failures must be uncapped here: {out}");
assert!(out.contains("test_real_0"), "got: {out}");
assert!(out.contains("test_real_1"), "got: {out}");
let assert_false = out.matches("E assert False").count();
@@ -75,7 +75,7 @@ fn filter_rspec(input: &str, exit_code: i32) -> String {
let mut out = String::new();
// Each rendered failure block is buffered separately so the total can be
// capped at MAX_RENDERED_FAILURES with a `+N more failures` marker. The
// capped at MAX_RENDERED_FAILURES with a `[…N failures elided…]` marker. The
// buffered blocks are rendered into `out` the moment the `Failures:` section
// ends — at the summary line or the `Failed examples:` boundary — so real
// rspec ordering (`Failures:` details, then summary, then `Failed examples:`)
@@ -139,8 +139,8 @@ fn filter_rspec(input: &str, exit_code: i32) -> String {
}
/// Render the buffered `Failures:` detail blocks into `out`, capped at
/// `MAX_RENDERED_FAILURES` with a `+N more failures` marker. Runs at most once
/// — guarded by `rendered` — so the section is emitted exactly where the
/// `MAX_RENDERED_FAILURES` with a `[…N failures elided…]` marker. Runs at most
/// once — guarded by `rendered` — so the section is emitted exactly where the
/// `Failures:` block ends (the summary line or `Failed examples:` boundary),
/// preserving real rspec section ordering.
fn render_rspec_blocks(out: &mut String, blocks: &mut Vec<String>, rendered: &mut bool) {
@@ -157,14 +157,14 @@ fn render_rspec_blocks(out: &mut String, blocks: &mut Vec<String>, rendered: &mu
out.push_str(block);
}
if total > MAX_RENDERED_FAILURES {
push_line(out, &format!("+{} more failures", total - MAX_RENDERED_FAILURES));
push_line(out, &format!("[…{} failures elided…]", total - MAX_RENDERED_FAILURES));
}
blocks.clear();
}
/// Cap on rendered failure blocks in non-JSON rspec output. rtk uses the same
/// limit (`MAX_RSPEC_FAILURES = 5`) — failure blocks carry full context, so a
/// handful is enough before collapsing to a `+N more failures` marker.
/// handful is enough before collapsing to a `[…N failures elided…]` marker.
const MAX_RENDERED_FAILURES: usize = 5;
fn flush_rspec_block(blocks: &mut Vec<String>, current: &mut String) {
@@ -896,7 +896,7 @@ mod tests {
assert!(out.contains("Example number 1 fails"));
assert!(out.contains("Example number 5 fails"));
assert!(!out.contains("Example number 6 fails"));
assert!(out.contains("+2 more failures"));
assert!(out.contains("[…2 failures elided…]"));
assert!(out.contains("7 examples, 7 failures"));
}
@@ -79,7 +79,7 @@ fn condense_rustfmt(input: &str, exit_code: i32) -> String {
out.push('\n');
}
if total > 3 {
let _ = writeln!(out, " … {} more", total - 3);
let _ = writeln!(out, " […{} files elided…]", total - 3);
}
out
}
@@ -125,7 +125,7 @@ mod tests {
assert!(out.changed);
assert!(out.text.contains("50 files reformatted"));
assert!(out.text.contains("src/file_0.rs"));
assert!(out.text.contains("… 47 more"));
assert!(out.text.contains("[…47 files elided…]"));
// Diff bodies must be elided.
assert!(!out.text.contains("old line"));
// Savings ratio ≥ 0.7
@@ -154,7 +154,7 @@ mod tests {
let out = filter(&context, &input, 1);
assert!(out.changed);
// head_tail_lines(input, 80, 40) keeps 120 lines + marker.
assert!(out.text.contains("lines omitted"));
assert!(out.text.contains("ln elided…]"));
}
#[test]
@@ -1,6 +1,6 @@
//! Conservative text filters for system-style commands.
use std::collections::HashMap;
use std::{collections::HashMap, fmt::Write as _};
use super::git;
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
@@ -358,9 +358,7 @@ fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String {
for line in lines.iter().take(head) {
push_counted_line(&mut out, &line.original, line.count);
}
out.push_str("… ");
out.push_str(&(lines.len() - head - tail).to_string());
out.push_str(" unique lines omitted …\n");
let _ = writeln!(out, "[…{} unique lines elided…]", lines.len() - head - tail);
for line in lines.iter().skip(lines.len() - tail) {
push_counted_line(&mut out, &line.original, line.count);
}
@@ -750,9 +748,7 @@ fn compact_format_output(input: &str) -> String {
out.push('\n');
}
if files.len() > 50 {
out.push_str("… ");
out.push_str(&(files.len() - 50).to_string());
out.push_str(" more files\n");
let _ = writeln!(out, "[…{} files elided…]", files.len() - 50);
}
}
if errors.is_empty() && summary.is_empty() && files.is_empty() {
@@ -1073,7 +1069,7 @@ Time 0.42s
assert!(out.text.contains("errors:"));
assert!(out.text.contains("failed to parse src/bad.py"));
assert!(out.text.contains("files:"));
assert!(out.text.contains("more files"));
assert!(out.text.contains("[…50 files elided…]"));
}
#[test]
+161 -48
View File
@@ -28,7 +28,7 @@ use brush_parser::{
ParserOptions, SourceInfo,
ast::{
AndOr, Command, CommandPrefixOrSuffixItem, CompoundListItem, IoFileRedirectTarget,
IoRedirect, Pipeline, Program, SeparatorOperator, Word,
IoRedirect, Pipeline, Program, SeparatorOperator, SimpleCommand, Word,
},
};
@@ -66,21 +66,24 @@ pub enum CommandPlan {
/// Parse `command` with `brush-parser` and classify its structure.
#[must_use]
pub fn analyze(command: &str) -> CommandPlan {
let trimmed = command.trim();
if trimmed.is_empty() {
if command.trim().is_empty() {
return CommandPlan::Unsupported;
}
let Some(program) = parse(command) else {
return CommandPlan::Unsupported;
};
classify(&program)
}
/// Parse `command` with the same `brush-parser` configuration the vendored
/// runtime uses, discarding error detail. Returns `None` on any syntax error
/// or unsupported construct.
fn parse(command: &str) -> Option<Program> {
let options = ParserOptions::default();
let source_info = SourceInfo::default();
let reader = std::io::Cursor::new(command.as_bytes());
let mut parser = brush_parser::Parser::new(reader, &options, &source_info);
let Ok(program) = parser.parse_program() else {
return CommandPlan::Unsupported;
};
classify(&program)
parser.parse_program().ok()
}
fn classify(program: &Program) -> CommandPlan {
@@ -216,51 +219,115 @@ fn io_redirect_is_safe(io: &IoRedirect) -> bool {
}
}
/// True when every part of a simple command is safe to reconstruct through
/// `brush`'s `Display` impl: no command/process substitutions and no here-doc
/// in the command word, prefix, or suffix (those re-emit in forms that diverge
/// from the source). The full reconstruction is still re-parse-verified by
/// [`reconstruction_reparses_to_same_shape`] before any segment is executed.
fn simple_command_is_safe(simple: &SimpleCommand) -> bool {
if let Some(prefix) = simple.prefix.as_ref()
&& prefix
.0
.iter()
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
{
return false;
}
if let Some(suffix) = simple.suffix.as_ref()
&& suffix
.0
.iter()
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
{
return false;
}
if let Some(word) = simple.word_or_name.as_ref()
&& word_has_command_substitution(word)
{
return false;
}
true
}
fn simple_segment(pipeline: &Pipeline) -> Option<(String, String)> {
if pipeline.timed.is_some() || pipeline.bang || pipeline.seq.is_empty() {
return None;
}
// For multi-stage pipes inside a chain segment, identify the segment by its
// first stage's program. The downstream per-segment minimizer::apply will
// detect the pipeline at runtime via plan::CommandPlan::Piped and pass it
// through unchanged — so a piped segment is safely captured but never
// rewritten. This keeps the chain decomposable when even one inner stage
// uses a pipe (e.g. `ls | head -10 && git status`).
let first = pipeline.seq.first()?;
match first {
Command::Simple(simple) => {
if simple.prefix.as_ref().is_some_and(|prefix| {
prefix
.0
.iter()
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
}) {
return None;
}
if simple.suffix.as_ref().is_some_and(|suffix| {
suffix
.0
.iter()
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
}) {
return None;
}
let program_word = simple.word_or_name.as_ref()?;
if word_has_command_substitution(program_word) {
return None;
}
let program = program_word.to_string();
if program.trim().is_empty() {
return None;
}
Some((pipeline.to_string(), program))
},
// Compound shell syntax (if / for / while / subshell / { ... }) is
// not something the minimizer should touch.
Command::Compound(..) | Command::Function(_) | Command::ExtendedTest(..) => None,
// Every stage must be a Display-safe simple command. Compound stages
// (`if` / `for` / `while` / subshells / `{ … }`) and unsafe words/redirects
// (here-docs, substitutions) do not round-trip through `Display`. Validating
// only `seq.first()` once let a compound later stage through — e.g.
// `git log … | while read x; do … done` — and the reconstructed segment then
// failed to execute with "syntax error at end of input".
for command in &pipeline.seq {
let Command::Simple(simple) = command else {
return None;
};
if !simple_command_is_safe(simple) {
return None;
}
}
// Identify the segment by its first stage's program word. Multi-stage pipes
// are captured but never rewritten (runtime detects `CommandPlan::Piped`),
// keeping the chain decomposable when an inner stage pipes (e.g.
// `ls | head -10 && git status`).
let Command::Simple(first) = pipeline.seq.first()? else {
return None;
};
let program_word = first.word_or_name.as_ref()?;
let program = program_word.to_string();
if program.trim().is_empty() {
return None;
}
// The chain runner re-executes this reconstructed string verbatim. brush's
// `Display` is not a guaranteed inverse of its parser, so re-parse the
// reconstruction and require the same pipeline shape before committing to
// segmentation. Any divergence (a lossy compound terminator today, a future
// `Display` change tomorrow) falls back to the unsegmented whole-command
// path instead of failing at execution.
let command = pipeline.to_string();
if !reconstruction_reparses_to_same_shape(&command, pipeline.seq.len()) {
return None;
}
Some((command, program))
}
/// Confirm a reconstructed segment string re-parses to the *same shape* it was
/// built from: exactly one sequential top-level pipeline with `expected_stages`
/// commands, no `&&`/`||`/`;`/`&` continuation, and no `!`/`time` modifier.
///
/// This is a syntax/shape guard, not a proof of full semantic equivalence. It
/// guarantees the chain runner never executes a reconstruction that fails to
/// parse or that `Display` reshaped into different top-level structure. brush's
/// `Display` is not a guaranteed inverse of its parser — compound terminators
/// (`while … done`) and quoted here-doc close tags re-emit in forms that fail
/// to re-parse — so a divergent segment drops back to the unsegmented
/// whole-command path instead of blowing up at execution with
/// "pi-natives:command: syntax error". The per-stage `simple_command_is_safe`
/// whitelist already excludes constructs whose `Display` is value-lossy
/// (substitutions, here-docs); words carry raw source text and round-trip
/// verbatim.
fn reconstruction_reparses_to_same_shape(reconstructed: &str, expected_stages: usize) -> bool {
let Some(program) = parse(reconstructed) else {
return false;
};
let mut items = program.complete_commands.iter().flat_map(|cl| cl.0.iter());
let Some(CompoundListItem(and_or, separator)) = items.next() else {
return false;
};
// A second top-level item, a trailing `&` (Async), or an `&&`/`||`
// continuation all mean `Display` reshaped the command.
if items.next().is_some()
|| !and_or.additional.is_empty()
|| matches!(separator, SeparatorOperator::Async)
{
return false;
}
let pipeline = &and_or.first;
!pipeline.bang && pipeline.timed.is_none() && pipeline.seq.len() == expected_stages
}
fn classify_pipeline(pipeline: &Pipeline) -> Option<CommandPlan> {
@@ -453,4 +520,50 @@ mod tests {
assert_eq!(analyze(""), CommandPlan::Unsupported);
assert_eq!(analyze(" "), CommandPlan::Unsupported);
}
#[test]
fn compound_later_pipeline_stage_is_not_segmented() {
// Regression: a pipeline whose *later* stage is a compound command
// (`while`/`for`/`if`/subshell) must never be segmented. The chain runner
// re-executes `pipeline.to_string()`, and brush's `Display` drops the
// compound terminator, so the reconstruction failed to re-parse and blew
// up at execution with "syntax error at end of input". Validating only
// `seq.first()` (a simple `git`/`seq`) let it slip through.
assert_not_chain(
"echo start && git log --oneline | while read h; do echo \"$h\"; done | head -3",
);
assert_not_chain("seq 3 | while read n; do echo \"$n\"; done && echo done");
assert_not_chain("printf x && ls | for f in a b; do echo $f; done");
// The reported shape classifies as Compound, so it runs whole and
// unsegmented via the single path (no reconstruction, no minimization).
assert_eq!(
analyze("echo a && seq 2 | while read n; do echo $n; done"),
CommandPlan::Compound,
);
}
#[test]
fn chain_segments_reparse_cleanly() {
// Every command string the chain runner will execute must itself re-parse
// to a single pipeline — the contract enforced by
// `reconstruction_reparses_to_same_shape`. A segment whose `Display`
// reconstruction diverges is dropped rather than emitted.
for command in [
"git diff --stat && git diff --name-only",
"ls -lh *.txt | head -5 && git status --short",
"grep -c foo bar 2>/dev/null && echo done",
"FOO=1 git status ; bun test",
] {
let Some(segments) = chain_of(analyze(command)) else {
panic!("{command:?} should classify as Chain");
};
for segment in &segments {
assert!(
parse(&segment.command).is_some(),
"segment {:?} from {command:?} did not re-parse",
segment.command,
);
}
}
}
}
+10 -10
View File
@@ -93,9 +93,9 @@ pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String {
out.push_str(line);
out.push('\n');
}
out.push_str("… ");
out.push_str("[…");
out.push_str(&omitted.to_string());
out.push_str(" lines omitted …\n");
out.push_str("ln elided…]\n");
for line in lines.iter().skip(lines.len() - tail) {
out.push_str(line);
out.push('\n');
@@ -292,9 +292,9 @@ pub fn head_lines_only(input: &str, head: usize) -> String {
out.push_str(line);
out.push('\n');
}
out.push_str("… ");
out.push_str("[…");
out.push_str(&omitted.to_string());
out.push_str(" lines omitted …\n");
out.push_str("ln elided…]\n");
out
}
@@ -307,9 +307,9 @@ pub fn tail_lines_only(input: &str, tail: usize) -> String {
}
let omitted = lines.len() - tail;
let mut out = String::new();
out.push_str("… ");
out.push_str("[…");
out.push_str(&omitted.to_string());
out.push_str(" lines omitted …\n");
out.push_str("ln elided…]\n");
for line in lines.iter().skip(omitted) {
out.push_str(line);
out.push('\n');
@@ -330,9 +330,9 @@ pub fn max_lines(input: &str, max: usize) -> String {
out.push_str(line);
out.push('\n');
}
out.push_str("… ");
out.push_str("[…");
out.push_str(&dropped.to_string());
out.push_str(" lines truncated …\n");
out.push_str("ln elided…]\n");
out
}
@@ -380,7 +380,7 @@ mod tests {
#[test]
fn head_tail_marks_omitted_lines() {
let out = head_tail_lines("1\n2\n3\n4\n5\n", 2, 1);
assert_eq!(out, "1\n2\n… 2 lines omitted …\n5\n");
assert_eq!(out, "1\n2\n[…2ln elided…]\n5\n");
}
#[test]
@@ -397,7 +397,7 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
let out = head_tail_cap(&input, CapClass::List);
assert!(out.contains("lines omitted"));
assert!(out.contains("ln elided…]"));
assert!(out.lines().count() <= CapClass::List.lines() + 1);
}
+61
View File
@@ -2237,6 +2237,67 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
assert!(!output.contains("unterminated"));
}
/// Regression: a `&&` / `;` chain whose later pipeline stage is a compound
/// command (`while … done`) must execute instead of failing with
/// "pi-natives:command: syntax error at end of input". The segmented chain
/// runner rebuilt each segment via the brush AST `Display` impl, but only
/// validated the *first* pipeline stage — so a compound later stage was
/// reconstructed without its terminator and re-run as invalid shell. Such a
/// command now bails out of segmentation and runs whole via the single path.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn compound_stage_in_chain_runs_via_single_path() {
let root = unique_temp_dir("compound-chain");
let minimizer = printf_minimizer(&root.join("minimizer.toml"), None);
let (result, output) = run_command_capture(
"printf 'start\\n' && seq 5 | while read n; do echo \"n=$n\"; done | head -2",
None,
Some(minimizer),
CancelToken::default(),
)
.await;
let _ = std::fs::remove_dir_all(&root);
assert_eq!(result.exit_code, Some(0));
assert_eq!(output, "start\nn=1\nn=2\n");
assert!(!output.contains("syntax error"));
// Ran whole (unsegmented), so nothing was minimized.
assert!(result.minimized.is_none());
}
/// A segment that carries a file redirect is still segmented, and the brush
/// `Display` reconstruction the runner executes must round-trip through
/// brush's own parser **without losing the redirect**. `echo hidden
/// >/dev/null` suppresses its own stdout: if the reconstruction dropped the
/// redirect, `hidden` would leak into the captured output. Proves the
/// reconstruction path is semantically sound for the redirect-bearing
/// shapes the per-stage whitelist accepts (not just syntactically
/// parseable).
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn segmented_chain_with_redirect_executes_correctly() {
let root = unique_temp_dir("redirect-chain");
let minimizer = printf_minimizer(&root.join("minimizer.toml"), None);
let (result, output) = run_command_capture(
"echo hidden >/dev/null && printf 'hello\\n'",
None,
Some(minimizer),
CancelToken::default(),
)
.await;
let _ = std::fs::remove_dir_all(&root);
assert_eq!(result.exit_code, Some(0));
// The redirect survived reconstruction: segment 1's stdout went to
// /dev/null, so only segment 2's output is captured.
assert!(!output.contains("hidden"), "redirect must suppress segment-1 stdout");
assert_eq!(output, "hello\n");
let minimized = result
.minimized
.expect("redirect chain should be minimized");
assert_eq!(minimized.original_text, "hello\n");
assert_eq!(minimized.text, "HI\n");
assert!(!output.contains("syntax error"));
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn segmented_chain_exceeding_aggregate_capture_cap_stays_raw() {
+20 -1
View File
@@ -9,6 +9,7 @@ The advisor is not a second executor. It cannot edit files, run commands, approv
- [`src/advisor/runtime.ts`](../packages/coding-agent/src/advisor/runtime.ts)
- [`src/advisor/advise-tool.ts`](../packages/coding-agent/src/advisor/advise-tool.ts)
- [`src/advisor/watchdog.ts`](../packages/coding-agent/src/advisor/watchdog.ts)
- [`src/advisor/transcript-recorder.ts`](../packages/coding-agent/src/advisor/transcript-recorder.ts)
- [`src/prompts/advisor/system.md`](../packages/coding-agent/src/prompts/advisor/system.md)
- [`src/prompts/advisor/advise-tool.md`](../packages/coding-agent/src/prompts/advisor/advise-tool.md)
- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)
@@ -203,4 +204,22 @@ The advisor has its own append-only context. Before each advisor prompt, `AgentS
2. if promotion cannot fit enough context, compact the advisor's own message history
3. if compaction has no candidates or still cannot fit, re-prime from the current bounded primary transcript
The advisor transcript is in-memory for the session. It is retained while the session runs so `/advisor dump` can inspect it, but advisor state is not a replacement for the primary persisted transcript.
The advisor's live context is in-memory and append-only; it is retained while the session runs so `/advisor dump` can inspect it, and is independently promoted/compacted/re-primed (above). It is not a replacement for the primary persisted transcript.
## Transcript persistence and observability
The advisor is a passive reviewer with its own model usage, so — like a task subagent — every finalized advisor turn is appended to a JSONL inside the owning session's artifacts dir:
- main session: `<session>/__advisor.jsonl`
- subagent advisor (`advisor.subagents: true`): `<session>/<SubId>/__advisor.jsonl`
The path is derived from the session file (not the artifacts dir, which subagents share with their parent), so each advisor writes a distinct file. The reserved `__advisor` stem cannot collide with a task subagent's `<id>.jsonl` (task id allocation reserves it).
Why a file:
- **Usage attribution.** `omp stats` scans each session folder recursively, so advisor assistant turns (with their usage/cost) are attributed to the same project/session like any other subagent. Advisor "session update" prompts are persisted as `synthetic`, agent-attributed user messages so they never inflate user-message metrics.
- **Observability.** The Agent Hub discovers `__advisor.jsonl` on open and shows it as a read-only `advisor`-kind transcript under its owning session.
The file follows session switches: on `/new`, resume/switch, and branch the recorder reopens at the new session's path on the next advisor turn; before a `/drop` deletes the old artifacts dir the recorder feed is detached and drained so a queued write cannot recreate the deleted file. The on-disk log is append-only and independent of the in-memory context — re-primes and compaction never truncate it.
The advisor is never a peer. The `advisor`-kind registry ref is excluded from every agent-facing surface — the `irc` peer roster and broadcast targets, the subagent peer prompt, and the `history://` index/lookup/completions — and cannot be messaged (`irc send` and collab chat refuse it) or revived/killed from the Agent Hub or collab. It is observability only.
+23 -8
View File
@@ -20,6 +20,8 @@ Collab session started!
The browser line is click-to-join (an OSC 8 hyperlink to the full `https://` deep link): the relay serves the web guest client at `/`, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works:
Running `/collab` or `/collab view` starts or displays the active hosting session, rendering both the terminal/browser join links and their corresponding QR codes.
```
/join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU…
```
@@ -30,9 +32,9 @@ The guest's previous session is restored on `/leave` (or when the host stops).
| Command | Effect |
|---|---|
| `/collab` | Start sharing (or re-print the link when already hosting) |
| `/collab` | Start sharing full-control (or re-print the link/QR when already hosting) |
| `/collab <relay>` | Start sharing through a specific relay (`relay.example.com`, `ws://localhost:7475`) |
| `/collab view` | Print a read-only (view-only) link (starts sharing first if needed) |
| `/collab view` | Start sharing read-only (or re-print the link/QR when already hosting) |
| `/collab status` | Show link + participants |
| `/collab stop` | Stop sharing |
| `/join <link>` | Join a shared session as a guest |
@@ -40,19 +42,29 @@ The guest's previous session is restored on `/leave` (or when the host stops).
## Link format
Accepted by `/join <link>` and `omp join "<link>"`:
```
https://host[:port]/#<link> → browser deep link (printed by /collab; /join accepts it too)
<roomId>.<key> → default relay (my.omp.sh)
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
ws://localhost:7475/r/<roomId>.<key> → plain ws, allowed for localhost only
<roomId>.<key> → default relay (wss://my.omp.sh)
<roomId>#<key> → legacy bare form
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
host[:port]/r/<roomId>#<key> → legacy direct relay form
https://host[:port]/r/<roomId>.<key> → direct relay URL, normalized to wss://
wss://host[:port]/r/<roomId>.<key> → direct websocket relay URL
ws://localhost:7475/r/<roomId>.<key> → direct plain ws, localhost only
https://host[:port]/#<link> → browser deep link when web UI and relay share a host
https://web-host[:port][/<path>]/#<relay-link> → browser UI wrapper with relay link in the fragment
https://web.example/collab/#relay.example.com/r/<roomId>.<key> → web UI and relay on different hosts
```
The trailing `.<key>` part is the room secret, base64url-encoded, in one of two strengths:
`<link>` / `<relay-link>` are parsed recursively as any accepted link above. For `http(s)` browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets `https://web.example/collab/#relay.example.com/r/<roomId>.<key>` open the web UI at `web.example` while joining `wss://relay.example.com/r/<roomId>`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/<roomId>#<key>` still means relay `relay.example.com`.
The trailing `.<key>` or `#<key>` part is the room secret, base64url-encoded, in one of two strengths:
- **Full link** — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control.
- **View-only link** — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only.
The room secret is dot-joined rather than `#`-joined: RFC 3986 forbids a raw `#` inside a URL fragment, so strict URL stacks (macOS Foundation behind terminal click-to-open) percent-encode a second `#` to `%23` and break the link. Parsers leniently accept the legacy `#` form and the mangled `%23` form. In the browser deep link, everything after the `#` — room id and key — is a URL fragment: it never appears in any HTTP request, and neither secret is ever sent to the relay.
The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw `#` inside a URL fragment; parsers still accept legacy `#` forms and `%23`-mangled legacy deep links.
## End-to-end encryption
@@ -85,11 +97,14 @@ Known v1 limit for guests: a turn already streaming when you join becomes visibl
`packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at `/`, which is what makes the `/collab` deep link click-to-join: `https://<relay>/#<link>` loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run `bun run dev` in the package for a local instance, `bun run mock-host` for an offline scripted host to develop against, and `bun run build` to emit a static `dist/` deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment.
Set `collab.webUrl` when the browser UI is hosted separately from the websocket relay. When empty, `/collab` derives `http(s)://host[:port]` from `collab.relayUrl`; explicit web UI URLs must use `https://` except for `http://localhost` development origins. The generated browser URL still carries the relay-specific collab link in the fragment.
## Settings
| Setting | Default | Meaning |
|---|---|---|
| `collab.relayUrl` | `wss://my.omp.sh` | Relay used by `/collab` when no relay is passed inline |
| `collab.webUrl` | empty | Browser UI URL for `/collab` links; empty derives from relay; explicit `http://` is allowed only for localhost |
| `collab.displayName` | OS username | Name shown to other participants |
| `share.serverUrl` | `https://my.omp.sh/s` | Share viewer/upload base used by `/share` (links are `<base>/<id>#<key>`) |
| `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload |
+3 -3
View File
@@ -134,8 +134,8 @@ The automatic paths are intentionally different:
- The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the **model id** when the model line was measured: Claude reads X.org `8x13` glyphs on an 11px advance (extra letter-spacing, black ink — `11on16-bw`; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic's 4,784 visual-token cap, older lines stay at 1568px), Gemini reads `8x13` glyphs on a 22px pitch (extra leading, black ink — `8on22-bw` at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same `8on22-bw` shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read `8x13` glyphs on a 16px pitch (`8on16-bw` at 1568px — kimi's processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown → `11on16-bw`, Google → `8on22-bw`, OpenAI-compatible → `8on22-bw`); billing (per-family patch/budget formulas, OpenAI's `detail: "original"` hint) always follows the API carrying the request, computed for the resolved frame size. The `snapcompact.shape` setting (default `auto`) forces one of the research-eval variants instead: square grids (`8x8r`/`8x8u`/`6x6u`/`5x8` × sentence-hue/black ink) or the per-model eval winners (`6x12-dim`, `8x13-bw`, `8on16-bw`, `8on22-bw`, `11on16-bw`, and the two-column word-wrapped `doc-8on16-bw`/`-sent`/`-sent-dim`, where `dim` prints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider's image billing. The same setting governs inline system-prompt/tool-result imaging (`snapcompact.systemPrompt`, `snapcompact.toolResults`).
- Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`).
- Frames persist under `CompactionEntry.preserveData.snapcompact` and are re-attached to the `compactionSummary` message as image blocks on every context rebuild; the entry's `summary` is a deterministic reading guide (grid geometry, role tags, truncation notes) plus the usual file-operation lists.
- Later compactions carry earlier frames forward. The frame budget is provider-aware (`providerFrameBudget`): the per-provider image cap clamped to 8 (`MAX_FRAMES`) — OpenRouter hard-caps requests at 8 images and silently drops the excess, unknown providers get a safe floor of 5. Beyond the budget the archive fades from the middle out: the earliest frame (session head — the original request, or the filmed summary of older history) is pinned, and the oldest *unpinned* frames are evicted. Pages of the *current* compaction that no longer fit are never rendered or dropped — the newest unframed slice survives verbatim as a text tail on the summary (`Archive.textTail`, capped at two frame capacities with middle elision) and is folded back into frames by the next compaction. If the previous compaction was text-based, its summary is printed at the head of the frame archive as `[Summary of earlier history]`.
- The snapcompact archive persists under `CompactionEntry.preserveData.snapcompact` as bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry's `summary` is just the short resume lead-in plus the usual file-operation list.
- Later compactions re-render from that bounded source text (`Archive.text`), not by carrying old PNGs forward blindly. `maxFrames` now defaults to `MAX_FRAMES_DEFAULT` (80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text.
- No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary).
- Rationale: the shape table comes from the snapcompact 200k-token evals in `packages/snapcompact`, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models.
@@ -263,7 +263,7 @@ Cumulative behavior:
- In split turns, includes turn-prefix file ops too.
- `details.readFiles` excludes files also modified; `details.modifiedFiles` carries the rest (persisted shape is unchanged).
Summary text gets one `<files>` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `… (N more files omitted)` line.
The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line. LLM-summary strategies append it as a `<files>` tag (via `upsertFileOperations`); snapcompact renders it inside its summary template as a `FILES` section instead.
```xml
<files>
+22 -10
View File
@@ -1,6 +1,6 @@
# Resolve tool runtime internals
This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the tool-choice queue and `pushPendingAction`.
This document explains how preview/apply workflows are modeled in coding-agent and how built-in or custom tools can participate via the pending-invoker registry and `pushPendingAction`. (Pending previews live in a separate non-forcing registry inside `ToolChoiceQueue`; only genuine hard forces use the consuming directive queue.)
## Scope and key files
@@ -24,20 +24,32 @@ If no pending action exists, `resolve(action="apply")` fails with:
`resolve(action="discard")` with no pending action succeeds instead, returning `Nothing to discard; no pending action remains.` — the desired end-state (no staged change) already holds.
## Pending actions use the tool-choice queue
## Pending previews use a non-forcing soft tool requirement
Preview producers call `queueResolveHandler(...)`, which pushes a one-shot forced `resolve` directive onto the session tool-choice queue and adds a `resolve-reminder` steering message.
Preview producers call `queueResolveHandler(...)`, which registers a non-forcing
pending invoker on the session (a stack keyed by a unique
`pending-action:<tool>:<seq>` id — never clobbered by label). It does NOT force
`tool_choice` and does NOT inject a steering reminder.
While a preview is pending, the session's `getToolChoice` callback
(`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`)
carrying the resolve reminder, as a non-consuming peek. The agent runtime owns the
lifecycle: it injects the reminder once, runs with `tool_choice` unchanged, and
escalates to a one-turn forced `resolve` choice ONLY if the model fails to call
`resolve` that turn (skipping any detour tool batch first). A model that resolves
on the reminder pays no message-cache invalidation — the previous design forced
`tool_choice` on every preview, busting the provider message cache twice per cycle.
Runtime behavior:
- the queued handler owns the pending `apply`/`reject` callbacks,
- `resolve` looks up the current queue invoker with `session.peekQueueInvoker()`,
- if the model rejects the forced tool choice, the queue directive is requeued,
- `resolve` does not maintain a separate pending-action stack.
- the pending invoker owns the `apply`/`reject` callbacks,
- `resolve` dispatches via `peekQueueInvoker() ?? peekPendingInvoker() ?? peekStandingResolveHandler()`,
- a genuine hard forced tool choice (queued via `nextToolChoice`) preempts the soft requirement,
- if an apply callback throws, the helper re-registers the same pending invoker (same id) so the preview can still be discarded or retried.
`resolve` also checks a standing resolve handler after the queue invoker; this is used by long-lived approval flows that are not ordinary preview tool calls.
`resolve` also checks a standing resolve handler after the invokers; this is used by long-lived approval flows that are not ordinary preview tool calls.
Multiple pending previews therefore follow the active tool-choice queue ordering, not an independent pending-action store. If an apply callback throws, the queued helper re-pushes the same resolve directive and reminder so the preview can still be discarded or retried.
Multiple pending previews stack as unique-keyed invokers and resolve independently (head-first), not through forced tool-choice ordering.
## Built-in producer example (`ast_edit`)
@@ -130,4 +142,4 @@ When `queueResolveHandler(...)` registers a preview, the agent runtime forces a
- Keep `label` concise and specific; it is shown in resolve renderer output.
- Ensure `apply(reason)` is deterministic and idempotent enough for one-shot execution; `reason` is informational and should not change behavior.
- Implement `reject(reason)` when the discard needs cleanup (temp state, locks, notifications); omit it for stateless previews where the default message suffices.
- If your tool can stage multiple previews, remember they are mediated by the tool-choice queue rather than a separate pending-action stack.
- If your tool can stage multiple previews, remember they stack as unique-keyed pending invokers (resolved head-first), not a forced tool-choice sequence and not a separate `pushPendingAction` stack.
+10
View File
@@ -95,6 +95,16 @@ Runtime precedence:
Invalid override keys are ignored and logged (`logger.debug`).
#### Box-drawing borders
All outlined chrome — tool-result frames, overlays, code fences, the editor, the welcome banner — draws with the `boxRound.*` tokens: rounded corners (`╭╮╰╯`) plus tee/cross junctions (`├┤┬┴┼`, which have no rounded Unicode form, so they are sourced from the `boxSharp.*` tokens). Markdown tables are the sole exception and keep the fully sharp `boxSharp.*` set (`┌┐└┘`).
Override behavior follows from that split:
- `boxRound.{topLeft,topRight,bottomLeft,bottomRight,horizontal,vertical}` restyle every border's corners and edges.
- `boxSharp.{cross,teeDown,teeUp,teeRight,teeLeft}` restyle dividers/junctions everywhere (rounded frames and tables alike).
- `boxSharp.{topLeft,topRight,bottomLeft,bottomRight}` now affect markdown table corners only.
## Built-in vs custom theme sources
Theme lookup order (`loadThemeJson`):
+3 -3
View File
@@ -61,7 +61,7 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md#
- compiles every rewrite pattern for that language,
- parses each file, skips files with syntax-error trees, collects `replace_by(...)` edits for every match, enforces replacement and file caps, and returns textual before/after slices plus source ranges.
7. The TS wrapper deduplicates and caps parse errors, groups changes by file, and renders preview diff lines.
8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a forced `resolve` action and injects a `resolve-reminder` steering message.
8. If preview found replacements and `applied` is false, `queueResolveHandler(...)` registers a non-forcing pending `resolve` invoker. While it is pending the session surfaces a `SoftToolRequirement` carrying the resolve reminder; the agent runtime injects the reminder and forces `resolve` only if the model declines that turn (no per-preview `tool_choice` cache bust).
9. On `resolve(action: "apply")`, the queued callback reruns the same rewrite set with `dryRun: false`, recomputes counts, and returns an error result if the live result no longer matches the preview (`stalePreview`). The current implementation compares replacement totals and per-file counts after the rerun; if the new run has already written different counts, the result is marked error.
10. On a non-stale apply, the callback returns `Applied N replacements in M files.` (in hashline mode followed by fresh `[path#tag]` snapshot headers re-recorded from the post-apply content); on discard, `resolve` returns a discard message without mutating files.
@@ -79,8 +79,8 @@ Shared AST pattern grammar and language catalog: see [`ast_grep`](./ast-grep.md#
- Preview reads files and scans directories.
- Apply rewrites files in place with `std::fs::write(...)`, but only when the computed output differs from the original source.
- Session state (transcript, memory, jobs, checkpoints, registries)
- Queues a one-shot forced `resolve` tool choice through `queueResolveHandler(...)`.
- Adds a `resolve-reminder` steering message.
- Registers a non-forcing pending `resolve` invoker through `queueResolveHandler(...)`.
- Surfaces a `SoftToolRequirement` (with the resolve reminder) while pending; the agent runtime forces `resolve` only on non-compliance — no steering message and no per-preview forced tool choice.
- User-visible prompts / interactive UI
- Direct `ast_edit` results are previews.
- Follow-up apply/discard is exposed through the hidden `resolve` tool.
+4 -5
View File
@@ -22,7 +22,6 @@
| `hidden` | `boolean` | No | Whether hidden files are included. Defaults to `true` (`hidden ?? true`). |
| `gitignore` | `boolean` | No | Whether `.gitignore` is respected during local native globbing. Defaults to `true`; set `false` to include gitignored files. |
| `limit` | `number` | No | Max returned paths. Defaults to `200`; finite positive inputs are floored then clamped to `1..200`. |
| `timeout` | `number` | No | Timeout in seconds. Defaults to `5`; clamped to `0.5..60`. On timeout, returns partial matches collected so far with a timeout notice and `truncated: true`. |
## Outputs
The tool returns a single text block plus structured `details`.
@@ -51,7 +50,7 @@ The tool returns a single text block plus structured `details`.
- glob in the first segment => search from `.` and, unless the pattern already starts with `**/`, prefix it with `**/`.
- glob later in the path => split at the first glob-bearing segment.
6. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`.
7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. `timeout` is converted to milliseconds and clamped to `500..60_000` before building an `AbortSignal.timeout(...)`.
7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. An internal timeout of `5` seconds (`5000` ms) is built via `AbortSignal.timeout(...)`.
8. Execution then branches:
- **Custom operations branch**: if `FindToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`.
- **Built-in local branch**: the tool stats each target's `searchPath`. Exact-file inputs return immediately. Directory inputs call `natives.glob()` with `hidden`, `maxResults: effectiveLimit`, `sortByMtime: true`, `gitignore: useGitignore`, `recursive: false` (recursion comes from the `**/` prefix `parseFindPattern()` adds), and the combined abort signal; multi-target calls run their globs concurrently.
@@ -79,12 +78,12 @@ The tool returns a single text block plus structured `details`.
- Emits structured progress updates when `onUpdate` is provided.
- Adds truncation / limit metadata to the tool result.
- Background work / cancellation
- Local globbing is cancellable through the caller abort signal plus the configured internal timeout.
- Local globbing is cancellable through the caller abort signal plus the internal timeout.
## Limits & Caps
- Default result limit: `200` (`DEFAULT_LIMIT` in `packages/coding-agent/src/tools/find.ts`).
- Maximum result limit: `200` (`MAX_LIMIT`); larger inputs are clamped.
- Local glob timeout: default `5000` ms, clamped to `500..60_000` ms.
- Local glob timeout: fixed at `5000` ms.
- Output byte cap: `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`).
- Default generic line cap in `truncateHead()` is `3000`, but `find` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so byte size — not line count — is the practical output truncation cap.
- Streaming update throttle: `200` ms between `onUpdate` emissions.
@@ -97,7 +96,7 @@ The tool returns a single text block plus structured `details`.
- `Searching from root directory '/' is not allowed`
- `Limit must be a positive number`
- `Path is not a directory: ...`
- timeout result text is `find timed out after <seconds>s; returning <N> partial matches — increase timeout or narrow pattern` and is returned as a successful, truncated partial result rather than an error.
- timeout result text is `find timed out after <seconds>s; returning <N> partial matches — narrow the pattern instead of retrying blindly` and is returned as a successful, truncated partial result rather than an error.
- If the caller aborts, the local branch converts `AbortError` into `ToolAbortError`.
- Non-`ENOENT` stat failures and other unexpected errors are rethrown.
- Empty matches are not errors; they return the no-files text result.
+4 -4
View File
@@ -7,7 +7,7 @@
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/read.md`
- Key collaborators:
- `packages/coding-agent/src/tools/path-utils.ts` — split `path` from trailing selectors; normalize local paths.
- `packages/coding-agent/src/tools/archive-reader.ts` — detect `archive.ext:inner/path`, index archives, list/read entries.
- `packages/coding-agent/src/utils/zip.ts` — the unified ZIP/tar wrapper: detect `archive.ext:inner/path`, index archives, list/read entries.
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite targets, parse selectors, render tables.
- `packages/coding-agent/src/tools/fetch.ts` — URL parsing, fetch/render pipeline, URL cache/artifacts.
- `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`.
@@ -97,7 +97,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts
### Local text files
- No selector: if summarization is enabled and the file is small enough, `#trySummarize()` calls `summarizeCode()`.
- Guards: file size `<= 2 MiB` (`MAX_SUMMARY_BYTES`), line count `<= 20_000` (`MAX_SUMMARY_LINES`).
- Summary output keeps selected declarations and replaces elided spans with `...` or merged brace-pair lines containing `..`. When at least one span is elided, the text content ends with a footer like `[NN lines elided; re-read needed ranges, e.g. <path>:5-16,40-80]` using concrete ranges from the actual elisions.
- Summary output keeps selected declarations and replaces elided spans with `…` or merged brace-pair lines containing `{ … }`. When at least one span is elided, the text content ends with a footer like `[…NNln elided; re-read needed ranges, e.g. <path>:5-16,40-80]` using concrete ranges from the actual elisions.
- When an elided block sits between matching brace lines, `#renderSummary()` may merge them into one anchored line rather than emitting separate opener/closer lines.
- Explicit selector or summarization miss: streamed text read.
- Default open-ended limit is `min(session setting read.defaultLimit, DEFAULT_MAX_LINES)`.
@@ -123,7 +123,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts
- Syntax: `archive.ext`, `archive.ext:path/inside`, `archive.ext:path/inside:50-60`.
- `openArchive()` branches by format:
- tar/tgz reads the whole archive into memory (capped at `MAX_TAR_ARCHIVE_BYTES = 256 MiB`) and indexes it with `new Bun.Archive(bytes)`
- zip is indexed via ranged central-directory reads (`readZipEntries()`); entries are inflated on demand with `fflate.inflateSync()`, with declared member sizes capped at `MAX_ARCHIVE_MEMBER_BYTES = 64 MiB`
- zip is indexed via ranged central-directory reads (`readZipEntries()`); members are inflated on demand with raw DEFLATE (`node:zlib`), and the read tool caps individual extraction at `MAX_ARCHIVE_MEMBER_BYTES = 64 MiB` in `ArchiveReader.readFile()`
- Archive paths normalize `/`, drop `.` segments, and reject `..`.
- Directory reads list immediate children; files show `name` plus ` (size)` when size > 0.
- Directory listing default limit is `500` entries in `#readArchiveDirectory()`.
@@ -242,7 +242,7 @@ Notes: ...
- URL mode performs HTTP fetches, binary refetches, and alternate-endpoint probes.
- Subprocesses / native bindings
- Uses Bun SQLite for `.db`/`.sqlite*`.
- Uses `Bun.Archive` for tar/tgz and `fflate` for zip.
- Uses `Bun.Archive` for tar/tgz; ZIP is framed in `packages/coding-agent/src/utils/zip.ts` over the `node:zlib` DEFLATE codec.
- URL HTML rendering can delegate into site handlers and HTML-to-text backends from `packages/coding-agent/src/tools/fetch.ts`.
- Session state
- Records whole-file snapshots of local text reads into `session.fileSnapshotStore` for later stale-anchor recovery.
+15 -16
View File
@@ -34,9 +34,8 @@
## Flow
1. Preview-producing code can call `queueResolveHandler(...)` with a label, source tool name, `apply(reason, extra?)` callback, and optional `reject(reason, extra?)` callback.
2. Modes can also register a standing resolve handler through `session.setStandingResolveHandler(...)`; `resolve.execute()` consults it only when no queued invoker is active.
3. `queueResolveHandler(...)` asks the session for a forced `resolve` tool choice and pushes it into the tool-choice queue with `pushOnce(...)`.
4. The queued entry is marked `now: true`; if the model rejects that forced tool choice, `onRejected` returns `requeue`, so the reminder comes back.
5. `queueResolveHandler(...)` also injects a `resolve-reminder` steering message:
3. `queueResolveHandler(...)` registers a non-forcing pending invoker on the session's tool-choice queue under a unique `pending-action:<sourceTool>:<seq>` id. It does NOT force a tool choice and does NOT steer a reminder.
4. While a preview is pending, the session's `getToolChoice` (`nextToolChoiceDirective`) returns a `SoftToolRequirement` (`toolName: "resolve"`) carrying the resolve reminder — a non-consuming peek. The agent runtime injects the reminder once and forces `tool_choice: resolve` for one turn only if the model declines (see `docs/resolve-tool-runtime.md`). The reminder text is:
```text
<system-reminder>
@@ -44,27 +43,27 @@ This is a preview. Call the `resolve` tool to apply or discard these changes.
</system-reminder>
```
6. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and fetches `session.peekQueueInvoker?.() ?? session.peekStandingResolveHandler?.()`.
7. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds.
8. Otherwise it invokes the current handler with the full params object.
9. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`.
10. For `apply`, it calls the producer's `apply(reason, extra)` callback.
11. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The queued preview integration uses this to re-push the resolve directive and steering reminder so the action remains pending. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: <message>")`.
12. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message.
13. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool.
5. When `resolve.execute()` runs, it wraps the call in `untilAborted(...)` and dispatches via `session.peekQueueInvoker?.() ?? session.peekPendingInvoker?.() ?? session.peekStandingResolveHandler?.()`.
6. If no invoker exists, `apply` throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`; `discard` instead returns a success payload `Nothing to discard; no pending action remains.` because the desired end-state (no staged change) already holds.
7. Otherwise it invokes the current handler with the full params object.
8. `runResolveInvocation(...)` builds base details from `action`, `reason`, `extra`, `sourceToolName`, and `label`.
9. For `apply`, it calls the producer's `apply(reason, extra)` callback.
10. If `apply` throws, `runResolveInvocation(...)` calls `onApplyError` when present. The pending-preview integration uses this to re-register the same pending invoker (same id) so the action remains pending for discard or retry. Non-`ToolError` exceptions are wrapped as `ToolError("Apply failed: <message>")`.
11. For `discard`, it calls `reject(reason, extra)` when provided. If no reject callback exists or it returns `undefined`, `resolve` fabricates the default discard message.
12. Before returning callback results, it merges resolve metadata into `result.details` so renderer/UI code can show the action, label, and originating tool.
## Modes / Variants
- `apply`: runs the pending action's `apply(reason, extra?)` callback and returns its content.
- `discard` with reject callback: runs `reject(reason, extra?)` and returns that callback's content when non-`undefined`.
- `discard` without reject callback, or with a reject callback returning `undefined`: returns the built-in `Discarded: ...` text payload.
- `discard` with no pending action at all: returns `Nothing to discard; no pending action remains.` as a success result.
- Queued handler: one in-flight tool-choice queue invoker, used by preview producers such as `ast_edit`.
- Pending invoker: a non-forcing preview invoker in the pending-invoker registry (separate from the consuming directive queue), used by preview producers such as `ast_edit`.
- Standing handler: long-lived mode-owned handler, used as a fallback when no queue invoker is active.
## Side Effects
- Session state
- Consumes or invokes the current pending action through the session tool-choice queue or standing handler; `resolve` does not maintain its own stack.
- Adds a `resolve-reminder` steering message when a queued preview is registered.
- Consumes or invokes the current pending action through the pending invoker, tool-choice queue, or standing handler; `resolve` does not maintain its own stack.
- Does not steer a reminder or force a tool choice for previews — the reminder rides a non-forcing `SoftToolRequirement` and the agent runtime forces `resolve` only on non-compliance.
- On queued apply failure, requeues the same pending action before rethrowing so the model can discard or retry instead of losing the pending preview.
- User-visible prompts / interactive UI
- The visible effect depends on the preview-producing tool and the resolve renderer.
@@ -74,8 +73,8 @@ This is a preview. Call the `resolve` tool to apply or discard these changes.
## Limits & Caps
- Hidden tool: `ResolveTool.hidden = true`, and normal requested-tool filtering removes `resolve`; `createTools(...)` adds it separately as a hidden tool.
- Exactly one active queue invoker is consulted per call via `session.peekQueueInvoker()`; if none exists, one standing handler may be consulted via `session.peekStandingResolveHandler()`.
- There is no independent queue depth cap in this tool; ordering follows the shared tool-choice queue and mode-owned standing handler lifecycle.
- Per call, `resolve` consults the in-flight hard-directive queue invoker (`session.peekQueueInvoker()`), then the non-forcing pending-preview invoker (`session.peekPendingInvoker()`), then a standing handler (`session.peekStandingResolveHandler()`).
- There is no independent depth cap in this tool; pending previews stack as unique-keyed invokers (resolved head-first), separate from the consuming directive queue and the mode-owned standing handler lifecycle.
## Errors
- `apply` with no pending action or standing handler: throws `ToolError("No pending action to resolve. Nothing to apply or discard.")`. `discard` in the same situation succeeds with `Nothing to discard; no pending action remains.` instead of erroring.
+4 -4
View File
@@ -6,7 +6,7 @@
- Entry: `packages/coding-agent/src/tools/write.ts`
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/write.md`
- Key collaborators:
- `packages/coding-agent/src/tools/archive-reader.ts` — parse `archive.ext:entry` selectors.
- `packages/coding-agent/src/utils/zip.ts` — the unified ZIP/tar wrapper: parse `archive.ext:entry` selectors and rewrite the archive whole.
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite paths and perform row insert/update/delete.
- `packages/coding-agent/src/tools/conflict-detect.ts` — parse `conflict://` URIs and splice recorded merge-conflict regions.
- `packages/coding-agent/src/lsp/index.ts` — format-on-write and diagnostics writethrough.
@@ -56,10 +56,10 @@ Single-shot result.
1. `WriteTool.execute()` in `packages/coding-agent/src/tools/write.ts` strips pasted `[PATH#HASH]` headers and `LINE:` hashline prefixes from `content` when the session is in hashline display mode.
2. If `path` is an internal URL whose handler exposes `write`, the tool delegates directly to `handler.write(...)` and returns.
3. `conflict://...` paths are handled next by the merge-conflict resolver. Scope reads such as `conflict://<id>/ours` are rejected as read-only; writable conflict URIs must omit the scope.
4. It calls `#resolveArchiveWritePath()` next. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/tools/archive-reader.ts`, checks candidate archive files on disk (longest match first), and falls back to the shortest candidate archive path even when the archive file does not exist yet.
4. It calls `#resolveArchiveWritePath()` next. That uses `parseArchivePathCandidates()` from `packages/coding-agent/src/utils/zip.ts`, checks candidate archive files on disk (longest match first), and falls back to the shortest candidate archive path even when the archive file does not exist yet.
5. Archive writes call `enforcePlanModeWrite(..., { op: exists ? "update" : "create" })`, then `#writeArchiveEntry()`.
- The parent directory of the archive file is created with `fs.mkdir(..., { recursive: true })`.
- `.zip` archives are read with `fflate.unzipSync()`, the target entry is replaced in an in-memory map, and the archive is rewritten with `fflate.zipSync()` + `Bun.write()`.
- `.zip` archives are read with `unzip()`, the target entry is replaced in an in-memory map, and the archive is reframed with `zip()` + `Bun.write()` (both from `packages/coding-agent/src/utils/zip.ts`, over `node:zlib`).
- `.tar`, `.tar.gz`, and `.tgz` archives are read with `Bun.Archive`, existing entries are copied into an object map, the target entry is replaced, and `Bun.Archive.write()` rewrites the archive.
- `invalidateFsScanAfterWrite()` runs on the archive file path.
6. If the path is not treated as an archive, `execute()` calls `#resolveSqliteWritePath()`. That uses `parseSqlitePathCandidates()` and `isSqliteFile()` from `packages/coding-agent/src/tools/sqlite-reader.ts`. Existing non-SQLite files suppress the SQLite path interpretation.
@@ -147,7 +147,7 @@ content: ""
- May chmod a shebang file executable after a successful plain-file write.
- Subprocesses / native bindings
- Uses Bun SQLite bindings via `bun:sqlite`.
- Uses Bun archive APIs and lazily imports `fflate` for ZIP reads/writes.
- Uses `Bun.Archive` for tar; ZIP read/write is framed in `packages/coding-agent/src/utils/zip.ts` over the `node:zlib` DEFLATE codec.
- May talk to configured LSP servers through `packages/coding-agent/src/lsp/index.ts`.
- Session state (transcript, memory, jobs, checkpoints, registries)
- Invalidates shared filesystem scan cache entries through `invalidateFsScanAfterWrite()`.
+1 -1
View File
@@ -262,7 +262,7 @@ width models in measure-vs-slice produced crashes.
- Fast path: printable ASCII is one cell per code unit.
- Anything past the ASCII prefix measures through `Bun.stringWidth` (CSI/OSC
stripped to zero); tabs are added back at `getDefaultTabWidth()` columns.
stripped to zero); tabs are added back at the fixed `DEFAULT_TAB_WIDTH` columns.
- OSC 66 sized spans are added back as `scale × (explicit w ?? payload width)` —
`Bun.stringWidth` would otherwise strip the whole span to zero.
+16 -16
View File
@@ -5,7 +5,6 @@
"type": "module",
"packageManager": "bun@1.3.14",
"patchedDependencies": {
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch",
"@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch"
},
"workspaces": {
@@ -25,18 +24,18 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "16.0.7",
"@oh-my-pi/omp-stats": "16.0.7",
"@oh-my-pi/pi-agent-core": "16.0.7",
"@oh-my-pi/pi-ai": "16.0.7",
"@oh-my-pi/pi-catalog": "16.0.7",
"@oh-my-pi/pi-coding-agent": "16.0.7",
"@oh-my-pi/pi-mnemopi": "16.0.7",
"@oh-my-pi/pi-natives": "16.0.7",
"@oh-my-pi/pi-tui": "16.0.7",
"@oh-my-pi/pi-utils": "16.0.7",
"@oh-my-pi/pi-wire": "16.0.7",
"@oh-my-pi/snapcompact": "16.0.7",
"@oh-my-pi/hashline": "16.1.2",
"@oh-my-pi/omp-stats": "16.1.2",
"@oh-my-pi/pi-agent-core": "16.1.2",
"@oh-my-pi/pi-ai": "16.1.2",
"@oh-my-pi/pi-catalog": "16.1.2",
"@oh-my-pi/pi-coding-agent": "16.1.2",
"@oh-my-pi/pi-mnemopi": "16.1.2",
"@oh-my-pi/pi-natives": "16.1.2",
"@oh-my-pi/pi-tui": "16.1.2",
"@oh-my-pi/pi-utils": "16.1.2",
"@oh-my-pi/pi-wire": "16.1.2",
"@oh-my-pi/snapcompact": "16.1.2",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -55,21 +54,22 @@
"@typescript/native-preview": "7.0.0-dev.20260609.1",
"@xterm/headless": "^6.0.0",
"arktype": "^2.2.0",
"beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"date-fns": "^4.4.0",
"diff": "^9.0.0",
"fflate": "0.8.3",
"fastembed": "2.1.0",
"fast-xml-parser": "^5.9.0",
"ghostty-web": "^0.4.0",
"handlebars": "^4.7.9",
"linkedom": "^0.18.12",
"lint-staged": "^17.0.7",
"lru-cache": "11.5.1",
"lucide-react": "^1.17.0",
"mammoth": "^1.12.0",
"marked": "^18.0.5",
"markit-ai": "0.5.3",
"mupdf": "^1.27.0",
"onnxruntime-node": "1.26.0",
"partial-json": "^0.1.7",
"postcss": "^8.5.15",
@@ -171,7 +171,7 @@
"lint:py": "ruff check python && ruff format --check python",
"fix:py": "ruff check --fix python && ruff format python",
"prepublishOnly": "bun run check",
"prepare": "bun run generate-docs-index && bun run build-tool-views",
"prepare": "bun run build-tool-views",
"publish": "bun run prepublishOnly && npm publish -ws --access public",
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
"release": "bun scripts/release.ts",
+30
View File
@@ -2,6 +2,36 @@
## [Unreleased]
## [16.1.2] - 2026-06-19
### Fixed
- Ensure deep-cloning of tool-call arguments respects own enumerable properties
- Prevent direct object references between agent message snapshots and streaming events
## [16.1.0] - 2026-06-19
### Added
- Added `SoftToolRequirement` support to `getToolChoice`: a host can require a tool by returning a soft requirement instead of a hard `ToolChoice`. The loop injects the supplied reminder once (leaving `tool_choice` on auto), and escalates to a one-turn forced choice — skipping any detour tool batch — only if the model fails to call the required tool, avoiding the provider message-cache invalidation of forcing every turn.
- Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs
### Fixed
- Improved token estimation accuracy for compaction summaries containing multi-block content
## [16.0.11] - 2026-06-19
### Changed
- Updated the display format for truncated file operation summaries
## [16.0.8] - 2026-06-18
### Fixed
- Stopped the compaction `<files>` summary from tracking `scheme://` URLs — internal URIs (`conflict://`, `artifact://`, `local://`, `history://`, …) and web URLs are no longer recorded as files, and legacy entries rehydrated from older compaction summaries are dropped.
## [16.0.6] - 2026-06-18
### Added
+2 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "16.0.7",
"version": "16.1.2",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -39,6 +39,7 @@
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"@oh-my-pi/snapcompact": "catalog:",
"@opentelemetry/api": "catalog:"
},
+195 -43
View File
@@ -14,8 +14,11 @@ import {
resolveApiKeyOnce,
seedApiKeyResolver,
streamSimple,
stripSchemaDescriptions,
type ToolChoice,
type ToolResultMessage,
type TSchema,
toolWireSchema,
validateToolArguments,
zodToWireSchema,
} from "@oh-my-pi/pi-ai";
@@ -37,7 +40,8 @@ import {
signalListLabel,
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
import {
type AgentTelemetry,
@@ -65,6 +69,7 @@ import type {
AsideMessage,
StreamFn,
} from "./types";
import { isSoftToolRequirement } from "./types";
import { yieldIfDue } from "./utils/yield";
/** Stop-details marker for a provider error after assistant content/tool args already streamed. */
@@ -81,6 +86,27 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
*/
const MAX_PAUSED_TURN_CONTINUATIONS = 8;
/**
* Cap on consecutive forced escalations for a single soft tool requirement.
* A forced `toolChoice` guarantees the call, so this is purely defensive: if a
* model somehow never satisfies the requirement, give up forcing rather than
* spin the loop. Reset whenever the requirement id changes or clears.
*/
const MAX_SOFT_TOOL_ESCALATIONS = 3;
/**
* Whether a hard `toolChoice` for a turn conflicts with a pending soft tool
* requirement — i.e. forbids tools (`"none"`) or forces a *different* specific
* tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model
* satisfy the requirement, so they do not conflict and the soft gate stays active.
*/
function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean {
if (choice === undefined) return false;
if (typeof choice === "string") return choice === "none";
const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
return name !== requiredTool;
}
/**
* Cadence (ms) for polling queued steering while an `interruptible` tool is in
* flight, so a steer cuts the wait short instead of sitting idle until the
@@ -124,22 +150,6 @@ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefi
type AssistantContentBlock = AssistantMessage["content"][number];
type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
type CloneableRecord = Record<string, unknown>;
function cloneUnknown(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneUnknown);
if (!value || typeof value !== "object") return value;
const source = value as CloneableRecord;
const out: CloneableRecord = {};
for (const [key, child] of Object.entries(source)) {
out[key] = cloneUnknown(child);
}
return out;
}
function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
}
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
switch (block.type) {
@@ -150,7 +160,7 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
case "redactedThinking":
return { ...block };
case "toolCall":
return { ...block, arguments: cloneToolArguments(block.arguments) };
return { ...block, arguments: structuredCloneJSON(block.arguments) };
}
}
@@ -166,10 +176,19 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
};
}
function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
/**
* Deep-clone an assistant streaming event so subscribers get an immutable view.
* Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
* (the `message_update` push sites alias it as the event's `message`) so the
* identical partial is not deep-cloned twice per streaming delta.
*/
function snapshotAssistantMessageEvent(
event: AssistantMessageEvent,
partialSnapshot?: AssistantMessage,
): AssistantMessageEvent {
switch (event.type) {
case "start":
return { ...event, partial: snapshotAssistantMessage(event.partial) };
return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
case "text_start":
case "text_delta":
case "text_end":
@@ -178,12 +197,12 @@ function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantM
case "thinking_end":
case "toolcall_start":
case "toolcall_delta":
return { ...event, partial: snapshotAssistantMessage(event.partial) };
return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
case "toolcall_end":
return {
...event,
toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
partial: snapshotAssistantMessage(event.partial),
partial: partialSnapshot ?? snapshotAssistantMessage(event.partial),
};
case "done":
return { ...event, message: snapshotAssistantMessage(event.message) };
@@ -517,9 +536,13 @@ function normalizeMessagesForProvider(
});
}
export const INTENT_FIELD = "_i";
const INTENT_FIELD_DESCRIPTION = "concise intent";
function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown {
function injectIntentIntoSchema(
schema: unknown,
mode: "require" | "optional" = "require",
describeIntent = true,
): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
const schemaRecord = schema as Record<string, unknown>;
const propertiesValue = schemaRecord.properties;
@@ -545,10 +568,9 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
return {
...schemaRecord,
properties: {
[INTENT_FIELD]: {
type: "string",
description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
},
[INTENT_FIELD]: describeIntent
? { type: "string", description: INTENT_FIELD_DESCRIPTION }
: { type: "string" },
...properties,
},
...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
@@ -559,26 +581,36 @@ export function normalizeTools(
tools: AgentContext["tools"],
injectIntent: boolean,
exampleDialect?: Dialect,
pruneDescriptions = false,
): Context["tools"] {
injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
return tools?.map(t => {
const intentMode = resolveIntentMode(t.intent);
const doInjectIntent = injectIntent && intentMode !== "omit";
// When the full catalog is rendered into the system prompt, ship the tool
// specs without their descriptions (top-level + nested schema annotations)
// so they are not duplicated on the wire. Strip the STABLE wire schema (the
// memoized `stripSchemaDescriptions` result is reused across requests), then
// re-inject `_i` (without its hint, which `describeIntent: false` omits) so
// intent tracing keeps the field while no descriptions ride the wire.
if (pruneDescriptions) {
let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
return { ...t, parameters, description: "" };
}
let parameters: TSchema = t.parameters;
if (injectIntent && intentMode !== "omit") {
if (doInjectIntent) {
if (isZodSchema(parameters)) {
const wired = zodToWireSchema(parameters);
parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema;
} else if (isArkSchema(parameters)) {
const wired = arkToWireSchema(parameters);
parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
parameters = injectIntentIntoSchema(arkToWireSchema(parameters), intentMode) as TSchema;
} else {
parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
}
}
const description = t.description ?? "";
const injectExampleIntent = injectIntent && intentMode !== "omit";
const examplesBlock = exampleDialect
? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined)
? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
: "";
const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
return { ...t, parameters, description: finalDescription };
@@ -714,6 +746,19 @@ async function runLoopBody(
let harmonyTruncateResumeCount = 0;
let pausedTurnContinuations = 0;
// Soft tool requirement lifecycle (reminder → escalate; see SoftToolRequirement).
// `forcedToolChoice` carries a one-turn escalation into the next model call. It
// overrides the static toolChoice but NEVER the host's hard getToolChoice().
let softRequirementId: string | undefined;
let forcedToolChoice: ToolChoice | undefined;
let softEscalations = 0;
// Resolved once per logical turn at the fetch site below and reused across
// Harmony-leak re-samples (which re-enter the same turn) so the consuming
// getToolChoice is never advanced twice; the flag resets at the message boundary.
let hostToolChoice: ToolChoice | undefined;
let softRequiredTool: string | undefined;
let directiveResolvedForTurn = false;
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
@@ -749,6 +794,41 @@ async function runLoopBody(
await config.syncContextBeforeModelCall(currentContext);
}
// Resolve the per-turn tool-choice directive ONCE per logical turn. The
// host hard-choice path (getToolChoice → nextToolChoice) is CONSUMING — it
// advances a generator on every call — so Harmony-leak retries, which
// re-sample the same turn via `continue` without a turn_end, must reuse the
// values fetched on the first attempt rather than double-advancing it.
// Fetched here (after pending-message flush + context sync, immediately
// before the call) so a throw in between cannot wedge an in-flight
// directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement
// triggers the remind-then-escalate lifecycle: inject its reminder inline
// once per new id (toolChoice stays auto), and the gate below escalates to
// a forced choice only if the model declines. The host wrapper already
// dropped a soft requirement whose tool is inactive.
if (!directiveResolvedForTurn) {
const directive = signal?.aborted ? undefined : config.getToolChoice?.();
const softReq = isSoftToolRequirement(directive) ? directive : undefined;
hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
softRequiredTool = softReq?.toolName;
if (softReq !== undefined) {
if (softReq.id !== softRequirementId) {
softRequirementId = softReq.id;
softEscalations = 0;
for (const reminder of softReq.reminder) {
stream.push({ type: "message_start", message: reminder });
stream.push({ type: "message_end", message: reminder });
currentContext.messages.push(reminder);
newMessages.push(reminder);
}
}
} else {
softRequirementId = undefined;
softEscalations = 0;
}
directiveResolvedForTurn = true;
}
// Stream assistant response
let recovered: HarmonyRecoveredToolCall | undefined;
let message: AssistantMessage;
@@ -763,6 +843,8 @@ async function runLoopBody(
stepCounter,
streamFn,
harmonyRetryAttempt,
hostToolChoice,
forcedToolChoice,
);
harmonyRetryAttempt = 0;
harmonyTruncateResumeCount = 0;
@@ -779,6 +861,10 @@ async function runLoopBody(
recovered = err.recovered;
message = recovered.message;
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
// A recovered message completes the turn, so the abort-retry counter
// resets like the normal success path (the truncate-resume counter
// keeps accumulating for its cross-turn cap).
harmonyRetryAttempt = 0;
} else {
if (harmonyRetryAttempt >= 2) {
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
@@ -799,6 +885,14 @@ async function runLoopBody(
}
newMessages.push(message);
// The escalation choice (if any) applied to the call above; clear it so
// only the single escalation turn carries the forced choice.
forcedToolChoice = undefined;
// A fresh logical turn re-resolves the directive next iteration; a Harmony
// retry `continue`s before this line and keeps the cached value.
directiveResolvedForTurn = false;
if (message.stopReason === "error" || message.stopReason === "aborted") {
// Create placeholder tool results for any tool calls in the aborted message
// This maintains the tool_use/tool_result pairing that the API requires
@@ -851,8 +945,51 @@ async function runLoopBody(
hasMoreToolCalls = false;
}
// A turn is compliant ONLY when it calls the required tool and nothing
// else — mirroring the forced-tool_choice turn, which can emit only that
// tool. A required+detour batch is treated as non-compliant so detour
// tools never run side effects while the requirement is still pending.
const calledOnlyRequiredTool =
softRequiredTool !== undefined &&
toolCalls.length > 0 &&
toolCalls.every(toolCall => toolCall.name === softRequiredTool);
const softGateActive =
softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
if (softNonCompliant && softRequiredTool !== undefined) {
if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) {
throw new Error(
`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
);
}
// A soft-required tool is pending but the model called something else
// (or yielded). Do NOT execute the detour — pair each call with a
// skipped result and force the required tool next turn. This is the
// only turn that changes toolChoice; a model that complies with the
// reminder pays no message-cache invalidation. Re-engage so the loop
// never yields while the requirement is unmet.
for (const toolCall of toolCalls) {
const result = createAbortedToolResult(
toolCall,
stream,
"skipped",
`Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`,
);
currentContext.messages.push(result);
newMessages.push(result);
toolResults.push(result);
recordSkippedTool(telemetry, {
toolCallId: toolCall.id,
toolName: toolCall.name,
status: "skipped",
});
}
forcedToolChoice = { type: "tool", name: softRequiredTool };
softEscalations++;
hasMoreToolCalls = true;
} else if (hasMoreToolCalls) {
const executionResult = await executeToolCalls(
currentContext,
message,
@@ -1000,6 +1137,8 @@ async function streamAssistantResponse(
stepCounter: StepCounter,
streamFn?: StreamFn,
harmonyRetryAttempt = 0,
hostToolChoice?: ToolChoice,
forcedToolChoice?: ToolChoice,
): Promise<AssistantMessage> {
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages;
@@ -1013,6 +1152,9 @@ async function streamAssistantResponse(
const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
// Owned/in-band dialects carry the catalog in the prompt as text and send no
// native `tools`, so description pruning only applies to native tool calling.
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
// Build LLM context — append-only mode caches system prompt + tools
// AND keeps an append-only message log so prior-turn bytes are stable.
let llmContext: Context;
@@ -1021,12 +1163,13 @@ async function streamAssistantResponse(
llmContext = config.appendOnlyContext.build(context, {
intentTracing: !!config.intentTracing,
exampleDialect,
pruneToolDescriptions,
});
} else {
llmContext = {
systemPrompt: context.systemPrompt,
messages: normalizedMessages,
tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect),
tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
};
}
if (config.transformProviderContext) {
@@ -1049,7 +1192,6 @@ async function streamAssistantResponse(
const streamFunction = streamFn || streamSimple;
const dynamicToolChoice = config.getToolChoice?.();
const dynamicReasoning = config.getReasoning?.();
const dynamicDisableReasoning = config.getDisableReasoning?.();
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
@@ -1084,7 +1226,7 @@ async function streamAssistantResponse(
const effectiveTemperature =
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
// Owned tool calling sends no native tools, so any tool_choice would error.
const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice);
const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
const effectiveReasoning = dynamicReasoning ?? config.reasoning;
const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
@@ -1263,10 +1405,15 @@ async function streamAssistantResponse(
if (addedPartial) {
context.messages[context.messages.length - 1] = partialMessage;
completedToolCallIds.clear();
// `message` and `assistantMessageEvent.partial` intentionally share one
// immutable snapshot of the streaming partial: every message_update
// consumer treats both as read-only, so cloning the identical partial
// twice per delta was pure waste.
const messageSnapshot = snapshotAssistantMessage(partialMessage);
stream.push({
type: "message_update",
assistantMessageEvent: snapshotAssistantMessageEvent(event),
message: snapshotAssistantMessage(partialMessage),
assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
message: messageSnapshot,
});
} else {
context.messages.push(partialMessage);
@@ -1291,10 +1438,15 @@ async function streamAssistantResponse(
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
config.onAssistantMessageEvent?.(partialMessage, event);
// `message` and `assistantMessageEvent.partial` intentionally share one
// immutable snapshot of the streaming partial: every message_update
// consumer treats both as read-only, so cloning the identical partial
// twice per delta was pure waste.
const messageSnapshot = snapshotAssistantMessage(partialMessage);
stream.push({
type: "message_update",
assistantMessageEvent: snapshotAssistantMessageEvent(event),
message: snapshotAssistantMessage(partialMessage),
assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
message: messageSnapshot,
});
}
break;
+21 -7
View File
@@ -39,7 +39,9 @@ import type {
AsideMessage,
StreamFn,
ToolCallContext,
ToolChoiceDirective,
} from "./types";
import { isSoftToolRequirement } from "./types";
import { EventLoopKeepalive } from "./utils/yield";
/**
@@ -223,6 +225,12 @@ export interface AgentOptions {
/** Enable intent tracing schema injection/stripping in the harness. */
intentTracing?: boolean;
/**
* Strip tool descriptions from provider-bound tool specs (top-level + nested
* schema annotations). Use when the full catalog is rendered into the system
* prompt so descriptions are not duplicated on the wire. Native tool calling only.
*/
pruneToolDescriptions?: boolean;
/** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
dialect?: Dialect;
/**
@@ -232,8 +240,8 @@ export interface AgentOptions {
* the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}.
*/
abortOnFabricatedToolResult?: boolean;
/** Dynamic tool choice override, resolved per LLM call. */
getToolChoice?: () => ToolChoice | undefined;
/** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
getToolChoice?: () => ToolChoiceDirective | undefined;
/**
* Cursor exec handlers for local tool execution.
@@ -336,9 +344,10 @@ export class Agent {
#preferWebsockets?: boolean;
#transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
#intentTracing: boolean;
#pruneToolDescriptions: boolean;
#dialect?: Dialect;
#abortOnFabricatedToolResult?: boolean;
#getToolChoice?: () => ToolChoice | undefined;
#getToolChoice?: () => ToolChoiceDirective | undefined;
#onPayload?: SimpleStreamOptions["onPayload"];
#onResponse?: SimpleStreamOptions["onResponse"];
#onSseEvent?: SimpleStreamOptions["onSseEvent"];
@@ -407,6 +416,7 @@ export class Agent {
this.#preferWebsockets = opts.preferWebsockets;
this.#transformToolCallArguments = opts.transformToolCallArguments;
this.#intentTracing = opts.intentTracing === true;
this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
this.#dialect = opts.dialect;
this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
this.#getToolChoice = opts.getToolChoice;
@@ -1009,10 +1019,13 @@ export class Agent {
}
: undefined;
const getToolChoice = () => {
const queuedToolChoice = this.#getToolChoice?.();
if (queuedToolChoice !== undefined) {
return refreshToolChoiceForActiveTools(queuedToolChoice, this.#state.tools);
const getToolChoice = (): ToolChoiceDirective | undefined => {
const queued = this.#getToolChoice?.();
if (queued !== undefined) {
if (isSoftToolRequirement(queued)) {
return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined;
}
return refreshToolChoiceForActiveTools(queued, this.#state.tools);
}
return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
};
@@ -1059,6 +1072,7 @@ export class Agent {
cursorOnToolResult,
transformToolCallArguments: this.#transformToolCallArguments,
intentTracing: this.#intentTracing,
pruneToolDescriptions: this.#pruneToolDescriptions,
dialect: this.#dialect,
abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
appendOnlyContext: this.#appendOnlyContext,
+5 -1
View File
@@ -35,6 +35,8 @@ export interface BuildOptions {
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
intentTracing: boolean;
exampleDialect?: Dialect;
/** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
pruneToolDescriptions?: boolean;
}
/**
@@ -270,7 +272,8 @@ export class AppendOnlyContextManager {
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
const systemPrompt = [...context.systemPrompt];
const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? [];
const tools =
normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? [];
return {
systemPrompt,
tools,
@@ -291,6 +294,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
})),
i: options.intentTracing,
ex: options.exampleDialect,
pd: options.pruneToolDescriptions,
});
let hash = 0;
for (let i = 0; i < payload.length; i++) {
+10 -3
View File
@@ -326,9 +326,16 @@ export function estimateTokens(message: AgentMessage): number {
case "branchSummary":
case "compactionSummary": {
fragments.push(message.summary);
if (message.role === "compactionSummary" && message.images) {
// Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
if (message.role === "compactionSummary") {
if (message.blocks) {
for (const block of message.blocks) {
if (block.type === "text") fragments.push(block.text);
else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
}
} else if (message.images) {
// Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
}
}
break;
}
+21 -9
View File
@@ -51,7 +51,11 @@ export interface CompactionSummaryMessage {
shortSummary?: string;
tokensBefore: number;
providerPayload?: ProviderPayload;
/** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */
/** Runtime-only ordered archive blocks for snapcompact: old text region,
* imaged middle, then new text region. When present, `summary` is already
* the final lead-in text (no legacy wrapper applied). */
blocks?: (TextContent | ImageContent)[];
/** Snapcompact image blocks, kept for display counts / legacy consumers. */
images?: ImageContent[];
timestamp: number;
}
@@ -101,14 +105,19 @@ export function createCompactionSummaryMessage(
shortSummary?: string,
providerPayload?: ProviderPayload,
images?: ImageContent[],
blocks?: (TextContent | ImageContent)[],
): CompactionSummaryMessage {
const imageBlocks =
blocks?.filter((block): block is ImageContent => block.type === "image") ??
(images && images.length > 0 ? images : undefined);
return {
role: "compactionSummary",
summary,
shortSummary,
tokensBefore,
providerPayload,
images: images && images.length > 0 ? images : undefined,
blocks: blocks && blocks.length > 0 ? blocks : undefined,
images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
timestamp: new Date(timestamp).getTime(),
};
}
@@ -182,13 +191,16 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
case "compactionSummary":
return {
role: "user",
content: [
{
type: "text" as const,
text: renderCompactionSummaryContext(message.summary),
},
...(message.images ?? []),
],
content:
message.blocks !== undefined
? [{ type: "text" as const, text: message.summary }, ...message.blocks]
: [
{
type: "text" as const,
text: renderCompactionSummaryContext(message.summary),
},
...(message.images ?? []),
],
attribution: "agent",
providerPayload: message.providerPayload,
timestamp: message.timestamp,
+97 -15
View File
@@ -30,6 +30,24 @@ export interface PruneConfig {
supersedeKey?: SupersedeKeyFn;
/** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
pruneUseless?: boolean;
/**
* Compaction boundary: the `firstKeptEntryId` of the latest compaction on
* the branch. Entries at indices BEFORE this id are summarized away and never
* sent to the model, so mutating them only churns persisted history without
* shrinking the prompt — they are skipped. Undefined = no compaction (the
* whole branch is sent).
*/
keepBoundaryId?: string;
/**
* Prompt-cache guard. When set, a tool result whose all-message suffix
* (tokens of every message after it) EXCEEDS this is part of the warm,
* already-sent cache prefix: mutating it forces the provider to re-write the
* whole suffix (cacheWrite premium). Such results — including superseded and
* useless ones, which otherwise bypass {@link protectTokens} — are left for
* compaction/shake (which rebuild the cache anyway) to reclaim. Undefined =
* no cache guard (legacy: superseded/useless prune at any depth).
*/
cacheWarmSuffixTokens?: number;
}
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
@@ -66,10 +84,22 @@ export interface SupersedePruneConfig {
pruneUseless?: boolean;
/** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
suffixTokenLimit?: number;
/** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
/**
* Prune all candidates when the last message is at least this old: the
* provider prompt cache is then cold, so re-writing it is free. MUST exceed
* the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted
* by the flush. Default 30 min — callers on long retention override it.
*/
idleFlushMs?: number;
/** Clock override for tests. */
now?: number;
/**
* Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
* before it are summarized away and never sent, so they are skipped in every
* path — including the idle flush — to avoid pointless history churn.
* Undefined = no compaction (the whole branch is sent).
*/
keepBoundaryId?: string;
/** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
protectedTools: ProtectedToolMatcher[];
}
@@ -103,6 +133,35 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
return Math.max(0, tokens - noticeTokens);
}
/**
* For each entry index, the estimated token total of all *message* entries
* strictly after it — how much prompt-cache content the provider must re-write
* (cacheWrite premium) if that entry is mutated in place. Used to keep prune
* mutations inside the cheap-to-recache tail.
*/
function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] {
const suffix = new Array<number>(entries.length);
let accumulated = 0;
for (let i = entries.length - 1; i >= 0; i--) {
suffix[i] = accumulated;
const entry = entries[i];
if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
}
return suffix;
}
/**
* Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
* before this index are summarized away by the latest compaction and never sent,
* so prune passes must not mutate them. Returns 0 when there is no boundary (no
* compaction → whole branch is sent) or the id is absent from `entries`.
*/
function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
if (keepBoundaryId === undefined) return 0;
const index = entries.findIndex(entry => entry.id === keepBoundaryId);
return index < 0 ? 0 : index;
}
interface SupersedeCandidate {
entry: SessionMessageEntry;
message: ToolResultMessage;
@@ -183,7 +242,8 @@ function collectUselessResults(
* flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
* candidate is pruned now only when the suffix after it is small (tail case —
* the read→edit→read loop) or when the context has been idle long enough that
* the provider cache is cold anyway (then ALL candidates flush).
* the provider cache is cold anyway (then all still-sent candidates flush).
* Never mutates entries before `keepBoundaryId` (summarized away — not sent).
*/
export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
const toolCallsById = collectToolCallsById(entries);
@@ -209,20 +269,24 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
const idle =
lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
let toPrune: SupersedeCandidate[];
if (idle) {
toPrune = candidates;
// Provider cache is cold (idle exceeds the retention TTL), so re-writing
// the sent region costs nothing. Entries before the compaction boundary
// are summarized away and never sent — skip them to avoid pointless churn.
toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex);
} else {
const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
// suffixTokens[i] = estimated tokens of all messages strictly after entry i.
const suffixTokens = new Array<number>(entries.length);
let accumulated = 0;
for (let i = entries.length - 1; i >= 0; i--) {
suffixTokens[i] = accumulated;
const entry = entries[i];
if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
}
toPrune = candidates.filter(candidate => suffixTokens[candidate.index] <= suffixTokenLimit);
// Mutating a candidate re-writes its suffix in the warm cache, so prune only
// when that suffix is small (cheap-to-recache tail) and the candidate sits
// at/after the compaction boundary.
const suffixTokens = computeMessageSuffixTokens(entries);
toPrune = candidates.filter(
candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
);
}
if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 };
@@ -262,6 +326,11 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
)
: undefined;
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
// All-message suffix per index, only when the cache guard is armed.
const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries);
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getToolResultMessage(entry);
@@ -275,10 +344,23 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
continue;
}
// Superseded and useless results are pruned first: they bypass the
// protect window (a stale copy of re-read content — or a result the
// tool itself flagged as carrying no information — is dead weight at
// any age).
// Prompt-cache guard: a result whose all-message suffix exceeds the
// warm-cache window sits in the already-sent cached prefix — mutating it
// re-writes the whole suffix (cacheWrite premium). Entries before the
// compaction boundary are summarized away (never sent). Both are skipped
// before any prune decision, so superseded/useless cannot reach a deep,
// still-cached copy; compaction/shake reclaim those when they rebuild.
const inWarmPrefix =
messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
if (inWarmPrefix || i < boundaryIndex) {
accumulatedTokens += tokens;
continue;
}
// Superseded and useless results bypass the age-based protect window
// (a stale re-read copy, or a result the tool flagged as uninformative,
// is dead weight at any age) — but only within the cache-warm tail: the
// guard above already excluded deeper, still-cached copies.
const superseded = supersededMessages?.has(message) ?? false;
const useless = uselessMessages?.has(message) ?? false;
const tooSmall = tokens < MIN_PRUNE_TOKENS;
+19
View File
@@ -31,6 +31,14 @@ export interface ShakeConfig {
protectedTools: ProtectedToolMatcher[];
/** Minimum token size for a fenced/XML block to be eligible. */
fenceMinTokens: number;
/**
* Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
* before it are summarized away and never sent, so they are skipped — shaking
* them only churns persisted history. Undefined = no compaction (whole branch
* is sent). Note: shake still elides the warm cached prefix at/after the
* boundary — that is its job as a compaction-class reducer.
*/
keepBoundaryId?: string;
}
/** Auto-shake config: protects the live tail, conservative thresholds. */
@@ -289,9 +297,20 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
const toolCallsById = collectToolCallsById(entries);
// Entries before the compaction boundary are summarized away and never sent —
// shaking them only churns persisted history (no prompt/cache effect).
const boundaryIndex =
config.keepBoundaryId === undefined
? 0
: Math.max(
0,
entries.findIndex(entry => entry.id === config.keepBoundaryId),
);
const regions: ShakeRegion[] = [];
for (let i = 0; i < n; i++) {
const entry = entries[i];
if (i < boundaryIndex) continue;
const toolResult = getToolResultMessage(entry);
// Useless-flagged results carry no information once consumed; they are
// eligible even inside the protect-recent window.
+27 -3
View File
@@ -76,6 +76,23 @@ export function stripReadSelector(path: string): string {
return splitReadSelector(path).path;
}
/**
* A real filesystem path never contains a `scheme://` URL. Tool-call paths that
* do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
* `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
* form — are session-scoped or remote resources, not files the post-compaction
* agent can re-ground on. Keep them out of the `<files>` summary.
*/
const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
/**
* Whether `path` references a `scheme://` URL (internal URI or web URL) rather
* than a filesystem path that belongs in the compaction `<files>` summary.
*/
export function isUrlSchemePath(path: string): boolean {
return URL_SCHEME_RE.test(path);
}
/**
* Extract file operations from tool calls in an assistant message.
*/
@@ -94,6 +111,10 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
// Internal URIs (conflict://, artifact://, local://, history://, …) and
// web URLs are not re-groundable files — keep them out of `<files>`.
if (isUrlSchemePath(path)) continue;
switch (block.name) {
case "read":
fileOps.read.add(stripReadSelector(path));
@@ -113,8 +134,11 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter(f => !modified.has(f)).sort();
// Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
// rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
// real files belong in `<files>`. New tool-call scans are already filtered.
const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles: readOnly, modifiedFiles };
}
@@ -149,7 +173,7 @@ export function formatFileOperations(
const all = [...mode.keys()].sort();
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
}
return prompt.render(fileOperationsTemplate, { files });
}
+50 -3
View File
@@ -36,6 +36,43 @@ export type StreamFn = (
*/
export type AsideMessage = AgentMessage | (() => AgentMessage | null);
/**
* A soft tool requirement: the host wants `toolName` called before the loop
* runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
* up front (changing `tool_choice` invalidates the provider message cache).
* Returned from {@link AgentLoopConfig.getToolChoice} in place of a hard
* {@link ToolChoice}: the loop injects `reminder` once when a new `id` becomes
* active, runs with `toolChoice` unchanged, and escalates to a one-turn forced
* choice only if the model fails to call `toolName`. Auto-clears when the host
* stops returning it or `toolName` is no longer an active tool.
*/
export interface SoftToolRequirement {
/** Discriminates a soft requirement from a hard {@link ToolChoice}. */
soft: true;
/**
* Stable id of the *current* requirement. The loop injects `reminder` when
* this id first becomes active and again whenever it changes (e.g. one
* stacked preview resolves and the next becomes the head), but never
* re-injects for an unchanged id across turns.
*/
id: string;
/** Tool that must be called before the loop runs other tools or yields. */
toolName: string;
/** Host-owned reminder messages, injected once per `id` activation. */
reminder: AgentMessage[];
}
/**
* A per-turn tool-choice directive: either a hard provider {@link ToolChoice}
* (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate).
*/
export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
/** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement {
return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
}
/**
* Configuration for the agent loop.
*/
@@ -203,6 +240,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* then strips from arguments before executing tools.
*/
intentTracing?: boolean;
/**
* Strip tool descriptions (top-level + nested schema annotations) from the
* provider-bound tool specs. Use when the full catalog is rendered into the
* system prompt instead, so descriptions are not duplicated on the wire.
*/
pruneToolDescriptions?: boolean;
/**
* Owned tool calling dialect.
*
@@ -246,10 +289,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
/**
* Dynamic tool choice override, resolved per LLM call.
* When set and returns a value, overrides the static `toolChoice`.
* Dynamic tool-choice directive, resolved once per turn. Returns a hard
* {@link ToolChoice} (applied verbatim, overriding the static `toolChoice`),
* a {@link SoftToolRequirement} (the loop reminds-then-escalates instead of
* forcing `tool_choice` immediately, so a model that complies with the
* reminder pays no message-cache invalidation), or `undefined` to fall back
* to the static `toolChoice`.
*/
getToolChoice?: () => ToolChoice | undefined;
getToolChoice?: () => ToolChoiceDirective | undefined;
/**
* Dynamic reasoning effort override, resolved per LLM call.
+137 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop";
import { agentLoop, agentLoopContinue, agentLoopDetailed } from "@oh-my-pi/pi-agent-core/agent-loop";
import type {
AgentContext,
AgentEvent,
@@ -12,6 +12,7 @@ import type {
import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { type } from "arktype";
import { createAssistantMessage, createUserMessage } from "./helpers";
@@ -2095,3 +2096,138 @@ describe("agentLoopContinue with AgentMessage", () => {
expect(finalMessage.errorMessage).toBe("Deadline exceeded");
});
});
describe("agentLoop streaming snapshots", () => {
it("deep-clones tool-call arguments into message_update snapshots, copying only own enumerable properties", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
// Arguments carry a nested object, a nested array, primitives, and an
// INHERITED enumerable property. The snapshot must deep-clone the own
// nested structures (fresh references), pass primitives through by value,
// and carry only OWN enumerable data — the inherited key must never leak
// into the immutable view subscribers receive.
const inheritedProto = { inheritedKey: "from-prototype" };
const innerArray = [2, 3];
const base: Record<string, unknown> = Object.create(inheritedProto);
const sourceArgs: Record<string, unknown> = Object.assign(base, {
nestedObj: { a: 1, b: "two" },
nestedArr: [1, innerArray, { c: 4 }],
num: 42,
str: "hi",
flag: true,
nul: null,
});
const toolCall = { type: "toolCall" as const, id: "tc-clone", name: "noop", arguments: sourceArgs };
// Turn 0 streams the tool call; the unknown tool produces an error result
// and the loop calls the model again — turn 1 returns plain text so the
// loop terminates instead of spinning forever.
let turn = 0;
const streamFn = () => {
const stream = new AssistantMessageEventStream();
if (turn++ === 0) {
const partial = createAssistantMessage([toolCall], "toolUse");
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_start", contentIndex: 0, partial });
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "{}", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial });
stream.push({ type: "done", reason: "toolUse", message: partial });
} else {
const partial = createAssistantMessage([{ type: "text", text: "done" }], "stop");
stream.push({ type: "start", partial });
stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial });
stream.push({ type: "done", reason: "stop", message: partial });
}
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("call noop")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
const toolUpdate = events.find(
(e): e is Extract<AgentEvent, { type: "message_update" }> =>
e.type === "message_update" &&
e.message.role === "assistant" &&
e.message.content.some(c => c.type === "toolCall"),
);
expect(toolUpdate).toBeDefined();
if (toolUpdate?.message.role !== "assistant") throw new Error("missing tool-call update");
const block = toolUpdate.message.content.find(c => c.type === "toolCall");
if (block?.type !== "toolCall") throw new Error("missing tool-call block");
const cloned: Record<string, unknown> = block.arguments;
// Fresh top-level object, not the source reference.
expect(cloned).not.toBe(sourceArgs);
// Own enumerable keys only — the inherited property must not appear.
expect("inheritedKey" in cloned).toBe(false);
expect(Object.hasOwn(cloned, "inheritedKey")).toBe(false);
// Nested object: deep-cloned (equal value, distinct reference).
expect(cloned.nestedObj).toEqual({ a: 1, b: "two" });
expect(cloned.nestedObj).not.toBe(sourceArgs.nestedObj);
// Nested array: deep-cloned recursively (distinct references at every level).
expect(Array.isArray(cloned.nestedArr)).toBe(true);
expect(cloned.nestedArr).toEqual([1, [2, 3], { c: 4 }]);
expect(cloned.nestedArr).not.toBe(sourceArgs.nestedArr);
expect((cloned.nestedArr as unknown[])[1]).not.toBe(innerArray);
// Primitives pass through by value.
expect(cloned.num).toBe(42);
expect(cloned.str).toBe("hi");
expect(cloned.flag).toBe(true);
expect(cloned.nul).toBeNull();
});
it("shares one immutable snapshot between message and assistantMessageEvent.partial on message_update", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createMockModel().model,
convertToLlm: identityConverter,
};
const livePartial = createAssistantMessage([{ type: "text", text: "Hi" }], "stop");
const streamFn = () => {
const stream = new AssistantMessageEventStream();
stream.push({ type: "start", partial: livePartial });
stream.push({ type: "text_start", contentIndex: 0, partial: livePartial });
stream.push({ type: "text_delta", contentIndex: 0, delta: "Hi", partial: livePartial });
stream.push({ type: "text_end", contentIndex: 0, content: "Hi", partial: livePartial });
stream.push({ type: "done", reason: "stop", message: livePartial });
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("say hi")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
const update = events.find(
(e): e is Extract<AgentEvent, { type: "message_update" }> => e.type === "message_update",
);
expect(update).toBeDefined();
if (!update) throw new Error("missing message_update");
const ame = update.assistantMessageEvent;
expect("partial" in ame).toBe(true);
if (!("partial" in ame)) throw new Error("expected a partial-bearing assistant event");
// Alias contract: `message` and `assistantMessageEvent.partial` are ONE
// shared snapshot...
expect(update.message).toBe(ame.partial);
// ...and that snapshot is an independent deep clone of the live streaming
// partial, never the mutable partial object itself.
expect(update.message).not.toBe(livePartial);
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context";
import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types";
import type { Message, Tool, ToolExample } from "@oh-my-pi/pi-ai";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { type } from "arktype";
// ---------------------------------------------------------------------------
@@ -603,8 +604,8 @@ describe("intent injection through build()", () => {
const result = mgr.build(ctx, { intentTracing: true });
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
expect(params?.properties).toBeDefined();
expect(params!.properties!._i).toBeDefined();
expect(params!.required).toContain("_i");
expect(params!.properties![INTENT_FIELD]).toBeDefined();
expect(params!.required).toContain(INTENT_FIELD);
});
it("materializes ArkType params and keeps `_i` first in authored order", () => {
@@ -616,8 +617,8 @@ describe("intent injection through build()", () => {
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
// `_i` must lead; authored order (path before content) is preserved rather
// than ArkType's alphabetized-by-hash order (content, path).
expect(Object.keys(params.properties ?? {})).toEqual(["_i", "path", "content"]);
expect(params.required).toContain("_i");
expect(Object.keys(params.properties ?? {})).toEqual([INTENT_FIELD, "path", "content"]);
expect(params.required).toContain(INTENT_FIELD);
});
it("omits `_i` when intentTracing is false", () => {
@@ -631,8 +632,8 @@ describe("intent injection through build()", () => {
const result = mgr.build(ctx, { intentTracing: false });
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
expect(params?.properties?._i).toBeUndefined();
expect(params?.required ?? []).not.toContain("_i");
expect(params?.properties?.[INTENT_FIELD]).toBeUndefined();
expect(params?.required ?? []).not.toContain(INTENT_FIELD);
});
it("intentTracing flip invalidates the fingerprint cache", () => {
@@ -685,7 +686,7 @@ describe("tool examples injection through build()", () => {
const result = mgr.build(ctx, { intentTracing: true, exampleDialect: "anthropic" });
const desc = result.tools?.[0]?.description ?? "";
expect(desc).toContain('<parameter name="_i"');
expect(desc).toContain(`<parameter name="${INTENT_FIELD}"`);
expect(desc).toContain("…");
});
@@ -4,6 +4,7 @@ import {
createFileOps,
extractFileOpsFromMessage,
formatFileOperations,
isUrlSchemePath,
stripReadSelector,
} from "../src/compaction/utils";
import { createAssistantMessage } from "./helpers";
@@ -12,6 +13,10 @@ function readCall(id: string, path: string) {
return { type: "toolCall" as const, id, name: "read", arguments: { path } };
}
function writeCall(id: string, path: string) {
return { type: "toolCall" as const, id, name: "write", arguments: { path } };
}
describe("stripReadSelector", () => {
it("strips line-range and raw selectors in every supported shape", () => {
expect(stripReadSelector("src/foo.ts:50")).toBe("src/foo.ts");
@@ -64,6 +69,41 @@ describe("extractFileOpsFromMessage", () => {
expect(readFiles).toEqual([]);
expect(modifiedFiles).toEqual(["src/login.ts"]);
});
it("skips internal URLs and web URLs so they never enter <files>", () => {
const fileOps = createFileOps();
const message = createAssistantMessage([
readCall("r1", "src/keep.ts"),
readCall("r2", "artifact://7"),
readCall("r3", "local://ctx.md"),
readCall("r4", "https://example.com/page"),
writeCall("w1", "conflict://1"),
writeCall("w2", "conflict://*"),
// Tolerated `<file>:conflict://N` prefix typo form the write tool accepts.
writeCall("w3", "src/login.ts:conflict://3"),
{ type: "toolCall" as const, id: "e1", name: "edit", arguments: { path: "agent://abc" } },
]);
extractFileOpsFromMessage(message, fileOps);
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
expect(readFiles).toEqual(["src/keep.ts"]);
expect(modifiedFiles).toEqual([]);
});
});
describe("computeFileLists", () => {
it("drops scheme:// URLs rehydrated from legacy compaction details", () => {
const fileOps = createFileOps();
// Simulate a pre-fix summary's details.readFiles/modifiedFiles fed straight
// into fileOps without going through extractFileOpsFromMessage.
fileOps.read.add("src/read-only.ts");
fileOps.read.add("artifact://7");
fileOps.edited.add("src/edited.ts");
fileOps.edited.add("conflict://1");
fileOps.written.add("local://ctx.md");
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
expect(readFiles).toEqual(["src/read-only.ts"]);
expect(modifiedFiles).toEqual(["src/edited.ts"]);
});
});
describe("formatFileOperations", () => {
@@ -83,3 +123,24 @@ describe("formatFileOperations", () => {
expect(rendered).toBe(["<files>", "c.ts (Write)", "</files>"].join("\n"));
});
});
describe("isUrlSchemePath", () => {
it("flags internal URIs and web URLs", () => {
expect(isUrlSchemePath("conflict://1")).toBe(true);
expect(isUrlSchemePath("conflict://*")).toBe(true);
expect(isUrlSchemePath("artifact://7")).toBe(true);
expect(isUrlSchemePath("local://ctx.md")).toBe(true);
expect(isUrlSchemePath("history://AuthLoader")).toBe(true);
expect(isUrlSchemePath("https://example.com/page")).toBe(true);
// Prefixed conflict typo form — scheme appears after a colon, not at start.
expect(isUrlSchemePath("src/login.ts:conflict://3")).toBe(true);
});
it("leaves real filesystem paths untouched", () => {
expect(isUrlSchemePath("src/foo.ts")).toBe(false);
expect(isUrlSchemePath("C:/Users/me/file.ts")).toBe(false);
expect(isUrlSchemePath("db.sqlite:users")).toBe(false);
expect(isUrlSchemePath("archive.zip:dir/file.ts")).toBe(false);
expect(isUrlSchemePath("docs/compaction.md:100-170:raw")).toBe(false);
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import { normalizeTools } from "@oh-my-pi/pi-agent-core/agent-loop";
import type { AgentTool } from "@oh-my-pi/pi-agent-core/types";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { type } from "arktype";
const toolSchema = type({
path: type("string").describe("where to read"),
nested: type({ inner: type("string").describe("inner value") }).describe("a nested object"),
});
function makeTool(): AgentTool<typeof toolSchema, { path: string }> {
return {
name: "demo",
label: "Demo",
description: "top-level tool description",
parameters: toolSchema,
async execute() {
return { content: [{ type: "text", text: "ok" }] };
},
};
}
/** Read the `properties` map off a wire JSON schema with runtime narrowing. */
function wireProps(parameters: unknown): Record<string, unknown> {
if (!parameters || typeof parameters !== "object" || !("properties" in parameters)) {
throw new Error("expected a wire schema with properties");
}
const properties = parameters.properties;
if (!properties || typeof properties !== "object") throw new Error("expected a properties object");
// Narrowed to a non-null object above; wire property maps are plain string-keyed objects.
const map = properties as Record<string, unknown>;
return map;
}
function hasField(parameters: unknown, field: string): boolean {
return field in wireProps(parameters);
}
function fieldDescription(parameters: unknown, field: string): unknown {
const value = wireProps(parameters)[field];
if (value && typeof value === "object" && "description" in value) return value.description;
return undefined;
}
describe("normalizeTools — pruneDescriptions", () => {
it("keeps the top-level description when pruning is off", () => {
const tools = normalizeTools([makeTool()], false);
expect(tools?.[0]?.description).toBe("top-level tool description");
});
it("empties the description and strips nested schema descriptions when pruning", () => {
const tools = normalizeTools([makeTool()], false, undefined, true);
const tool = tools?.[0];
expect(tool?.description).toBe("");
const wire = JSON.stringify(tool?.parameters);
expect(wire).not.toContain("where to read");
expect(wire).not.toContain("inner value");
expect(wire).not.toContain("a nested object");
// Structure is preserved.
expect(hasField(tool?.parameters, "path")).toBe(true);
expect(hasField(tool?.parameters, "nested")).toBe(true);
});
it("injects the intent field WITHOUT a description when pruning", () => {
const tools = normalizeTools([makeTool()], true, undefined, true);
const params = tools?.[0]?.parameters;
expect(hasField(params, INTENT_FIELD)).toBe(true);
expect(fieldDescription(params, INTENT_FIELD)).toBeUndefined();
// No description text rides the wire at all.
expect(JSON.stringify(params)).not.toContain("description");
});
it("keeps the intent field description when not pruning", () => {
const tools = normalizeTools([makeTool()], true);
expect(typeof fieldDescription(tools?.[0]?.parameters, INTENT_FIELD)).toBe("string");
});
});
@@ -102,6 +102,79 @@ describe("agentLoop with owned in-band tool calls", () => {
expect(wireText(internalResult!)).toBe("echoed:hello world");
});
it("prunes native tool descriptions from the wire when pruneToolDescriptions is set", async () => {
const toolSchema = type({ msg: type("string").describe("the message to echo") });
const echoTool: AgentTool<typeof toolSchema, { msg: string }> = {
name: "echo",
label: "Echo",
description: "Echo a message back",
parameters: toolSchema,
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params };
},
};
const captured: Context[] = [];
const mock = createMockModel({
responses: [
context => {
captured.push(context);
return { content: ["done"] };
},
],
});
const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] };
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
pruneToolDescriptions: true,
};
await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result();
const wireTools = captured[0]?.tools;
expect(wireTools).toHaveLength(1);
expect(wireTools?.[0].name).toBe("echo");
// Native tool calling: spec ships with no description text (top-level or nested).
expect(wireTools?.[0].description).toBe("");
expect(JSON.stringify(wireTools?.[0].parameters)).not.toContain("the message to echo");
});
it("keeps in-band tool descriptions for owned dialects even when pruneToolDescriptions is set", async () => {
const toolSchema = type({ msg: "string" });
const echoTool: AgentTool<typeof toolSchema, { msg: string }> = {
name: "echo",
label: "Echo",
description: "Echo a message back",
parameters: toolSchema,
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `echoed:${params.msg}` }], details: params };
},
};
const captured: Context[] = [];
const mock = createMockModel({
responses: [
context => {
captured.push(context);
return { content: ["done"] };
},
],
});
const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] };
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
dialect: "glm",
pruneToolDescriptions: true,
};
await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result();
// Owned dialect carries the catalog in the prompt as text and sends no native
// tools, so pruning must not strip its descriptions.
expect(captured[0]?.tools).toBeUndefined();
const promptSection = (captured[0]?.systemPrompt ?? []).join("\n");
expect(promptSection).toContain("<tools>");
expect(promptSection).toContain("Echo a message back");
});
it("executes Hermes/Qwen JSON tool calls when that dialect is selected", async () => {
const echoArgs: Array<{ msg: string }> = [];
const toolSchema = type({ msg: "string" });
@@ -0,0 +1,211 @@
import { describe, expect, it } from "bun:test";
import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop";
import type {
AgentContext,
AgentLoopConfig,
AgentMessage,
AgentTool,
SoftToolRequirement,
} from "@oh-my-pi/pi-agent-core/types";
import type { Message, ToolChoice } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { type } from "arktype";
import { createUserMessage } from "./helpers";
function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
}
const emptySchema = type({});
/**
* Build a host that gates `resolve` as a soft requirement while a preview is
* pending. The `resolve` tool clears it, mirroring coding-agent's pending
* invoker. `getToolChoice` returns the soft requirement (a peek — never
* consuming) until the preview resolves.
*/
function makeSoftResolveHarness() {
let pendingPreview = true;
let resolveRuns = 0;
let peekRuns = 0;
const reminder = createUserMessage("<system-reminder>Resolve the pending preview.</system-reminder>");
const resolveTool: AgentTool<typeof emptySchema, Record<string, never>> = {
name: "resolve",
label: "Resolve",
description: "Resolve the pending action",
parameters: emptySchema,
async execute() {
resolveRuns++;
pendingPreview = false;
return { content: [{ type: "text", text: "resolved" }], details: {} };
},
};
const peekTool: AgentTool<typeof emptySchema, Record<string, never>> = {
name: "peek",
label: "Peek",
description: "A non-resolve detour tool",
parameters: emptySchema,
async execute() {
peekRuns++;
return { content: [{ type: "text", text: "peeked" }], details: {} };
},
};
const getToolChoice = (): SoftToolRequirement | undefined =>
pendingPreview ? { soft: true, id: "preview-1", toolName: "resolve", reminder: [reminder] } : undefined;
return {
tools: [resolveTool, peekTool],
getToolChoice,
reminder,
get resolveRuns() {
return resolveRuns;
},
get peekRuns() {
return peekRuns;
},
};
}
describe("agentLoop soft tool requirement", () => {
it("never forces tool_choice when the model complies with the reminder", async () => {
const h = makeSoftResolveHarness();
const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "c1", name: "resolve", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolChoice: h.getToolChoice,
};
const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
const messages = await stream.result();
// Compliant: resolve ran, and NO turn ever carried a forced tool_choice.
expect(h.resolveRuns).toBe(1);
expect(mock.calls).toHaveLength(2);
expect(mock.calls[0]?.options?.toolChoice).toBeUndefined();
expect(mock.calls[1]?.options?.toolChoice).toBeUndefined();
// The reminder was injected exactly once (on the new requirement id).
expect(messages.filter(m => m === h.reminder)).toHaveLength(1);
});
it("skips a detour batch and force-escalates resolve on the next turn", async () => {
const h = makeSoftResolveHarness();
const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools };
const mock = createMockModel({
responses: [
// Turn 1: model ignores the reminder and calls a detour tool.
{ content: [{ type: "toolCall", id: "p1", name: "peek", arguments: {} }] },
// Turn 2: forced to resolve.
{ content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolChoice: h.getToolChoice,
};
const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
const messages = await stream.result();
// The detour tool was NOT executed (its batch was skipped), resolve ran once.
expect(h.peekRuns).toBe(0);
expect(h.resolveRuns).toBe(1);
// Turn 1 ran with auto (no bust); turn 2 was force-escalated to resolve.
expect(mock.calls[0]?.options?.toolChoice).toBeUndefined();
expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" });
// The skipped peek call still produced a paired tool result (API pairing).
const peekResult = messages.find(m => m.role === "toolResult" && m.toolCallId === "p1");
expect(peekResult).toBeDefined();
});
it("does not yield while the requirement is unmet — escalates after a bare-text turn", async () => {
const h = makeSoftResolveHarness();
const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: h.tools };
const mock = createMockModel({
responses: [
// Turn 1: model yields with text, no tool call.
{ content: ["I'll get to it later."] },
// Turn 2: forced to resolve.
{ content: [{ type: "toolCall", id: "r1", name: "resolve", arguments: {} }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolChoice: h.getToolChoice,
};
const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
// The loop did not stop after the bare-text turn; it forced resolve.
expect(h.resolveRuns).toBe(1);
expect(mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" });
});
it("leaves tool_choice untouched when no requirement is pending", async () => {
const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] };
const mock = createMockModel({ responses: [{ content: ["hi"] }] });
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolChoice: () => undefined,
};
const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
expect(mock.calls).toHaveLength(1);
expect(mock.calls[0]?.options?.toolChoice).toBeUndefined();
});
it("reuses the fetched hard tool choice across a Harmony retry instead of re-consuming getToolChoice", async () => {
const context: AgentContext = { systemPrompt: ["sys"], messages: [], tools: [] };
// Consuming source: yields the forced choice once, then nothing — mirrors
// coding-agent's ToolChoiceQueue.nextToolChoice advancing its generator.
const queue: ToolChoice[] = [{ type: "tool", name: "resolve" }];
const leak = "Some prose. analysis to=functions.edit code 大发官网";
const mock = createMockModel({
provider: "openai-codex",
responses: [{ content: [leak] }, { content: ["clean retry"] }],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
getToolChoice: () => queue.shift(),
};
const stream = agentLoop([createUserMessage("go")], context, config, undefined, mock.stream);
for await (const _ of stream) {
// drain
}
// The leaked attempt was retried; both calls carried the SAME forced choice
// and the consuming source advanced exactly once (no double-consume).
expect(mock.calls).toHaveLength(2);
expect(mock.calls[0]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" });
expect(mock.calls[1]?.options?.toolChoice).toEqual({ type: "tool", name: "resolve" });
expect(queue).toHaveLength(0);
});
});
+133
View File
@@ -522,3 +522,136 @@ describe("pruneToolOutputs — small-result floor", () => {
expect(resultMessage(bigResult).prunedAt).toBeDefined();
});
});
describe("cache-stable boundary — warm prefix protection", () => {
// (a) The primary bug: in pruneToolOutputs a superseded result bypasses the
// protect window and is rewritten at any depth. With the cache guard armed it
// must be left alone when it sits in the warm, already-sent cached prefix.
test("(a) deep superseded result is rewritten WITHOUT the guard but kept WITH it", () => {
const build = (): { entries: SessionEntry[]; result1: SessionMessageEntry; result2: SessionMessageEntry } => {
const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0);
const big = textEntry(BIG_TEXT, T0 + 500); // pushes result1 deep into the suffix
const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // tail, supersedes result1
return { entries: [call1, result1, big, call2, result2], result1, result2 };
};
const base = {
protectTokens: 1_000_000, // everything inside the (age) protect window
minimumSavings: 0,
protectedTools: [],
supersedeKey: readToolSupersedeKey,
};
// Legacy (no cacheWarmSuffixTokens): superseded result1 bypasses the window -> pruned.
const legacy = build();
const legacyRun = pruneToolOutputs(legacy.entries, base);
expect(legacyRun.prunedCount).toBe(1);
expect(resultText(legacy.result1)).toBe(SUPERSEDED_NOTICE);
// Guard armed: result1's all-message suffix (BIG_TEXT + call2 + result2) far
// exceeds the window, so it is part of the warm cached prefix and is kept.
const guarded = build();
const guardedRun = pruneToolOutputs(guarded.entries, { ...base, cacheWarmSuffixTokens: 200 });
expect(guardedRun.prunedCount).toBe(0);
expect(resultText(guarded.result1)).toBe(FILE_CONTENT);
expect(resultMessage(guarded.result1).prunedAt).toBeUndefined();
});
test("(a) deep useless result is kept when the cache guard is armed", () => {
const [call1, result1] = uselessPair("search", NO_MATCH_TEXT, T0);
const big = textEntry(BIG_TEXT, T0 + 500);
const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000);
const entries: SessionEntry[] = [call1, result1, big, call2, result2];
const result = pruneToolOutputs(entries, {
protectTokens: 1_000_000,
minimumSavings: 0,
protectedTools: [],
pruneUseless: true,
cacheWarmSuffixTokens: 200,
});
expect(result.prunedCount).toBe(0);
expect(resultText(result1)).toBe(NO_MATCH_TEXT);
expect(resultMessage(result1).prunedAt).toBeUndefined();
expect(resultText(result2)).toBe(FILE_CONTENT);
});
// (b) The legit case must still fire: a superseded copy in the cheap-to-recache
// tail (suffix below the window) is still reclaimed.
test("(b) tail-case superseded result still prunes with the guard armed", () => {
const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0);
const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000);
const entries: SessionEntry[] = [call1, result1, call2, result2];
const result = pruneToolOutputs(entries, {
protectTokens: 1_000_000,
minimumSavings: 0,
protectedTools: [],
supersedeKey: readToolSupersedeKey,
cacheWarmSuffixTokens: 100_000, // result1's suffix is far below this -> tail -> prunable
});
expect(result.prunedCount).toBe(1);
expect(resultText(result1)).toBe(SUPERSEDED_NOTICE);
expect(resultText(result2)).toBe(FILE_CONTENT);
});
test("(b) supersede pass still prunes the tail case with keepBoundaryId set", () => {
const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0);
const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000);
const entries: SessionEntry[] = [call1, result1, call2, result2];
const result = pruneSupersededToolResults(entries, cfg({ keepBoundaryId: call1.id, now: T0 + 1_000 }));
expect(result.prunedCount).toBe(1);
expect(resultText(result1)).toBe(SUPERSEDED_NOTICE);
expect(resultText(result2)).toBe(FILE_CONTENT);
});
// (c) Entries before firstKeptEntryId are summarized away — never sent — so no
// pass may mutate them, not even the idle full-flush.
test("(c) idle flush never mutates entries before keepBoundaryId", () => {
const [call1, result1] = readPair("src/foo.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary
const [call2, result2] = readPair("src/foo.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2
const [call3, result3] = readPair("src/foo.ts", FILE_CONTENT, T0 + 2_000); // idx 4,5 — latest
const big = textEntry(BIG_TEXT, T0 + 3_000);
const entries: SessionEntry[] = [call1, result1, call2, result2, call3, result3, big];
// Cold cache (idle > threshold) with suffixTokenLimit 0: only the idle path can fire.
const result = pruneSupersededToolResults(
entries,
cfg({
keepBoundaryId: call2.id,
suffixTokenLimit: 0,
idleFlushMs: 30 * 60_000,
now: T0 + 3_000 + 31 * 60_000,
}),
);
expect(result.prunedCount).toBe(1);
expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched
expect(resultMessage(result1).prunedAt).toBeUndefined();
expect(resultText(result2)).toBe(SUPERSEDED_NOTICE); // at/after boundary -> flushed
expect(resultText(result3)).toBe(FILE_CONTENT); // latest -> kept
});
test("(c) pruneToolOutputs never mutates entries before keepBoundaryId", () => {
const [call1, result1] = readPair("src/old.ts", FILE_CONTENT, T0); // idx 0,1 — before boundary
const [call2, result2] = readPair("src/new.ts", FILE_CONTENT, T0 + 1_000); // idx 2,3 — boundary at call2
const entries: SessionEntry[] = [call1, result1, call2, result2];
// protectTokens 0 -> the age path would prune both; the window is wide so the
// guard does not protect either; only keepBoundaryId shields result1.
pruneToolOutputs(entries, {
protectTokens: 0,
minimumSavings: 0,
protectedTools: [],
keepBoundaryId: call2.id,
cacheWarmSuffixTokens: 1_000_000,
});
expect(resultText(result1)).toBe(FILE_CONTENT); // before boundary -> untouched
expect(resultMessage(result1).prunedAt).toBeUndefined();
expect(resultMessage(result2).prunedAt).toBeDefined(); // at/after boundary, in tail -> pruned
});
});
+46
View File
@@ -2,8 +2,54 @@
## [Unreleased]
## [16.1.2] - 2026-06-19
### Added
- Added authentication broker discovery to sync credentials between local SQLite and remote state
### Fixed
- Added automatic fallback for unsupported OpenAI reasoning effort levels
- Improved reliability when handling invalid reasoning parameter errors across OpenAI-compatible APIs
- Fixed OpenAI-compatible Chat Completions, Responses, and Azure Responses requests to retry once with the nearest provider-supported reasoning effort when an endpoint rejects `xhigh`/`minimal`-style effort values.
## [16.1.0] - 2026-06-19
### Added
- Added utility functions to strip schema descriptions for optimized LLM context usage
## [16.0.10] - 2026-06-18
### Added
- Replaced the old legacy XML-ish `pi` owned tool-calling dialect with the new sigil-delimited format (`§` call header with inline `key=value` scalars, `«…»` verbatim body fence for the dominant string argument, `¤` reasoning, `‡‡` tool result) using single-token markers that never occur in source code. Verbatim fences escalate Markdown-style (`««…»»`) so re-rendered history never collides with payload content, and the scanner gates a bare `§` on an exact known-tool name to avoid swallowing prose. Round-trips and streams through the existing scanner contract at ~46% fewer tokens than the legacy format on typical calls; selectable via `tools.format` or `PI_DIALECT=pi`.
### Changed
- Updated `pi` dialect formatting to use a token-frugal, sigil-delimited format (`§`, `¤`, `‡‡`)
- Updated `pi` dialect body fences to automatically escalate when content contains fence markers
- Changed `pi` dialect tool results response format to `‡‡` blocks
### Fixed
- Fixed Bedrock application inference profile ARNs to route requests to the ARN's region instead of the default Bedrock runtime region. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004))
## [16.0.9] - 2026-06-18
### Fixed
- Fixed OAuth login replacing all other active accounts for the same provider, allowing multiple OAuth accounts to coexist concurrently.
- Fixed legacy `api_key` credentials not being replaced/disabled atomically upon upgrading to OAuth login.
- Fixed a logic issue where AuthStorage lost session-to-credential stickiness upon CLI restarts, causing cold-starts for server-side prompt cache (KV cache) and wasting tokens.
- Fixed GitHub Copilot Responses requests rejecting image inputs that carry the `detail: "original"` hint with an HTTP 400 by degrading the hint to `"auto"` for hosts that do not support it; other hosts still preserve native-resolution frames (snapcompact). ([#2822](https://github.com/can1357/oh-my-pi/issues/2822))
## [16.0.8] - 2026-06-18
### Fixed
- Improved reliability of auth-broker snapshot loading by implementing a robust manual schema check
- Fixed MCP tool argument validation to drop optional empty-string parameters before schema validation, matching the existing optional null handling and avoiding pattern/type failures for omitted model-filled fields. ([#2981](https://github.com/can1357/oh-my-pi/issues/2981))
- Fixed API-key credential replacement to hard-delete superseded disabled `api_key` rows so `auth_credentials` does not grow indefinitely after key rotation. ([#2941](https://github.com/can1357/oh-my-pi/issues/2941))
- Fixed Cursor provider streaming to close text blocks before tool calls so post-tool text opens a new content block and TUI transcript cards render inline instead of grouped near the bottom. ([#2924](https://github.com/can1357/oh-my-pi/issues/2924))
+2 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "16.0.7",
"version": "16.1.2",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -40,6 +40,7 @@
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/pi-wire": "catalog:",
"arktype": "catalog:",
"partial-json": "catalog:",
"zod": "catalog:"
+217
View File
@@ -0,0 +1,217 @@
/**
* Broker-aware auth-storage discovery used by both the coding-agent runtime and
* the catalog model generator. Keeps the precedence logic (env → config.yml →
* token file → local SQLite) in one place so build-time tooling sees the same
* credentials as the TUI.
*/
import * as path from "node:path";
import {
getAgentDbPath,
getAgentDir,
getAuthBrokerSnapshotCachePath,
getConfigRootDir,
isEnoent,
logger,
} from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { AuthStorage } from "../auth-storage";
import { AuthBrokerClient } from "./client";
import { RemoteAuthCredentialStore } from "./remote-store";
import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "./types";
export interface AuthBrokerClientConfig {
url: string;
token: string;
}
export interface ResolveAuthBrokerConfigOptions {
agentDir?: string;
configValueResolver?: (config: string) => Promise<string | undefined>;
}
export interface DiscoverAuthStorageOptions {
agentDir?: string;
configValueResolver?: (config: string) => Promise<string | undefined>;
cachePath?: string;
sourceLabel?: string;
}
/** Path to the local bearer token file. Created by `omp auth-broker token`. */
export function getAuthBrokerTokenFilePath(): string {
return path.join(getConfigRootDir(), "auth-broker.token");
}
/**
* Default resolver for config values: checks `process.env` first, then treats
* the value as a literal. Does NOT execute `!command` syntax; such values are
* left unresolved so the caller can fall back to the token file.
*/
async function defaultResolveConfigValue(config: string): Promise<string | undefined> {
if (config.startsWith("!")) return undefined;
const envValue = process.env[config];
return envValue || config;
}
async function readTokenFile(): Promise<string | null> {
try {
const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
} catch (err) {
if (isEnoent(err)) return null;
logger.warn("auth-broker token file unreadable", { error: String(err) });
return null;
}
}
interface ConfigSnapshot {
url?: string;
token?: string;
}
async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
const configPath = path.join(agentDir, "config.yml");
try {
const raw = await Bun.file(configPath).text();
const parsed = YAML.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const record = parsed as Record<string, unknown>;
const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
const token =
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
return { url, token };
} catch (err) {
if (isEnoent(err)) return {};
logger.warn("auth-broker config.yml unreadable", { error: String(err) });
return {};
}
}
function resolveSnapshotTtlMs(): number {
const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
const value = raw.trim();
if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
const ttlMs = Number(value);
if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
}
/**
* Resolve broker connection configuration using the same precedence as the TUI:
*
* 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
* 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
* 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
*
* Returns `null` when no broker URL is configured — callers should fall back to
* the local SQLite store. Throws when a URL is configured but no token is
* available, matching the TUI behavior.
*/
export async function resolveAuthBrokerConfig(
options: ResolveAuthBrokerConfigOptions = {},
): Promise<AuthBrokerClientConfig | null> {
const agentDir = options.agentDir ?? getAgentDir();
const resolveConfig = options.configValueResolver ?? defaultResolveConfigValue;
const envUrl = process.env.OMP_AUTH_BROKER_URL;
const envToken = process.env.OMP_AUTH_BROKER_TOKEN;
let url = envUrl && envUrl.length > 0 ? envUrl : undefined;
let configToken: string | undefined;
if (!url || !envToken) {
const fromConfig = await readConfigYaml(agentDir);
if (!url && fromConfig.url) {
const resolved = await resolveConfig(fromConfig.url);
if (resolved && resolved.length > 0) url = resolved;
}
if (fromConfig.token) {
const resolved = await resolveConfig(fromConfig.token);
if (resolved && resolved.length > 0) configToken = resolved;
}
}
if (!url) return null;
const token =
(envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
if (!token) {
throw new Error(
`OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
`Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
);
}
return { url, token };
}
/**
* Create an AuthStorage instance, using the broker when configured and falling
* back to the local SQLite store otherwise. This is the single source of truth
* for the TUI and the catalog generator.
*/
export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise<AuthStorage> {
const agentDir = options.agentDir ?? getAgentDir();
const brokerConfig = await resolveAuthBrokerConfig({
agentDir,
configValueResolver: options.configValueResolver,
});
if (brokerConfig) {
const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
const cachePath = options.cachePath ?? getAuthBrokerSnapshotCachePath();
const ttlMs = resolveSnapshotTtlMs();
const persist =
ttlMs > 0
? (snapshot: SnapshotResponse): void => {
void writeAuthBrokerSnapshotCache({
path: cachePath,
token: brokerConfig.token,
url: brokerConfig.url,
snapshot,
}).catch(error => {
logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
});
}
: undefined;
let initialSnapshot: SnapshotResponse | undefined;
if (ttlMs > 0) {
initialSnapshot =
(await readAuthBrokerSnapshotCache({
path: cachePath,
token: brokerConfig.token,
url: brokerConfig.url,
ttlMs,
}).catch(error => {
logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
return null;
})) ?? undefined;
}
if (!initialSnapshot) {
const initialResult = await client.fetchSnapshot();
if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
initialSnapshot = initialResult.snapshot;
persist?.(initialSnapshot);
}
const store = new RemoteAuthCredentialStore({
client,
initialSnapshot,
onSnapshot: persist,
});
const storage = new AuthStorage(store, {
configValueResolver: options.configValueResolver,
sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}`,
});
await storage.reload();
return storage;
}
const dbPath = getAgentDbPath(agentDir);
const storage = await AuthStorage.create(dbPath, {
configValueResolver: options.configValueResolver,
sourceLabel: options.sourceLabel ?? `local ${dbPath}`,
});
await storage.reload();
return storage;
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./client";
export * from "./discover";
export * from "./refresher";
export * from "./remote-store";
export * from "./server";
+21 -5
View File
@@ -9,9 +9,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import type { SnapshotResponse } from "./types";
import { snapshotResponseSchema } from "./wire-schemas";
const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
const VERSION = 1;
@@ -40,6 +38,25 @@ export interface WriteAuthBrokerSnapshotCacheOptions {
snapshot: SnapshotResponse;
}
/**
* Cheap structural guard for a decrypted cache payload. The bytes are already
* AES-256-GCM authenticated, so this only rejects shape/version drift (a cache
* written by a different omp build, or a buggy write) — not tampering. A
* mismatch returns null so the caller refetches a fresh snapshot.
*/
function isSnapshotResponseShape(v: unknown): v is SnapshotResponse {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.generation === "number" &&
typeof o.generatedAt === "number" &&
typeof o.serverNowMs === "number" &&
typeof o.refresher === "object" &&
o.refresher !== null &&
Array.isArray(o.credentials)
);
}
export async function readAuthBrokerSnapshotCache(
opts: ReadAuthBrokerSnapshotCacheOptions,
): Promise<SnapshotResponse | null> {
@@ -56,12 +73,11 @@ export async function readAuthBrokerSnapshotCache(
const plaintext = await decryptCachePayload(data, opts.token, opts.url);
if (!plaintext) return null;
const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext));
const result = snapshotResponseSchema(parsed);
if (result instanceof type.errors) {
if (!isSnapshotResponseShape(parsed)) {
logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path });
return null;
}
const snapshot = result;
const snapshot = parsed;
const now = opts.now?.() ?? Date.now();
if (now - snapshot.generatedAt > opts.ttlMs) return null;
return snapshot;
+83 -9
View File
@@ -1352,6 +1352,19 @@ export class AuthStorage {
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
sessionMap.set(sessionId, { type, index });
this.#sessionLastCredential.set(provider, sessionMap);
try {
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
if (credentialId !== undefined) {
const cacheKey = `session:sticky:${provider}:${sessionId}`;
const cacheValue = JSON.stringify({ type, index, credentialId });
// Expires in 30 days
const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
}
} catch (err) {
logger.debug("Failed to write session sticky credential to persistent store cache", { err });
}
}
/** Retrieves the last credential used by a session. */
@@ -1360,17 +1373,59 @@ export class AuthStorage {
sessionId: string | undefined,
): { type: AuthCredential["type"]; index: number } | undefined {
if (!sessionId) return undefined;
return this.#sessionLastCredential.get(provider)?.get(sessionId);
let sessionMap = this.#sessionLastCredential.get(provider);
if (sessionMap?.has(sessionId)) {
return sessionMap.get(sessionId);
}
try {
const cacheKey = `session:sticky:${provider}:${sessionId}`;
const raw = this.#store.getCache(cacheKey);
if (raw) {
const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
if (val.credentialId !== undefined) {
const stored = this.#getStoredCredentials(provider);
const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
this.#store.setCache(cacheKey, "", 0);
return undefined;
}
val.index = actualIndex;
} else {
// Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
this.#store.setCache(cacheKey, "", 0);
return undefined;
}
if (!sessionMap) {
sessionMap = new Map();
this.#sessionLastCredential.set(provider, sessionMap);
}
const sessionVal = { type: val.type, index: val.index };
sessionMap.set(sessionId, sessionVal);
return sessionVal;
}
} catch (err) {
logger.debug("Failed to read session sticky credential from persistent store cache", { err });
}
return undefined;
}
/** Clears the last credential used by a session for a provider. */
#clearSessionCredential(provider: string, sessionId: string | undefined): void {
if (!sessionId) return;
const sessionMap = this.#sessionLastCredential.get(provider);
if (!sessionMap) return;
sessionMap.delete(sessionId);
if (sessionMap.size === 0) {
this.#sessionLastCredential.delete(provider);
if (sessionMap) {
sessionMap.delete(sessionId);
if (sessionMap.size === 0) {
this.#sessionLastCredential.delete(provider);
}
}
try {
const cacheKey = `session:sticky:${provider}:${sessionId}`;
this.#store.setCache(cacheKey, "", 0);
} catch (err) {
logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
}
}
@@ -1549,6 +1604,17 @@ export class AuthStorage {
return rows;
}
async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
const stored = this.#store.upsertAuthCredentialRemote
? await this.#store.upsertAuthCredentialRemote(provider, credential)
: this.#store.upsertAuthCredentialForProvider(provider, credential);
this.#setStoredCredentials(
provider,
stored.map(entry => ({ id: entry.id, credential: entry.credential })),
);
this.#resetProviderAssignments(provider);
}
/**
* Remove credential for a provider.
*/
@@ -1786,10 +1852,10 @@ export class AuthStorage {
return;
}
const newCredential: OAuthCredential = { type: "oauth", ...result };
// Use set() instead of #upsertOAuthCredential to replace ALL existing credentials
// (including legacy api_key rows from older versions) with the new OAuth credential.
// This ensures getApiKey() doesn't match an old api_key row before the new OAuth row.
await this.set(def.storeCredentialsAs ?? provider, newCredential);
// Use #upsertOAuthCredential to upsert the new credential.
// Any legacy api_key rows from older versions will be cleaned up so they do not
// shadow the new OAuth row, while preserving other active OAuth credentials.
await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
}
/**
@@ -4916,6 +4982,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
identityKey: resolveRowCredentialIdentityKey(providerName, row),
}));
if (item.type === "oauth") {
for (const row of existing) {
if (row.credential && row.credential.type === "api_key") {
this.#deleteStmt.run("replaced by oauth login", row.id);
}
}
}
let targetId: number | null = null;
for (const row of existing) {
if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
+1 -1
View File
@@ -19,7 +19,7 @@ const RESPONSE_OPEN_TOKENS: Record<Dialect, readonly string[]> = {
minimax: ["<function_results>", "<tool_response>"],
deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"],
harmony: ["<|start|>functions."],
pi: ["<tool_response>"],
pi: ["‡‡"],
qwen3: ["<tool_response>"],
gemini: ["```tool_outputs"],
gemma: ["<|tool_response>"],
+35 -37
View File
@@ -1,57 +1,55 @@
## Format guide
A tool call is a `<call:NAME>…</call:NAME>` block (or self-closing `<call:NAME …/>`) written as plain assistant text; arguments are given as tag attributes, child elements, or a verbatim inline body.
A tool call begins with `§` immediately followed by the function NAME (start each call on its own line). Scalar arguments follow on the same line as `key=value` pairs; a single large or multi-line string argument goes in a verbatim body fenced by `«…»` right after the header.
Scalar-only call (the line ends the call):
```text
<call:read path="src/a.ts" offset=50/>
§read path=src/a.ts offset=50 limit=200
```
Objects and arrays use child elements, repeating an element for each array item:
Call with a verbatim body — everything between `«` and `»` is taken literally, no quoting or escaping:
```text
<call:configure>
<object>
<y>4</y>
<list>alpha</list>
<list>beta</list>
</object>
</call:configure>
```
A single string argument can fill the body directly:
```text
<call:edit>
§edit path=src/server/auth.ts«
*** Begin Patch
...
*** Update File: src/server/auth.ts
@@ class AuthService
- login(user) {
+ async login(user, opts) {
*** End Patch
</call:edit>
»
```
Tool results arrive as response blocks, read in call order:
Argument values:
- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`).
- Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
- Arrays and objects are inline JSON (`paths=["src","test"]`).
- The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
Private reasoning goes in a `¤…¤` block before your calls:
```text
<tool_response>
verbatim tool result
</tool_response>
```
Private reasoning goes in a `<thinking>…</thinking>` block before your calls:
```text
<thinking>
¤
brief reasoning
</thinking>
¤
```
Tool results arrive in `‡‡…‡‡` blocks, read in call order:
```text
‡‡
verbatim tool result
‡‡
```
## Rules
- `NAME` must match a listed function; never wrap calls in JSON or fences.
- Use attributes only for top-level scalars; put objects, arrays, and long strings in child elements.
- Strings are verbatim (no quotes, no entity escaping); numbers, booleans, and null are JSON literals.
- An object opens a child block whose scalar subfields may also be attributes; an array repeats its element once per item.
- The inline body fills the first unset string-typed parameter and may contain any raw text except `</call:NAME>`.
- Emit parallel calls as consecutive blocks. NEVER invent call ids; results are positional.
- Private reasoning goes in a `<thinking>…</thinking>` block before your calls; NEVER put calls inside it.
- Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
- `NAME` MUST match a listed function; never wrap calls in JSON or fences.
- Put each scalar argument once as `key=value`; reserve the `«…»` body for the one dominant string argument (file contents, patches, commands, queries).
- Body text is verbatim — include no surrounding quotes. If the body itself contains `»`, widen BOTH guillemet fences equally (`««…»»`, `«««…»»»`).
- Emit parallel calls as consecutive `§…` blocks. NEVER invent call ids; results are positional.
- Private reasoning goes in a `¤…¤` block before your calls; NEVER put calls inside it, and keep a literal `¤` out of the reasoning text.
- Read each `‡‡…‡‡` result in call order. NEVER emit a `‡‡` block yourself.
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,8 +1,8 @@
export { type Type, type } from "arktype";
export { type ZodType, z } from "zod/v4";
export * from "./api-registry";
export * from "./auth-broker";
export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
export type * from "./auth-broker";
export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
export * from "./auth-gateway/types";
export * from "./auth-retry";
export * from "./auth-storage";
+13 -1
View File
@@ -80,6 +80,13 @@ function resolveBearerToken(options: BedrockOptions): string | undefined {
return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
}
function inferRegionFromBedrockArn(modelId: string): string | undefined {
const parts = modelId.split(":", 6);
if (parts[0] !== "arn" || parts[2] !== "bedrock") return undefined;
const region = parts[3];
return region || undefined;
}
type Block = (TextContent | ThinkingContent | ToolCall) & {
index?: number;
partialJson?: string;
@@ -206,7 +213,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
const blocks = output.content as Block[];
let rawRequestDump: RawHttpRequestDump | undefined;
const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
const region =
options.region ||
inferRegionFromBedrockArn(model.id) ||
$env.AWS_REGION ||
$env.AWS_DEFAULT_REGION ||
"us-east-1";
try {
const cacheRetention = resolveCacheRetention(options.cacheRetention);
@@ -18,9 +18,15 @@ import {
getOpenAIStreamIdleTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { postOpenAIStream } from "../utils/openai-http";
import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import {
applyOpenAIReasoningEffortFallback,
createOpenAIReasoningEffortFallbackKey,
type OpenAIReasoningEffortFallback,
resolveOpenAIReasoningEffortFallback,
} from "./openai-reasoning-fallback";
import type { ResponseCreateParamsStreaming, ResponseStreamEvent } from "./openai-responses-wire";
import {
applyCommonResponsesSamplingParams,
@@ -135,29 +141,52 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
url,
body: params,
};
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(() => abortTracker.abortLocally(firstEventTimeoutAbortError), requestTimeoutMs);
}
const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
"azure-responses",
url,
typeof params.model === "string" ? params.model : model.id,
);
const attemptedReasoningEffortFallbacks = new Set<string>();
let openaiStream: AsyncIterable<ResponseStreamEvent>;
try {
const headersWithTimeout = { ...headers };
while (true) {
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString();
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const headersWithTimeout = { ...headers };
if (requestTimeoutMs !== undefined) {
headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString();
}
const handle = await postOpenAIStream<ResponseStreamEvent>({
url,
headers: headersWithTimeout,
body: params,
signal: requestSignal,
fetch: options?.fetch,
// Watchdog armed → no retries, so they cannot silently extend the deadline.
maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined,
onSseEvent: rawSseObserver,
});
openaiStream = handle.events;
break;
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const reasoningEffortFallback: OpenAIReasoningEffortFallback | undefined = !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, params)
: undefined;
if (reasoningEffortFallback === undefined) throw error;
const retryMarker = `${reasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
applyOpenAIReasoningEffortFallback(params, reasoningEffortFallback);
rawRequestDump.body = params;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
const handle = await postOpenAIStream<ResponseStreamEvent>({
url,
headers: headersWithTimeout,
body: params,
signal: requestSignal,
fetch: options?.fetch,
// Watchdog armed → no retries, so they cannot silently extend the deadline.
maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined,
onSseEvent: rawSseObserver,
});
openaiStream = handle.events;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
stream.push({ type: "start", partial: output });
@@ -297,6 +326,7 @@ function buildParams(
model,
context,
strictResponsesPairing: true,
supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
systemRole,
includeThinkingSignatures: true,
developerStringContent: true,
@@ -3253,7 +3253,15 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
}
if (msg.role === "toolResult") {
appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
appendResponsesToolResultMessages(
messages,
msg,
model,
false,
model.compat.supportsImageDetailOriginal,
knownCallIds,
customCallIds,
);
}
msgIndex += 1;
@@ -3271,7 +3279,10 @@ function normalizeInputMessageContent(
return [{ type: "input_text", text: content.toWellFormed() }];
}
return convertResponsesInputContent(content, model.input.includes("image")) ?? [];
return (
convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ??
[]
);
}
/** @internal Exported for tests. */
@@ -56,6 +56,17 @@ import type {
ChatCompletionTool,
ChatCompletionToolMessageParam,
} from "./openai-chat-wire";
import {
applyOpenAIReasoningEffortFallback,
clearOpenAIReasoningEffortFallbackState,
createOpenAIReasoningEffortFallbackKey,
createOpenAIReasoningEffortFallbackState,
getOpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallbackState,
rememberOpenAIReasoningEffortFallback,
resolveOpenAIReasoningEffortFallback,
} from "./openai-reasoning-fallback";
import {
applyChatCompletionsCompatPolicy,
applyChatCompletionsToolStream,
@@ -444,14 +455,19 @@ type BuiltOpenAICompletionTools = {
const OPENAI_COMPLETIONS_PROVIDER_SESSION_STATE_PREFIX = "openai-completions:";
type OpenAICompletionsProviderSessionState = ProviderSessionState & OpenAIStrictToolsState;
type OpenAICompletionsProviderSessionState = ProviderSessionState &
OpenAIStrictToolsState &
OpenAIReasoningEffortFallbackState;
function createOpenAICompletionsProviderSessionState(): OpenAICompletionsProviderSessionState {
const strictToolsState = createOpenAIStrictToolsState();
const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
const state: OpenAICompletionsProviderSessionState = {
...strictToolsState,
...reasoningEffortFallbackState,
close: () => {
clearOpenAIStrictToolsState(state);
clearOpenAIReasoningEffortFallbackState(state);
},
};
return state;
@@ -581,6 +597,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
);
const premiumRequestsTotal = copilotPremiumRequests;
let appliedStrictTools = false;
const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
const attemptedReasoningEffortFallbacks = new Set<string>();
let activeReasoningEffortFallbackKey: string | undefined;
let activeRequestParams: OpenAICompletionsParams | undefined;
const providerSessionState = getOpenAICompletionsProviderSessionState(
model,
baseUrl,
@@ -601,6 +621,19 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
effectiveToolStrictModeOverride,
);
appliedStrictTools = strictToolsApplied;
const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
"chat-completions",
trimmedBaseUrl,
params.model,
);
const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(reasoningEffortFallbackKey)
? requestReasoningEffortFallbacks.get(reasoningEffortFallbackKey)
: getOpenAIReasoningEffortFallback(providerSessionState, reasoningEffortFallbackKey);
if (requestReasoningEffortFallback !== undefined) {
applyOpenAIReasoningEffortFallback(params, requestReasoningEffortFallback);
}
activeReasoningEffortFallbackKey = reasoningEffortFallbackKey;
activeRequestParams = params;
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
@@ -650,7 +683,24 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
});
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
if (
const reasoningEffortFallback =
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
})
: undefined;
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
openaiStream = await createCompletionsStream();
rememberOpenAIReasoningEffortFallback(
providerSessionState,
activeReasoningEffortFallbackKey,
reasoningEffortFallback,
);
} else if (
isOpenRouterAnthropicModel(model) &&
!disableStrictTools &&
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse)
@@ -0,0 +1,269 @@
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
/** @internal */
export type OpenAIReasoningEffortFallback = string | null;
/** @internal */
export interface OpenAIReasoningEffortFallbackState {
reasoningEffortFallbacks: Map<string, OpenAIReasoningEffortFallback>;
}
const ENABLED_REASONING_VALUES = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
const KNOWN_REASONING_VALUE: Readonly<Record<string, true>> = {
none: true,
minimal: true,
low: true,
medium: true,
high: true,
xhigh: true,
max: true,
};
const REASONING_VALUE_RANK: Readonly<Record<string, number>> = {
minimal: 0,
low: 1,
medium: 2,
high: 3,
xhigh: 4,
max: 5,
};
/** @internal */
export function createOpenAIReasoningEffortFallbackState(): OpenAIReasoningEffortFallbackState {
return { reasoningEffortFallbacks: new Map() };
}
/** @internal */
export function clearOpenAIReasoningEffortFallbackState(state: OpenAIReasoningEffortFallbackState): void {
state.reasoningEffortFallbacks.clear();
}
/** @internal */
export function getOpenAIReasoningEffortFallback(
state: OpenAIReasoningEffortFallbackState | undefined,
key: string,
): OpenAIReasoningEffortFallback | undefined {
return state?.reasoningEffortFallbacks.get(key);
}
/** @internal */
export function rememberOpenAIReasoningEffortFallback(
state: OpenAIReasoningEffortFallbackState | undefined,
key: string,
fallback: OpenAIReasoningEffortFallback,
): void {
state?.reasoningEffortFallbacks.set(key, fallback);
}
/** @internal */
export function createOpenAIReasoningEffortFallbackKey(
endpoint: "chat-completions" | "responses" | "azure-responses",
baseUrl: string | undefined,
wireModelId: string | undefined,
): string {
return `${endpoint}:${baseUrl ?? ""}:${wireModelId ?? ""}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** @internal */
export function readOpenAIReasoningEffort(params: unknown): string | undefined {
if (!isRecord(params)) return undefined;
if (typeof params.reasoning_effort === "string") return params.reasoning_effort;
const reasoning = params.reasoning;
return isRecord(reasoning) && typeof reasoning.effort === "string" ? reasoning.effort : undefined;
}
function deleteReasoningEffort(reasoning: Record<string, unknown>, parent: Record<string, unknown>): boolean {
if (typeof reasoning.effort !== "string") return false;
delete reasoning.effort;
for (const key in reasoning) {
if (key !== "effort") return true;
}
delete parent.reasoning;
return true;
}
/** @internal */
export function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean {
if (!isRecord(params)) return false;
let changed = false;
if (typeof params.reasoning_effort === "string") {
if (fallback === null) {
delete params.reasoning_effort;
} else {
params.reasoning_effort = fallback;
}
changed = true;
}
const reasoning = params.reasoning;
if (isRecord(reasoning) && typeof reasoning.effort === "string") {
if (fallback === null) {
changed = deleteReasoningEffort(reasoning, params) || changed;
} else {
reasoning.effort = fallback;
changed = true;
}
}
return changed;
}
function capturedStringField(
captured: CapturedHttpErrorResponse | undefined,
field: "code" | "message" | "param" | "type",
) {
const body = isRecord(captured?.bodyJson) ? captured.bodyJson : undefined;
const error = isRecord(body?.error) ? body.error : undefined;
if (typeof error?.[field] === "string") return error[field];
return typeof body?.[field] === "string" ? body[field] : undefined;
}
function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse | undefined): string {
const parts = [
error instanceof Error ? error.message : undefined,
capturedStringField(captured, "message"),
capturedStringField(captured, "param"),
capturedStringField(captured, "code"),
capturedStringField(captured, "type"),
captured?.bodyText,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return parts.join("\n");
}
const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i;
function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean {
const param = capturedStringField(captured, "param");
const code = capturedStringField(captured, "code");
const type = capturedStringField(captured, "type");
const message = collectMessageParts(error, captured);
return (
REASONING_EFFORT_FIELD_PATTERN.test(param ?? "") ||
REASONING_EFFORT_FIELD_PATTERN.test(code ?? "") ||
REASONING_EFFORT_FIELD_PATTERN.test(type ?? "") ||
REASONING_EFFORT_FIELD_PATTERN.test(message)
);
}
function isInvalidReasoningEffortError(
error: unknown,
captured: CapturedHttpErrorResponse | undefined,
currentEffort: string,
): boolean {
const status = extractHttpStatusFromError(error) ?? captured?.status;
if (status !== 400 && status !== 422) return false;
if (!mentionsReasoningEffort(error, captured)) return false;
const message = collectMessageParts(error, captured);
if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false;
if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true;
if (
/(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|must be|expected)/i.test(
message,
)
) {
return true;
}
if (/(?:unsupported|not supported)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) {
return true;
}
return new RegExp(
`(?:invalid|unsupported|not supported)[^\\n]*["'\`]${escapeRegExp(currentEffort)}["'\`]`,
"i",
).test(message);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseKnownReasoningValues(text: string): Set<string> {
const values = new Set<string>();
const quotedPattern = /["'`](none|minimal|low|medium|high|xhigh|max)["'`]/gi;
let quotedMatch = quotedPattern.exec(text);
while (quotedMatch !== null) {
values.add(quotedMatch[1]!.toLowerCase());
quotedMatch = quotedPattern.exec(text);
}
const allowedMatch = /(?:must be|one of|allowed values?|supported values?(?: are)?|expected)([^.\n]+)/i.exec(text);
if (allowedMatch) {
const allowedText = allowedMatch[1]!;
const barePattern = /\b(none|minimal|low|medium|high|xhigh|max)\b/gi;
let bareMatch = barePattern.exec(allowedText);
while (bareMatch !== null) {
values.add(bareMatch[1]!.toLowerCase());
bareMatch = barePattern.exec(allowedText);
}
}
return values;
}
function parseAllowedReasoningValues(message: string, currentEffort: string): Set<string> | undefined {
const values = parseKnownReasoningValues(message);
const hasAllowedCue = /must be|one of|allowed values?|supported values?|expected/i.test(message);
values.delete(currentEffort.toLowerCase());
if (!hasAllowedCue && values.size === 0) return undefined;
return values;
}
function orderedEnabledAllowedValues(allowed: Set<string>): string[] {
return ENABLED_REASONING_VALUES.filter(value => allowed.has(value));
}
function lowestEnabledAllowedValue(allowed: Set<string>): string | undefined {
for (const value of ENABLED_REASONING_VALUES) {
if (allowed.has(value)) return value;
}
return undefined;
}
function nearestEnabledReasoningFallback(currentEffort: string, allowed: Set<string>): string | undefined {
const current = currentEffort.toLowerCase();
const allowedEnabled = orderedEnabledAllowedValues(allowed);
if (allowedEnabled.length === 0) return undefined;
if (current === "minimal" && allowedEnabled.includes("low")) return "low";
if (current === "xhigh" && allowedEnabled.includes("max")) return "max";
if (current === "xhigh" && allowedEnabled.includes("high")) return "high";
if (current === "max" && allowedEnabled.includes("xhigh")) return "xhigh";
const currentRank = REASONING_VALUE_RANK[current];
if (currentRank === undefined) return undefined;
let best: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
let bestRank = Number.NEGATIVE_INFINITY;
for (const candidate of allowedEnabled) {
if (candidate === current) continue;
const candidateRank = REASONING_VALUE_RANK[candidate];
if (candidateRank === undefined) continue;
const distance = Math.abs(candidateRank - currentRank);
if (distance < bestDistance || (distance === bestDistance && candidateRank > bestRank)) {
best = candidate;
bestDistance = distance;
bestRank = candidateRank;
}
}
return best;
}
/** @internal */
export function resolveOpenAIReasoningEffortFallback(
error: unknown,
captured: CapturedHttpErrorResponse | undefined,
params: unknown,
options?: { explicitDisable?: boolean },
): OpenAIReasoningEffortFallback | undefined {
const currentEffort = readOpenAIReasoningEffort(params);
if (!currentEffort || !KNOWN_REASONING_VALUE[currentEffort.toLowerCase()]) return undefined;
if (!isInvalidReasoningEffortError(error, captured, currentEffort)) return undefined;
const message = collectMessageParts(error, captured);
const allowed = parseAllowedReasoningValues(message, currentEffort);
const normalizedCurrent = currentEffort.toLowerCase();
if (allowed === undefined) return null;
if (options?.explicitDisable) {
if (normalizedCurrent !== "none" && allowed.has("none")) return "none";
const fallback = lowestEnabledAllowedValue(allowed);
return fallback && fallback !== normalizedCurrent ? fallback : null;
}
if (normalizedCurrent === "none") return null;
return nearestEnabledReasoningFallback(normalizedCurrent, allowed) ?? null;
}
+82 -7
View File
@@ -44,6 +44,17 @@ import {
type OpenAIResponsesToolChoice,
} from "../utils/tool-choice";
import { compactGrammarDefinition } from "./grammar";
import {
applyOpenAIReasoningEffortFallback,
clearOpenAIReasoningEffortFallbackState,
createOpenAIReasoningEffortFallbackKey,
createOpenAIReasoningEffortFallbackState,
getOpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallback,
type OpenAIReasoningEffortFallbackState,
rememberOpenAIReasoningEffortFallback,
resolveOpenAIReasoningEffortFallback,
} from "./openai-reasoning-fallback";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
@@ -140,7 +151,10 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
/** Consecutive stale-previous-response failures before chaining is disabled for the session. */
const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState {
interface OpenAIResponsesProviderSessionState
extends ProviderSessionState,
OpenAIStrictToolsState,
OpenAIReasoningEffortFallbackState {
nativeHistoryReplayWarmed: boolean;
/** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
chains: Map<string, OpenAIResponsesChainState>;
@@ -164,14 +178,17 @@ interface OpenAIResponsesChainState {
function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
const strictToolsState = createOpenAIStrictToolsState();
const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
const state: OpenAIResponsesProviderSessionState = {
...strictToolsState,
...reasoningEffortFallbackState,
nativeHistoryReplayWarmed: false,
chains: new Map(),
close: () => {
state.nativeHistoryReplayWarmed = false;
state.chains.clear();
clearOpenAIStrictToolsState(state);
clearOpenAIReasoningEffortFallbackState(state);
},
};
return state;
@@ -385,6 +402,26 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const { trailingScaffoldingItems } = builtParams;
let activeParams = params;
let activeTrailingScaffoldingItems = trailingScaffoldingItems;
const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
const attemptedReasoningEffortFallbacks = new Set<string>();
let pendingReasoningEffortFallback: { key: string; fallback: OpenAIReasoningEffortFallback } | undefined;
let activeReasoningEffortFallbackKey: string | undefined;
let activeRequestParams: OpenAIResponsesSamplingParams | undefined;
const applyReasoningEffortFallbackForRequest = (requestParams: OpenAIResponsesSamplingParams): string => {
const fallbackKey = createOpenAIReasoningEffortFallbackKey(
"responses",
resolvedBaseUrl,
typeof requestParams.model === "string" ? requestParams.model : model.id,
);
const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(fallbackKey)
? requestReasoningEffortFallbacks.get(fallbackKey)
: getOpenAIReasoningEffortFallback(providerSessionState, fallbackKey);
if (requestReasoningEffortFallback !== undefined) {
applyOpenAIReasoningEffortFallback(requestParams, requestReasoningEffortFallback);
}
return fallbackKey;
};
if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) {
chainState = getOpenAIResponsesChainState(providerSessionState, model, baseUrl, routingSessionId);
if (!chainState.disabled) {
@@ -392,6 +429,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
params.store = true;
}
}
applyReasoningEffortFallbackForRequest(params);
let chained: OpenAIResponsesChainedParams =
chainState && !chainState.disabled
? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
@@ -402,12 +440,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const requestUrl = `${(baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "")}/responses`;
const requestUrl = `${resolvedBaseUrl}/responses`;
const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => {
const replacementPayload = await options?.onPayload?.(requestParams, model);
return replacementPayload !== undefined
? (replacementPayload as OpenAIResponsesSamplingParams)
: requestParams;
const payload =
replacementPayload !== undefined ? (replacementPayload as OpenAIResponsesSamplingParams) : requestParams;
applyReasoningEffortFallbackForRequest(payload);
return payload;
};
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
rawRequestDump = {
@@ -418,8 +457,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
url: requestUrl,
body: chained.params,
};
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) =>
callWithCopilotModelRetry(
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
"responses",
resolvedBaseUrl,
typeof requestParams.model === "string" ? requestParams.model : model.id,
);
activeRequestParams = requestParams;
return callWithCopilotModelRetry(
async () => {
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
@@ -458,6 +503,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
},
{ provider: model.provider, signal: requestSignal },
);
};
let openaiStream: AsyncIterable<ResponseStreamEvent>;
let strictRetryAvailable = true;
let activeStrictToolsApplied = builtParams.strictToolsApplied;
@@ -465,9 +511,37 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
while (true) {
try {
openaiStream = await openResponsesStream(chained.params);
if (pendingReasoningEffortFallback) {
rememberOpenAIReasoningEffortFallback(
providerSessionState,
pendingReasoningEffortFallback.key,
pendingReasoningEffortFallback.fallback,
);
pendingReasoningEffortFallback = undefined;
}
break;
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const reasoningEffortFallback =
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
})
: undefined;
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
rawRequestDump.body = chained.params;
pendingReasoningEffortFallback = {
key: activeReasoningEffortFallbackKey,
fallback: reasoningEffortFallback,
};
continue;
}
const compiledGrammarTooLarge =
isOpenRouterAnthropicModel(model) &&
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
@@ -687,6 +761,7 @@ export function buildParams(
model,
context,
strictResponsesPairing,
supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
nativeHistory: {
replay: shouldReplayNativeHistory,
filterReasoning: policy.reasoning.filterReasoningHistory,
+25 -3
View File
@@ -1178,9 +1178,24 @@ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseIn
return repaired;
}
/**
* Some Responses backends (notably GitHub Copilot) reject the OpenAI image
* `detail: "original"` value with a 400. When the model does not advertise
* support for it, degrade `"original"` to `"auto"` so the request still goes
* through with the closest valid fidelity instead of failing outright. See #2822.
*/
function clampResponsesImageDetail(
detail: ImageContent["detail"],
supportsImageDetailOriginal: boolean,
): ResponseInputImage["detail"] {
const resolved = detail ?? "auto";
return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved;
}
export function convertResponsesInputContent(
content: string | Array<TextContent | ImageContent>,
supportsImages: boolean,
supportsImageDetailOriginal: boolean,
): ResponseInputContent[] | undefined {
if (typeof content === "string") {
if (content.trim().length === 0) return undefined;
@@ -1200,7 +1215,7 @@ export function convertResponsesInputContent(
for (const item of imageBlocks) {
normalizedContent.push({
type: "input_image",
detail: item.detail ?? "auto",
detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal),
image_url: `data:${item.mimeType};base64,${item.data}`,
} satisfies ResponseInputImage);
}
@@ -1217,6 +1232,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
model: Model<TApi>;
context: Context;
strictResponsesPairing: boolean;
supportsImageDetailOriginal: boolean;
systemRole?: "system" | "developer";
nativeHistory?: {
replay: boolean;
@@ -1267,7 +1283,11 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
msgIndex++;
continue;
}
const content = convertResponsesInputContent(msg.content, options.model.input.includes("image"));
const content = convertResponsesInputContent(
msg.content,
options.model.input.includes("image"),
options.supportsImageDetailOriginal,
);
if (!content) continue;
messages.push({
role: "user",
@@ -1318,6 +1338,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
msg,
options.model,
options.strictResponsesPairing,
options.supportsImageDetailOriginal,
knownCallIds,
customCallIds,
);
@@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
toolResult: ToolResultMessage,
model: Model<TApi>,
strictResponsesPairing: boolean,
supportsImageDetailOriginal: boolean,
knownCallIds: ReadonlySet<string>,
customCallIds?: ReadonlySet<string>,
): void {
@@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: block.detail ?? "auto",
detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal),
image_url: `data:${block.mimeType};base64,${block.data}`,
} satisfies ResponseInputImage);
}
+2
View File
@@ -19,6 +19,7 @@ const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
id: provider.id,
name: provider.name,
available: provider.available ?? true,
storeCredentialsAs: provider.storeCredentialsAs,
}));
const customOAuthProviders = new Map<string, OAuthProviderInterface>();
@@ -163,6 +164,7 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
id: provider.id,
name: provider.name,
available: true,
storeCredentialsAs: provider.storeCredentialsAs,
}));
return [...builtInOAuthProviders, ...customProviders];
}
+6
View File
@@ -31,6 +31,12 @@ export interface OAuthProviderInfo {
id: OAuthProviderId;
name: string;
available: boolean;
/**
* Provider id the login stores credentials under, when it differs from `id`
* (e.g. `openai-codex-device` ⇒ `openai-codex`). Lets callers map a login
* entry back to the model provider it authenticates.
*/
storeCredentialsAs?: string;
}
export interface OAuthController {
+85
View File
@@ -152,6 +152,7 @@ function arkJsonAstToWire(value: unknown): unknown {
const kZodWireSchema = Symbol("pi.schema.zod.wire");
const kJsonWireSchema = Symbol("pi.schema.json.wire");
const kArkWireSchema = Symbol("pi.schema.ark.wire");
const kStrippedSchema = Symbol("pi.schema.descriptions.stripped");
/**
* Post-process Zod-emitted JSON Schema so it matches the wire shape providers
@@ -634,3 +635,87 @@ export function toolWireSchema(tool: Tool): Record<string, unknown> {
return postProcessJsonSchema(upgraded);
});
}
/**
* Schema-valued keywords whose value is a single subschema (or an array of
* subschemas — the recursion dispatches on array-ness, so tuple forms like
* draft-07 `items: []` are handled too). Covers the draft 2020-12 surface plus
* the legacy `additionalItems` that may survive an incomplete upgrade.
*/
const STRIP_SCHEMA_VALUE_KEYS = [
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"items",
"additionalItems",
"contains",
"propertyNames",
"contentSchema",
"if",
"then",
"else",
"not",
"anyOf",
"oneOf",
"allOf",
"prefixItems",
] as const;
/** Keywords whose value is a `{ name: Schema }` map — names are NOT annotations. */
const STRIP_SCHEMA_MAP_KEYS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"] as const;
/**
* Recursively strip human-readable `description` annotations from a JSON Schema,
* descending only through schema-valued keywords so a property literally named
* `"description"` inside a `properties`/`$defs` map keeps its schema (only its own
* annotation is dropped), and data-bearing keywords (`default`/`const`/`examples`)
* are never traversed. Mutates `node` in place — callers pass a clone.
*/
function stripSchemaDescriptionsInPlace(node: unknown): void {
if (Array.isArray(node)) {
for (const child of node) stripSchemaDescriptionsInPlace(child);
return;
}
if (!isSchemaRecord(node)) return;
delete node.description;
for (const key of STRIP_SCHEMA_VALUE_KEYS) {
if (Object.hasOwn(node, key)) stripSchemaDescriptionsInPlace(node[key]);
}
for (const mapKey of STRIP_SCHEMA_MAP_KEYS) {
const map = node[mapKey];
if (isSchemaRecord(map)) {
for (const key in map) stripSchemaDescriptionsInPlace(map[key]);
}
}
}
/**
* Return a deep clone of `schema` with every `description` annotation removed.
* The result is memoized on the input via a non-enumerable symbol (`stamp`) so
* repeated provider requests reuse the same stripped object; the input is never
* mutated, so the stamped `toolWireSchema` cache stays intact for
* system-prompt/UI rendering.
*/
export function stripSchemaDescriptions(schema: Record<string, unknown>): Record<string, unknown> {
return stamp(schema, kStrippedSchema, source => {
const clone = structuredClone(source);
stripSchemaDescriptionsInPlace(clone);
return clone;
});
}
/**
* Strip a tool's human-readable text from its provider-bound spec: empties the
* top-level `description` and removes nested schema `description` annotations.
* Used when the full tool catalog is rendered into the system prompt instead, so
* the descriptions ride the wire once (in the prompt) rather than duplicated on
* every tool definition. Parameters are resolved to wire JSON Schema and cloned,
* leaving the original tool objects and the stamped schema cache untouched.
*/
export function stripToolDescriptions(tools: readonly Tool[]): Tool[] {
return tools.map(tool => ({
...tool,
description: "",
parameters: stripSchemaDescriptions(toolWireSchema(tool)),
}));
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from "bun:test";
import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic";
import type {
AssistantMessage,
Message,
Model,
ModelSpec,
ToolResultMessage,
UserMessage,
} from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
/**
* End-to-end encoder contract for #2851. GitHub Copilot's `anthropic-messages`
* proxy forwards to signature-enforcing Anthropic, so after the fix it is a
* SIGNING endpoint (`replayUnsignedThinking: false` — see the catalog compat
* regression test). This pins the consequence: when a checkpoint/branch-return
* turn is an abandoned tool-use turn (adaptive Opus emits a tool call then ends
* on `stop`), the transform strips its end_turn-bound signature and the encoder
* must DEGRADE the block to text — never replay it as `{ signature: "" }`, which
* 400s the whole request with "Invalid signature" on every full re-send.
*
* The signing classification is asserted directly in
* `packages/catalog/test/anthropic-copilot-signing-compat.test.ts`; the explicit
* `compat` override here models that post-fix classification so the encoder
* behavior is exercised independently of cross-package module resolution.
*/
function copilotSigningModel(): Model<"anthropic-messages"> {
return buildModel({
api: "anthropic-messages",
provider: "github-copilot",
id: "claude-opus-4.8",
name: "Claude Opus 4.8",
baseUrl: "https://api.githubcopilot.com",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 8_192,
contextWindow: 200_000,
reasoning: true,
// Post-#2851 classification: github-copilot is a signing endpoint.
compat: { replayUnsignedThinking: false },
} as ModelSpec<"anthropic-messages">);
}
const emptyUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
type WireBlock = { type: string; signature?: string; thinking?: string; text?: string; id?: string };
interface WireParam {
role: string;
content: string | WireBlock[];
}
function copilotAssistant(
content: AssistantMessage["content"],
overrides: Partial<AssistantMessage> = {},
): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "github-copilot",
model: "claude-opus-4.8",
usage: emptyUsage,
stopReason: "stop",
timestamp: 0,
...overrides,
};
}
/** Every thinking block emitted in the whole request, across all params. */
function allThinkingBlocks(params: WireParam[]): WireBlock[] {
const blocks: WireBlock[] = [];
for (const p of params) {
if (!Array.isArray(p.content)) continue;
for (const b of p.content) {
if (b.type === "thinking") blocks.push(b);
}
}
return blocks;
}
function assistantBlocksAt(params: WireParam[], index: number): WireBlock[] {
const assistants = params.filter(p => p.role === "assistant");
const content = assistants[index]?.content;
return Array.isArray(content) ? content : [];
}
describe("#2851 github-copilot checkpoint/branch-return thinking signature", () => {
it("classifies the explicit signing model as non-replay (sanity)", () => {
expect(copilotSigningModel().compat.replayUnsignedThinking).toBe(false);
});
it("never emits an empty-signature thinking block for a historical checkpoint turn", () => {
const model = copilotSigningModel();
// The store holds a full (~14.6k char) Anthropic signature for the checkpoint
// turn's thinking; the turn ended on `stop` (abandoned tool-use), so its
// signature is end_turn-bound and gets stripped on replay.
const checkpointSig = "real_anthropic_signature_".repeat(600);
const messages: Message[] = [
{ role: "user", content: "investigate the flaky test", timestamp: 1 } satisfies UserMessage,
copilotAssistant(
[
{ type: "thinking", thinking: "Checkpoint first, then explore.", thinkingSignature: checkpointSig },
{ type: "toolCall", id: "toolu_ckpt", name: "checkpoint", arguments: { goal: "find the flake" } },
],
{ stopReason: "stop", timestamp: 2 },
),
{
role: "toolResult",
toolCallId: "toolu_ckpt",
toolName: "checkpoint",
content: [{ type: "text", text: "checkpoint started" }],
isError: false,
timestamp: 3,
} satisfies ToolResultMessage,
// branch-return summary surfaces as a user turn in the rebuilt history
{ role: "user", content: "[branch summary] explored, found the race", timestamp: 4 } satisfies UserMessage,
copilotAssistant([{ type: "text", text: "Here's the fix." }], { stopReason: "stop", timestamp: 5 }),
];
const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[];
// (a) Anthropic's all-or-none contract: no thinking block may carry an empty signature.
for (const block of allThinkingBlocks(params)) {
expect(block.signature && block.signature.length > 0).toBeTruthy();
}
// The stripped checkpoint thinking is preserved as text (reasoning not silently lost),
// and its tool_use stays paired with the appended tool_result.
const checkpointBlocks = assistantBlocksAt(params, 0);
expect(checkpointBlocks.some(b => b.type === "text" && b.text?.includes("Checkpoint first"))).toBe(true);
expect(checkpointBlocks.some(b => b.type === "thinking")).toBe(false);
expect(checkpointBlocks.some(b => b.type === "tool_use" && b.id === "toolu_ckpt")).toBe(true);
});
it("still replays a signed historical thinking block natively (no regression to the common case)", () => {
const model = copilotSigningModel();
// A clean tool-use turn (stopReason "toolUse") keeps a replayable signature.
const messages: Message[] = [
{ role: "user", content: "read the file", timestamp: 1 } satisfies UserMessage,
copilotAssistant(
[
{ type: "thinking", thinking: "I'll read README.", thinkingSignature: "sig_replayable" },
{ type: "toolCall", id: "toolu_read", name: "read", arguments: { path: "README.md" } },
],
{ stopReason: "toolUse", timestamp: 2 },
),
{
role: "toolResult",
toolCallId: "toolu_read",
toolName: "read",
content: [{ type: "text", text: "file body" }],
isError: false,
timestamp: 3,
} satisfies ToolResultMessage,
{ role: "user", content: "now summarize", timestamp: 4 } satisfies UserMessage,
copilotAssistant([{ type: "text", text: "Summary." }], { stopReason: "stop", timestamp: 5 }),
];
const params = convertAnthropicMessages(messages, model, false) as unknown as WireParam[];
for (const block of allThinkingBlocks(params)) {
expect(block.signature && block.signature.length > 0).toBeTruthy();
}
const firstAssistant = assistantBlocksAt(params, 0);
expect(firstAssistant.some(b => b.type === "thinking" && b.signature === "sig_replayable")).toBe(true);
});
});
+20 -2
View File
@@ -659,8 +659,17 @@ describe("history replay: custom_tool_call round-trip", () => {
};
const knownCallIds = new Set<string>(["call_1"]);
const customCallIds = new Set<string>(["call_1"]);
const model = makeModel();
appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds);
appendResponsesToolResultMessages(
messages as never,
toolResult,
model,
true,
model.compat.supportsImageDetailOriginal,
knownCallIds,
customCallIds,
);
expect(messages).toHaveLength(1);
const item = messages[0] as { type: string; call_id: string; output: string };
@@ -681,8 +690,17 @@ describe("history replay: custom_tool_call round-trip", () => {
};
const knownCallIds = new Set<string>(["call_2"]);
const customCallIds = new Set<string>(); // call_2 not custom
const model = makeModel();
appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds);
appendResponsesToolResultMessages(
messages as never,
toolResult,
model,
true,
model.compat.supportsImageDetailOriginal,
knownCallIds,
customCallIds,
);
const item = messages[0] as { type: string };
expect(item.type).toBe("function_call_output");
@@ -2,16 +2,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage, type OAuthCredential, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import {
AuthBrokerClient,
type AuthBrokerServerHandle,
AuthStorage,
type OAuthCredential,
REMOTE_REFRESH_SENTINEL,
RemoteAuthCredentialStore,
SqliteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai";
} from "@oh-my-pi/pi-ai/auth-broker";
// MCP OAuth credentials extend the base OAuthCredential with refresh material
// (tokenUrl/clientId/clientSecret/resource) embedded so token refresh works for
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import { AuthBrokerRefresher } from "@oh-my-pi/pi-ai/auth-broker";
import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth";
const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const;
@@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import {
AuthBrokerClient,
type AuthBrokerServerHandle,
AuthStorage,
REMOTE_REFRESH_SENTINEL,
RemoteAuthCredentialStore,
type SnapshotResponse,
SqliteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai";
} from "@oh-my-pi/pi-ai/auth-broker";
const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const;
const savedEnv: Partial<Record<(typeof ANTHROPIC_ENV)[number], string | undefined>> = {};

Some files were not shown because too many files have changed in this diff Show More