Merge upstream main into feat/auth-broker-account-pools
This commit is contained in:
@@ -290,6 +290,13 @@ Three standalone, lowercase words opt a turn into specialized agent behavior:
|
||||
|
||||
They trigger only in prose, not inside code spans, fenced code blocks, XML/HTML sections, identifiers, or paths. See [Magic keywords](docs/magic-keywords.md) for exact matching rules and configuration.
|
||||
|
||||
### Session controls
|
||||
|
||||
Slash commands shift how a whole session runs:
|
||||
|
||||
- `/vibe` — enter [Vibe mode](docs/vibe-mode.md): act as a director driving persistent `fast`/`good` worker sessions with a `read`-only toolset.
|
||||
- `/fresh` — reset the provider stream state (stale prompt cache, wedged stream) without changing the local transcript. See [Session operations](docs/session-operations-export-share-fork-resume.md#fresh).
|
||||
|
||||
## Forty-plus providers, hundreds of models, _one /model away_.
|
||||
|
||||
Roles route work by intent. `default` for normal turns. `smol` for cheap subagent fan-out. `slow` for deep reasoning. `plan` for plan mode. `commit` for changelogs. Override at launch with `--smol`, `--slow`, or `--plan`; cycle through the configured models for the active role with `Ctrl+P`. Swap the active model mid-session with the `/model` slash command.
|
||||
|
||||
@@ -155,6 +155,31 @@ Cancellation/abort semantics in share:
|
||||
- The upload itself is not aborted mid-flight; cancellation is UI-level and
|
||||
checked after the upload returns.
|
||||
|
||||
## Fresh
|
||||
|
||||
Interactive `/fresh` resets the provider-facing stream state of the current
|
||||
session **without touching the local transcript, session file, or header**. Use
|
||||
it to recover from a wedged or corrupted provider stream (stale prompt cache,
|
||||
a mid-turn glitch, or a server-side conversation id that has drifted) while
|
||||
keeping the conversation you can see.
|
||||
|
||||
`AgentSession.freshSession()`:
|
||||
|
||||
- Is rejected while the agent is streaming — wait for the response to finish or
|
||||
abort it first.
|
||||
- Closes every cached provider-session state entry (server-side conversation /
|
||||
prompt-cache handles) and reports how many were pruned.
|
||||
- Mints a fresh provider session id and re-keys hindsight and mnemopi memory to
|
||||
it, and invalidates the append-only context so the next turn re-sends the full
|
||||
local transcript to the provider.
|
||||
- Leaves the local transcript, session file, and session identity unchanged, so
|
||||
nothing you have said or received is lost.
|
||||
|
||||
Because it keeps the current session file, `/fresh` differs from `/new` (start a
|
||||
brand-new empty session) and `/drop` (delete the current session and start a new
|
||||
one): only `/fresh` preserves the visible history while giving the provider a
|
||||
clean slate.
|
||||
|
||||
## Fork
|
||||
|
||||
Interactive `/fork` creates a new session from the current one and switches the active session identity.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Vibe mode
|
||||
|
||||
Vibe mode turns the session into a **director** that drives persistent background
|
||||
worker sessions instead of editing code itself. In vibe mode your own toolset is
|
||||
stripped down to `read` plus five worker-control tools; the workers do the
|
||||
grepping, editing, running, and building, and you verify their work by reading
|
||||
the files they touch.
|
||||
|
||||
## Enabling and disabling
|
||||
|
||||
Toggle it with the `/vibe` slash command:
|
||||
|
||||
```text
|
||||
/vibe # enter vibe mode
|
||||
/vibe fix the flaky test in packages/tui # enter and submit a first directive
|
||||
/vibe # run again to exit
|
||||
```
|
||||
|
||||
- Entering installs the vibe tools, reduces the active toolset to `read` + the
|
||||
vibe tools, and injects the director instructions for the turn.
|
||||
- An inline prompt (`/vibe <prompt>`) enters the mode and submits that prompt as
|
||||
the first directive.
|
||||
- Exiting restores the previous toolset and **kills every worker session** — a
|
||||
worker never outlives the mode that directs it.
|
||||
- Vibe mode is mutually exclusive with plan mode and goal mode; exit those
|
||||
first. The status line shows a `Vibe` indicator while it is on.
|
||||
|
||||
## The two worker tiers
|
||||
|
||||
Every worker is a full coding agent with the normal tool surface. You choose a
|
||||
tier when you spawn one:
|
||||
|
||||
| Tier | Backing agent | Model role | Use for |
|
||||
|--------|---------------|------------|---------|
|
||||
| `fast` | `sonic` | `@smol` (low-latency role) | Mechanical execution, drafts, high-volume work |
|
||||
| `good` | `task` | `@task` (the session's strong model) | Design, judgment calls, reviewing `fast` output |
|
||||
|
||||
Model resolution follows the same path as a `task` spawn, so
|
||||
`task.agentModelOverrides` and your model-role settings apply.
|
||||
|
||||
## Worker-control tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `vibe_spawn` | Start a worker (`fast` or `good`) with a complete, self-contained brief. Workers start blank — they never see the director's conversation. |
|
||||
| `vibe_send` | Send a follow-up turn to a worker: a correction, the next step, or a review request. |
|
||||
| `vibe_wait` | Block until a worker settles its next turn. Sends and spawns return immediately; results arrive on their own, so call `vibe_wait` only when you cannot proceed without one. |
|
||||
| `vibe_kill` | Tear down a worker that is stuck or whose workstream is done. |
|
||||
| `vibe_list` | List the active worker roster when you lose track of it. |
|
||||
|
||||
A spawn or send returns immediately; the worker's turn result is delivered back
|
||||
into the director's conversation on its own, exactly like an async `task`
|
||||
result. Running one `fast` and one `good` worker on different workstreams
|
||||
concurrently is the normal shape.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Split the request into independent workstreams — one worker session per
|
||||
workstream so each builds useful local context.
|
||||
2. `vibe_spawn` with a self-contained brief: files, constraints, acceptance
|
||||
criteria.
|
||||
3. Keep directing other workers while turns are in flight; `vibe_wait` only when
|
||||
blocked.
|
||||
4. When a turn result arrives, `read` the touched files to verify claims before
|
||||
building on them, then `vibe_send` the next step.
|
||||
5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls
|
||||
or the problem needs judgment.
|
||||
6. `vibe_kill` finished or stuck workers; `vibe_list` to recover the roster.
|
||||
|
||||
You stay responsible for the final outcome — verify with `read`, never take a
|
||||
worker's word for it.
|
||||
@@ -6,11 +6,19 @@
|
||||
|
||||
- Queued steering no longer hard-aborts non-interruptible tools (e.g. `bash`): it aborts interruptible waits only and raises a cooperative steering signal (`ToolCallContext.steeringSignal`) that long-running tools may observe to finish early or background themselves. The mid-batch steering/IRC watch now runs for every tool batch instead of only batches containing an interruptible tool.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an unbounded allocation loop when a steer (or follow-up) was queued on a session with an empty transcript: `Agent.continue()` now delivers the queued message as the opening turn instead of throwing, so idle-drain callers no longer respin `continue()` on every microtask until OOM ([#6344](https://github.com/can1357/oh-my-pi/issues/6344)).
|
||||
- Fixed provider-switched sessions being stranded without their remotely-compacted history: compaction now judges whether a prior OpenAI remote-compaction replay payload can be reused against the active model rather than the whole candidate set, so switching to a model that cannot replay the payload re-expands the originals into a portable local summary instead of leaving the model with only a placeholder ([#6343](https://github.com/can1357/oh-my-pi/issues/6343)).
|
||||
|
||||
## [17.0.8] - 2026-07-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved resilience against transient stream JSON parse failures by recovering completed tool calls while safely preventing incomplete, unknown, refused, or sensitive calls from executing.
|
||||
### Fixed
|
||||
|
||||
- Fixed compaction/summarization serializing prior assistant reasoning back to Claude as text (rendered verbatim inside `<thinking>` tags for the `anthropic` dialect), which tripped Anthropic's `reasoning_extraction` refusal and blocked compaction on Fable 5 sessions; `serializeConversation` now drops `thinking` blocks for Anthropic-dialect summary targets while other dialects (e.g. Harmony) keep their native reasoning ([#6093](https://github.com/can1357/oh-my-pi/issues/6093)).
|
||||
|
||||
## [17.0.5] - 2026-07-18
|
||||
|
||||
|
||||
@@ -1047,6 +1047,22 @@ export class Agent {
|
||||
|
||||
const messages = this.#state.messages;
|
||||
if (messages.length === 0) {
|
||||
// An empty transcript has nothing to resume, but a queued steer/follow-up
|
||||
// must still be delivered as the opening turn — mirroring the assistant-tail
|
||||
// branch below. Throwing here leaves the message undeliverable, and idle-drain
|
||||
// callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every
|
||||
// microtask because hasQueuedMessages() never clears, spinning an unbounded
|
||||
// allocation loop until OOM (issue #6344).
|
||||
const queuedSteering = this.#dequeueSteeringMessages();
|
||||
if (queuedSteering.length > 0) {
|
||||
await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true });
|
||||
return;
|
||||
}
|
||||
const queuedFollowUp = this.#dequeueFollowUpMessages();
|
||||
if (queuedFollowUp.length > 0) {
|
||||
await this.#runLoop(queuedFollowUp);
|
||||
return;
|
||||
}
|
||||
throw new Error("No messages to continue from");
|
||||
}
|
||||
if (messages[messages.length - 1].role === "assistant") {
|
||||
|
||||
@@ -1130,33 +1130,37 @@ export interface CompactionPreparation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a prior compaction's preserve data can be carried forward by the
|
||||
* upcoming compaction. A local compaction (no remote preserve) always can — it
|
||||
* holds a real textual summary. A remote compaction (V2 or V1) only can when
|
||||
* some candidate model shares its provider AND remote replay is still enabled;
|
||||
* otherwise its provider-native replay is dead weight and only the opaque
|
||||
* placeholder summary survives, so the caller must re-expand the originals.
|
||||
* Whether a prior remote compaction's provider-native replay can still be read
|
||||
* by the active model — the model that assembles the request context on every
|
||||
* turn. A local compaction (no remote preserve) always can: it holds a real
|
||||
* textual summary. A remote compaction (V2 or V1) only can when the active model
|
||||
* shares the blob's provider AND remote replay is still enabled; otherwise the
|
||||
* active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
|
||||
* and only the opaque placeholder summary survives, so the caller must re-expand
|
||||
* the originals into a portable local summary rather than strand that history.
|
||||
*
|
||||
* Judged against the ACTIVE model, not the compaction candidate set: a role
|
||||
* model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
|
||||
* let the active model replay it, so keying reuse on "any candidate shares the
|
||||
* provider" left a provider-switched session permanently context-less (#6343).
|
||||
*/
|
||||
function remotePreserveReusableByAny(
|
||||
function remotePreserveReusable(
|
||||
preserveData: Record<string, unknown> | undefined,
|
||||
models: readonly Model[],
|
||||
activeModel: Model,
|
||||
settings: CompactionSettings,
|
||||
): boolean {
|
||||
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
||||
if (!remote) return true;
|
||||
if (settings.remoteEnabled === false) return false;
|
||||
for (const model of models) {
|
||||
if (remote.provider !== model.provider) continue;
|
||||
const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
|
||||
if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
|
||||
}
|
||||
return false;
|
||||
if (remote.provider !== activeModel.provider) return false;
|
||||
const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
|
||||
return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
|
||||
}
|
||||
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionEntry[],
|
||||
settings: CompactionSettings,
|
||||
compactionModels: readonly Model[] = [],
|
||||
activeModel?: Model,
|
||||
): CompactionPreparation | undefined {
|
||||
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return undefined;
|
||||
@@ -1165,13 +1169,13 @@ export function prepareCompaction(
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
||||
if (pathEntries[i].type !== "compaction") continue;
|
||||
// Skip a prior remote compaction (V2 or V1) whose provider-native replay
|
||||
// none of the upcoming compaction candidates can reuse: its summary is only
|
||||
// an opaque placeholder, so re-expand its original messages and summarize
|
||||
// them locally rather than stranding that history. compact() still reuses it
|
||||
// when a candidate can (same provider, remote enabled).
|
||||
// Skip a prior remote compaction (V2 or V1) whose provider-native replay the
|
||||
// active model cannot read: its summary is only an opaque placeholder, so
|
||||
// re-expand its original messages and summarize them locally rather than
|
||||
// stranding that history. compact() still reuses the payload when the active
|
||||
// model can replay it (same provider, remote enabled).
|
||||
const entry = pathEntries[i] as CompactionEntry;
|
||||
if (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
|
||||
if (activeModel && !remotePreserveReusable(entry.preserveData, activeModel, settings)) {
|
||||
continue;
|
||||
}
|
||||
prevCompactionIndex = i;
|
||||
@@ -1502,7 +1506,7 @@ export async function compact(
|
||||
// summarization so a successful remote compaction never pays for a second,
|
||||
// redundant LLM round. If a LATER compaction cannot reuse this payload,
|
||||
// prepareCompaction re-expands the original messages and summarizes them
|
||||
// locally then (see remotePreserveReusableByAny).
|
||||
// locally then (see remotePreserveReusable).
|
||||
const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
|
||||
summary =
|
||||
"Remote compaction preserved provider-native history for this session." +
|
||||
|
||||
@@ -234,10 +234,21 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
|
||||
}
|
||||
}
|
||||
if (dialect) {
|
||||
// Claude's classifier refuses inputs that reproduce the model's own
|
||||
// reasoning as text ("reasoning_extraction"), and the anthropic dialect
|
||||
// otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
|
||||
// ephemeral and low-signal for a summary, so drop it from Anthropic-target
|
||||
// summary input. Other dialects (e.g. Harmony) carry reasoning natively in
|
||||
// their transcript format and keep it.
|
||||
const dropThinking = dialect === "anthropic";
|
||||
const processed: Message[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const content = msg.content.filter(block => block.type !== "toolCall" || !uselessCallIds.has(block.id));
|
||||
const content = msg.content.filter(
|
||||
block =>
|
||||
(block.type !== "toolCall" || !uselessCallIds.has(block.id)) &&
|
||||
(!dropThinking || block.type !== "thinking"),
|
||||
);
|
||||
if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
||||
|
||||
/**
|
||||
* Regression: a `steer` (or follow-up) queued on an empty transcript must be
|
||||
* delivered as the opening turn instead of leaving `continue()` to throw
|
||||
* `No messages to continue from`. Before the fix the steer stayed queued, so the
|
||||
* RPC idle-drain re-armed `continue()` on every microtask (gated only on
|
||||
* `hasQueuedMessages()`), an unbounded allocation loop that OOM-killed the
|
||||
* process (issue #6344).
|
||||
*/
|
||||
describe("Agent.continue() on an empty transcript", () => {
|
||||
it("runs a queued steer as the opening turn and drains the queue", async () => {
|
||||
const mock = createMockModel({ responses: [{ content: ["Answer"] }] });
|
||||
const agent = new Agent({ streamFn: mock.stream });
|
||||
|
||||
agent.steer({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await expect(agent.continue()).resolves.toBeUndefined();
|
||||
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(agent.hasQueuedMessages()).toBe(false);
|
||||
expect(agent.state.messages.map(m => m.role)).toEqual(["user", "assistant"]);
|
||||
const steerDelivered = agent.state.messages.some(
|
||||
m =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some(part => part.type === "text" && part.text === "hello"),
|
||||
);
|
||||
expect(steerDelivered).toBe(true);
|
||||
});
|
||||
|
||||
it("runs a queued follow-up as the opening turn and drains the queue", async () => {
|
||||
const mock = createMockModel({ responses: [{ content: ["Answer"] }] });
|
||||
const agent = new Agent({ streamFn: mock.stream });
|
||||
|
||||
agent.followUp({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "later" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await expect(agent.continue()).resolves.toBeUndefined();
|
||||
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(agent.hasQueuedMessages()).toBe(false);
|
||||
expect(agent.state.messages.map(m => m.role)).toEqual(["user", "assistant"]);
|
||||
});
|
||||
|
||||
it("still throws when the transcript is empty and nothing is queued", async () => {
|
||||
const agent = new Agent();
|
||||
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
||||
});
|
||||
});
|
||||
@@ -1256,18 +1256,89 @@ describe("compact() remote compaction failure handling", () => {
|
||||
const baseSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 };
|
||||
|
||||
// Remote disabled → the V2 replay is unusable → re-expand the pre-V2 original.
|
||||
const reexpanded = prepareCompaction(entries, { ...baseSettings, remoteEnabled: false }, [v2Model]);
|
||||
const reexpanded = prepareCompaction(entries, { ...baseSettings, remoteEnabled: false }, v2Model);
|
||||
expect(reexpanded).toBeDefined();
|
||||
const reexpandedText = JSON.stringify(reexpanded?.messagesToSummarize ?? []);
|
||||
expect(reexpandedText).toContain("ORIGINAL ALPHA port 4242");
|
||||
|
||||
// Remote + V2 still enabled, same provider → reuse the replay, don't re-summarize originals.
|
||||
const reused = prepareCompaction(entries, { ...baseSettings, remoteStreamingV2Enabled: true }, [v2Model]);
|
||||
const reused = prepareCompaction(entries, { ...baseSettings, remoteStreamingV2Enabled: true }, v2Model);
|
||||
expect(reused).toBeDefined();
|
||||
const reusedText = JSON.stringify(reused?.messagesToSummarize ?? []);
|
||||
expect(reusedText).not.toContain("ORIGINAL ALPHA port 4242");
|
||||
});
|
||||
|
||||
test("re-expands a stranded remote compaction when the active model cannot replay it (#6343)", () => {
|
||||
const ts = (n: number) => new Date(n).toISOString();
|
||||
const anthropicActive = buildModel({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 64_000,
|
||||
});
|
||||
const openaiSmol = makeOpenAiModel({ id: "gpt-5-mini", name: "GPT-5 mini" });
|
||||
// Prior OpenAI remote compaction: opaque placeholder summary, provider-native
|
||||
// replay stored under preserveData tagged "openai".
|
||||
const entries: SessionEntry[] = [
|
||||
{
|
||||
type: "message",
|
||||
id: "m1",
|
||||
parentId: null,
|
||||
timestamp: ts(1),
|
||||
message: { role: "user", content: "ORIGINAL ALPHA port 4242", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "compaction",
|
||||
id: "c1",
|
||||
parentId: "m1",
|
||||
timestamp: ts(2),
|
||||
summary: "Remote compaction preserved provider-native history for this session.",
|
||||
firstKeptEntryId: "m1",
|
||||
tokensBefore: 100_000,
|
||||
preserveData: {
|
||||
openaiRemoteCompaction: {
|
||||
provider: "openai",
|
||||
replacementHistory: [{ type: "message", role: "user", content: "opaque native replay" }],
|
||||
compactionItem: { type: "compaction", encrypted_content: "enc_v1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "m2",
|
||||
parentId: "c1",
|
||||
timestamp: ts(3),
|
||||
message: { role: "user", content: "second turn", timestamp: 3 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "m3",
|
||||
parentId: "m2",
|
||||
timestamp: ts(4),
|
||||
message: { role: "user", content: "third turn", timestamp: 4 },
|
||||
},
|
||||
];
|
||||
const settings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 };
|
||||
// Reuse is judged by the ACTIVE model, not the candidate set. The active
|
||||
// anthropic model's encoder drops the OpenAI replay payload, so the stranded
|
||||
// originals are re-expanded into a portable local summary — even though the
|
||||
// OpenAI smol role could still replay the blob.
|
||||
const foreignActive = prepareCompaction(entries, settings, anthropicActive);
|
||||
expect(foreignActive).toBeDefined();
|
||||
expect(JSON.stringify(foreignActive?.messagesToSummarize ?? [])).toContain("ORIGINAL ALPHA port 4242");
|
||||
// The same-provider OpenAI model can replay the payload, so the boundary is
|
||||
// kept and the originals are not re-summarized.
|
||||
const sameProviderActive = prepareCompaction(entries, settings, openaiSmol);
|
||||
expect(sameProviderActive).toBeDefined();
|
||||
expect(JSON.stringify(sameProviderActive?.messagesToSummarize ?? [])).not.toContain("ORIGINAL ALPHA port 4242");
|
||||
});
|
||||
|
||||
test("user abort during the remote compact request rejects without falling back to local summarization", async () => {
|
||||
// Contract: Esc is a cancellation, not a remote failure. Before the fix
|
||||
// the AbortError was swallowed by the fallback catch and compaction kept
|
||||
|
||||
@@ -130,4 +130,39 @@ describe("serializeConversation — useless pairs", () => {
|
||||
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("strips assistant reasoning from Anthropic-dialect summary input but keeps text and tool calls", () => {
|
||||
const reasoning = "PRIVATE chain of thought that must not be replayed to Claude";
|
||||
const out = serializeConversation(
|
||||
[
|
||||
assistantMessage([
|
||||
{ type: "thinking", thinking: reasoning },
|
||||
{ type: "text", text: "The visible answer." },
|
||||
{ type: "toolCall", id: "c1", name: "search", arguments: { pattern: "delta" } },
|
||||
]),
|
||||
],
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
expect(out).not.toContain(reasoning);
|
||||
expect(out).not.toContain("<thinking>");
|
||||
expect(out).toContain("The visible answer.");
|
||||
expect(out).toContain("<function_calls>");
|
||||
});
|
||||
|
||||
test("keeps assistant reasoning for non-Anthropic dialects", () => {
|
||||
const reasoning = "reasoning kept for the XML transcript";
|
||||
const out = serializeConversation(
|
||||
[
|
||||
assistantMessage([
|
||||
{ type: "thinking", thinking: reasoning },
|
||||
{ type: "text", text: "answer" },
|
||||
]),
|
||||
],
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(out).toContain(reasoning);
|
||||
expect(out).toContain("<thinking>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Bedrock cache checkpoints to use resolved model compatibility: unsupported 1-hour retention now falls back to the provider-default 5-minute cache, bundled Nova Lite, Micro, Pro, and Premier requests—and Nova Premier's documented in-region model ID—emit AWS-recommended explicit checkpoints for cache savings, and forced opaque profiles remain conservative.
|
||||
- Fixed outbound credential-pattern redaction (`[github_token_redacted]` & co.) running unconditionally: it is now opt-in via `configureCredentialRedaction` and disabled by default, so credential-shaped strings the user deliberately pastes reach the provider unmodified unless the host enables redaction.
|
||||
- Added interactive Meta Model API key login and `MODEL_API_KEY` / `META_API_KEY` environment authentication ([#4941](https://github.com/can1357/oh-my-pi/issues/4941)).
|
||||
- Fixed SuperGrok (`xai-oauth`) `/usage` showing "no usage data" for unified-billing accounts: when `?format=credits` lacks `creditUsagePercent` (or marks `isUnifiedBillingUser`), fall back to / merge the default monthly `monthlyLimit`/`used` payload.
|
||||
- Fixed sessions wedging onto their fallback model with `400 Invalid \`signature\` in \`thinking\` block` after switching to an Anthropic signing endpoint while the latest assistant turn came from a different Anthropic-compatible provider (e.g. Kimi k3). The cross-model thinking-signature strip skipped the latest surviving assistant turn entirely, replaying the foreign signature verbatim on every attempt; the latest turn now strips signatures whose issuing provider differs from the target (same-provider switches keep their byte-for-byte latest turn), and foreign `redacted_thinking` siblings are dropped alongside instead of riding the wire unverifiable.
|
||||
- Fixed OAuth callback servers aborting login when an invalid callback arrives before the legitimate browser redirect, and restricted `localhost` callback listeners to the IPv4 loopback interface ([#4106](https://github.com/can1357/oh-my-pi/issues/4106)).
|
||||
|
||||
## [17.0.9] - 2026-07-23
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ type Block = (TextContent | ThinkingContent | ToolCall) & {
|
||||
interface CachePoint {
|
||||
cachePoint: { type: "default"; ttl?: "5m" | "1h" };
|
||||
}
|
||||
|
||||
interface BedrockPromptCachePolicy {
|
||||
emitCheckpoints: boolean;
|
||||
ttl?: "1h";
|
||||
}
|
||||
interface TextBlockWire {
|
||||
text: string;
|
||||
}
|
||||
@@ -310,7 +315,8 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
|
||||
try {
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention);
|
||||
const convertedMessages = convertMessages(context, model, cacheRetention);
|
||||
const promptCachePolicy = resolvePromptCachePolicy(model, cacheRetention);
|
||||
const convertedMessages = convertMessages(context, model, promptCachePolicy);
|
||||
const toolPlan = planToolConfig(context.tools, options.toolChoice, convertedMessages);
|
||||
const toolConfig = toolPlan.toolConfig;
|
||||
const sentinelInjected = toolPlan.sentinelInjected;
|
||||
@@ -325,7 +331,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
|
||||
const commandInput: ConverseStreamRequest = {
|
||||
messages: convertedMessages,
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
|
||||
system: buildSystemPrompt(context.systemPrompt, promptCachePolicy),
|
||||
inferenceConfig: {
|
||||
maxTokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
@@ -693,29 +699,32 @@ function handleContentBlockStop(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model supports prompt caching.
|
||||
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+
|
||||
*
|
||||
* For base models and system-defined inference profiles the model ID / ARN
|
||||
* contains the model name, so we can decide locally.
|
||||
*
|
||||
* For application inference profiles (whose ARNs don't contain the model name),
|
||||
* set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models
|
||||
* have automatic caching and don't need explicit cache points.
|
||||
* Resolve Bedrock's explicit-cache request policy from the catalog's
|
||||
* materialized provider contract. `AWS_BEDROCK_FORCE_CACHE` remains an escape
|
||||
* hatch for opaque application inference profiles, but it cannot invent 1h
|
||||
* retention that the model compat did not explicitly grant.
|
||||
*/
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
|
||||
if (model.cost.cacheRead || model.cost.cacheWrite) return true;
|
||||
const id = model.id.toLowerCase();
|
||||
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
|
||||
if (id.includes("claude") && (id.includes("-4-") || id.includes("-4."))) return true;
|
||||
// Claude 3.5 Haiku, Claude 3.7 Sonnet (legacy naming)
|
||||
if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true;
|
||||
// Claude Haiku 4.5+ (new naming)
|
||||
if (id.includes("claude-haiku")) return true;
|
||||
// Application inference profiles don't contain the model name in the ARN.
|
||||
// Allow users to force cache points via environment variable.
|
||||
if (typeof process !== "undefined" && $flag("AWS_BEDROCK_FORCE_CACHE")) return true;
|
||||
return false;
|
||||
function resolvePromptCachePolicy(
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
): BedrockPromptCachePolicy {
|
||||
if (cacheRetention === "none" || model.compat.promptCacheMode === "automatic") {
|
||||
return { emitCheckpoints: false };
|
||||
}
|
||||
|
||||
const forced = $flag("AWS_BEDROCK_FORCE_CACHE");
|
||||
if (model.compat.promptCacheMode !== "explicit" && !forced) {
|
||||
return { emitCheckpoints: false };
|
||||
}
|
||||
|
||||
return {
|
||||
emitCheckpoints: true,
|
||||
...(cacheRetention === "long" && model.compat.supportsLongPromptCacheRetention ? { ttl: "1h" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createCachePoint(policy: BedrockPromptCachePolicy): CachePoint {
|
||||
return { cachePoint: { type: "default", ...(policy.ttl ? { ttl: policy.ttl } : {}) } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -731,19 +740,15 @@ function supportsThinkingSignature(model: Model<"bedrock-converse-stream">): boo
|
||||
|
||||
function buildSystemPrompt(
|
||||
systemPrompt: readonly string[] | undefined,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
promptCachePolicy: BedrockPromptCachePolicy,
|
||||
): SystemContent[] | undefined {
|
||||
const prompts = systemPrompt?.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.length > 0) ?? [];
|
||||
if (prompts.length === 0) return undefined;
|
||||
|
||||
const blocks: SystemContent[] = prompts.map(prompt => ({ text: prompt }));
|
||||
|
||||
// Add cache point for supported Claude models
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
|
||||
blocks.push({
|
||||
cachePoint: { type: "default", ...(cacheRetention === "long" ? { ttl: "1h" } : {}) },
|
||||
});
|
||||
if (promptCachePolicy.emitCheckpoints) {
|
||||
blocks.push(createCachePoint(promptCachePolicy));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
@@ -752,7 +757,7 @@ function buildSystemPrompt(
|
||||
function convertMessages(
|
||||
context: Context,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
promptCachePolicy: BedrockPromptCachePolicy,
|
||||
): WireMessage[] {
|
||||
const result: WireMessage[] = [];
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
@@ -883,13 +888,11 @@ function convertMessages(
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache point to the last user message for supported Claude models
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
|
||||
// Preserve the existing second checkpoint after the final user message.
|
||||
if (promptCachePolicy.emitCheckpoints && result.length > 0) {
|
||||
const lastMessage = result[result.length - 1];
|
||||
if (lastMessage.role === "user" && lastMessage.content) {
|
||||
(lastMessage.content as UserContent[]).push({
|
||||
cachePoint: { type: "default", ...(cacheRetention === "long" ? { ttl: "1h" } : {}) },
|
||||
});
|
||||
(lastMessage.content as UserContent[]).push(createCachePoint(promptCachePolicy));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export abstract class OAuthCallbackFlow {
|
||||
allowPortFallback: boolean;
|
||||
#manualInputOnly: boolean;
|
||||
#callbackResolve?: (result: CallbackResult) => void;
|
||||
#callbackReject?: (error: string) => void;
|
||||
#callbackReject?: (error: Error) => void;
|
||||
/**
|
||||
* Authorization URL the `/launch` route currently redirects to. Set by
|
||||
* {@link login} after {@link generateAuthUrl} and before {@link OAuthController.onAuth}
|
||||
@@ -280,9 +280,9 @@ export abstract class OAuthCallbackFlow {
|
||||
* Create HTTP server for OAuth callback.
|
||||
*/
|
||||
#createServer(port: number, expectedState: string): Bun.Server<unknown> {
|
||||
const hostname = this.callbackHostname === DEFAULT_HOSTNAME ? undefined : this.callbackHostname;
|
||||
const hostname = this.callbackHostname === DEFAULT_HOSTNAME ? "127.0.0.1" : this.callbackHostname;
|
||||
return Bun.serve({
|
||||
...(hostname === undefined ? {} : { hostname }),
|
||||
hostname,
|
||||
port,
|
||||
reusePort: false,
|
||||
fetch: req => this.#handleCallback(req, expectedState),
|
||||
@@ -334,16 +334,23 @@ export abstract class OAuthCallbackFlow {
|
||||
resultState = { ok: true, code, state };
|
||||
}
|
||||
|
||||
// Signal to waitForCallback - capture refs before they could be cleared
|
||||
const resolve = this.#callbackResolve;
|
||||
const reject = this.#callbackReject;
|
||||
queueMicrotask(() => {
|
||||
if (resultState.ok) {
|
||||
if (resultState.ok) {
|
||||
const resolve = this.#callbackResolve;
|
||||
queueMicrotask(() => {
|
||||
resolve?.({ code: resultState.code, state: resultState.state });
|
||||
} else {
|
||||
reject?.(resultState.error ?? "Unknown error");
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (error && (!expectedState || state === expectedState)) {
|
||||
// The redirect carries our state nonce, so it came from the genuine
|
||||
// authorization flow (e.g. the user denied the consent screen).
|
||||
// Surface the denial now instead of leaving the login waiting for
|
||||
// the 5-minute timeout. Errors WITHOUT the expected state stay
|
||||
// ignored — any local process can forge those (#4106).
|
||||
const reject = this.#callbackReject;
|
||||
const message = resultState.error ?? `Authorization failed: ${errorDescription}`;
|
||||
queueMicrotask(() => {
|
||||
reject?.(new AIError.OAuthError(message, { kind: "device-auth" }));
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
(templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", JSON.stringify(resultState)),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock";
|
||||
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { withEnv } from "./helpers";
|
||||
|
||||
interface CachePoint {
|
||||
cachePoint: { type: "default"; ttl?: "1h" };
|
||||
}
|
||||
interface Payload {
|
||||
system?: Array<{ text: string } | CachePoint>;
|
||||
messages: Array<{ role: string; content: Array<{ text: string } | CachePoint> }>;
|
||||
inferenceConfig: { maxTokens?: number; temperature?: number; topP?: number };
|
||||
toolConfig?: unknown;
|
||||
additionalModelRequestFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const context: Context = {
|
||||
systemPrompt: ["Use concise answers."],
|
||||
messages: [{ role: "user", content: "What is the answer?", timestamp: 0 }],
|
||||
};
|
||||
|
||||
function model(id: string): Model<"bedrock-converse-stream"> {
|
||||
return buildModel({
|
||||
id,
|
||||
name: id,
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
}
|
||||
|
||||
function abortedSignal(): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function capturePayload(
|
||||
bedrockModel: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: "none" | "short" | "long",
|
||||
): Promise<Payload> {
|
||||
const { promise, resolve } = Promise.withResolvers<Payload>();
|
||||
void streamBedrock(bedrockModel, context, {
|
||||
signal: abortedSignal(),
|
||||
cacheRetention,
|
||||
onPayload: payload => {
|
||||
resolve(payload as Payload);
|
||||
},
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function checkpoints(payload: Payload): CachePoint[] {
|
||||
return [...(payload.system ?? []), ...payload.messages.flatMap(message => message.content)].filter(
|
||||
(block): block is CachePoint => "cachePoint" in block,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Bedrock prompt cache checkpoints", () => {
|
||||
test("downgrades unsupported long retention to default 5m at the existing two checkpoint locations", async () => {
|
||||
const payload = await capturePayload(model("anthropic.claude-opus-4-6-v1"), "long");
|
||||
expect(payload.system).toEqual([{ text: "Use concise answers." }, { cachePoint: { type: "default" } }]);
|
||||
expect(payload.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "What is the answer?" }, { cachePoint: { type: "default" } }] },
|
||||
]);
|
||||
expect(checkpoints(payload)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("uses 1h only for catalog-confirmed long-retention models", async () => {
|
||||
const payload = await capturePayload(model("anthropic.claude-haiku-4-5-20251001-v1:0"), "long");
|
||||
expect(checkpoints(payload)).toEqual([
|
||||
{ cachePoint: { type: "default", ttl: "1h" } },
|
||||
{ cachePoint: { type: "default", ttl: "1h" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits checkpoints for the default bundled Opus 4.8 inference profile", async () => {
|
||||
const payload = await capturePayload(model("us.anthropic.claude-opus-4-8"), "long");
|
||||
expect(checkpoints(payload)).toEqual([
|
||||
{ cachePoint: { type: "default", ttl: "1h" } },
|
||||
{ cachePoint: { type: "default", ttl: "1h" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses Bedrock's default 5m TTL for short retention", async () => {
|
||||
const payload = await capturePayload(model("anthropic.claude-haiku-4-5-20251001-v1:0"), "short");
|
||||
expect(checkpoints(payload)).toEqual([{ cachePoint: { type: "default" } }, { cachePoint: { type: "default" } }]);
|
||||
});
|
||||
|
||||
test("emits default-5m checkpoints for every bundled Nova cache-capable payload", async () => {
|
||||
for (const id of [
|
||||
"us.amazon.nova-lite-v1:0",
|
||||
"us.amazon.nova-micro-v1:0",
|
||||
"us.amazon.nova-pro-v1:0",
|
||||
"us.amazon.nova-premier-v1:0",
|
||||
] as const) {
|
||||
const nova = getBundledModel<"bedrock-converse-stream">("amazon-bedrock", id);
|
||||
expect(nova).toBeDefined();
|
||||
const payload = await capturePayload(nova!, "long");
|
||||
expect(payload).toEqual({
|
||||
system: [{ text: "Use concise answers." }, { cachePoint: { type: "default" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "What is the answer?" }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
],
|
||||
inferenceConfig: { maxTokens: undefined, temperature: undefined, topP: undefined },
|
||||
toolConfig: undefined,
|
||||
additionalModelRequestFields: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("emits default-5m system and message checkpoints for Nova Premier's in-region ID", async () => {
|
||||
const payload = await capturePayload(model("amazon.nova-premier-v1:0"), "long");
|
||||
expect(payload.system).toEqual([{ text: "Use concise answers." }, { cachePoint: { type: "default" } }]);
|
||||
expect(payload.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "What is the answer?" }, { cachePoint: { type: "default" } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("forces opaque profiles to default checkpoints without granting 1h retention", async () => {
|
||||
await withEnv({ AWS_BEDROCK_FORCE_CACHE: "1" }, async () => {
|
||||
const payload = await capturePayload(
|
||||
model("arn:aws:bedrock:us-east-1:1234567890:application-inference-profile/opaque-profile"),
|
||||
"long",
|
||||
);
|
||||
expect(checkpoints(payload)).toEqual([
|
||||
{ cachePoint: { type: "default" } },
|
||||
{ cachePoint: { type: "default" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,13 @@ class TestCallbackFlow extends OAuthCallbackFlow {
|
||||
|
||||
/**
|
||||
* Bind a real loopback port so the next `Bun.serve({ port })` against the
|
||||
* same port fails with EADDRINUSE. Returns the bound port plus a `release`
|
||||
* callback for teardown.
|
||||
* same port fails with EADDRINUSE. Occupies `127.0.0.1` explicitly — the
|
||||
* interface callback flows bind for `localhost` — because macOS lets a
|
||||
* specific-address bind coexist with a wildcard one. Returns the bound port
|
||||
* plus a `release` callback for teardown.
|
||||
*/
|
||||
function occupyLoopbackPort(): { port: number; release: () => void } {
|
||||
const server = Bun.serve({ port: 0, fetch: () => new Response("blocker") });
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("blocker") });
|
||||
const port = server.port;
|
||||
if (typeof port !== "number") {
|
||||
server.stop(true);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/registry/oauth/callback-server";
|
||||
import type { OAuthAuthInfo, OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types";
|
||||
|
||||
class CallbackProbeFlow extends OAuthCallbackFlow {
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string }> {
|
||||
const url = new URL("https://provider.example.com/authorize");
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
return { url: url.toString() };
|
||||
}
|
||||
|
||||
async exchangeToken(code: string): Promise<OAuthCredentials> {
|
||||
return { access: code, refresh: "refresh", expires: Date.now() + 60_000 };
|
||||
}
|
||||
}
|
||||
|
||||
async function startFlow(): Promise<{
|
||||
info: OAuthAuthInfo;
|
||||
abort: AbortController;
|
||||
login: Promise<OAuthCredentials>;
|
||||
}> {
|
||||
const abort = new AbortController();
|
||||
const authFired = Promise.withResolvers<OAuthAuthInfo>();
|
||||
const flow = new CallbackProbeFlow(
|
||||
{
|
||||
onAuth: info => authFired.resolve(info),
|
||||
signal: abort.signal,
|
||||
},
|
||||
{ preferredPort: 0 },
|
||||
);
|
||||
const login = flow.login();
|
||||
void login.catch(() => undefined);
|
||||
const info = await authFired.promise;
|
||||
return { info, abort, login };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("OAuthCallbackFlow callback security", () => {
|
||||
it("keeps waiting after invalid callback requests and accepts the legitimate callback", async () => {
|
||||
const { info, abort, login } = await startFlow();
|
||||
const authUrl = new URL(info.url);
|
||||
const redirectUri = authUrl.searchParams.get("redirect_uri");
|
||||
const state = authUrl.searchParams.get("state");
|
||||
if (!redirectUri || !state) throw new Error("OAuth test flow did not advertise its callback parameters");
|
||||
|
||||
try {
|
||||
const invalidCallbacks = [
|
||||
`${redirectUri}?error=access_denied&error_description=Denied`,
|
||||
redirectUri,
|
||||
`${redirectUri}?code=attacker-code&state=wrong-state`,
|
||||
];
|
||||
for (const callback of invalidCallbacks) {
|
||||
const response = await fetch(callback);
|
||||
expect(response.status).toBe(500);
|
||||
}
|
||||
|
||||
const response = await fetch(`${redirectUri}?code=legitimate-code&state=${encodeURIComponent(state)}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await login).access).toBe("legitimate-code");
|
||||
} finally {
|
||||
abort.abort("test cleanup");
|
||||
await login.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces provider denials that carry the expected state instead of waiting for the timeout", async () => {
|
||||
const { info, abort, login } = await startFlow();
|
||||
const authUrl = new URL(info.url);
|
||||
const redirectUri = authUrl.searchParams.get("redirect_uri");
|
||||
const state = authUrl.searchParams.get("state");
|
||||
if (!redirectUri || !state) throw new Error("OAuth test flow did not advertise its callback parameters");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${redirectUri}?error=access_denied&error_description=User%20denied&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
await expect(login).rejects.toThrow("Authorization failed: User denied");
|
||||
} finally {
|
||||
abort.abort("test cleanup");
|
||||
await login.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds localhost callback URLs to the IPv4 loopback interface", async () => {
|
||||
const serve = Bun.serve;
|
||||
let hostname: string | undefined;
|
||||
vi.spyOn(Bun, "serve").mockImplementation(options => {
|
||||
hostname = options.hostname;
|
||||
return serve(options);
|
||||
});
|
||||
|
||||
const { abort, login } = await startFlow();
|
||||
try {
|
||||
expect(hostname).toBe("127.0.0.1");
|
||||
} finally {
|
||||
abort.abort("test cleanup");
|
||||
await login.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added resolved Bedrock Converse prompt-cache compatibility limits, including explicit 5-minute checkpoint support for bundled Nova Lite, Micro, Pro, and Premier models plus Nova Premier's documented in-region model ID, and model-specific 1-hour Claude retention.
|
||||
- Added the native Meta Model API provider and Muse Spark 1.1 with Responses API reasoning replay, image input, and the full supported reasoning-effort ladder ([#4941](https://github.com/can1357/oh-my-pi/issues/4941)).
|
||||
|
||||
## [17.0.9] - 2026-07-23
|
||||
|
||||
@@ -364,9 +364,9 @@ function applyOpenAICatalogPolicy(model: ModelSpec<Api>, parsedModel: OpenAIMode
|
||||
}
|
||||
// GPT-5.6 luna/sol/terra on the Codex transport: OpenAI's Codex model
|
||||
// registry declares context_window = max_context_window = 372000, but Codex
|
||||
// discovery under-reports it — omitting the field for some accounts and
|
||||
// actively returning 272000 for others (#5705, #6259). Pin the true 372K
|
||||
// input window on the bundled catalog; discovery enforces the same floor.
|
||||
// discovery omits `context_window` for these SKUs and falls back to
|
||||
// DEFAULT_CONTEXT_WINDOW (272000, src/discovery/codex.ts), which regressed
|
||||
// the bundled hard capacity (#5705). Pin the true 372K input window.
|
||||
if (model.api === "openai-codex-responses" && semverEqual(parsedModel.version, "5.6")) {
|
||||
model.contextWindow = 372000;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* Request handlers read fields — they never detect, parse ids, or allocate
|
||||
* compat per request.
|
||||
*/
|
||||
|
||||
import { buildAnthropicCompat } from "./compat/anthropic";
|
||||
import { buildBedrockCompat } from "./compat/bedrock";
|
||||
import { buildDevinCompat } from "./compat/devin";
|
||||
import { buildOpenAICompat, buildOpenAIResponsesCompat, buildOpenRouterCompat } from "./compat/openai";
|
||||
import { resolveModelThinking } from "./model-thinking";
|
||||
@@ -39,6 +41,8 @@ export function buildCompat(spec: ModelSpec<Api>): CompatOf<Api> {
|
||||
return buildOpenAIResponsesCompat(spec as ModelSpec<"openai-responses">);
|
||||
case "anthropic-messages":
|
||||
return buildAnthropicCompat(spec as ModelSpec<"anthropic-messages">);
|
||||
case "bedrock-converse-stream":
|
||||
return buildBedrockCompat(spec as ModelSpec<"bedrock-converse-stream">);
|
||||
case "devin-agent":
|
||||
return buildDevinCompat(spec as ModelSpec<"devin-agent">);
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ModelSpec, ResolvedBedrockCompat } from "../types";
|
||||
import { applyCompatOverrides } from "./apply";
|
||||
|
||||
const NO_EXPLICIT_CHECKPOINTS: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "none",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 0,
|
||||
promptCacheMaximumCheckpoints: 0,
|
||||
};
|
||||
const EXPLICIT_CHECKPOINTS_1024_5M: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 1024,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
|
||||
const EXPLICIT_CHECKPOINTS_1024_1H: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: true,
|
||||
promptCacheMinimumTokens: 1024,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
|
||||
const EXPLICIT_CHECKPOINTS_2048_5M: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 2048,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
|
||||
const EXPLICIT_CHECKPOINTS_4096_5M: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 4096,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
|
||||
const EXPLICIT_CHECKPOINTS_4096_1H: ResolvedBedrockCompat = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: true,
|
||||
promptCacheMinimumTokens: 4096,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit Nova cache points complement Bedrock's automatic prefix caching:
|
||||
* AWS recommends them for consistent cache hits and input-cost savings. Keep
|
||||
* these exact documented model and inference-profile IDs conservative rather
|
||||
* than treating arbitrary Nova-like application profiles as checkpoint-capable.
|
||||
*/
|
||||
function detectedBedrockCompat(modelId: string): ResolvedBedrockCompat {
|
||||
const id = modelId.toLowerCase();
|
||||
|
||||
if (
|
||||
id === "amazon.nova-lite-v1:0" ||
|
||||
id === "us.amazon.nova-lite-v1:0" ||
|
||||
id === "amazon.nova-micro-v1:0" ||
|
||||
id === "us.amazon.nova-micro-v1:0" ||
|
||||
id === "amazon.nova-pro-v1:0" ||
|
||||
id === "us.amazon.nova-pro-v1:0" ||
|
||||
id === "amazon.nova-premier-v1:0" ||
|
||||
id === "us.amazon.nova-premier-v1:0"
|
||||
) {
|
||||
return EXPLICIT_CHECKPOINTS_1024_5M;
|
||||
}
|
||||
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
|
||||
// This list is deliberately sourced from AWS model cards, not cache pricing:
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html
|
||||
if (
|
||||
id.includes("anthropic.claude-opus-4-5") ||
|
||||
id.includes("anthropic.claude-sonnet-4-5") ||
|
||||
id.includes("anthropic.claude-haiku-4-5") ||
|
||||
id.includes("anthropic.claude-opus-4-7") ||
|
||||
id.includes("anthropic.claude-opus-4-8") ||
|
||||
id.includes("anthropic.claude-sonnet-5")
|
||||
) {
|
||||
return EXPLICIT_CHECKPOINTS_4096_1H;
|
||||
}
|
||||
if (id.includes("anthropic.claude-opus-4-6")) {
|
||||
return EXPLICIT_CHECKPOINTS_4096_5M;
|
||||
}
|
||||
if (id.includes("anthropic.claude-3-5-haiku")) {
|
||||
return EXPLICIT_CHECKPOINTS_2048_5M;
|
||||
}
|
||||
if (id.includes("anthropic.claude-fable-5")) {
|
||||
return EXPLICIT_CHECKPOINTS_1024_1H;
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("anthropic.claude-opus-4-1") ||
|
||||
id.includes("anthropic.claude-opus-4-20250514") ||
|
||||
id.includes("anthropic.claude-sonnet-4-20250514") ||
|
||||
id.includes("anthropic.claude-sonnet-4-6") ||
|
||||
id.includes("anthropic.claude-3-7-sonnet") ||
|
||||
id.includes("anthropic.claude-3-5-sonnet-20241022-v2")
|
||||
) {
|
||||
return EXPLICIT_CHECKPOINTS_1024_5M;
|
||||
}
|
||||
|
||||
return NO_EXPLICIT_CHECKPOINTS;
|
||||
}
|
||||
|
||||
/** Resolve Bedrock Converse prompt-cache capabilities once per model. */
|
||||
export function buildBedrockCompat(spec: ModelSpec<"bedrock-converse-stream">): ResolvedBedrockCompat {
|
||||
const compat = { ...detectedBedrockCompat(spec.id) };
|
||||
applyCompatOverrides(compat, spec.compat);
|
||||
return compat;
|
||||
}
|
||||
@@ -8,11 +8,11 @@ const DEFAULT_MODEL_LIST_PATHS = ["/codex/models", "/models"] as const;
|
||||
const DEFAULT_CONTEXT_WINDOW = 272_000;
|
||||
const DEFAULT_MAX_TOKENS = 128_000;
|
||||
/**
|
||||
* GPT-5.6 luna/sol/terra hard context capacity. OpenAI's Codex model registry
|
||||
* declares context_window = max_context_window = 372000 (#5705), but Codex
|
||||
* discovery under-reports it — omitting the field for some accounts and
|
||||
* actively returning 272000 for others (#6259). Applied as a floor for these
|
||||
* SKUs so the reported/absent value never regresses the real window.
|
||||
* GPT-5.6 luna/sol/terra hard context capacity. Codex discovery omits
|
||||
* `context_window` for these SKUs, so the generic {@link DEFAULT_CONTEXT_WINDOW}
|
||||
* (272000) would understate the real window — OpenAI's Codex model registry
|
||||
* declares context_window = max_context_window = 372000 (#5705). Used as the
|
||||
* fallback only when upstream reports no value.
|
||||
*/
|
||||
const GPT_5_6_CONTEXT_WINDOW = 372_000;
|
||||
const CODEX_REMOTE_COMPACTION = {
|
||||
@@ -223,18 +223,14 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo
|
||||
}
|
||||
|
||||
const name = toNonEmptyString(payload.display_name) ?? slug;
|
||||
// GPT-5.6 luna/sol/terra have a 372000 hard window, but Codex discovery
|
||||
// under-reports it: for some accounts the field is omitted, for others it is
|
||||
// actively returned as 272000 (#6259). Treat GPT_5_6_CONTEXT_WINDOW as a
|
||||
// floor for these SKUs so neither the omission nor the active under-report
|
||||
// regresses the real capacity; other models honor the reported value with
|
||||
// the generic 272000 fallback.
|
||||
// Codex discovery omits `context_window` for GPT-5.6 luna/sol/terra; the
|
||||
// generic 272000 fallback understates their real 372000 window (#5705).
|
||||
const parsed = parseKnownModel(slug);
|
||||
const isGpt56 = parsed.family === "openai" && semverEqual(parsed.version, "5.6");
|
||||
const reportedContextWindow = toPositiveInt(payload.context_window);
|
||||
const contextWindow = isGpt56
|
||||
? Math.max(GPT_5_6_CONTEXT_WINDOW, reportedContextWindow ?? 0)
|
||||
: (reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW);
|
||||
const fallbackContextWindow =
|
||||
parsed.family === "openai" && semverEqual(parsed.version, "5.6")
|
||||
? GPT_5_6_CONTEXT_WINDOW
|
||||
: DEFAULT_CONTEXT_WINDOW;
|
||||
const contextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow;
|
||||
const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow);
|
||||
const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels);
|
||||
const input = normalizeInputModalities(payload.input_modalities);
|
||||
|
||||
@@ -459,6 +459,29 @@ export interface AnthropicCompat {
|
||||
escapeBuiltinToolNames?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility settings for Bedrock Converse prompt caching. Cache pricing is
|
||||
* deliberately not used to infer these request-shape capabilities.
|
||||
*/
|
||||
export interface BedrockCompat {
|
||||
/** Whether this endpoint accepts no checkpoints, automatic caching, or explicit cachePoint blocks. */
|
||||
promptCacheMode?: "none" | "automatic" | "explicit";
|
||||
/** Whether explicit cachePoint blocks accept `ttl: "1h"`; omitted TTL means Bedrock's 5-minute default. */
|
||||
supportsLongPromptCacheRetention?: boolean;
|
||||
/** Minimum prompt-prefix tokens required for an effective checkpoint. Zero means no explicit checkpoints. */
|
||||
promptCacheMinimumTokens?: number;
|
||||
/** Maximum explicit cache checkpoints accepted in one request. Zero means no explicit checkpoints. */
|
||||
promptCacheMaximumCheckpoints?: number;
|
||||
}
|
||||
|
||||
/** Fully-resolved Bedrock Converse prompt-cache capabilities, materialized once by `buildModel`. */
|
||||
export interface ResolvedBedrockCompat {
|
||||
promptCacheMode: NonNullable<BedrockCompat["promptCacheMode"]>;
|
||||
supportsLongPromptCacheRetention: boolean;
|
||||
promptCacheMinimumTokens: number;
|
||||
promptCacheMaximumCheckpoints: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter provider routing preferences.
|
||||
* Controls which upstream providers OpenRouter routes requests to.
|
||||
@@ -681,9 +704,11 @@ export type CompatConfigOf<TApi extends Api> = TApi extends
|
||||
? OpenAICompat
|
||||
: TApi extends "anthropic-messages"
|
||||
? AnthropicCompat
|
||||
: TApi extends "devin-agent"
|
||||
? DevinCompat
|
||||
: undefined;
|
||||
: TApi extends "bedrock-converse-stream"
|
||||
? BedrockCompat
|
||||
: TApi extends "devin-agent"
|
||||
? DevinCompat
|
||||
: undefined;
|
||||
|
||||
/** Resolved compat for a given API: complete record, materialized once by `buildModel`. */
|
||||
export type CompatOf<TApi extends Api> = TApi extends "openrouter"
|
||||
@@ -694,9 +719,11 @@ export type CompatOf<TApi extends Api> = TApi extends "openrouter"
|
||||
? ResolvedOpenAIResponsesCompat
|
||||
: TApi extends "anthropic-messages"
|
||||
? ResolvedAnthropicCompat
|
||||
: TApi extends "devin-agent"
|
||||
? ResolvedDevinCompat
|
||||
: undefined;
|
||||
: TApi extends "bedrock-converse-stream"
|
||||
? ResolvedBedrockCompat
|
||||
: TApi extends "devin-agent"
|
||||
? ResolvedDevinCompat
|
||||
: undefined;
|
||||
|
||||
/** Provider-native compaction endpoint configuration for one model. */
|
||||
export interface RemoteCompactionConfig<TApi extends Api = Api> {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
|
||||
|
||||
function bedrockSpec(
|
||||
overrides: Partial<ModelSpec<"bedrock-converse-stream">> = {},
|
||||
): ModelSpec<"bedrock-converse-stream"> {
|
||||
return {
|
||||
id: "anthropic.claude-opus-4-6-v1",
|
||||
name: "Claude Opus 4.6",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Bedrock prompt-cache compat", () => {
|
||||
test("resolves the AWS-documented capability for every cache-priced bundled Claude family", () => {
|
||||
const cases = [
|
||||
{
|
||||
id: "anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
minimumTokens: 2048,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
// Current AWS docs do not advertise Converse cache checkpoints for this
|
||||
// legacy v1 model, so catalog cache pricing alone must not enable them.
|
||||
{
|
||||
id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
minimumTokens: 0,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
minimumTokens: 1024,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
minimumTokens: 1024,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{ id: "anthropic.claude-fable-5", minimumTokens: 1024, supportsLongRetention: true },
|
||||
{
|
||||
id: "anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
minimumTokens: 4096,
|
||||
supportsLongRetention: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
minimumTokens: 1024,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-opus-4-20250514-v1:0",
|
||||
minimumTokens: 1024,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
minimumTokens: 4096,
|
||||
supportsLongRetention: true,
|
||||
},
|
||||
{ id: "anthropic.claude-opus-4-6-v1", minimumTokens: 4096, supportsLongRetention: false },
|
||||
{ id: "global.anthropic.claude-opus-4-7", minimumTokens: 4096, supportsLongRetention: true },
|
||||
{ id: "us.anthropic.claude-opus-4-8", minimumTokens: 4096, supportsLongRetention: true },
|
||||
{
|
||||
id: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
minimumTokens: 1024,
|
||||
supportsLongRetention: false,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
minimumTokens: 4096,
|
||||
supportsLongRetention: true,
|
||||
},
|
||||
{ id: "anthropic.claude-sonnet-4-6", minimumTokens: 1024, supportsLongRetention: false },
|
||||
{ id: "us.anthropic.claude-sonnet-5", minimumTokens: 4096, supportsLongRetention: true },
|
||||
] as const;
|
||||
|
||||
for (const { id, minimumTokens, supportsLongRetention } of cases) {
|
||||
expect(buildModel(bedrockSpec({ id })).compat).toEqual({
|
||||
promptCacheMode: minimumTokens === 0 ? "none" : "explicit",
|
||||
supportsLongPromptCacheRetention: supportsLongRetention,
|
||||
promptCacheMinimumTokens: minimumTokens,
|
||||
promptCacheMaximumCheckpoints: minimumTokens === 0 ? 0 : 4,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("models exact cache-capable Nova IDs for explicit 5m checkpoints", () => {
|
||||
const expected = {
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 1024,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
} as const;
|
||||
|
||||
for (const id of [
|
||||
"us.amazon.nova-lite-v1:0",
|
||||
"us.amazon.nova-micro-v1:0",
|
||||
"us.amazon.nova-pro-v1:0",
|
||||
"us.amazon.nova-premier-v1:0",
|
||||
] as const) {
|
||||
expect(getBundledModel<"bedrock-converse-stream">("amazon-bedrock", id)?.compat).toEqual(expected);
|
||||
}
|
||||
|
||||
// AWS documents both the in-region model IDs and the US geo inference IDs.
|
||||
for (const id of [
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-premier-v1:0",
|
||||
"us.amazon.nova-premier-v1:0",
|
||||
] as const) {
|
||||
expect(buildModel(bedrockSpec({ id })).compat).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps unknown routes conservative and honors sparse profile overrides", () => {
|
||||
const opaqueProfileId = "arn:aws:bedrock:us-east-1:123:application-inference-profile/opaque";
|
||||
const unknown = buildModel(bedrockSpec({ id: opaqueProfileId }));
|
||||
expect(unknown.compat.promptCacheMode).toBe("none");
|
||||
|
||||
for (const id of ["amazon.nova-premier-v1:1", "us.amazon.nova-unknown-v1:0"]) {
|
||||
expect(buildModel(bedrockSpec({ id })).compat.promptCacheMode).toBe("none");
|
||||
}
|
||||
const sparse = {
|
||||
promptCacheMode: "explicit" as const,
|
||||
promptCacheMinimumTokens: 1024,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
};
|
||||
const configured = buildModel(
|
||||
bedrockSpec({ id: "arn:aws:bedrock:us-east-1:123:application-inference-profile/opaque", compat: sparse }),
|
||||
);
|
||||
expect(configured.compat).toEqual({ ...unknown.compat, ...sparse });
|
||||
expect(configured.compatConfig).toBe(sparse);
|
||||
});
|
||||
|
||||
test("keeps bundled models memoized while materializing resolved compat", () => {
|
||||
const first = getBundledModel<"bedrock-converse-stream">("amazon-bedrock", "anthropic.claude-opus-4-6-v1");
|
||||
const second = getBundledModel<"bedrock-converse-stream">("amazon-bedrock", "anthropic.claude-opus-4-6-v1");
|
||||
expect(first).toBe(second);
|
||||
expect(first?.compat.promptCacheMode).toBe("explicit");
|
||||
});
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe("Codex model discovery", () => {
|
||||
expect(legacy?.contextWindow).toBe(272_000);
|
||||
});
|
||||
|
||||
it("floors GPT-5.6 SKUs to 372K when upstream actively reports 272000 (#6259)", async () => {
|
||||
it("honors context_window when upstream actively reports it for GPT-5.6 SKUs", async () => {
|
||||
const fetchFn: typeof fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
@@ -178,8 +178,7 @@ describe("Codex model discovery", () => {
|
||||
});
|
||||
|
||||
const sol = result?.models.find(model => model.id === "gpt-5.6-sol");
|
||||
expect(sol?.contextWindow).toBe(372_000);
|
||||
// Non-5.6 SKUs still honor the reported value verbatim.
|
||||
expect(sol?.contextWindow).toBe(272_000);
|
||||
const legacy = result?.models.find(model => model.id === "gpt-5.5");
|
||||
expect(legacy?.contextWindow).toBe(272_000);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
- Made the statusline `git` segment jj-aware: in a Jujutsu repo it shows the nearest bookmark (falling back to the short change-id) instead of git's `detached` label or nothing, and working-copy change counts come from jj where there is no `.git` to read ([#3582](https://github.com/can1357/oh-my-pi/issues/3582))
|
||||
- Added `block`/`unblock` todo operations and a `blocked` status for tasks waiting on external input; blocked tasks stay visible in the todo HUD and summary but are excluded from the incomplete-todo stop reminder, and an optional blocker note records what the task is waiting for.
|
||||
- Added a toggle-list editor in `/settings` for array-of-enum settings: `providers.webSearchOrder` and `providers.imageOrder` (ordered — Enter/Space toggles, ←/→ nudges, 1-9 splices the hovered provider into that position) and `providers.webSearchExclude` now appear under Providers → Services instead of being config-file only.
|
||||
- Added `models.yml` Bedrock Converse prompt-cache capability overrides for bundled and opaque inference profiles.
|
||||
- Documented Vibe mode (`/vibe`) in `docs/vibe-mode.md` and the `/fresh` provider-stream reset in the session-operations doc, and linked both from the README's new "Session controls" section ([#6440](https://github.com/can1357/oh-my-pi/issues/6440)).
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -30,9 +32,23 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed `omp auth-gateway serve` and `omp auth-gateway check` bypassing the process-scoped OAuth account pool configured by `OMP_AUTH_BROKER_ACCOUNT_POOL_FILE`.
|
||||
- Fixed a first-use race in `ArtifactManager` where two concurrent `allocatePath`/`save` callers on a fresh instance both re-seeded `#nextId` across the directory-scan yield and allocated the same artifact id, silently overwriting the first artifact (same tool type) or making `artifact://` resolution ambiguous (different tool types). The initial scan is now memoized as a single in-flight promise so all concurrent callers share one initialization and receive distinct ids ([#4091](https://github.com/can1357/oh-my-pi/issues/4091)).
|
||||
- Fixed blob reference resolution passing unvalidated `blob:sha256:` suffixes into `path.join`, allowing a crafted ref (e.g. `blob:sha256:../../../etc/passwd`) in a persisted/shared session to escape the blob directory and read arbitrary files into resolved image history; `parseBlobRef` now rejects any suffix that is not a canonical 64-char lowercase hex hash, gating every resolution path ([#4088](https://github.com/can1357/oh-my-pi/issues/4088)).
|
||||
- Fixed discarded `Settings` instances keeping debounced save timers and chained background saves armed; discarding an instance now cancels its pending writes so they cannot race a successor's file locks.
|
||||
- Fixed startup status messages (warnings, errors, extension/tool errors, status lines) keeping the dark-mode color after auto-theme detection later switched the active theme to light — e.g. `dark-catppuccin`/`light-catppuccin` warnings rendered in Mocha yellow on the Latte background. Transient status presenters now resolve their color lazily at render time so a theme swap re-shapes them ([#6337](https://github.com/can1357/oh-my-pi/issues/6337)).
|
||||
- Fixed MCP OAuth endpoint discovery and Smithery browser-login polling hanging indefinitely against an endpoint that accepts the TCP connection but never responds. `discoverOAuthEndpoints`/`fetchResourceMetadataScopes` now bound every metadata/well-known/authorization-server fetch with a per-request `AbortSignal.timeout`, and `pollSmitheryCliAuthSession` bounds each poll so the loop reaches its 5-minute deadline instead of stalling ([#4103](https://github.com/can1357/oh-my-pi/issues/4103)).
|
||||
- Fixed bash internal-URL expansion skipping unquoted `skill://` (and other supported schemes) inside a legacy backtick command substitution nested directly in double quotes (e.g. ``echo "`cat skill://valid-skill/SKILL.md`"``); `isInsideShellQuote` now treats `` ` `` as an expansion-context boundary like `$()`, including `$()`/backtick nesting in either order, while single-quoted and escaped-backtick text stay literal ([#5645](https://github.com/can1357/oh-my-pi/issues/5645)).
|
||||
- Fixed `omp say` playing no audio for a short single-segment clip on hosts where the first streaming backend (the bundled ffmpeg built without pulse/alsa output) spawns then exits nonzero: the pipe write succeeds before that death and `player.end()` has already closed the input, so neither the broken-pipe replay nor the early-exit handler advanced to `paplay`/`aplay`. `StreamingAudioPlayer` now retains the utterance PCM and, when the streaming backend exits nonzero, replays it through per-file playback so short clips still reach the speakers ([#5875](https://github.com/can1357/oh-my-pi/issues/5875)).
|
||||
- Fixed mid-session `memory.backend` changes leaving runtime state, tools, listeners, and prompt context on different backends; Mnemopi clear/enqueue now rehydrate listeners, and legacy `memories.enabled` no longer activates the local pipeline after migration ([#5638](https://github.com/can1357/oh-my-pi/issues/5638)).
|
||||
- Fixed `error.notify` raising a "Stopped with error" toast for provider failures while an auto-retry or async-delivery continuation was pending; the toast now waits for the true terminal settle.
|
||||
- Fixed concurrent MCP config mutations losing updates and racing on a shared temp path: every `mcp.json` read-modify-write (add/update/remove server, disabled/force-enabled lists) is now serialized under a per-file lock, and each atomic write uses a unique temp file so overlapping writers no longer rename each other's `.tmp` out from under them (ENOENT or clobbered config) — reachable in-process via the fire-and-forget extensions-dashboard toggle and across processes on a shared `~/.omp/mcp.json` ([#4104](https://github.com/can1357/oh-my-pi/issues/4104)).
|
||||
- Fixed transient provider stream stalls after tool calls failing to auto-retry even when every call already had a tool result, including synthetic `executed:false` results from OpenAI-completions stalls ([#6414](https://github.com/can1357/oh-my-pi/issues/6414)).
|
||||
- Fixed terminal `yield` results racing post-turn maintenance, which could trigger an unnecessary automatic handoff or compaction.
|
||||
- Fixed PDF image reads returning stale extractions after same-path content replacement and racing concurrent cold reads by binding each cache generation to immutable source bytes and coalescing extraction with independent caller cancellation. ([#6368](https://github.com/can1357/oh-my-pi/issues/6368))
|
||||
- Fixed direct edit calls failing with `File not found` for relative paths that `read` resolved to a unique nested workspace file. ([#6359](https://github.com/can1357/oh-my-pi/issues/6359))
|
||||
- Fixed concurrent dead-kernel replacement in persistent Python sessions starting multiple generations and orphaning the losing process; callers now share one generation-scoped replacement, while resets and disposal invalidate and drain stale replacements ([#6367](https://github.com/can1357/oh-my-pi/issues/6367)).
|
||||
- Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator.
|
||||
- Fixed a cancelled `lsp reload` reporting `Restarted <server>` while killing the server with no replacement: `reloadServer` swallowed `ToolAbortError`/tool timeout in both fallback `catch` blocks and fell through to `proc.kill()`. Cancellation and timeout now propagate; the rust-analyzer fallback only triggers on genuine method-not-found; and a wedged-connection teardown removes the client by identity and awaits confirmed process exit before claiming a restart (surfacing a truthful teardown error if the process outlives the kill). ([#6369](https://github.com/can1357/oh-my-pi/issues/6369))
|
||||
- Fixed Ctrl-clicking a wrapped OAuth authorization URL opening only the clicked row's truncated fragment by preserving the complete hyperlink target on every rendered row.
|
||||
- Fixed used-only absolute usage amounts across output surfaces: CLI now renders `$123.45 used`; the TUI shows a neutral, width-bounded amount instead of a pending/dotted/account-count placeholder; and ACP preserves `123.45 usd used` while suppressing duplicate window suffixes such as `— extra`. ([#5575](https://github.com/can1357/oh-my-pi/issues/5575))
|
||||
- Fixed auto-compaction re-triggering the "Compaction freed too little context" warning on every resume when the branch's last entry was an over-threshold snapcompact archive: the dead-end rescue now rebuilds the trailing archive locally at a threshold-derived frame budget (superseding the stale frame payload) instead of pausing, since the elide/image tiers can never touch a compaction entry (#4786).
|
||||
@@ -77,6 +93,7 @@
|
||||
### Removed
|
||||
|
||||
- Added dynamic multi-root workspace context (issue [#2569](https://github.com/can1357/oh-my-pi/issues/2569)): a session now carries an ordered list of workspace directories beyond `cwd`, managed live from the terminal. New `/add-dir <path>`, `/remove-dir <path>`, and `/dirs` slash commands let you add and remove folders mid-session; the repeatable `--add-dir <path>` CLI flag seeds them at launch, and the `workspace.additionalDirectories` setting persists defaults per project. Additional roots are persisted in the session header, survive reopen/fork/move, and are surfaced to the agent in the system prompt so it knows they exist and can `read`/`grep`/`glob` them by absolute path. Design aligns with the endorsed community implementation on `feature/session-workspace`.
|
||||
- Fixed the `browser` tool's `open` action ignoring the requested `timeout` during browser acquisition (CDP discovery/connect ran to its own fixed wait), and orphaning a freshly-created browser on abort/timeout before tab publication. The requested timeout now bounds the whole open lifecycle, and one explicit registry lease is held across tab acquisition so rollback disposes exactly the failed open — a concurrent open of a different tab name on the same browser can no longer dispose the browser out from under it. ([#6365](https://github.com/can1357/oh-my-pi/issues/6365))
|
||||
|
||||
## [17.0.9] - 2026-07-23
|
||||
|
||||
@@ -300,6 +317,9 @@
|
||||
### Changed
|
||||
|
||||
- Changed `providers.webSearch` preferred provider failure handling to fall back and cascade through other configured/default search providers rather than stopping immediately.
|
||||
### Fixed
|
||||
|
||||
- Fixed snapcompact archiving reproduced assistant reasoning (`¶think:` sections) into frames replayed to the model on every subsequent request, wedging Fable 5 sessions on `reasoning_extraction` refusals; snapcompact serialization now excludes reasoning when the session model uses the Anthropic dialect ([#6093](https://github.com/can1357/oh-my-pi/issues/6093)).
|
||||
|
||||
## [17.0.5] - 2026-07-18
|
||||
|
||||
|
||||
@@ -32,4 +32,4 @@ The agent supports three mutually-exclusive memory backends, selected via the `m
|
||||
- `HINDSIGHT_RECALL_BUDGET`, `HINDSIGHT_RECALL_MAX_TOKENS` — recall sizing
|
||||
- `HINDSIGHT_BANK_MISSION`, `HINDSIGHT_DEBUG`
|
||||
|
||||
Switching backends mid-session is honoured on the next system-prompt rebuild and the next `/memory` slash command. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch.
|
||||
Switching backends mid-session immediately replaces the live backend, memory tools, listeners, and system-prompt context. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch; afterward, `memory.backend` is the sole runtime selector.
|
||||
|
||||
@@ -5,10 +5,10 @@ import { gunzipSync, gzipSync } from "node:zlib";
|
||||
import { getAgentDir, getBlobsDir, getHistoryDbPath, getModelDbPath, getSessionsDir } from "@oh-my-pi/pi-utils";
|
||||
import { Settings } from "../config/settings";
|
||||
import { getDefault } from "../config/settings-schema";
|
||||
import { BLOB_HASH_RE } from "../session/blob-store";
|
||||
import { listSessionsReadOnly, type SessionInfo, type SessionStatus } from "../session/session-listing";
|
||||
import { FileSessionStorage } from "../session/session-storage";
|
||||
|
||||
const HASH_RE = /^[a-f0-9]{64}$/;
|
||||
const BLOB_FILE_RE = /^([a-f0-9]{64})(?:\.[A-Za-z0-9][A-Za-z0-9._-]{0,31})?$/;
|
||||
const BLOB_REF_RE = /\bblob:sha256:([a-f0-9]{64})\b/gi;
|
||||
const JSONL_GLOB = new Bun.Glob("**/*.jsonl");
|
||||
@@ -268,7 +268,7 @@ async function collectReferencedBlobHashes(sessionRoots: string[]): Promise<Set<
|
||||
const text = await readTextIfPresent(file);
|
||||
for (const match of text.matchAll(BLOB_REF_RE)) {
|
||||
const hash = match[1]?.toLowerCase();
|
||||
if (hash && HASH_RE.test(hash)) hashes.add(hash);
|
||||
if (hash && BLOB_HASH_RE.test(hash)) hashes.add(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,19 @@ export const OpenAICompatSchema = type({
|
||||
"whenThinking?": OpenAICompatFieldsSchema,
|
||||
});
|
||||
|
||||
const BedrockCompatSchema = type({
|
||||
"promptCacheMode?": '"none" | "automatic" | "explicit"',
|
||||
"supportsLongPromptCacheRetention?": "boolean",
|
||||
"promptCacheMinimumTokens?": "number >= 0",
|
||||
"promptCacheMaximumCheckpoints?": "number >= 0",
|
||||
});
|
||||
|
||||
// Provider-level overrides can target bundled models whose API is not repeated
|
||||
// in models.yml, so preserve the sparse compat shape for each supported API.
|
||||
const ApiCompatSchema = OpenAICompatSchema.or(BedrockCompatSchema);
|
||||
|
||||
const ApiSchema = type(
|
||||
'"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
|
||||
'"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
|
||||
);
|
||||
|
||||
const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh" | "max"');
|
||||
@@ -173,7 +184,7 @@ const ModelDefinitionSchema = type({
|
||||
"maxTokens?": "number",
|
||||
"omitMaxOutputTokens?": "boolean",
|
||||
"headers?": { "[string]": "string" },
|
||||
"compat?": OpenAICompatSchema,
|
||||
"compat?": ApiCompatSchema,
|
||||
"contextPromotionTarget?": "string",
|
||||
"compactionModel?": "string",
|
||||
"remoteCompaction?": RemoteCompactionSchema,
|
||||
@@ -222,7 +233,7 @@ export const ModelOverrideSchema = type({
|
||||
"maxTokens?": "number",
|
||||
"omitMaxOutputTokens?": "boolean",
|
||||
"headers?": { "[string]": "string" },
|
||||
"compat?": OpenAICompatSchema,
|
||||
"compat?": ApiCompatSchema,
|
||||
"contextPromotionTarget?": "string",
|
||||
"compactionModel?": "string",
|
||||
"remoteCompaction?": RemoteCompactionSchema,
|
||||
@@ -263,7 +274,7 @@ const ProviderConfigSchema = type({
|
||||
"apiKey?": "string",
|
||||
"api?": ApiSchema,
|
||||
"headers?": { "[string]": "string" },
|
||||
"compat?": OpenAICompatSchema,
|
||||
"compat?": ApiCompatSchema,
|
||||
"remoteCompaction?": RemoteCompactionSchema,
|
||||
"authHeader?": "boolean",
|
||||
"auth?": ProviderAuthSchema,
|
||||
|
||||
@@ -2493,9 +2493,8 @@ export const SETTINGS_SCHEMA = {
|
||||
"memories.summaryInjectionTokenLimit": { type: "number", default: 5000 },
|
||||
|
||||
// Memory backend selector — picks between local memories pipeline,
|
||||
// Mnemopi local SQLite, Hindsight remote memory, or off. Legacy
|
||||
// `memories.enabled` keeps gating the local backend; see config/settings.ts
|
||||
// migration for details.
|
||||
// Mnemopi local SQLite, Hindsight remote memory, or off. The legacy
|
||||
// `memories.enabled` flag is migration input only; see config/settings.ts.
|
||||
"memory.backend": {
|
||||
type: "enum",
|
||||
values: ["off", "local", "hindsight", "mnemopi"] as const,
|
||||
|
||||
@@ -364,6 +364,7 @@ export class Settings {
|
||||
if (options.configFiles) configFiles.push(...options.configFiles);
|
||||
this.#configFiles = configFiles.map(file => path.resolve(this.#cwd, expandTilde(file)));
|
||||
this.#persist = !options.inMemory && options.readOnly !== true;
|
||||
liveSettingsInstances.add(new WeakRef(this));
|
||||
|
||||
if (options.overrides) {
|
||||
for (const [key, value] of Object.entries(options.overrides)) {
|
||||
@@ -537,6 +538,23 @@ export class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/** Set once this instance is discarded; background saves become no-ops. */
|
||||
#savesCancelled = false;
|
||||
|
||||
/**
|
||||
* Drop pending debounced saves and refuse any further background writes.
|
||||
* Used when an instance is being discarded (test teardown): an armed timer
|
||||
* or a chained in-flight save on a dropped instance would otherwise fire
|
||||
* later and race the successor's file locks.
|
||||
*/
|
||||
cancelPendingSaves(): void {
|
||||
this.#savesCancelled = true;
|
||||
clearTimeout(this.#saveTimer);
|
||||
this.#saveTimer = undefined;
|
||||
clearTimeout(this.#projectSaveTimer);
|
||||
this.#projectSaveTimer = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending saves to disk.
|
||||
* Call before exit to ensure all changes are persisted.
|
||||
@@ -1688,7 +1706,7 @@ export class Settings {
|
||||
}
|
||||
|
||||
async #saveNow(): Promise<void> {
|
||||
if (!this.#persist || !this.#configPath) return;
|
||||
if (this.#savesCancelled || !this.#persist || !this.#configPath) return;
|
||||
if (this.#modified.size === 0 && this.#modifiedGlobalModelRoles.size === 0) return;
|
||||
|
||||
const configPath = this.#configPath;
|
||||
@@ -1792,7 +1810,7 @@ export class Settings {
|
||||
}
|
||||
|
||||
async #saveProjectNow(): Promise<void> {
|
||||
if (!this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
|
||||
if (this.#savesCancelled || !this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
|
||||
|
||||
const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
|
||||
const modifiedModelRoles = [...this.#modifiedProjectModelRoles];
|
||||
@@ -2023,6 +2041,13 @@ export const onHindsightScopeChanged = (cb: () => void) => hindsightScopeSignal.
|
||||
// Global Singleton
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Weak registry of every constructed instance so `resetSettingsForTest` can
|
||||
* disarm stray background saves on isolated instances too. WeakRefs never
|
||||
* retain instances; the set is cleared on every test reset.
|
||||
*/
|
||||
const liveSettingsInstances = new Set<WeakRef<Settings>>();
|
||||
|
||||
let globalInstance: Settings | null = null;
|
||||
let globalInstancePromise: Promise<Settings> | null = null;
|
||||
let boundSettingsInstance: Settings | null = null;
|
||||
@@ -2042,6 +2067,14 @@ export function isSettingsInitialized(): boolean {
|
||||
* @internal
|
||||
*/
|
||||
export function resetSettingsForTest(): void {
|
||||
// Disarm every constructed instance's debounced saves — including isolated
|
||||
// (non-singleton) instances: an armed timer or chained in-flight save on a
|
||||
// dropped instance fires mid-way through the NEXT test and races its file
|
||||
// locks/spies (cross-file pollution).
|
||||
for (const ref of liveSettingsInstances) {
|
||||
ref.deref()?.cancelPendingSaves();
|
||||
}
|
||||
liveSettingsInstances.clear();
|
||||
globalInstance = null;
|
||||
globalInstancePromise = null;
|
||||
clearBoundSettingsMethods();
|
||||
|
||||
@@ -3,7 +3,7 @@ import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text
|
||||
import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" };
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ToolExample } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import { isEnoent, isEnotdir, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { createLspWritethrough, flushLspWritethroughBatch, type WritethroughCallback, writethroughNoop } from "../lsp";
|
||||
import { DeferredDiagnostics } from "../lsp/deferred-diagnostics";
|
||||
import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
|
||||
@@ -12,7 +12,8 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
||||
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
||||
import type { ToolSession } from "../tools";
|
||||
import { truncateForPrompt } from "../tools/approval";
|
||||
import { isInternalUrlPath } from "../tools/path-utils";
|
||||
import { findUniqueWorkspaceSuffix, isInternalUrlPath } from "../tools/path-utils";
|
||||
import { resolvePlanPath } from "../tools/plan-mode-guard";
|
||||
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import { executeHashlineSingle, hashlineEditParamsSchema } from "./hashline";
|
||||
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
|
||||
@@ -73,6 +74,24 @@ function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
|
||||
return editMode;
|
||||
}
|
||||
|
||||
async function resolveEditPath(
|
||||
session: ToolSession,
|
||||
authoredPath: string,
|
||||
options: { mustExist: boolean; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
if (!options.mustExist || isInternalUrlPath(authoredPath)) return authoredPath;
|
||||
|
||||
try {
|
||||
await Bun.file(resolvePlanPath(session, authoredPath)).stat();
|
||||
return authoredPath;
|
||||
} catch (error) {
|
||||
if (!isEnoent(error) && !isEnotdir(error)) throw error;
|
||||
}
|
||||
|
||||
const match = await findUniqueWorkspaceSuffix(authoredPath, session.cwd, options.signal);
|
||||
return match?.displayPath ?? authoredPath;
|
||||
}
|
||||
|
||||
function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
|
||||
switch (rawValue) {
|
||||
case "true":
|
||||
@@ -517,7 +536,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
note: "All entries in one call apply to the top-level `path`; use separate calls for different files.",
|
||||
},
|
||||
] satisfies readonly ToolExample<PatchParams>[],
|
||||
execute: (
|
||||
execute: async (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -525,11 +544,15 @@ export class EditTool implements AgentTool<TInput> {
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits, path } = params as PatchParams;
|
||||
const targetPath = await resolveEditPath(tool.session, path, {
|
||||
mustExist: (edits[0]?.op ?? "update") !== "create",
|
||||
signal,
|
||||
});
|
||||
const runs = (edits as PatchEditEntry[]).map(
|
||||
entry => (br: LspBatchRequest | undefined) =>
|
||||
executePatchSingle({
|
||||
session: tool.session,
|
||||
path,
|
||||
path: targetPath,
|
||||
params: entry,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
@@ -542,7 +565,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
|
||||
}),
|
||||
);
|
||||
return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
|
||||
return executeSinglePathEntries(targetPath, runs, batchRequest, onUpdate, tool.session.cwd, signal);
|
||||
},
|
||||
},
|
||||
apply_patch: {
|
||||
@@ -564,14 +587,26 @@ export class EditTool implements AgentTool<TInput> {
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
|
||||
// Resolve each authored path once per patch so paired hunks (e.g. delete
|
||||
// then re-add of the same file) share the same workspace target.
|
||||
const resolvedTargets = new Map<string, Promise<string>>();
|
||||
const resolveOnce = (path: string, mustExist: boolean): Promise<string> => {
|
||||
let pending = resolvedTargets.get(path);
|
||||
if (!pending) {
|
||||
pending = resolveEditPath(tool.session, path, { mustExist, signal });
|
||||
resolvedTargets.set(path, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
const perFile = entries.map(entry => {
|
||||
const { path, ...patchParams } = entry;
|
||||
return {
|
||||
path,
|
||||
run: (br: LspBatchRequest | undefined) =>
|
||||
executePatchSingle({
|
||||
run: async (br: LspBatchRequest | undefined) => {
|
||||
const targetPath = await resolveOnce(path, patchParams.op !== "create");
|
||||
return executePatchSingle({
|
||||
session: tool.session,
|
||||
path,
|
||||
path: targetPath,
|
||||
params: patchParams,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
@@ -579,7 +614,8 @@ export class EditTool implements AgentTool<TInput> {
|
||||
fuzzyThreshold: tool.#fuzzyThreshold,
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
return executeApplyPatchPerFile(perFile, batchRequest, tool.session.cwd, signal, onUpdate);
|
||||
@@ -609,7 +645,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
replace: {
|
||||
description: () => prompt.render(replaceDescription),
|
||||
parameters: replaceEditSchema,
|
||||
execute: (
|
||||
execute: async (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -617,11 +653,12 @@ export class EditTool implements AgentTool<TInput> {
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
) => {
|
||||
const { edits, path } = params as ReplaceParams;
|
||||
const targetPath = await resolveEditPath(tool.session, path, { mustExist: true, signal });
|
||||
const runs = (edits as ReplaceEditEntry[]).map(
|
||||
entry => (br: LspBatchRequest | undefined) =>
|
||||
executeReplaceSingle({
|
||||
session: tool.session,
|
||||
path,
|
||||
path: targetPath,
|
||||
params: entry,
|
||||
signal,
|
||||
batchRequest: br,
|
||||
@@ -631,7 +668,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
|
||||
}),
|
||||
);
|
||||
return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
|
||||
return executeSinglePathEntries(targetPath, runs, batchRequest, onUpdate, tool.session.cwd, signal);
|
||||
},
|
||||
},
|
||||
}[this.mode];
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type KernelDisplayOutput,
|
||||
type KernelExecuteOptions,
|
||||
type KernelExecuteResult,
|
||||
type KernelShutdownResult,
|
||||
PythonKernel,
|
||||
} from "./kernel";
|
||||
import { resolveExplicitPythonRuntime } from "./runtime";
|
||||
@@ -136,11 +137,19 @@ export interface PythonResult {
|
||||
// the same tuple; the kernel stays alive until the last owner detaches.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SessionKernelReplacement {
|
||||
generation: number;
|
||||
deadlineMs?: number;
|
||||
promise: Promise<PythonKernel>;
|
||||
}
|
||||
|
||||
interface PythonSession {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
kernel: PythonKernel;
|
||||
generation: number;
|
||||
replacement?: SessionKernelReplacement;
|
||||
ownerIds: Set<string>;
|
||||
hasFallbackOwner: boolean;
|
||||
}
|
||||
@@ -254,6 +263,7 @@ async function acquireSession(
|
||||
sessionId,
|
||||
cwd,
|
||||
kernel,
|
||||
generation: 0,
|
||||
ownerIds: new Set(),
|
||||
hasFallbackOwner: false,
|
||||
};
|
||||
@@ -272,31 +282,99 @@ async function acquireSession(
|
||||
|
||||
async function replaceSessionKernel(
|
||||
session: PythonSession,
|
||||
kernel: PythonKernel,
|
||||
generation: number,
|
||||
cwd: string,
|
||||
options: PythonExecutorOptions,
|
||||
): Promise<void> {
|
||||
const old = session.kernel;
|
||||
const remaining = getRemainingTimeoutMs(options.deadlineMs);
|
||||
await old
|
||||
.shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
|
||||
.catch(() => undefined);
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
): Promise<PythonKernel> {
|
||||
const inFlight = session.replacement;
|
||||
if (inFlight?.generation === generation) {
|
||||
if (
|
||||
inFlight.deadlineMs !== undefined &&
|
||||
(options.deadlineMs === undefined || options.deadlineMs > inFlight.deadlineMs)
|
||||
) {
|
||||
inFlight.deadlineMs = options.deadlineMs;
|
||||
}
|
||||
return await waitForPromiseWithCancellation(inFlight.promise, options, PythonExecutionCancelledError);
|
||||
}
|
||||
if (sessions.get(session.sessionKey) !== session || session.generation !== generation || session.kernel !== kernel) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
requireRemainingTimeoutMs(options.deadlineMs);
|
||||
const next = await startKernel(cwd, options);
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
await next.shutdown().catch(() => undefined);
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
|
||||
const deferred = Promise.withResolvers<PythonKernel>();
|
||||
const replacement: SessionKernelReplacement = {
|
||||
generation,
|
||||
deadlineMs: options.deadlineMs,
|
||||
promise: deferred.promise,
|
||||
};
|
||||
session.replacement = replacement;
|
||||
void (async () => {
|
||||
try {
|
||||
const remaining = getRemainingTimeoutMs(options.deadlineMs);
|
||||
await kernel
|
||||
.shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
|
||||
.catch(() => undefined);
|
||||
if (replacement.deadlineMs !== undefined && replacement.deadlineMs <= Date.now()) {
|
||||
throw new PythonExecutionCancelledError(true);
|
||||
}
|
||||
if (
|
||||
sessions.get(session.sessionKey) !== session ||
|
||||
session.generation !== generation ||
|
||||
session.kernel !== kernel
|
||||
) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
const next = await startKernel(cwd, {
|
||||
...options,
|
||||
signal: undefined,
|
||||
deadlineMs: undefined,
|
||||
});
|
||||
if (
|
||||
sessions.get(session.sessionKey) !== session ||
|
||||
session.generation !== generation ||
|
||||
session.kernel !== kernel
|
||||
) {
|
||||
await next.shutdown().catch(() => undefined);
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
session.kernel = next;
|
||||
session.generation += 1;
|
||||
deferred.resolve(next);
|
||||
} catch (err) {
|
||||
deferred.reject(err);
|
||||
} finally {
|
||||
if (session.replacement === replacement) session.replacement = undefined;
|
||||
}
|
||||
})();
|
||||
return await waitForPromiseWithCancellation(deferred.promise, options, PythonExecutionCancelledError);
|
||||
}
|
||||
|
||||
async function shutdownInvalidatedSession(session: PythonSession): Promise<KernelShutdownResult> {
|
||||
const replacement = session.replacement;
|
||||
if (replacement) await replacement.promise.catch(() => undefined);
|
||||
return await session.kernel.shutdown();
|
||||
}
|
||||
|
||||
async function acquireLiveSessionKernel(
|
||||
session: PythonSession,
|
||||
cwd: string,
|
||||
options: PythonExecutorOptions,
|
||||
): Promise<PythonKernel> {
|
||||
while (sessions.get(session.sessionKey) === session) {
|
||||
const kernel = session.kernel;
|
||||
const generation = session.generation;
|
||||
if (kernel.isAlive()) return kernel;
|
||||
await replaceSessionKernel(session, kernel, generation, cwd, options);
|
||||
}
|
||||
session.kernel = next;
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
|
||||
async function resetSession(sessionKey: string): Promise<void> {
|
||||
const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
|
||||
if (!existing) return;
|
||||
existing.generation += 1;
|
||||
sessions.delete(sessionKey);
|
||||
await existing.kernel.shutdown().catch(() => undefined);
|
||||
await shutdownInvalidatedSession(existing).catch(() => undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -315,9 +393,10 @@ export async function disposeAllKernelSessions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
for (const [id, session] of all) {
|
||||
session.generation += 1;
|
||||
if (sessions.get(id) === session) sessions.delete(id);
|
||||
}
|
||||
const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown()));
|
||||
const results = await Promise.allSettled(all.map(([, session]) => shutdownInvalidatedSession(session)));
|
||||
for (let i = 0; i < all.length; i += 1) {
|
||||
const [id, session] = all[i];
|
||||
const result = results[i];
|
||||
@@ -344,9 +423,10 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
|
||||
session.ownerIds.delete(ownerId);
|
||||
}
|
||||
for (const session of toShutdown) {
|
||||
session.generation += 1;
|
||||
if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
|
||||
}
|
||||
const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
|
||||
const results = await Promise.allSettled(toShutdown.map(session => shutdownInvalidatedSession(session)));
|
||||
for (let i = 0; i < toShutdown.length; i += 1) {
|
||||
const session = toShutdown[i];
|
||||
const result = results[i];
|
||||
@@ -458,31 +538,21 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
|
||||
isTimedOutCancellation(options.signal.reason, PythonExecutionCancelledError, options.signal),
|
||||
);
|
||||
}
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
const kernel = await acquireLiveSessionKernel(session, cwd, options);
|
||||
if (sessions.get(session.sessionKey) !== session || session.kernel !== kernel) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
if (!session.kernel.isAlive()) {
|
||||
await replaceSessionKernel(session, cwd, options);
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
}
|
||||
const runOptions = { ...options, cwd };
|
||||
try {
|
||||
return await executeWithKernel(session.kernel, code, runOptions);
|
||||
return await executeWithKernel(kernel, code, runOptions);
|
||||
} catch (err) {
|
||||
if (isCancellationError(err, PythonExecutionCancelledError) || options.signal?.aborted) throw err;
|
||||
if (session.kernel.isAlive()) throw err;
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
if (kernel.isAlive()) throw err;
|
||||
const retryKernel = await acquireLiveSessionKernel(session, cwd, options);
|
||||
if (sessions.get(session.sessionKey) !== session || session.kernel !== retryKernel) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
// Shared kernels are keyed by cwd, so a dead kernel can be recreated in place
|
||||
// without risking cross-directory state bleed.
|
||||
await replaceSessionKernel(session, cwd, options);
|
||||
if (sessions.get(session.sessionKey) !== session) {
|
||||
throw new PythonExecutionCancelledError(false);
|
||||
}
|
||||
return await executeWithKernel(session.kernel, code, runOptions);
|
||||
return await executeWithKernel(retryKernel, code, runOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,15 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
||||
if (pending) {
|
||||
client.pendingRequests.delete(message.id);
|
||||
if ("error" in message && message.error) {
|
||||
pending.reject(new Error(`LSP error: ${message.error.message}`));
|
||||
// Include the JSON-RPC error code: `isMethodNotFoundError` matches
|
||||
// `-32601` by substring, so method-not-found is recognized even when
|
||||
// the server's message text is nonstandard (e.g. "Unknown request").
|
||||
const code = message.error.code;
|
||||
pending.reject(
|
||||
new Error(
|
||||
`LSP error${typeof code === "number" ? ` ${code}` : ""}: ${message.error.message}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
@@ -1181,9 +1189,19 @@ async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolea
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown a specific client instance using the LSP shutdown/exit handshake.
|
||||
* Tear down a specific client instance using the LSP shutdown/exit handshake.
|
||||
*
|
||||
* Removes the client from the registry by identity first (never evicting a
|
||||
* newer client already republished under the same key), then performs a bounded
|
||||
* graceful shutdown, force-killing and awaiting confirmed process exit.
|
||||
*
|
||||
* @returns `true` once the process is confirmed exited, `false` if it outlived
|
||||
* the shutdown budget — callers reporting a restart must treat `false` as a
|
||||
* failed teardown, not a completed restart.
|
||||
*/
|
||||
async function shutdownClientInstance(client: LspClient): Promise<void> {
|
||||
export async function shutdownClientInstance(client: LspClient): Promise<boolean> {
|
||||
if (clients.get(client.name) === client) clients.delete(client.name);
|
||||
|
||||
const err = new Error("LSP client shutdown");
|
||||
for (const pending of Array.from(client.pendingRequests.values())) {
|
||||
pending.reject(err);
|
||||
@@ -1196,21 +1214,23 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
|
||||
);
|
||||
if (shutdownCompleted) {
|
||||
await sendNotification(client, "exit", undefined).catch(() => {});
|
||||
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
|
||||
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return true;
|
||||
}
|
||||
|
||||
client.proc.kill();
|
||||
await waitForExit(client, EXIT_TIMEOUT_MS);
|
||||
return await waitForExit(client, EXIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown a specific client by key.
|
||||
*
|
||||
* @returns `true` when the client is gone (already absent or confirmed exited),
|
||||
* `false` if a live process outlived the shutdown budget.
|
||||
*/
|
||||
export async function shutdownClient(key: string): Promise<void> {
|
||||
export async function shutdownClient(key: string): Promise<boolean> {
|
||||
const client = clients.get(key);
|
||||
if (!client) return;
|
||||
clients.delete(key);
|
||||
await shutdownClientInstance(client);
|
||||
if (!client) return true;
|
||||
return await shutdownClientInstance(client);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sendNotification,
|
||||
sendRequest,
|
||||
setIdleTimeout,
|
||||
shutdownClientInstance,
|
||||
supportsDocumentDiagnostics,
|
||||
syncContent,
|
||||
WARMUP_TIMEOUT_MS,
|
||||
@@ -507,12 +508,18 @@ function isMethodNotFoundError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
|
||||
// rust-analyzer exposes a real reload request.
|
||||
throwIfAborted(signal);
|
||||
// rust-analyzer exposes a real reload request. Every other server rejects it
|
||||
// with method-not-found — that alone justifies the generic fallback. A caller
|
||||
// cancel or tool timeout must propagate, never be mistaken for an unsupported
|
||||
// method and swallowed into a bogus "Restarted" (issue #6369).
|
||||
try {
|
||||
await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch {
|
||||
// Method not supported — fall through.
|
||||
} catch (err) {
|
||||
throwIfAborted(signal);
|
||||
if (!isMethodNotFoundError(err)) throw err;
|
||||
// Method not supported — fall through to the generic reload.
|
||||
}
|
||||
// workspace/didChangeConfiguration is a notification per spec; sending it
|
||||
// as a request hangs until the tool deadline on servers that route it to
|
||||
@@ -521,7 +528,15 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
|
||||
await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} }, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch {
|
||||
client.proc.kill();
|
||||
throwIfAborted(signal);
|
||||
// The reload notification could not be delivered — the connection is
|
||||
// wedged or the process already died. Tear the client down (removing it
|
||||
// from the registry by identity and awaiting confirmed process exit) so
|
||||
// the next request cold-starts a fresh client. A kill that never confirms
|
||||
// exit is not a restart: surface the teardown failure truthfully.
|
||||
if (!(await shutdownClientInstance(client))) {
|
||||
throw new Error(`Failed to restart ${serverName}: server process did not exit after kill`);
|
||||
}
|
||||
return `Restarted ${serverName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { addMCPServer, readDisabledServers, readMCPConfigFile, setServerDisabled } from "./config-writer";
|
||||
|
||||
describe("config-writer concurrent mutations", () => {
|
||||
let dir: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-config-"));
|
||||
filePath = path.join(dir, "mcp.json");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("preserves both servers when two adds race the same file", async () => {
|
||||
await Promise.all([
|
||||
addMCPServer(filePath, "alpha", { type: "stdio", command: "a" }),
|
||||
addMCPServer(filePath, "bravo", { type: "stdio", command: "b" }),
|
||||
]);
|
||||
|
||||
const config = await readMCPConfigFile(filePath);
|
||||
expect(Object.keys(config.mcpServers ?? {}).sort()).toEqual(["alpha", "bravo"]);
|
||||
});
|
||||
|
||||
it("preserves both denylist edits when disable calls race", async () => {
|
||||
await Promise.all([setServerDisabled(filePath, "alpha", true), setServerDisabled(filePath, "bravo", true)]);
|
||||
|
||||
expect((await readDisabledServers(filePath)).sort()).toEqual(["alpha", "bravo"]);
|
||||
});
|
||||
|
||||
it("writes into a directory that does not exist yet", async () => {
|
||||
const nestedPath = path.join(dir, "nested", "deep", "mcp.json");
|
||||
await addMCPServer(nestedPath, "alpha", { type: "stdio", command: "a" });
|
||||
|
||||
const config = await readMCPConfigFile(nestedPath);
|
||||
expect(Object.keys(config.mcpServers ?? {})).toEqual(["alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,12 @@
|
||||
*
|
||||
* Utilities for reading/writing .omp/mcp.json files at user or project level.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { invalidate as invalidateFsCache } from "../capability/fs";
|
||||
import { withFileLock } from "../config/file-lock";
|
||||
|
||||
import { validateServerConfig } from "./config";
|
||||
import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types";
|
||||
@@ -18,6 +20,20 @@ function withSchema(config: MCPConfigFile): MCPConfigFile {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a read-modify-write against one config file.
|
||||
*
|
||||
* Wraps {@link withFileLock} but first ensures the config's parent directory
|
||||
* exists, because the lock directory (`${filePath}.lock`) is created with a
|
||||
* non-recursive `mkdir` — without this the very first write (before the config
|
||||
* file or its parent exists) would fail to acquire the lock with ENOENT.
|
||||
*/
|
||||
function withConfigLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return fs.promises
|
||||
.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
|
||||
.then(() => withFileLock(filePath, fn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an MCP config file.
|
||||
* Returns empty config if file doesn't exist.
|
||||
@@ -45,13 +61,20 @@ export async function writeMCPConfigFile(filePath: string, config: MCPConfigFile
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Write to temp file first (atomic write)
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
// Write to a per-writer temp file, then atomically rename into place. The
|
||||
// temp name is unique (pid + random) so two concurrent writers to the same
|
||||
// config never share one `.tmp` path and rename each other's file out from
|
||||
// under them (which surfaced as ENOENT or a clobbered final file).
|
||||
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
const content = JSON.stringify(withSchema(config), null, 2);
|
||||
await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
||||
|
||||
// Rename to final path (atomic on most systems)
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
try {
|
||||
await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
||||
// Rename to final path (atomic on most systems)
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
// Invalidate the capability fs cache so subsequent reads see the new content
|
||||
invalidateFsCache(filePath);
|
||||
}
|
||||
@@ -97,25 +120,26 @@ export async function addMCPServer(filePath: string, name: string, config: MCPSe
|
||||
throw new Error(`Invalid server config: ${errors.join("; ")}`);
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
// Serialize the read-modify-write under a per-file lock so a concurrent
|
||||
// mutation cannot overwrite this one (lost update). The lock also guards
|
||||
// against cross-process writers sharing the same config file.
|
||||
await withConfigLock(filePath, async () => {
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
|
||||
// Check for duplicate name
|
||||
if (existing.mcpServers?.[name]) {
|
||||
throw new Error(`Server "${name}" already exists in ${filePath}`);
|
||||
}
|
||||
// Check for duplicate name
|
||||
if (existing.mcpServers?.[name]) {
|
||||
throw new Error(`Server "${name}" already exists in ${filePath}`);
|
||||
}
|
||||
|
||||
// Add server
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
[name]: config,
|
||||
},
|
||||
};
|
||||
|
||||
// Write back
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
[name]: config,
|
||||
},
|
||||
};
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,20 +161,19 @@ export async function updateMCPServer(filePath: string, name: string, config: MC
|
||||
throw new Error(`Invalid server config: ${errors.join("; ")}`);
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
// Serialize the read-modify-write (see addMCPServer).
|
||||
await withConfigLock(filePath, async () => {
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
|
||||
// Update server
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
[name]: config,
|
||||
},
|
||||
};
|
||||
|
||||
// Write back
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
[name]: config,
|
||||
},
|
||||
};
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,23 +182,21 @@ export async function updateMCPServer(filePath: string, name: string, config: MC
|
||||
* @throws Error if server doesn't exist
|
||||
*/
|
||||
export async function removeMCPServer(filePath: string, name: string): Promise<void> {
|
||||
// Read existing config
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
// Serialize the read-modify-write (see addMCPServer).
|
||||
await withConfigLock(filePath, async () => {
|
||||
const existing = await readMCPConfigFile(filePath);
|
||||
|
||||
// Check if server exists
|
||||
if (!existing.mcpServers?.[name]) {
|
||||
throw new Error(`Server "${name}" not found in ${filePath}`);
|
||||
}
|
||||
if (!existing.mcpServers?.[name]) {
|
||||
throw new Error(`Server "${name}" not found in ${filePath}`);
|
||||
}
|
||||
|
||||
// Remove server
|
||||
const { [name]: _removed, ...remaining } = existing.mcpServers;
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: remaining,
|
||||
};
|
||||
|
||||
// Write back
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
const { [name]: _removed, ...remaining } = existing.mcpServers;
|
||||
const updated: MCPConfigFile = {
|
||||
...existing,
|
||||
mcpServers: remaining,
|
||||
};
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,25 +228,28 @@ export async function readDisabledServers(filePath: string): Promise<string[]> {
|
||||
* Add or remove a server name from the disabled servers list.
|
||||
*/
|
||||
export async function setServerDisabled(filePath: string, name: string, disabled: boolean): Promise<void> {
|
||||
const config = await readMCPConfigFile(filePath);
|
||||
const current = new Set(config.disabledServers ?? []);
|
||||
// Serialize the read-modify-write (see addMCPServer).
|
||||
await withConfigLock(filePath, async () => {
|
||||
const config = await readMCPConfigFile(filePath);
|
||||
const current = new Set(config.disabledServers ?? []);
|
||||
|
||||
if (disabled) {
|
||||
current.add(name);
|
||||
} else {
|
||||
current.delete(name);
|
||||
}
|
||||
if (disabled) {
|
||||
current.add(name);
|
||||
} else {
|
||||
current.delete(name);
|
||||
}
|
||||
|
||||
const updated: MCPConfigFile = {
|
||||
...config,
|
||||
disabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
|
||||
};
|
||||
const updated: MCPConfigFile = {
|
||||
...config,
|
||||
disabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
|
||||
};
|
||||
|
||||
if (!updated.disabledServers) {
|
||||
delete updated.disabledServers;
|
||||
}
|
||||
if (!updated.disabledServers) {
|
||||
delete updated.disabledServers;
|
||||
}
|
||||
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,25 +267,28 @@ export async function readEnabledServers(filePath: string): Promise<string[]> {
|
||||
* NOT override the `disabledServers` denylist.
|
||||
*/
|
||||
export async function setServerForceEnabled(filePath: string, name: string, force: boolean): Promise<void> {
|
||||
const config = await readMCPConfigFile(filePath);
|
||||
const current = new Set(config.enabledServers ?? []);
|
||||
// Serialize the read-modify-write (see addMCPServer).
|
||||
await withConfigLock(filePath, async () => {
|
||||
const config = await readMCPConfigFile(filePath);
|
||||
const current = new Set(config.enabledServers ?? []);
|
||||
|
||||
if (force) {
|
||||
current.add(name);
|
||||
} else {
|
||||
current.delete(name);
|
||||
}
|
||||
if (force) {
|
||||
current.add(name);
|
||||
} else {
|
||||
current.delete(name);
|
||||
}
|
||||
|
||||
const updated: MCPConfigFile = {
|
||||
...config,
|
||||
enabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
|
||||
};
|
||||
const updated: MCPConfigFile = {
|
||||
...config,
|
||||
enabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
|
||||
};
|
||||
|
||||
if (!updated.enabledServers) {
|
||||
delete updated.enabledServers;
|
||||
}
|
||||
if (!updated.enabledServers) {
|
||||
delete updated.enabledServers;
|
||||
}
|
||||
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
await writeMCPConfigFile(filePath, updated);
|
||||
});
|
||||
}
|
||||
|
||||
/** Paths and target state for toggling one MCP server across known config files. */
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import { withTimeoutSignal } from "../utils/fetch-timeout";
|
||||
|
||||
/** Per-request abort deadline for each OAuth discovery metadata fetch. */
|
||||
const DISCOVERY_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface OAuthEndpoints {
|
||||
authorizationUrl: string;
|
||||
@@ -346,7 +350,7 @@ function readMetadataScopes(metadata: Record<string, unknown>): string | undefin
|
||||
*/
|
||||
export async function fetchResourceMetadataScopes(
|
||||
resourceMetadataUrl: string,
|
||||
opts?: { fetch?: FetchImpl },
|
||||
opts?: { fetch?: FetchImpl; signal?: AbortSignal },
|
||||
): Promise<string | undefined> {
|
||||
const fetchImpl: FetchImpl = opts?.fetch ?? fetch;
|
||||
try {
|
||||
@@ -354,6 +358,7 @@ export async function fetchResourceMetadataScopes(
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
|
||||
});
|
||||
if (!resp.ok) return undefined;
|
||||
const meta = (await resp.json()) as Record<string, unknown>;
|
||||
@@ -371,7 +376,7 @@ export async function discoverOAuthEndpoints(
|
||||
serverUrl: string,
|
||||
authServerUrl?: string,
|
||||
resourceMetadataUrl?: string,
|
||||
opts?: { fetch?: FetchImpl; protectedResource?: string; protectedScopes?: string },
|
||||
opts?: { fetch?: FetchImpl; protectedResource?: string; protectedScopes?: string; signal?: AbortSignal },
|
||||
): Promise<OAuthEndpoints | null> {
|
||||
const fetchImpl: FetchImpl = opts?.fetch ?? fetch;
|
||||
const wellKnownPaths = [
|
||||
@@ -402,6 +407,7 @@ export async function discoverOAuthEndpoints(
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
|
||||
});
|
||||
if (metaResp.ok) {
|
||||
const meta = (await metaResp.json()) as Record<string, unknown>;
|
||||
@@ -486,6 +492,7 @@ export async function discoverOAuthEndpoints(
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: withTimeoutSignal(DISCOVERY_FETCH_TIMEOUT_MS, opts?.signal),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -521,6 +528,7 @@ export async function discoverOAuthEndpoints(
|
||||
fetch: fetchImpl,
|
||||
protectedResource: discoveredProtectedResource,
|
||||
protectedScopes: readMetadataScopes(metadata) ?? protectedScopes,
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (discovered) return discovered;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { pollSmitheryCliAuthSession } from "./smithery-auth";
|
||||
|
||||
type FetchInput = string | URL | Request;
|
||||
type FetchInit = RequestInit | BunFetchRequestInit;
|
||||
|
||||
describe("pollSmitheryCliAuthSession fetch cancellation", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("bounds each poll request with a timeout abort signal even without a caller signal", async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
const fetchStub = Object.assign(
|
||||
async (_input: FetchInput, init?: FetchInit) => {
|
||||
if (init?.signal instanceof AbortSignal) signals.push(init.signal);
|
||||
return Response.json({ status: "pending" });
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
);
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
|
||||
|
||||
const result = await pollSmitheryCliAuthSession("sess-123");
|
||||
|
||||
expect(result.status).toBe("pending");
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,14 @@ import { withTimeoutSignal } from "../utils/fetch-timeout";
|
||||
const SMITHERY_AUTH_FILENAME = "smithery.json";
|
||||
const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
|
||||
const SMITHERY_AUTH_TIMEOUT_MS = 10_000;
|
||||
const SMITHERY_POLL_TIMEOUT_MS = 30_000;
|
||||
|
||||
type SmitheryCliAuthSession = {
|
||||
sessionId: string;
|
||||
authUrl: string;
|
||||
};
|
||||
|
||||
type SmitheryCliPollResponse = {
|
||||
export type SmitheryCliPollResponse = {
|
||||
status: "pending" | "success" | "error";
|
||||
apiKey?: string;
|
||||
message?: string;
|
||||
@@ -53,7 +54,7 @@ export async function pollSmitheryCliAuthSession(
|
||||
signal?: AbortSignal,
|
||||
): Promise<SmitheryCliPollResponse> {
|
||||
const response = await fetch(`${SMITHERY_URL}/api/auth/cli/poll/${sessionId}`, {
|
||||
signal,
|
||||
signal: withTimeoutSignal(SMITHERY_POLL_TIMEOUT_MS, signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
|
||||
@@ -142,9 +142,12 @@ export function startMemoryStartupTask(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg }).catch(error => {
|
||||
logger.warn("Memory startup failed", { error: String(error) });
|
||||
});
|
||||
const signal = session.beginLocalMemoryStartup?.() ?? new AbortController().signal;
|
||||
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg, signal })
|
||||
.catch(error => {
|
||||
if (!signal.aborted) logger.warn("Memory startup failed", { error: String(error) });
|
||||
})
|
||||
.finally(() => session.endLocalMemoryStartup?.(signal));
|
||||
}
|
||||
|
||||
interface MemoryInstructionSession {
|
||||
@@ -315,26 +318,32 @@ export function enqueueMemoryConsolidation(agentDir: string, cwd: string, source
|
||||
}
|
||||
}
|
||||
|
||||
async function runMemoryStartup(options: {
|
||||
interface MemoryStartupOptions {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
function isMemoryStartupActive(options: MemoryStartupOptions): boolean {
|
||||
return !options.signal.aborted && !options.session.isDisposed && options.settings.get("memory.backend") === "local";
|
||||
}
|
||||
|
||||
async function runMemoryStartup(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await runPhase1(options);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await runPhase2(options);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await refreshMemoryToolDeveloperInstructionsCacheAfterStartup(options.session, options.agentDir, options.settings);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await options.session.refreshBaseSystemPrompt?.();
|
||||
}
|
||||
|
||||
async function runPhase1(options: {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
async function runPhase1(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const { session, modelRegistry, agentDir, config } = options;
|
||||
const db = openMemoryDb(getAgentDbPath(agentDir));
|
||||
const nowSec = unixNow();
|
||||
@@ -344,6 +353,7 @@ async function runPhase1(options: {
|
||||
|
||||
try {
|
||||
const threads = await collectThreads(session, currentThreadId);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
upsertThreads(db, threads);
|
||||
|
||||
const phase1Model = await resolveMemoryModel({
|
||||
@@ -364,6 +374,7 @@ async function runPhase1(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const claims = claimStage1Jobs(db, {
|
||||
nowSec,
|
||||
threadScanLimit: config.threadScanLimit,
|
||||
@@ -387,6 +398,7 @@ async function runPhase1(options: {
|
||||
};
|
||||
|
||||
await runWithConcurrency(claims, config.stage1Concurrency, async claim => {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const result = await runStage1Job({
|
||||
claim,
|
||||
model: phase1Model,
|
||||
@@ -395,6 +407,7 @@ async function runPhase1(options: {
|
||||
config,
|
||||
metadata: session.agent?.metadataForProvider(phase1Model.provider),
|
||||
});
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
|
||||
if (result.kind === "failed") {
|
||||
logger.error("Memory phase1 stage1 job failed", {
|
||||
@@ -460,13 +473,8 @@ async function runPhase1(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPhase2(options: {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
async function runPhase2(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const { session, modelRegistry, agentDir, config } = options;
|
||||
const cwd = session.sessionManager.getCwd();
|
||||
const db = openMemoryDb(getAgentDbPath(agentDir));
|
||||
@@ -488,8 +496,10 @@ async function runPhase2(options: {
|
||||
const newWatermark = computeCompletionWatermark(claim.inputWatermark, outputs);
|
||||
|
||||
await syncPhase2Artifacts(memoryRoot, outputs);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
if (outputs.length === 0) {
|
||||
await cleanupConsolidatedArtifacts(memoryRoot);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const marked = markGlobalPhase2Succeeded(db, {
|
||||
ownershipToken: claim.ownershipToken,
|
||||
newWatermark,
|
||||
@@ -502,6 +512,7 @@ async function runPhase2(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const phase2Model = await resolveMemoryModel({
|
||||
modelRegistry,
|
||||
session,
|
||||
@@ -529,8 +540,13 @@ async function runPhase2(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
let heartbeatLostOwnership = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!isMemoryStartupActive(options)) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
const ok = heartbeatGlobalJob(db, {
|
||||
ownershipToken: claim.ownershipToken,
|
||||
leaseSeconds: config.phase2LeaseSeconds,
|
||||
@@ -544,13 +560,16 @@ async function runPhase2(options: {
|
||||
}, config.phase2HeartbeatSeconds * 1000);
|
||||
|
||||
try {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const consolidated = await runConsolidationModel({
|
||||
memoryRoot,
|
||||
model: phase2Model,
|
||||
apiKey: modelRegistry.resolver(phase2Model, session.sessionId),
|
||||
metadata: session.agent?.metadataForProvider(phase2Model.provider),
|
||||
});
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await applyConsolidation(memoryRoot, consolidated);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
if (heartbeatLostOwnership) {
|
||||
throw new Error("Phase2 lease ownership lost before completion");
|
||||
}
|
||||
@@ -564,6 +583,7 @@ async function runPhase2(options: {
|
||||
throw new Error("Phase2 could not mark success: ownership lost");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
markPhase2FailureWithFallback(db, {
|
||||
claim,
|
||||
retryDelaySeconds: config.phase2RetryDelaySeconds,
|
||||
@@ -1227,7 +1247,7 @@ async function resolveMemoryModel(options: {
|
||||
|
||||
function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
|
||||
return {
|
||||
enabled: settings.get("memory.backend") === "local" || settings.get("memories.enabled") === true,
|
||||
enabled: settings.get("memory.backend") === "local",
|
||||
maxRolloutsPerStartup: settings.get("memories.maxRolloutsPerStartup") ?? DEFAULTS.maxRolloutsPerStartup,
|
||||
maxRolloutAgeDays: settings.get("memories.maxRolloutAgeDays") ?? DEFAULTS.maxRolloutAgeDays,
|
||||
minRolloutIdleHours: settings.get("memories.minRolloutIdleHours") ?? DEFAULTS.minRolloutIdleHours,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Built-in tools whose availability depends on the selected memory backend. */
|
||||
export const MEMORY_BACKEND_TOOL_NAMES = ["retain", "recall", "reflect", "memory_edit", "learn"] as const;
|
||||
@@ -62,6 +62,20 @@ const STATIC_INSTRUCTIONS = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
|
||||
const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
||||
const previous = setMnemopiSessionState(session, state);
|
||||
await previous?.dispose();
|
||||
try {
|
||||
state.attachSessionListeners();
|
||||
return state;
|
||||
} catch (error) {
|
||||
setMnemopiSessionState(session, undefined);
|
||||
await state.dispose({ consolidate: false });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const mnemopiBackend: MemoryBackend = {
|
||||
id: "mnemopi",
|
||||
|
||||
@@ -90,10 +104,7 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
try {
|
||||
const config = await loadMnemopiConfigWithProviders(settings, agentDir, modelRegistry, sessionId);
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
const state = new MnemopiSessionState({ sessionId, config, session });
|
||||
const previous = setMnemopiSessionState(session, state);
|
||||
await previous?.dispose();
|
||||
state.attachSessionListeners();
|
||||
await installMnemopiState(session, config);
|
||||
} catch (error) {
|
||||
logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
|
||||
}
|
||||
@@ -129,12 +140,19 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
requireMnemopiCore().resetMemoryForTests();
|
||||
await Bun.sleep(0);
|
||||
await removeDbFiles(getMnemopiScopedDbPaths(config));
|
||||
if (!session?.sessionId || previous?.aliasOf || session.settings.get("memory.backend") !== "mnemopi") return;
|
||||
try {
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
await installMnemopiState(session, config);
|
||||
} catch (error) {
|
||||
logger.warn("Mnemopi: clear rehydrate failed; memory backend inert.", { error: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
async enqueue(agentDir, _cwd, session): Promise<void> {
|
||||
try {
|
||||
let state = getMnemopiSessionState(session);
|
||||
if (!state && session) {
|
||||
if (!state && session?.sessionId) {
|
||||
const config = await loadMnemopiConfigWithProviders(
|
||||
session.settings,
|
||||
agentDir,
|
||||
@@ -142,8 +160,7 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
session.sessionId,
|
||||
);
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
||||
setMnemopiSessionState(session, state);
|
||||
state = await installMnemopiState(session, config);
|
||||
}
|
||||
await state?.consolidate({ full: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -532,7 +532,7 @@ export class ExtensionUiController {
|
||||
* Show a tool error in the chat.
|
||||
*/
|
||||
showToolError(toolName: string, error: string): void {
|
||||
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
|
||||
const errorText = new Text(`Tool "${toolName}" error: ${error}`, 1, 0).setStyleFn(t => theme.fg("error", t));
|
||||
this.ctx.present(errorText);
|
||||
}
|
||||
|
||||
@@ -1092,7 +1092,9 @@ export class ExtensionUiController {
|
||||
}
|
||||
|
||||
showExtensionError(extensionPath: string, error: string): void {
|
||||
const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
|
||||
const errorText = new Text(`Extension "${extensionPath}" error: ${error}`, 1, 0).setStyleFn(t =>
|
||||
theme.fg("error", t),
|
||||
);
|
||||
this.ctx.present(errorText);
|
||||
}
|
||||
async #handleInteractiveCompact(instructionsOrOptions: string | CompactOptions | undefined): Promise<void> {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
getSmitheryApiKey,
|
||||
getSmitheryLoginUrl,
|
||||
pollSmitheryCliAuthSession,
|
||||
type SmitheryCliPollResponse,
|
||||
saveSmitheryApiKey,
|
||||
} from "../../mcp/smithery-auth";
|
||||
import { SmitheryConnectError } from "../../mcp/smithery-connect";
|
||||
@@ -51,6 +52,7 @@ import type { MCPAuthChallenge, MCPAuthConfig, MCPServerConfig, MCPServerConnect
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { urlHyperlinkAlways } from "../../tui";
|
||||
import { copyToClipboard } from "../../utils/clipboard";
|
||||
import { isTimeoutError } from "../../utils/fetch-timeout";
|
||||
import { openPath } from "../../utils/open";
|
||||
import { ChatBlock } from "../components/chat-block";
|
||||
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
||||
@@ -2121,7 +2123,14 @@ export class MCPCommandController {
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
throw new Error("Smithery authorization timed out after 5 minutes.");
|
||||
}
|
||||
const response = await pollSmitheryCliAuthSession(sessionId, signal);
|
||||
let response: SmitheryCliPollResponse;
|
||||
try {
|
||||
response = await pollSmitheryCliAuthSession(sessionId, signal);
|
||||
} catch (error) {
|
||||
// A single hung/slow poll aborts with TimeoutError; retry until the deadline.
|
||||
if (isTimeoutError(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
if (response.status === "success" && response.apiKey) {
|
||||
return response.apiKey;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,11 @@ export class SelectorController {
|
||||
this.ctx.showError(`Failed to apply personality: ${err}`);
|
||||
});
|
||||
break;
|
||||
case "memory.backend":
|
||||
void this.ctx.session.applyMemoryBackend().catch(err => {
|
||||
this.ctx.showError(`Failed to apply memory backend: ${err}`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "autocompleteMaxVisible":
|
||||
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
|
||||
|
||||
@@ -114,16 +114,19 @@ export class UiHelpers {
|
||||
const last = children.length > 0 ? children[children.length - 1] : undefined;
|
||||
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
|
||||
const useDim = options?.dim ?? true;
|
||||
const rendered = useDim ? theme.fg("dim", message) : message;
|
||||
// Resolve the dim color lazily so a later theme change re-shapes the line
|
||||
// instead of leaving the palette that was active when it was presented.
|
||||
const styleFn = useDim ? (t: string) => theme.fg("dim", t) : undefined;
|
||||
|
||||
if (last && secondLast && last === this.ctx.lastStatusText && secondLast === this.ctx.lastStatusSpacer) {
|
||||
this.ctx.lastStatusText.setText(rendered);
|
||||
this.ctx.lastStatusText.setStyleFn(styleFn);
|
||||
this.ctx.lastStatusText.setText(message);
|
||||
this.ctx.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const spacer = new Spacer(1);
|
||||
const text = new Text(rendered, 1, 0);
|
||||
const text = new Text(message, 1, 0).setStyleFn(styleFn);
|
||||
this.ctx.present([spacer, text]);
|
||||
this.ctx.lastStatusSpacer = spacer;
|
||||
this.ctx.lastStatusText = text;
|
||||
@@ -714,11 +717,13 @@ export class UiHelpers {
|
||||
}
|
||||
|
||||
showError(errorMessage: string): void {
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)]);
|
||||
const text = new Text(`Error: ${errorMessage}`, 1, 0).setStyleFn(t => theme.fg("error", t));
|
||||
this.ctx.present([new Spacer(1), text]);
|
||||
}
|
||||
|
||||
showWarning(warningMessage: string): void {
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)]);
|
||||
const text = new Text(`Warning: ${warningMessage}`, 1, 0).setStyleFn(t => theme.fg("warning", t));
|
||||
this.ctx.present([new Spacer(1), text]);
|
||||
}
|
||||
|
||||
showNewVersionNotification(newVersion: string): void {
|
||||
|
||||
@@ -28,56 +28,64 @@ For independent per-item chains (review → verify, fetch → extract → score)
|
||||
|
||||
**Python (`eval`, Python backend):**
|
||||
|
||||
DIMENSIONS = [{"key": "bugs", "prompt": "…"}, {"key": "perf", "prompt": "…"}]
|
||||
def review_and_verify(d):
|
||||
found = agent(d["prompt"], label=f"review:{d['key']}", schema=FINDINGS_SCHEMA)
|
||||
return parallel([lambda f=f: {**f, "verdict": agent(
|
||||
f"Refute if you can (default refuted when unsure): {f['title']}",
|
||||
label=f"verify:{f['file']}", schema=VERDICT_SCHEMA)} for f in found["findings"]])
|
||||
phase("Review")
|
||||
results = parallel([lambda d=d: review_and_verify(d) for d in DIMENSIONS])
|
||||
confirmed = [f for group in results for f in group if f["verdict"]["is_real"]]
|
||||
```python
|
||||
DIMENSIONS = [{"key": "bugs", "prompt": "…"}, {"key": "perf", "prompt": "…"}]
|
||||
def review_and_verify(d):
|
||||
found = agent(d["prompt"], label=f"review:{d['key']}", schema=FINDINGS_SCHEMA)
|
||||
return parallel([lambda f=f: {**f, "verdict": agent(
|
||||
f"Refute if you can (default refuted when unsure): {f['title']}",
|
||||
label=f"verify:{f['file']}", schema=VERDICT_SCHEMA)} for f in found["findings"]])
|
||||
phase("Review")
|
||||
results = parallel([lambda d=d: review_and_verify(d) for d in DIMENSIONS])
|
||||
confirmed = [f for group in results for f in group if f["verdict"]["is_real"]]
|
||||
```
|
||||
|
||||
**JavaScript (`eval`, JavaScript backend):**
|
||||
|
||||
const DIMENSIONS = [{ key: "bugs", prompt: "…" }, { key: "perf", prompt: "…" }];
|
||||
async function reviewAndVerify(d) {
|
||||
const found = await agent(d.prompt, {
|
||||
label: `review:${d.key}`,
|
||||
schema: FINDINGS_SCHEMA,
|
||||
});
|
||||
return await parallel(found.findings.map((f) => async () => ({
|
||||
…f,
|
||||
verdict: await agent(
|
||||
`Refute if you can (default refuted when unsure): ${f.title}`,
|
||||
{ label: `verify:${f.file}`, schema: VERDICT_SCHEMA },
|
||||
),
|
||||
})));
|
||||
}
|
||||
phase("Review");
|
||||
const results = await parallel(DIMENSIONS.map((d) => async () => reviewAndVerify(d)));
|
||||
const confirmed = results.flat().filter((f) => f.verdict.is_real);
|
||||
```js
|
||||
const DIMENSIONS = [{ key: "bugs", prompt: "…" }, { key: "perf", prompt: "…" }];
|
||||
async function reviewAndVerify(d) {
|
||||
const found = await agent(d.prompt, {
|
||||
label: `review:${d.key}`,
|
||||
schema: FINDINGS_SCHEMA,
|
||||
});
|
||||
return await parallel(found.findings.map((f) => async () => ({
|
||||
...f,
|
||||
verdict: await agent(
|
||||
`Refute if you can (default refuted when unsure): ${f.title}`,
|
||||
{ label: `verify:${f.file}`, schema: VERDICT_SCHEMA },
|
||||
),
|
||||
})));
|
||||
}
|
||||
phase("Review");
|
||||
const results = await parallel(DIMENSIONS.map((d) => async () => reviewAndVerify(d)));
|
||||
const confirmed = results.flat().filter((f) => f.verdict.is_real);
|
||||
```
|
||||
Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer:
|
||||
|
||||
**Python (`eval`, Python backend):**
|
||||
|
||||
phase("Find")
|
||||
found = parallel([lambda d=d: agent(d["prompt"], schema=FINDINGS_SCHEMA) for d in DIMENSIONS])
|
||||
findings = dedupe([f for r in found for f in r["findings"]]) # needs everything at once
|
||||
phase("Verify")
|
||||
verdicts = parallel([lambda f=f: agent(verify_prompt(f), schema=VERDICT_SCHEMA) for f in findings])
|
||||
```python
|
||||
phase("Find")
|
||||
found = parallel([lambda d=d: agent(d["prompt"], schema=FINDINGS_SCHEMA) for d in DIMENSIONS])
|
||||
findings = dedupe([f for r in found for f in r["findings"]]) # needs everything at once
|
||||
phase("Verify")
|
||||
verdicts = parallel([lambda f=f: agent(verify_prompt(f), schema=VERDICT_SCHEMA) for f in findings])
|
||||
```
|
||||
|
||||
**JavaScript (`eval`, JavaScript backend):**
|
||||
|
||||
phase("Find");
|
||||
const found = await parallel(DIMENSIONS.map((d) => async () =>
|
||||
await agent(d.prompt, { schema: FINDINGS_SCHEMA }),
|
||||
));
|
||||
const findings = dedupe(found.flatMap((r) => r.findings)); // needs everything at once
|
||||
phase("Verify");
|
||||
const verdicts = await parallel(findings.map((f) => async () =>
|
||||
await agent(verifyPrompt(f), { schema: VERDICT_SCHEMA }),
|
||||
));
|
||||
```js
|
||||
phase("Find");
|
||||
const found = await parallel(DIMENSIONS.map((d) => async () =>
|
||||
await agent(d.prompt, { schema: FINDINGS_SCHEMA }),
|
||||
));
|
||||
const findings = dedupe(found.flatMap((r) => r.findings)); // needs everything at once
|
||||
phase("Verify");
|
||||
const verdicts = await parallel(findings.map((f) => async () =>
|
||||
await agent(verifyPrompt(f), { schema: VERDICT_SCHEMA }),
|
||||
));
|
||||
```
|
||||
Use ordinary code between calls to flatten/map/filter; don't add a barrier just for that. Nested `parallel()` pools each cap independently, so keep total fan-out sane.
|
||||
</structure>
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
} from "./mcp";
|
||||
import { MCP_CONNECTION_STATUS_EVENT_CHANNEL, type McpConnectionStatusEvent } from "./mcp/startup-events";
|
||||
import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memory-backend";
|
||||
import { MEMORY_BACKEND_TOOL_NAMES } from "./memory-backend/tool-names";
|
||||
import type { MnemopiSessionState } from "./mnemopi/state";
|
||||
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
|
||||
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
|
||||
@@ -3029,6 +3030,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
skillsSettings: settings.getGroup("skills"),
|
||||
modelRegistry,
|
||||
toolRegistry,
|
||||
memoryAgentDir: agentDir,
|
||||
memoryTaskDepth: taskDepth,
|
||||
createMemoryTools: restrictToolNames
|
||||
? undefined
|
||||
: async () => {
|
||||
const tools = await Promise.all(
|
||||
MEMORY_BACKEND_TOOL_NAMES.map(name => BUILTIN_TOOLS[name](toolSession)),
|
||||
);
|
||||
return tools.filter((tool): tool is AgentTool => tool !== null);
|
||||
},
|
||||
createVibeTools:
|
||||
(options.taskDepth ?? 0) === 0 && !options.parentTaskPrefix
|
||||
? () => createVibeTools(toolSession)
|
||||
|
||||
@@ -131,6 +131,7 @@ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
||||
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
|
||||
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
||||
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
||||
@@ -256,7 +257,8 @@ import type { Goal, GoalModeState } from "../goals/state";
|
||||
import type { HindsightSessionState } from "../hindsight/state";
|
||||
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
||||
import { IrcBus, type IrcMessage } from "../irc/bus";
|
||||
import { resolveMemoryBackend } from "../memory-backend";
|
||||
import { resolveMemoryBackend } from "../memory-backend/resolve";
|
||||
import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
|
||||
import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
|
||||
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
|
||||
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
|
||||
@@ -936,6 +938,12 @@ export interface AgentSessionConfig {
|
||||
/** Custom commands (TypeScript slash commands) */
|
||||
customCommands?: LoadedCustomCommand[];
|
||||
skillsSettings?: SkillsSettings;
|
||||
/** Agent directory used when applying memory backend changes during a live session. */
|
||||
memoryAgentDir?: string;
|
||||
/** Recursion depth used to suppress live backend replacement in subagents. */
|
||||
memoryTaskDepth?: number;
|
||||
/** Creates the built-in memory tools allowed by the current backend selection. */
|
||||
createMemoryTools?: () => Promise<AgentTool[]>;
|
||||
/** Model registry for API key resolution and model discovery */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Tool registry for LSP and settings */
|
||||
@@ -2258,6 +2266,11 @@ export class AgentSession {
|
||||
#synchronouslyTerminatedYieldToolCallIds = new Set<string>();
|
||||
#providerSessionState = new Map<string, ProviderSessionState>();
|
||||
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
||||
#memoryAgentDir: string | undefined;
|
||||
#memoryTaskDepth = 0;
|
||||
#createMemoryTools: (() => Promise<AgentTool[]>) | undefined;
|
||||
#memoryBackendTransition: Promise<void> = Promise.resolve();
|
||||
#localMemoryStartupAbort: AbortController | undefined;
|
||||
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
||||
|
||||
#resetPromptMaintenanceState(): void {
|
||||
@@ -2790,6 +2803,9 @@ export class AgentSession {
|
||||
this.#skillsReloadable = config.skillsReloadable ?? true;
|
||||
this.#skillsSettings = config.skillsSettings;
|
||||
this.#modelRegistry = config.modelRegistry;
|
||||
this.#memoryAgentDir = config.memoryAgentDir;
|
||||
this.#memoryTaskDepth = config.memoryTaskDepth ?? 0;
|
||||
this.#createMemoryTools = config.createMemoryTools;
|
||||
// Resolve the wire service-tier per request so the Fireworks Priority
|
||||
// toggle scopes priority to Fireworks alone, without mutating the shared
|
||||
// session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
|
||||
@@ -3856,11 +3872,7 @@ export class AgentSession {
|
||||
this.sessionId,
|
||||
advisor.slug,
|
||||
);
|
||||
const preparation = prepareCompaction(
|
||||
pathEntries,
|
||||
compactionSettings,
|
||||
await this.#runnableCompactionCandidates(candidates, advisorProviderSessionId),
|
||||
);
|
||||
const preparation = prepareCompaction(pathEntries, compactionSettings, advisorModel);
|
||||
if (!preparation) {
|
||||
// Cannot prepare compaction, fallback to re-prime
|
||||
return true;
|
||||
@@ -5227,11 +5239,11 @@ export class AgentSession {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const resumeCursorStreamStall = this.#canResumeCursorStreamStall(msg);
|
||||
if (resumeCursorStreamStall || this.#isRetryableError(msg)) {
|
||||
const resumeResolvedStreamStall = this.#canResumeResolvedStreamStall(msg);
|
||||
if (resumeResolvedStreamStall || this.#isRetryableError(msg)) {
|
||||
const didRetry = await this.#handleRetryableError(
|
||||
msg,
|
||||
resumeCursorStreamStall ? { preserveFailedTurn: true } : undefined,
|
||||
resumeResolvedStreamStall ? { preserveFailedTurn: true } : undefined,
|
||||
);
|
||||
if (didRetry) {
|
||||
await emitAgentEndNotification({ willContinue: true });
|
||||
@@ -6976,6 +6988,7 @@ export class AgentSession {
|
||||
*/
|
||||
beginDispose(): void {
|
||||
this.#isDisposed = true;
|
||||
this.cancelLocalMemoryStartup();
|
||||
this.#titleGenerationAbortController.abort();
|
||||
this.#abortAutolearnCapture();
|
||||
this.#flushPendingIrcAsides();
|
||||
@@ -7106,6 +7119,7 @@ export class AgentSession {
|
||||
logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
|
||||
}
|
||||
await this.#drainAutolearnCapture();
|
||||
await this.#memoryBackendTransition;
|
||||
|
||||
const hindsightState = this.getHindsightSessionState();
|
||||
const mnemopiState = setMnemopiSessionState(this, undefined);
|
||||
@@ -7845,13 +7859,126 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the local rollout-memory startup owned by this session. */
|
||||
cancelLocalMemoryStartup(): void {
|
||||
this.#localMemoryStartupAbort?.abort();
|
||||
this.#localMemoryStartupAbort = undefined;
|
||||
}
|
||||
|
||||
/** Start a new local rollout-memory generation and cancel its predecessor. */
|
||||
beginLocalMemoryStartup(): AbortSignal {
|
||||
this.cancelLocalMemoryStartup();
|
||||
const controller = new AbortController();
|
||||
this.#localMemoryStartupAbort = controller;
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
/** Release the local startup slot if `signal` still owns it. */
|
||||
endLocalMemoryStartup(signal: AbortSignal): void {
|
||||
if (this.#localMemoryStartupAbort?.signal === signal) this.#localMemoryStartupAbort = undefined;
|
||||
}
|
||||
|
||||
async #disposeMemoryBackendState(consolidateMnemopi = true): Promise<void> {
|
||||
this.cancelLocalMemoryStartup();
|
||||
const hindsight = this.getHindsightSessionState();
|
||||
if (hindsight) {
|
||||
try {
|
||||
await hindsight.flushRetainQueue();
|
||||
} catch (error) {
|
||||
logger.warn("Memory lifecycle: Hindsight flush failed", { error: String(error) });
|
||||
}
|
||||
this.setHindsightSessionState(undefined);
|
||||
hindsight.dispose();
|
||||
}
|
||||
|
||||
const mnemopi = setMnemopiSessionState(this, undefined);
|
||||
if (mnemopi) {
|
||||
try {
|
||||
await mnemopi.dispose({ consolidate: consolidateMnemopi });
|
||||
} catch (error) {
|
||||
logger.warn("Memory lifecycle: Mnemopi dispose failed", { error: String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the selected memory backend to runtime state, tools, and prompt.
|
||||
* Concurrent settings changes run in order and settle before the next turn.
|
||||
*/
|
||||
async applyMemoryBackend(): Promise<void> {
|
||||
if (this.#isDisposed) return;
|
||||
const transition = this.#memoryBackendTransition.then(() => this.#applyMemoryBackend());
|
||||
this.#memoryBackendTransition = transition.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
await transition;
|
||||
}
|
||||
|
||||
async #applyMemoryBackend(): Promise<void> {
|
||||
if (this.#isDisposed) return;
|
||||
try {
|
||||
await this.#disposeMemoryBackendState();
|
||||
if (this.#memoryAgentDir && this.#memoryTaskDepth === 0 && !this.#isDisposed) {
|
||||
const backend = await resolveMemoryBackend(this.settings);
|
||||
await backend.start({
|
||||
session: this,
|
||||
settings: this.settings,
|
||||
modelRegistry: this.#modelRegistry,
|
||||
agentDir: this.#memoryAgentDir,
|
||||
taskDepth: this.#memoryTaskDepth,
|
||||
});
|
||||
}
|
||||
if (this.#isDisposed) return;
|
||||
await this.#refreshMemoryTools();
|
||||
if (this.#isDisposed) return;
|
||||
await this.refreshBaseSystemPrompt();
|
||||
} catch (error) {
|
||||
await this.#disposeMemoryBackendState(false);
|
||||
if (!this.#isDisposed) {
|
||||
await this.#replaceMemoryTools([]).catch(refreshError => {
|
||||
logger.warn("Failed to remove memory tools after backend apply error", {
|
||||
error: String(refreshError),
|
||||
});
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshMemoryTools(): Promise<void> {
|
||||
const tools = (await this.#createMemoryTools?.()) ?? [];
|
||||
await this.#replaceMemoryTools(tools);
|
||||
}
|
||||
|
||||
async #replaceMemoryTools(tools: AgentTool[]): Promise<void> {
|
||||
const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
|
||||
const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
|
||||
for (const name of removed) {
|
||||
this.#toolRegistry.delete(name);
|
||||
this.#builtInToolNames.delete(name);
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
const wrapped = this.#wrapRuntimeTool(tool);
|
||||
this.#toolRegistry.set(wrapped.name, wrapped);
|
||||
this.#builtInToolNames.add(wrapped.name);
|
||||
nextActive.push(wrapped.name);
|
||||
}
|
||||
await this.#applyActiveToolsByName([...new Set(nextActive)]);
|
||||
}
|
||||
|
||||
/** Rebuild the base system prompt using the current active tool set. */
|
||||
async refreshBaseSystemPrompt(): Promise<void> {
|
||||
if (!this.#rebuildSystemPrompt) return;
|
||||
if (this.#isDisposed || !this.#rebuildSystemPrompt) return;
|
||||
const activeToolNames = this.getActiveToolNames();
|
||||
this.#setActiveToolNames?.(activeToolNames);
|
||||
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
||||
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
||||
if (this.#isDisposed) return;
|
||||
this.#baseSystemPrompt = built.systemPrompt;
|
||||
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
|
||||
if (
|
||||
@@ -9194,6 +9321,14 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// A prompt issued while the session is already disposing must still run:
|
||||
// the dispose-driven abort settles its turn (see "does not auto-retry
|
||||
// empty reasonless aborts once the session is disposing"). Only drop the
|
||||
// prompt when disposal began during the backend-transition await, where
|
||||
// resuming would start a turn on a torn-down session.
|
||||
const disposingBeforeTransition = this.#isDisposed;
|
||||
await this.#memoryBackendTransition;
|
||||
if ((this.#isDisposed && !disposingBeforeTransition) || this.#promptGeneration !== generation) return;
|
||||
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
|
||||
|
||||
// Emit before_agent_start extension event
|
||||
@@ -11227,11 +11362,7 @@ export class AgentSession {
|
||||
compactionCandidates = this.#getCompactionModelCandidates(availableModels);
|
||||
}
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
const preparation = prepareCompaction(
|
||||
pathEntries,
|
||||
effectiveSettings,
|
||||
await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId),
|
||||
);
|
||||
const preparation = prepareCompaction(pathEntries, effectiveSettings, this.model);
|
||||
if (!preparation) {
|
||||
// Check why we can't compact
|
||||
const lastEntry = pathEntries[pathEntries.length - 1];
|
||||
@@ -11284,6 +11415,11 @@ export class AgentSession {
|
||||
let snapcompactReady = wantsSnapcompact;
|
||||
const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
|
||||
let snapcompactShape: snapcompact.Shape | undefined;
|
||||
// Claude refuses inputs that reproduce its own reasoning as text
|
||||
// ("reasoning_extraction"), and the snapcompact archive is replayed as
|
||||
// text into every later request; drop `¶think:` sections for
|
||||
// Anthropic-dialect targets (issue #6093).
|
||||
const snapcompactIncludeThinking = preferredDialect(this.model.id) !== "anthropic";
|
||||
if (wantsSnapcompact && !this.model.input.includes("image")) {
|
||||
if (explicitSnapcompact) {
|
||||
this.emitNotice(
|
||||
@@ -11302,6 +11438,7 @@ export class AgentSession {
|
||||
} else if (snapcompactReady) {
|
||||
const text = snapcompact.serializeConversation(
|
||||
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
||||
{ includeThinking: snapcompactIncludeThinking },
|
||||
);
|
||||
const probeText = snapcompact.renderabilityProbeText(
|
||||
text,
|
||||
@@ -11357,6 +11494,7 @@ export class AgentSession {
|
||||
model: this.model,
|
||||
...(snapcompactShapeSetting === "auto" ? {} : { shape }),
|
||||
maxFrames,
|
||||
includeThinking: snapcompactIncludeThinking,
|
||||
});
|
||||
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
||||
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
||||
@@ -13583,17 +13721,6 @@ export class AgentSession {
|
||||
return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compaction candidates that can actually run — those with a resolvable API
|
||||
* key, matching the per-candidate getApiKey gate the execution loop applies.
|
||||
* Re-expansion reusability (prepareCompaction) must judge remote-preserve
|
||||
* reuse against these, not against candidates the loop would skip at runtime.
|
||||
*/
|
||||
async #runnableCompactionCandidates(candidates: readonly Model[], sessionId: string | undefined): Promise<Model[]> {
|
||||
const keys = await Promise.all(candidates.map(model => this.#modelRegistry.getApiKey(model, sessionId)));
|
||||
return candidates.filter((_, index) => keys[index] !== undefined);
|
||||
}
|
||||
|
||||
#resolveCompactionModelCandidates(
|
||||
preferredModel: Model | null | undefined,
|
||||
availableModels: Model[],
|
||||
@@ -14406,12 +14533,8 @@ export class AgentSession {
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
|
||||
const autoCompactionCandidates = await this.#runnableCompactionCandidates(
|
||||
this.#getCompactionModelCandidates(availableModels),
|
||||
this.sessionId,
|
||||
);
|
||||
let pathEntriesForCompaction = pathEntries;
|
||||
let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates);
|
||||
let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.model);
|
||||
if (!preparation) {
|
||||
// prepareCompaction found nothing to summarize because the kept region
|
||||
// is a single oversized recent turn — findCutPoint never cuts inside a
|
||||
@@ -14461,11 +14584,7 @@ export class AgentSession {
|
||||
// branch has been rewritten either way.
|
||||
rescueRewroteHistory = true;
|
||||
pathEntriesForCompaction = this.sessionManager.getBranch();
|
||||
preparation = prepareCompaction(
|
||||
pathEntriesForCompaction,
|
||||
compactionSettings,
|
||||
autoCompactionCandidates,
|
||||
);
|
||||
preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.model);
|
||||
return preparation !== undefined;
|
||||
},
|
||||
});
|
||||
@@ -14587,8 +14706,13 @@ export class AgentSession {
|
||||
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
||||
let snapcompactBlocker: string | undefined;
|
||||
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
||||
// Drop `¶think:` sections for Anthropic-dialect targets: the archive
|
||||
// is replayed as text and Claude refuses reproduced reasoning
|
||||
// ("reasoning_extraction", issue #6093).
|
||||
const snapcompactIncludeThinking = preferredDialect(this.model.id) !== "anthropic";
|
||||
const text = snapcompact.serializeConversation(
|
||||
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
||||
{ includeThinking: snapcompactIncludeThinking },
|
||||
);
|
||||
const probeText = snapcompact.renderabilityProbeText(
|
||||
text,
|
||||
@@ -14619,6 +14743,7 @@ export class AgentSession {
|
||||
model: this.model,
|
||||
...(shapeSetting === "auto" ? {} : { shape }),
|
||||
maxFrames,
|
||||
includeThinking: snapcompactIncludeThinking,
|
||||
});
|
||||
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
||||
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
||||
@@ -15266,17 +15391,13 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a stalled Cursor turn after every server-executed tool has produced
|
||||
* a result. The failed assistant/tool-result pair must stay in context: it
|
||||
* records completed side effects and lets the next request continue from
|
||||
* them instead of replaying the original turn.
|
||||
* Resume a stalled turn after every emitted tool call has produced a result.
|
||||
* Cursor calls must also carry the server-execution marker. The failed
|
||||
* assistant/tool-result pair stays in context so completed side effects are
|
||||
* continued from rather than replayed.
|
||||
*/
|
||||
#canResumeCursorStreamStall(message: AssistantMessage): boolean {
|
||||
if (
|
||||
message.provider !== "cursor" ||
|
||||
message.stopReason !== "error" ||
|
||||
!message.errorMessage?.toLowerCase().includes("stream stall")
|
||||
) {
|
||||
#canResumeResolvedStreamStall(message: AssistantMessage): boolean {
|
||||
if (message.stopReason !== "error" || !message.errorMessage?.toLowerCase().includes("stream stall")) {
|
||||
return false;
|
||||
}
|
||||
const id = this.#classifyRetryMessage(message);
|
||||
@@ -15285,7 +15406,12 @@ export class AgentSession {
|
||||
const resolvedToolCallIds: string[] = [];
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
if (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true) return false;
|
||||
if (
|
||||
message.provider === "cursor" &&
|
||||
(!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
resolvedToolCallIds.push(block.id);
|
||||
}
|
||||
if (resolvedToolCallIds.length === 0) return false;
|
||||
@@ -16131,8 +16257,8 @@ export class AgentSession {
|
||||
errorId: message.errorId,
|
||||
});
|
||||
|
||||
// Cursor exec-channel tools have already run and emitted results. Keep that
|
||||
// failed turn intact so continuation cannot repeat their side effects.
|
||||
// Resolved stream-stall tools have already emitted results. Keep that failed
|
||||
// turn intact so continuation cannot repeat their side effects.
|
||||
if (!options?.preserveFailedTurn) {
|
||||
this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class ArtifactManager {
|
||||
#nextId = 0;
|
||||
readonly #dir: string;
|
||||
#dirCreated = false;
|
||||
#initialized = false;
|
||||
#initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* @param dir Directory that will hold artifact files. Created lazily on first save.
|
||||
@@ -61,10 +61,11 @@ export class ArtifactManager {
|
||||
await fs.mkdir(this.#dir, { recursive: true });
|
||||
this.#dirCreated = true;
|
||||
}
|
||||
if (!this.#initialized) {
|
||||
await this.#scanExistingIds();
|
||||
this.#initialized = true;
|
||||
}
|
||||
// Memoize the first-use scan so it runs exactly once. Concurrent callers
|
||||
// share the in-flight promise instead of each re-seeding #nextId across
|
||||
// the readdir yield in #scanExistingIds (which would hand duplicate ids).
|
||||
this.#initPromise ??= this.#scanExistingIds();
|
||||
await this.#initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterAll, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { BlobStore, parseBlobRef, resolveImageData, resolveImageDataSync, resolveImageDataUrl } from "./blob-store";
|
||||
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "blob-store-test-"));
|
||||
const blobDir = path.join(base, "agent", "blobs", "data");
|
||||
fs.mkdirSync(blobDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(base, "secret.txt"), "TOP-SECRET-CONTENTS");
|
||||
const store = new BlobStore(blobDir);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseBlobRef validation", () => {
|
||||
it("accepts a canonical 64-char lowercase hex suffix", () => {
|
||||
const hash = "a".repeat(64);
|
||||
expect(parseBlobRef(`blob:sha256:${hash}`)).toBe(hash);
|
||||
});
|
||||
|
||||
it("returns null for non-blob strings", () => {
|
||||
expect(parseBlobRef("data:image/png;base64,AAAA")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../../../secret.txt",
|
||||
`${"../".repeat(6)}etc/passwd`,
|
||||
"A".repeat(64), // uppercase hex is not the canonical shape
|
||||
"a".repeat(63), // too short
|
||||
"a".repeat(65), // too long
|
||||
"", // empty
|
||||
])("rejects malformed suffix %p", suffix => {
|
||||
expect(parseBlobRef(`blob:sha256:${suffix}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blob resolution path confinement", () => {
|
||||
const traversalRef = "blob:sha256:../../../secret.txt";
|
||||
|
||||
it("leaves a traversal ref unresolved instead of reading outside the blob dir (base64 path)", async () => {
|
||||
expect(await resolveImageData(store, traversalRef)).toBe(traversalRef);
|
||||
expect(resolveImageDataSync(store, traversalRef)).toBe(traversalRef);
|
||||
});
|
||||
|
||||
it("leaves a traversal ref unresolved instead of reading outside the blob dir (data-url path)", async () => {
|
||||
expect(await resolveImageDataUrl(store, traversalRef)).toBe(traversalRef);
|
||||
});
|
||||
|
||||
it("still resolves a valid stored blob", async () => {
|
||||
const put = store.putSync(Buffer.from("hello"));
|
||||
expect(Buffer.from(resolveImageDataSync(store, put.ref), "base64").toString("utf8")).toBe("hello");
|
||||
expect(Buffer.from(await resolveImageData(store, put.ref), "base64").toString("utf8")).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const BLOB_PREFIX = "blob:sha256:";
|
||||
|
||||
/** Canonical blob hash shape: exactly 64 lowercase hex chars (a SHA-256 digest). */
|
||||
export const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
|
||||
|
||||
export interface BlobPutOptions {
|
||||
/** Optional file extension for a sidecar hardlink/copy that OS openers can type-detect. */
|
||||
extension?: string;
|
||||
@@ -179,10 +182,23 @@ export function isBlobRef(data: string): boolean {
|
||||
return data.startsWith(BLOB_PREFIX);
|
||||
}
|
||||
|
||||
/** Extract the SHA-256 hash from a blob reference string. */
|
||||
/**
|
||||
* Extract the SHA-256 hash from a blob reference string.
|
||||
*
|
||||
* Returns null when the string is not a blob ref, or when the suffix is not a
|
||||
* canonical 64-char lowercase hex hash. Rejecting non-hash suffixes here is the
|
||||
* single choke point that keeps every resolution path confined to the blob dir:
|
||||
* `get`/`getSync` feed this value into `path.join(this.dir, hash)`, so an
|
||||
* unvalidated `../` suffix would otherwise escape the store and read arbitrary files.
|
||||
*/
|
||||
export function parseBlobRef(data: string): string | null {
|
||||
if (!data.startsWith(BLOB_PREFIX)) return null;
|
||||
return data.slice(BLOB_PREFIX.length);
|
||||
const hash = data.slice(BLOB_PREFIX.length);
|
||||
if (!BLOB_HASH_RE.test(hash)) {
|
||||
logger.warn("Rejected malformed blob reference", { suffix: hash });
|
||||
return null;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** Identify provider transport image data URLs so persistence can externalize and restore them losslessly. */
|
||||
|
||||
@@ -144,6 +144,8 @@ function unquoteToken(token: string): string {
|
||||
function isInsideShellQuote(command: string, index: number): boolean {
|
||||
type ShellQuote = "'" | '"' | undefined;
|
||||
interface CommandSubstitution {
|
||||
/** `$(` … `)` tracks paren depth; `` ` `` … `` ` `` is a plain toggle. */
|
||||
kind: "dollar" | "backtick";
|
||||
outerQuote: ShellQuote;
|
||||
depth: number;
|
||||
}
|
||||
@@ -152,6 +154,19 @@ function isInsideShellQuote(command: string, index: number): boolean {
|
||||
const substitutions: CommandSubstitution[] = [];
|
||||
for (let i = 0; i < index; i++) {
|
||||
const char = command[i];
|
||||
// Inside a backtick substitution nested in double quotes, bash treats `\"`
|
||||
// as a quote delimiter for the inner command, not as an escaped literal.
|
||||
if (
|
||||
char === "\\" &&
|
||||
command[i + 1] === '"' &&
|
||||
quote !== "'" &&
|
||||
substitutions.at(-1)?.kind === "backtick" &&
|
||||
substitutions.at(-1)?.outerQuote === '"'
|
||||
) {
|
||||
quote = quote === '"' ? undefined : '"';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\" && quote !== "'") {
|
||||
i++;
|
||||
continue;
|
||||
@@ -165,15 +180,26 @@ function isInsideShellQuote(command: string, index: number): boolean {
|
||||
continue;
|
||||
}
|
||||
if (char === "$" && command[i + 1] === "(" && quote !== "'") {
|
||||
substitutions.push({ outerQuote: quote, depth: 1 });
|
||||
substitutions.push({ kind: "dollar", outerQuote: quote, depth: 1 });
|
||||
quote = undefined;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "`" && quote !== "'") {
|
||||
const top = substitutions.at(-1);
|
||||
if (top?.kind === "backtick") {
|
||||
substitutions.pop();
|
||||
quote = top.outerQuote;
|
||||
} else {
|
||||
substitutions.push({ kind: "backtick", outerQuote: quote, depth: 0 });
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote !== undefined) continue;
|
||||
|
||||
const substitution = substitutions.at(-1);
|
||||
if (!substitution) continue;
|
||||
if (substitution?.kind !== "dollar") continue;
|
||||
if (char === "(") {
|
||||
substitution.depth++;
|
||||
} else if (char === ")") {
|
||||
|
||||
@@ -7,9 +7,24 @@ import type { ToolSession } from "../sdk";
|
||||
import { enforceInlineByteCap } from "../session/streaming-output";
|
||||
import { truncateForPrompt } from "./approval";
|
||||
import { resolveCmuxKind } from "./browser/cmux/rpc";
|
||||
import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
|
||||
import {
|
||||
acquireBrowser,
|
||||
type BrowserHandle,
|
||||
type BrowserKind,
|
||||
type BrowserKindTag,
|
||||
holdBrowser,
|
||||
releaseBrowser,
|
||||
} from "./browser/registry";
|
||||
import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
|
||||
import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
|
||||
import {
|
||||
type AcquireTabResult,
|
||||
acquireTab,
|
||||
dropHeadlessTabs,
|
||||
getTab,
|
||||
releaseAllTabs,
|
||||
releaseTab,
|
||||
runInTab,
|
||||
} from "./browser/tab-supervisor";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import { resolveToCwd } from "./path-utils";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
@@ -232,52 +247,84 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
||||
);
|
||||
}
|
||||
|
||||
const browser = await untilAborted(signal, () =>
|
||||
acquireBrowser(kind, {
|
||||
cwd: this.session.cwd,
|
||||
viewport: params.viewport
|
||||
? {
|
||||
width: params.viewport.width,
|
||||
height: params.viewport.height,
|
||||
deviceScaleFactor: params.viewport.scale,
|
||||
}
|
||||
: undefined,
|
||||
appArgs: params.app?.args,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
// The requested timeout must cover the *entire* open — browser
|
||||
// acquisition (CDP discovery/connect), queued tab acquisition, worker
|
||||
// creation, and navigation — not only `acquireTab`. Compose one deadline
|
||||
// from the caller signal and `params.timeout` and thread it through both
|
||||
// stages so a stalled acquisition rejects at the requested boundary.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const openSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
try {
|
||||
const browser = await untilAborted(openSignal, () =>
|
||||
acquireBrowser(kind, {
|
||||
cwd: this.session.cwd,
|
||||
viewport: params.viewport
|
||||
? {
|
||||
width: params.viewport.width,
|
||||
height: params.viewport.height,
|
||||
deviceScaleFactor: params.viewport.scale,
|
||||
}
|
||||
: undefined,
|
||||
appArgs: params.app?.args,
|
||||
signal: openSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await untilAborted(signal, () =>
|
||||
acquireTab(name, browser, {
|
||||
url: params.url,
|
||||
waitUntil: params.wait_until,
|
||||
viewport: params.viewport
|
||||
? {
|
||||
width: params.viewport.width,
|
||||
height: params.viewport.height,
|
||||
deviceScaleFactor: params.viewport.scale,
|
||||
}
|
||||
: undefined,
|
||||
target: params.app?.target,
|
||||
timeoutMs,
|
||||
dialogs: params.dialogs,
|
||||
signal,
|
||||
ownerSessionId: this.session.getSessionId?.() ?? undefined,
|
||||
}),
|
||||
);
|
||||
const tab = result.tab;
|
||||
const url = tab.info.url;
|
||||
const title = tab.info.title ?? "";
|
||||
details.url = url;
|
||||
details.viewport = tab.info.viewport;
|
||||
const verb = result.created ? "Opened" : "Reused";
|
||||
const lines = [
|
||||
`${verb} tab ${JSON.stringify(name)} on ${describeBrowser(browser)}`,
|
||||
`URL: ${url}`,
|
||||
title ? `Title: ${title}` : null,
|
||||
].filter((l): l is string => typeof l === "string");
|
||||
details.result = lines.join("\n");
|
||||
return toolResult(details).text(lines.join("\n")).done();
|
||||
// Hold one open-acquisition lease across the whole tab acquisition.
|
||||
// A freshly-created browser sits in the registry at refCount 0 until a
|
||||
// tab takes a hold; without this lease an abort/timeout mid-acquisition
|
||||
// (or a sibling open of a different tab name on the same browser that
|
||||
// fails) could dispose it out from under this operation. The lease is
|
||||
// released exactly once — the success and failure paths are mutually
|
||||
// exclusive — transferring ownership to the published tab on success or
|
||||
// rolling the fresh browser back on failure.
|
||||
holdBrowser(browser);
|
||||
let result: AcquireTabResult;
|
||||
try {
|
||||
result = await untilAborted(openSignal, () =>
|
||||
acquireTab(name, browser, {
|
||||
url: params.url,
|
||||
waitUntil: params.wait_until,
|
||||
viewport: params.viewport
|
||||
? {
|
||||
width: params.viewport.width,
|
||||
height: params.viewport.height,
|
||||
deviceScaleFactor: params.viewport.scale,
|
||||
}
|
||||
: undefined,
|
||||
target: params.app?.target,
|
||||
timeoutMs,
|
||||
dialogs: params.dialogs,
|
||||
signal: openSignal,
|
||||
ownerSessionId: this.session.getSessionId?.() ?? undefined,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await releaseBrowser(browser, { kill: false });
|
||||
throw error;
|
||||
}
|
||||
await releaseBrowser(browser, { kill: false });
|
||||
|
||||
const tab = result.tab;
|
||||
const url = tab.info.url;
|
||||
const title = tab.info.title ?? "";
|
||||
details.url = url;
|
||||
details.viewport = tab.info.viewport;
|
||||
const verb = result.created ? "Opened" : "Reused";
|
||||
const lines = [
|
||||
`${verb} tab ${JSON.stringify(name)} on ${describeBrowser(browser)}`,
|
||||
`URL: ${url}`,
|
||||
title ? `Title: ${title}` : null,
|
||||
].filter((l): l is string => typeof l === "string");
|
||||
details.result = lines.join("\n");
|
||||
return toolResult(details).text(lines.join("\n")).done();
|
||||
} catch (error) {
|
||||
// Caller cancellation stays a ToolAbortError; the requested timeout
|
||||
// becomes a timeout ToolError; anything else passes through unchanged.
|
||||
if (signal?.aborted) throw error instanceof ToolAbortError ? error : new ToolAbortError();
|
||||
if (timeoutSignal.aborted) throw new ToolError(`Browser open timed out after ${timeoutMs}ms`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #close(
|
||||
|
||||
@@ -286,13 +286,16 @@ async function acquireTabImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller aborted while we were spawning/initializing the worker,
|
||||
// tear the freshly-built worker down before publishing the tab so the
|
||||
// browser refCount (which `holdBrowser` below would take) never grows for
|
||||
// a tab nobody is waiting for.
|
||||
// If the caller aborted while we were spawning/initializing the worker, tear
|
||||
// the freshly-built worker down before publishing the tab so the browser
|
||||
// refCount (which `holdBrowser` below would take) never grows for a tab
|
||||
// nobody is waiting for. Mirror the error paths' `refCount === 0` release so
|
||||
// a fresh browser held by nothing but this aborted open is not orphaned in
|
||||
// the registry; a browser still leased/held elsewhere (refCount > 0) is left
|
||||
// for its owner to release.
|
||||
if (opts.signal?.aborted) {
|
||||
await worker.terminate().catch(() => undefined);
|
||||
if (tempHold) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
|
||||
if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
|
||||
throw new ToolAbortError("Browser tab open aborted");
|
||||
}
|
||||
|
||||
|
||||
@@ -518,6 +518,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (!requestedTools.includes(name)) requestedTools.push(name);
|
||||
}
|
||||
}
|
||||
if (session.settings.get("memory.backend") === "mnemopi" && !requestedTools.includes("memory_edit")) {
|
||||
requestedTools.push("memory_edit");
|
||||
}
|
||||
// Auto-learn tools are gated by `autolearn.enabled` but, like the memory
|
||||
// tools above, must also be force-included into an explicit requestedTools
|
||||
// list so a restricted top-level session whose controller/guidance is
|
||||
@@ -561,6 +564,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (name === "retain" || name === "recall" || name === "reflect") {
|
||||
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
|
||||
}
|
||||
if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
|
||||
if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
|
||||
if (name === "learn") {
|
||||
return (
|
||||
|
||||
@@ -2,10 +2,11 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import type { Skill } from "../extensibility/skills";
|
||||
import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { ToolAbortError, ToolError } from "./tool-errors";
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
// A single line-range chunk: `N`, `N-M`, `N+K`, or open-ended `N-`. `..` is
|
||||
@@ -1132,6 +1133,52 @@ export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const WORKSPACE_SUFFIX_TIMEOUT_MS = 5000;
|
||||
|
||||
function escapeGlobMetachars(value: string): string {
|
||||
return value.replace(/[*?[{]/g, "[$&]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a unique workspace entry whose trailing path matches a missing authored path.
|
||||
* Returns `null` for no match, ambiguity, timeout, or scan failure.
|
||||
*/
|
||||
export async function findUniqueWorkspaceSuffix(
|
||||
rawPath: string,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ absolutePath: string; displayPath: string } | null> {
|
||||
const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(WORKSPACE_SUFFIX_TIMEOUT_MS);
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
|
||||
let matches: string[];
|
||||
try {
|
||||
const result = await untilAborted(combinedSignal, () =>
|
||||
glob({
|
||||
pattern: `**/${escapeGlobMetachars(normalized)}`,
|
||||
path: cwd,
|
||||
hidden: true,
|
||||
}),
|
||||
);
|
||||
matches = result.matches.map(match => match.path);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (!signal?.aborted) return null;
|
||||
throw new ToolAbortError();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matches.length !== 1) return null;
|
||||
return {
|
||||
absolutePath: path.resolve(cwd, matches[0]),
|
||||
displayPath: matches[0],
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool-scope resolution (search/ast tools)
|
||||
// =============================================================================
|
||||
|
||||
@@ -11,12 +11,14 @@ import type {
|
||||
ToolTier,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
|
||||
import { type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
getRemoteDir,
|
||||
type ImageMetadata,
|
||||
isEexist,
|
||||
isEnotempty,
|
||||
isProbablyBinary,
|
||||
logger,
|
||||
prompt,
|
||||
@@ -94,6 +96,7 @@ import {
|
||||
} from "./output-meta";
|
||||
import {
|
||||
expandPath,
|
||||
findUniqueWorkspaceSuffix,
|
||||
formatPathRelativeToCwd,
|
||||
isReadableUrlPath,
|
||||
type LineRange,
|
||||
@@ -640,7 +643,6 @@ async function streamLinesFromFile(
|
||||
|
||||
// Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
|
||||
const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
|
||||
const GLOB_TIMEOUT_MS = 5000;
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
@@ -648,61 +650,6 @@ function isNotFoundError(error: unknown): boolean {
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape glob metacharacters so a literal path (e.g. `foo[1].ts`) interpolated
|
||||
* into a suffix-glob pattern matches itself. Each metachar is wrapped in a
|
||||
* character class (the native glob engine rewrites `\` to `/`, so backslash
|
||||
* escaping is unavailable). `]`/`}` need no escaping once their openers are
|
||||
* neutralized — unmatched closers are literal.
|
||||
*/
|
||||
function escapeGlobMetachars(value: string): string {
|
||||
return value.replace(/[*?[{]/g, "[$&]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a non-existent path by finding a unique suffix match within the workspace.
|
||||
* Uses a glob suffix pattern so the native engine handles matching directly.
|
||||
* Returns null when 0 or >1 candidates match (ambiguous = no auto-resolution).
|
||||
*/
|
||||
async function findUniqueSuffixMatch(
|
||||
rawPath: string,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ absolutePath: string; displayPath: string } | null> {
|
||||
const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
||||
if (!normalized) return null;
|
||||
const pattern = `**/${escapeGlobMetachars(normalized)}`;
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
|
||||
let matches: string[];
|
||||
try {
|
||||
const result = await untilAborted(combinedSignal, () =>
|
||||
glob({
|
||||
pattern,
|
||||
path: cwd,
|
||||
// No fileType filter: matches both files and directories
|
||||
hidden: true,
|
||||
}),
|
||||
);
|
||||
matches = result.matches.map(m => m.path);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (!signal?.aborted) return null; // timeout — give up silently
|
||||
throw new ToolAbortError();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matches.length !== 1) return null;
|
||||
|
||||
return {
|
||||
absolutePath: path.resolve(cwd, matches[0]),
|
||||
displayPath: matches[0],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeUtf8Text(bytes: Uint8Array): string | null {
|
||||
if (bytes.indexOf(0) !== -1) return null;
|
||||
|
||||
@@ -722,6 +669,22 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
|
||||
const PDF_IMAGE_PLACEHOLDER_RE = /<!--\s*image:\s*([^\s<>]+)(.*?)-->/g;
|
||||
const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
|
||||
const PDF_IMAGE_MEMBER_EXTENSION_RE = /\.png$/i;
|
||||
const PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH = 96;
|
||||
|
||||
interface PdfImageSnapshot {
|
||||
directory: string;
|
||||
filePath: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
interface PdfImageExtraction {
|
||||
controller: AbortController;
|
||||
promise: Promise<string>;
|
||||
settled: boolean;
|
||||
waiters: number;
|
||||
}
|
||||
|
||||
const pdfImageExtractions = new Map<string, PdfImageExtraction>();
|
||||
|
||||
function pdfImageMemberPath(pdfPath: string, imageId: string): string {
|
||||
const member = PDF_IMAGE_MEMBER_EXTENSION_RE.test(imageId) ? imageId : `${imageId}.png`;
|
||||
@@ -991,7 +954,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized {@link findUniqueSuffixMatch} for a single read call. A missing
|
||||
* Memoized {@link findUniqueWorkspaceSuffix} for a single read call. A missing
|
||||
* path with archive/sqlite extensions probes the workspace once per stage
|
||||
* (archive candidates, sqlite candidates, plain path) — each glob carries a
|
||||
* 5s timeout, so repeated lookups of the same string stack into a long
|
||||
@@ -1004,7 +967,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
): Promise<{ absolutePath: string; displayPath: string } | null> {
|
||||
const hit = cache.get(rawPath);
|
||||
if (hit !== undefined) return hit;
|
||||
const result = await findUniqueSuffixMatch(rawPath, this.session.cwd, signal);
|
||||
const result = await findUniqueWorkspaceSuffix(rawPath, this.session.cwd, signal);
|
||||
cache.set(rawPath, result);
|
||||
return result;
|
||||
}
|
||||
@@ -1106,7 +1069,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
return null;
|
||||
}
|
||||
|
||||
#pdfImageCacheDir(absolutePdfPath: string): string {
|
||||
#pdfImageCacheDir(absolutePdfPath: string, contentDigest: string): string {
|
||||
const artifactsDir = this.session.getArtifactsDir?.();
|
||||
let root = artifactsDir ?? undefined;
|
||||
if (root === undefined) {
|
||||
@@ -1115,8 +1078,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
? sessionFile.slice(0, -6)
|
||||
: path.join(os.tmpdir(), "omp-read-pdf-images");
|
||||
}
|
||||
const basename = path.basename(absolutePdfPath).replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
return path.join(root, "read-pdf-images", `${basename}-${Bun.hash(absolutePdfPath).toString(36)}`);
|
||||
const basename = path
|
||||
.basename(absolutePdfPath)
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.slice(0, PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH);
|
||||
const pathDigest = Bun.hash(absolutePdfPath).toString(36);
|
||||
return path.join(root, "read-pdf-images", `${basename}-${pathDigest}-${contentDigest}`);
|
||||
}
|
||||
|
||||
async #snapshotPdfSource(absolutePdfPath: string, signal?: AbortSignal): Promise<PdfImageSnapshot> {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-pdf-"));
|
||||
try {
|
||||
const bytes = await untilAborted(signal, () => Bun.file(absolutePdfPath).bytes());
|
||||
signal?.throwIfAborted();
|
||||
const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
||||
const filePath = path.join(directory, "source.pdf");
|
||||
await Bun.write(filePath, bytes);
|
||||
signal?.throwIfAborted();
|
||||
return { directory, filePath, digest };
|
||||
} catch (error) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #listPdfImageMembers(imageDir: string): Promise<string[]> {
|
||||
@@ -1133,8 +1116,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
|
||||
const imageDir = this.#pdfImageCacheDir(absolutePdfPath);
|
||||
async #extractPdfImages(snapshot: PdfImageSnapshot, imageDir: string, signal: AbortSignal): Promise<string> {
|
||||
const markerPath = path.join(imageDir, ".extracted");
|
||||
try {
|
||||
await fs.stat(markerPath);
|
||||
@@ -1143,15 +1125,74 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (!isNotFoundError(error)) throw error;
|
||||
}
|
||||
|
||||
await fs.rm(imageDir, { recursive: true, force: true });
|
||||
await fs.mkdir(imageDir, { recursive: true });
|
||||
const result = await convertFileWithMarkit(absolutePdfPath, signal, { imageDir });
|
||||
if (!result.ok) {
|
||||
await fs.rm(imageDir, { recursive: true, force: true });
|
||||
throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
|
||||
await fs.mkdir(path.dirname(imageDir), { recursive: true });
|
||||
const stagingDir = await fs.mkdtemp(`${imageDir}.tmp-`);
|
||||
let published = false;
|
||||
try {
|
||||
const result = await convertFileWithMarkit(snapshot.filePath, signal, { imageDir: stagingDir });
|
||||
if (!result.ok) {
|
||||
throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
|
||||
}
|
||||
await Bun.write(path.join(stagingDir, ".extracted"), "ok");
|
||||
try {
|
||||
await fs.rename(stagingDir, imageDir);
|
||||
published = true;
|
||||
} catch (error) {
|
||||
if (!isEexist(error) && !isEnotempty(error)) throw error;
|
||||
try {
|
||||
await fs.stat(markerPath);
|
||||
} catch (markerError) {
|
||||
if (isNotFoundError(markerError)) throw error;
|
||||
throw markerError;
|
||||
}
|
||||
}
|
||||
return imageDir;
|
||||
} finally {
|
||||
if (!published) await fs.rm(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
await Bun.write(markerPath, "ok");
|
||||
return imageDir;
|
||||
}
|
||||
|
||||
#createPdfImageExtraction(snapshot: PdfImageSnapshot, imageDir: string): PdfImageExtraction {
|
||||
const controller = new AbortController();
|
||||
const promise = this.#extractPdfImages(snapshot, imageDir, controller.signal).finally(() =>
|
||||
fs.rm(snapshot.directory, { recursive: true, force: true }),
|
||||
);
|
||||
const extraction: PdfImageExtraction = { controller, promise, settled: false, waiters: 0 };
|
||||
const settle = () => {
|
||||
extraction.settled = true;
|
||||
if (pdfImageExtractions.get(imageDir) === extraction) pdfImageExtractions.delete(imageDir);
|
||||
};
|
||||
void promise.then(settle, settle);
|
||||
return extraction;
|
||||
}
|
||||
|
||||
async #waitForPdfImageExtraction(extraction: PdfImageExtraction, signal: AbortSignal | undefined): Promise<string> {
|
||||
extraction.waiters++;
|
||||
try {
|
||||
return await untilAborted(signal, extraction.promise);
|
||||
} finally {
|
||||
extraction.waiters--;
|
||||
if (extraction.waiters === 0 && !extraction.settled) {
|
||||
extraction.controller.abort();
|
||||
try {
|
||||
await extraction.promise;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
|
||||
const snapshot = await this.#snapshotPdfSource(absolutePdfPath, signal);
|
||||
const imageDir = this.#pdfImageCacheDir(absolutePdfPath, snapshot.digest);
|
||||
const existing = pdfImageExtractions.get(imageDir);
|
||||
if (existing && !existing.settled && !existing.controller.signal.aborted) {
|
||||
await fs.rm(snapshot.directory, { recursive: true, force: true });
|
||||
return this.#waitForPdfImageExtraction(existing, signal);
|
||||
}
|
||||
|
||||
const extraction = this.#createPdfImageExtraction(snapshot, imageDir);
|
||||
pdfImageExtractions.set(imageDir, extraction);
|
||||
return this.#waitForPdfImageExtraction(extraction, signal);
|
||||
}
|
||||
|
||||
async #readPdfImageMember(
|
||||
|
||||
@@ -90,7 +90,13 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
||||
grep: grepToolRenderer as ToolRenderer,
|
||||
lsp: lspToolRenderer as ToolRenderer,
|
||||
inspect_image: inspectImageToolRenderer as ToolRenderer,
|
||||
hub: hubToolRenderer as ToolRenderer,
|
||||
// Lazy getter: `hubToolRenderer` lives in a module whose deps (messaging →
|
||||
// persisted-agents → vibe/runtime → task/executor → sdk) close an import
|
||||
// cycle back here, so reading it at init order-dependently hits its
|
||||
// temporal dead zone. Deferring the read to first access sidesteps it.
|
||||
get hub(): ToolRenderer {
|
||||
return hubToolRenderer as ToolRenderer;
|
||||
},
|
||||
read: readToolRenderer as ToolRenderer,
|
||||
// Keyed by xd:// resolution-device names: the write dispatch delegates here
|
||||
// by dispatch tool, and historical `resolve` tool transcripts still render
|
||||
|
||||
@@ -31,6 +31,15 @@ const DEFAULT_SAMPLE_RATE = 24_000;
|
||||
const LEAD_SECONDS = 0.6;
|
||||
/** Output gain applied while ducked (the user is speaking over the assistant). */
|
||||
export const DUCK_GAIN = 0.25;
|
||||
/**
|
||||
* Cap on streamed PCM retained for the nonzero-exit replay. Past this the
|
||||
* buffer is dropped: the failure being recovered is a short clip that fits the
|
||||
* OS pipe buffer before a broken backend dies, while a backend that consumed
|
||||
* minutes of realtime-paced audio was playing it — replaying a whole long
|
||||
* utterance would duplicate audio, and unbounded retention (~5.8 MB/min at
|
||||
* 24 kHz mono f32) would defeat streaming for long input.
|
||||
*/
|
||||
const REPLAY_RETENTION_SECONDS = 60;
|
||||
|
||||
/** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */
|
||||
export interface StreamingPlayerLookup {
|
||||
@@ -89,6 +98,20 @@ export function streamingPlayerCommandsFor(
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seams for {@link StreamingAudioPlayer}: override backend discovery and
|
||||
* the per-file fallback so playback logic can be exercised without a real audio
|
||||
* device. Both default to the platform lookup and {@link playAudioFile}.
|
||||
*/
|
||||
export interface StreamingPlayerOptions {
|
||||
/** Ordered backend commands for a sample rate; defaults to {@link streamingPlayerCommandsFor}. */
|
||||
commandsFor?: (sampleRate: number) => PlayerCommand[];
|
||||
/** Per-file fallback playback; defaults to {@link playAudioFile}. */
|
||||
playAudio?: (wavPath: string, signal: AbortSignal) => Promise<void>;
|
||||
/** Max seconds of streamed PCM retained for the nonzero-exit replay; defaults to {@link REPLAY_RETENTION_SECONDS}. */
|
||||
replayRetentionSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-session gapless player. Lifecycle: {@link start} once, {@link write}
|
||||
* chunks in order, then {@link end} to drain or {@link stop} to abort. Not
|
||||
@@ -111,6 +134,18 @@ export class StreamingAudioPlayer {
|
||||
#abortController = new AbortController();
|
||||
#wake: (() => void) | null = null;
|
||||
#drain: Promise<void> = Promise.resolve();
|
||||
readonly #commandsFor: (sampleRate: number) => PlayerCommand[];
|
||||
readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise<void>;
|
||||
readonly #replayRetentionSec: number;
|
||||
/** Streamed PCM retained for this utterance so a failed backend can be replayed via file playback. */
|
||||
#played: Float32Array[] = [];
|
||||
#playedSec = 0;
|
||||
|
||||
constructor(options: StreamingPlayerOptions = {}) {
|
||||
this.#commandsFor = options.commandsFor ?? (rate => streamingPlayerCommandsFor(process.platform, rate));
|
||||
this.#playAudio = options.playAudio ?? ((wavPath, signal) => playAudioFile(wavPath, { signal }));
|
||||
this.#replayRetentionSec = options.replayRetentionSeconds ?? REPLAY_RETENTION_SECONDS;
|
||||
}
|
||||
|
||||
/** Pick a backend and begin draining. Idempotent; the first call's rate wins. */
|
||||
start(sampleRate: number): void {
|
||||
@@ -146,6 +181,7 @@ export class StreamingAudioPlayer {
|
||||
if (this.#stopped) return;
|
||||
this.#stopped = true;
|
||||
this.#queue.length = 0;
|
||||
this.#played.length = 0;
|
||||
this.#abortController.abort();
|
||||
this.#signal();
|
||||
try {
|
||||
@@ -168,7 +204,7 @@ export class StreamingAudioPlayer {
|
||||
* in-flight chunk.
|
||||
*/
|
||||
#spawnStream(): boolean {
|
||||
this.#candidates ??= streamingPlayerCommandsFor(process.platform, this.#sampleRate);
|
||||
this.#candidates ??= this.#commandsFor(this.#sampleRate);
|
||||
for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
|
||||
const { cmd, args } = command;
|
||||
try {
|
||||
@@ -213,6 +249,12 @@ export class StreamingAudioPlayer {
|
||||
continue;
|
||||
}
|
||||
if (this.#mode === "stream") {
|
||||
if (this.#playedSec <= this.#replayRetentionSec) {
|
||||
this.#played.push(chunk);
|
||||
this.#playedSec += chunk.length / this.#sampleRate;
|
||||
// Over the cap: drop retention for the rest of the utterance.
|
||||
if (this.#playedSec > this.#replayRetentionSec) this.#played.length = 0;
|
||||
}
|
||||
// Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping
|
||||
// ducking and stop responsive instead of locked behind buffered audio.
|
||||
const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000;
|
||||
@@ -242,11 +284,27 @@ export class StreamingAudioPlayer {
|
||||
try {
|
||||
await this.#sink?.end();
|
||||
} catch {}
|
||||
if (this.#proc) {
|
||||
const proc = this.#proc;
|
||||
let exitCode: number | null = null;
|
||||
if (proc) {
|
||||
try {
|
||||
await this.#proc.exited;
|
||||
exitCode = await proc.exited;
|
||||
} catch {}
|
||||
}
|
||||
// A streaming backend that exits nonzero never opened its audio
|
||||
// device (e.g. the bundled ffmpeg built without pulse/alsa output).
|
||||
// For a short single-segment clip the pipe write succeeds before
|
||||
// that death and #inputClosed is already set, so neither the
|
||||
// broken-pipe replay nor the early-exit handler advances backends.
|
||||
// Replay the buffered utterance through per-file playback so it
|
||||
// still reaches the speakers.
|
||||
if (!this.#stopped && proc && exitCode !== 0) {
|
||||
this.#mode = "file";
|
||||
for (const chunk of this.#played) {
|
||||
if (this.#stopped) break;
|
||||
await this.#playFile(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("tts: streaming player drain failed", {
|
||||
@@ -291,7 +349,7 @@ export class StreamingAudioPlayer {
|
||||
const wavPath = path.join(os.tmpdir(), `omp-speech-${Snowflake.next()}.wav`);
|
||||
try {
|
||||
await fs.writeFile(wavPath, encodeWav(this.#scaled(pcm), this.#sampleRate));
|
||||
if (!this.#stopped) await playAudioFile(wavPath, { signal: this.#abortController.signal });
|
||||
if (!this.#stopped) await this.#playAudio(wavPath, this.#abortController.signal);
|
||||
} catch (error) {
|
||||
logger.debug("tts: file playback failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -38,6 +38,7 @@ interface FakeAcpBuiltinSession {
|
||||
model: { provider: string; id: string } | undefined;
|
||||
newSession(opts?: { drop?: boolean; parentSession?: string }): Promise<boolean>;
|
||||
switchSession(sessionPath: string): Promise<boolean>;
|
||||
moveSession(newCwd: string, targetSessionDir?: string): Promise<void>;
|
||||
markMovedFromEmptySessionFile(sessionFile: string): void;
|
||||
fork(): Promise<boolean>;
|
||||
handoff(instr?: string): Promise<{ document: string; savedPath?: string } | undefined>;
|
||||
@@ -121,6 +122,10 @@ function createRuntime() {
|
||||
await fakeSessionManager.setSessionFile(this._switchedTo);
|
||||
return true;
|
||||
},
|
||||
async moveSession(newCwd: string, _targetSessionDir?: string) {
|
||||
if (!fakeSessionManager) throw new Error("fake session manager not initialized");
|
||||
await fakeSessionManager.moveTo(newCwd);
|
||||
},
|
||||
markMovedFromEmptySessionFile(sessionFile: string) {
|
||||
this._movedFromEmptySessionFile = path.resolve(sessionFile);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { getMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
function createTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} memory tool`,
|
||||
parameters: type({}),
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: name }] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession memory backend lifecycle", () => {
|
||||
let authStorage: AuthStorage;
|
||||
let session: AgentSession | undefined;
|
||||
let settings: Settings;
|
||||
let tempDir: TempDir;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@memory-backend-lifecycle-");
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"memory.backend": "off",
|
||||
"mnemopi.noEmbeddings": true,
|
||||
"mnemopi.llmMode": "none",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.dispose();
|
||||
session = undefined;
|
||||
resetMemoryForTests();
|
||||
authStorage.close();
|
||||
tempDir.removeSync();
|
||||
});
|
||||
|
||||
function createSession(createMemoryTools: () => Promise<AgentTool[]>): AgentSession {
|
||||
const model = buildModel({
|
||||
id: "mock",
|
||||
name: "mock",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
});
|
||||
const read = createTool("read");
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: ["initial"], tools: [read] },
|
||||
streamFn: createMockModel({ responses: [{ content: ["ok"] }] }).stream,
|
||||
});
|
||||
const toolRegistry = new Map<string, AgentTool>([[read.name, read]]);
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(tempDir.path()),
|
||||
settings,
|
||||
modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")),
|
||||
memoryAgentDir: tempDir.path(),
|
||||
memoryTaskDepth: 0,
|
||||
createMemoryTools,
|
||||
toolRegistry,
|
||||
builtInToolNames: [read.name],
|
||||
rebuildSystemPrompt: async toolNames => ({
|
||||
systemPrompt: [`backend:${settings.get("memory.backend")};tools:${toolNames.sort().join(",")}`],
|
||||
}),
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
it("switches runtime state, memory tools, and prompt in one apply", async () => {
|
||||
const current = createSession(async () =>
|
||||
settings.get("memory.backend") === "mnemopi" ? [createTool("retain"), createTool("memory_edit")] : [],
|
||||
);
|
||||
|
||||
settings.override("memory.backend", "mnemopi");
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(getMnemopiSessionState(current)).toBeDefined();
|
||||
expect(current.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "retain", "memory_edit"]));
|
||||
expect(current.systemPrompt).toEqual(["backend:mnemopi;tools:memory_edit,read,retain"]);
|
||||
|
||||
settings.override("memory.backend", "off");
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(getMnemopiSessionState(current)).toBeUndefined();
|
||||
expect(current.getActiveToolNames()).toEqual(["read"]);
|
||||
expect(current.getAllToolNames()).toEqual(["read"]);
|
||||
expect(current.systemPrompt).toEqual(["backend:off;tools:read"]);
|
||||
});
|
||||
it("cancels a displaced local startup generation", async () => {
|
||||
const current = createSession(async () => []);
|
||||
const localStartup = current.beginLocalMemoryStartup();
|
||||
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(localStartup.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes concurrent backend applies", async () => {
|
||||
const firstStarted = Promise.withResolvers<void>();
|
||||
const releaseFirst = Promise.withResolvers<void>();
|
||||
let calls = 0;
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const current = createSession(async () => {
|
||||
calls++;
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
if (calls === 1) {
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
running--;
|
||||
return [];
|
||||
});
|
||||
|
||||
const first = current.applyMemoryBackend();
|
||||
await firstStarted.promise;
|
||||
const second = current.applyMemoryBackend();
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(1);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(maxRunning).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -744,9 +744,11 @@ describe("AgentSession retry delay cap", () => {
|
||||
|
||||
const retryStartEvents: AutoRetryStartEvent[] = [];
|
||||
const retryEndEvents: AutoRetryEndEvent[] = [];
|
||||
const agentEndEvents: Array<Extract<AgentSessionEvent, { type: "agent_end" }>> = [];
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_retry_start") retryStartEvents.push(event);
|
||||
if (event.type === "auto_retry_end") retryEndEvents.push(event);
|
||||
if (event.type === "agent_end") agentEndEvents.push(event);
|
||||
});
|
||||
|
||||
await session.prompt("Write a large report");
|
||||
@@ -756,11 +758,128 @@ describe("AgentSession retry delay cap", () => {
|
||||
expect(retryStartEvents).toHaveLength(0);
|
||||
expect(retryEndEvents).toHaveLength(0);
|
||||
expect(session.agent.state.messages.at(-1)?.role).toBe("toolResult");
|
||||
const lastError = [...session.agent.state.messages]
|
||||
expect(agentEndEvents).toHaveLength(1);
|
||||
expect(agentEndEvents[0].isTerminal).toBe(true);
|
||||
const terminalError = [...agentEndEvents[0].messages]
|
||||
.reverse()
|
||||
.find((message): message is AssistantMessage => message.role === "assistant");
|
||||
expect(lastError?.stopReason).toBe("error");
|
||||
expect(lastError?.errorMessage).toBe("The operation timed out.");
|
||||
expect(terminalError?.stopReason).toBe("error");
|
||||
expect(terminalError?.errorMessage).toBe("The operation timed out.");
|
||||
});
|
||||
|
||||
it("resumes an OpenAI-completions stall after a synthetic unexecuted tool result", async () => {
|
||||
const stallMessage = "OpenAI completions stream stalled while waiting for the next event";
|
||||
const model = createMockModel({
|
||||
id: "grok-4",
|
||||
provider: "openrouter",
|
||||
});
|
||||
authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key");
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: "grok-write-1",
|
||||
name: "write",
|
||||
arguments: { path: "review.md", content: "partial review" },
|
||||
};
|
||||
let streamCalls = 0;
|
||||
let resumedWithSyntheticResult = false;
|
||||
const agent = new Agent({
|
||||
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
streamFn: (_requestedModel, context, options) => {
|
||||
streamCalls += 1;
|
||||
if (streamCalls > 1) {
|
||||
const matchingResult = context.messages.find(
|
||||
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
|
||||
);
|
||||
resumedWithSyntheticResult =
|
||||
matchingResult?.role === "toolResult" &&
|
||||
typeof matchingResult.details === "object" &&
|
||||
matchingResult.details !== null &&
|
||||
"executed" in matchingResult.details &&
|
||||
matchingResult.details.executed === false;
|
||||
model.push({ content: ["Recovered after Grok stall"] });
|
||||
return model.stream(model, context, options);
|
||||
}
|
||||
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [toolCall],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "start", partial });
|
||||
stream.push({ type: "toolcall_start", contentIndex: 0, partial });
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: 0,
|
||||
delta: JSON.stringify(toolCall.arguments),
|
||||
partial,
|
||||
});
|
||||
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial });
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: {
|
||||
...partial,
|
||||
stopReason: "error",
|
||||
errorMessage: stallMessage,
|
||||
},
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"retry.baseDelayMs": 5,
|
||||
"retry.maxRetries": 1,
|
||||
});
|
||||
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings,
|
||||
modelRegistry,
|
||||
});
|
||||
const retryStartEvents: AutoRetryStartEvent[] = [];
|
||||
const retryEndEvents: AutoRetryEndEvent[] = [];
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_retry_start") retryStartEvents.push(event);
|
||||
if (event.type === "auto_retry_end") retryEndEvents.push(event);
|
||||
});
|
||||
|
||||
await session.prompt("Write a review");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(streamCalls).toBe(2);
|
||||
expect(resumedWithSyntheticResult).toBe(true);
|
||||
expect(
|
||||
session.agent.state.messages.filter(
|
||||
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(retryStartEvents).toHaveLength(1);
|
||||
expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 }));
|
||||
expect(lastAssistant(session).content).toContainEqual({ type: "text", text: "Recovered after Grok stall" });
|
||||
});
|
||||
|
||||
it("resumes a stalled Cursor stream after its exec tool result", async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ArtifactManager } from "@oh-my-pi/pi-coding-agent/session/artifacts";
|
||||
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("ArtifactManager concurrent first-use", () => {
|
||||
const dirs: string[] = [];
|
||||
|
||||
function freshDir(): string {
|
||||
const dir = path.join(os.tmpdir(), `omp-artifacts-${crypto.randomUUID()}`, "session");
|
||||
dirs.push(path.dirname(dir));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
removeSyncWithRetries(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// First-use init (dir scan → #nextId seed) must run exactly once. Two callers
|
||||
// racing a fresh manager both yield inside #scanExistingIds before either
|
||||
// marks init done; if the second re-seeds #nextId after the first consumed an
|
||||
// id, both allocate the same numeric id and the second write clobbers the
|
||||
// first. Same toolType => file overwrite; the first id resolves to B's bytes.
|
||||
it("hands concurrent same-toolType savers distinct ids that each resolve to their own content", async () => {
|
||||
const mgr = new ArtifactManager(freshDir());
|
||||
const [idA, idB] = await Promise.all([mgr.save("CONTENT-A", "bash"), mgr.save("CONTENT-B", "bash")]);
|
||||
|
||||
expect(idA).not.toBe(idB);
|
||||
|
||||
const pathA = await mgr.getPath(idA);
|
||||
const pathB = await mgr.getPath(idB);
|
||||
expect(pathA).not.toBeNull();
|
||||
expect(pathB).not.toBeNull();
|
||||
expect(await Bun.file(pathA as string).text()).toBe("CONTENT-A");
|
||||
expect(await Bun.file(pathB as string).text()).toBe("CONTENT-B");
|
||||
});
|
||||
|
||||
// Different toolTypes turn a duplicate id into two coexisting files
|
||||
// (`{id}.bash.log` + `{id}.async.log`); getPath's startsWith(`${id}.`) then
|
||||
// resolves ambiguously in unspecified readdir order. Distinct ids keep each
|
||||
// artifact:// pointing at the content its caller wrote.
|
||||
it("hands concurrent different-toolType savers distinct ids that each resolve to their own content", async () => {
|
||||
const mgr = new ArtifactManager(freshDir());
|
||||
const [idA, idB] = await Promise.all([mgr.save("BASH-BYTES", "bash"), mgr.save("ASYNC-BYTES", "async")]);
|
||||
|
||||
expect(idA).not.toBe(idB);
|
||||
|
||||
const pathA = await mgr.getPath(idA);
|
||||
const pathB = await mgr.getPath(idB);
|
||||
expect(await Bun.file(pathA as string).text()).toBe("BASH-BYTES");
|
||||
expect(await Bun.file(pathB as string).text()).toBe("ASYNC-BYTES");
|
||||
});
|
||||
|
||||
// The race also re-opens on a fresh manager over a directory that already
|
||||
// holds artifacts (e.g. after a `#artifactManager = null` reset): the scan
|
||||
// seeds from maxId, and concurrent callers must still get ids past it.
|
||||
it("does not reuse ids when racing init over a pre-populated directory", async () => {
|
||||
const dir = freshDir();
|
||||
const seed = new ArtifactManager(dir);
|
||||
await seed.save("OLD", "bash");
|
||||
|
||||
const mgr = new ArtifactManager(dir);
|
||||
const [idA, idB] = await Promise.all([mgr.save("NEW-A", "bash"), mgr.save("NEW-B", "bash")]);
|
||||
|
||||
expect(idA).not.toBe(idB);
|
||||
expect(await Bun.file((await mgr.getPath(idA)) as string).text()).toBe("NEW-A");
|
||||
expect(await Bun.file((await mgr.getPath(idB)) as string).text()).toBe("NEW-B");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import {
|
||||
disposeAllKernelSessions,
|
||||
disposeKernelSessionsByOwner,
|
||||
executePython,
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import {
|
||||
type KernelExecuteOptions,
|
||||
type KernelExecuteResult,
|
||||
@@ -29,6 +33,10 @@ class FakeKernel {
|
||||
return this.#alive;
|
||||
}
|
||||
|
||||
markDead(): void {
|
||||
this.#alive = false;
|
||||
}
|
||||
|
||||
async execute(code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
||||
this.executeCalls.push(code);
|
||||
this.#onExecute?.(options);
|
||||
@@ -53,6 +61,10 @@ const okResult: KernelExecuteResult = {
|
||||
stdinRequested: false,
|
||||
};
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("executePython session lifecycle", () => {
|
||||
const originalStart = PythonKernel.start;
|
||||
|
||||
@@ -95,6 +107,215 @@ describe("executePython session lifecycle", () => {
|
||||
expect(liveKernel.executeCalls).toEqual(["print('restart')"]);
|
||||
});
|
||||
|
||||
it("coalesces concurrent replacement of one dead session generation", async () => {
|
||||
const deadKernel = new FakeKernel(okResult);
|
||||
const replacementOne = new FakeKernel(okResult);
|
||||
const replacementTwo = new FakeKernel(okResult);
|
||||
const replacementStarted = Promise.withResolvers<void>();
|
||||
const releaseReplacement = Promise.withResolvers<void>();
|
||||
const replacements = [replacementOne, replacementTwo];
|
||||
let startCount = 0;
|
||||
|
||||
PythonKernel.start = async () => {
|
||||
startCount += 1;
|
||||
if (startCount === 1) return deadKernel as unknown as PythonKernel;
|
||||
replacementStarted.resolve();
|
||||
await releaseReplacement.promise;
|
||||
return replacements.shift() as unknown as PythonKernel;
|
||||
};
|
||||
|
||||
await executePython("print('setup')", { sessionId: "session-concurrent-restart" });
|
||||
deadKernel.markDead();
|
||||
|
||||
const first = executePython("print('first')", { sessionId: "session-concurrent-restart" });
|
||||
await replacementStarted.promise;
|
||||
const second = executePython("print('second')", { sessionId: "session-concurrent-restart" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startCount).toBe(2);
|
||||
|
||||
releaseReplacement.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(replacementOne.executeCalls).toEqual(["print('first')", "print('second')"]);
|
||||
expect(replacementTwo.executeCalls).toEqual([]);
|
||||
|
||||
await disposeAllKernelSessions();
|
||||
expect(replacementOne.shutdownCalls).toBe(1);
|
||||
expect(replacementTwo.shutdownCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a shared replacement alive when one caller cancels", async () => {
|
||||
const deadKernel = new FakeKernel(okResult);
|
||||
const replacement = new FakeKernel(okResult);
|
||||
const replacementStarted = Promise.withResolvers<void>();
|
||||
const releaseReplacement = Promise.withResolvers<void>();
|
||||
let startCount = 0;
|
||||
|
||||
PythonKernel.start = async () => {
|
||||
startCount += 1;
|
||||
if (startCount === 1) return deadKernel as unknown as PythonKernel;
|
||||
replacementStarted.resolve();
|
||||
await releaseReplacement.promise;
|
||||
return replacement as unknown as PythonKernel;
|
||||
};
|
||||
|
||||
await executePython("print('setup')", { sessionId: "session-cancelled-restart" });
|
||||
deadKernel.markDead();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const cancelled = executePython("print('cancelled')", {
|
||||
sessionId: "session-cancelled-restart",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
await replacementStarted.promise;
|
||||
const retained = executePython("print('retained')", { sessionId: "session-cancelled-restart" });
|
||||
await flushMicrotasks();
|
||||
abortController.abort(Object.assign(new Error("replacement wait cancelled"), { name: "AbortError" }));
|
||||
|
||||
expect((await cancelled).cancelled).toBe(true);
|
||||
expect(startCount).toBe(2);
|
||||
|
||||
releaseReplacement.resolve();
|
||||
expect((await retained).cancelled).toBe(false);
|
||||
expect(replacement.executeCalls).toEqual(["print('retained')"]);
|
||||
});
|
||||
|
||||
it("invalidates an in-flight replacement before resetting to a fresh generation", async () => {
|
||||
const deadKernel = new FakeKernel(okResult);
|
||||
const staleReplacement = new FakeKernel(okResult);
|
||||
const freshKernel = new FakeKernel(okResult);
|
||||
const replacementStarted = Promise.withResolvers<void>();
|
||||
const releaseReplacement = Promise.withResolvers<void>();
|
||||
let startCount = 0;
|
||||
|
||||
PythonKernel.start = async () => {
|
||||
startCount += 1;
|
||||
if (startCount === 1) return deadKernel as unknown as PythonKernel;
|
||||
if (startCount === 2) {
|
||||
replacementStarted.resolve();
|
||||
await releaseReplacement.promise;
|
||||
return staleReplacement as unknown as PythonKernel;
|
||||
}
|
||||
return freshKernel as unknown as PythonKernel;
|
||||
};
|
||||
|
||||
await executePython("print('setup')", { sessionId: "session-reset-replacement" });
|
||||
deadKernel.markDead();
|
||||
|
||||
const obsolete = executePython("print('obsolete')", { sessionId: "session-reset-replacement" });
|
||||
await replacementStarted.promise;
|
||||
const reset = executePython("print('reset')", {
|
||||
sessionId: "session-reset-replacement",
|
||||
reset: true,
|
||||
});
|
||||
await flushMicrotasks();
|
||||
releaseReplacement.resolve();
|
||||
|
||||
expect((await obsolete).cancelled).toBe(true);
|
||||
expect((await reset).cancelled).toBe(false);
|
||||
expect(staleReplacement.executeCalls).toEqual([]);
|
||||
expect(staleReplacement.shutdownCalls).toBe(1);
|
||||
expect(freshKernel.executeCalls).toEqual(["print('reset')"]);
|
||||
|
||||
await executePython("print('later')", { sessionId: "session-reset-replacement" });
|
||||
expect(startCount).toBe(3);
|
||||
expect(freshKernel.executeCalls).toEqual(["print('reset')", "print('later')"]);
|
||||
});
|
||||
|
||||
it("drains replacements invalidated by owner and global disposal", async () => {
|
||||
const ownerKernel = new FakeKernel(okResult);
|
||||
const globalKernel = new FakeKernel(okResult);
|
||||
const ownerReplacement = new FakeKernel(okResult);
|
||||
const globalReplacement = new FakeKernel(okResult);
|
||||
const replacementsStarted = Promise.withResolvers<void>();
|
||||
const releaseReplacements = Promise.withResolvers<void>();
|
||||
const initialKernels = [ownerKernel, globalKernel];
|
||||
const replacementKernels = [ownerReplacement, globalReplacement];
|
||||
let replacementStartCount = 0;
|
||||
|
||||
PythonKernel.start = async () => {
|
||||
const initial = initialKernels.shift();
|
||||
if (initial) return initial as unknown as PythonKernel;
|
||||
replacementStartCount += 1;
|
||||
if (replacementStartCount === 2) replacementsStarted.resolve();
|
||||
await releaseReplacements.promise;
|
||||
return replacementKernels.shift() as unknown as PythonKernel;
|
||||
};
|
||||
|
||||
await executePython("print('owner setup')", {
|
||||
sessionId: "session-owner-disposal-replacement",
|
||||
kernelOwnerId: "replacement-owner",
|
||||
});
|
||||
await executePython("print('global setup')", { sessionId: "session-global-disposal-replacement" });
|
||||
ownerKernel.markDead();
|
||||
globalKernel.markDead();
|
||||
|
||||
const ownerExecution = executePython("print('owner obsolete')", {
|
||||
sessionId: "session-owner-disposal-replacement",
|
||||
kernelOwnerId: "replacement-owner",
|
||||
});
|
||||
const globalExecution = executePython("print('global obsolete')", {
|
||||
sessionId: "session-global-disposal-replacement",
|
||||
});
|
||||
await replacementsStarted.promise;
|
||||
|
||||
const ownerDisposal = disposeKernelSessionsByOwner("replacement-owner");
|
||||
await flushMicrotasks();
|
||||
const globalDisposal = disposeAllKernelSessions();
|
||||
await flushMicrotasks();
|
||||
releaseReplacements.resolve();
|
||||
|
||||
expect((await ownerExecution).cancelled).toBe(true);
|
||||
expect((await globalExecution).cancelled).toBe(true);
|
||||
await Promise.all([ownerDisposal, globalDisposal]);
|
||||
expect(ownerReplacement.executeCalls).toEqual([]);
|
||||
expect(globalReplacement.executeCalls).toEqual([]);
|
||||
expect(ownerReplacement.shutdownCalls).toBe(1);
|
||||
expect(globalReplacement.shutdownCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps replacement coordination independent across normalized cwd keys", async () => {
|
||||
const deadOne = new FakeKernel(okResult);
|
||||
const deadTwo = new FakeKernel(okResult);
|
||||
const replacementOne = new FakeKernel(okResult);
|
||||
const replacementTwo = new FakeKernel(okResult);
|
||||
const kernels = [deadOne, deadTwo, replacementOne, replacementTwo];
|
||||
let startCount = 0;
|
||||
|
||||
PythonKernel.start = async () => {
|
||||
startCount += 1;
|
||||
return kernels.shift() as unknown as PythonKernel;
|
||||
};
|
||||
|
||||
await executePython("print('setup one')", {
|
||||
cwd: "/tmp/replacement-key-one",
|
||||
sessionId: "session-independent-replacement",
|
||||
});
|
||||
await executePython("print('setup two')", {
|
||||
cwd: "/tmp/replacement-key-two",
|
||||
sessionId: "session-independent-replacement",
|
||||
});
|
||||
deadOne.markDead();
|
||||
deadTwo.markDead();
|
||||
|
||||
await Promise.all([
|
||||
executePython("print('one')", {
|
||||
cwd: "/tmp/replacement-key-one",
|
||||
sessionId: "session-independent-replacement",
|
||||
}),
|
||||
executePython("print('two')", {
|
||||
cwd: "/tmp/replacement-key-two",
|
||||
sessionId: "session-independent-replacement",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(startCount).toBe(4);
|
||||
expect(replacementOne.executeCalls).toEqual(["print('one')"]);
|
||||
expect(replacementTwo.executeCalls).toEqual(["print('two')"]);
|
||||
});
|
||||
|
||||
it("resets the session kernel when requested", async () => {
|
||||
const firstKernel = new FakeKernel(okResult);
|
||||
const secondKernel = new FakeKernel(okResult);
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("memories runtime", () => {
|
||||
process.env.XDG_STATE_HOME = savedXdgState;
|
||||
});
|
||||
|
||||
test("startup gating skips when disabled or subagent depth", async () => {
|
||||
test("startup gating follows memory.backend and skips subagents", async () => {
|
||||
const disabled = await createFixture({ "memories.enabled": false });
|
||||
const openSpy = vi.spyOn(memoryStorage, "openMemoryDb");
|
||||
startMemoryStartupTask({
|
||||
@@ -156,6 +156,15 @@ describe("memories runtime", () => {
|
||||
taskDepth: 0,
|
||||
});
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
const explicitlyOff = await createFixture({ "memory.backend": "off", "memories.enabled": true });
|
||||
startMemoryStartupTask({
|
||||
session: explicitlyOff.session,
|
||||
settings: explicitlyOff.settings,
|
||||
modelRegistry: explicitlyOff.modelRegistry,
|
||||
agentDir: explicitlyOff.agentDir,
|
||||
taskDepth: 0,
|
||||
});
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
const subagent = await createFixture({ "memories.enabled": true });
|
||||
startMemoryStartupTask({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
||||
@@ -17,13 +17,13 @@ import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state
|
||||
import { mnemopiBackend } from "@oh-my-pi/pi-coding-agent/mnemopi/backend";
|
||||
import { loadMnemopiConfig, type MnemopiBackendConfig } from "@oh-my-pi/pi-coding-agent/mnemopi/config";
|
||||
import {
|
||||
getMnemopiScopedDbPaths,
|
||||
getMnemopiSessionState,
|
||||
loadMnemopi,
|
||||
loadMnemopiCore,
|
||||
MnemopiSessionState,
|
||||
setMnemopiSessionState,
|
||||
} from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
||||
import type { AgentSessionEventListener } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
|
||||
import { MemoryEditTool } from "@oh-my-pi/pi-coding-agent/tools/memory-edit";
|
||||
import { MemoryRecallTool } from "@oh-my-pi/pi-coding-agent/tools/memory-recall";
|
||||
@@ -32,8 +32,7 @@ import { MemoryRetainTool } from "@oh-my-pi/pi-coding-agent/tools/memory-retain"
|
||||
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// Mnemopi is lazy-loaded at runtime; preload it so the sync construction in
|
||||
// registerMnemopiState() and getMnemopiScopedDbPaths() can resolve the module.
|
||||
// Mnemopi is lazy-loaded at runtime; preload it for synchronous state construction.
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
|
||||
const TEST_SESSION_ID = "test-session-id";
|
||||
@@ -159,6 +158,7 @@ interface RegisterMnemopiStateOptions {
|
||||
cwd?: string;
|
||||
sessionId?: string;
|
||||
entries?: () => unknown[];
|
||||
listeners?: Set<AgentSessionEventListener>;
|
||||
}
|
||||
|
||||
function registerMnemopiState(
|
||||
@@ -172,12 +172,25 @@ function registerMnemopiState(
|
||||
config: finalConfig,
|
||||
session: {
|
||||
sessionId,
|
||||
settings: Settings.isolated({
|
||||
"memory.backend": "mnemopi",
|
||||
"mnemopi.noEmbeddings": true,
|
||||
"mnemopi.llmMode": "none",
|
||||
}),
|
||||
modelRegistry: {
|
||||
getApiKeyForProvider: async () => undefined,
|
||||
resolver: () => async () => undefined,
|
||||
} as never,
|
||||
sessionManager: {
|
||||
getEntries: options.entries ?? (() => []),
|
||||
getCwd: () => options.cwd ?? "/tmp",
|
||||
} as never,
|
||||
emitNotice: () => {},
|
||||
getHindsightSessionState: () => undefined,
|
||||
subscribe: (listener: AgentSessionEventListener) => {
|
||||
options.listeners?.add(listener);
|
||||
return () => options.listeners?.delete(listener);
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
setMnemopiSessionState(registeredMnemopiState.session as never, registeredMnemopiState);
|
||||
@@ -838,7 +851,7 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
expect(parentRetainSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears every scoped Mnemopi database for per-project-tagged mode", async () => {
|
||||
it("clears scoped Mnemopi data and rehydrates active state", async () => {
|
||||
const config = makeMnemopiConfig({
|
||||
scoping: "per-project-tagged",
|
||||
bank: "project-alpha",
|
||||
@@ -846,33 +859,40 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
retainBank: "project-alpha",
|
||||
recallBanks: ["project-alpha", "default"],
|
||||
});
|
||||
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
||||
const listeners = new Set<AgentSessionEventListener>();
|
||||
const state = registerMnemopiState(config, { cwd: "/work/project-alpha", listeners });
|
||||
state.rememberInScope("project clear marker", { scope: "bank", extract: false, source: "test" });
|
||||
state.globalMemory?.remember("global clear marker", { scope: "bank", extract: false, source: "test" });
|
||||
const dbPaths = getMnemopiScopedDbPaths(config);
|
||||
for (const dbPath of dbPaths) expect(existsSync(dbPath)).toBe(true);
|
||||
const session = state.session;
|
||||
setMnemopiSessionState(session, state);
|
||||
|
||||
await mnemopiBackend.clear(path.dirname(config.dbPath), "/work/project-alpha", session);
|
||||
|
||||
// The clear() contract: all scoped DB files are deleted. On Windows under
|
||||
// bun:test, SQLite handle release may lag behind the await; poll briefly
|
||||
// before asserting rather than failing on a transient lock.
|
||||
const assertGone = async (p: string): Promise<void> => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (!existsSync(p)) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
};
|
||||
for (const dbPath of dbPaths) {
|
||||
await assertGone(dbPath);
|
||||
await assertGone(`${dbPath}-wal`);
|
||||
await assertGone(`${dbPath}-shm`);
|
||||
}
|
||||
// Assert state was cleared even if file deletion is still in-flight.
|
||||
expect(getMnemopiSessionState(session)).toBeUndefined();
|
||||
const rehydrated = getMnemopiSessionState(session);
|
||||
if (!rehydrated) throw new Error("Mnemopi state was not rehydrated");
|
||||
expect(rehydrated).not.toBe(state);
|
||||
expect(listeners.size).toBe(1);
|
||||
const remaining = await rehydrated.recallResultsScoped("clear marker");
|
||||
expect(remaining.some(hit => String(hit.content).includes("clear marker"))).toBe(false);
|
||||
expect(rehydrated.rememberScoped("after-clear", { source: "test", scope: "bank", extract: false })).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
registeredMnemopiState = rehydrated;
|
||||
});
|
||||
it("attaches listeners when enqueue rehydrates missing state", async () => {
|
||||
const config = makeMnemopiConfig();
|
||||
const listeners = new Set<AgentSessionEventListener>();
|
||||
const seed = registerMnemopiState(config, { listeners });
|
||||
const session = seed.session;
|
||||
setMnemopiSessionState(session, undefined);
|
||||
await seed.dispose({ consolidate: false });
|
||||
registeredMnemopiState = undefined;
|
||||
|
||||
await mnemopiBackend.enqueue(path.dirname(config.dbPath), "/tmp", session);
|
||||
|
||||
registeredMnemopiState = getMnemopiSessionState(session);
|
||||
expect(registeredMnemopiState).toBeDefined();
|
||||
expect(listeners.size).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() skips consolidation before deleting the DBs (#2327 review)", async () => {
|
||||
@@ -911,8 +931,8 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
expect(bank.sleep).not.toHaveBeenCalled();
|
||||
expect(bank.close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(getMnemopiSessionState(session)).toBeUndefined();
|
||||
registeredMnemopiState = undefined;
|
||||
registeredMnemopiState = getMnemopiSessionState(session);
|
||||
expect(registeredMnemopiState).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposes direct mnemopi runtime status and search/save results", async () => {
|
||||
|
||||
@@ -33,6 +33,22 @@ describe("ModelRegistry default custom models config", () => {
|
||||
expect(model?.baseUrl).toBe("https://yaml-default.example.com/v1");
|
||||
});
|
||||
|
||||
test("loads Bedrock cache capabilities from a model override", () => {
|
||||
writeBedrockCacheOverride();
|
||||
|
||||
const model = loadDefaultRegistryModel({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "us.anthropic.claude-opus-4-8",
|
||||
});
|
||||
|
||||
expect(model?.compat).toEqual({
|
||||
promptCacheMode: "explicit",
|
||||
supportsLongPromptCacheRetention: false,
|
||||
promptCacheMinimumTokens: 1024,
|
||||
promptCacheMaximumCheckpoints: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers default models.yml over models.yaml when both exist", () => {
|
||||
writeModelsYaml("models.yml", {
|
||||
provider: "yaml-precedence",
|
||||
@@ -105,6 +121,12 @@ interface ModelSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string | undefined;
|
||||
compat: {
|
||||
promptCacheMode: string;
|
||||
supportsLongPromptCacheRetention: boolean;
|
||||
promptCacheMinimumTokens: number;
|
||||
promptCacheMaximumCheckpoints: number;
|
||||
};
|
||||
}
|
||||
|
||||
function writeModelsYaml(file: "models.yml" | "models.yaml", fixture: ProviderFixture): void {
|
||||
@@ -133,6 +155,24 @@ function writeModelsYaml(file: "models.yml" | "models.yaml", fixture: ProviderFi
|
||||
);
|
||||
}
|
||||
|
||||
function writeBedrockCacheOverride(): void {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir.path(), "models.yml"),
|
||||
[
|
||||
"providers:",
|
||||
" amazon-bedrock:",
|
||||
" modelOverrides:",
|
||||
" us.anthropic.claude-opus-4-8:",
|
||||
" compat:",
|
||||
" promptCacheMode: explicit",
|
||||
" supportsLongPromptCacheRetention: false",
|
||||
" promptCacheMinimumTokens: 1024",
|
||||
" promptCacheMaximumCheckpoints: 4",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeModelsJson(fixture: ProviderFixture): void {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir.path(), "models.json"),
|
||||
@@ -173,6 +213,7 @@ function loadDefaultRegistryModel(lookup: ModelLookup): ModelSnapshot | undefine
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
baseUrl: model.baseUrl,
|
||||
compat: model.compat,
|
||||
} : null));
|
||||
} finally {
|
||||
authStorage.close();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import {
|
||||
analyzeAuthError,
|
||||
discoverOAuthEndpoints,
|
||||
@@ -641,3 +642,32 @@ describe("RFC 8414 §3.3 issuer validation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded discovery fetches", () => {
|
||||
// A fetch that never resolves on its own; it settles only when its
|
||||
// AbortSignal fires. Pre-fix, discovery passed no signal, so this hung forever.
|
||||
const hangingFetch: FetchImpl = (_input, init) => {
|
||||
const { promise, reject } = Promise.withResolvers<Response>();
|
||||
const signal = init?.signal;
|
||||
const abort = () => reject(new DOMException("aborted", "AbortError"));
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
return promise;
|
||||
};
|
||||
|
||||
it("aborts hanging well-known discovery fetches instead of stalling", async () => {
|
||||
const oauth = await discoverOAuthEndpoints("https://mcp.example.test/mcp", undefined, undefined, {
|
||||
fetch: hangingFetch,
|
||||
signal: AbortSignal.timeout(50),
|
||||
});
|
||||
expect(oauth).toBeNull();
|
||||
});
|
||||
|
||||
it("aborts a hanging resource_metadata fetch and returns undefined", async () => {
|
||||
const scopes = await fetchResourceMetadataScopes(
|
||||
"https://mcp.example.test/.well-known/oauth-protected-resource",
|
||||
{ fetch: hangingFetch, signal: AbortSignal.timeout(50) },
|
||||
);
|
||||
expect(scopes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { type ExecuteHashlineSingleOptions, executeHashlineSingle } from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { EditTool, type ExecuteHashlineSingleOptions, executeHashlineSingle } from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
@@ -141,6 +141,87 @@ describe("read → edit round-trip for out-of-cwd files", () => {
|
||||
expect(textOutput(result)).not.toContain("Artifact content.");
|
||||
});
|
||||
|
||||
it("uses the read-resolved workspace suffix across direct edit modes", async () => {
|
||||
const cases: Array<{
|
||||
mode: "replace" | "patch" | "apply_patch";
|
||||
run: (tool: EditTool, fileName: string) => Promise<void>;
|
||||
}> = [
|
||||
{
|
||||
mode: "replace",
|
||||
run: async (tool, fileName) => {
|
||||
await tool.execute("edit-workspace-suffix-replace", {
|
||||
path: fileName,
|
||||
edits: [{ old_text: "alpha", new_text: "ALPHA" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "patch",
|
||||
run: async (tool, fileName) => {
|
||||
await tool.execute("edit-workspace-suffix-patch", {
|
||||
path: fileName,
|
||||
edits: [{ op: "update", diff: "@@\n-alpha\n+ALPHA" }],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "apply_patch",
|
||||
run: async (tool, fileName) => {
|
||||
const input = [
|
||||
"*** Begin Patch",
|
||||
`*** Update File: ${fileName}`,
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+ALPHA",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n");
|
||||
await tool.execute("edit-workspace-suffix-apply-patch", { input });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const fileName = `${testCase.mode}.txt`;
|
||||
const workspaceFile = path.join(cwdDir, "src", fileName);
|
||||
await Bun.write(workspaceFile, "alpha\nbeta\n");
|
||||
|
||||
const session = createSession(cwdDir);
|
||||
session.settings.set("edit.mode", testCase.mode);
|
||||
const readResult = await new ReadTool(session).execute(`read-workspace-suffix-${testCase.mode}`, {
|
||||
path: fileName,
|
||||
});
|
||||
expect(textOutput(readResult)).toContain("alpha");
|
||||
|
||||
await testCase.run(new EditTool(session), fileName);
|
||||
expect(await Bun.file(workspaceFile).text()).toBe("ALPHA\nbeta\n");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the resolved workspace target across delete/add hunks for the same authored path", async () => {
|
||||
const fileName = "recreate.txt";
|
||||
const workspaceFile = path.join(cwdDir, "src", fileName);
|
||||
await Bun.write(workspaceFile, "alpha\nbeta\n");
|
||||
|
||||
const session = createSession(cwdDir);
|
||||
session.settings.set("edit.mode", "apply_patch");
|
||||
const readResult = await new ReadTool(session).execute("read-workspace-suffix-recreate", { path: fileName });
|
||||
expect(textOutput(readResult)).toContain("alpha");
|
||||
|
||||
const input = [
|
||||
"*** Begin Patch",
|
||||
`*** Delete File: ${fileName}`,
|
||||
`*** Add File: ${fileName}`,
|
||||
"+rewritten",
|
||||
"*** End Patch",
|
||||
"",
|
||||
].join("\n");
|
||||
await new EditTool(session).execute("edit-workspace-suffix-recreate", { input });
|
||||
|
||||
expect(await Bun.file(workspaceFile).text()).toBe("rewritten\n");
|
||||
expect(await Bun.file(path.join(cwdDir, fileName)).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers a unique workspace suffix match over the approved local plan alias", async () => {
|
||||
const artifactsDir = path.join(outDir, "artifacts");
|
||||
const planFilePath = "local://windows-packaging-plan.md";
|
||||
|
||||
@@ -66,6 +66,17 @@ describe("selector setting side effects", () => {
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("applies memory backend changes to the live session", () => {
|
||||
const applyMemoryBackend = vi.fn(async () => {});
|
||||
const controller = new SelectorController({
|
||||
session: { applyMemoryBackend },
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext);
|
||||
|
||||
controller.handleSettingChange("memory.backend", "mnemopi");
|
||||
|
||||
expect(applyMemoryBackend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
for (const id of ["terminal.showImages", "showImages"]) {
|
||||
for (const visible of [false, true]) {
|
||||
|
||||
@@ -472,7 +472,6 @@ describe("Settings", () => {
|
||||
const withFileLock = fileLock.withFileLock;
|
||||
vi.spyOn(fileLock, "withFileLock").mockImplementation(async (filePath, fn, options) => {
|
||||
firstSaveEntered.resolve();
|
||||
await releaseFirstSave.promise;
|
||||
const result = await withFileLock(filePath, fn, options);
|
||||
firstSaveFinished.resolve();
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/**
|
||||
* Regression for issue #6337: a status message presented while the auto-theme
|
||||
* default guess (dark) was active must re-resolve its color when the terminal's
|
||||
* appearance reply later switches the active theme to light. The transient
|
||||
* status presenters supply the color via `Text.setStyleFn` (evaluated at render
|
||||
* time against the live `theme` binding) instead of baking `theme.fg()` into the
|
||||
* component, so invalidating on `onThemeChange` re-shapes it.
|
||||
*/
|
||||
|
||||
/** Opening SGR sequence `theme.fg(color, ...)` emits, independent of color mode. */
|
||||
function fgPrefix(color: "warning"): string {
|
||||
const styled = themeModule.theme.fg(color, "\u0001");
|
||||
return styled.slice(0, styled.indexOf("\u0001"));
|
||||
}
|
||||
|
||||
describe("lazy status color re-resolves on theme switch", () => {
|
||||
beforeEach(async () => {
|
||||
themeModule.stopThemeWatcher();
|
||||
const dark = await themeModule.getThemeByName("dark");
|
||||
if (!dark) throw new Error("Failed to load dark theme for tests");
|
||||
themeModule.setThemeInstance(dark);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
themeModule.stopThemeWatcher();
|
||||
const dark = await themeModule.getThemeByName("dark");
|
||||
if (dark) themeModule.setThemeInstance(dark);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("swaps a presented warning from dark-catppuccin to light-catppuccin color", async () => {
|
||||
// Auto-theme resolves dark before the appearance reply arrives.
|
||||
themeModule.onTerminalAppearanceChange("dark");
|
||||
await themeModule.initTheme(false, undefined, undefined, "dark-catppuccin", "light-catppuccin");
|
||||
expect(themeModule.getCurrentThemeName()).toBe("dark-catppuccin");
|
||||
|
||||
const darkPrefix = fgPrefix("warning");
|
||||
const warning = new Text("Warning: Failed to load extension", 1, 0).setStyleFn(t =>
|
||||
themeModule.theme.fg("warning", t),
|
||||
);
|
||||
expect(warning.render(80).join("")).toContain(darkPrefix);
|
||||
|
||||
// The OSC 11 reply arrives → auto-theme switches to light-catppuccin.
|
||||
const switched = Promise.withResolvers<void>();
|
||||
const off = themeModule.onThemeChange(() => switched.resolve());
|
||||
themeModule.onTerminalAppearanceChange("light");
|
||||
await switched.promise;
|
||||
off();
|
||||
expect(themeModule.getCurrentThemeName()).toBe("light-catppuccin");
|
||||
|
||||
const lightPrefix = fgPrefix("warning");
|
||||
expect(lightPrefix).not.toBe(darkPrefix);
|
||||
|
||||
// The onThemeChange handler invalidates + repaints; the warning must now
|
||||
// render the light-mode color, not the baked dark one.
|
||||
warning.invalidate();
|
||||
const out = warning.render(80).join("");
|
||||
expect(out).toContain(lightPrefix);
|
||||
expect(out).not.toContain(darkPrefix);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +216,65 @@ describe("expandInternalUrls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("expands an unquoted URL inside a backtick substitution nested in double quotes", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = 'echo "`cat skill://valid-skill/SKILL.md`"';
|
||||
const expectedPath = path.join(skills[0].baseDir, "SKILL.md");
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(
|
||||
`echo "\`cat ${shellEscape(expectedPath)}\`"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("expands a top-level unquoted URL inside a backtick substitution", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = "echo `cat skill://valid-skill/SKILL.md`";
|
||||
const expectedPath = path.join(skills[0].baseDir, "SKILL.md");
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(`echo \`cat ${shellEscape(expectedPath)}\``);
|
||||
});
|
||||
|
||||
it("expands nested $() inside a double-quoted backtick substitution", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = 'echo "`echo $(cat skill://valid-skill/SKILL.md)`"';
|
||||
const expectedPath = path.join(skills[0].baseDir, "SKILL.md");
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(
|
||||
`echo "\`echo $(cat ${shellEscape(expectedPath)})\`"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("expands nested backticks inside a double-quoted $() substitution", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = 'echo "$(echo `cat skill://valid-skill/SKILL.md`)"';
|
||||
const expectedPath = path.join(skills[0].baseDir, "SKILL.md");
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(
|
||||
`echo "$(echo \`cat ${shellEscape(expectedPath)}\`)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a URL inside a single-quoted backtick string literal", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = "echo '`cat skill://valid-skill/SKILL.md`'";
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(command);
|
||||
});
|
||||
|
||||
it("leaves a URL behind an escaped backtick in double quotes literal", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = 'echo "\\`skill://valid-skill/SKILL.md\\`"';
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(command);
|
||||
});
|
||||
|
||||
it("leaves a URL inside escaped quotes within a double-quoted backtick substitution", async () => {
|
||||
const skills = [createSkill("valid-skill", "/tmp/skills/valid-skill")];
|
||||
const command = 'echo "`printf %s \\"literal skill://valid-skill/SKILL.md\\"`"';
|
||||
|
||||
await expect(expandInternalUrls(command, { skills })).resolves.toBe(command);
|
||||
});
|
||||
|
||||
it("leaves literal internal URLs embedded in quoted text unchanged", async () => {
|
||||
const router = createInternalRouter({
|
||||
"memory://root/summary.md": { sourcePath: "/tmp/memories/summary.md" },
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Regression tests for issue #6365: `BrowserTool.#open` must apply the
|
||||
* requested `timeout` to the *entire* open lifecycle (browser acquisition +
|
||||
* tab acquisition), and must hold one explicit browser lease across tab
|
||||
* acquisition so a refCount:0 browser is never orphaned by an abort/timeout
|
||||
* nor disposed out from under a concurrent open of a different tab name.
|
||||
*
|
||||
* The tool resolves the cmux backend (`CMUX_SOCKET_PATH` + settings), so
|
||||
* `CmuxSocketClient.prototype` is spied and no real socket / Chromium is used.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn, vi } from "bun:test";
|
||||
import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser";
|
||||
import { CmuxSocketClient } from "@oh-my-pi/pi-coding-agent/tools/browser/cmux/socket-client";
|
||||
import { getBrowsersMapForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/registry";
|
||||
import { getTabsMapForTest, releaseTab } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
|
||||
import { ToolAbortError, ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
|
||||
|
||||
function makeSession(): ToolSession {
|
||||
return {
|
||||
cwd: "/tmp",
|
||||
hasUI: false,
|
||||
settings: {
|
||||
get: (key: string) => (key === "browser.cmux" ? true : key === "tools.maxTimeout" ? 0 : undefined),
|
||||
},
|
||||
getSessionId: () => "session-open-lease",
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
async function drainAllTabs(): Promise<void> {
|
||||
for (const name of [...getTabsMapForTest().keys()]) {
|
||||
await releaseTab(name, { kill: false }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let prevSocketPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevSocketPath = process.env.CMUX_SOCKET_PATH;
|
||||
// Unique per test so the module-global browsers map (keyed by socket path)
|
||||
// never carries a handle across tests.
|
||||
process.env.CMUX_SOCKET_PATH = `/tmp/omp-open-lease-${process.pid}-${Math.random().toString(36).slice(2)}.sock`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await drainAllTabs().catch(() => undefined);
|
||||
vi.restoreAllMocks();
|
||||
if (prevSocketPath === undefined) delete process.env.CMUX_SOCKET_PATH;
|
||||
else process.env.CMUX_SOCKET_PATH = prevSocketPath;
|
||||
});
|
||||
|
||||
describe("browser open — requested timeout bounds the whole acquisition (#6365)", () => {
|
||||
it("rejects with a timeout ToolError when browser acquisition stays pending past the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const connectGate = Promise.withResolvers<void>();
|
||||
spyOn(CmuxSocketClient.prototype, "connect").mockImplementation(async () => {
|
||||
await connectGate.promise;
|
||||
});
|
||||
const closeSpy = spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
|
||||
|
||||
const tool = new BrowserTool(makeSession());
|
||||
const open = tool.execute("call-timeout", { action: "open", name: "late", timeout: 1 });
|
||||
const settled = open.then(
|
||||
() => ({ ok: true as const }),
|
||||
(err: unknown) => ({ ok: false as const, err }),
|
||||
);
|
||||
|
||||
// The requested 1s deadline elapses while `acquireBrowser` is still
|
||||
// blocked on the (never-resolving) socket connect. Bun's fake timers fire
|
||||
// `AbortSignal.timeout` synchronously on advance; awaiting `settled` below
|
||||
// flushes the rejection.
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const outcome = await settled;
|
||||
expect(outcome.ok).toBe(false);
|
||||
if (outcome.ok) throw new Error("unreachable");
|
||||
// The requested action timeout surfaces as a timeout ToolError — never a
|
||||
// ToolAbortError (that is reserved for caller cancellation).
|
||||
expect(outcome.err).toBeInstanceOf(ToolError);
|
||||
expect(outcome.err).not.toBeInstanceOf(ToolAbortError);
|
||||
expect((outcome.err as Error).message).toMatch(/timed out/i);
|
||||
|
||||
// Let the orphan launch resolve; the aborted deadline must dispose it so
|
||||
// no refCount:0 browser survives in the registry.
|
||||
connectGate.resolve();
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getBrowsersMapForTest().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser open — caller cancellation rolls back the fresh browser (#6365)", () => {
|
||||
it("aborting before tab publication rejects with ToolAbortError and leaves both maps empty", async () => {
|
||||
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
|
||||
const closeSpy = spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
|
||||
const openSplitGate = Promise.withResolvers<void>();
|
||||
const surfaceClosed: string[] = [];
|
||||
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
|
||||
async (method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
if (method === "browser.open_split") {
|
||||
await openSplitGate.promise;
|
||||
return { surface_id: "surface-abort", url: "about:blank" };
|
||||
}
|
||||
if (method === "surface.close") {
|
||||
surfaceClosed.push(String(params.surface_id));
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
const tool = new BrowserTool(makeSession());
|
||||
const controller = new AbortController();
|
||||
const open = tool.execute("call-abort", { action: "open", name: "fresh", timeout: 30 }, controller.signal);
|
||||
const settled = open.then(
|
||||
() => ({ ok: true as const }),
|
||||
(err: unknown) => ({ ok: false as const, err }),
|
||||
);
|
||||
|
||||
// Browser acquisition has resolved; tab acquisition is parked in
|
||||
// `open_split`. Cancel here — before any tab is published.
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
const outcome = await settled;
|
||||
expect(outcome.ok).toBe(false);
|
||||
if (outcome.ok) throw new Error("unreachable");
|
||||
expect(outcome.err).toBeInstanceOf(ToolAbortError);
|
||||
|
||||
// The open-acquisition lease rollback disposes the fresh browser exactly
|
||||
// once and leaves nothing owned solely by the failed open.
|
||||
expect(getTabsMapForTest().has("fresh")).toBe(false);
|
||||
expect(getBrowsersMapForTest().size).toBe(0);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Let the orphaned acquisition unwind so it does not leak past the test.
|
||||
openSplitGate.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser open — concurrent different-name acquisitions each own a lease (#6365)", () => {
|
||||
it("aborting one open releases only its lease; the survivor keeps the browser and one tab", async () => {
|
||||
spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined);
|
||||
const closeSpy = spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined);
|
||||
const openGate = Promise.withResolvers<void>();
|
||||
let splitCount = 0;
|
||||
const aEntered = Promise.withResolvers<void>();
|
||||
const bEntered = Promise.withResolvers<void>();
|
||||
const surfaceClosed: string[] = [];
|
||||
spyOn(CmuxSocketClient.prototype, "request").mockImplementation(
|
||||
async (method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
if (method === "browser.open_split") {
|
||||
const id = `surface-${++splitCount}`;
|
||||
(splitCount === 1 ? aEntered : bEntered).resolve();
|
||||
await openGate.promise;
|
||||
return { surface_id: id, url: "about:blank" };
|
||||
}
|
||||
if (method === "surface.close") {
|
||||
surfaceClosed.push(String(params.surface_id));
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
const tool = new BrowserTool(makeSession());
|
||||
|
||||
// Open A first and wait until it is parked inside `open_split` — proof it
|
||||
// acquired the shared browser and took its open-acquisition lease.
|
||||
const controllerA = new AbortController();
|
||||
const openA = tool.execute("call-a", { action: "open", name: "tab-a", timeout: 30 }, controllerA.signal);
|
||||
const settledA = openA.then(
|
||||
() => ({ ok: true as const }),
|
||||
(err: unknown) => ({ ok: false as const, err }),
|
||||
);
|
||||
await aEntered.promise;
|
||||
expect(getBrowsersMapForTest().size).toBe(1);
|
||||
|
||||
// Open B against the SAME browser (different tab name). It reuses the
|
||||
// registry handle and takes its own lease; both are now parked.
|
||||
const openB = tool.execute("call-b", { action: "open", name: "tab-b", timeout: 30 });
|
||||
await bEntered.promise;
|
||||
|
||||
// Abort A while both are queued; releasing A's lease must not dispose the
|
||||
// browser B still needs.
|
||||
controllerA.abort();
|
||||
const outcomeA = await settledA;
|
||||
expect(outcomeA.ok).toBe(false);
|
||||
if (outcomeA.ok) throw new Error("unreachable");
|
||||
expect(outcomeA.err).toBeInstanceOf(ToolAbortError);
|
||||
|
||||
// Release the gate so B publishes its tab.
|
||||
openGate.resolve();
|
||||
const resultB = await openB;
|
||||
expect(resultB.content.some(part => part.type === "text" && /Opened tab "tab-b"/.test(part.text ?? ""))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// B's browser survived A's rollback: still present, never closed, exactly
|
||||
// one published tab. A's rollback closed only its own orphan surface.
|
||||
expect(getBrowsersMapForTest().size).toBe(1);
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
expect(getTabsMapForTest().has("tab-b")).toBe(true);
|
||||
expect(getTabsMapForTest().has("tab-a")).toBe(false);
|
||||
expect(getTabsMapForTest().size).toBe(1);
|
||||
expect(surfaceClosed).toEqual(["surface-1"]);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@oh-my-pi/pi-coding-agent/lsp/utils";
|
||||
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
|
||||
import { clampTimeout } from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts";
|
||||
import * as piUtils from "@oh-my-pi/pi-utils";
|
||||
import { sanitizeText, TempDir } from "@oh-my-pi/pi-utils";
|
||||
@@ -86,7 +87,7 @@ type FakeLspHandler = (message: RpcMessage, server: FakeLspServer) => void | Pro
|
||||
// no real-clock latency. Installed by spying on the shared `ptree` namespace
|
||||
// object (NOT `mock.module`, which would leak across files); the suite's
|
||||
// `afterEach` `vi.restoreAllMocks()` removes it.
|
||||
function installFakeLsp(handler: FakeLspHandler): FakeLspServer {
|
||||
function installFakeLsp(handler: FakeLspHandler, options?: { killResolvesExit?: boolean }): FakeLspServer {
|
||||
const encoder = new TextEncoder();
|
||||
const received: RpcMessage[] = [];
|
||||
const waiters: Array<{
|
||||
@@ -191,7 +192,7 @@ function installFakeLsp(handler: FakeLspHandler): FakeLspServer {
|
||||
peekStderr: () => "",
|
||||
kill() {
|
||||
killed = true;
|
||||
server.exit(0);
|
||||
if (options?.killResolvesExit !== false) server.exit(0);
|
||||
},
|
||||
} as unknown as LspClient["proc"];
|
||||
|
||||
@@ -2772,6 +2773,194 @@ describe("lsp regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("reload cancellation and truthful teardown (#6369)", () => {
|
||||
// A JSON-RPC error response the client maps to isMethodNotFoundError, so a
|
||||
// non-rust server falls through from `rust-analyzer/reloadWorkspace` to the
|
||||
// generic `workspace/didChangeConfiguration` reload.
|
||||
const methodNotFound = (id: RpcMessage["id"]): RpcMessage => ({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32_601, message: "method not found" },
|
||||
});
|
||||
|
||||
it("propagates cancellation of the reload request instead of reporting Restarted", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-reload-cancel-req-");
|
||||
try {
|
||||
const server = installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
// rust-analyzer/reloadWorkspace is left pending: only the caller
|
||||
// signal decides its fate.
|
||||
});
|
||||
const config: ServerConfig = { command: "fake-reload-cancel-req", fileTypes: [".ts"], rootMarkers: [] };
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
|
||||
|
||||
const tool = new LspTool(makeLspSession(tempDir.path()));
|
||||
const controller = new AbortController();
|
||||
const pending = tool.execute("reload-cancel-req", { action: "reload", file: "*" }, controller.signal);
|
||||
await server.waitFor(m => m.method === "rust-analyzer/reloadWorkspace");
|
||||
controller.abort(new ToolAbortError());
|
||||
|
||||
// Pre-fix, the bare `catch` swallowed the abort, fell through to the
|
||||
// notification (also aborted), hit the second bare `catch`, killed
|
||||
// the process, and returned "Restarted".
|
||||
await expect(pending).rejects.toBeInstanceOf(ToolAbortError);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates cancellation that arrives during the notification fallback", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-reload-cancel-fallback-");
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "rust-analyzer/reloadWorkspace") {
|
||||
// Fall through to the generic reload, then cancel before it lands.
|
||||
srv.send(methodNotFound(message.id));
|
||||
controller.abort(new ToolAbortError());
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
});
|
||||
const config: ServerConfig = { command: "fake-reload-cancel-fb", fileTypes: [".ts"], rootMarkers: [] };
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
|
||||
|
||||
const tool = new LspTool(makeLspSession(tempDir.path()));
|
||||
const pending = tool.execute("reload-cancel-fb", { action: "reload", file: "*" }, controller.signal);
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(ToolAbortError);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("still falls back to the generic reload on method-not-found without killing the server", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-reload-fallback-ok-");
|
||||
try {
|
||||
const server = installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "rust-analyzer/reloadWorkspace") {
|
||||
srv.send(methodNotFound(message.id));
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
});
|
||||
const config: ServerConfig = { command: "fake-reload-fallback", fileTypes: [".ts"], rootMarkers: [] };
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
|
||||
|
||||
const tool = new LspTool(makeLspSession(tempDir.path()));
|
||||
const result = await tool.execute("reload-fallback", { action: "reload", file: "*" });
|
||||
|
||||
expect(textResult(result)).toContain("Reloaded fake");
|
||||
expect(server.killed).toBe(false);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes -32601 by code even when the server's message text is nonstandard", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-reload-fallback-code-");
|
||||
try {
|
||||
const server = installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "rust-analyzer/reloadWorkspace") {
|
||||
// None of isMethodNotFoundError's message substrings — only the
|
||||
// JSON-RPC code identifies this as method-not-found.
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, error: { code: -32_601, message: "Unknown request" } });
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
});
|
||||
const config: ServerConfig = { command: "fake-reload-fallback-code", fileTypes: [".ts"], rootMarkers: [] };
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
|
||||
|
||||
const tool = new LspTool(makeLspSession(tempDir.path()));
|
||||
const result = await tool.execute("reload-fallback-code", { action: "reload", file: "*" });
|
||||
|
||||
expect(textResult(result)).toContain("Reloaded fake");
|
||||
expect(server.killed).toBe(false);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("shutdownClientInstance removes the client by identity and confirms process exit", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-teardown-confirm-");
|
||||
try {
|
||||
installFakeLsp((message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
} else if (message.method === "exit") {
|
||||
srv.exit(0);
|
||||
}
|
||||
});
|
||||
const config: ServerConfig = { command: "fake-teardown-confirm", fileTypes: [".ts"], rootMarkers: [] };
|
||||
const client = await lspClient.getOrCreateClient(config, tempDir.path());
|
||||
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(true);
|
||||
|
||||
const exited = await lspClient.shutdownClientInstance(client);
|
||||
expect(exited).toBe(true);
|
||||
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(false);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await lspClient.shutdownAll();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("shutdownClientInstance reports a failed teardown when the process outlives the kill", async () => {
|
||||
const tempDir = TempDir.createSync("@omp-lsp-teardown-delayed-");
|
||||
try {
|
||||
installFakeLsp(
|
||||
(message, srv) => {
|
||||
if (message.method === "initialize") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
|
||||
} else if (message.method === "shutdown") {
|
||||
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
}
|
||||
// `exit` notification and `kill()` never resolve `proc.exited`.
|
||||
},
|
||||
{ killResolvesExit: false },
|
||||
);
|
||||
const config: ServerConfig = { command: "fake-teardown-delayed", fileTypes: [".ts"], rootMarkers: [] };
|
||||
const client = await lspClient.getOrCreateClient(config, tempDir.path());
|
||||
|
||||
const exited = await lspClient.shutdownClientInstance(client);
|
||||
expect(exited).toBe(false);
|
||||
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(false);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
tempDir.removeSync();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// #3962 — LSP cold-start and notification writes must honor the tool's
|
||||
// combined timeout/caller abort signal. Before the fix, a wedged server
|
||||
// hung past the tool's advertised deadline: `initialize` fell back to the
|
||||
|
||||
@@ -10,9 +10,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { ReadTool, type ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import * as markit from "@oh-my-pi/pi-coding-agent/utils/markit";
|
||||
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
|
||||
@@ -48,6 +49,28 @@ function mockExtraction(members: Record<string, Buffer> = { "p11-img0.png": TINY
|
||||
});
|
||||
}
|
||||
|
||||
function imageBytes(result: AgentToolResult<ReadToolDetails>): Buffer {
|
||||
const image = result.content.find(content => content.type === "image");
|
||||
if (image?.type !== "image") throw new Error("Expected an image result");
|
||||
return Buffer.from(image.data, "base64");
|
||||
}
|
||||
|
||||
function mockBlockedExtraction() {
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const spy = vi.spyOn(markit, "convertFileWithMarkit").mockImplementation(async (_sourcePath, signal, options) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
signal?.throwIfAborted();
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(options.imageDir, "p11-img0.png"), TINY_PNG);
|
||||
}
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
return { entered, release, spy };
|
||||
}
|
||||
|
||||
describe("read PDF image extraction", () => {
|
||||
let testDir: string;
|
||||
let pdfPath: string;
|
||||
@@ -131,6 +154,218 @@ describe("read PDF image extraction", () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-extracts image members after same-path PDF replacement", async () => {
|
||||
const sourceA = Buffer.from("%PDF-source-a");
|
||||
const sourceB = Buffer.from("%PDF-source-b");
|
||||
fs.writeFileSync(pdfPath, sourceA);
|
||||
const spy = vi.spyOn(markit, "convertFileWithMarkit").mockImplementation(async (sourcePath, _signal, options) => {
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(options.imageDir, "p11-img0.png"),
|
||||
Buffer.concat([TINY_PNG, fs.readFileSync(sourcePath)]),
|
||||
);
|
||||
}
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
|
||||
const originalStat = fs.statSync(pdfPath);
|
||||
const first = await tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
fs.writeFileSync(pdfPath, sourceB);
|
||||
fs.utimesSync(pdfPath, originalStat.atime, originalStat.mtime);
|
||||
const second = await tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
|
||||
expect(imageBytes(first).subarray(TINY_PNG.length)).toEqual(sourceA);
|
||||
expect(imageBytes(second).subarray(TINY_PNG.length)).toEqual(sourceB);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("converts an immutable snapshot when the source changes during extraction", async () => {
|
||||
const sourceA = Buffer.from("%PDF-source-a");
|
||||
const sourceB = Buffer.from("%PDF-source-b");
|
||||
fs.writeFileSync(pdfPath, sourceA);
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
vi.spyOn(markit, "convertFileWithMarkit").mockImplementation(async (sourcePath, _signal, options) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(options.imageDir, "p11-img0.png"),
|
||||
Buffer.concat([TINY_PNG, fs.readFileSync(sourcePath)]),
|
||||
);
|
||||
}
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
const pending = new ReadTool(makeSession(testDir)).execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
|
||||
await entered.promise;
|
||||
fs.writeFileSync(pdfPath, sourceB);
|
||||
release.resolve();
|
||||
const result = await pending;
|
||||
|
||||
expect(imageBytes(result).subarray(TINY_PNG.length)).toEqual(sourceA);
|
||||
});
|
||||
|
||||
it("coalesces concurrent cold image extraction", async () => {
|
||||
const { entered, release, spy } = mockBlockedExtraction();
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
const first = tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
const second = tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
|
||||
await entered.promise;
|
||||
const conversionCount = spy.mock.calls.length;
|
||||
release.resolve();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
expect(conversionCount).toBe(1);
|
||||
expect(imageBytes(firstResult)).toEqual(imageBytes(secondResult));
|
||||
});
|
||||
|
||||
it("keeps shared extraction running when its owner aborts", async () => {
|
||||
const { entered, release, spy } = mockBlockedExtraction();
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
const ownerController = new AbortController();
|
||||
const owner = tool.execute("call", { path: `${pdfPath}:p11-img0.png` }, ownerController.signal);
|
||||
const joiner = tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
await entered.promise;
|
||||
|
||||
ownerController.abort();
|
||||
await expect(owner).rejects.toThrow(/Aborted|Cancelled/);
|
||||
release.resolve();
|
||||
const result = await joiner;
|
||||
|
||||
expect(result.content.some(content => content.type === "image")).toBe(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps shared extraction running when a joiner aborts", async () => {
|
||||
const { entered, release, spy } = mockBlockedExtraction();
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
const joinerController = new AbortController();
|
||||
const owner = tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
const joiner = tool.execute("call", { path: `${pdfPath}:p11-img0.png` }, joinerController.signal);
|
||||
await entered.promise;
|
||||
|
||||
joinerController.abort();
|
||||
await expect(joiner).rejects.toThrow(/Aborted|Cancelled/);
|
||||
release.resolve();
|
||||
const result = await owner;
|
||||
|
||||
expect(result.content.some(content => content.type === "image")).toBe(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans temporary extraction state when the only caller aborts", async () => {
|
||||
const entered = Promise.withResolvers<void>();
|
||||
let snapshotPath: string | undefined;
|
||||
let stagingDir: string | undefined;
|
||||
vi.spyOn(markit, "convertFileWithMarkit").mockImplementation(async (sourcePath, signal, options) => {
|
||||
snapshotPath = sourcePath;
|
||||
stagingDir = options?.imageDir;
|
||||
entered.resolve();
|
||||
const aborted = Promise.withResolvers<void>();
|
||||
const onAbort = () => aborted.resolve();
|
||||
if (signal?.aborted) onAbort();
|
||||
else signal?.addEventListener("abort", onAbort, { once: true });
|
||||
await aborted.promise;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
signal?.throwIfAborted();
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = new ReadTool(makeSession(testDir)).execute(
|
||||
"call",
|
||||
{ path: `${pdfPath}:p11-img0.png` },
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
await entered.promise;
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toThrow(/Aborted|Cancelled/);
|
||||
if (!snapshotPath || !stagingDir) throw new Error("Expected extraction paths");
|
||||
|
||||
expect(fs.existsSync(path.dirname(snapshotPath))).toBe(false);
|
||||
expect(fs.existsSync(stagingDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a failed generation delete a replacement generation", async () => {
|
||||
const sourceA = Buffer.from("%PDF-source-a");
|
||||
const sourceB = Buffer.from("%PDF-source-b");
|
||||
fs.writeFileSync(pdfPath, sourceA);
|
||||
const firstEntered = Promise.withResolvers<void>();
|
||||
const failFirst = Promise.withResolvers<void>();
|
||||
const spy = vi.spyOn(markit, "convertFileWithMarkit").mockImplementation(async (sourcePath, _signal, options) => {
|
||||
const source = fs.readFileSync(sourcePath);
|
||||
if (source.equals(sourceA)) {
|
||||
firstEntered.resolve();
|
||||
await failFirst.promise;
|
||||
return { ok: false, content: "", error: "generation A failed" };
|
||||
}
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(options.imageDir, "p11-img0.png"), Buffer.concat([TINY_PNG, source]));
|
||||
}
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
const first = tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
await firstEntered.promise;
|
||||
fs.writeFileSync(pdfPath, sourceB);
|
||||
|
||||
const replacement = await tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
failFirst.resolve();
|
||||
await expect(first).rejects.toThrow(/Cannot extract images/);
|
||||
const cachedReplacement = await tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
|
||||
expect(imageBytes(replacement).subarray(TINY_PNG.length)).toEqual(sourceB);
|
||||
expect(imageBytes(cachedReplacement)).toEqual(imageBytes(replacement));
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("isolates equal-content PDFs with the same basename in different directories", async () => {
|
||||
const otherDir = path.join(testDir, "other");
|
||||
const otherPdfPath = path.join(otherDir, path.basename(pdfPath));
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
fs.writeFileSync(otherPdfPath, fs.readFileSync(pdfPath));
|
||||
let conversion = 0;
|
||||
const spy = vi
|
||||
.spyOn(markit, "convertFileWithMarkit")
|
||||
.mockImplementation(async (_sourcePath, _signal, options) => {
|
||||
conversion++;
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(options.imageDir, "p11-img0.png"),
|
||||
Buffer.concat([TINY_PNG, Buffer.from(String(conversion))]),
|
||||
);
|
||||
}
|
||||
return { ok: true, content: "" };
|
||||
});
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
|
||||
const first = await tool.execute("call", { path: `${pdfPath}:p11-img0.png` });
|
||||
const second = await tool.execute("call", { path: `${otherPdfPath}:p11-img0.png` });
|
||||
|
||||
expect(imageBytes(first).subarray(TINY_PNG.length).toString()).toBe("1");
|
||||
expect(imageBytes(second).subarray(TINY_PNG.length).toString()).toBe("2");
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("supports PDF basenames at the filesystem component limit", async () => {
|
||||
const longPdfPath = path.join(testDir, `${"a".repeat(250)}.pdf`);
|
||||
fs.writeFileSync(longPdfPath, "%PDF-stub");
|
||||
mockExtraction();
|
||||
|
||||
const result = await new ReadTool(makeSession(testDir)).execute("call", {
|
||||
path: `${longPdfPath}:p11-img0.png`,
|
||||
});
|
||||
|
||||
expect(result.content.some(content => content.type === "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors with the available members for an unknown member", async () => {
|
||||
mockExtraction();
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
@@ -160,12 +395,20 @@ describe("read PDF image extraction", () => {
|
||||
});
|
||||
|
||||
it("does not cache a failed conversion", async () => {
|
||||
let failedSnapshotPath: string | undefined;
|
||||
let failedImageDir: string | undefined;
|
||||
const spy = vi.spyOn(markit, "convertFileWithMarkit");
|
||||
// First attempt fails and writes nothing → throws, leaves no `.extracted` marker.
|
||||
spy.mockResolvedValueOnce({ ok: false, content: "", error: "boom" });
|
||||
spy.mockImplementationOnce(async (sourcePath, _signal, options) => {
|
||||
failedSnapshotPath = sourcePath;
|
||||
failedImageDir = options?.imageDir;
|
||||
return { ok: false, content: "", error: "boom" };
|
||||
});
|
||||
const tool = new ReadTool(makeSession(testDir));
|
||||
await expect(tool.execute("call", { path: `${pdfPath}:p11-img0.png` })).rejects.toThrow(/Cannot extract images/);
|
||||
// A later attempt succeeds and must re-run conversion (cache not poisoned).
|
||||
if (!failedSnapshotPath || !failedImageDir) throw new Error("Expected failed extraction paths");
|
||||
expect(fs.existsSync(path.dirname(failedSnapshotPath))).toBe(false);
|
||||
expect(fs.existsSync(path.join(failedImageDir, ".extracted"))).toBe(false);
|
||||
|
||||
spy.mockImplementationOnce(async (_filePath: string, _signal, options) => {
|
||||
if (options?.imageDir) {
|
||||
fs.mkdirSync(options.imageDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { PlayerCommand } from "@oh-my-pi/pi-coding-agent/tts/player";
|
||||
import { StreamingAudioPlayer } from "@oh-my-pi/pi-coding-agent/tts/streaming-player";
|
||||
|
||||
/** A one-segment ~0.5s clip at 24 kHz, the shape `omp say "hi"` produces. */
|
||||
const clip = (): Float32Array => new Float32Array(24_000 / 2).fill(0.1);
|
||||
|
||||
describe("StreamingAudioPlayer nonzero-exit fallback", () => {
|
||||
it("replays the buffered clip via file playback when the streaming backend exits nonzero", async () => {
|
||||
// `cat` drains stdin so the pipe write succeeds, then the shell exits 1 —
|
||||
// exactly the bundled-ffmpeg-without-outdev failure that used to silently
|
||||
// drop `omp say`'s single short clip.
|
||||
const played: string[] = [];
|
||||
const player = new StreamingAudioPlayer({
|
||||
commandsFor: (): PlayerCommand[] => [{ cmd: "sh", args: ["-c", "cat >/dev/null; exit 1"] }],
|
||||
playAudio: async wavPath => {
|
||||
played.push(wavPath);
|
||||
},
|
||||
});
|
||||
player.start(24_000);
|
||||
player.write(clip());
|
||||
await player.end();
|
||||
expect(played.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not replay when the streaming backend exits cleanly", async () => {
|
||||
const played: string[] = [];
|
||||
const player = new StreamingAudioPlayer({
|
||||
commandsFor: (): PlayerCommand[] => [{ cmd: "sh", args: ["-c", "cat >/dev/null"] }],
|
||||
playAudio: async wavPath => {
|
||||
played.push(wavPath);
|
||||
},
|
||||
});
|
||||
player.start(24_000);
|
||||
player.write(clip());
|
||||
await player.end();
|
||||
expect(played.length).toBe(0);
|
||||
});
|
||||
|
||||
it("plays every chunk via the file fallback when no streaming backend exists", async () => {
|
||||
const played: string[] = [];
|
||||
const player = new StreamingAudioPlayer({
|
||||
commandsFor: (): PlayerCommand[] => [],
|
||||
playAudio: async wavPath => {
|
||||
played.push(wavPath);
|
||||
},
|
||||
});
|
||||
player.start(24_000);
|
||||
player.write(clip());
|
||||
player.write(clip());
|
||||
await player.end();
|
||||
expect(played.length).toBe(2);
|
||||
});
|
||||
|
||||
it("drops the replay buffer once the utterance exceeds the retention cap", async () => {
|
||||
// Long input must not accumulate unbounded PCM; past the cap the
|
||||
// nonzero-exit replay is forfeited rather than duplicating audio the
|
||||
// backend already played.
|
||||
const played: string[] = [];
|
||||
const player = new StreamingAudioPlayer({
|
||||
commandsFor: (): PlayerCommand[] => [{ cmd: "sh", args: ["-c", "cat >/dev/null; exit 1"] }],
|
||||
playAudio: async wavPath => {
|
||||
played.push(wavPath);
|
||||
},
|
||||
replayRetentionSeconds: 0.25,
|
||||
});
|
||||
player.start(24_000);
|
||||
player.write(clip());
|
||||
await player.end();
|
||||
expect(played.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `xd://resolve`/`xd://reject`/`xd://propose` cards losing action metadata after the xdev unwrap (badge rendered `?`/warn instead of apply/discard/propose semantics), and registered the missing `reject`/`propose` renderers plus the hub-family aliases (`irc`, `job`, `await`, `poll`, `cancel_job`) so those transcript names no longer fall back to generic JSON. ([#5640](https://github.com/can1357/oh-my-pi/issues/5640))
|
||||
|
||||
## [17.0.8] - 2026-07-22
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -19,6 +19,8 @@ import { goalRenderer } from "./tools/goal";
|
||||
import { grepRenderer } from "./tools/grep";
|
||||
import { hubRenderer } from "./tools/hub";
|
||||
import { inspectImageRenderer } from "./tools/inspect-image";
|
||||
import { ircRenderer } from "./tools/irc";
|
||||
import { jobRenderer } from "./tools/job";
|
||||
import { lspRenderer } from "./tools/lsp";
|
||||
import { recallRenderer } from "./tools/memory-recall";
|
||||
import { reflectRenderer } from "./tools/memory-reflect";
|
||||
@@ -55,6 +57,11 @@ const RENDERERS: Record<string, ToolRenderer> = {
|
||||
goal: goalRenderer,
|
||||
inspect_image: inspectImageRenderer,
|
||||
hub: hubRenderer,
|
||||
irc: ircRenderer,
|
||||
job: jobRenderer,
|
||||
await: jobRenderer,
|
||||
poll: jobRenderer,
|
||||
cancel_job: jobRenderer,
|
||||
lsp: lspRenderer,
|
||||
recall: recallRenderer,
|
||||
reflect: reflectRenderer,
|
||||
@@ -62,6 +69,8 @@ const RENDERERS: Record<string, ToolRenderer> = {
|
||||
read: readRenderer,
|
||||
report_tool_issue: reportToolIssueRenderer,
|
||||
resolve: resolveRenderer,
|
||||
reject: resolveRenderer,
|
||||
propose: resolveRenderer,
|
||||
grep: grepRenderer,
|
||||
search: grepRenderer,
|
||||
task: taskRenderer,
|
||||
|
||||
@@ -1,32 +1,91 @@
|
||||
/** `resolve` — apply or discard a pending preview/approval action. */
|
||||
/**
|
||||
* `resolve` / `reject` / `propose` — finalize a staged preview (apply/discard)
|
||||
* or submit a plan for approval.
|
||||
*
|
||||
* In collab-web these arrive as `write xd://<device>` calls that `ToolView`
|
||||
* unwraps: the card model then lives on the unwrapped inner details
|
||||
* (`result.details`) and the device name (`props.name`). Historical top-level
|
||||
* `resolve` transcripts instead carried `action`/`reason` on `args`, so read
|
||||
* details first and fall back to args. When neither is present yet (a running
|
||||
* card), the device name supplies the default action.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import type { Tone } from "../parts";
|
||||
import { Badge, Badges, Kv, KvGrid, Note, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord, isRecord, normalizeWs, str, truncate } from "../util";
|
||||
|
||||
function Summary({ args, result }: ToolRenderProps): ReactNode {
|
||||
const action = str(args.action);
|
||||
const reason = str(args.reason);
|
||||
const tone = result?.isError ? "err" : action === "apply" ? "ok" : "warn";
|
||||
type ResolveKind = "apply" | "discard" | "propose";
|
||||
|
||||
/** Short badge word (summary) and full transition badge (body) per card kind. */
|
||||
const KIND_WORD: Record<ResolveKind, string> = { apply: "apply", discard: "discard", propose: "propose" };
|
||||
const KIND_TRANSITION: Record<ResolveKind, string> = {
|
||||
apply: "proposed → resolved",
|
||||
discard: "proposed → rejected",
|
||||
propose: "plan proposed",
|
||||
};
|
||||
const KIND_TONE: Record<ResolveKind, Tone> = { apply: "ok", discard: "warn", propose: "accent" };
|
||||
|
||||
interface ResolveCard {
|
||||
kind: ResolveKind;
|
||||
tone: Tone;
|
||||
/** Apply/discard reason. */
|
||||
reason: string | null;
|
||||
/** Source tool that staged the preview (apply/discard). */
|
||||
sourceToolName: string | null;
|
||||
/** Preview label (apply/discard). */
|
||||
label: string | null;
|
||||
/** Plan title (propose). */
|
||||
title: string | null;
|
||||
/** Plan artifact path (propose). */
|
||||
planFilePath: string | null;
|
||||
/** Free-form extra metadata rows. */
|
||||
extra: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Derive the card model from unwrapped inner details, args fallback, and device name. */
|
||||
function cardModel({ name, args, result }: ToolRenderProps): ResolveCard {
|
||||
const details = detailsRecord(result);
|
||||
const explicit = str(details?.action) ?? str(args.action);
|
||||
const kind: ResolveKind =
|
||||
explicit === "apply" || explicit === "discard"
|
||||
? explicit
|
||||
: name === "propose"
|
||||
? "propose"
|
||||
: name === "reject"
|
||||
? "discard"
|
||||
: "apply";
|
||||
const extra = isRecord(args.extra) ? args.extra : details && isRecord(details.extra) ? details.extra : null;
|
||||
return {
|
||||
kind,
|
||||
tone: result?.isError ? "err" : KIND_TONE[kind],
|
||||
reason: str(details?.reason) ?? str(args.reason),
|
||||
sourceToolName: str(details?.sourceToolName) ?? str(args.sourceToolName),
|
||||
label: str(details?.label) ?? str(args.label),
|
||||
title: str(details?.title) ?? str(args.title),
|
||||
planFilePath: str(details?.planFilePath) ?? str(args.planFilePath),
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
function Summary(props: ToolRenderProps): ReactNode {
|
||||
const card = cardModel(props);
|
||||
const trailing = card.kind === "propose" ? card.title : card.reason;
|
||||
return (
|
||||
<>
|
||||
<Badge tone={tone}>{action ?? "?"}</Badge> {reason && <span>{truncate(normalizeWs(reason), 100)}</span>}
|
||||
<Badge tone={card.tone}>{KIND_WORD[card.kind]}</Badge>{" "}
|
||||
{trailing && <span>{truncate(normalizeWs(trailing), 100)}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
const action = str(args.action);
|
||||
const reason = str(args.reason);
|
||||
const tone = result?.isError ? "err" : action === "apply" ? "ok" : "warn";
|
||||
const details = detailsRecord(result);
|
||||
const sourceToolName = details ? str(details.sourceToolName) : null;
|
||||
const label = details ? str(details.label) : null;
|
||||
const extra = isRecord(args.extra) ? args.extra : details && isRecord(details.extra) ? details.extra : null;
|
||||
function Body(props: ToolRenderProps): ReactNode {
|
||||
const { result } = props;
|
||||
const card = cardModel(props);
|
||||
const extraRows: ReactNode[] = [];
|
||||
if (extra) {
|
||||
for (const k in extra) {
|
||||
const v = extra[k];
|
||||
if (card.extra) {
|
||||
for (const k in card.extra) {
|
||||
const v = card.extra[k];
|
||||
let text: string;
|
||||
if (typeof v === "string") text = v;
|
||||
else {
|
||||
@@ -47,18 +106,22 @@ function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
<>
|
||||
<Badges
|
||||
items={[
|
||||
<Badge key="action" tone={tone}>
|
||||
{action === "apply"
|
||||
? "proposed → resolved"
|
||||
: action === "discard"
|
||||
? "proposed → rejected"
|
||||
: (action ?? "?")}
|
||||
<Badge key="action" tone={card.tone}>
|
||||
{KIND_TRANSITION[card.kind]}
|
||||
</Badge>,
|
||||
sourceToolName && <Badge key="source">{sourceToolName}</Badge>,
|
||||
label && <span key="label">{truncate(normalizeWs(label), 120)}</span>,
|
||||
card.sourceToolName && <Badge key="source">{card.sourceToolName}</Badge>,
|
||||
card.label && <span key="label">{truncate(normalizeWs(card.label), 120)}</span>,
|
||||
card.kind === "propose" && card.title && (
|
||||
<span key="title">{truncate(normalizeWs(card.title), 120)}</span>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
{reason && <Note>{reason}</Note>}
|
||||
{card.kind !== "propose" && card.reason && <Note>{card.reason}</Note>}
|
||||
{card.kind === "propose" && card.planFilePath && (
|
||||
<KvGrid>
|
||||
<Kv k="plan">{card.planFilePath}</Kv>
|
||||
</KvGrid>
|
||||
)}
|
||||
{extraRows.length > 0 && <KvGrid>{extraRows}</KvGrid>}
|
||||
<ResultText result={result} maxLines={6} />
|
||||
</>
|
||||
|
||||
@@ -28,4 +28,127 @@ describe("ToolView xd:// dispatches", () => {
|
||||
expect(html).toContain("alpine lake");
|
||||
expect(html).toContain('src="data:image/png;base64,aW1hZ2U="');
|
||||
});
|
||||
|
||||
it("renders xd://resolve apply cards from unwrapped inner details", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView
|
||||
name="write"
|
||||
defaultOpen
|
||||
result={{
|
||||
content: [{ type: "text", text: "Applied 1 replacement in 1 file." }],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "resolve",
|
||||
mode: "execute",
|
||||
args: { reason: "looks correct" },
|
||||
inner: {
|
||||
action: "apply",
|
||||
reason: "looks correct",
|
||||
sourceToolName: "ast_edit",
|
||||
label: "ast_edit: edit foo.ts",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("xd://resolve");
|
||||
expect(html).toContain("proposed → resolved");
|
||||
expect(html).toContain("tv-badge--ok");
|
||||
expect(html).toContain("ast_edit: edit foo.ts");
|
||||
// The historical args-only path once left the badge as a warn "?".
|
||||
expect(html).not.toContain('tv-badge--warn">?');
|
||||
});
|
||||
|
||||
it("renders xd://reject discard cards with reject semantics", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView
|
||||
name="write"
|
||||
defaultOpen
|
||||
result={{
|
||||
content: [{ type: "text", text: "Discarded pending action." }],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "reject",
|
||||
mode: "execute",
|
||||
args: { reason: "not right" },
|
||||
inner: { action: "discard", reason: "not right", sourceToolName: "edit" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("xd://reject");
|
||||
expect(html).toContain("proposed → rejected");
|
||||
expect(html).toContain("tv-badge--warn");
|
||||
// Not the generic JSON dump.
|
||||
expect(html).not.toContain("tv-out-title");
|
||||
});
|
||||
|
||||
it("defaults a running xd://reject to discard before details arrive", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView name="reject" defaultOpen running args={{ reason: "" }} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("proposed → rejected");
|
||||
});
|
||||
|
||||
it("renders xd://propose plan metadata from unwrapped inner details", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView
|
||||
name="write"
|
||||
defaultOpen
|
||||
result={{
|
||||
content: [{ type: "text", text: "Plan ready for review." }],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "propose",
|
||||
mode: "execute",
|
||||
args: { title: "ship it" },
|
||||
inner: { planFilePath: "local://ship-it-plan.md", title: "ship it", planExists: true },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("xd://propose");
|
||||
expect(html).toContain("plan proposed");
|
||||
expect(html).toContain("local://ship-it-plan.md");
|
||||
});
|
||||
|
||||
it("keeps historical top-level resolve cards working from args.action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView
|
||||
name="resolve"
|
||||
defaultOpen
|
||||
args={{ action: "apply", reason: "ok" }}
|
||||
result={{ content: [], details: { sourceToolName: "ast_edit", label: "edit foo.ts" } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("proposed → resolved");
|
||||
expect(html).toContain("tv-badge--ok");
|
||||
});
|
||||
|
||||
it("routes the hub-family alias irc through the messaging renderer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView name="irc" defaultOpen args={{ op: "send", to: "Main", message: "hi" }} result={{ content: [] }} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("→ Main");
|
||||
// Not the generic JSON dump of the args.
|
||||
expect(html).not.toContain("tv-out-title");
|
||||
});
|
||||
|
||||
it("routes the hub-family alias job through the job renderer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView name="job" defaultOpen args={{ poll: ["a1b2"] }} result={{ content: [] }} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("poll a1b2");
|
||||
expect(html).not.toContain("tv-out-title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added an `includeThinking` serialize option (default `true`) so callers can exclude assistant reasoning (`¶think:` sections) from archived transcripts; used to keep reproduced reasoning out of frames replayed to Anthropic-dialect models ([#6093](https://github.com/can1357/oh-my-pi/issues/6093)).
|
||||
|
||||
## [16.5.0] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -735,6 +735,12 @@ export interface SerializeOptions {
|
||||
/** Print tool-result text in dim gray ink so archived conversation reads
|
||||
* louder than archived tool noise. Defaults to `true`. */
|
||||
dimToolResults?: boolean;
|
||||
/** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
|
||||
* Callers archiving for a Claude/Anthropic-dialect model set this `false`:
|
||||
* the archive frames are replayed as text into every later request, and
|
||||
* reasoning rendered back to Claude trips its `reasoning_extraction`
|
||||
* classifier (issue #6093). */
|
||||
includeThinking?: boolean;
|
||||
}
|
||||
|
||||
/** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */
|
||||
@@ -773,6 +779,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
|
||||
const toolCallMaxChars = options?.toolCallMaxChars ?? TOOL_CALL_MAX_CHARS;
|
||||
const headRatio = options?.truncateHeadRatio ?? TRUNCATE_HEAD_RATIO;
|
||||
const dimToolResults = options?.dimToolResults !== false;
|
||||
const includeThinking = options?.includeThinking !== false;
|
||||
const parts: string[] = [];
|
||||
let lastPrefix: string | null = null;
|
||||
|
||||
@@ -845,6 +852,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
|
||||
const text = stripDimMarkers(block.text);
|
||||
if (text.trim()) pendingText.push(text);
|
||||
} else if (block.type === "thinking") {
|
||||
if (!includeThinking) continue;
|
||||
const thinking = stripDimMarkers(block.thinking);
|
||||
if (thinking.trim()) pendingThinking.push(thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
@@ -1833,6 +1841,29 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number):
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `¶think:` sections from serialized archive source text.
|
||||
*
|
||||
* Archives written before {@link SerializeOptions.includeThinking} existed bake
|
||||
* reasoning into their kept source; replaying it to Claude trips the
|
||||
* `reasoning_extraction` classifier (issue #6093). Re-compaction re-renders the
|
||||
* whole unfolded source, so scrubbing the prior text heals a poisoned session
|
||||
* at its next compaction. Conservative by construction: only sections that
|
||||
* start with `¶think:` at a section boundary are dropped.
|
||||
*/
|
||||
function stripThinkingSections(text: string): string {
|
||||
return text
|
||||
.split(NEWLINE_GLYPH)
|
||||
.map(segment =>
|
||||
segment
|
||||
.split(/\n\n(?=¶(?:user|think|ai|call):)/)
|
||||
.filter(section => !section.startsWith("¶think:"))
|
||||
.join("\n\n"),
|
||||
)
|
||||
.filter(segment => segment.length > 0)
|
||||
.join(NEWLINE_GLYPH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
||||
* the discarded history, appends it to the accumulated archive source text, and
|
||||
@@ -1858,11 +1889,18 @@ export async function compact<TMessage = Message>(
|
||||
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
||||
const serialized = serializeConversation(llmMessages, options);
|
||||
const previousArchive = getPreservedArchive(previousPreserveData);
|
||||
const previousText =
|
||||
const previousTextRaw =
|
||||
previousArchive?.text ??
|
||||
[previousArchive?.textHead, previousArchive?.textTail]
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
.join(NEWLINE_GLYPH);
|
||||
// Legacy archives may carry `¶think:` sections from before includeThinking
|
||||
// existed; scrub them when this compaction excludes thinking so the
|
||||
// re-rendered archive stops replaying reasoning (issue #6093).
|
||||
const previousText =
|
||||
options?.includeThinking === false && previousTextRaw.length > 0
|
||||
? stripThinkingSections(previousTextRaw)
|
||||
: previousTextRaw;
|
||||
const hasPreviousText = previousText.length > 0;
|
||||
const includedPreviousSummary = !hasPreviousText && !!previousSummary;
|
||||
const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
|
||||
|
||||
@@ -717,6 +717,21 @@ describe("serializeConversation", () => {
|
||||
expect(out).toBe("¶think:weigh options\n\n¶ai:the answer");
|
||||
});
|
||||
|
||||
it("drops ¶think reasoning sections when includeThinking is false but keeps the reply", () => {
|
||||
const out = snapcompact.serializeConversation(
|
||||
[
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "private chain of thought" },
|
||||
{ type: "text", text: "the answer" },
|
||||
]),
|
||||
],
|
||||
{ includeThinking: false },
|
||||
);
|
||||
expect(out).not.toContain("¶think:");
|
||||
expect(out).not.toContain("private chain of thought");
|
||||
expect(out).toBe("¶ai:the answer");
|
||||
});
|
||||
|
||||
it("gives a thinking-only turn its own heading before the tool calls", () => {
|
||||
const out = snapcompact.serializeConversation([
|
||||
createAssistantMessage([
|
||||
@@ -1050,6 +1065,57 @@ describe("compact", () => {
|
||||
expect(second.preserveData?.openaiRemoteCompaction).toBeUndefined();
|
||||
expect(second.preserveData?.appKey).toBe("kept");
|
||||
});
|
||||
|
||||
it("scrubs legacy ¶think: sections from the prior archive when thinking is excluded", async () => {
|
||||
const first = await snapcompact.compact(
|
||||
makePreparation({
|
||||
messagesToSummarize: [
|
||||
createUserMessage("Investigate the flaky auth test."),
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "legacy private chain of thought" },
|
||||
{ type: "text", text: "The token clock is skewed." },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
{ frameSize: TEST_FRAME_SIZE },
|
||||
);
|
||||
expect(snapcompact.getPreservedArchive(first.preserveData)?.text ?? "").toContain("¶think:");
|
||||
|
||||
const second = await snapcompact.compact(
|
||||
makePreparation({
|
||||
messagesToSummarize: [createUserMessage("Continue after switching to Claude.")],
|
||||
previousPreserveData: first.preserveData,
|
||||
}),
|
||||
{ frameSize: TEST_FRAME_SIZE, includeThinking: false },
|
||||
);
|
||||
const archiveText = snapcompact.getPreservedArchive(second.preserveData)?.text ?? "";
|
||||
expect(archiveText).not.toContain("¶think:");
|
||||
expect(archiveText).not.toContain("legacy private chain of thought");
|
||||
expect(archiveText).toContain("Investigate the flaky auth test.");
|
||||
expect(archiveText).toContain("The token clock is skewed.");
|
||||
});
|
||||
|
||||
it("keeps legacy ¶think: sections when thinking stays included", async () => {
|
||||
const first = await snapcompact.compact(
|
||||
makePreparation({
|
||||
messagesToSummarize: [
|
||||
createAssistantMessage([
|
||||
{ type: "thinking", thinking: "legacy private chain of thought" },
|
||||
{ type: "text", text: "Visible reply." },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
{ frameSize: TEST_FRAME_SIZE },
|
||||
);
|
||||
const second = await snapcompact.compact(
|
||||
makePreparation({
|
||||
messagesToSummarize: [createUserMessage("Another turn.")],
|
||||
previousPreserveData: first.preserveData,
|
||||
}),
|
||||
{ frameSize: TEST_FRAME_SIZE },
|
||||
);
|
||||
expect(snapcompact.getPreservedArchive(second.preserveData)?.text ?? "").toContain("¶think:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive helpers", () => {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed malformed persisted content blocks aborting stats ingestion before later projects and settled pending full-session migrations after successful backfills ([#6373](https://github.com/can1357/oh-my-pi/issues/6373)).
|
||||
|
||||
## [17.0.6] - 2026-07-20
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
insertMessageStats,
|
||||
insertToolCalls,
|
||||
insertUserMessageStats,
|
||||
markSessionBackfillsComplete,
|
||||
setFileOffset,
|
||||
updateToolResults,
|
||||
updateUserMessageLinks,
|
||||
@@ -209,12 +210,15 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
|
||||
await initDb();
|
||||
|
||||
const files = await listAllSessionFiles();
|
||||
if (files.length === 0) return { processed: 0, files: 0 };
|
||||
|
||||
let totalProcessed = 0;
|
||||
let filesProcessed = 0;
|
||||
let completed = 0;
|
||||
let cursor = 0;
|
||||
const finish = () => {
|
||||
markSessionBackfillsComplete();
|
||||
return { processed: totalProcessed, files: filesProcessed };
|
||||
};
|
||||
if (files.length === 0) return finish();
|
||||
|
||||
const report = (sessionFile: string) => {
|
||||
completed++;
|
||||
@@ -259,7 +263,7 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
|
||||
for (const sessionFile of files) {
|
||||
await processFile(sessionFile, parseSessionFile);
|
||||
}
|
||||
return { processed: totalProcessed, files: filesProcessed };
|
||||
return finish();
|
||||
}
|
||||
|
||||
const poolSize = Math.min(files.length, requestedWorkers);
|
||||
@@ -282,7 +286,7 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
|
||||
for (const handle of handles) handle.worker.terminate();
|
||||
}
|
||||
|
||||
return { processed: totalProcessed, files: filesProcessed };
|
||||
return finish();
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
+16
-21
@@ -1079,28 +1079,23 @@ function backfillPriorityPremiumRequests(database: Database): void {
|
||||
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
|
||||
}
|
||||
|
||||
export function markPriorityPremiumRequestsBackfillComplete(): void {
|
||||
/**
|
||||
* Settle every full-session backfill after a successful sync pass.
|
||||
*/
|
||||
export function markSessionBackfillsComplete(): void {
|
||||
if (!db) return;
|
||||
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
|
||||
PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
|
||||
BACKFILL_COMPLETE,
|
||||
);
|
||||
}
|
||||
|
||||
export function markUserMessagesBackfillComplete(): void {
|
||||
if (!db) return;
|
||||
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
|
||||
USER_MESSAGES_BACKFILL_KEY,
|
||||
BACKFILL_COMPLETE,
|
||||
);
|
||||
}
|
||||
|
||||
export function markUserMessageLinksRepairComplete(): void {
|
||||
if (!db) return;
|
||||
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
|
||||
USER_MESSAGE_LINKS_REPAIR_KEY,
|
||||
BACKFILL_COMPLETE,
|
||||
);
|
||||
const markComplete = db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
||||
const apply = db.transaction(() => {
|
||||
for (const key of [
|
||||
USER_MESSAGES_BACKFILL_KEY,
|
||||
TOOL_CALLS_BACKFILL_KEY,
|
||||
USER_MESSAGE_LINKS_REPAIR_KEY,
|
||||
PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
|
||||
]) {
|
||||
markComplete.run(key, BACKFILL_COMPLETE);
|
||||
}
|
||||
});
|
||||
apply();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -240,7 +240,11 @@ function extractToolCalls(
|
||||
|
||||
const blocks = msg.content.filter(
|
||||
(block): block is ToolCall =>
|
||||
block.type === "toolCall" && typeof block.id === "string" && typeof block.name === "string",
|
||||
block !== null &&
|
||||
typeof block === "object" &&
|
||||
block.type === "toolCall" &&
|
||||
typeof block.id === "string" &&
|
||||
typeof block.name === "string",
|
||||
);
|
||||
if (blocks.length === 0) return [];
|
||||
|
||||
@@ -277,7 +281,9 @@ function extractToolResultLink(sessionFile: string, entry: SessionMessageEntry):
|
||||
let resultChars = 0;
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text" && typeof block.text === "string") resultChars += block.text.length;
|
||||
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
||||
resultChars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -95,4 +95,25 @@ describe("behavior backfill", () => {
|
||||
expect(getBehaviorOverall(null).totalMessages).toBe(1);
|
||||
expect(getFileOffset(sessionFile)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks full-session backfills complete after a successful sync", async () => {
|
||||
await writeSessionFile();
|
||||
await syncAllSessions({ workers: 1 });
|
||||
closeDb();
|
||||
|
||||
const database = new Database(getStatsDbPath(), { readonly: true });
|
||||
const rows = database
|
||||
.query(
|
||||
"SELECT key, value FROM meta WHERE key IN ('user_messages_v8', 'tool_calls_v1', 'user_message_links_v1', 'premium_requests_priority_v1') ORDER BY key",
|
||||
)
|
||||
.all() as { key: string; value: string }[];
|
||||
database.close();
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ key: "premium_requests_priority_v1", value: "complete" },
|
||||
{ key: "tool_calls_v1", value: "complete" },
|
||||
{ key: "user_message_links_v1", value: "complete" },
|
||||
{ key: "user_messages_v8", value: "complete" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,21 @@ describe("malformed session entries", () => {
|
||||
expect(result.stats.map(s => s.entryId)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("ignores malformed content blocks without aborting later entries", async () => {
|
||||
const file = await writeSession([
|
||||
assistantEntry("a1", {
|
||||
content: [null, { type: "toolCall", id: "call-1", name: "bash", arguments: {} }],
|
||||
usage: USAGE,
|
||||
timestamp: 1752000000000,
|
||||
}),
|
||||
assistantEntry("a2", { content: [], usage: USAGE, timestamp: 1752000001000 }),
|
||||
]);
|
||||
|
||||
const result = await parseSessionFile(file);
|
||||
expect(result.stats.map(s => s.entryId)).toEqual(["a1", "a2"]);
|
||||
expect(result.toolCalls.map(c => c.toolCallId)).toEqual(["call-1"]);
|
||||
});
|
||||
|
||||
it("keeps tool_calls insertable when the turn lacks a message timestamp", async () => {
|
||||
const file = await writeSession([
|
||||
assistantEntry("a1", {
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `Text.setStyleFn()` applies a foreground styler at render time, so a component re-resolves its color after `invalidate()` instead of baking the palette that was active when it was constructed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed teardown leaving the terminal in cursor-key/keypad application mode (DECCKM), which broke arrow keys in the parent shell after exit; `stop()` and `emergencyTerminalRestore()` now emit the standard `rmkx` resets ([#6374](https://github.com/can1357/oh-my-pi/issues/6374)).
|
||||
|
||||
## [17.0.9] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,13 +11,20 @@ import {
|
||||
} from "../utils";
|
||||
|
||||
/**
|
||||
* Text component - displays multi-line text with word wrapping
|
||||
* Text component - displays multi-line text with word wrapping.
|
||||
*
|
||||
* Foreground colors may be supplied lazily via {@link setStyleFn} instead of
|
||||
* baked into `text`: the styler runs at render time, so a caller that
|
||||
* invalidates the component on a theme change (see the coding-agent's
|
||||
* `onThemeChange` handler) re-resolves the color against the now-active theme
|
||||
* rather than replaying the palette active when the component was constructed.
|
||||
*/
|
||||
export class Text implements Component {
|
||||
#text: string;
|
||||
#paddingX: number; // Left/right padding
|
||||
#paddingY: number; // Top/bottom padding
|
||||
#customBgFn?: (text: string) => string;
|
||||
#styleFn?: (text: string) => string;
|
||||
|
||||
#ignoreTight = false;
|
||||
|
||||
@@ -64,6 +71,21 @@ export class Text implements Component {
|
||||
this.#cachedLines = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supply a foreground styler applied to the text at render time (e.g. a
|
||||
* theme color resolver). Unlike baking the color into `text`, the styler
|
||||
* re-runs on every render, so invalidating the component after a theme
|
||||
* change re-resolves the color against the active theme.
|
||||
*/
|
||||
setStyleFn(styleFn?: (text: string) => string): this {
|
||||
this.#styleFn = styleFn;
|
||||
this.#cachedText = undefined;
|
||||
this.#cachedWidth = undefined;
|
||||
this.#cachedWidthEpoch = undefined;
|
||||
this.#cachedLines = undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.#cachedText = undefined;
|
||||
this.#cachedWidth = undefined;
|
||||
@@ -93,7 +115,7 @@ export class Text implements Component {
|
||||
}
|
||||
|
||||
// Replace tabs with 3 spaces
|
||||
const normalizedText = replaceTabs(this.#text);
|
||||
const normalizedText = replaceTabs(this.#styleFn ? this.#styleFn(this.#text) : this.#text);
|
||||
|
||||
// Calculate content width (subtract left/right margins)
|
||||
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
||||
|
||||
@@ -312,6 +312,7 @@ export function emergencyTerminalRestore(): void {
|
||||
process.stdout.write(
|
||||
"\x1b[?2026l" + // End synchronized output
|
||||
"\x1b[?7h" + // Restore autowrap
|
||||
"\x1b[?1l\x1b>" + // Restore normal cursor-key + keypad mode (rmkx, #6374)
|
||||
"\x1b[?2004l" + // Disable bracketed paste
|
||||
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
||||
"\x1b[?2048l" + // Disable in-band resize notifications
|
||||
@@ -625,6 +626,15 @@ export class ProcessTerminal implements Terminal {
|
||||
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
||||
this.#safeWrite("\x1b[?2004h");
|
||||
|
||||
// Force normal cursor-key (DECCKM) and numeric-keypad mode (terminfo
|
||||
// `rmkx` = "\x1b[?1l\x1b>"). omp decodes both CSI ("\x1b[A") and SS3
|
||||
// ("\x1bOA") arrow encodings, so it never enables application mode
|
||||
// itself — but a prior program that left the TTY in application-cursor-
|
||||
// keys mode makes arrows arrive as SS3. Normalizing on entry keeps input
|
||||
// in the predictable default state; stop() restores the same on exit.
|
||||
// See #6374.
|
||||
this.#safeWrite("\x1b[?1l\x1b>");
|
||||
|
||||
// Set up resize handler immediately. The OS refreshes process.stdout
|
||||
// dimensions before firing `resize`, so it is authoritative for geometry:
|
||||
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
||||
@@ -1376,6 +1386,13 @@ export class ProcessTerminal implements Terminal {
|
||||
// begin/end halves of a frame. Safe no-ops on terminals that ignored them.
|
||||
this.#safeWrite("\x1b[?2026l\x1b[?7h");
|
||||
|
||||
// Restore normal cursor-key (DECCKM) and numeric-keypad mode (terminfo
|
||||
// `rmkx`). Symmetric with the normalize in start(): a TTY-sharing child
|
||||
// can leave the terminal in application-cursor-keys mode, and without
|
||||
// this reset the parent shell inherits SS3 arrows so Up/Down history
|
||||
// navigation stays broken after omp exits (#6374).
|
||||
this.#safeWrite("\x1b[?1l\x1b>");
|
||||
|
||||
// Disable bracketed paste mode
|
||||
this.#safeWrite("\x1b[?2004l");
|
||||
this.#safeWrite("\x1b[?5522l");
|
||||
|
||||
@@ -93,4 +93,30 @@ describe("ProcessTerminal headless suppression", () => {
|
||||
setTerminalHeadless(previous);
|
||||
}
|
||||
});
|
||||
|
||||
// #6374: arrows stopped working inside omp and stayed broken in the shell
|
||||
// after exit — a missing cursor-key/keypad reset. omp owns the TTY and emits
|
||||
// a full private-mode reset menu, but never restored normal cursor-key
|
||||
// (DECCKM) / numeric-keypad mode (terminfo `rmkx` = "\x1b[?1l\x1b>"). If the
|
||||
// terminal was left in application-cursor-keys mode, arrows arrived as SS3
|
||||
// and the parent shell's Up/Down history navigation broke. start() must
|
||||
// normalize the state and stop() must restore it.
|
||||
it("emits rmkx on start and stop to normalize/restore cursor-key + keypad mode (#6374)", () => {
|
||||
const previous = setTerminalHeadless(false);
|
||||
const terminal = new ProcessTerminal();
|
||||
try {
|
||||
terminal.start(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
expect(writes.join("")).toContain("\x1b[?1l\x1b>");
|
||||
|
||||
writes.length = 0;
|
||||
terminal.stop();
|
||||
expect(writes.join("")).toContain("\x1b[?1l\x1b>");
|
||||
} finally {
|
||||
terminal.stop();
|
||||
setTerminalHeadless(previous);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,4 +9,28 @@ describe("Text component", () => {
|
||||
expect(text.setText("b")).toBe(true);
|
||||
expect(text.getText()).toBe("b");
|
||||
});
|
||||
|
||||
it("applies the style fn at render time, not construction time", () => {
|
||||
const text = new Text("hello", 0, 0).setStyleFn(t => `<c>${t}</c>`);
|
||||
expect(text.render(40).join("\n")).toContain("<c>hello</c>");
|
||||
});
|
||||
|
||||
it("re-resolves the style fn after invalidate so a theme change re-shapes", () => {
|
||||
// The styler reads a mutable `color`, standing in for the active theme.
|
||||
// The coding-agent invalidates status components on `onThemeChange`, so a
|
||||
// lazily-styled Text must pick up the new color on the next render —
|
||||
// something a baked ANSI string can never do (issue #6337).
|
||||
let color = "RED";
|
||||
const text = new Text("hello", 0, 0).setStyleFn(t => `[${color}]${t}`);
|
||||
expect(text.render(40).join("\n")).toContain("[RED]hello");
|
||||
|
||||
// Without invalidation the cached render is returned unchanged.
|
||||
color = "BLUE";
|
||||
expect(text.render(40).join("\n")).toContain("[RED]hello");
|
||||
|
||||
text.invalidate();
|
||||
const out = text.render(40).join("\n");
|
||||
expect(out).toContain("[BLUE]hello");
|
||||
expect(out).not.toContain("[RED]hello");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,10 +300,13 @@ def route(
|
||||
if not isinstance(number, int):
|
||||
return RouteDecision("skip", None, repo, None, "issue missing number")
|
||||
key = issue_key(repo, number)
|
||||
if action == "opened":
|
||||
if action in ("opened", "reopened"):
|
||||
# A reopen is submitter-attributable exactly like an open, and
|
||||
# `finalized_issue_comment.md` promises re-triage on reopen, so it
|
||||
# re-triages from scratch and spends the same per-user rate budget.
|
||||
login, assoc = _submitter_info(issue)
|
||||
return RouteDecision(
|
||||
"queue", "triage_issue", repo, key, "issues.opened", submitter=login, association=assoc
|
||||
"queue", "triage_issue", repo, key, f"issues.{action}", submitter=login, association=assoc
|
||||
)
|
||||
if action == "closed":
|
||||
# Cleanup is a lifecycle event, not a user submission; no rate-limit subject.
|
||||
|
||||
@@ -382,7 +382,7 @@ class WorkerPool:
|
||||
"recovered": row.attempts >= 2,
|
||||
},
|
||||
)
|
||||
if event == "issues" and action == "opened":
|
||||
if event == "issues" and action in ("opened", "reopened"):
|
||||
await tasks.triage_issue(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
|
||||
@@ -282,7 +282,8 @@ async def triage_issue(
|
||||
log.info("skip: triage on PR-like issue", extra={"repo": repo.full_name, "n": issue.number})
|
||||
return
|
||||
key = issue_key(repo.full_name, issue.number)
|
||||
if db.get_issue(key) is None:
|
||||
existing = db.get_issue(key)
|
||||
if existing is None:
|
||||
# First-time triage: bail if a PR (human or another bot) already
|
||||
# claims to close this issue via Closes/Fixes/Resolves syntax or
|
||||
# the Development panel. We never replay closing-PR detection on
|
||||
@@ -304,6 +305,12 @@ async def triage_issue(
|
||||
extra={"key": key, "prs": list(closing_prs)},
|
||||
)
|
||||
return
|
||||
elif existing.state in ("merged", "closed", "abandoned"):
|
||||
# Reopen of a finalized issue (issues.reopened): the prior branch is
|
||||
# stale (merged/deleted), so tear the workspace down and branch afresh
|
||||
# from default — the same teardown the maintainer directive-reopen uses.
|
||||
log.info("reopen re-triage", extra={"key": key, "from_state": existing.state})
|
||||
await _run_workspace_op(sandbox.remove_workspace, repo=repo.full_name, number=issue.number)
|
||||
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
|
||||
clone_url = repo.clone_url
|
||||
workspace = await _run_workspace_op(
|
||||
|
||||
@@ -53,6 +53,27 @@ def test_route_issue_opened_queues_triage() -> None:
|
||||
assert decision.issue_key == "octo/widget#4"
|
||||
|
||||
|
||||
def test_route_issue_reopened_queues_triage() -> None:
|
||||
# `finalized_issue_comment.md` promises re-triage on reopen; the router must
|
||||
# queue it as a submitter-attributable triage (not drop it to the skip branch).
|
||||
decision = route(
|
||||
"issues",
|
||||
{
|
||||
"action": "reopened",
|
||||
"issue": {"number": 4, "user": {"login": "alice"}, "author_association": "CONTRIBUTOR"},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "triage_issue"
|
||||
assert decision.issue_key == "octo/widget#4"
|
||||
assert decision.reason == "issues.reopened"
|
||||
assert decision.submitter == "alice"
|
||||
assert decision.association == "CONTRIBUTOR"
|
||||
|
||||
|
||||
def test_route_skips_disallowed_repo() -> None:
|
||||
decision = route(
|
||||
"issues",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user