Merge branch 'main' into feat/no-webp-llama-cpp

This commit is contained in:
Can Bölük
2026-05-25 13:00:46 +03:00
committed by GitHub
30 changed files with 1372 additions and 83 deletions
+3
View File
@@ -5,6 +5,9 @@
### Added
- Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support)
- Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294))
- Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
- Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session
### Fixed
- Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
@@ -2612,6 +2612,22 @@ export const SETTINGS_SCHEMA = {
description: "Use Parallel extract API for URL fetching when credentials are available",
},
},
"provider.appendOnlyContext": {
type: "enum",
values: ["auto", "on", "off"] as const,
default: "auto",
ui: {
tab: "providers",
label: "Append-Only Context",
description:
"Cache system prompt + tool specs and keep an append-only message log so provider prefix caches (DeepSeek, Anthropic) hit at maximum rate. Auto enables for DeepSeek.",
options: [
{ value: "auto", label: "Auto", description: "Enable for DeepSeek (recommended)" },
{ value: "on", label: "On", description: "Always enable append-only context" },
{ value: "off", label: "Off", description: "Disable append-only context" },
],
},
},
// Exa
"exa.enabled": {
@@ -856,7 +856,26 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
setDefaultTabWidth(value);
}
},
"provider.appendOnlyContext": value => {
if (typeof value === "string") {
for (const cb of appendOnlyModeCallbacks) cb(value);
}
},
};
/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
const appendOnlyModeCallbacks = new Set<(value: string) => void>();
/**
* Subscribe to append-only mode setting changes.
* Returns an unsubscribe function. Multiple sessions (main + subagents)
* can register independently without overwriting each other.
*/
export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void {
appendOnlyModeCallbacks.add(cb);
return () => {
appendOnlyModeCallbacks.delete(cb);
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Global Singleton
@@ -395,6 +395,15 @@ export class CommandController {
info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
// Append-only context
{
const setting = this.ctx.settings.get("provider.appendOnlyContext") ?? "auto";
const provider = this.ctx.session.model?.provider;
const mode = setting === "on" ? true : setting === "off" ? false : provider === "deepseek";
const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive");
const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting;
info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`;
}
info += `${theme.bold("Tokens")}\n`;
info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
@@ -691,7 +691,7 @@ export class InteractiveMode implements InteractiveModeContext {
}
#isLoopAutoSubmitBlocked(): boolean {
return this.session.isStreaming || this.session.isCompacting;
return this.session.isStreaming || this.session.isCompacting || this.session.hasPostPromptWork;
}
#submitLoopPromptWhenReady(prompt: string): void {
+24
View File
@@ -4,6 +4,7 @@ import {
type AgentMessage,
type AgentTelemetryConfig,
type AgentTool,
AppendOnlyContextManager,
INTENT_FIELD,
type ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
@@ -589,6 +590,24 @@ function registerPythonCleanup(): void {
postmortem.register("python-cleanup", disposeAllKernelSessions);
}
/**
* Resolve whether to enable append-only context mode based on the setting and provider.
*
* - `"on"` → always enable
* - `"off"` → never enable
* - `"auto"` → enable for DeepSeek (prefix-caching provider)
*/
function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean {
switch (setting ?? "auto") {
case "on":
return true;
case "off":
return false;
default:
return provider === "deepseek";
}
}
function customToolToDefinition(tool: CustomTool): ToolDefinition {
const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
name: tool.name,
@@ -1897,6 +1916,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
intentTracing: !!intentField,
getToolChoice: () => session?.nextToolChoice(),
telemetry: options.telemetry,
appendOnlyContext: model
? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider)
? new AppendOnlyContextManager()
: undefined
: undefined,
});
cursorEventEmitter = event => agent.emitExternalEvent(event);
@@ -26,6 +26,7 @@ import {
type AgentMessage,
type AgentState,
type AgentTool,
AppendOnlyContextManager,
resolveTelemetry,
ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
@@ -98,6 +99,7 @@ import {
} from "../config/model-resolver";
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
import { onAppendOnlyModeChanged } from "../config/settings";
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
import { loadCapability } from "../discovery";
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -1138,6 +1140,8 @@ export class AgentSession {
// Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic)
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
// Re-evaluate append-only context mode when the setting changes at runtime.
onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
}
/** Model registry for API key resolution and model discovery */
@@ -3573,6 +3577,18 @@ export class AgentSession {
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
}
/**
* Whether idle-flush tasks, auto-continuations, or other short-lived
* post-prompt work are pending. True in the brief window after
* `session.prompt()` returns but before a scheduled background delivery
* (e.g. an async-job result) has finished its own streaming turn.
* Loop-mode and similar auto-submit paths should treat this as a block
* to avoid racing against the delivery turn.
*/
get hasPostPromptWork(): boolean {
return this.#postPromptTasks.size > 0;
}
/** All messages including custom types like BashExecutionMessage */
get messages(): AgentMessage[] {
return this.agent.state.messages;
@@ -5947,6 +5963,9 @@ export class AgentSession {
this.#closeProviderSessionsForModelSwitch(currentModel, model);
}
this.agent.setModel(model);
// Re-evaluate append-only context mode — provider or setting may have changed
this.#syncAppendOnlyContext(model);
}
#closeCodexProviderSessionsForHistoryRewrite(): void {
@@ -5955,6 +5974,24 @@ export class AgentSession {
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
}
/**
* Re-evaluate append-only context mode, creating or destroying the
* manager as needed. Called on model switch AND setting change.
*/
#syncAppendOnlyContext(model: Model | null | undefined): void {
const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
const enable = setting === "on" || (setting === "auto" && model?.provider === "deepseek");
if (enable && !this.agent.appendOnlyContext) {
this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
} else if (enable && this.agent.appendOnlyContext) {
// Already active — invalidate prefix + log so the next turn
// rebuilds for the current model's normalization.
this.agent.appendOnlyContext.invalidateForModelChange();
} else if (!enable && this.agent.appendOnlyContext) {
this.agent.setAppendOnlyContext(undefined);
}
}
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
const providerKeys = new Set<string>();
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
@@ -18,6 +18,7 @@ import {
getProjectDir,
getSessionsDir,
getTerminalSessionsDir,
hasFsCode,
isEnoent,
logger,
parseJsonlLenient,
@@ -2146,7 +2147,59 @@ export class SessionManager {
{ ignoreError: true },
);
}
// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
// Move the old session file aside first so a failed retry can roll back to the last good file.
async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
const dir = path.resolve(targetPath, "..");
const backupPath = path.join(dir, `.${path.basename(targetPath)}.${Snowflake.next()}.bak`);
try {
await this.storage.rename(targetPath, backupPath);
} catch (err) {
if (isEnoent(err)) {
await this.storage.rename(tempPath, targetPath);
return;
}
throw toError(renameError);
}
try {
await this.storage.rename(tempPath, targetPath);
} catch (err) {
const replaceError = toError(err);
try {
await this.storage.rename(backupPath, targetPath);
} catch (rollbackErr) {
const rollbackError = toError(rollbackErr);
throw new Error(
`Failed to replace session file after EPERM (${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
{ cause: replaceError },
);
}
throw replaceError;
}
try {
await this.storage.unlink(backupPath);
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to remove session rewrite backup", {
sessionFile: targetPath,
backupPath,
error: toError(err).message,
});
}
}
}
async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
try {
await this.storage.rename(tempPath, targetPath);
} catch (err) {
if (!hasFsCode(err, "EPERM")) throw toError(err);
await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
}
}
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
if (!this.#sessionFile) return;
const dir = path.resolve(this.#sessionFile, "..");
@@ -2159,7 +2212,7 @@ export class SessionManager {
await writer.flush();
await writer.fsync();
await writer.close();
await this.storage.rename(tempPath, this.#sessionFile);
await this.#replaceSessionFile(tempPath, this.#sessionFile);
} catch (err) {
try {
await writer.close();
@@ -72,7 +72,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
inlineHint: "[prompt]",
allowArgs: true,
handleTui: async (command, runtime) => {
const hadArgs = !!command.args;
await runtime.ctx.handlePlanModeCommand(command.args || undefined);
if (hadArgs && runtime.ctx.planModeEnabled) {
// plan was already active — preserve the typed command in input history
runtime.ctx.editor.addToHistory(command.text);
}
runtime.ctx.editor.setText("");
},
},
@@ -90,7 +95,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
inlineHint: "[objective]",
allowArgs: true,
handleTui: async (command, runtime) => {
const hadArgs = !!command.args;
await runtime.ctx.handleGoalModeCommand(command.args || undefined);
if (hadArgs && runtime.ctx.goalModeEnabled) {
// goal was already active — preserve the typed command in input history
runtime.ctx.editor.addToHistory(command.text);
}
runtime.ctx.editor.setText("");
},
},
@@ -111,4 +111,30 @@ describe("InteractiveMode loop auto-submit", () => {
expect(resolved).toHaveLength(1);
expect(resolved[0].text).toBe("repeat after compact");
});
it("does not resolve the next loop prompt while post-prompt background work is pending", async () => {
vi.useFakeTimers();
let hasPendingWork = true;
Object.defineProperty(session, "isCompacting", { configurable: true, get: () => false });
Object.defineProperty(session, "isStreaming", { configurable: true, get: () => false });
Object.defineProperty(session, "hasPostPromptWork", { configurable: true, get: () => hasPendingWork });
mode.loopModeEnabled = true;
mode.loopPrompt = "deliver this";
const resolved: SubmittedUserInput[] = [];
void mode.getUserInput().then(input => resolved.push(input));
// Loop timer fires while an idle-flush / delivery turn is still pending.
vi.advanceTimersByTime(800);
await flushMicrotasks();
expect(resolved).toHaveLength(0);
// Background delivery completes; loop may now fire.
hasPendingWork = false;
vi.advanceTimersByTime(800);
await flushMicrotasks();
expect(resolved).toHaveLength(1);
expect(resolved[0].text).toBe("deliver this");
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "bun:test";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { MemorySessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
class FsCodeError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
class RenameEpermOnceStorage extends MemorySessionStorage {
failNextSessionReplace = false;
backupCleanupPath: string | undefined;
rename(source: string, target: string): Promise<void> {
if (
this.failNextSessionReplace &&
source.includes(".tmp") &&
target.endsWith(".jsonl") &&
this.existsSync(target)
) {
this.failNextSessionReplace = false;
return Promise.reject(
new FsCodeError("EPERM", `EPERM: operation not permitted, rename '${source}' -> '${target}'`),
);
}
return super.rename(source, target);
}
unlink(target: string): Promise<void> {
if (target.endsWith(".bak")) {
this.backupCleanupPath = target;
}
return super.unlink(target);
}
}
describe("SessionManager rewrite EPERM replacement fallback", () => {
it("keeps the active session healthy when replacing an existing file hits EPERM", async () => {
const storage = new RenameEpermOnceStorage();
const session = SessionManager.create("/cwd", "/sessions", storage);
await session.ensureOnDisk();
const sessionFile = session.getSessionFile();
if (!sessionFile) throw new Error("Expected session file");
storage.failNextSessionReplace = true;
await expect(session.setSessionName("renamed session", "user")).resolves.toBe(true);
const rewritten = storage.readTextSync(sessionFile);
expect(rewritten).toContain('"title":"renamed session"');
const backupPath = storage.backupCleanupPath;
if (!backupPath) throw new Error("Expected EPERM fallback to create a rollback backup");
expect(storage.existsSync(backupPath)).toBe(false);
session.appendMessage({ role: "user", content: "after rewrite", timestamp: Date.now() });
await expect(session.flush()).resolves.toBeUndefined();
});
});