Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions apps/server/src/provider/Layers/PiAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1067,26 +1101,34 @@ 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",
messageType: event.type,
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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/piTurnFailure.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
25 changes: 25 additions & 0 deletions apps/server/src/provider/piTurnFailure.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
44 changes: 44 additions & 0 deletions apps/web/src/components/chat/composerProviderRegistry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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");

Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ function reasoningDescriptorId(provider: ProviderKind, caps: ModelCapabilities):
? "thinkingBudget"
: "thinkingLevel";
}
if (provider === "pi") {
return "thinkingLevel";
}
return "reasoningEffort";
}

Expand Down
Loading