diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 492ad42c..bbf0cb80 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -42,6 +42,7 @@ import { } from "../Errors.ts"; import { PiAdapter, type PiAdapterShape } from "../Services/PiAdapter.ts"; import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { classifyPiTurnFailure } from "../piTurnFailure.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "pi" as const; @@ -771,6 +772,38 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => } satisfies ProviderRuntimeEvent); }; + const completePromptRejection = ( + context: PiSessionContext, + turnId: TurnId, + cause: unknown, + ) => { + if (context.activeTurnId !== turnId) { + return; + } + + const message = toMessage(cause, "Pi turn failed."); + const failure = classifyPiTurnFailure(message); + const completionBase = makeEventBase(context); + if (failure.state === "failed") { + offerRuntimeError(context, { message, method: "prompt", cause }); + } + context.activeTurnId = undefined; + context.activeAssistantItemId = undefined; + context.activeReasoningItemId = undefined; + context.activeToolItems.clear(); + context.session = makeSessionSnapshot(context); + offerRuntimeEvent({ + ...completionBase, + type: "turn.completed", + payload: { + state: failure.state, + stopReason: failure.stopReason, + errorMessage: message, + }, + raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, + } satisfies ProviderRuntimeEvent); + }; + const recordItem = (context: PiSessionContext, item: unknown) => { const turn = context.activeTurnId ? context.turns.find((candidate) => candidate.id === context.activeTurnId) @@ -1028,6 +1061,7 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => context.lastKnownTokenUsage = usage; const turnId = context.activeTurnId; const errorMessage = context.runtime.session.agent.state.errorMessage; + const failure = errorMessage ? classifyPiTurnFailure(errorMessage) : undefined; const leafId = context.runtime.session.sessionManager.getLeafId(); const turn = turnId ? context.turns.find((candidate) => candidate.id === turnId) @@ -1067,15 +1101,7 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, } satisfies ProviderRuntimeEvent); } - offerRuntimeEvent({ - ...makeEventBase(context), - type: "turn.completed", - payload: errorMessage - ? { state: "failed", stopReason: "error", errorMessage, usage: stats } - : { state: "completed", stopReason: null, usage: stats }, - raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, - } satisfies ProviderRuntimeEvent); - if (errorMessage) { + if (errorMessage && failure?.state === "failed") { offerRuntimeError(context, { message: errorMessage, method: "prompt", @@ -1083,10 +1109,26 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => cause: event, }); } + const completionBase = makeEventBase(context); context.activeTurnId = undefined; context.activeAssistantItemId = undefined; context.activeReasoningItemId = undefined; + context.activeToolItems.clear(); context.session = makeSessionSnapshot(context); + offerRuntimeEvent({ + ...completionBase, + type: "turn.completed", + payload: + errorMessage && failure + ? { + state: failure.state, + stopReason: failure.stopReason, + errorMessage, + usage: stats, + } + : { state: "completed", stopReason: null, usage: stats }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); return; } default: @@ -1413,16 +1455,7 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => void context.runtime.session .prompt(payload.text, payload.images.length > 0 ? { images: payload.images } : undefined) .catch((cause) => { - const message = toMessage(cause, "Pi turn failed."); - offerRuntimeEvent({ - ...makeEventBase(context), - type: "turn.completed", - payload: { state: "failed", stopReason: "error", errorMessage: message }, - raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, - } satisfies ProviderRuntimeEvent); - offerRuntimeError(context, { message, method: "prompt", cause }); - context.activeTurnId = undefined; - context.session = makeSessionSnapshot(context); + completePromptRejection(context, turnId, cause); }); return { threadId: input.threadId, @@ -1458,16 +1491,7 @@ const makePiAdapter = (options?: PiAdapterLiveOptions) => payload.images.length > 0 ? { images: payload.images } : undefined, ) .catch((cause) => { - const message = toMessage(cause, "Pi turn failed."); - offerRuntimeEvent({ - ...makeEventBase(context), - type: "turn.completed", - payload: { state: "failed", stopReason: "error", errorMessage: message }, - raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, - } satisfies ProviderRuntimeEvent); - offerRuntimeError(context, { message, method: "prompt", cause }); - context.activeTurnId = undefined; - context.session = makeSessionSnapshot(context); + completePromptRejection(context, turnId, cause); }); } return { diff --git a/apps/server/src/provider/piTurnFailure.test.ts b/apps/server/src/provider/piTurnFailure.test.ts new file mode 100644 index 00000000..be447356 --- /dev/null +++ b/apps/server/src/provider/piTurnFailure.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { classifyPiTurnFailure } from "./piTurnFailure.ts"; + +describe("classifyPiTurnFailure", () => { + it("treats Pi abort messages as interrupted turns", () => { + expect(classifyPiTurnFailure("Error: Request was aborted.")).toEqual({ + state: "interrupted", + stopReason: "aborted", + }); + }); + + it("keeps real Pi failures failed", () => { + expect(classifyPiTurnFailure("Model provider returned a 500")).toEqual({ + state: "failed", + stopReason: "error", + }); + }); +}); diff --git a/apps/server/src/provider/piTurnFailure.ts b/apps/server/src/provider/piTurnFailure.ts new file mode 100644 index 00000000..db794f05 --- /dev/null +++ b/apps/server/src/provider/piTurnFailure.ts @@ -0,0 +1,25 @@ +const PI_INTERRUPTION_MARKERS = [ + "request was aborted", + "operation was aborted", + "aborterror", + "interrupted by user", + "user aborted", +] as const; + +interface PiTurnFailureClassification { + readonly state: "failed" | "interrupted"; + readonly stopReason: "error" | "aborted"; +} + +function isPiInterruptedMessage(message: string): boolean { + const normalized = message.trim().toLowerCase(); + return PI_INTERRUPTION_MARKERS.some((marker) => normalized.includes(marker)); +} + +export function classifyPiTurnFailure(message: string): PiTurnFailureClassification { + if (isPiInterruptedMessage(message)) { + return { state: "interrupted", stopReason: "aborted" }; + } + + return { state: "failed", stopReason: "error" }; +} diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 2b2d9b94..74e2ad28 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -49,6 +49,19 @@ const CURSOR_RUNTIME_MODEL_300K: ProviderModelDescriptor = { defaultContextWindow: "300k", }; +const PI_RUNTIME_MODEL_WITH_REASONING: ProviderModelDescriptor = { + slug: "openai/gpt-5.5", + name: "GPT-5.5", + upstreamProviderId: "openai", + upstreamProviderName: "OpenAI", + supportedReasoningEfforts: [ + { value: "off", label: "Off" }, + { value: "medium", label: "Medium" }, + { value: "xhigh", label: "Extra High" }, + ], + defaultReasoningEffort: "medium", +}; + describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ @@ -415,6 +428,37 @@ describe("getComposerProviderState", () => { }); }); + it("keeps Pi runtime thinking selections on the thinkingLevel field", () => { + const selection = getComposerTraitSelection( + "pi", + "openai/gpt-5.5", + "", + { thinkingLevel: "xhigh" }, + PI_RUNTIME_MODEL_WITH_REASONING, + ); + const state = getComposerProviderState({ + provider: "pi", + model: "openai/gpt-5.5", + runtimeModel: PI_RUNTIME_MODEL_WITH_REASONING, + prompt: "", + modelOptions: { + pi: { + thinkingLevel: "xhigh", + }, + }, + }); + + expect(selection.primarySelectDescriptor?.id).toBe("thinkingLevel"); + expect(selection.effort).toBe("xhigh"); + expect(state).toEqual({ + provider: "pi", + promptEffort: "xhigh", + modelOptionsForDispatch: { + thinkingLevel: "xhigh", + }, + }); + }); + it("does not render a traits picker for OpenCode models without exposed controls", () => { const threadId = ThreadId.makeUnsafe("thread-opencode-traits-hidden"); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 8da58aac..17ae085f 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -2037,6 +2037,42 @@ describe("store read model sync", () => { }); }); + it("creates an initial sidebar summary when hot-path detail sync sees a new thread first", () => { + const threadId = ThreadId.makeUnsafe("thread-detail-before-shell"); + const initialState: AppState = { + ...makeState(makeThread()), + threadIds: [], + threads: [], + sidebarThreadSummaryById: {}, + }; + + const next = syncServerThreadDetailHotPath( + initialState, + makeReadModelThread({ + id: threadId, + title: "Visible while running", + latestTurn: { + turnId: TurnId.makeUnsafe("turn-detail-before-shell"), + state: "running", + requestedAt: "2026-02-27T00:00:00.000Z", + startedAt: "2026-02-27T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.threadIds).toContain(threadId); + expect(next.sidebarThreadSummaryById[threadId]).toMatchObject({ + id: threadId, + title: "Visible while running", + latestTurn: { + state: "running", + }, + }); + }); + it("keeps createBranchFlowCompleted sticky during stale hot-path detail syncs", () => { const threadId = ThreadId.makeUnsafe("thread-hot-path-branch-flow"); const liveState = makeState( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 423b2757..37049c79 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2301,9 +2301,10 @@ function commitThreadProjection( : state.threads; const previousSummary = state.sidebarThreadSummaryById[threadId]; - const nextSummary = shouldUpdateSidebarSummary - ? buildSidebarThreadSummary(nextThread, previousSummary) - : previousSummary; + const nextSummary = + shouldUpdateSidebarSummary || previousSummary === undefined + ? buildSidebarThreadSummary(nextThread, previousSummary) + : previousSummary; if (threads === state.threads && nextSummary === previousSummary) { return state; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index a9fd9bc0..3dc0ec88 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -288,6 +288,30 @@ describe("provider option descriptor helpers", () => { }); }); + it("maps Pi reasoning controls onto the thinkingLevel option", () => { + const descriptors = getProviderOptionDescriptors({ + provider: "pi", + caps: { + reasoningEffortLevels: [ + { value: "off", label: "Off" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + selections: { thinkingLevel: "xhigh" }, + }); + + expect(descriptors.find((descriptor) => descriptor.id === "thinkingLevel")).toMatchObject({ + type: "select", + currentValue: "xhigh", + }); + expect(descriptors.some((descriptor) => descriptor.id === "reasoningEffort")).toBe(false); + }); + it("honors explicit descriptors and serializes their current values", () => { const descriptors = getProviderOptionDescriptors({ provider: "codex", diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index f6dc9f7f..cdd4a469 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -420,6 +420,9 @@ function reasoningDescriptorId(provider: ProviderKind, caps: ModelCapabilities): ? "thinkingBudget" : "thinkingLevel"; } + if (provider === "pi") { + return "thinkingLevel"; + } return "reasoningEffort"; }