feat: added one-shot AI request dump path support for debug workflows

- Added explicit request-debug path helpers in `packages/ai/src/utils/request-debug.ts` for one-shot dumps.
- Added `/debug dump-next-request`, `/debug dump-request`, and `/debug next-request` subcommands to arm next-request dumps.
- Changed `debug` handling in `packages/coding-agent/src/slash-commands/builtin-registry.ts` to execute args instead of always opening selector.
- Fixed explicit request-debug mode to resolve `~`/relative paths and create parent directories before logging.
- Fixed one-shot request-debug mode to consume its target after one call and overwrite existing response logs.
This commit is contained in:
can1357
2026-06-12 04:46:53 +02:00
parent f29297da49
commit 99c9072427
6 changed files with 236 additions and 16 deletions
+10 -1
View File
@@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
### Added
- Added a one-shot request-debug target that writes the next provider HTTP request JSON to an explicit path.
### Fixed
- Fixed one-shot request-debug path handling so an explicit request log target is consumed after the next request and no longer affects subsequent calls
- Fixed explicit request-debug path mode to create missing parent directories before writing request logs
- Fixed explicit request-debug mode to overwrite existing `.res.log` files for the requested path instead of failing when they already exist
## [15.11.4] - 2026-06-12
@@ -3298,4 +3307,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
## [0.9.4] - 2025-11-26
Initial release with multi-provider LLM support.
Initial release with multi-provider LLM support.
+55 -12
View File
@@ -1,5 +1,6 @@
import { Buffer } from "node:buffer";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { FetchImpl } from "../types";
const REQUEST_DEBUG_ENV = "PI_REQ_DEBUG";
@@ -8,6 +9,7 @@ const textEncoder = new TextEncoder();
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
let nextSessionId = 1;
let nextRequestDebugPath: string | undefined;
type DebugFetch = FetchImpl & { [DEBUG_FETCH_MARKER]?: true };
type RequestBodyInit = NonNullable<RequestInit["body"]>;
@@ -27,6 +29,14 @@ export interface RequestDebugPayload {
protocol?: string;
}
interface ReservedRequestDebugFile {
id: number;
requestPath: string;
responsePath: string;
handle: fs.FileHandle;
overwrite: boolean;
}
export interface RequestDebugResponseLog {
write(chunk: Uint8Array | string): void;
close(): Promise<void>;
@@ -40,10 +50,32 @@ export interface RequestDebugSession {
wrapResponse(response: Response): Promise<Response>;
}
export function isRequestDebugEnabled(): boolean {
function isRequestDebugEnvEnabled(): boolean {
return Bun.env[REQUEST_DEBUG_ENV] === "1";
}
export function isRequestDebugEnabled(): boolean {
return isRequestDebugEnvEnabled() || nextRequestDebugPath !== undefined;
}
export function setNextRequestDebugPath(requestPath: string): void {
nextRequestDebugPath = requestPath;
}
export function clearNextRequestDebugPath(): void {
nextRequestDebugPath = undefined;
}
export function getNextRequestDebugPath(): string | undefined {
return nextRequestDebugPath;
}
function consumeNextRequestDebugPath(): string | undefined {
const requestPath = nextRequestDebugPath;
nextRequestDebugPath = undefined;
return requestPath;
}
export function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl {
if (!isRequestDebugEnabled()) return fetchImpl;
const maybeWrapped = fetchImpl as DebugFetch;
@@ -51,6 +83,7 @@ export function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl {
const wrapped = Object.assign(
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
if (!isRequestDebugEnabled()) return fetchImpl(input, init);
const session = await createFetchRequestDebugSession(input, init);
const response = await fetchImpl(input, init);
return session.wrapResponse(response);
@@ -69,7 +102,7 @@ export function withRequestDebugFetch<T extends { fetch?: FetchImpl } | undefine
}
export async function createRequestDebugSession(payload: RequestDebugPayload): Promise<RequestDebugSession> {
const { id, requestPath, responsePath, handle } = await reserveRequestDebugFile();
const { id, requestPath, responsePath, handle, overwrite } = await reserveRequestDebugFile();
const requestDump: Record<string, unknown> = {
id,
protocol: payload.protocol ?? "http",
@@ -89,7 +122,7 @@ export async function createRequestDebugSession(payload: RequestDebugPayload): P
await handle.close();
}
return new FileRequestDebugSession(id, requestPath, responsePath);
return new FileRequestDebugSession(id, requestPath, responsePath, overwrite);
}
async function createFetchRequestDebugSession(
@@ -110,15 +143,17 @@ class FileRequestDebugSession implements RequestDebugSession {
readonly id: number;
readonly requestPath: string;
readonly responsePath: string;
readonly #overwriteResponseLog: boolean;
constructor(id: number, requestPath: string, responsePath: string) {
constructor(id: number, requestPath: string, responsePath: string, overwriteResponseLog: boolean) {
this.id = id;
this.requestPath = requestPath;
this.responsePath = responsePath;
this.#overwriteResponseLog = overwriteResponseLog;
}
async openResponseLog(statusLine: string, headers?: RequestDebugHeaders): Promise<RequestDebugResponseLog> {
const handle = await fs.open(this.responsePath, "wx");
const handle = await fs.open(this.responsePath, this.#overwriteResponseLog ? "w" : "wx");
const headerBlock = formatResponseHeaderBlock(statusLine, headers);
await handle.write(textEncoder.encode(headerBlock));
return new FileRequestDebugResponseLog(handle);
@@ -213,18 +248,26 @@ function copyResponseMetadata(target: Response, source: Response): void {
}
}
async function reserveRequestDebugFile(): Promise<{
id: number;
requestPath: string;
responsePath: string;
handle: fs.FileHandle;
}> {
async function reserveRequestDebugFile(): Promise<ReservedRequestDebugFile> {
const explicitPath = consumeNextRequestDebugPath();
if (explicitPath) {
await fs.mkdir(path.dirname(explicitPath), { recursive: true });
const handle = await fs.open(explicitPath, "w");
return {
id: nextSessionId++,
requestPath: explicitPath,
responsePath: `${explicitPath}.res.log`,
handle,
overwrite: true,
};
}
for (;;) {
const id = nextSessionId++;
const requestPath = `rr-session-${id}.json`;
try {
const handle = await fs.open(requestPath, "wx");
return { id, requestPath, responsePath: `rr-session-${id}.res.log`, handle };
return { id, requestPath, responsePath: `rr-session-${id}.res.log`, handle, overwrite: false };
} catch (error) {
if (isFileExistsError(error)) continue;
throw error;
+45 -1
View File
@@ -6,7 +6,12 @@ import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai/api-registry
import { stream } from "@oh-my-pi/pi-ai/stream";
import type { AssistantMessage, FetchImpl, Model, ModelSpec } from "@oh-my-pi/pi-ai/types";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { wrapFetchForRequestDebug } from "@oh-my-pi/pi-ai/utils/request-debug";
import {
clearNextRequestDebugPath,
getNextRequestDebugPath,
setNextRequestDebugPath,
wrapFetchForRequestDebug,
} from "@oh-my-pi/pi-ai/utils/request-debug";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const enc = new TextEncoder();
@@ -24,6 +29,7 @@ beforeEach(async () => {
afterEach(async () => {
clearCustomApis();
clearNextRequestDebugPath();
process.chdir(previousCwd);
if (previousDebugFlag === undefined) delete Bun.env.PI_REQ_DEBUG;
else Bun.env.PI_REQ_DEBUG = previousDebugFlag;
@@ -86,6 +92,44 @@ describe("PI_REQ_DEBUG request/response recording", () => {
expect(wrapFetchForRequestDebug(fetchImpl)).toBe(fetchImpl);
});
it("records only the next fetch to an explicit request path", async () => {
delete Bun.env.PI_REQ_DEBUG;
const requestPath = path.join(tempDir!, "nested", "next-request.json");
setNextRequestDebugPath(requestPath);
let calls = 0;
const fetchImpl: FetchImpl = async () => {
calls += 1;
return new Response(calls === 1 ? "first" : "second", { headers: { "x-call": String(calls) } });
};
const wrapped = wrapFetchForRequestDebug(fetchImpl);
const first = await wrapped("https://provider.test/first", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ first: true }),
});
await first.text();
const second = await wrapped("https://provider.test/second", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ second: true }),
});
await second.text();
expect(calls).toBe(2);
expect(getNextRequestDebugPath()).toBeUndefined();
const request = JSON.parse(await fs.readFile(requestPath, "utf8")) as Record<string, unknown>;
expect(request).toMatchObject({
protocol: "http",
method: "POST",
url: "https://provider.test/first",
body: { first: true },
});
const log = splitResponseLog(await fs.readFile(`${requestPath}.res.log`));
expect(log.headers).toContain("x-call: 1");
expect(new TextDecoder().decode(log.body)).toBe("first");
});
it("records request JSON before fetch and raw response bytes after headers", async () => {
Bun.env.PI_REQ_DEBUG = "1";
const responseBody = new Uint8Array([0x66, 0x69, 0x72, 0x73, 0x74, 0x00, 0xff, 0x0a]);
+11
View File
@@ -1,6 +1,17 @@
# Changelog
## [Unreleased]
### Added
- Added `/debug dump-request` and `/debug next-request` as aliases for `/debug dump-next-request` when arming a one-shot AI provider request dump
- Added `/debug dump-next-request <path>` to dump the next AI provider HTTP request JSON to a chosen file.
### Changed
- Changed `/debug` handling in interactive mode so `/debug` with arguments now executes the requested debug subcommand instead of always opening the debug selector
- Changed `/debug dump-next-request` path handling to expand `~` and resolve relative paths against the current working directory
### Fixed
- Fixed settings search to rank matching tabs by relevance so exact matches appear before incidental matches
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import type { SettingPath, SettingValue } from "../config/settings";
@@ -65,6 +66,46 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
return commandConsumed();
};
const DEBUG_DUMP_NEXT_REQUEST_USAGE = "Usage: /debug dump-next-request <path>";
function resolveDebugRequestDumpPath(target: string, cwd: string): string {
const expanded =
target === "~"
? os.homedir()
: target.startsWith("~/") || target.startsWith("~\\")
? path.join(os.homedir(), target.slice(2))
: target;
return path.resolve(cwd, expanded);
}
async function handleDebugSubcommand(
args: string,
cwd: string,
output: (text: string) => Promise<void> | void,
): Promise<SlashCommandResult> {
const { verb, rest } = parseSubcommand(args);
switch (verb) {
case "":
await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
return commandConsumed();
case "dump-next-request":
case "dump-request":
case "next-request": {
if (!rest) {
await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
return commandConsumed();
}
const requestPath = resolveDebugRequestDumpPath(rest, cwd);
setNextRequestDebugPath(requestPath);
await output(`Next AI provider request will be dumped to ${requestPath}`);
return commandConsumed();
}
default:
await output(`Unknown /debug subcommand "${verb}". ${DEBUG_DUMP_NEXT_REQUEST_USAGE}`);
return commandConsumed();
}
}
/** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */
function parseShakeMode(args: string): ShakeMode | { error: string } {
const verb = args.trim().toLowerCase();
@@ -974,8 +1015,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
{
name: "debug",
description: "Open debug tools selector",
handleTui: (_command, runtime) => {
runtime.ctx.showDebugSelector();
allowArgs: true,
subcommands: [
{
name: "dump-next-request",
description: "Dump the next AI provider HTTP request as JSON",
usage: "<path>",
},
],
handle: async (command, runtime) =>
handleDebugSubcommand(command.args, runtime.cwd, text => runtime.output(text)),
handleTui: async (command, runtime) => {
const args = command.args.trim();
if (args.length === 0) {
runtime.ctx.showDebugSelector();
} else {
await handleDebugSubcommand(args, runtime.ctx.sessionManager.getCwd(), text =>
runtime.ctx.showStatus(text),
);
}
runtime.ctx.editor.setText("");
},
},
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { clearNextRequestDebugPath, getNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
function createRuntimeHarness(cwd: string) {
const setText = vi.fn();
const showStatus = vi.fn();
const showDebugSelector = vi.fn();
return {
setText,
showStatus,
showDebugSelector,
runtime: {
ctx: {
editor: { setText } as unknown as InteractiveModeContext["editor"],
sessionManager: { getCwd: () => cwd } as unknown as InteractiveModeContext["sessionManager"],
showStatus,
showDebugSelector,
} as unknown as InteractiveModeContext,
},
};
}
afterEach(() => {
clearNextRequestDebugPath();
});
describe("/debug slash command", () => {
it("opens the debug selector without arguments", async () => {
const harness = createRuntimeHarness(path.join(os.tmpdir(), "omp-debug-cwd"));
expect(await executeBuiltinSlashCommand("/debug", harness.runtime)).toBe(true);
expect(harness.showDebugSelector).toHaveBeenCalledTimes(1);
expect(harness.showStatus).not.toHaveBeenCalled();
expect(harness.setText).toHaveBeenCalledWith("");
expect(getNextRequestDebugPath()).toBeUndefined();
});
it("arms a one-shot provider request dump path", async () => {
const cwd = path.join(os.tmpdir(), "omp-debug-cwd");
const harness = createRuntimeHarness(cwd);
const expectedPath = path.resolve(cwd, "request.json");
expect(await executeBuiltinSlashCommand("/debug dump-next-request request.json", harness.runtime)).toBe(true);
expect(harness.showDebugSelector).not.toHaveBeenCalled();
expect(harness.showStatus).toHaveBeenCalledWith(`Next AI provider request will be dumped to ${expectedPath}`);
expect(harness.setText).toHaveBeenCalledWith("");
expect(getNextRequestDebugPath()).toBe(expectedPath);
});
});