Support rewrite runtime overrides and debug output

This commit is contained in:
murashit 2026-05-18 11:57:43 +09:00
parent 2dd6043340
commit 2c2730926a
6 changed files with 132 additions and 9 deletions

View file

@ -1,12 +1,12 @@
import { MarkdownView, Notice, type Editor, type Plugin } from "obsidian";
import { RewriteSelectionModal } from "./modal";
import type { RewriteSession } from "./model";
import type { RewriteRuntimeSettings, RewriteSession } from "./model";
export interface RewriteSelectionCommandHost extends Plugin {
settings: {
codexPath: string;
};
} & RewriteRuntimeSettings;
vaultPath: string;
}
@ -38,12 +38,14 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH
status: "editing-prompt",
streamText: "",
replacementText: null,
debugText: null,
};
new RewriteSelectionModal(plugin.app, {
codexPath: plugin.settings.codexPath,
cwd: plugin.vaultPath,
editor,
runtimeSettings: plugin.settings,
session,
}).open();
},

View file

@ -2,7 +2,8 @@ import { Modal, Notice, type App, type Editor } from "obsidian";
import { renderUnifiedDiff } from "../ui/turn-diff";
import { buildSelectionUnifiedDiff } from "./diff";
import { canApplyRewrite, type RewriteSession } from "./model";
import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model";
import { RewriteOutputError } from "./output";
import { buildRewritePrompt } from "./prompt";
import { runRewriteSelection } from "./runner";
@ -10,6 +11,7 @@ export interface RewriteSelectionModalOptions {
codexPath: string;
cwd: string;
editor: Editor;
runtimeSettings: RewriteRuntimeSettings;
session: RewriteSession;
}
@ -84,6 +86,7 @@ export class RewriteSelectionModal extends Modal {
this.options.session.status = "generating";
this.options.session.streamText = "";
this.options.session.replacementText = null;
this.options.session.debugText = null;
this.previewEl?.setText("");
this.diffEl?.empty();
this.setStatus("Generating patch...");
@ -94,6 +97,7 @@ export class RewriteSelectionModal extends Modal {
codexPath: this.options.codexPath,
cwd: this.options.cwd,
prompt: buildRewritePrompt(this.options.session),
runtimeSettings: this.options.runtimeSettings,
onPreview: (text) => this.updatePreview(text),
});
this.options.session.replacementText = output.replacementText;
@ -102,6 +106,7 @@ export class RewriteSelectionModal extends Modal {
this.setStatus("Review the patch before applying it.");
} catch (error) {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null;
this.setStatus(error instanceof Error ? error.message : String(error));
} finally {
this.syncControls();

View file

@ -1,4 +1,5 @@
import type { EditorPosition } from "obsidian";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
export type RewriteStatus = "editing-prompt" | "generating" | "preview" | "applied" | "cancelled" | "failed";
@ -14,6 +15,12 @@ export interface RewriteSession {
status: RewriteStatus;
streamText: string;
replacementText: string | null;
debugText: string | null;
}
export interface RewriteRuntimeSettings {
rewriteSelectionModel: string | null;
rewriteSelectionEffort: ReasoningEffort | null;
}
export function canApplyRewrite(currentText: string, originalText: string): boolean {

View file

@ -4,6 +4,21 @@ export interface RewriteOutput {
replacementText: string;
}
export interface RewriteOutputParseResult {
output: RewriteOutput | null;
rawText: string | null;
}
export class RewriteOutputError extends Error {
constructor(
message: string,
readonly rawText: string | null,
) {
super(message);
this.name = "RewriteOutputError";
}
}
export function parseRewriteOutput(text: string): RewriteOutput | null {
try {
const parsed = JSON.parse(text.trim()) as unknown;
@ -17,8 +32,13 @@ export function parseRewriteOutput(text: string): RewriteOutput | null {
}
export function rewriteOutputFromTurn(turn: Turn): RewriteOutput | null {
return rewriteOutputParseResultFromTurn(turn).output;
}
export function rewriteOutputParseResultFromTurn(turn: Turn): RewriteOutputParseResult {
const text = lastAgentMessageText(turn);
return text ? parseRewriteOutput(text) : null;
if (!text) return { output: null, rawText: null };
return { output: parseRewriteOutput(text), rawText: text };
}
function lastAgentMessageText(turn: Turn): string | null {

View file

@ -1,10 +1,14 @@
import { AppServerClient } from "../app-server/client";
import type { ServerNotification } from "../generated/app-server/ServerNotification";
import type { JsonValue } from "../generated/app-server/serde_json/JsonValue";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { Model } from "../generated/app-server/v2/Model";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { runtimeOverride, validatedRuntimeOverride } from "../runtime/model";
import type { RewriteRuntimeSettings } from "./model";
import { REWRITE_DEVELOPER_INSTRUCTIONS, REWRITE_SERVICE_NAME } from "./prompt";
import { rewriteOutputFromTurn, type RewriteOutput } from "./output";
import { RewriteOutputError, rewriteOutputParseResultFromTurn, type RewriteOutput } from "./output";
const REWRITE_TIMEOUT_MS = 120_000;
@ -23,6 +27,7 @@ export interface RunRewriteSelectionOptions {
codexPath: string;
cwd: string;
prompt: string;
runtimeSettings?: RewriteRuntimeSettings;
onPreview?: (text: string) => void;
}
@ -82,13 +87,21 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
try {
await client.connect();
const runtime = options.runtimeSettings ? await rewriteRuntimeForClient(client, options.runtimeSettings) : {};
const threadResponse = await client.startEphemeralThread(options.cwd, REWRITE_SERVICE_NAME, REWRITE_DEVELOPER_INSTRUCTIONS);
threadId = threadResponse.thread.id;
const turnResponse = await client.startStructuredTurn(threadId, options.cwd, options.prompt, REWRITE_OUTPUT_SCHEMA);
const turnResponse = await client.startStructuredTurn(
threadId,
options.cwd,
options.prompt,
REWRITE_OUTPUT_SCHEMA,
runtime.model,
runtime.effort,
);
expectedTurnId = turnResponse.turn.id;
const turn = turnResponse.turn.status === "completed" ? turnWithCollectedItems(turnResponse.turn, completedItems) : await completedTurn;
const output = rewriteOutputFromTurn(turn);
if (!output) throw new Error("Codex did not return a valid rewrite patch.");
const { output, rawText } = rewriteOutputParseResultFromTurn(turn);
if (!output) throw new RewriteOutputError("Codex did not return a valid rewrite patch.", rawText);
return output;
} finally {
completed = true;
@ -101,3 +114,27 @@ function turnWithCollectedItems(turn: Turn, completedItems: ThreadItem[]): Turn
if (turn.items.length > 0 || completedItems.length === 0) return turn;
return { ...turn, items: completedItems };
}
export interface RewriteRuntime {
model?: string;
effort?: ReasoningEffort;
}
export function rewriteRuntime(settings: RewriteRuntimeSettings): RewriteRuntime {
return runtimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort });
}
export function validatedRewriteRuntime(settings: RewriteRuntimeSettings, models: Model[]): RewriteRuntime {
return validatedRuntimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort }, models);
}
async function rewriteRuntimeForClient(client: AppServerClient, settings: RewriteRuntimeSettings): Promise<RewriteRuntime> {
const runtime = rewriteRuntime(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
const response = await client.listModels(false);
return validatedRewriteRuntime(settings, response.data);
} catch {
return runtime;
}
}

View file

@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import { buildSelectionUnifiedDiff } from "../../src/editor-rewrite/diff";
import { canApplyRewrite, type RewriteSession } from "../../src/editor-rewrite/model";
import { parseRewriteOutput, rewriteOutputFromTurn } from "../../src/editor-rewrite/output";
import { parseRewriteOutput, rewriteOutputFromTurn, rewriteOutputParseResultFromTurn } from "../../src/editor-rewrite/output";
import { buildRewritePrompt } from "../../src/editor-rewrite/prompt";
import { rewriteRuntime, validatedRewriteRuntime } from "../../src/editor-rewrite/runner";
import type { Model } from "../../src/generated/app-server/v2/Model";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
describe("editor rewrite output", () => {
@ -27,6 +29,14 @@ describe("editor rewrite output", () => {
),
).toEqual({ replacementText: "final" });
});
it("keeps raw model text when rewrite output parsing fails", () => {
expect(
rewriteOutputParseResultFromTurn(
turn([{ type: "agentMessage", id: "a1", text: "replacementText: final", phase: "final_answer", memoryCitation: null }]),
),
).toEqual({ output: null, rawText: "replacementText: final" });
});
});
describe("editor rewrite prompt", () => {
@ -67,6 +77,27 @@ describe("editor rewrite apply guard", () => {
});
});
describe("editor rewrite runtime", () => {
it("uses explicit rewrite runtime settings", () => {
expect(rewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" })).toEqual({
model: "gpt-5.4-mini",
effort: "minimal",
});
});
it("omits rewrite runtime overrides that are set to Codex default", () => {
expect(rewriteRuntime({ rewriteSelectionModel: null, rewriteSelectionEffort: null })).toEqual({});
});
it("omits an explicit rewrite effort when the selected model does not support it", () => {
expect(
validatedRewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" }, [
model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]),
]),
).toEqual({ model: "gpt-5.4-mini" });
});
});
function session(overrides: Partial<RewriteSession> = {}): RewriteSession {
return {
filePath: "Note.md",
@ -80,6 +111,7 @@ function session(overrides: Partial<RewriteSession> = {}): RewriteSession {
status: "editing-prompt",
streamText: "",
replacementText: null,
debugText: null,
...overrides,
};
}
@ -97,3 +129,23 @@ function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
...overrides,
};
}
function model(name: string, efforts: Model["supportedReasoningEfforts"][number]["reasoningEffort"][]): Model {
return {
id: name,
model: name,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: name,
description: "",
hidden: false,
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: "" })),
defaultReasoningEffort: efforts[0] ?? "low",
inputModalities: ["text"],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
isDefault: false,
};
}