diff --git a/.changeset/fn-199-reasoning-summary.md b/.changeset/fn-199-reasoning-summary.md new file mode 100644 index 0000000000..31bebec92f --- /dev/null +++ b/.changeset/fn-199-reasoning-summary.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show detailed reasoning bodies alongside titles for supported Responses models. +category: fix +dev: Uses the pi Agent `onPayload` seam only for the OpenAI Responses API family. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d50ef0ccb5..f24144ca6d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -110,7 +110,7 @@ Press `Escape` to close the current/topmost dashboard popup. Popped-out task win ### Thinking traces -Thinking panes split titled reasoning traces with captured bodies into independently expandable sections, with **Collapse all** and **Expand all** controls. Headings without captured reasoning stay inline in the flowing trace instead of becoming empty collapsible rows, and the defensive empty-state label appears at most once per section. **Raw trace** shows the original unsectioned capture and switches back with **Sectioned trace**; it is available for every trace that contained headings, including a titles-only trace rendered as one flowing block. The workflow live-log console remains raw and unsectioned by design. The same behavior applies while Planning Mode, Mission Interview, and Milestone/Slice Interview stream a generation. +Thinking panes split titled reasoning traces with captured bodies into independently expandable sections, with **Collapse all** and **Expand all** controls. Responses-family models now request titled sections with their reasoning bodies. A titles-only trace can still arrive from a provider Fusion cannot configure; those headings stay inline in the flowing trace instead of becoming empty collapsible rows, and the defensive empty-state label appears at most once per section. **Raw trace** shows the original unsectioned capture and switches back with **Sectioned trace**; use it to diagnose a provider-side titles-only payload. The workflow live-log console remains raw and unsectioned by design. The same behavior applies while Planning Mode, Mission Interview, and Milestone/Slice Interview stream a generation. ### Chat Find diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 0d45ad454b..03ff22c8bf 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -103,6 +103,10 @@ Fusion persists one canonical ordered vocabulary: `off`, `minimal`, `low`, `medi Custom-provider models are presumed thinking-capable and expose all seven levels by default; no capability checkbox or other configuration is required. Fusion registers these levels with pi as transmissible, while pi owns API-specific `off` translation and up-then-down clamping. Consequently, a global or inherited thinking level that custom providers previously ignored is now sent to the gateway. A strict gateway can reject an unsupported effort; select **Off** for that gateway, which pi translates to its explicit non-reasoning form or omits when the API has no such form. +#### Reasoning-summary detail + +When a real thinking effort is resolved, Fusion requests `reasoning.summary: "detailed"` for Responses-family models: `openai-responses`, `openai-codex-responses`, and `azure-openai-responses`. Pi forwards the effort through its simple stream path but not this summary preference; without Fusion's request shaping, those APIs use their shortest `"auto"` summary. Non-Responses APIs are unchanged. If a provider rejects detailed summaries, Fusion retries that run with the previous request shape. Detailed summaries can consume slightly more output tokens. + | `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. | | `agentClarificationEnabled` | `boolean` | `false` | Legacy default for programmatic Planning Mode session notification eligibility. Dashboard Planning Mode always starts its infinite, user-validated interview with follow-up questions enabled; this setting no longer suppresses questions or creates a final summary. | | `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. | diff --git a/packages/dashboard/app/components/__tests__/ThinkingTrace.surfaces.test.tsx b/packages/dashboard/app/components/__tests__/ThinkingTrace.surfaces.test.tsx index 2e3010f412..1040fb7dfb 100644 --- a/packages/dashboard/app/components/__tests__/ThinkingTrace.surfaces.test.tsx +++ b/packages/dashboard/app/components/__tests__/ThinkingTrace.surfaces.test.tsx @@ -43,6 +43,25 @@ describe("ThinkingTrace production transcript surfaces", () => { expectTraceIsolation(disclosure); }); + it("keeps detailed bodies in the mobile live-thinking pane", () => { + const previousWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); + try { + const { container } = render(); + const disclosure = container.querySelector("details.chat-message-thinking") as HTMLDetailsElement; + fireEvent.click(disclosure.querySelector("summary")!); + + const sections = disclosure.querySelectorAll("[data-testid='thinking-trace-section']"); + expect(sections).toHaveLength(3); + expect(sections[0]).toHaveAttribute("open"); + expect(sections[0]).toHaveTextContent("Docker tests need development dependencies."); + expect(sections[1]).toHaveTextContent("Deployment commits remain independently reviewable."); + expect(sections[2]).toHaveTextContent("README edits remain visible in their own section."); + } finally { + Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth }); + } + }); + it("renders the same isolated trace in agent logs in markdown and plain modes", () => { const entries = [{ taskId: "FN-155", timestamp: "2026-08-22T00:00:00.000Z", type: "thinking", text: trace }] as AgentLogEntry[]; const { container, rerender } = render(); diff --git a/packages/dashboard/app/components/__tests__/ThinkingTrace.test.tsx b/packages/dashboard/app/components/__tests__/ThinkingTrace.test.tsx index d2f0e9e5f6..a5d6984288 100644 --- a/packages/dashboard/app/components/__tests__/ThinkingTrace.test.tsx +++ b/packages/dashboard/app/components/__tests__/ThinkingTrace.test.tsx @@ -76,6 +76,10 @@ describe("ThinkingTrace", () => { it("keeps every populated title expanded and isolated until its own section is collapsed", () => { render(); expect(sections()).toHaveLength(4); + const renderedBodies = sections().map((section) => section.textContent).join("\n"); + expect(renderedBodies).toContain("The Docker image needs development dependencies for test execution."); + expect(renderedBodies).toContain("Deployment commits should be split by independently reviewable behavior."); + expect(renderedBodies).toContain("The README change belongs in its own reviewed update."); const deployment = sections().find((section) => section.textContent?.includes("Planning deployment commit structure"))!; expect(deployment.textContent).toContain("Deployment commits should be split by independently reviewable behavior."); fireEvent.click(within(deployment).getByText("Planning deployment commit structure")); diff --git a/packages/engine/src/__tests__/pi-reasoning-summary.test.ts b/packages/engine/src/__tests__/pi-reasoning-summary.test.ts new file mode 100644 index 0000000000..c41fb39d94 --- /dev/null +++ b/packages/engine/src/__tests__/pi-reasoning-summary.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createAgentSessionMock = vi.fn(); +const modelRegistry = { + modelRuntime: { getAuth: vi.fn(), refresh: vi.fn() }, + find: vi.fn((provider: string, id: string) => ({ provider, id })), + getAll: vi.fn(() => []), + registerProvider: vi.fn(), +}; + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + LegacyCredentialStorage: { create: vi.fn(() => ({})) }, + createAgentSession: createAgentSessionMock, + createBashTool: vi.fn((cwd: string) => ({ name: "bash", cwd })), + createCodingTools: vi.fn(() => []), + createEditTool: vi.fn(() => ({ name: "edit" })), + createExtensionRuntime: vi.fn(), + createFindTool: vi.fn(() => ({ name: "find" })), + createGrepTool: vi.fn(() => ({ name: "grep" })), + createLsTool: vi.fn(() => ({ name: "ls" })), + createReadOnlyTools: vi.fn(() => []), + createReadTool: vi.fn(() => ({ name: "read" })), + createWriteTool: vi.fn(() => ({ name: "write" })), + DefaultResourceLoader: class { async reload() {} }, + DefaultPackageManager: class { async resolve() { return { extensions: [] }; } }, + discoverAndLoadExtensions: vi.fn(async () => ({ runtime: { pendingProviderRegistrations: [] }, errors: [] })), + getAgentDir: () => "/mock-agent-dir", + ModelRegistry: class {}, + ModelRuntime: { create: vi.fn(async () => modelRegistry.modelRuntime) }, + SessionManager: { inMemory: () => ({ getSessionId: () => undefined }) }, + SettingsManager: { inMemory: () => ({}) }, +})); + +vi.mock("../auth/auth-storage.js", () => ({ + createFusionAuthStorage: vi.fn(() => ({})), + createFusionModelRegistry: vi.fn(async () => modelRegistry), +})); +vi.mock("../auth/model-registry-refresh.js", () => ({ + refreshFusionModelRegistry: vi.fn(async () => "completed"), +})); +vi.mock("../auth/custom-providers.js", () => ({ readCustomProviders: vi.fn(() => []) })); + +function makeSession(agent: { onPayload?: (payload: unknown, model: { api?: unknown }) => unknown | Promise } = {}) { + return { + agent, + prompt: vi.fn(async () => undefined), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: vi.fn(), + }; +} + +async function createSession(session = makeSession(), options: Record = {}) { + createAgentSessionMock.mockResolvedValueOnce({ session }); + const { createPiAgentSessionRaw } = await import("../pi.js"); + const result = await createPiAgentSessionRaw({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "openai", + defaultModelId: "gpt-5", + ...options, + }); + return { result, session }; +} + +/* +FNXC:ThinkingTrace 2026-08-27-10:45: +Fusion can prove only the request payload it sends; a provider's generated reasoning bodies are outside this process. These tests therefore exercise the live createFnAgent session hook rather than asserting provider response content. +*/ +describe("createFnAgent reasoning-summary payload hook", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelRegistry.find.mockImplementation((provider: string, id: string) => ({ provider, id })); + modelRegistry.getAll.mockReturnValue([]); + }); + + it("installs onPayload on every created pi session", async () => { + const session = makeSession(); + await createSession(session); + + expect(session.agent.onPayload).toEqual(expect.any(Function)); + }); + + it("upgrades a Responses request while preserving its effort", async () => { + const { session } = await createSession(); + const result = await session.agent.onPayload?.( + { reasoning: { effort: "medium", summary: "auto" } }, + { api: "openai-responses" }, + ); + + expect(result).toEqual({ reasoning: { effort: "medium", summary: "detailed" } }); + }); + + it("leaves Anthropic and disabled-thinking requests unchanged", async () => { + const { session } = await createSession(); + const anthropicPayload = { reasoning: { effort: "medium", summary: "auto" } }; + const disabledPayload = { reasoning: { effort: "none" } }; + + expect(await session.agent.onPayload?.(anthropicPayload, { api: "anthropic-messages" })).toBeUndefined(); + expect(await session.agent.onPayload?.(disabledPayload, { api: "openai-responses" })).toBeUndefined(); + }); + + it("chains an upstream replacement and preserves it when Fusion makes no change", async () => { + const replacement = { reasoning: { effort: "high", summary: "auto" }, source: "upstream" }; + const upstream = vi.fn(() => replacement); + const { session } = await createSession(makeSession({ onPayload: upstream })); + + expect(await session.agent.onPayload?.({ ignored: true }, { api: "openai-responses" })).toEqual({ + reasoning: { effort: "high", summary: "detailed" }, + source: "upstream", + }); + expect(await session.agent.onPayload?.({ ignored: true }, { api: "anthropic-messages" })).toBe(replacement); + expect(upstream).toHaveBeenCalledTimes(2); + }); + + it("does not upgrade requests when summary detail is off", async () => { + const { session } = await createSession(makeSession(), { reasoningSummaryDetail: "off" }); + + expect(await session.agent.onPayload?.( + { reasoning: { effort: "medium", summary: "auto" } }, + { api: "openai-responses" }, + )).toBeUndefined(); + }); + + it("retries once on the same session after an unsupported-summary rejection", async () => { + const requests: unknown[] = []; + const agent: { onPayload?: (payload: unknown, model: { api?: unknown }) => Promise } = {}; + const session = makeSession(agent); + const prompt = session.prompt; + let attempts = 0; + prompt.mockImplementation(async () => { + requests.push(await agent.onPayload?.( + { reasoning: { effort: "medium", summary: "auto" } }, + { api: "openai-responses" }, + )); + attempts += 1; + if (attempts === 1) throw new Error("Unsupported reasoning summary: detailed"); + }); + + const { result } = await createSession(session); + await (result.session as any).promptWithFallback("test summary fallback"); + + expect(prompt).toHaveBeenCalledTimes(2); + expect(requests).toEqual([ + { reasoning: { effort: "medium", summary: "detailed" } }, + undefined, + ]); + }); +}); diff --git a/packages/engine/src/execution/__tests__/reasoning-summary-payload.test.ts b/packages/engine/src/execution/__tests__/reasoning-summary-payload.test.ts new file mode 100644 index 0000000000..2bab11b530 --- /dev/null +++ b/packages/engine/src/execution/__tests__/reasoning-summary-payload.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + applyReasoningSummaryToPayload, + isReasoningSummaryUnsupportedError, +} from "../reasoning-summary-payload.js"; + +const responsesApis = ["openai-responses", "openai-codex-responses", "azure-openai-responses"] as const; +const nonResponsesApis = [ + "anthropic-messages", + "openai-completions", + "google-generative-ai", + "bedrock-converse-stream", + "mistral-conversations", + "pi-messages", +] as const; + +describe("applyReasoningSummaryToPayload", () => { + it.each(responsesApis)("upgrades an enabled %s request while preserving effort", (api) => { + const payload = { reasoning: { effort: "medium", summary: "auto" }, input: "keep" }; + + expect(applyReasoningSummaryToPayload(payload, { api }, "detailed")).toEqual({ + reasoning: { effort: "medium", summary: "detailed" }, + input: "keep", + }); + }); + + it.each(nonResponsesApis)("leaves %s payloads byte-identical", (api) => { + const payload = { reasoning: { effort: "high", summary: "auto" }, input: "keep" }; + + expect(applyReasoningSummaryToPayload(payload, { api }, "detailed")).toBeUndefined(); + }); + + it("adds detailed only to an enabled request whose summary is absent", () => { + const payload = { reasoning: { effort: "high" } }; + + expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed")).toEqual({ + reasoning: { effort: "high", summary: "detailed" }, + }); + }); + + it.each([ + ["missing reasoning", { input: "keep" }], + ["thinking disabled", { reasoning: { effort: "none" } }], + ["existing detailed summary", { reasoning: { effort: "high", summary: "detailed" } }], + ["explicit concise summary", { reasoning: { effort: "high", summary: "concise" } }], + ["null payload", null], + ["string payload", "payload"], + ["array payload", []], + ["string reasoning", { reasoning: "high" }], + ["null reasoning", { reasoning: null }], + ])("does not alter %s", (_name, payload) => { + expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed")).toBeUndefined(); + }); + + it("does not mutate either the request or its reasoning object", () => { + const reasoning = { effort: "medium", summary: "auto" }; + const payload = { reasoning, input: "keep" }; + + const result = applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "detailed"); + + expect(payload).toEqual({ reasoning: { effort: "medium", summary: "auto" }, input: "keep" }); + expect(result).not.toBe(payload); + expect(result?.reasoning).not.toBe(reasoning); + }); + + it("treats auto and off detail as no-op requests", () => { + const payload = { reasoning: { effort: "medium", summary: "auto" } }; + + expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "auto")).toBeUndefined(); + expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "off")).toBeUndefined(); + }); + + it("can deliberately request concise without overriding an explicit choice", () => { + const payload = { reasoning: { effort: "medium", summary: "auto" } }; + + expect(applyReasoningSummaryToPayload(payload, { api: "openai-responses" }, "concise")).toEqual({ + reasoning: { effort: "medium", summary: "concise" }, + }); + }); +}); + +describe("isReasoningSummaryUnsupportedError", () => { + it.each([ + "Unsupported reasoning summary: detailed", + "reasoning_summary is invalid for this model", + "Unknown reasoning summary option", + "Summary reasoning is not supported by this endpoint", + ])("recognizes an explicit summary capability rejection: %s", (message) => { + expect(isReasoningSummaryUnsupportedError(message)).toBe(true); + }); + + it.each([ + "400 Bad Request", + "maximum context length exceeded", + "invalid API key", + "cannot specify both thinking and reasoning_effort", + "reasoning effort is unsupported", + "summary field is invalid", + ])("does not misclassify unrelated provider errors: %s", (message) => { + expect(isReasoningSummaryUnsupportedError(message)).toBe(false); + }); +}); diff --git a/packages/engine/src/execution/reasoning-summary-payload.ts b/packages/engine/src/execution/reasoning-summary-payload.ts new file mode 100644 index 0000000000..252a235368 --- /dev/null +++ b/packages/engine/src/execution/reasoning-summary-payload.ts @@ -0,0 +1,72 @@ +/* +FNXC:ThinkingTrace 2026-08-27-10:45: +Pi-coding-agent streams Responses models through `streamSimple`, which forwards reasoning effort but cannot carry `reasoningSummary`. Pi therefore defaults Responses payloads to the short `"auto"` summary that can contain titles without bodies. + +Pi 0.84.1 exposes `Agent.onPayload` as the request-shaping seam. This helper upgrades only already-enabled Responses reasoning; CLI and ACP runtimes are structurally exempt because they never construct a pi Agent. +*/ + +export const RESPONSES_FAMILY_APIS = new Set([ + "openai-responses", + "openai-codex-responses", + "azure-openai-responses", +] as const); + +export type ResponsesFamilyApi = typeof RESPONSES_FAMILY_APIS extends Set ? Api : never; +export type ReasoningSummaryDetail = "auto" | "concise" | "detailed" | "off"; + +type PayloadModel = { api?: unknown }; +type ReasoningPayload = { effort?: unknown; summary?: unknown }; +type ProviderPayload = Record & { reasoning?: unknown }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isResponsesFamilyApi(api: unknown): api is ResponsesFamilyApi { + return typeof api === "string" && RESPONSES_FAMILY_APIS.has(api as ResponsesFamilyApi); +} + +/** + * Returns a replacement only when an already-enabled Responses request needs a + * more detailed reasoning summary. This preserves pi's undefined-means-keep + * contract and never enables reasoning for a request that omitted it. + */ +export function applyReasoningSummaryToPayload( + payload: unknown, + model: PayloadModel, + detail: ReasoningSummaryDetail, +): ProviderPayload | undefined { + if (detail === "off" || detail === "auto" || !isResponsesFamilyApi(model.api) || !isRecord(payload)) { + return undefined; + } + + const reasoning = payload.reasoning; + if (!isRecord(reasoning)) { + return undefined; + } + + const effort = reasoning.effort; + if (typeof effort !== "string" || effort.length === 0 || effort === "none") { + return undefined; + } + + const summary = reasoning.summary; + if (summary !== undefined && summary !== "auto") { + return undefined; + } + + return { + ...payload, + reasoning: { + ...reasoning, + summary: detail, + } satisfies ReasoningPayload, + }; +} + +/** Match only explicit provider rejections of the optional summary request. */ +export function isReasoningSummaryUnsupportedError(message: string): boolean { + const mentionsReasoningSummary = /\breasoning[\s_-]*summary\b|\bsummary\b[\s\S]{0,80}\breasoning\b/i.test(message); + const rejectsFeature = /\b(?:unsupported|not supported|unknown|unrecognized|invalid|not allowed|not available)\b/i.test(message); + return mentionsReasoningSummary && rejectsFeature; +} diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 2c23923076..0533d6a9cc 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -88,6 +88,11 @@ import { resolvePermanentAgentToolDecision } from "./agents/permanent-agent-gati import type { SystemPromptLayers } from "./execution/prompt-layers.js"; import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflows/workflow-step-tool-policy.js"; import { createStreamingDeltaNormalizer } from "./execution/streaming-delta.js"; +import { + applyReasoningSummaryToPayload, + isReasoningSummaryUnsupportedError, + type ReasoningSummaryDetail, +} from "./execution/reasoning-summary-payload.js"; import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./errors/transient-error-detector.js"; import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp/mcp-runtime-support.js"; import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp/mcp-session-tools.js"; @@ -231,12 +236,14 @@ interface ToolHookResult { type AgentToolHookSession = AgentSession & { agent?: { afterToolCall?: (payload: ToolHookPayload) => Promise; + onPayload?: (payload: unknown, model: { api?: unknown }) => unknown | undefined | Promise; state?: { messages?: Array>; }; }; __fusionToolResultGuardInstalled?: boolean; __fusionMessageContentGuardInstalled?: boolean; + __fusionReasoningSummaryPayloadHookInstalled?: boolean; }; const FN_MEMORY_APPEND_TOOL_NAME = "fn_memory_append"; const FUSION_SHUTDOWN_WRAP_FLAG = "__fusionSessionShutdownDisposeWrapped"; @@ -1099,6 +1106,8 @@ export interface AgentOptions { fallbackThinkingLevel?: string; /** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */ defaultThinkingLevel?: string; + /** Detail requested for already-enabled Responses reasoning summaries; defaults to detailed. */ + reasoningSummaryDetail?: ReasoningSummaryDetail; /** Optional pre-configured SessionManager. When provided, the agent session * uses this instead of creating an in-memory session. Pass a file-based * SessionManager to enable session persistence and pause/resume. */ @@ -3135,10 +3144,37 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise { + if (session.__fusionReasoningSummaryPayloadHookInstalled || !session.agent) { + return; + } + + const agent = session.agent; + const previousOnPayload = agent.onPayload; + agent.onPayload = async (payload, model) => { + const previousResult = previousOnPayload ? await previousOnPayload(payload, model) : undefined; + const effectivePayload = previousResult ?? payload; + const replacement = applyReasoningSummaryToPayload( + effectivePayload, + model, + reasoningSummaryCompatibilityDisabled ? "off" : reasoningSummaryDetail, + ); + return replacement ?? previousResult; + }; + session.__fusionReasoningSummaryPayloadHookInstalled = true; + }; + let activeSession = sessionResult.session; wrapSessionDisposeWithShutdown(activeSession); installToolResultContentGuard(activeSession as AgentToolHookSession); installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike); + installReasoningSummaryPayloadHook(activeSession as AgentToolHookSession); (activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; const promptableSession = activeSession as PromptableSession; @@ -3172,6 +3208,7 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; const deltaNormalizer = createStreamingDeltaNormalizer(); targetSession.subscribe((event) => { @@ -3358,6 +3395,17 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise