fix(session): harden runEphemeralTurn against dropped provider content

Idle recap crashed with `TypeError: undefined is not an object (evaluating
'H.content.filter')` when the reporter's session_stop block-decision extension
poisoned the transcript with eight persisted `session-stop-continuation`
custom entries. The primary block-reason continuation flow was intact — the
custom message correctly renders as a `role: "developer"` LLM message —
but the follow-on idle recap fired 4 minutes later, and the side-channel
provider stream handed back a `done` event whose `message.content` had been
dropped. `runEphemeralTurn`'s sanitize step at
`assistantMessage.content.filter(block => block.type !== "toolCall")` then
tripped the TypeError, propagated as "Idle recap turn failed" in the debug
log while silently muting the session.

- Normalized the provider "done" event's `message.content` to `[]` when
  the shape is not an array, so a wrapper stream that drops content surfaces
  as an empty recap reply instead of an unrecoverable side-channel crash.
- Guarded `#buildEphemeralSnapshot`'s in-flight-assistant preservation
  branch with `Array.isArray(streaming.content)` for the same reason.
- Added a regression test that seeds the reporter's transcript shape (one
  real turn, eight persisted `session-stop-continuation` customs carrying a
  multi-line block reason with U+2717 glyphs) and drives a recap through a
  side stream that returns `content: undefined` on `done`. The test
  fails on the pre-fix tree with the exact reporter TypeError and passes
  after the guard lands.

Fixes #4323
This commit is contained in:
roboomp
2026-07-03 00:16:07 +00:00
parent f8becede72
commit 337ec0d16c
3 changed files with 174 additions and 3 deletions
+1
View File
@@ -25,6 +25,7 @@
- Fixed the assistant-message streaming fast path dropping the transient flag, which disabled the transient render path (code-highlight skip and streaming prefix caches) on every same-shape streaming tick. In-flight renders now correctly skip per-tick syntax highlighting; highlighting applies once at message finalization.
- Fixed hidden goal-mode todo context: phase names and task text are now sanitized before prompt injection (no raw newlines or control characters forging extra context lines), and the block is only rendered with tool-accurate guidance when the `todo` tool is active or discoverable instead of unconditionally instructing the agent to call an unavailable tool.
- Fixed custom tool loading treating `process.exit()` from a tool module's import or factory as a host process exit instead of a recoverable load failure. Custom tools now load under the shared extension exit guard, so an exiting tool is skipped with a load error while remaining tools still load ([#1704](https://github.com/can1357/oh-my-pi/issues/1704)).
- Fixed idle recap crashing with `TypeError: undefined is not an object (evaluating 'H.content.filter')` after a run poisoned the transcript with eight `session_stop` block-decision continuations. `runEphemeralTurn` now normalizes the provider "done" event's `message.content` to `[]` when a wrapper/proxy stream drops it, and `#buildEphemeralSnapshot` skips its streaming-partial preservation branch when the in-flight assistant carries no content array. Prevents a single side-channel malformation from turning a subsequent idle recap into a session-mute crash. ([#4323](https://github.com/can1357/oh-my-pi/issues/4323))
## [16.3.1] - 2026-07-02
@@ -13962,9 +13962,17 @@ export class AgentSession {
continue;
}
if (event.type === "done") {
// A well-formed provider "done" event carries `content: AssistantContentBlock[]`,
// but a proxy/wrapper (custom extension providers, gateway-wrapped OAuth streams,
// see #4323) can hand back a message whose `content` was dropped or replaced with
// `undefined`. Downstream `.content.filter` at the sanitize step below would then
// crash the recap turn with `TypeError: undefined is not an object (evaluating
// 'H.content.filter')`. Normalize to `[]` so the recap surfaces an empty reply
// instead of turning a malformed side-channel response into a session-mute crash.
const rawContent = Array.isArray(event.message.content) ? event.message.content : [];
assistantMessage = this.#obfuscator?.hasSecrets()
? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, event.message.content) }
: event.message;
? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, rawContent) }
: { ...event.message, content: rawContent };
break;
}
if (event.type === "error") {
@@ -13998,7 +14006,7 @@ export class AgentSession {
#buildEphemeralSnapshot(promptText: string): AgentMessage[] {
const messages = [...this.messages];
const streaming = this.agent.state.streamMessage;
if (streaming && streaming.role === "assistant") {
if (streaming && streaming.role === "assistant" && Array.isArray(streaming.content)) {
const preservedBlocks: AssistantMessage["content"] = [];
// Preserve thinking blocks: DeepSeek-class encoders replay them as
// `reasoning_content` and reject the request (HTTP 400) when the field
@@ -0,0 +1,162 @@
/**
* Regression: idle recap turn must not crash with
* `TypeError: undefined is not an object (evaluating 'H.content.filter')`
* when a provider "done" event carries a message whose `content` is not a
* well-formed array (issue #4323 — surfaced after 8 `session_stop`
* block-decision continuations poisoned the transcript).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, StopReason } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
// Mirrors the reporter's block-reason shape: multi-line prose with U+2717
// glyphs, ~300–600 chars. Persisted as-is on each `session-stop-continuation`
// custom entry.
const BLOCK_REASON =
"BLOCKED: Session cannot stop — lifecycle phases incomplete.\n\n" +
["phase-1 not complete", "phase-2 not complete", "phase-3 not complete"].map(f => ` ✗ ${f}`).join("\n") +
"\n\nComplete all phases before stopping.";
// Emit a well-formed `start` then a `done` whose message drops `content`.
// Mimics a gateway/proxy-wrapped Anthropic OAuth stream that hands back a
// truncated final message (the crash the reporter observed on the recap).
function malformedContentSideStreamFn(
model: Model,
_context: Context,
_options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const base: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as StopReason,
timestamp: Date.now(),
};
stream.push({ type: "start", partial: base });
// content dropped — the exact runtime shape the reporter's provider wrapper produced.
const malformed = { ...base, content: undefined as unknown as AssistantMessage["content"] };
stream.push({ type: "done", reason: "stop", message: malformed });
});
return stream;
}
describe("session_stop block continuation — idle recap resilience (#4323)", () => {
let session: AgentSession;
let tempDir: string;
const authStorages: AuthStorage[] = [];
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-4323-recap-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (session) await session.dispose();
for (const authStorage of authStorages.splice(0)) authStorage.close();
vi.restoreAllMocks();
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
});
it("runEphemeralTurn recovers from a provider 'done' event whose message.content is undefined", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [] },
// Main-loop stream is irrelevant here; the recap uses sideStreamFn.
streamFn: malformedContentSideStreamFn,
convertToLlm,
});
const extensionRunner = {
emit: vi.fn().mockResolvedValue(undefined),
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
hasHandlers: vi.fn(() => false),
emitSessionStop: vi.fn().mockResolvedValue(undefined),
} as unknown as ExtensionRunner;
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorages.push(authStorage);
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry,
extensionRunner,
sideStreamFn: malformedContentSideStreamFn,
});
// Seed the exact transcript shape the reporter saw after 8 block
// continuations were persisted: one real turn, then 8 dead
// `session-stop-continuation` customs.
agent.appendMessage({
role: "user",
content: [{ type: "text", text: "Hi" }],
timestamp: Date.now(),
});
agent.appendMessage({
role: "assistant",
content: [{ type: "text", text: "Hello" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
});
for (let i = 0; i < 8; i++) {
agent.appendMessage({
role: "custom",
customType: "session-stop-continuation",
content: BLOCK_REASON,
display: false,
attribution: "agent",
timestamp: Date.now(),
});
}
// Prior to the fix, this threw
// `TypeError: undefined is not an object (evaluating 'assistantMessage.content.filter')`
// under the reporter's real-provider setup. The regression is now a
// graceful empty-reply return.
const result = await session.runEphemeralTurn({ promptText: "Recap the session." });
expect(result.assistantMessage.role).toBe("assistant");
expect(Array.isArray(result.assistantMessage.content)).toBe(true);
expect(result.assistantMessage.content).toEqual([]);
});
});