From 00f98d59f653129ce4ff0e1a3eb48612e0e83d3d Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 27 Jun 2026 15:51:31 +0000 Subject: [PATCH] fix(autoresearch): guarded before_agent_start against undefined event.systemPrompt The handler called event.systemPrompt.join("\n\n") on a value that upstream system prompt resolution can leave unset, crashing the extension with 'event.systemPrompt is undefined'. Coerce defensively so the autoresearch block still renders. Fixes #3665 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/autoresearch/index.ts | 9 +- .../autoresearch-before-agent-start.test.ts | 135 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/autoresearch-before-agent-start.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c98881efa..77e202e92 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed autoresearch's `before_agent_start` handler crashing when `event.systemPrompt` was undefined. The handler now coerces a missing system prompt to an empty string so the autoresearch block still renders. ([#3665](https://github.com/can1357/oh-my-pi/issues/3665)) + ## [16.2.2] - 2026-06-27 ### Added diff --git a/packages/coding-agent/src/autoresearch/index.ts b/packages/coding-agent/src/autoresearch/index.ts index 4bbb07cb8..259910a74 100644 --- a/packages/coding-agent/src/autoresearch/index.ts +++ b/packages/coding-agent/src/autoresearch/index.ts @@ -320,6 +320,11 @@ export const createAutoresearchExtension: ExtensionFactory = api => { runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration; runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi; const state = runtime.state; + // `event.systemPrompt` is typed `string[]`, but upstream code paths can leave + // it unset (issue #3665). Coerce defensively so the autoresearch block still + // renders — the model just loses the upstream prefix for this turn, which is + // strictly better than crashing the handler. + const basePrompt = Array.isArray(event.systemPrompt) ? event.systemPrompt.join("\n\n") : ""; const currentSegmentResults = currentResults(state.results, state.currentSegment); const baselineMetric = findBaselineMetric(state.results, state.currentSegment); const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment); @@ -358,7 +363,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => { return { systemPrompt: [ prompt.render(setupPromptTemplate, { - base_system_prompt: event.systemPrompt.join("\n\n"), + base_system_prompt: basePrompt, has_goal: goal.trim().length > 0, goal, working_dir: ctx.cwd, @@ -373,7 +378,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => { return { systemPrompt: [ prompt.render(promptTemplate, { - base_system_prompt: event.systemPrompt.join("\n\n"), + base_system_prompt: basePrompt, has_goal: goal.trim().length > 0, goal, working_dir: ctx.cwd, diff --git a/packages/coding-agent/test/autoresearch-before-agent-start.test.ts b/packages/coding-agent/test/autoresearch-before-agent-start.test.ts new file mode 100644 index 000000000..e9100591b --- /dev/null +++ b/packages/coding-agent/test/autoresearch-before-agent-start.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { createAutoresearchExtension } from "@oh-my-pi/pi-coding-agent/autoresearch"; +import { closeAllAutoresearchStorages } from "@oh-my-pi/pi-coding-agent/autoresearch/storage"; +import type { + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + ExtensionAPI, + ExtensionContext, + ExtensionHandler, + SessionStartEvent, +} from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +// Reproduces issue #3665: when the upstream system prompt resolution leaves +// `event.systemPrompt` unset, the autoresearch handler must still render its +// own block instead of crashing with `event.systemPrompt.join is not a function`. + +interface CapturedHandlers { + session_start?: ExtensionHandler; + before_agent_start?: ExtensionHandler; +} + +function buildHarness(): { handlers: CapturedHandlers; activeTools: string[] } { + const handlers: CapturedHandlers = {}; + const activeTools: string[] = []; + const api = { + appendEntry(): void {}, + exec: async () => ({ code: 0, stderr: "", stdout: "" }), + on(event: string, handler: ExtensionHandler): void { + (handlers as Record>)[event] = handler; + }, + registerCommand(): void {}, + registerShortcut(): void {}, + registerTool(): void {}, + getActiveTools: (): string[] => [...activeTools], + setActiveTools: async (names: string[]): Promise => { + activeTools.splice(0, activeTools.length, ...names); + }, + sendUserMessage(): void {}, + sendMessage(): void {}, + } as unknown as ExtensionAPI; + createAutoresearchExtension(api); + return { handlers, activeTools }; +} + +function makeCtx(cwd: string): ExtensionContext { + return { + cwd, + hasUI: false, + hasPendingMessages: () => false, + sessionManager: { + getSessionId: () => "session-bas-test", + getBranch: () => [ + { + type: "custom", + customType: "autoresearch-control", + id: "ctrl-1", + parentId: null, + timestamp: new Date(0).toISOString(), + data: { mode: "on", goal: "speed up the thing" }, + }, + ], + }, + } as unknown as ExtensionContext; +} + +describe("autoresearch before_agent_start handler", () => { + let dbDir: TempDir; + let cwdDir: TempDir; + + beforeEach(() => { + dbDir = TempDir.createSync("@pi-autoresearch-bas-test-"); + process.env.OMP_AUTORESEARCH_DB_DIR = dbDir.path(); + cwdDir = TempDir.createSync("@pi-autoresearch-bas-cwd-"); + vi.spyOn(git.branch, "current").mockResolvedValue("autoresearch/test"); + vi.spyOn(git.repo, "root").mockResolvedValue(cwdDir.path()); + }); + + afterEach(() => { + delete process.env.OMP_AUTORESEARCH_DB_DIR; + closeAllAutoresearchStorages(); + cwdDir.removeSync(); + dbDir.removeSync(); + vi.restoreAllMocks(); + }); + + it("renders an autoresearch block when event.systemPrompt is undefined (issue #3665)", async () => { + const { handlers } = buildHarness(); + if (!handlers.session_start || !handlers.before_agent_start) { + throw new Error("Autoresearch extension should register both session_start and before_agent_start"); + } + + const ctx = makeCtx(cwdDir.path()); + await handlers.session_start({ type: "session_start" } as SessionStartEvent, ctx); + + // Crash repro: upstream leaves event.systemPrompt unset; handler must + // not throw, and the rendered block must still contain the autoresearch + // header so the model gets its mode-specific instructions. + const event = { + type: "before_agent_start", + prompt: "kick off", + images: undefined, + systemPrompt: undefined, + } as unknown as BeforeAgentStartEvent; + + const result = (await handlers.before_agent_start(event, ctx)) as BeforeAgentStartEventResult; + expect(result).toBeDefined(); + expect(Array.isArray(result.systemPrompt)).toBe(true); + const blocks = result.systemPrompt as string[]; + expect(blocks).toHaveLength(1); + expect(blocks[0]).toContain("Autoresearch Mode"); + }); + + it("joins event.systemPrompt blocks into the rendered base prompt", async () => { + const { handlers } = buildHarness(); + if (!handlers.session_start || !handlers.before_agent_start) { + throw new Error("Autoresearch extension should register both session_start and before_agent_start"); + } + + const ctx = makeCtx(cwdDir.path()); + await handlers.session_start({ type: "session_start" } as SessionStartEvent, ctx); + + const event: BeforeAgentStartEvent = { + type: "before_agent_start", + prompt: "kick off", + systemPrompt: ["alpha block", "beta block"], + }; + + const result = (await handlers.before_agent_start(event, ctx)) as BeforeAgentStartEventResult; + expect(result).toBeDefined(); + const rendered = (result.systemPrompt as string[])[0]; + expect(rendered.startsWith("alpha block\n\nbeta block")).toBe(true); + }); +});