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:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user