From 99c9072427eb726ee2ab4d59f64a5345201fbf76 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 12 Jun 2026 04:46:53 +0200 Subject: [PATCH] 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. --- packages/ai/CHANGELOG.md | 11 ++- packages/ai/src/utils/request-debug.ts | 67 +++++++++++++++---- packages/ai/test/request-debug.test.ts | 46 ++++++++++++- packages/coding-agent/CHANGELOG.md | 11 +++ .../src/slash-commands/builtin-registry.ts | 62 ++++++++++++++++- .../test/slash-commands/debug.test.ts | 55 +++++++++++++++ 6 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 packages/coding-agent/test/slash-commands/debug.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5833c389f..73006f5ac 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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. \ No newline at end of file diff --git a/packages/ai/src/utils/request-debug.ts b/packages/ai/src/utils/request-debug.ts index daa6831c4..e7f6fcf83 100644 --- a/packages/ai/src/utils/request-debug.ts +++ b/packages/ai/src/utils/request-debug.ts @@ -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; @@ -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; @@ -40,10 +50,32 @@ export interface RequestDebugSession { wrapResponse(response: Response): Promise; } -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 => { + 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 { - const { id, requestPath, responsePath, handle } = await reserveRequestDebugFile(); + const { id, requestPath, responsePath, handle, overwrite } = await reserveRequestDebugFile(); const requestDump: Record = { 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 { - 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 { + 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; diff --git a/packages/ai/test/request-debug.test.ts b/packages/ai/test/request-debug.test.ts index 95332136b..3db946c0a 100644 --- a/packages/ai/test/request-debug.test.ts +++ b/packages/ai/test/request-debug.test.ts @@ -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; + 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]); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 77e1fa001..b899908b4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 ` 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 diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index c56559f90..0c0cdac04 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -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 "; + +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, +): Promise { + 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 = [ { 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: "", + }, + ], + 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(""); }, }, diff --git a/packages/coding-agent/test/slash-commands/debug.test.ts b/packages/coding-agent/test/slash-commands/debug.test.ts new file mode 100644 index 000000000..a32240714 --- /dev/null +++ b/packages/coding-agent/test/slash-commands/debug.test.ts @@ -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); + }); +});