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