diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..73c8b8a9ecff 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -50,6 +50,20 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "pi") { + const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 524ff8d5f06c..285fa975e410 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -76,6 +76,10 @@ import { } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; +import { + rememberModelOptions, + withRememberedModelOptions, +} from "../../state/use-model-option-memory"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, @@ -492,10 +496,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentId: props.environmentId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, - onSelectModel: (option) => props.onUpdateModelSelection(option.selection), + onSelectModel: (option) => + props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), optionDescriptors: providerOptionDescriptors, - onUpdateOptionSelections: (options) => - props.onUpdateModelSelection({ ...currentModelSelection, options }), + onUpdateOptionSelections: (options) => { + rememberModelOptions( + currentModelSelection.instanceId, + currentModelSelection.model, + options ?? [], + ); + props.onUpdateModelSelection({ ...currentModelSelection, options }); + }, runtimeMode: currentRuntimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, }), diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 75ea0162ca55..108d10dd40d3 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -42,6 +42,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { rememberModelOptions } from "../../state/use-model-option-memory"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -60,7 +61,11 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; -import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; +import { + compatibleRuntimeModeForChoices, + runtimeModeChoicesForSupportedModes, + selectableChoices, +} from "./thread-settings-options"; import { modelMatchesCatalogQuery, pendingModelAfterPress, @@ -355,6 +360,7 @@ type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; readonly runtimeMode: RuntimeMode; + readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; readonly providerExpansionOverrides: ReadonlySet; @@ -415,6 +421,20 @@ function ThreadSettingsSessionProvider( : props.optionDescriptors, [pendingModel, props.optionDescriptors], ); + const displayedModel = useMemo( + () => + pendingModel ?? + props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ?? + null, + [isApplied, pendingModel, props.providerGroups], + ); + const runtimeModeChoices = runtimeModeChoicesForSupportedModes( + displayedModel?.supportedRuntimeModes, + ); + const compatibleRuntimeMode = compatibleRuntimeModeForChoices( + props.runtimeMode, + runtimeModeChoices, + ); const hasLegacyModels = useMemo( () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), @@ -434,6 +454,7 @@ function ThreadSettingsSessionProvider( return; } if (pendingModel) { + rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next); setPendingModel({ ...pendingModel, selection: { ...pendingModel.selection, options: next }, @@ -473,7 +494,8 @@ function ThreadSettingsSessionProvider( () => ({ environmentId: props.environmentId, providerGroups: props.providerGroups, - runtimeMode: props.runtimeMode, + runtimeMode: compatibleRuntimeMode, + runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, providerExpansionOverrides, @@ -495,6 +517,7 @@ function ThreadSettingsSessionProvider( [ applyOptionChange, commitPendingModel, + compatibleRuntimeMode, displayedDescriptors, providerExpansionOverrides, hasLegacyModels, @@ -506,7 +529,7 @@ function ThreadSettingsSessionProvider( providerFilter, props.onUpdateRuntimeMode, props.providerGroups, - props.runtimeMode, + runtimeModeChoices, searchQuery, showLegacyToggle, toggleProvider, @@ -731,7 +754,8 @@ function ThreadSettingsOptionsItem(props: { isLast label="Runtime" value={ - RUNTIME_MODE_CHOICES.find((choice) => choice.mode === session.runtimeMode)?.label + session.runtimeModeChoices.find((choice) => choice.mode === session.runtimeMode) + ?.label } onPress={() => props.onOpenSubmenu({ kind: "runtime" })} /> @@ -881,7 +905,7 @@ function ThreadSettingsChoiceContent(props: { const submenuContent = props.submenu.kind === "runtime" ? { - rows: RUNTIME_MODE_CHOICES.map((choice) => ({ + rows: session.runtimeModeChoices.map((choice) => ({ id: choice.mode, label: choice.label, description: choice.description, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 792bb143a834..2ea49e8076c1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -59,6 +59,10 @@ import { capturePendingTaskEditorWriteBaseline, flushPendingTaskEditorWrite, } from "../../state/pending-task-editor-writes"; +import { + rememberModelOptions, + withRememberedModelOptions, +} from "../../state/use-model-option-memory"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { @@ -203,6 +207,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -485,7 +490,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!option) { return; } - const selection = options ? { ...option.selection, options } : option.selection; + const selection = withRememberedModelOptions( + options ? { ...option.selection, options } : option.selection, + ); updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); setStickyComposerModelSelection(selection); }, @@ -496,6 +503,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!selectedModel || !selectedProjectDraftKey) { return; } + rememberModelOptions(selectedModel.instanceId, selectedModel.model, options ?? []); const nextSelection: ModelSelection = options ? { ...selectedModel, options } : { diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts index 041f8b9de010..7b13b0141edb 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -1,7 +1,7 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { selectableChoices } from "./thread-settings-options"; +import { runtimeModeChoicesForSupportedModes, selectableChoices } from "./thread-settings-options"; const effortDescriptor: Extract = { id: "effort", @@ -27,3 +27,9 @@ describe("selectableChoices", () => { ]); }); }); + +describe("runtimeModeChoicesForSupportedModes", () => { + it("keeps controls usable when forward-compatible decoding removes every advertised mode", () => { + expect(runtimeModeChoicesForSupportedModes([])).toHaveLength(4); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts index b678154f83bb..0aa375704290 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -36,6 +36,23 @@ export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ }, ]; +export function runtimeModeChoicesForSupportedModes( + supportedRuntimeModes: ReadonlyArray | undefined, +) { + return supportedRuntimeModes && supportedRuntimeModes.length > 0 + ? RUNTIME_MODE_CHOICES.filter((choice) => supportedRuntimeModes.includes(choice.mode)) + : RUNTIME_MODE_CHOICES; +} + +export function compatibleRuntimeModeForChoices( + runtimeMode: RuntimeMode, + choices: ReadonlyArray<{ readonly mode: RuntimeMode }>, +): RuntimeMode { + return choices.some((choice) => choice.mode === runtimeMode) + ? runtimeMode + : (choices[0]?.mode ?? runtimeMode); +} + export function selectableChoices( descriptor: Extract, ) { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 8c8f5a66b6be..bafef2a83ab1 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -2,6 +2,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import type { ModelCapabilities, ModelSelection, + RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { @@ -16,6 +17,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly supportedRuntimeModes?: ReadonlyArray; readonly isDefault: boolean; readonly isLegacy: boolean; readonly capabilities: ModelCapabilities | null; @@ -36,6 +38,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "pi") return "Pi"; return provider.instanceId; } @@ -142,6 +145,9 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + ...(provider.supportedRuntimeModes === undefined + ? {} + : { supportedRuntimeModes: provider.supportedRuntimeModes }), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index c5c6ca69f3c0..9e99b1179c17 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -133,6 +133,7 @@ import { flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + modelOptionMemoryAtom, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, @@ -164,6 +165,7 @@ afterEach(() => { appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); + appAtomRegistry.set(modelOptionMemoryAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); @@ -958,6 +960,44 @@ describe("mobile composer drafts", () => { }); }); + it("decodes model option memory from the composer document", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + modelOptionMemory: { + pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] }, + }, + }).modelOptionMemory, + ).toEqual({ pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] } }); + }); + + it("merges persisted option memory without replacing newer choices", async () => { + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: {}, + modelOptionMemory: { + pi: { + "xai/grok-4.6": [{ id: "thinking", value: "high" }], + "openai/gpt-5.4": [{ id: "thinking", value: "medium" }], + }, + }, + }); + appAtomRegistry.set(modelOptionMemoryAtom, { + pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] }, + }); + + ensureComposerDraftsLoaded(); + await waitForComposerDraftsLoaded(); + + expect(appAtomRegistry.get(modelOptionMemoryAtom)).toEqual({ + pi: { + "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }], + "openai/gpt-5.4": [{ id: "thinking", value: "medium" }], + }, + }); + }); + it("waits for hydration before persisting the latest composer state", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 2a613b4914da..11633b3c72c1 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -3,10 +3,12 @@ import { ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, + ProviderOptionSelection as ProviderOptionSelectionSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, + type ProviderOptionSelection, type RuntimeMode, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -100,6 +102,12 @@ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), + modelOptionMemory: Schema.optional( + Schema.Record( + Schema.String, + Schema.Record(Schema.String, Schema.Array(ProviderOptionSelectionSchema)), + ), + ), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( @@ -131,6 +139,15 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); +export type ModelOptionMemoryState = Readonly< + Record>>> +>; + +export const modelOptionMemoryAtom = Atom.make({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:model-option-memory"), +); + interface SignedOutDrafts { readonly drafts: Record; readonly queuedMessages: ReadonlyArray; @@ -188,6 +205,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; + readonly modelOptionMemory: ModelOptionMemoryState; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); @@ -221,6 +239,7 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, + modelOptionMemory: parsed.modelOptionMemory ?? {}, cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( @@ -257,6 +276,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, + modelOptionMemory: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -277,6 +297,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, + modelOptionMemory: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -298,6 +319,9 @@ async function writePersistedComposerState( schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), + ...(Object.keys(appAtomRegistry.get(modelOptionMemoryAtom)).length > 0 + ? { modelOptionMemory: appAtomRegistry.get(modelOptionMemoryAtom) } + : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 ? { @@ -528,7 +552,7 @@ export function retainComposerAttachmentFileForPreview( }); } -function schedulePersistComposerState(): void { +export function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); } @@ -574,6 +598,18 @@ export function ensureComposerDraftsLoaded(): void { ) { appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } + if (Object.keys(persisted.modelOptionMemory).length > 0) { + const current = appAtomRegistry.get(modelOptionMemoryAtom); + appAtomRegistry.set(modelOptionMemoryAtom, { + ...persisted.modelOptionMemory, + ...Object.fromEntries( + Object.entries(current).map(([instanceId, models]) => [ + instanceId, + { ...(persisted.modelOptionMemory[instanceId] ?? {}), ...models }, + ]), + ), + }); + } }) .catch((cause) => { console.warn( diff --git a/apps/mobile/src/state/use-model-option-memory.test.ts b/apps/mobile/src/state/use-model-option-memory.test.ts new file mode 100644 index 000000000000..0fe050e2dbed --- /dev/null +++ b/apps/mobile/src/state/use-model-option-memory.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { modelOptionMemoryAtom } from "./use-composer-drafts"; +import { + rememberModelOptions, + rememberedModelOptions, + withRememberedModelOptions, +} from "./use-model-option-memory"; + +const XHIGH = [{ id: "thinking", value: "xhigh" }] as const; +const HIGH = [{ id: "thinking", value: "high" }] as const; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + appAtomRegistry.set(modelOptionMemoryAtom, {}); +}); + +describe("model option memory state", () => { + it("records and looks up options per instance and model", () => { + rememberModelOptions("codex", "gpt-5.3-codex", [...XHIGH]); + rememberModelOptions("codex", "gpt-5.4", [...HIGH]); + expect(rememberedModelOptions("codex", "gpt-5.3-codex")).toEqual(XHIGH); + expect(rememberedModelOptions("codex", "gpt-5.4")).toEqual(HIGH); + expect(rememberedModelOptions("pi", "gpt-5.3-codex")).toBeUndefined(); + }); + + it("ignores empty option sets when recording", () => { + rememberModelOptions("codex", "gpt-5.4", []); + expect(rememberedModelOptions("codex", "gpt-5.4")).toBeUndefined(); + }); +}); + +describe("withRememberedModelOptions", () => { + it("restores the remembered options over descriptor defaults", () => { + rememberModelOptions("codex", "gpt-5.3-codex", [...XHIGH]); + expect( + withRememberedModelOptions({ + instanceId: "codex", + model: "gpt-5.3-codex", + options: [{ id: "reasoningEffort", value: "low" }], + }), + ).toEqual({ instanceId: "codex", model: "gpt-5.3-codex", options: XHIGH }); + }); + + it("keeps incoming selections that already match memory", () => { + rememberModelOptions("pi", "xai/grok-4.6", [...XHIGH]); + const selection = { instanceId: "pi", model: "xai/grok-4.6", options: [...XHIGH] }; + expect(withRememberedModelOptions(selection)).toBe(selection); + }); + + it("keeps incoming selections when nothing is remembered", () => { + const selection = { instanceId: "pi", model: "openai-codex/gpt-5.6-sol" }; + expect(withRememberedModelOptions(selection)).toBe(selection); + }); +}); diff --git a/apps/mobile/src/state/use-model-option-memory.ts b/apps/mobile/src/state/use-model-option-memory.ts new file mode 100644 index 000000000000..520b960d0585 --- /dev/null +++ b/apps/mobile/src/state/use-model-option-memory.ts @@ -0,0 +1,84 @@ +import type { ProviderOptionSelection } from "@t3tools/contracts"; +import { appAtomRegistry } from "./atom-registry"; +import { + modelOptionMemoryAtom, + schedulePersistComposerState, + type ModelOptionMemoryState, +} from "./use-composer-drafts"; + +/** Cross-thread memory of each model's last explicit option selection. */ +function recordModelOptionsInState( + state: ModelOptionMemoryState, + instanceId: string, + model: string, + options: ReadonlyArray, +): ModelOptionMemoryState { + if (options.length === 0) { + return state; + } + return { + ...state, + [instanceId]: { + ...(state[instanceId] ?? {}), + [model]: options, + }, + }; +} + +/** Pure lookup; `undefined` means "no memory, fall back to descriptor defaults". */ +function lookupModelOptionsInState( + state: ModelOptionMemoryState, + instanceId: string, + model: string, +): ReadonlyArray | undefined { + return state[instanceId]?.[model]; +} + +/** Records an explicitly chosen option set for one instance and model. */ +export function rememberModelOptions( + instanceId: string, + model: string, + options: ReadonlyArray, +): void { + if (options.length === 0) { + return; + } + const current = appAtomRegistry.get(modelOptionMemoryAtom); + const next = recordModelOptionsInState(current, String(instanceId), model, options); + if (next !== current) { + appAtomRegistry.set(modelOptionMemoryAtom, next); + schedulePersistComposerState(); + } +} + +export function rememberedModelOptions( + instanceId: string, + model: string, +): ReadonlyArray | undefined { + return lookupModelOptionsInState( + appAtomRegistry.get(modelOptionMemoryAtom), + String(instanceId), + model, + ); +} + +/** + * Restores a remembered option set for a freshly picked selection, keeping the + * incoming selections when nothing is remembered or they already match. + */ +export function withRememberedModelOptions< + T extends { + readonly instanceId: string; + readonly model: string; + readonly options?: ReadonlyArray; + }, +>(selection: T): T { + const remembered = rememberedModelOptions(selection.instanceId, selection.model); + if ( + remembered === undefined || + JSON.stringify(remembered) === JSON.stringify(selection.options ?? []) + ) { + return selection; + } + return { ...selection, options: remembered }; +} diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index d7237c304b6a..49c0ab0b93a3 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -1,4 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import { NodeId, OpenCodeSettings, @@ -11,6 +13,7 @@ import { RunId, MessageId, ThreadId, + type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -19,13 +22,14 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ServerConfig } from "../../config.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; -import type { ServerConfig } from "../../config.ts"; import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; import { @@ -92,6 +96,8 @@ function asyncEventStream() { }; } +const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); + function runtimePolicy( runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"], override: Partial = {}, @@ -728,6 +734,20 @@ describe("OpenCodeAdapterV2", () => { assert.isFalse(admission.admissionPending); }); + it("releases admission when an assistant completes under a provider-assigned message id", () => { + const admission = { + admissionPending: true, + admissionAccepted: false, + admissionMessageObserved: false, + idleDuringAdmission: true, + }; + + assert.equal(advanceOpenCodePromptAdmission(admission, "assistant-completed"), "release"); + assert.isTrue(admission.admissionAccepted); + assert.isTrue(admission.admissionMessageObserved); + assert.isFalse(admission.admissionPending); + }); + it("invalidates pending admission before aborting a turn", () => { const admission = { admissionGeneration: 4, @@ -1030,6 +1050,116 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(idAllocatorLayer)), ); + it.effect("adopts the handed-over provider thread identity on session create", () => + Effect.gen(function* () { + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + let createCount = 0; + const fakeClient = { + event: { + subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ + // Emits nothing and ends when the pump's abort signal fires. + stream: { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>((resolve) => { + const done = () => resolve({ done: true, value: undefined }); + if (options?.signal?.aborted) return done(); + options?.signal?.addEventListener("abort", done, { once: true }); + }), + }), + }, + }), + }, + session: { + create: async () => { + createCount += 1; + return { data: { id: `ses_native_${createCount}`, time: { created: 1, updated: 1 } } }; + }, + }, + } as unknown as OpencodeClient; + const unused = (operation: string) => () => Effect.die(`${operation} is not used`); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), + connectToOpenCodeServer: () => + Effect.succeed({ + url: "test://opencode", + version: "test", + exitCode: null, + external: true, + }), + runOpenCodeCommand: unused("runOpenCodeCommand"), + createOpenCodeSdkClient: () => fakeClient, + loadOpenCodeInventory: unused("loadOpenCodeInventory"), + loadInventoryFromCli: unused("loadInventoryFromCli"), + }; + const instanceId = ProviderInstanceId.make("opencode"); + const threadId = ThreadId.make("thread-opencode-adopt"); + const modelSelection = { instanceId, model: "default" }; + const adapter = makeOpenCodeAdapterV2({ + instanceId, + settings: OPENCODE_TEST_SETTINGS, + environment: {}, + runtime, + idAllocator, + serverConfig, + }); + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + }); + const now = yield* DateTime.now; + // The placeholder row the orchestrator creates for a first run: no + // native identity yet. The adapter must bind the created session to + // this row instead of minting a second session-keyed row. + const placeholder: OrchestrationV2ProviderThread = { + id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), + driver: OPENCODE_PROVIDER, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const adopted = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + existingProviderThread: placeholder, + }); + assert.equal(adopted.id, placeholder.id); + assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); + // Without a handed-over row the adapter still derives its own id. + const minted = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("full-access"), + }); + assert.notEqual(minted.id, placeholder.id); + assert.equal(minted.nativeThreadRef?.nativeId, "ses_native_2"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + idAllocatorLayer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-v2-adapter-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), + ), + ); + it("advertises the identity strengths exposed by the SDK boundary", () => { assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeThreadIds, "strong"); assert.equal(OpenCodeProviderCapabilitiesV2.identity.nativeTurnIds, "weak"); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index adeb80d689a7..a8f1a88abc0c 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -274,7 +274,12 @@ interface ActiveOpenCodeTurn { admissionAbortController: AbortController | null; } -type OpenCodeAdmissionSignal = "accepted" | "busy" | "idle" | "user-message"; +type OpenCodeAdmissionSignal = + | "accepted" + | "assistant-completed" + | "busy" + | "idle" + | "user-message"; type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release"; export function advanceOpenCodePromptAdmission( @@ -285,6 +290,12 @@ export function advanceOpenCodePromptAdmission( signal: OpenCodeAdmissionSignal, ): OpenCodeAdmissionAction { if (!admission.admissionPending) return "release"; + if (signal === "assistant-completed") { + admission.admissionAccepted = true; + admission.admissionMessageObserved = true; + admission.admissionPending = false; + return "release"; + } if (signal === "idle") { admission.idleDuringAdmission = true; return "hold"; @@ -2324,6 +2335,11 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid const state = threads.get(message.sessionID); const turn = state?.activeTurn; if (state === undefined || turn === null || turn === undefined) return; + // Some OpenCode versions ignore the client-provided message ID. A + // completed assistant message is definitive admission evidence, so + // let the following idle event settle the turn without weakening + // the stale-user-message guard. + advanceOpenCodePromptAdmission(turn, "assistant-completed"); for (const partId of turn.partIdsByMessage.get(message.id) ?? []) { const part = turn.parts.get(partId); if (part?.type === "text" || part?.type === "reasoning") { @@ -2663,7 +2679,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid events: Stream.fromEffectRepeat(Queue.take(events)), ensureThread: (threadInput) => Effect.gen(function* () { - if (threadInput.existingProviderThread !== undefined) { + // Only a row that already carries a native session can be + // resumed; a placeholder without one still needs session.create. + if (threadInput.existingProviderThread?.nativeThreadRef != null) { return yield* runtimeSession.resumeThread({ providerThread: threadInput.existingProviderThread, }); @@ -2682,7 +2700,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid ); const nativeSession = unwrapData("session.create", response); const createdAt = yield* DateTime.now; - const providerThread = makeProviderThread({ + const created = makeProviderThread({ idAllocator, providerInstanceId: options.instanceId, providerSessionId: input.providerSessionId, @@ -2690,6 +2708,21 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid nativeSession, now: createdAt, }); + const existing = threadInput.existingProviderThread; + // Bind the new native session to the caller's row when one was + // handed over: a second live row per app thread would make + // `activeProviderThreadId` flap between the two on every update. + const providerThread = + existing === undefined + ? created + : { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: created.nativeThreadRef, + nativeConversationHeadRef: created.nativeConversationHeadRef, + status: created.status, + updatedAt: created.updatedAt, + }; registerThread(nativeSession, providerThread, null); return providerThread; }).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts new file mode 100644 index 000000000000..37de35700af3 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -0,0 +1,2176 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CheckpointId, + EnvironmentId, + NodeId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { + ProviderAdapterV2RuntimePolicy, + type ProviderAdapterV2Event, + type ProviderAdapterV2SessionRuntime, +} from "../ProviderAdapter.ts"; +import { makePiAdapterV2, PI_PROVIDER } from "./PiAdapterV2.ts"; +import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-pi-v2-adapter-", +}).pipe(Layer.provide(NodeServices.layer)); + +const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer); + +const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const PI_INSTANCE_ID = ProviderInstanceId.make("pi"); +const THREAD_ID = ThreadId.make("thread-pi-test"); +const SESSION_ID = ProviderSessionId.make("provider-session-pi-test"); +const FAKE_SESSION_FILE = "/fake/.pi/agent/sessions/--workspace--/0001_abc.jsonl"; +/** Deliberately outside the valid pid range so a group-kill can never land. */ +const FAKE_PID = 999_999_999; + +const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, +}); + +const modelSelection = (model: string): ModelSelection => ({ + instanceId: PI_INSTANCE_ID, + model, +}); + +interface FakePi { + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly emit: (record: PiRpcRecord) => Effect.Effect; + readonly takeRequest: (type: string) => Effect.Effect; + /** Data returned by the next `get_entries` acks, consumed in order. */ + readonly queueEntries: (data: unknown) => void; + /** Data returned by the next active-branch `get_messages` acks. */ + readonly queueMessages: (data: unknown) => void; + /** Make the next `switch_session` ack report an extension veto. */ + readonly vetoNextSwitch: () => void; + /** Data returned by the next `get_state` acks, consumed in order. */ + readonly queueState: (data: unknown) => void; + /** Hold the next `get_state` response until the test resolves it. */ + readonly deferNextState: () => void; + /** Resolve the held `get_state` request. */ + readonly resolveDeferredState: (data: unknown) => Effect.Effect; + /** Reject the next `get_state` request. */ + readonly failNextState: () => void; + /** Every request received by the fake process. */ + readonly allRequests: () => ReadonlyArray; + /** Data returned by the next `get_session_stats` acks, consumed in order. */ + readonly queueStats: (data: unknown) => void; + /** Data returned by the next `get_commands` acks, consumed in order. */ + readonly queueCommands: (data: unknown) => void; + /** Make the next `get_commands` ack fail. */ + readonly failNextCommands: () => void; + /** Close the fake process stdout stream. */ + readonly closeStdout: Effect.Effect; + readonly lastSpawn: () => { + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + }; +} + +/** + * In-process fake `pi --mode rpc`: captures every stdin record, auto-acks + * requests with canned data, and lets tests push protocol events to stdout. + */ +const makeFakePi: Effect.Effect = Effect.gen(function* () { + const stdout = yield* Queue.unbounded(); + const requests = yield* Queue.unbounded(); + const entriesQueue: Array = []; + const messagesQueue: Array = []; + const stateQueue: Array = []; + const statsQueue: Array = []; + const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; + const allRequests: Array = []; + let deferState = false; + let deferredStateRequest: PiRpcRecord | undefined; + let failState = false; + let vetoSwitch = false; + let stdinBuffer = ""; + + const emit = (record: PiRpcRecord) => + Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( + Effect.asVoid, + ); + + const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { + if (typeof record["id"] !== "string") return null; + const base = { + type: "response", + id: record["id"], + command: String(record["type"]), + success: true, + }; + switch (record["type"]) { + case "get_state": + if (failState) { + failState = false; + return { ...base, success: false, error: "state unavailable" }; + } + return { + ...base, + data: stateQueue.shift() ?? { + model: null, + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + autoCompactionEnabled: true, + sessionFile: FAKE_SESSION_FILE, + sessionId: "abc", + }, + }; + case "switch_session": { + const cancelled = vetoSwitch; + vetoSwitch = false; + return { ...base, data: { cancelled } }; + } + case "get_entries": + return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; + case "get_messages": + return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; + case "get_session_stats": + return { ...base, data: statsQueue.shift() ?? {} }; + case "get_commands": + return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; + case "fork": + return { ...base, data: { cancelled: false, message: "forked" } }; + default: + return base; + } + }; + + const handleStdinChunk = (chunk: Uint8Array) => + Effect.gen(function* () { + stdinBuffer += new TextDecoder().decode(chunk); + while (true) { + const newline = stdinBuffer.indexOf("\n"); + if (newline === -1) return; + const line = stdinBuffer.slice(0, newline); + stdinBuffer = stdinBuffer.slice(newline + 1); + if (line.length === 0) continue; + const record = decodeJsonLine(line) as PiRpcRecord; + allRequests.push(record); + yield* Queue.offer(requests, record); + if (record["type"] === "get_state" && deferState) { + deferState = false; + deferredStateRequest = record; + continue; + } + const response = respondTo(record); + if (response !== null) yield* emit(response); + } + }); + + let lastSpawn: { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv } = { + args: [], + env: {}, + }; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (ChildProcess.isStandardCommand(command)) { + lastSpawn = { + args: command.args, + env: command.options.env ?? {}, + }; + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach(handleStdinChunk), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + const takeRequest = (type: string): Effect.Effect => + Effect.gen(function* () { + while (true) { + const record = yield* Queue.take(requests); + if (record["type"] === type) return record; + } + }); + + return { + spawner, + emit, + takeRequest, + queueEntries: (data) => entriesQueue.push(data), + queueMessages: (data) => messagesQueue.push(data), + deferNextState: () => { + deferState = true; + }, + resolveDeferredState: (data) => + Effect.gen(function* () { + const record = deferredStateRequest; + assert.isDefined(record); + deferredStateRequest = undefined; + yield* emit({ + type: "response", + id: record!["id"], + command: "get_state", + success: true, + data, + }); + }), + failNextState: () => { + failState = true; + }, + allRequests: () => allRequests, + vetoNextSwitch: () => { + vetoSwitch = true; + }, + queueState: (data) => stateQueue.push(data), + queueStats: (data) => statsQueue.push(data), + queueCommands: (data) => commandsQueue.push({ success: true, data }), + failNextCommands: () => commandsQueue.push({ success: false }), + closeStdout: Queue.end(stdout), + lastSpawn: () => lastSpawn, + } satisfies FakePi; +}); + +const makeAdapter = Effect.fnUntraced(function* (fake: FakePi, launchArgs = "") { + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + return makePiAdapterV2({ + instanceId: PI_INSTANCE_ID, + settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, + environment: {}, + spawner: fake.spawner, + fileSystem, + idAllocator, + serverConfig, + }); +}); + +const openRuntime = Effect.fnUntraced(function* ( + fake: FakePi, + model = "default", + threadId = THREAD_ID, + providerSessionId = SESSION_ID, +) { + const adapter = yield* makeAdapter(fake); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection: modelSelection(model), + runtimePolicy, + }); + const emitted = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(emitted, event)), + Effect.forkScoped, + ); + const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => + Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(emitted); + if (predicate(event)) return event; + } + }); + return { runtime, takeEvent }; +}); + +const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { + const now = yield* DateTime.now; + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: "project:fixture:pi" as OrchestrationV2AppThread["projectId"], + title: "Pi test thread", + providerInstanceId: PI_INSTANCE_ID, + modelSelection: modelSelection(model), + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + } satisfies OrchestrationV2AppThread; +}); + +const startTurn = Effect.fnUntraced(function* ( + runtime: ProviderAdapterV2SessionRuntime, + providerThread: OrchestrationV2ProviderThread, + model = "default", + attachments: ReadonlyArray = [], + text = "Hello pi", + selection?: ModelSelection, + runOrdinal = 1, + threadId = THREAD_ID, +) { + const appThread = yield* makeAppThread(model, threadId); + const runId = RunId.make(`run:${threadId}:${runOrdinal}`); + yield* runtime.startTurn({ + appThread, + threadId, + runId, + runOrdinal, + providerTurnOrdinal: runOrdinal, + attemptId: RunAttemptId.make(`run-attempt:${runId}:1`), + rootNodeId: NodeId.make(`node:${runId}:root`), + providerThread, + message: { + messageId: `message:${threadId}:${runOrdinal}` as never, + text, + attachments, + createdBy: "user", + creationSource: "web", + }, + modelSelection: selection ?? modelSelection(model), + runtimePolicy, + }); +}); + +const expectModelFailure = (errorMessage: string) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage, + }, + }); + yield* fake.emit({ type: "agent_settled" }); + + const sessionError = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "error", + ); + assert.isTrue( + sessionError.type === "provider_session.updated" && + sessionError.providerSession.lastError === errorMessage, + ); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === errorMessage, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)); + +describe("PiAdapterV2", () => { + it.effect("stops provider-initiated work that has no T3 turn owner", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* fake.emit({ type: "agent_start" }); + + const sessionError = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "error", + ); + assert.isTrue( + sessionError.type === "provider_session.updated" && + sessionError.providerSession.lastError?.includes("invisible tool execution") === true, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("injects the T3 MCP extension and bearer when a session exists", () => + Effect.gen(function* () { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-pi-mcp"), + threadId: THREAD_ID, + providerSessionId: "mcp-session-pi", + providerInstanceId: PI_INSTANCE_ID, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-pi-token", + browserToolsAvailable: true, + }); + const fake = yield* makeFakePi; + yield* openRuntime(fake); + const spawn = fake.lastSpawn(); + assert.isTrue(spawn.args.includes("--extension")); + const extensions = spawn.args.flatMap((arg, index) => + arg === "--extension" ? [spawn.args[index + 1]] : [], + ); + assert.isFalse(spawn.args.includes("--no-extensions")); + assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); + assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); + assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); + assert.equal(spawn.env.T3_PI_RUNTIME_MODE, "full-access"); + }).pipe( + Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID))), + Effect.scoped, + Effect.provide(testLayer), + ), + ); + + it.effect("registers the thread from get_state and resumes via switch_session", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + assert.equal(providerThread.driver, PI_PROVIDER); + assert.isFalse(fake.lastSpawn().args.includes("--no-extensions")); + + yield* runtime.resumeThread({ providerThread }); + const switchRequest = yield* fake.takeRequest("switch_session"); + assert.equal(switchRequest["sessionPath"], FAKE_SESSION_FILE); + + yield* startTurn(runtime, providerThread, "anthropic/claude-sonnet"); + const setModel = yield* fake.takeRequest("set_model"); + assert.equal(setModel["provider"], "anthropic"); + assert.equal(runtime.providerSession.model, "anthropic/claude-sonnet"); + yield* fake.takeRequest("prompt"); + const error = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.match(String(error.cause), /while a turn is active/); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("adopts the run's provider thread identity instead of minting a second row", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const now = yield* DateTime.now; + // The placeholder row the orchestrator creates for a first run: no + // native identity yet. The adapter must bind the pi session to this + // row instead of registering a second session-file-keyed row, or the + // projection ends up with two live rows per app thread. + const placeholder: OrchestrationV2ProviderThread = { + id: ProviderThreadId.make("thread:provider:pi:native-thread:pending:run:thread-pi-test:1"), + driver: PI_PROVIDER, + providerInstanceId: PI_INSTANCE_ID, + providerSessionId: SESSION_ID, + appThreadId: THREAD_ID, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + existingProviderThread: placeholder, + }); + assert.equal(providerThread.id, placeholder.id); + assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + const updated = yield* takeEvent((event) => event.type === "provider_thread.updated"); + assert.isTrue( + updated.type === "provider_thread.updated" && updated.providerThread.id === placeholder.id, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("resets applied thinking when returning to Pi default", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueState({ + model: { provider: "xai", id: "grok-4.6" }, + thinkingLevel: "medium", + sessionFile: FAKE_SESSION_FILE, + sessionId: "abc", + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + // An explicit effort on a concrete model. + yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { + instanceId: PI_INSTANCE_ID, + model: "xai/grok-4.6", + options: [{ id: "thinking", value: "high" }], + }); + const modelRequest = yield* fake.takeRequest("set_model"); + assert.equal(modelRequest["provider"], "xai"); + assert.equal(modelRequest["modelId"], "grok-4.6"); + const levelRequest = yield* fake.takeRequest("set_thinking_level"); + assert.equal(levelRequest["level"], "high"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + + // Back to Pi default with no explicit thinking choice of its own. + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Hello pi", + { + instanceId: PI_INSTANCE_ID, + model: "default", + }, + 2, + ); + const replayModel = yield* fake.takeRequest("set_model"); + assert.equal(replayModel["provider"], "xai"); + assert.equal(replayModel["modelId"], "grok-4.6"); + const resetLevel = yield* fake.takeRequest("set_thinking_level"); + assert.equal(resetLevel["level"], "medium"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("expands a selected $ skill through Pi's native skill command", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueCommands({ + commands: [ + { + name: "skill:repo-review", + description: "Review this repository.", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/repo-review/SKILL.md", + scope: "project", + }, + }, + ], + }); + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Review this change please $repo-review", + ); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "/skill:repo-review Review this change please"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("expands every selected $ skill through Pi native skill commands", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueCommands({ + commands: [ + { + name: "skill:repo-review", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/repo-review/SKILL.md", + scope: "project", + }, + }, + { + name: "skill:deploy", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/deploy/SKILL.md", + scope: "project", + }, + }, + ], + }); + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn(runtime, providerThread, "default", [], "use $repo-review and $deploy"); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "/skill:repo-review /skill:deploy use and"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("streams assistant text and settles a completed turn on agent_settled", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); + // Fire-and-forget: extension slash commands can hold the ack open on a + // user dialog, so the prompt must carry no correlation id to await. + assert.equal(prompt["id"], undefined); + + // A normal prompt ack only confirms that Pi accepted the command. Agent + // activity may follow it, so the adapter must still wait for settlement. + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "Hel" }, + }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "lo" }, + }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Hello" }, + }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + stopReason: "stop", + }, + }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + fake.queueStats({ + tokens: { input: 12_000, output: 500, cacheRead: 8_000, cacheWrite: 0, total: 20_500 }, + toolCalls: 3, + contextUsage: { tokens: 20_500, contextWindow: 200_000, percent: 10.25 }, + }); + yield* fake.emit({ type: "agent_settled" }); + + const assistantItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.streaming === false, + ); + assert.isTrue( + assistantItem.type === "turn_item.updated" && + assistantItem.turnItem.type === "assistant_message" && + assistantItem.turnItem.text === "Hello", + ); + // Session stats ride on the settled provider turn so the shared meter + // picks them up through the base's per-turn `tokenUsage` (#8144). + const completedTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + const { updatedAt, ...tokenUsage } = + completedTurn.type === "provider_turn.updated" + ? (completedTurn.providerTurn.tokenUsage ?? {}) + : {}; + assert.isString(updatedAt); + assert.deepEqual(tokenUsage, { + usedTokens: 20_500, + maxTokens: 200_000, + inputTokens: 12_000, + cachedInputTokens: 8_000, + outputTokens: 500, + }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + // An acknowledged stats request can still omit usable window values. + // That turn then carries no report, so the meter keeps the last one. + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + fake.queueStats({ contextUsage: { tokens: null, contextWindow: 200_000 } }); + yield* fake.emit({ type: "agent_settled" }); + const unreportedTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.isUndefined( + unreportedTurn.type === "provider_turn.updated" + ? unreportedTurn.providerTurn.tokenUsage + : null, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("captures session-tree refs at turn boundaries and rolls back via fork", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + // First get_entries ack baselines the leaf during ensureThread; the + // second answers the finalize capture with this turn's user entry. + fake.queueEntries({ entries: [], leafId: "leaf-0" }); + fake.queueEntries({ + entries: [{ type: "message", id: "u1", message: { role: "user" } }], + leafId: "a1", + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const finalTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + finalTurn.type === "provider_turn.updated" && + finalTurn.providerTurn.nativeTurnRef?.nativeId === "u1" && + finalTurn.providerTurn.nativeTurnRef.strength === "strong", + ); + + const turnRef = (ordinal: number, nativeId: string): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`provider-turn:test:${ordinal}`), + providerThreadId: providerThread.id, + nodeId: NodeId.make(`node:test:${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + const rollbackSnapshot = yield* runtime.rollbackThread({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:test:1"), + appRunOrdinal: 1, + providerTurn: turnRef(1, "u1"), + }, + providerThreadTurns: [turnRef(1, "u1"), turnRef(2, "u2")], + }); + const fork = yield* fake.takeRequest("fork"); + assert.equal(fork["entryId"], "u2"); + assert.equal(rollbackSnapshot.providerThread.id, providerThread.id); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("observes official subagent results without inventing child threads", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "tool_execution_update", + toolCallId: "call_sub", + toolName: "subagent", + partialResult: { + content: [{ type: "text", text: "(running...)" }], + details: { + mode: "single", + results: [ + { + agent: "scout", + task: "map the repo", + exitCode: 0, + stderr: "", + sessionFile: "/ignored/custom-extension-session.jsonl", + messages: [ + { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, + ], + }, + ], + }, + }, + }); + const running = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "running", + ); + assert.isTrue( + running.type === "subagent.updated" && + running.subagent.title === "scout" && + running.subagent.prompt === "map the repo" && + running.subagent.progress === "scanning files" && + running.subagent.childThreadId === null, + ); + + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_sub", + toolName: "subagent", + isError: false, + result: { + content: [{ type: "text", text: "done" }], + details: { + mode: "single", + results: [ + { + agent: "scout", + task: "map the repo", + exitCode: 0, + stopReason: "stop", + stderr: "", + messages: [ + { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, + ], + }, + ], + }, + }, + }); + const doneCard = yield* takeEvent( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ); + assert.isTrue( + doneCard.type === "subagent.updated" && + doneCard.subagent.result === "repo has one file" && + doneCard.subagent.childThreadId === null, + ); + const subagentItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status === "completed", + ); + assert.isTrue( + subagentItem.type === "turn_item.updated" && + subagentItem.turnItem.type === "subagent" && + subagentItem.turnItem.childThreadId === null, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/command-only"); + yield* fake.takeRequest("prompt"); + // A pure extension command: dialog + notify, then the deferred ack — + // pi emits no agent_start/agent_settled at all. + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-cmd", + method: "notify", + message: "done", + notifyType: "info", + }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + // The adapter probes get_state (auto-acked idle by the fake), then + // settles the turn as completed. + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("sends RPC compact for /compact instead of a prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/compact keep the auth rewrite"); + const compact = yield* fake.takeRequest("compact"); + assert.equal(compact["customInstructions"], "keep the auth rewrite"); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + const completed = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + assert.isTrue( + completed.type === "turn_item.updated" && + completed.turnItem.type === "compaction" && + completed.turnItem.title === "Context compacted", + ); + yield* fake.emit({ type: "response", command: "compact", success: true }); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("leaves /compacted as an ordinary prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/compacted please"); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "/compacted please"); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "compact")); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps a too-small compact as a failed compaction item", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/compact"); + yield* fake.takeRequest("compact"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: null, + aborted: false, + errorMessage: "Compaction failed: Nothing to compact (session too small)", + }); + const failed = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "failed", + ); + assert.isTrue( + failed.type === "turn_item.updated" && + failed.turnItem.type === "compaction" && + failed.turnItem.title === "Context compaction failed", + ); + yield* fake.emit({ + type: "response", + command: "compact", + success: false, + error: "Nothing to compact (session too small)", + }); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("fails a compact that never started", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/compact"); + yield* fake.takeRequest("compact"); + yield* fake.emit({ + type: "response", + command: "compact", + success: false, + error: "Nothing to compact (session too small)", + }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === "Nothing to compact (session too small)", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("restarts Pi when Stop interrupts a user compact", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "/compact"); + yield* fake.takeRequest("compact"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("steers /compact as RPC compact instead of a prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + yield* fake.emit({ type: "agent_start" }); + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer-compact" as never, + text: "/compact keep the tests", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const compact = yield* fake.takeRequest("compact"); + assert.equal(compact["customInstructions"], "keep the tests"); + assert.isUndefined(compact["streamingBehavior"]); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + yield* fake.emit({ type: "response", command: "compact", success: true }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("persists current xAI capacity text for the thread error banner", () => + expectModelFailure("The model is currently at capacity due to high demand."), + ); + + it.effect("persists extension-normalized xAI capacity text for the thread error banner", () => + expectModelFailure( + "Provider overloaded: The model is currently at capacity due to high demand.", + ), + ); + + it.effect("stops with restart by aborting and then terminating the process", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + requestRuntimeRestart: true, + }); + yield* fake.takeRequest("abort"); + // The fake process cannot die; pi settling still closes the turn as + // interrupted rather than failed. + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + yield* fake.closeStdout; + const stopped = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "stopped", + ); + assert.equal( + stopped.type === "provider_session.updated" ? stopped.providerSession.lastError : undefined, + null, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("emits session-start dialogs before a turn exists", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + // Project-trust style prompt before any turn exists. + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-trust", + method: "confirm", + title: "Run project extensions?", + message: "This project has .pi/extensions.", + }); + const pending = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + const requestId = + pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; + yield* runtime.respondToRuntimeRequest({ requestId: requestId!, decision: "accept" }); + const uiResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(uiResponse["id"], "ui-trust"); + assert.equal(uiResponse["confirmed"], true); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("reads a thread snapshot from pi's active branch", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + fake.queueMessages({ + messages: [ + { + role: "user", + content: "hello pi", + timestamp: 1700000000000, + }, + { + role: "assistant", + content: [{ type: "text", text: "hello back" }], + timestamp: 1700000001000, + }, + { role: "toolResult", content: [] }, + ], + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + assert.equal(snapshot.messages.length, 2); + assert.equal(snapshot.messages[0]!.role, "user"); + assert.equal(snapshot.messages[0]!.text, "hello pi"); + assert.equal(snapshot.messages[1]!.role, "assistant"); + assert.equal(snapshot.messages[1]!.text, "hello back"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("steers the active turn through pi's native steer command", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer" as never, + text: "Focus on tests", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const steer = yield* fake.takeRequest("prompt"); + assert.equal(steer["message"], "Focus on tests"); + assert.equal(steer["streamingBehavior"], "steer"); + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:command" as never, + text: "/my-command", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const command = yield* fake.takeRequest("prompt"); + assert.equal(command["message"], "/my-command"); + + yield* fake.emit({ type: "agent_settled" }); + const firstTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(firstTerminal.type === "turn.terminal" && firstTerminal.status === "completed"); + + yield* startTurn(runtime, providerThread, "default", [], "Second turn", undefined, 2); + yield* fake.takeRequest("prompt"); + // The slash command's response belongs to the settled first turn. It + // must not consume or fail the second turn's prompt acknowledgement. + yield* fake.emit({ + type: "response", + command: "prompt", + success: false, + error: "late command rejection", + }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const secondTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + secondTerminal.type === "turn.terminal" && secondTerminal.status === "completed", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("shows compaction progress and completes the same activity row", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "compaction_start", reason: "threshold" }); + + const runningNode = yield* takeEvent( + (event) => event.type === "node.updated" && event.node.kind === "system", + ); + const runningItem = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + assert.isTrue( + runningNode.type === "node.updated" && + runningNode.node.status === "running" && + runningItem.type === "turn_item.updated" && + runningItem.turnItem.type === "compaction" && + runningItem.turnItem.status === "running" && + runningItem.turnItem.title === "Compacting context...", + ); + + yield* fake.emit({ + type: "compaction_end", + reason: "threshold", + result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, + aborted: false, + willRetry: false, + }); + const completedNode = yield* takeEvent( + (event) => event.type === "node.updated" && event.node.kind === "system", + ); + const completedItem = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + assert.isTrue( + runningNode.type === "node.updated" && + completedNode.type === "node.updated" && + runningItem.type === "turn_item.updated" && + runningItem.turnItem.type === "compaction" && + completedItem.type === "turn_item.updated" && + completedItem.turnItem.type === "compaction" && + completedNode.node.id === runningNode.node.id && + completedNode.node.status === "completed" && + completedItem.turnItem.id === runningItem.turnItem.id && + completedItem.turnItem.ordinal === runningItem.turnItem.ordinal && + completedItem.turnItem.startedAt === runningItem.turnItem.startedAt && + completedItem.turnItem.status === "completed" && + completedItem.turnItem.title === "Context compacted" && + completedItem.turnItem.beforeTokenCount === 200_000 && + completedItem.turnItem.afterTokenCount === 3_400, + ); + + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("uses distinct compaction IDs for first turns in separate threads", () => + Effect.gen(function* () { + const firstFake = yield* makeFakePi; + const { runtime: firstRuntime, takeEvent: takeFirstEvent } = yield* openRuntime(firstFake); + const firstProviderThread = yield* firstRuntime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(firstRuntime, firstProviderThread); + yield* firstFake.takeRequest("prompt"); + yield* firstFake.emit({ type: "agent_start" }); + yield* firstFake.emit({ type: "compaction_start", reason: "threshold" }); + const first = yield* takeFirstEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + const secondThreadId = ThreadId.make("thread-pi-test-second"); + const secondFake = yield* makeFakePi; + const { runtime: secondRuntime, takeEvent: takeSecondEvent } = yield* openRuntime( + secondFake, + "default", + secondThreadId, + ProviderSessionId.make("provider-session-pi-test-second"), + ); + const secondProviderThread = yield* secondRuntime.ensureThread({ + threadId: secondThreadId, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn( + secondRuntime, + secondProviderThread, + "default", + [], + "Hello from another thread", + undefined, + 1, + secondThreadId, + ); + yield* secondFake.takeRequest("prompt"); + yield* secondFake.emit({ type: "agent_start" }); + yield* secondFake.emit({ type: "compaction_start", reason: "threshold" }); + const second = yield* takeSecondEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + assert.isTrue( + first.type === "turn_item.updated" && + first.turnItem.type === "compaction" && + second.type === "turn_item.updated" && + second.turnItem.type === "compaction" && + first.turnItem.ordinal === second.turnItem.ordinal && + first.turnItem.id !== second.turnItem.id, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("shows aborted compactions as stopped", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + const running = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: null, + aborted: true, + }); + const stopped = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + assert.isTrue( + running.type === "turn_item.updated" && + running.turnItem.type === "compaction" && + stopped.type === "turn_item.updated" && + stopped.turnItem.type === "compaction" && + stopped.turnItem.id === running.turnItem.id && + stopped.turnItem.status === "cancelled" && + stopped.turnItem.title === "Context compaction stopped", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps the turn open and updates one retry row through final failure", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: true }); + yield* fake.emit({ + type: "auto_retry_start", + attempt: 1, + maxAttempts: 3, + delayMs: 3_000, + errorMessage: "529 overloaded", + }); + const firstRetry = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + assert.isTrue( + firstRetry.type === "turn_item.updated" && + firstRetry.turnItem.type === "error" && + firstRetry.turnItem.status === "running" && + firstRetry.turnItem.title === "Provider retry" && + firstRetry.turnItem.failure.retryable === true && + firstRetry.turnItem.retry?.attempt === 1 && + firstRetry.turnItem.retry.maxAttempts === 3 && + firstRetry.turnItem.retry.retryDelayMs === 3_000, + ); + + yield* fake.emit({ + type: "auto_retry_start", + attempt: 3, + maxAttempts: 3, + delayMs: 12_000, + errorMessage: "529 still overloaded", + }); + const lastRetry = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + assert.isTrue( + firstRetry.type === "turn_item.updated" && + firstRetry.turnItem.type === "error" && + lastRetry.type === "turn_item.updated" && + lastRetry.turnItem.type === "error" && + lastRetry.turnItem.id === firstRetry.turnItem.id && + lastRetry.turnItem.startedAt === firstRetry.turnItem.startedAt && + lastRetry.turnItem.retry?.attempt === 3, + ); + + yield* fake.emit({ + type: "auto_retry_end", + success: false, + attempt: 3, + finalError: "529 overloaded", + }); + const failedRetry = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.status === "failed", + ); + assert.isTrue( + firstRetry.type === "turn_item.updated" && + firstRetry.turnItem.type === "error" && + failedRetry.type === "turn_item.updated" && + failedRetry.turnItem.type === "error" && + failedRetry.turnItem.id === firstRetry.turnItem.id && + failedRetry.turnItem.title === "Provider error" && + failedRetry.turnItem.failure.retryable === false && + failedRetry.turnItem.retry?.attempt === 3 && + failedRetry.turnItem.retry.maxAttempts === 3, + ); + yield* fake.emit({ type: "agent_settled" }); + + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "failed"); + assert.isTrue( + firstRetry.type === "turn_item.updated" && + firstRetry.turnItem.type === "error" && + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message.includes("overloaded") && + terminal.retry?.attempt === 3 && + terminal.retry.maxAttempts === 3 && + terminal.retryStartedAt === firstRetry.turnItem.startedAt, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("preserves exhausted retry failure through non-retrying compaction", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "auto_retry_start", + attempt: 5, + maxAttempts: 5, + delayMs: 48_000, + errorMessage: "socket timed out", + }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + yield* fake.emit({ + type: "auto_retry_end", + success: false, + attempt: 5, + finalError: "socket timed out", + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.status === "failed", + ); + yield* fake.emit({ type: "compaction_start", reason: "threshold" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + yield* fake.emit({ + type: "compaction_end", + reason: "threshold", + result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, + aborted: false, + willRetry: false, + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + yield* fake.emit({ type: "agent_settled" }); + + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === "socket timed out" && + terminal.retry?.attempt === 5 && + terminal.retry.maxAttempts === 5, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("marks retry progress recovered when Pi succeeds", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "temporary network failure", + }, + }); + yield* fake.emit({ + type: "auto_retry_start", + attempt: 1, + maxAttempts: 5, + delayMs: 3_000, + errorMessage: "temporary network failure", + }); + const running = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + yield* fake.emit({ type: "auto_retry_end", success: true, attempt: 1 }); + const recovered = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + assert.isTrue( + running.type === "turn_item.updated" && + running.turnItem.type === "error" && + recovered.type === "turn_item.updated" && + recovered.turnItem.type === "error" && + recovered.turnItem.id === running.turnItem.id && + recovered.turnItem.status === "completed" && + recovered.turnItem.title === "Provider recovered", + ); + + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("stops active retry progress when the turn is interrupted", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const runningTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + runningTurn.type === "provider_turn.updated" ? runningTurn.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + + yield* fake.emit({ + type: "auto_retry_start", + attempt: 2, + maxAttempts: 5, + delayMs: 6_000, + errorMessage: "temporary network failure", + }); + const retrying = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + yield* fake.takeRequest("abort"); + yield* fake.emit({ type: "agent_settled" }); + + const stopped = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", + ); + assert.isTrue( + retrying.type === "turn_item.updated" && + retrying.turnItem.type === "error" && + stopped.type === "turn_item.updated" && + stopped.turnItem.type === "error" && + stopped.turnItem.id === retrying.turnItem.id && + stopped.turnItem.status === "interrupted" && + stopped.turnItem.title === "Provider retry stopped", + ); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps extension-started compaction and recovery in the settled turn", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + + // Extension ctx.compact() waits for this first settlement, then starts + // compaction in a detached continuation. + fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + fake.queueState({ isStreaming: true, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + yield* fake.emit({ type: "agent_start" }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + yield* fake.takeRequest("get_state"); + + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps working after a settle probe fails before detached compaction", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + + fake.failNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, + aborted: false, + willRetry: false, + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("restarts Pi when Stop interrupts detached compaction", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + assert.equal(running.type, "provider_turn.updated"); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + yield* fake.emit({ type: "agent_start" }); + + fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ); + + yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); + yield* fake.closeStdout; + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("steers through an atomic prompt that can restart an idle Pi run", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:steer" as never, + text: "Focus on tests", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const steer = yield* fake.takeRequest("prompt"); + assert.equal(steer["message"], "Focus on tests"); + assert.equal(steer["streamingBehavior"], "steer"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("ignores an idle snapshot made stale by a steer", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const running = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + assert.equal(running.type, "provider_turn.updated"); + const providerTurnId = + running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + yield* fake.emit({ type: "agent_start" }); + + fake.deferNextState(); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: providerTurnId!, + message: { + messageId: "message:thread-pi-test:late-steer" as never, + text: "Continue after settlement", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + yield* fake.takeRequest("prompt"); + yield* fake.resolveDeferredState({ + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }); + + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Recovered" }, + }); + yield* fake.emit({ + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "Recovered" }], + stopReason: "stop", + }, + }); + const assistantItem = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.streaming === false, + ); + assert.isTrue( + assistantItem.type === "turn_item.updated" && + assistantItem.turnItem.type === "assistant_message" && + assistantItem.turnItem.text === "Recovered", + ); + + fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.takeRequest("get_state"); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); +}); + +describe("PiRpc framing", () => { + it.effect("reassembles records across chunk boundaries and strips CR", () => + Effect.gen(function* () { + const stdout = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + const connection = yield* makePiRpcConnection({ + command: "pi", + args: ["--mode", "rpc"], + cwd: undefined, + env: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const push = (text: string) => + Queue.offer(stdout, new TextEncoder().encode(text)).pipe(Effect.asVoid); + yield* push('{"type":"agent_'); + yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); + + const first = yield* Queue.take(connection.events); + assert.equal(first["type"], "agent_start"); + const second = yield* Queue.take(connection.events); + assert.equal(second["type"], "agent_settled"); + const third = yield* Queue.take(connection.events); + assert.equal(third["type"], "queue_update"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +// This fails before a provider transcript exists, so a replay fixture is not +// an honest fit. The boundary is the stdio transport seeing stdout end. +describe("PiRpc early process exit", () => { + const makeHandle = (options: { + readonly exitCode: Effect.Effect; + readonly stderr: Stream.Stream; + }) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: options.exitCode, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: options.stderr, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + + it.effect("reports a nonzero exit code instead of an unexplained stdout close", () => + Effect.gen(function* () { + const secret = "API_KEY=super-secret\n"; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeHandle({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.fromIterable([new TextEncoder().encode(secret)]), + }), + ), + ); + const connection = yield* makePiRpcConnection({ + command: "pi", + args: ["--mode", "rpc"], + cwd: undefined, + env: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const error = yield* Queue.take(connection.events).pipe(Effect.flip); + assert.equal(error._tag, "PiRpcError"); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "pi process exited with code 1"); + assert.isFalse((error.detail ?? "").includes("API_KEY")); + assert.isFalse((error.detail ?? "").includes("super-secret")); + assert.isFalse(error.message.includes("API_KEY")); + assert.isFalse(error.message.includes("super-secret")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps the unexplained stdout-close message when the process has not exited", () => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeHandle({ + exitCode: Effect.never, + stderr: Stream.empty, + }), + ), + ); + const connection = yield* makePiRpcConnection({ + command: "pi", + args: ["--mode", "rpc"], + cwd: undefined, + env: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); + yield* TestClock.adjust(Duration.millis(300)); + const error = yield* Fiber.join(fiber); + assert.equal(error._tag, "PiRpcError"); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "pi process closed stdout"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps the exit-code diagnosis when stdin breaks while exit is still pending", () => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.sleep(Duration.millis(50)).pipe( + Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(1))), + ), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "stdin", + description: "broken pipe", + }), + ), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + const connection = yield* makePiRpcConnection({ + command: "pi", + args: ["--mode", "rpc"], + cwd: undefined, + env: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); + yield* TestClock.adjust(Duration.millis(300)); + const error = yield* Fiber.join(fiber); + assert.equal(error._tag, "PiRpcError"); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "pi process exited with code 1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts new file mode 100644 index 000000000000..bdf4f921dbc5 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -0,0 +1,2724 @@ +/** + * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent + * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. + * + * Design intent: honor the user's Pi customizations. The process is spawned + * with no `--no-*` flags, so the user's extensions, skills, prompt templates, + * AGENTS.md / SYSTEM.md context, settings.json, custom models, and auth all + * load exactly as they do in the `pi` TUI. Sessions are stored by Pi itself + * (default `~/.pi/agent/sessions/`), and the session file path is the durable + * `nativeThreadRef`, so a thread started in T3 can be resumed from the TUI + * and vice versa. + * + * Turn lifecycle: `agent_settled` is the only terminal signal. `agent_end` + * merely closes one low-level run — compaction retries, auto-retries, and + * queued continuations may still follow it, so the turn stays open until Pi + * reports the session settled. An extension can start detached compaction as + * that signal unwinds, so the adapter confirms Pi is idle before terminalizing. + * + * Extension UI: Pi extensions raise dialogs through `extension_ui_request`. + * Dialog methods become v2 runtime requests (`confirm` → approval_request, + * `select`/`input`/`editor` → user_input_request); answers travel back as + * `extension_ui_response`. `notify` becomes a completed activity item. + * Terminal-only decoration such as status, widget, title, and editor-text + * updates has no matching T3 surface and is ignored. + */ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { + defaultInstanceIdForDriver, + PiSettings, + ProviderDriverKind, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderRetry, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ProviderApprovalDecision, + type ProviderInstanceId, + type OrchestrationV2ProviderTurnTokenUsage, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + expandPiSkillReference, + parsePiCompactCommand, + parsePiDiscoveredCommands, + type PiCompactCommand, +} from "../../provider/PiCommands.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterEventStreamError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunError, + ProviderAdapterTurnStartError, + ProviderAdapterV2, + type ProviderAdapterV2Error, + type ProviderAdapterV2Event, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2SteerInput, + type ProviderAdapterV2ThreadSnapshot, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts"; +import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + makePiRpcConnection, + parsePiModelSlug, + piRecordField as recordField, + piRecordNumber as recordNumber, + piRecordString as recordString, + type PiRpcConnection, + type PiRpcRecord, +} from "./PiRpc.ts"; +import { + buildPiRpcLaunch, + materializePiT3McpExtension, + resolvePiLaunchArgs, +} from "./piT3McpInjection.ts"; + +export const PI_PROVIDER = ProviderDriverKind.make("pi"); +export const PI_DRIVER_KIND = PI_PROVIDER; +const PI_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(PI_DRIVER_KIND); +const DEFAULT_PI_SETTINGS = Schema.decodeSync(PiSettings)({}); + +/** + * Sentinel model slug meaning "do not call set_model": Pi resolves the model + * from the user's own settings.json (`defaultProvider`/`defaultModel`). + */ +const PI_INHERIT_MODEL_SLUG = "default"; + +const STREAM_FLUSH_MS = 50; +const PI_REQUEST_TIMEOUT_MS = 15_000; +const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; +const PI_UNSOLICITED_ACTIVITY_ERROR = + "Pi started agent work outside an active T3 turn. The session was stopped to prevent invisible tool execution."; +const SETTLE_PROBE_MAX_ATTEMPTS = 3; +const SETTLE_PROBE_RETRY_DELAY = Duration.millis(100); + +export const PiProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + // Mode changes restart this process so the injected permission hook gets + // one immutable policy for its whole lifetime. + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + // T3's portable full-thread handoff matches Cursor and Grok without + // making this process clone a Pi session and switch back behind T3. + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + // Pi exposes a blocking tool_call extension hook. The T3 bridge uses it + // for supervised and auto-accept modes and forwards its confirmations + // through the same extension UI protocol as user-installed extensions. + supportsCommandApproval: true, + supportsFileReadApproval: false, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + // T3 delegation uses the shared MCP `delegate_task` path. Installed Pi + // subagent extensions are observed best-effort, but their official tool + // runs children with --no-session and exposes no resumable child id. + supportsSubagents: true, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: true, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + // T3 delivers both full and delta handoffs through Pi's normal user-message + // input, so neither strategy depends on a Pi-specific context hook. + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: true, + // CommandPolicy.ensureRollback requires the snapshot whenever provider + // rollback is enabled; rollbackThread returns the updated provider thread. + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface PiAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: PiSettings; + readonly environment: NodeJS.ProcessEnv; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; +} + +/** Concatenate the `text` fields of a Pi content-block array. */ +function contentText(content: unknown): string { + if (!Array.isArray(content)) { + return typeof content === "string" ? content : ""; + } + return content + .map((block) => { + if (recordField(block, "type") === "text") return recordString(block, "text") ?? ""; + return ""; + }) + .join(""); +} + +function providerRef( + nativeId: string, + strength: "strong" | "weak" = "strong", +): OrchestrationV2ProviderRef { + return { driver: PI_PROVIDER, nativeId, strength }; +} + +const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +// ── per-session state ───────────────────────────────────────── + +interface PiStreamItemState { + readonly nativeItemId: string; + readonly kind: "assistant_message" | "reasoning"; + text: string; + completed: boolean; + flushScheduled: boolean; + readonly startedAt: DateTime.Utc; +} + +type PiCompactionStatus = "running" | "completed" | "failed" | "cancelled"; + +interface PiCompactionState { + readonly nativeItemId: string; + readonly startedAt: DateTime.Utc; +} + +interface PiProviderRetryState { + readonly retry: OrchestrationV2ProviderRetry; + readonly failure: OrchestrationV2ProviderFailure; + readonly startedAt: DateTime.Utc; + readonly itemOrdinal: number; +} + +function compactionTitle(status: PiCompactionStatus): string { + switch (status) { + case "running": + return "Compacting context..."; + case "completed": + return "Context compacted"; + case "failed": + return "Context compaction failed"; + case "cancelled": + return "Context compaction stopped"; + } +} + +interface ActivePiTurn { + readonly turnInput: ProviderAdapterV2TurnInput; + readonly providerTurn: OrchestrationV2ProviderTurn; + readonly startedAt: DateTime.Utc; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + /** Increments on assistant `message_start` so content indexes stay unique. */ + messageOrdinal: number; + readonly streamItems: Map; + readonly toolArgs: Map; + /** + * First-seen time per `toolCallId`. Later update/end events reuse it so a + * tool keeps one start timestamp and reports a real duration. + */ + readonly toolStartedAt: Map; + interrupted: boolean; + /** + * Whether any agent run activity was observed. Command-only prompts (pure + * extension slash commands) never start an agent run and never emit + * `agent_settled`; their deferred prompt ack plus an idle probe settles + * the turn instead. + */ + sawAgentActivity: boolean; + /** Only slash-command prompts can complete without starting an agent run. */ + readonly promptMayBeCommandOnly: boolean; + /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ + latestCompactionAfterTokens: number | null; + /** Invalidates idle snapshots when new work starts after a settle probe. */ + settleProbeGeneration: number; + /** An extension may start compaction immediately after Pi emits agent_settled. */ + settleWhenIdle: boolean; + sawCompaction: boolean; + /** RPC compact is in flight; Pi abort does not cancel it. */ + manualCompactInFlight: boolean; + activeCompaction: PiCompactionState | null; + activeProviderRetry: PiProviderRetryState | null; + failure: ReturnType | null; +} + +interface PendingPiPrompt { + readonly nativeRequestId: string; + readonly method: "select" | "confirm" | "input" | "editor"; + readonly questionId: string; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; +} + +interface PiThreadState { + providerThread: OrchestrationV2ProviderThread; + activeTurn: ActivePiTurn | null; +} + +// ── adapter ─────────────────────────────────────────────────── + +export function makePiAdapterV2(options: PiAdapterV2Options): ProviderAdapterV2Shape { + const { idAllocator } = options; + + const protocolError = (detail: string, payload?: unknown) => + new ProviderAdapterProtocolError({ + driver: PI_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); + + return ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: PI_PROVIDER, + getCapabilities: () => Effect.succeed(PiProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession: Effect.fn("PiAdapterV2.openSession")(function* ( + input: ProviderAdapterV2OpenSessionInput, + ) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const provideCacheFs = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + // The extension owns both the optional MCP bridge and Pi's permission + // hook. Materialize it even when this session has no MCP credential so + // Supervised never silently degrades to unrestricted tool execution. + const extensionPath = yield* provideCacheFs( + materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir), + ); + const resolvedLaunchArgs = resolvePiLaunchArgs(options.settings.launchArgs); + if (!resolvedLaunchArgs.ok) { + return yield* protocolError(resolvedLaunchArgs.message); + } + const launch = buildPiRpcLaunch({ + launchArgs: resolvedLaunchArgs.args, + environment: options.environment, + mcpSession, + extensionPath, + runtimeMode: input.runtimePolicy.runtimeMode, + }); + const connection: PiRpcConnection = yield* makePiRpcConnection({ + command: options.settings.binaryPath || "pi", + args: launch.args, + cwd, + env: launch.env, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const discoverSkillNames = connection + .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) + .pipe( + Effect.map( + (data) => new Set(parsePiDiscoveredCommands(data).skills.map((skill) => skill.name)), + ), + ); + let skillNames: Set | null = null; + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: PiProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded< + ProviderAdapterV2Event, + ProviderAdapterV2Error | Cause.Done + >(); + const pendingPrompts = new Map(); + // Answering a dialog and terminalizing a turn both publish lifecycle + // events. Pi can settle immediately after `extension_ui_response`, so + // serialize the two paths to stop `turn.terminal` from overtaking the + // dialog's own resolution updates. + const sessionEventPermit = yield* Semaphore.make(1); + let threadState: PiThreadState | null = null; + // User Stop intentionally tears down this RPC process after aborting. + // Keep that intent beyond turn finalization so the later stdout close is + // not mistaken for an unexpected transport failure. + let stopRequested = false; + // Pi extensions can trigger an agent turn after the owning T3 turn has + // settled. Until orchestration has a first-class provider-initiated run, + // stop that runtime before it can execute tools without a timeline owner. + let unsolicitedActivityDetected = false; + let appliedModel: string | null = null; + let appliedThinking: string | null = null; + /** Last thread title synced into pi's session name (`/resume` listing). */ + let appliedSessionName: string | null = null; + /** Extension failures raised during startup are attached to the next turn. */ + const outOfTurnExtensionErrors: Array = []; + /** + * Leaf entry id of the pi session tree as of the last turn boundary. + * Turn-start user entries are located relative to it, giving each + * provider turn a durable native ref for session-tree rollback. + */ + let lastKnownLeaf: string | null = null; + /** + * Set when a `get_entries` capture failed. Pi may have advanced past + * `lastKnownLeaf` since, so the cursor no longer bounds a single turn + * and the next capture re-syncs it instead of trusting it. + */ + let leafCursorStale = false; + // Pi's own configured defaults, captured from the first `get_state` so + // that selecting the displayed "Pi default" again can restore them. Pi + // has no "unset" commands, so the baselines have to be replayed + // explicitly. + let baselineModel: { provider: string; modelId: string } | null = null; + let baselineThinking: string | null = null; + // Prompt responses carry no id. Keep their session-wide send order and + // owner so a late ack from a settled turn cannot affect the next turn. + const pendingPromptResponses: Array<{ + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly kind: "turn_start" | "steer"; + }> = []; + const pendingCompactResponses: Array<{ + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly kind: "turn_start" | "steer"; + }> = []; + + const compactRpcRecord = (command: PiCompactCommand): PiRpcRecord => + command.customInstructions === undefined + ? { type: "compact" } + : { type: "compact", customInstructions: command.customInstructions }; + + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const updateProviderSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = ( + state: PiThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emit({ + type: "provider_thread.updated", + driver: PI_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const itemOrdinal = (turn: ActivePiTurn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const request = (record: PiRpcRecord, timeoutMs = PI_REQUEST_TIMEOUT_MS) => + connection.request(record, timeoutMs); + + const nonNegativeInteger = (input: unknown, key: string): number | undefined => { + const value = recordNumber(input, key); + return value === undefined ? undefined : Math.max(0, Math.trunc(value)); + }; + + const tokenUsageFromStats = ( + stats: unknown, + fallbackUsedTokens: number | null, + updatedAt: DateTime.Utc, + ): OrchestrationV2ProviderTurnTokenUsage | undefined => { + const contextUsage = recordField(stats, "contextUsage"); + const maxTokens = nonNegativeInteger(contextUsage, "contextWindow"); + const usedTokens = + nonNegativeInteger(contextUsage, "tokens") ?? fallbackUsedTokens ?? undefined; + if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) + return undefined; + + const totals = recordField(stats, "tokens"); + const inputTokens = nonNegativeInteger(totals, "input"); + const cachedInputTokens = nonNegativeInteger(totals, "cacheRead"); + const outputTokens = nonNegativeInteger(totals, "output"); + return { + usedTokens, + maxTokens, + ...(inputTokens === undefined ? {} : { inputTokens }), + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(outputTokens === undefined ? {} : { outputTokens }), + updatedAt: DateTime.formatIso(updatedAt), + }; + }; + + /** + * Pi only reports context usage through `get_session_stats`, so the + * settled turn carries it on the base's per-turn `tokenUsage` (#8144). + * Usage is secondary telemetry: the request is bounded and a provider + * version without stats simply leaves the turn without a report, which + * keeps the meter on the last turn that had one. + */ + const readTokenUsage = (fallbackUsedTokens: number | null, updatedAt: DateTime.Utc) => + request({ type: "get_session_stats" }, 2_000).pipe( + Effect.map((stats) => tokenUsageFromStats(stats, fallbackUsedTokens, updatedAt)), + Effect.orElseSucceed(() => undefined), + ); + + const baseItemFields = ( + turn: ActivePiTurn, + nativeItemId: string, + startedAt: DateTime.Utc, + updatedAt: DateTime.Utc, + ) => ({ + id: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + startedAt, + updatedAt, + }); + + const emitItemNode = ( + turn: ActivePiTurn, + nativeItemId: string, + kind: OrchestrationV2ExecutionNode["kind"], + status: OrchestrationV2ExecutionNode["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + ) => + emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { + id: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: turn.turnInput.rootNodeId, + rootNodeId: turn.turnInput.rootNodeId, + kind, + status, + countsForRun: false, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + const emitProviderRetry = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + providerRetry: PiProviderRetryState, + status: "running" | "completed" | "failed" | "interrupted" | "cancelled", + updatedAt: DateTime.Utc, + ) { + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: makeProviderRetryTurnItem({ + idAllocator, + driver: PI_PROVIDER, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: turn.turnInput.rootNodeId, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + itemOrdinal: providerRetry.itemOrdinal, + failure: providerRetry.failure, + retry: providerRetry.retry, + status, + startedAt: providerRetry.startedAt, + updatedAt, + }), + }); + }); + + const compactionNativeItemId = (turn: ActivePiTurn): string => + `compaction:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; + + const emitCompaction = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + compaction: PiCompactionState, + status: PiCompactionStatus, + details: { + readonly summary?: string; + readonly beforeTokenCount?: number; + readonly afterTokenCount?: number; + } = {}, + ) { + const emittedAt = yield* DateTime.now; + const completedAt = status === "running" ? null : emittedAt; + yield* emitItemNode( + turn, + compaction.nativeItemId, + "system", + status, + compaction.startedAt, + completedAt, + ); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, compaction.nativeItemId, compaction.startedAt, emittedAt), + status, + title: compactionTitle(status), + completedAt, + type: "compaction", + driver: PI_PROVIDER, + ...details, + }, + }); + }); + + // ── streaming text / reasoning ──────────────────────── + + const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => + Effect.gen(function* () { + const emittedAt = yield* DateTime.now; + const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); + yield* emitItemNode( + turn, + item.nativeItemId, + item.kind, + streaming ? "running" : "completed", + item.startedAt, + streaming ? null : emittedAt, + ); + if (item.kind === "assistant_message") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: item.nativeItemId, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "assistant_message", + messageId, + text: item.text, + streaming, + }, + }); + yield* emit({ + type: "message.updated", + driver: PI_PROVIDER, + message: { + id: messageId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: item.nativeItemId, + }), + role: "assistant", + text: item.text, + attachments: [], + streaming, + createdBy: "agent", + creationSource: "provider", + createdAt: item.startedAt, + updatedAt: emittedAt, + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "reasoning", + text: item.text, + streaming, + }, + }); + }); + + const scheduleStreamFlush = (turn: ActivePiTurn, item: PiStreamItemState) => + Effect.gen(function* () { + if (item.flushScheduled || item.completed) return; + item.flushScheduled = true; + yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( + Effect.andThen( + Effect.suspend(() => { + item.flushScheduled = false; + return item.completed ? Effect.void : emitStreamItem(turn, item, true); + }), + ), + Effect.forkIn(scope), + ); + }); + + const streamItemFor = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + kind: PiStreamItemState["kind"], + contentIndex: number, + ) { + const nativeItemId = `${turn.providerTurn.id}:m${turn.messageOrdinal}:c${contentIndex}`; + const existing = turn.streamItems.get(nativeItemId); + if (existing !== undefined) return existing; + const startedAt = yield* DateTime.now; + const item: PiStreamItemState = { + nativeItemId, + kind, + text: "", + completed: false, + flushScheduled: false, + startedAt, + }; + turn.streamItems.set(nativeItemId, item); + // Ordinal reserved on first delta so items appear in stream order. + itemOrdinal(turn, nativeItemId); + return item; + }); + + const completeStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, text?: string) => + Effect.suspend(() => { + if (item.completed) return Effect.void; + item.completed = true; + if (text !== undefined && text.length > 0) item.text = text; + return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); + }); + + const completeOpenStreamItems = (turn: ActivePiTurn) => + Effect.forEach( + Array.from(turn.streamItems.values()).filter((item) => !item.completed), + (item) => completeStreamItem(turn, item), + { discard: true }, + ); + + // ── tools ───────────────────────────────────────────── + + const emitToolItem = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + event: PiRpcRecord, + phase: "start" | "update" | "end", + ) { + const toolCallId = recordString(event, "toolCallId"); + const toolName = recordString(event, "toolName") ?? "tool"; + if (toolCallId === undefined) return; + if (phase === "start") { + turn.toolArgs.set(toolCallId, event["args"]); + } + const args = event["args"] ?? turn.toolArgs.get(toolCallId); + const emittedAt = yield* DateTime.now; + const startedAt = turn.toolStartedAt.get(toolCallId) ?? emittedAt; + turn.toolStartedAt.set(toolCallId, startedAt); + const completed = phase === "end"; + const isError = event["isError"] === true; + const resultRecord = completed ? event["result"] : event["partialResult"]; + const outputText = contentText(recordField(resultRecord, "content")); + // A Stop aborts in-flight tools, and pi reports those as error ends. + // Present them as interrupted (matching the run) rather than failed. + const status = completed + ? isError + ? turn.interrupted + ? "interrupted" + : "failed" + : "completed" + : "running"; + const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); + yield* emitItemNode( + turn, + toolCallId, + "tool_call", + status, + startedAt, + completed ? emittedAt : null, + ); + const shared = { + ...base, + status, + completedAt: completed ? emittedAt : null, + } as const; + if (toolName === "bash") { + const exitCode = recordNumber(recordField(resultRecord, "details"), "exitCode"); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "command_execution", + input: recordString(args, "command") ?? "", + ...(outputText.length > 0 ? { output: outputText } : {}), + ...(exitCode === undefined ? {} : { exitCode }), + }, + }); + return; + } + if (toolName === "edit" || toolName === "write") { + const fileName = recordString(args, "path") ?? recordString(args, "file_path"); + if (fileName !== undefined) { + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "file_change", + fileName, + }, + }); + return; + } + } + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "dynamic_tool", + toolName, + input: args ?? {}, + ...(outputText.length > 0 ? { output: outputText } : {}), + }, + }); + if (toolName === "subagent") { + yield* emitSubagentTasks(turn, toolCallId, resultRecord, completed); + } + }); + + /** + * Observe the result shape from Pi's official example subagent extension. + * The extension runs children with --no-session, so these entries are + * visible in T3's shared subagent UI without inventing a child thread. + * Unknown or changed result shapes stay ordinary dynamic tool output. + */ + const emitSubagentTasks = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + toolCallId: string, + resultRecord: unknown, + completed: boolean, + ) { + const results = recordField(recordField(resultRecord, "details"), "results"); + if (!Array.isArray(results)) return; + const emittedAt = yield* DateTime.now; + const parentNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: toolCallId, + }); + for (const [index, result] of results.entries()) { + const agent = recordString(result, "agent"); + const task = recordString(result, "task"); + if (agent === undefined || task === undefined) continue; + const nativeTaskId = `${toolCallId}:subagent:${recordNumber(result, "step") ?? index}`; + const subagentId = idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: nativeTaskId, + }); + const startedAt = turn.toolStartedAt.get(nativeTaskId) ?? emittedAt; + turn.toolStartedAt.set(nativeTaskId, startedAt); + const finished = completed || recordField(result, "finished") === true; + const stopReason = recordString(result, "stopReason"); + const interrupted = finished && stopReason === "aborted"; + const failed = + finished && + !interrupted && + ((recordNumber(result, "exitCode") ?? 0) !== 0 || stopReason === "error"); + const status = interrupted + ? "interrupted" + : failed + ? "failed" + : finished + ? "completed" + : "running"; + const outputText = piSubagentOutput(result); + const progress = + !finished && outputText.length > 0 ? { progress: outputText.slice(0, 200) } : {}; + const resultText = finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null; + yield* emit({ + type: "subagent.updated", + driver: PI_PROVIDER, + subagent: { + id: subagentId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId, + origin: "provider_native", + createdBy: "agent", + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: turn.turnInput.providerThread.id, + childThreadId: null, + nativeTaskRef: providerRef(nativeTaskId), + prompt: task, + title: agent, + model: recordString(result, "model") ?? null, + status, + ...progress, + result: resultText, + startedAt, + completedAt: finished ? emittedAt : null, + updatedAt: emittedAt, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeTaskId, startedAt, emittedAt), + status, + title: agent, + completedAt: finished ? emittedAt : null, + type: "subagent", + subagentId, + origin: "provider_native", + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + childThreadId: null, + prompt: task, + ...progress, + result: resultText, + }, + }); + } + }); + + // ── extension UI prompts ────────────────────────────── + + const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => + Effect.gen(function* () { + yield* connection + .send({ + type: "extension_ui_response", + id: pending.nativeRequestId, + cancelled: true, + }) + .pipe(Effect.ignore); + pending.runtimeRequest = { + ...pending.runtimeRequest, + status: "cancelled", + resolvedAt, + }; + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "cancelled", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelPendingPrompts = (resolvedAt: DateTime.Utc) => + Effect.gen(function* () { + const pending = Array.from(pendingPrompts.values()); + pendingPrompts.clear(); + yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { + discard: true, + }); + }); + + const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { + const method = recordString(event, "method"); + const nativeRequestId = recordString(event, "id"); + if (method === undefined) return; + if (method === "notify") { + const state = threadState; + const turn = state?.activeTurn ?? null; + const message = recordString(event, "message") ?? ""; + if (turn === null || message.length === 0) return; + const emittedAt = yield* DateTime.now; + const nativeItemId = `notify:${turn.nextItemOrdinal}`; + yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), + status: "completed", + completedAt: emittedAt, + title: "notify", + type: "dynamic_tool", + toolName: "notify", + input: { + message, + notifyType: recordString(event, "notifyType") ?? "info", + }, + }, + }); + return; + } + if ( + method !== "select" && + method !== "confirm" && + method !== "input" && + method !== "editor" + ) { + // Terminal decoration has no matching T3 surface. + yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); + return; + } + if (nativeRequestId === undefined) return; + const state = threadState; + const turn = state?.activeTurn ?? null; + const createdAt = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: PI_PROVIDER, + ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const title = recordString(event, "title") ?? method; + const threadId = + turn?.turnInput.threadId ?? state?.providerThread.appThreadId ?? input.threadId; + const providerThreadId = state?.providerThread.id ?? null; + const providerTurnId = turn?.providerTurn.id ?? null; + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId, + nativeRequestRef: providerRef(nativeRequestId), + kind: method === "confirm" ? "command" : "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId, + runId: turn?.turnInput.runId ?? null, + parentNodeId: turn?.turnInput.rootNodeId ?? null, + rootNodeId: turn?.turnInput.rootNodeId ?? nodeId, + kind: method === "confirm" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const itemBase = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId, + runId: turn?.turnInput.runId ?? null, + nodeId, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + parentItemId: null, + // Runless startup/session-switch requests are normalized into the + // thread-level ordinal range by TurnItemPositionStore. + ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), + status: "waiting" as const, + title, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + }; + const turnItem: OrchestrationV2TurnItem = + method === "confirm" + ? { + ...itemBase, + type: "approval_request", + requestId, + requestKind: "command", + prompt: recordString(event, "message") ?? title, + } + : { + ...itemBase, + type: "user_input_request", + requestId, + questions: [piQuestion(nativeRequestId, method, title, event)], + }; + pendingPrompts.set(String(requestId), { + nativeRequestId, + method, + questionId: nativeRequestId, + runtimeRequest, + node, + turnItem, + }); + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId, + runtimeRequest, + }); + yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); + yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); + }); + + const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { + const state = threadState; + const turn = state?.activeTurn ?? null; + if (turn === null) { + outOfTurnExtensionErrors.push(event); + return; + } + const emittedAt = yield* DateTime.now; + const nativeItemId = `extension-error:${turn.nextItemOrdinal}`; + const extensionName = piExtensionDisplayName(recordString(event, "extensionPath")); + const extensionEvent = recordString(event, "event"); + const detail = recordString(event, "error")?.trim(); + const message = [ + `${extensionName} failed${extensionEvent === undefined ? "" : ` during ${extensionEvent}`}.`, + detail === undefined || detail.length === 0 ? undefined : detail.slice(0, 2_000), + ] + .filter((part): part is string => part !== undefined) + .join("\n\n"); + const failure = makeProviderFailure({ + message, + class: "provider_error", + retryable: false, + }); + yield* emitItemNode(turn, nativeItemId, "system", "failed", emittedAt, emittedAt); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), + status: "failed", + title: extensionName, + completedAt: emittedAt, + type: "error", + failure, + }, + }); + }); + + // ── turn lifecycle ──────────────────────────────────── + + /** + * Locate this turn's first user entry and the new leaf in pi's session + * tree. The user-entry id becomes the provider turn's native ref (the + * point `fork` rolls back to); the leaf becomes the conversation head. + * Pure bookkeeping: failures degrade to the synthetic refs. + */ + const captureTurnTreeRefs = Effect.fnUntraced(function* () { + const cursorWasStale = leafCursorStale; + const cursor = cursorWasStale ? null : lastKnownLeaf; + const data = yield* request({ + type: "get_entries", + ...(cursor === null ? {} : { since: cursor }), + }).pipe(Effect.orElseSucceed(() => undefined)); + if (data === undefined) { + // Pi may have advanced past `lastKnownLeaf` while this failed, so the + // cursor can no longer be trusted to bound a single turn. + leafCursorStale = true; + return null; + } + const entries = recordField(data, "entries"); + const leafId = recordString(data, "leafId"); + if (leafId !== undefined) lastKnownLeaf = leafId; + // Without a trustworthy cursor this window spans more than one turn, so + // its first user entry belongs to an earlier turn. Re-sync the cursor + // and skip the turn-start ref rather than pointing rollback too far + // back; the next turn gets an accurate ref again. + leafCursorStale = false; + const firstUserEntryId = cursorWasStale + ? undefined + : Array.isArray(entries) + ? entries + .filter( + (entry) => + recordField(entry, "type") === "message" && + recordString(recordField(entry, "message"), "role") === "user", + ) + .map((entry) => recordString(entry, "id")) + .find((id) => id !== undefined) + : undefined; + return { + turnStartEntryId: firstUserEntryId ?? null, + leafId: leafId ?? null, + }; + }); + + const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState, readUsage = true) { + const turn = state.activeTurn; + if (turn === null) return; + state.activeTurn = null; + const completedAt = yield* DateTime.now; + yield* completeOpenStreamItems(turn); + if (turn.activeCompaction !== null) { + const status = turn.interrupted + ? "cancelled" + : turn.failure === null + ? "completed" + : "failed"; + yield* emitCompaction(turn, turn.activeCompaction, status); + turn.activeCompaction = null; + } + if (turn.activeProviderRetry !== null) { + if (turn.interrupted) { + yield* emitProviderRetry(turn, turn.activeProviderRetry, "interrupted", completedAt); + turn.activeProviderRetry = null; + } else if (turn.failure === null) { + yield* emitProviderRetry(turn, turn.activeProviderRetry, "completed", completedAt); + turn.activeProviderRetry = null; + } + } + yield* cancelPendingPrompts(completedAt); + const treeRefs = yield* captureTurnTreeRefs(); + const tokenUsage = readUsage + ? yield* readTokenUsage(turn.latestCompactionAfterTokens, completedAt) + : undefined; + const failure = turn.interrupted ? null : turn.failure; + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: { + ...turn.providerTurn, + ...(treeRefs?.turnStartEntryId == null + ? {} + : { nativeTurnRef: providerRef(treeRefs.turnStartEntryId) }), + status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", + completedAt, + ...(tokenUsage === undefined ? {} : { tokenUsage }), + }, + }); + yield* updateProviderThread(state, { + status: "idle", + ...(treeRefs?.leafId == null + ? {} + : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), + }); + yield* updateProviderSession( + failure !== null ? "error" : "ready", + failure?.message ?? null, + ); + if (failure !== null) { + const failureItemId = `terminal-failure:${turn.providerTurn.id}`; + if (turn.activeProviderRetry !== null) { + yield* emitProviderRetry( + turn, + { ...turn.activeProviderRetry, failure }, + "failed", + completedAt, + ); + } else { + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, failureItemId, completedAt, completedAt), + status: "failed", + title: null, + completedAt, + type: "error", + failure, + }, + }); + } + yield* emit({ + type: "turn.terminal", + driver: PI_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, failureItemId), + status: "failed", + failure, + ...(turn.activeProviderRetry === null + ? {} + : { + retry: turn.activeProviderRetry.retry, + retryStartedAt: turn.activeProviderRetry.startedAt, + }), + threadDisposition: "reusable", + }); + } else { + yield* emit({ + type: "turn.terminal", + driver: PI_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + status: turn.interrupted ? "interrupted" : "completed", + failure: null, + threadDisposition: "reusable", + }); + } + }); + + // ── event pump ──────────────────────────────────────── + + const scheduleSettleProbe = ( + turn: ActivePiTurn, + settleAfterAgentActivity = false, + attempt = 1, + ) => { + const providerTurnId = turn.providerTurn.id; + const settleProbeGeneration = turn.settleProbeGeneration; + return request({ type: "get_state" }, 2_000).pipe( + Effect.matchEffect({ + onSuccess: (data) => + Queue.offer(connection.events, { + type: "t3.settle_probe", + providerTurnId, + settleAfterAgentActivity, + settleProbeGeneration, + attempt, + data, + }), + // A failed probe still has to reach the pump. Dropping it would + // leave a command-only turn active forever, because Pi never emits + // agent events for one. + onFailure: () => + Queue.offer(connection.events, { + type: "t3.settle_probe", + providerTurnId, + settleAfterAgentActivity, + settleProbeGeneration, + attempt, + probeFailed: true, + }), + }), + Effect.ignore, + Effect.forkIn(scope), + ); + }; + + const handleSessionEvent = Effect.fnUntraced(function* (event: PiRpcRecord) { + const state = threadState; + const turn = state?.activeTurn ?? null; + switch (event["type"]) { + case "agent_start": { + if (turn === null) { + unsolicitedActivityDetected = true; + yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); + yield* connection.terminate; + return; + } + turn.sawAgentActivity = true; + turn.settleProbeGeneration += 1; + return; + } + case "message_start": { + if (turn !== null && recordString(event["message"], "role") === "assistant") { + turn.sawAgentActivity = true; + turn.messageOrdinal += 1; + } + return; + } + case "message_update": { + if (turn === null) return; + turn.sawAgentActivity = true; + const delta = event["assistantMessageEvent"]; + const deltaType = recordString(delta, "type"); + const contentIndex = recordNumber(delta, "contentIndex") ?? 0; + if (deltaType === "text_delta" || deltaType === "thinking_delta") { + const item = yield* streamItemFor( + turn, + deltaType === "text_delta" ? "assistant_message" : "reasoning", + contentIndex, + ); + item.text += recordString(delta, "delta") ?? ""; + yield* scheduleStreamFlush(turn, item); + return; + } + if (deltaType === "text_end" || deltaType === "thinking_end") { + const item = yield* streamItemFor( + turn, + deltaType === "text_end" ? "assistant_message" : "reasoning", + contentIndex, + ); + yield* completeStreamItem( + turn, + item, + recordString(delta, "content") ?? recordString(delta, "thinking"), + ); + return; + } + return; + } + case "message_end": { + if (turn === null) return; + const message = event["message"]; + if (recordString(message, "role") !== "assistant") return; + yield* completeOpenStreamItems(turn); + if (recordString(message, "stopReason") === "error" && turn.failure === null) { + turn.failure = makeProviderFailure({ + message: recordString(message, "errorMessage") ?? "Pi reported a model error.", + class: "provider_error", + }); + } + return; + } + case "tool_execution_start": + if (turn !== null) { + turn.sawAgentActivity = true; + yield* emitToolItem(turn, event, "start"); + } + return; + case "tool_execution_update": + if (turn !== null) yield* emitToolItem(turn, event, "update"); + return; + case "tool_execution_end": + if (turn !== null) yield* emitToolItem(turn, event, "end"); + return; + case "compaction_start": { + if (turn === null) return; + turn.settleProbeGeneration += 1; + turn.sawCompaction = true; + if (turn.activeCompaction !== null) { + yield* emitCompaction(turn, turn.activeCompaction, "cancelled"); + } + const startedAt = yield* DateTime.now; + const compaction = { + nativeItemId: compactionNativeItemId(turn), + startedAt, + } satisfies PiCompactionState; + turn.activeCompaction = compaction; + yield* emitCompaction(turn, compaction, "running"); + return; + } + case "compaction_end": { + if (turn === null) return; + const observedAt = yield* DateTime.now; + const compaction = turn.activeCompaction ?? { + nativeItemId: compactionNativeItemId(turn), + startedAt: observedAt, + }; + turn.activeCompaction = null; + const result = event["result"]; + if (result === null || result === undefined) { + if (event["aborted"] === true) { + yield* emitCompaction(turn, compaction, "cancelled"); + if (turn.settleWhenIdle || !turn.sawAgentActivity) { + yield* scheduleSettleProbe(turn, turn.settleWhenIdle); + } + return; + } + const errorMessage = + recordString(event, "errorMessage") ?? "Pi context compaction failed."; + yield* emitCompaction(turn, compaction, "failed", { + summary: errorMessage.slice(0, 1_000), + }); + if (turn.settleWhenIdle || !turn.sawAgentActivity) { + yield* scheduleSettleProbe(turn, turn.settleWhenIdle); + } + return; + } + // An overflow can surface as a model error (`message_end` with + // stopReason error) before Pi compacts and retries the turn. Clear + // that failure only when Pi confirms that compaction will retry; + // a successful non-retrying compaction must not erase an exhausted + // provider retry. + if (event["willRetry"] === true) turn.failure = null; + turn.latestCompactionAfterTokens = + nonNegativeInteger(result, "estimatedTokensAfter") ?? null; + const summary = recordString(result, "summary"); + const beforeTokenCount = nonNegativeInteger(result, "tokensBefore"); + const afterTokenCount = nonNegativeInteger(result, "estimatedTokensAfter"); + yield* emitCompaction(turn, compaction, "completed", { + ...(summary === undefined ? {} : { summary }), + ...(beforeTokenCount === undefined ? {} : { beforeTokenCount }), + ...(afterTokenCount === undefined ? {} : { afterTokenCount }), + }); + if (turn.settleWhenIdle || !turn.sawAgentActivity) { + yield* scheduleSettleProbe(turn, turn.settleWhenIdle); + } + return; + } + case "auto_retry_start": { + if (turn === null) return; + const emittedAt = yield* DateTime.now; + const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); + const maxAttempts = Math.max( + attempt, + Math.trunc(recordNumber(event, "maxAttempts") ?? attempt), + ); + const retryDelayMs = Math.max(0, Math.trunc(recordNumber(event, "delayMs") ?? 0)); + const failure = makeProviderFailure({ + message: recordString(event, "errorMessage") ?? "Pi provider request failed.", + class: "provider_error", + retryable: true, + }); + const current = turn.activeProviderRetry; + const providerRetry = { + retry: { attempt, maxAttempts, retryDelayMs }, + failure, + startedAt: current?.startedAt ?? emittedAt, + itemOrdinal: + current?.itemOrdinal ?? + itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), + } satisfies PiProviderRetryState; + turn.activeProviderRetry = providerRetry; + yield* emitProviderRetry(turn, providerRetry, "running", emittedAt); + return; + } + case "auto_retry_end": { + if (turn === null) return; + const emittedAt = yield* DateTime.now; + if (event["success"] === true) { + // The retry recovered. Pi emits the erroring `message_end` + // before retrying, so leaving that failure in place would make + // `agent_settled` terminalize a successful turn as failed. + if (turn.activeProviderRetry !== null) { + const attempt = Math.max( + 1, + Math.trunc( + recordNumber(event, "attempt") ?? turn.activeProviderRetry.retry.attempt, + ), + ); + const recoveredRetry = { + ...turn.activeProviderRetry, + retry: { ...turn.activeProviderRetry.retry, attempt }, + }; + yield* emitProviderRetry(turn, recoveredRetry, "completed", emittedAt); + turn.activeProviderRetry = null; + } + turn.failure = null; + return; + } + const failure = makeProviderFailure({ + message: recordString(event, "finalError") ?? "Pi auto-retry failed.", + class: "provider_error", + retryable: false, + }); + const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); + const current = turn.activeProviderRetry; + const providerRetry = { + retry: { + attempt, + maxAttempts: current?.retry.maxAttempts ?? attempt, + retryDelayMs: current?.retry.retryDelayMs ?? null, + }, + failure, + startedAt: current?.startedAt ?? emittedAt, + itemOrdinal: + current?.itemOrdinal ?? + itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), + } satisfies PiProviderRetryState; + turn.activeProviderRetry = providerRetry; + turn.failure = failure; + yield* emitProviderRetry(turn, providerRetry, "failed", emittedAt); + return; + } + case "extension_ui_request": + yield* handleExtensionUiRequest(event); + return; + case "extension_error": { + yield* emitExtensionError(event); + return; + } + case "agent_settled": { + if (turn?.interrupted === true) { + if (state !== null) yield* finalizeTurn(state); + return; + } + if (turn !== null) { + turn.settleWhenIdle = true; + turn.settleProbeGeneration += 1; + yield* scheduleSettleProbe(turn, true); + } + return; + } + case "response": { + // Correlated responses never reach the pump; an id-less response + // is the deferred ack of a fire-and-forget prompt/steer/compact. + const command = recordString(event, "command"); + if (command === "compact") { + const pendingCompact = pendingCompactResponses.shift(); + const compactTurn = + pendingCompact?.providerTurnId === turn?.providerTurn.id ? turn : null; + if (compactTurn !== null) compactTurn.manualCompactInFlight = false; + if (event["success"] === true) { + if ( + pendingCompact?.kind === "turn_start" && + compactTurn !== null && + compactTurn.promptMayBeCommandOnly && + !compactTurn.sawAgentActivity + ) { + yield* scheduleSettleProbe(compactTurn); + } + return; + } + if (event["success"] !== false) return; + if (compactTurn === null) return; + if (compactTurn.activeCompaction !== null) return; + if (pendingCompact?.kind === "steer") { + yield* Effect.logWarning("Pi rejected a compact steer.", { + errorLength: recordString(event, "error")?.length, + }); + return; + } + if (!compactTurn.sawCompaction) { + compactTurn.failure = makeProviderFailure({ + message: recordString(event, "error") ?? "Pi compact failed.", + class: "provider_error", + }); + if (state !== null) yield* finalizeTurn(state); + return; + } + if (!compactTurn.sawAgentActivity) { + yield* scheduleSettleProbe(compactTurn); + } + return; + } + const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; + const responseTurn = + pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; + if (event["success"] === true) { + // Deferred success ack. Command-only prompts (pure extension + // slash commands) never start an agent run and never emit + // `agent_settled`, so probe for idleness. The probe result is + // re-queued behind any events Pi emitted before answering + // get_state, which keeps the check stream-ordered. + if ( + pendingPrompt?.kind === "turn_start" && + responseTurn !== null && + responseTurn.promptMayBeCommandOnly && + !responseTurn.sawAgentActivity + ) { + yield* scheduleSettleProbe(responseTurn); + } + return; + } + if (event["success"] !== false) return; + if (command === "steer" || pendingPrompt?.kind === "steer") { + // A rejected steer only means that one message was refused. The + // turn it was aimed at is still running on Pi, so terminalizing + // here would report a failure while output keeps streaming. + yield* Effect.logWarning("Pi rejected a steer message.", { + errorLength: recordString(event, "error")?.length, + }); + return; + } + const failedTurn = + command === "prompt" && pendingPrompt?.kind === "turn_start" + ? responseTurn + : command === "parse" + ? turn + : null; + if (failedTurn !== null) { + failedTurn.failure = makeProviderFailure({ + message: recordString(event, "error") ?? "Pi rejected the prompt.", + class: "provider_error", + }); + if (state !== null) yield* finalizeTurn(state); + } + return; + } + case "t3.flush_extension_errors": { + // Startup extension failures are informational and do not block + // Pi, so attach them to the next real turn instead of creating a + // standalone failed run. + for (const extensionError of outOfTurnExtensionErrors.splice(0)) { + yield* emitExtensionError(extensionError); + } + return; + } + case "t3.settle_probe": { + // New work increments the generation before the pump can consume + // a stale idle snapshot, so only a current snapshot may settle. + const data = event["data"]; + const probeFailed = event["probeFailed"] === true; + const settleAfterAgentActivity = event["settleAfterAgentActivity"] === true; + const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); + if ( + turn === null || + turn.providerTurn.id !== event["providerTurnId"] || + turn.settleProbeGeneration !== event["settleProbeGeneration"] || + (!settleAfterAgentActivity && turn.sawAgentActivity) || + turn.activeCompaction !== null + ) { + return; + } + if (probeFailed) { + if (!settleAfterAgentActivity) { + if (state !== null) yield* finalizeTurn(state); + return; + } + if (attempt < SETTLE_PROBE_MAX_ATTEMPTS) { + yield* Effect.sleep(SETTLE_PROBE_RETRY_DELAY).pipe( + Effect.andThen(scheduleSettleProbe(turn, true, attempt + 1)), + Effect.forkIn(scope), + ); + return; + } + stopRequested = true; + yield* connection.terminate; + return; + } + if ( + recordField(data, "isStreaming") !== true && + recordField(data, "isCompacting") !== true && + (recordNumber(data, "pendingMessageCount") ?? 0) === 0 + ) { + turn.settleWhenIdle = false; + if (state !== null) yield* finalizeTurn(state); + } + return; + } + default: + return; + } + }); + + yield* Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(connection.events); + yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); + } + }).pipe( + Effect.catchCause((cause) => + sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + // Transport death finalizes any live turn. Stop-with-restart + // closes the provider stream cleanly; only an unexpected death + // is surfaced as an event-stream failure. + const state = threadState; + const interrupted = state?.activeTurn?.interrupted === true; + if (state?.activeTurn != null) { + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "Pi process exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state, false); + } + if (unsolicitedActivityDetected) { + yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); + yield* Queue.end(events); + } else if (stopRequested) { + yield* updateProviderSession("stopped", null); + yield* Queue.end(events); + } else { + yield* updateProviderSession( + "error", + interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", + ); + yield* Queue.fail( + events, + new ProviderAdapterEventStreamError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + } + }), + ), + ), + Effect.forkIn(scope), + ); + + // Discovery can invoke extension code and therefore raise a blocking + // UI request. Start it only after the event pump exists, and never hold + // session opening on it; startup requests are persisted at session + // scope and can be answered before a turn begins. + yield* discoverSkillNames.pipe( + Effect.tap((discovered) => Effect.sync(() => (skillNames = discovered))), + Effect.ignore, + Effect.forkIn(scope), + ); + + // ── session runtime ─────────────────────────────────── + + const registerThread = Effect.fnUntraced(function* ( + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + if (threadState !== null && threadState.activeTurn !== null) { + return yield* protocolError("Cannot register a Pi thread while a turn is active"); + } + const existing = threadInput.existingProviderThread; + if (existing?.nativeThreadRef?.nativeId != null) { + const switchData = yield* request({ + type: "switch_session", + sessionPath: existing.nativeThreadRef.nativeId, + }); + // A session_before_switch extension handler can veto the switch. + // Proceeding would silently adopt whatever session is active and + // write the wrong thread's turns into it. + if (recordField(switchData, "cancelled") === true) { + return yield* protocolError("A Pi extension cancelled the session switch"); + } + // Pi is now attached to the target session. Drop the previous + // binding before reading its state so a failed refresh cannot let a + // later turn run against the old T3 thread and the new Pi session. + threadState = null; + // These caches describe the session we just left. Clearing them + // stops the next turn from treating this session as already + // configured and skipping set_model or set_session_name. + appliedModel = null; + appliedThinking = null; + appliedSessionName = null; + // The baselines describe the session we just left too. Dropping + // them lets the `get_state` below re-capture this session's own + // defaults, so the "Pi default" choice cannot replay the previous + // session's model or thinking level. + baselineModel = null; + baselineThinking = null; + } + const stateData = yield* request({ type: "get_state" }); + // Each baseline is captured independently, and only while nothing has + // been applied yet, so a `get_state` that arrives after our own + // selection cannot record that selection as Pi's default. + if (baselineModel === null && appliedModel === null) { + const stateModel = recordField(stateData, "model"); + const provider = recordString(stateModel, "provider"); + const modelId = recordString(stateModel, "id"); + if (provider !== undefined && modelId !== undefined) { + baselineModel = { provider, modelId }; + } + } + if (baselineThinking === null && appliedThinking === null) { + baselineThinking = recordString(stateData, "thinkingLevel") ?? null; + } + const nativeId = + recordString(stateData, "sessionFile") ?? recordString(stateData, "sessionId"); + if (nativeId === undefined) { + return yield* protocolError( + "get_state returned neither sessionFile nor sessionId", + stateData, + ); + } + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = + existing !== undefined + ? { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: providerRef(nativeId), + status: "idle", + updatedAt: createdAt, + } + : { + id: idAllocator.derive.providerThread({ + driver: PI_PROVIDER, + nativeThreadId: nativeId, + }), + driver: PI_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + createdAt, + updatedAt: createdAt, + }; + threadState = { providerThread, activeTurn: null }; + // Baseline the session-tree leaf so the first turn's user entry can + // be located with a `since` cursor instead of a full entry scan. + const baselineEntries = yield* request({ type: "get_entries" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + lastKnownLeaf = recordString(baselineEntries, "leafId") ?? null; + // A successful full baseline makes the cursor trustworthy again. Only + // a failed one leaves it stale, so a recovered session does not keep + // skipping turn refs. An empty tree is a success with no leafId. + leafCursorStale = baselineEntries === undefined; + yield* emit({ + type: "provider_thread.updated", + driver: PI_PROVIDER, + providerThread, + }); + return providerThread; + }); + + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { + const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); + if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { + // Returning to "Pi default" after an explicit pick has to replay the + // captured baseline, otherwise Pi stays on the last model applied. + if (appliedModel !== null && baselineModel !== null) { + yield* request({ type: "set_model", ...baselineModel }); + appliedModel = null; + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, model: PI_INHERIT_MODEL_SLUG, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + } + // The "Pi default" model advertises no thinking choices of its own, + // so an unqualified return also restores Pi's configured level + // instead of silently keeping the effort a previous pick applied. + if ( + thinking === undefined && + appliedThinking !== null && + baselineThinking !== null && + appliedThinking !== baselineThinking + ) { + yield* request({ type: "set_thinking_level", level: baselineThinking }); + appliedThinking = null; + } + } else if (modelSelection.model !== appliedModel) { + const parsed = parsePiModelSlug(modelSelection.model); + if (parsed === null) { + return yield* protocolError( + `Pi model '${modelSelection.model}' must use provider/model format`, + ); + } + yield* request({ + type: "set_model", + provider: parsed.provider, + modelId: parsed.modelId, + }); + appliedModel = modelSelection.model; + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, model: modelSelection.model, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + } + if ( + thinking !== undefined && + thinking !== appliedThinking && + PI_THINKING_LEVELS.has(thinking) + ) { + yield* request({ type: "set_thinking_level", level: thinking }); + appliedThinking = thinking; + } + }); + + const resolvePromptPayload = Effect.fnUntraced(function* ( + text: string, + attachments: ReadonlyArray, + ) { + // Provider discovery and the live session are separate Pi processes. + // Retry a failed session-local lookup once at first use so a transient + // startup failure cannot leave a visible $ skill inert for this session. + if (skillNames === null && text.includes("$")) { + skillNames = yield* discoverSkillNames.pipe( + Effect.orElseSucceed(() => new Set()), + ); + } + const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); + const images: Array<{ type: "image"; data: string; mimeType: string }> = []; + const extraLines: Array = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: options.serverConfig.attachmentsDir, + attachment, + }); + if (path === null) continue; + if (attachment.mimeType.startsWith("image/")) { + const bytes = yield* options.fileSystem.readFile(path); + images.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } else { + extraLines.push(`[Attachment saved at ${path}]`); + } + } + const message = + extraLines.length === 0 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; + return { message, images }; + }); + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + get providerSession() { + return sessionEntity; + }, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + registerThread(threadInput).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: PI_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + registerThread({ + threadId: + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: PI_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const state = threadState; + if (state === null) { + return yield* protocolError("Pi session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* protocolError( + `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, + ); + } + yield* applySelection(turnInput.modelSelection); + // Mirror the thread title into pi's session name so the session + // stays identifiable in pi's own /resume listing. Best-effort: + // naming must never block a turn. + if (turnInput.appThread.title !== appliedSessionName) { + yield* request({ + type: "set_session_name", + name: turnInput.appThread.title, + }).pipe( + Effect.tap(() => + Effect.sync(() => (appliedSessionName = turnInput.appThread.title)), + ), + Effect.ignore, + ); + } + // Resolved before the turn is installed: a failure here (an + // unreadable attachment) must not leave `activeTurn` set, which + // would reject every later turn as already active. + // Orchestration instructions reach pi through the T3 MCP + // extension's before_agent_start system-prompt hook, never by + // wrapping the user text: a wrapped first message would no + // longer start with "/" and slash commands would stop expanding. + const compactCommand = parsePiCompactCommand(turnInput.message.text); + const payload = + compactCommand === null + ? yield* resolvePromptPayload(turnInput.message.text, turnInput.message.attachments) + : null; + const startedAt = yield* DateTime.now; + const syntheticNativeTurnId = `${state.providerThread.id}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ + driver: PI_PROVIDER, + nativeTurnId: syntheticNativeTurnId, + }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const activeTurn: ActivePiTurn = { + turnInput, + providerTurn, + startedAt, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + messageOrdinal: 0, + streamItems: new Map(), + toolArgs: new Map(), + toolStartedAt: new Map(), + interrupted: false, + sawAgentActivity: false, + promptMayBeCommandOnly: + compactCommand !== null || (payload?.message.trimStart().startsWith("/") ?? false), + latestCompactionAfterTokens: null, + settleProbeGeneration: 0, + settleWhenIdle: false, + sawCompaction: false, + manualCompactInFlight: compactCommand !== null, + activeCompaction: null, + activeProviderRetry: null, + failure: null, + }; + // Only the install/send/start-event boundary excludes the event + // pump. Earlier correlated requests must leave the pump free so + // project trust, login, and session-switch dialogs can be shown + // and answered instead of deadlocking the caller. + yield* Effect.gen(function* () { + state.activeTurn = activeTurn; + if (compactCommand !== null) { + yield* connection.send(compactRpcRecord(compactCommand)); + pendingCompactResponses.push({ + providerTurnId: providerTurn.id, + kind: "turn_start", + }); + } else if (payload !== null) { + yield* connection.send({ + type: "prompt", + message: payload.message, + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }); + pendingPromptResponses.push({ + providerTurnId: providerTurn.id, + kind: "turn_start", + }); + } + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + if (outOfTurnExtensionErrors.length > 0) { + yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); + } + }).pipe( + sessionEventPermit.withPermits(1), + Effect.tapError(() => + Effect.sync(() => { + if (state.activeTurn === activeTurn) state.activeTurn = null; + }), + ), + ); + // Pi acks `prompt` only after slash-command expansion completes, + // and extension commands may block on user dialogs indefinitely. + // Rejections therefore return later as id-less response records + // handled by the event pump. + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput: ProviderAdapterV2SteerInput) => + Effect.gen(function* () { + const turn = threadState?.activeTurn ?? null; + if (turn === null || turn.providerTurn.id !== steerInput.providerTurnId) { + return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); + } + const compactCommand = parsePiCompactCommand(steerInput.message.text); + const payload = + compactCommand === null + ? yield* resolvePromptPayload( + steerInput.message.text, + steerInput.message.attachments, + ) + : null; + // Prompt with streamingBehavior steer is atomic on Pi's side: it + // queues during an active run and starts a new run if settlement + // won the race. A direct `steer` sent after Pi became idle would + // remain queued forever. Send fire-and-forget under the session + // permit so a slash-command dialog cannot block the turn, and so + // settlement cannot overtake the active-turn check. + // /compact is not a prompt: Pi's compact RPC aborts the agent first. + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + if (threadState?.activeTurn !== turn) { + return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); + } + if (compactCommand !== null) { + turn.manualCompactInFlight = true; + yield* connection.send(compactRpcRecord(compactCommand)); + pendingCompactResponses.push({ + providerTurnId: turn.providerTurn.id, + kind: "steer", + }); + } else if (payload !== null) { + yield* connection.send({ + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }); + pendingPromptResponses.push({ + providerTurnId: turn.providerTurn.id, + kind: "steer", + }); + } + turn.settleProbeGeneration += 1; + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterSteerRunError({ + driver: PI_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const turn = threadState?.activeTurn ?? null; + if (turn === null || turn.providerTurn.id !== interruptInput.providerTurnId) { + return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); + } + turn.interrupted = true; + if ( + interruptInput.requestRuntimeRestart === true || + turn.settleWhenIdle || + turn.activeCompaction !== null || + turn.manualCompactInFlight + ) { + // Pi's generic abort does not cancel manual compaction. Terminate + // so Stop covers user /compact as well as detached recovery compact. + stopRequested = true; + if (interruptInput.requestRuntimeRestart === true && !turn.settleWhenIdle) { + yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); + } + yield* connection.terminate; + return; + } + yield* request({ type: "abort" }).pipe( + Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: PI_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingPrompts.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* protocolError( + `No pending Pi extension request ${requestInput.requestId}`, + ); + } + const response = piUiResponse(pending, requestInput.decision, requestInput.answers); + yield* connection.send({ + type: "extension_ui_response", + id: pending.nativeRequestId, + ...response, + }); + // Dropped only once Pi has the answer, so a failed send leaves the + // request retryable and still cancellable during teardown. + pendingPrompts.delete(String(requestInput.requestId)); + const resolvedAt = yield* DateTime.now; + pending.runtimeRequest = { + ...pending.runtimeRequest, + status: "resolved", + resolvedAt, + }; + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { ...pending.node, status: "completed", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "completed", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }).pipe( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterRuntimeRequestResponseError({ + driver: PI_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + Effect.gen(function* () { + const state = threadState; + const boundNativeId = state?.providerThread.nativeThreadRef?.nativeId; + const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; + if (state === null || wantedNativeId == null || boundNativeId !== wantedNativeId) { + return yield* protocolError( + "Pi snapshot requested for a thread this session does not host", + ); + } + // get_messages is Pi's active-branch view. get_entries returns the + // whole session tree, including abandoned branches after /tree or + // fork, which would leak discarded conversation into handoffs. + const messagesData = yield* request({ type: "get_messages" }); + const activeMessages = recordField(messagesData, "messages"); + const threadId = state.providerThread.appThreadId ?? input.threadId; + const messages = (Array.isArray(activeMessages) ? activeMessages : []).flatMap( + (message, index) => { + const role = recordString(message, "role"); + if (role !== "user" && role !== "assistant") return []; + const text = contentText(recordField(message, "content")); + if (text.length === 0) return []; + const timestamp = recordNumber(message, "timestamp"); + const at = Option.getOrElse( + DateTime.make(timestamp ?? Number.NaN), + () => state.providerThread.createdAt, + ); + return [ + { + id: idAllocator.derive.messageFromProviderItem({ + driver: PI_PROVIDER, + // RPC messages do not expose session-tree entry ids. The + // active-branch index is stable for the lifetime of this + // snapshot and keeps abandoned branch ids out of it. + nativeItemId: `snapshot-message:${index}`, + }), + threadId, + runId: null, + nodeId: null, + role: role as "user" | "assistant", + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + createdAt: at, + updatedAt: at, + }, + ]; + }, + ); + return { + providerThread: state.providerThread, + providerTurns: [], + messages, + runtimeRequests: [], + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: PI_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + const state = threadState; + if (state === null) { + return yield* protocolError("Pi session has no registered thread"); + } + if (state.providerThread.id !== rollbackInput.providerThread.id) { + return yield* protocolError( + "Pi rollback requested for a thread this session does not host", + ); + } + if (state.activeTurn !== null) { + return yield* protocolError("Cannot roll back while a Pi turn is active"); + } + // `fork(entryId)` re-roots the active branch before that user + // message, so the rollback boundary is the first user entry of + // the earliest turn being discarded. + const forkEntryId = piRollbackForkEntry(rollbackInput); + if (forkEntryId === null) { + // Nothing after the target: the conversation is already there. + return piThreadSnapshot(state.providerThread); + } + if (forkEntryId === undefined) { + return yield* protocolError("Pi rollback target has no captured session-tree entry"); + } + const forkData = yield* request({ type: "fork", entryId: forkEntryId }); + if (recordField(forkData, "cancelled") === true) { + return yield* protocolError("A Pi extension cancelled the session fork"); + } + const entriesData = yield* request({ type: "get_entries" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + const leafId = recordString(entriesData, "leafId") ?? null; + lastKnownLeaf = leafId; + // The fork re-baselined the tree, so the cursor is trustworthy + // again unless this listing itself failed. + leafCursorStale = entriesData === undefined; + yield* updateProviderThread(state, { + nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), + }); + return piThreadSnapshot(state.providerThread); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRollbackThreadError({ + driver: PI_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapterForkThreadError({ + driver: PI_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause: "Pi threads use T3 Code's portable full-thread fork.", + }), + ), + }; + return runtime; + }), + }); +} + +/** + * Resolve the pi session-tree entry `fork` should re-root at for a rollback. + * Returns `null` when no turns follow the target (nothing to discard) and + * `undefined` when the boundary turn has no captured entry ref (only + * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). + */ +function piRollbackForkEntry(input: { + readonly target: + | { readonly type: "thread_start" } + | { readonly type: "provider_turn"; readonly providerTurn: OrchestrationV2ProviderTurn }; + readonly providerThreadTurns: ReadonlyArray; +}): string | null | undefined { + const boundaryOrdinal = + input.target.type === "thread_start" ? 0 : input.target.providerTurn.ordinal; + const discarded = input.providerThreadTurns + .filter((turn) => turn.ordinal > boundaryOrdinal) + .sort((a, b) => a.ordinal - b.ordinal); + const boundary = discarded[0]; + if (boundary === undefined) return null; + const ref = boundary.nativeTurnRef; + if (ref === null || ref.strength !== "strong" || ref.nativeId === null) return undefined; + return ref.nativeId; +} + +/** + * Human-readable output for one subagent-extension task result: the last + * assistant text from its transcript, or the error/stderr when it failed. + */ +function piSubagentOutput(result: unknown): string { + const stopReason = recordString(result, "stopReason"); + const failed = + (recordNumber(result, "exitCode") ?? 0) !== 0 || + stopReason === "error" || + stopReason === "aborted"; + if (failed) { + // Falsy fallback, not `??`: an empty `errorMessage` must not suppress a + // non-empty `stderr`, which is often the only description of the failure. + const failure = recordString(result, "errorMessage") || recordString(result, "stderr"); + if (failure !== undefined && failure.length > 0) return failure; + } + const messages = recordField(result, "messages"); + if (!Array.isArray(messages)) return ""; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (recordString(message, "role") !== "assistant") continue; + const text = contentText(recordField(message, "content")); + if (text.length > 0) return text; + } + return ""; +} + +function piExtensionDisplayName(extensionPath: string | undefined): string { + if (extensionPath === undefined) return "Pi extension"; + const normalized = extensionPath.replace(/\\/g, "/").replace(/\/+$/, ""); + const name = normalized.slice(normalized.lastIndexOf("/") + 1).replace(/\.[^.]+$/, ""); + return name.length === 0 ? "Pi extension" : name; +} + +function piThreadSnapshot( + providerThread: OrchestrationV2ProviderThread, +): ProviderAdapterV2ThreadSnapshot { + return { providerThread, providerTurns: [], messages: [], runtimeRequests: [] }; +} + +function piQuestion( + questionId: string, + method: "select" | "input" | "editor", + title: string, + event: PiRpcRecord, +): OrchestrationV2UserInputQuestion { + const options = + method === "select" && Array.isArray(event["options"]) + ? event["options"] + .filter((option): option is string => typeof option === "string") + .map((option) => ({ label: option, description: option })) + : []; + // The user-input contract has no prefill field, so an editor dialog's + // prefill is surfaced inside the question text; without it the user would + // edit blind against content they cannot see. + const prefill = method === "editor" ? recordString(event, "prefill") : undefined; + const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; + return { + id: questionId, + header: title, + question: + prefill === undefined || prefill.length === 0 + ? question + : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, + options, + }; +} + +function piUiResponse( + pending: PendingPiPrompt, + decision: ProviderApprovalDecision | undefined, + answers: Record | undefined, +): PiRpcRecord { + if (pending.method === "confirm") { + if (decision === "accept" || decision === "acceptForSession") return { confirmed: true }; + if (decision === "decline") return { confirmed: false }; + return { cancelled: true }; + } + const answer = answers?.[pending.questionId]; + // An empty string is a valid dialog value per the RPC spec (the extension + // receives ""), distinct from cancelling (the extension receives undefined). + if (typeof answer === "string") return { value: answer }; + return { cancelled: true }; +} + +// ── driver ──────────────────────────────────────────────────── + +export type PiAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | IdAllocatorV2 + | ServerConfig; + +export const PiAdapterV2Driver: ProviderAdapterDriver = { + driverKind: PI_DRIVER_KIND, + configSchema: PiSettings, + defaultConfig: (): PiSettings => DEFAULT_PI_SETTINGS, + create: Effect.fn("PiAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + return makePiAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + spawner, + fileSystem, + idAllocator, + serverConfig, + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: PI_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Pi adapter.", + cause, + }), + ), + ), + ), +}; + +export const layer: Layer.Layer = Layer.effect( + ProviderAdapterV2, + Effect.gen(function* () { + const hostEnvironment = yield* HostProcessEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const serverConfig = yield* ServerConfig; + return makePiAdapterV2({ + instanceId: PI_DEFAULT_INSTANCE_ID, + settings: DEFAULT_PI_SETTINGS, + environment: hostEnvironment, + spawner, + fileSystem, + idAllocator, + serverConfig, + }); + }), +); diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts new file mode 100644 index 000000000000..998cfc02c5e9 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/PiRpc.ts @@ -0,0 +1,444 @@ +/** + * PiRpc — stdio JSONL transport for the Pi coding agent's RPC mode. + * + * Spawns `pi --mode rpc` and speaks Pi's line-delimited JSON protocol: + * requests go to stdin as `{"type": "...", "id": "..."}` records, responses + * come back as `{"type": "response", "id": ..., "success": ...}` and are + * correlated by `id`; every other stdout record is a session event and is + * surfaced on the `events` queue in arrival order. + * + * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` + * stripped. Lines are split manually (never with `readline`, which also + * splits on U+2028/U+2029 and would corrupt frames). Records that fail to + * parse as JSON are dropped with a debug log rather than failing the + * transport, so a chatty extension cannot take the session down. + * + * Used by `PiAdapterV2` for sessions and by `PiTextGeneration` / + * `PiProvider` for ephemeral one-shot processes. + */ +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +export class PiRpcError extends Schema.TaggedErrorClass()("PiRpcError", { + operation: Schema.String, + detail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Pi RPC ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; + } +} + +export type PiRpcRecord = Record; + +export function piRecordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +export function piRecordString(input: unknown, key: string): string | undefined { + const value = piRecordField(input, key); + return Predicate.isString(value) ? value : undefined; +} + +export function piRecordNumber(input: unknown, key: string): number | undefined { + const value = piRecordField(input, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +} + +/** + * Splits a `provider/model` slug into the two fields `set_model` expects. + * Returns null for slugs without a usable separator so callers can reject the + * selection instead of silently leaving Pi on its configured default. + */ +export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { + const separator = slug.indexOf("/"); + if (separator <= 0 || separator === slug.length - 1) return null; + return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; +} + +export interface PiRpcSpawnOptions { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string | undefined; + readonly env: NodeJS.ProcessEnv; +} + +export interface PiRpcConnection { + /** Fire-and-forget write (used for `extension_ui_response`). */ + readonly send: (record: PiRpcRecord) => Effect.Effect; + /** + * Correlated request: assigns an `id`, waits for the matching response + * record, and returns its `data` (undefined when the command carries none). + * Fails on `success: false`, transport death, or timeout. + */ + readonly request: (record: PiRpcRecord, timeoutMs?: number) => Effect.Effect; + /** + * Session events (every non-response stdout record) in arrival order. The + * full queue is exposed so consumers can append order-preserving synthetic + * records of their own (see PiAdapterV2's settle probe). + */ + readonly events: Queue.Queue; + /** Resolves when the process has exited, with its exit code. */ + readonly exited: Effect.Effect; + /** + * Kill the pi process group immediately (SIGTERM, grace, SIGKILL). Used by + * Stop-with-restart when the process may be wedged and `abort` cannot be + * trusted to land. The transport fails and the session manager respawns a + * fresh process on the next turn. + */ + readonly terminate: Effect.Effect; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const TERMINATION_GRACE = Duration.seconds(1); + +interface PendingPiRequest { + readonly deferred: Deferred.Deferred; +} + +function splitJsonlChunks(buffer: string, chunk: string): readonly [ReadonlyArray, string] { + const combined = buffer + chunk; + const parts = combined.split("\n"); + const remainder = parts.pop() ?? ""; + const lines = parts + .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0); + return [lines, remainder]; +} + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); +const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); + +const PI_ERROR_DETAIL_MAX_CHARS = 200; + +/** + * Bounded, human-readable summary of a failed response's `error` payload. + * The untruncated value stays on the error's `cause`, so `message` never + * carries unbounded remote text while logs keep something diagnostic. + */ +function summarizePiError(error: unknown): string { + const text = typeof error === "string" ? error : JSON.stringify(error); + if (text === undefined) return "unknown error"; + return text.length > PI_ERROR_DETAIL_MAX_CHARS + ? `${text.slice(0, PI_ERROR_DETAIL_MAX_CHARS)}…` + : text; +} + +/** + * Stdout closure is not enough to diagnose an early crash: Pi 0.84+ can exit + * 1 on import before writing any protocol line. The numeric exit code is + * sanitized; stderr stays out of public details because it can carry + * credentials or prompt text. + */ +function describePiStdoutClosure(exitCode: number | undefined): string { + if (exitCode === undefined || !Number.isFinite(exitCode)) { + return "pi process closed stdout"; + } + return `pi process exited with code ${exitCode}`; +} + +function parsePiRecord(line: string): PiRpcRecord | undefined { + try { + const parsed: unknown = decodeJsonLine(line); + return Predicate.isObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * Kill the pi process group: SIGTERM, short grace, then SIGKILL. + * + * `hasExited` is consulted before each signal. Once the original child is + * gone its pid/pgid can be recycled by the OS, so escalating blindly could + * deliver SIGKILL to an unrelated process. + */ +const terminatePiProcess = (kill: (signal: NodeJS.Signals) => boolean, hasExited: () => boolean) => + Effect.gen(function* () { + if (hasExited()) return; + if (!kill("SIGTERM")) return; + yield* Effect.sleep(TERMINATION_GRACE); + if (hasExited()) return; + kill("SIGKILL"); + }); + +export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSpawnOptions) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; + const scope = yield* Effect.scope; + + const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { + env: options.env, + }).pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: options.env, + extendEnv: false, + shell: spawnCommand.shell, + detached: platform !== "win32", + }), + ) + .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); + + let childExited = false; + let diagnosingStdoutClose = false; + + const killProcessGroup = (signal: NodeJS.Signals): boolean => { + try { + if (platform === "win32") { + process.kill(Number(child.pid), signal); + } else { + process.kill(-Number(child.pid), signal); + } + return true; + } catch { + return false; + } + }; + + /** Signal 0 probes liveness without delivering anything. */ + const hasExited = (): boolean => { + if (childExited) return true; + try { + process.kill(platform === "win32" ? Number(child.pid) : -Number(child.pid), 0); + return false; + } catch { + return true; + } + }; + + /** + * Windows has no process groups, so `process.kill` reaches only pi itself + * and leaves extension subprocesses running with inherited stdio handles. + * `taskkill /T` reaps the whole tree. + */ + const terminateWindowsTree = Effect.gen(function* () { + if (hasExited()) return; + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), + ); + yield* taskkill.exitCode; + }).pipe(Effect.scoped, Effect.ignore); + + const terminateProcess = + platform === "win32" ? terminateWindowsTree : terminatePiProcess(killProcessGroup, hasExited); + + // Registered before any further setup: an interrupt or failure between the + // spawn and the rest of this constructor would otherwise leak a detached + // pi process with no finalizer to reap it. + yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); + + const pendingRequests = new Map(); + const events = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); + const transportDown = yield* Deferred.make(); + const exitDeferred = yield* Deferred.make(); + let nextRequestId = 0; + + const failTransport = (error: PiRpcError) => + Effect.gen(function* () { + const claimed = yield* Deferred.fail(transportDown, error); + if (!claimed) return; + for (const [key, pending] of pendingRequests) { + pendingRequests.delete(key); + yield* Deferred.fail(pending.deferred, error); + } + // Closing `outgoing` is what makes `send` non-racy: once the writer is + // gone every later offer is refused rather than silently buffered. + yield* Queue.fail(outgoing, error); + yield* Queue.fail(events, error); + }); + + const routeRecord = (record: PiRpcRecord) => + Effect.gen(function* () { + if (record["type"] === "response" && typeof record["id"] === "string") { + const pending = pendingRequests.get(record["id"]); + if (pending !== undefined) { + pendingRequests.delete(record["id"]); + if (record["success"] === true) { + yield* Deferred.succeed(pending.deferred, record["data"]); + } else { + yield* Deferred.fail( + pending.deferred, + new PiRpcError({ + operation: String(record["command"] ?? "request"), + detail: summarizePiError(record["error"]), + ...(record["error"] === undefined ? {} : { cause: record["error"] }), + }), + ); + } + return; + } + } + yield* Queue.offer(events, record); + }); + + // Watch exit before the reader so an immediate crash can populate + // `exitDeferred` before stdout-close diagnosis runs. + yield* child.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => + Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), + onSuccess: (code) => + Effect.suspend(() => { + childExited = true; + return Deferred.succeed(exitDeferred, Number(code)); + }), + }), + Effect.forkIn(scope), + ); + + // Reader: decode stdout into LF-delimited JSON records. + yield* Effect.gen(function* () { + let buffer = ""; + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.gen(function* () { + const [lines, remainder] = splitJsonlChunks(buffer, chunk); + buffer = remainder; + for (const line of lines) { + const record = parsePiRecord(line); + if (record === undefined) { + yield* Effect.logDebug("Dropping non-JSON pi stdout line.", { + lineLength: line.length, + }); + continue; + } + yield* routeRecord(record); + } + }), + ), + ); + const trailing = buffer.length > 0 ? parsePiRecord(buffer) : undefined; + if (trailing !== undefined) { + yield* routeRecord(trailing); + } + }).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => failTransport(new PiRpcError({ operation: "read", cause })), + onSuccess: () => + Effect.gen(function* () { + // Claim the stdout-close path before waiting so a broken stdin + // writer cannot replace the exit diagnosis with a write error. + diagnosingStdoutClose = true; + const polled = yield* Deferred.poll(exitDeferred); + const maybeExitCode = Option.isSome(polled) + ? yield* polled.value.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ) + : yield* Deferred.await(exitDeferred).pipe( + Effect.timeoutOption(Duration.millis(250)), + // Adapter tests run under TestClock. A planned stdout close + // without an exit, for example Stop-with-restart, must not wait + // on that clock or the transport never fails. + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + Effect.orElseSucceed(() => Option.none()), + ); + return yield* failTransport( + new PiRpcError({ + operation: "read", + detail: describePiStdoutClosure( + Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, + ), + }), + ); + }), + }), + Effect.forkIn(scope), + ); + + // Surface stderr as debug logs; pi reserves stdout for the protocol. + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + chunk.trim().length === 0 + ? Effect.void + : // Length only: pi's stderr is unbounded remote output and can carry + // credentials or prompt text, so it never enters a log annotation. + Effect.logDebug("pi stderr", { stderrLength: chunk.length }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + // Writer starts after the reader so an already-closed stdout can mark + // diagnosis before a broken-pipe stdin claims the transport. + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(child.stdin), + Effect.catchCause((cause) => + diagnosingStdoutClose + ? Effect.void + : failTransport(new PiRpcError({ operation: "write", cause })), + ), + Effect.forkIn(scope), + ); + + const send = (record: PiRpcRecord): Effect.Effect => + Effect.gen(function* () { + const accepted = yield* Queue.offer( + outgoing, + new TextEncoder().encode(`${encodeJsonLine(record)}\n`), + ); + // A refused offer means `failTransport` already closed the queue, so the + // write can never land; surface the transport error instead of + // reporting a success the caller cannot rely on. + if (!accepted) { + return yield* Deferred.await(transportDown); + } + }); + + const request = ( + record: PiRpcRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Effect.Effect => + Effect.gen(function* () { + const id = `t3-${nextRequestId++}`; + const deferred = yield* Deferred.make(); + pendingRequests.set(id, { deferred }); + yield* send({ ...record, id }).pipe( + Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + // Raced against the transport: a death that lands after this request was + // registered (or between `send`'s check and its enqueue) would otherwise + // leave the caller waiting out the full timeout for a reply that is + // never coming. + return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.fail( + new PiRpcError({ + operation: String(record["type"] ?? "request"), + detail: `timed out after ${timeoutMs}ms`, + }), + ), + }), + Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), + Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + }); + + return { + send, + request, + events, + exited: Deferred.await(exitDeferred), + terminate: terminateProcess.pipe(Effect.ignore, Effect.uninterruptible), + } satisfies PiRpcConnection; +}); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts new file mode 100644 index 000000000000..96b3dd9c8941 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -0,0 +1,301 @@ +/** + * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. + * + * Pi core has no MCP client. This file is TypeScript that Pi itself loads via + * `--extension`. It is written to a cache path at session open so packaged + * AppImage builds do not need a sibling .ts file next to the bundled server. + * + * Do not import t3code modules from the string body. The Pi process resolves + * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. + */ +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; + +export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; + +export const T3_MCP_URL_ENV = "T3_MCP_URL"; +export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; +export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; + +export const PI_T3_MCP_EXTENSION_SOURCE = `\ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; +const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; +const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; +const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; +const PROTOCOL = "2025-06-18"; +const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); +const FILE_CHANGE_TOOLS = new Set(["edit", "write"]); + +type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; + +type JsonRpcResponse = { + readonly id?: number | string; + readonly result?: unknown; + readonly error?: { readonly message?: string }; +}; + +type McpTool = { + readonly name: string; + readonly description?: string; + readonly inputSchema?: Record; +}; + +function env(name: string): string | undefined { + const value = process.env[name]; + return value && value.length > 0 ? value : undefined; +} + +function runtimeMode(): RuntimeMode { + const value = env(RUNTIME_MODE_ENV); + return value === "approval-required" || + value === "auto-accept-edits" || + value === "auto" || + value === "full-access" + ? value + : "full-access"; +} + +function toolInputSummary(input: unknown): string { + try { + return JSON.stringify(input, null, 2).slice(0, 4_000); + } catch { + return String(input).slice(0, 4_000); + } +} + +function parseSseOrJson(body: string, contentType: string): JsonRpcResponse { + if (contentType.includes("text/event-stream")) { + for (const line of body.split("\\n")) { + const trimmed = line.startsWith("data:") ? line.slice(5).trim() : ""; + if (trimmed.length === 0) continue; + const parsed = JSON.parse(trimmed) as JsonRpcResponse; + if (parsed.id !== undefined || parsed.result !== undefined || parsed.error !== undefined) { + return parsed; + } + } + throw new Error("MCP SSE response had no JSON-RPC payload."); + } + return JSON.parse(body) as JsonRpcResponse; +} + +function jsonSchemaToTypebox(schema: Record | undefined) { + const unsafe = (Type as { Unsafe?: (value: unknown) => unknown }).Unsafe; + if (typeof unsafe === "function" && schema !== undefined) { + return unsafe(schema); + } + return Type.Object({}, { additionalProperties: true }); +} + +function formatMcpContent(result: unknown): string { + if (result === null || result === undefined) return ""; + if (typeof result !== "object") return String(result); + const record = result as { + readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; + readonly structuredContent?: unknown; + readonly isError?: boolean; + }; + const texts: string[] = []; + if (Array.isArray(record.content)) { + for (const part of record.content) { + if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); + } + } + if (record.structuredContent !== undefined) { + texts.push(JSON.stringify(record.structuredContent)); + } + if (texts.length > 0) return texts.join("\\n"); + return JSON.stringify(result); +} + +function isMcpToolError(result: unknown): boolean { + return ( + typeof result === "object" && + result !== null && + "isError" in result && + result.isError === true + ); +} + +function createMcpClient(endpoint: string, token: string) { + let nextId = 1; + let sessionId: string | undefined; + + const headers = (): Record => { + const next: Record = { + accept: "application/json, text/event-stream", + authorization: token.startsWith("Bearer ") ? token : \`Bearer \${token}\`, + "content-type": "application/json", + // Effect's HTTP MCP rejects post-initialize requests without this + // (400). The worktree client in McpHttpServer tests sends the same + // header; initialize itself does not require it. + "mcp-protocol-version": PROTOCOL, + }; + if (sessionId !== undefined) next["mcp-session-id"] = sessionId; + return next; + }; + + const request = async (method: string, params?: unknown, signal?: AbortSignal) => { + const id = nextId++; + const response = await fetch(endpoint, { + method: "POST", + headers: headers(), + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + signal, + }); + const nextSession = response.headers.get("mcp-session-id"); + if (nextSession) sessionId = nextSession; + const body = await response.text(); + if (!response.ok) { + throw new Error(\`MCP \${method} failed (\${response.status}): \${body.slice(0, 400)}\`); + } + if (body.length === 0) return undefined; + const parsed = parseSseOrJson(body, response.headers.get("content-type") ?? ""); + if (parsed.error) { + throw new Error(parsed.error.message ?? \`MCP \${method} returned an error\`); + } + return parsed.result; + }; + + const notify = async (method: string, params?: unknown, signal?: AbortSignal) => { + await fetch(endpoint, { + method: "POST", + headers: headers(), + body: JSON.stringify({ jsonrpc: "2.0", method, params }), + signal, + }); + }; + + return { + async connect(signal?: AbortSignal) { + await request( + "initialize", + { + protocolVersion: PROTOCOL, + capabilities: {}, + clientInfo: { name: "t3-pi-mcp", version: "1.0.0" }, + }, + signal, + ); + await notify("notifications/initialized", {}, signal).catch(() => undefined); + }, + async listTools(signal?: AbortSignal) { + const tools: McpTool[] = []; + let cursor: string | undefined; + do { + const result = (await request( + "tools/list", + cursor === undefined ? {} : { cursor }, + signal, + )) as { tools?: McpTool[]; nextCursor?: string } | undefined; + tools.push(...(result?.tools ?? [])); + cursor = result?.nextCursor; + } while (cursor); + return tools; + }, + async callTool(name: string, args: Record, signal?: AbortSignal) { + return request("tools/call", { name, arguments: args }, signal); + }, + }; +} + +export default async function t3McpExtension(pi: ExtensionAPI) { + // Pi deliberately leaves permission policy to extensions. T3's injected + // bridge uses Pi's public blocking tool hook so the shared runtime modes + // keep their normal meaning without replacing or shadowing Pi's runtime. + pi.on("tool_call", async (event, ctx) => { + const mode = runtimeMode(); + if (mode === "full-access" || READ_ONLY_TOOLS.has(event.toolName)) return; + if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) { + return; + } + const approved = await ctx.ui.confirm( + \`Allow \${event.toolName}?\`, + toolInputSummary(event.input), + ); + if (!approved) { + return { block: true, reason: \`\${event.toolName} was declined in T3 Code.\` }; + } + }); + + const endpoint = env(URL_ENV); + const token = env(TOKEN_ENV); + if (endpoint === undefined || token === undefined) { + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify( + "t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.", + "warning", + ); + }); + return; + } + + const client = createMcpClient(endpoint, token); + let started: Promise | undefined; + + const ensureStarted = () => { + if (started !== undefined) return started; + const attempt = (async () => { + const signal = AbortSignal.timeout(10_000); + await client.connect(signal); + const tools = await client.listTools(signal); + for (const tool of tools) { + const name = tool.name; + const registeredName = \`mcp__t3-code__\${name}\`; + const description = tool.description ?? name; + pi.registerTool({ + name: registeredName, + label: name, + description, + promptSnippet: description.split("\\n")[0] ?? name, + promptGuidelines: [ + \`Use \${registeredName} from the t3-code MCP server when the user asks for T3 orchestration that this tool covers.\`, + ], + parameters: jsonSchemaToTypebox(tool.inputSchema), + async execute(_toolCallId, params, signal) { + const result = await client.callTool( + name, + (params ?? {}) as Record, + signal, + ); + const text = formatMcpContent(result); + return { + content: [{ type: "text", text }], + details: { server: "t3-code", tool: name }, + ...(isMcpToolError(result) ? { isError: true } : {}), + }; + }, + }); + } + })(); + started = attempt; + void attempt.catch(() => { + if (started === attempt) started = undefined; + }); + return attempt; + }; + + // Await here so tools exist before session_start and the first prompt. + // session_start is a retry if the process later reloads the extension. + // Best effort during extension load. A failed first connection is retried + // below on session_start instead of pinning this process to the failure. + await ensureStarted().catch(() => undefined); + + pi.on("session_start", async (_event, ctx) => { + try { + await ensureStarted(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); + } + }); + + // Deliver orchestration guidance through pi's real system-prompt channel. + // Wrapping the first user message instead would stop it from starting + // with "/" and silently break slash-command expansion. + pi.on("before_agent_start", (event) => ({ + systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS, + })); +} +`; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts new file mode 100644 index 000000000000..b50a80a1cabe --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -0,0 +1,156 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + PI_T3_MCP_EXTENSION_FILENAME, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, + T3_PI_RUNTIME_MODE_ENV, +} from "./piT3McpExtensionSource.ts"; +import { + buildPiRpcLaunch, + materializePiT3McpExtension, + resolvePiLaunchArgs, +} from "./piT3McpInjection.ts"; + +const threadId = ThreadId.make("thread-pi-t3-mcp"); + +const mcpSession = { + environmentId: EnvironmentId.make("environment-pi-t3-mcp"), + threadId, + providerSessionId: "mcp-session-pi", + providerInstanceId: ProviderInstanceId.make("pi"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-pi-token", + browserToolsAvailable: true, +}; + +describe("pi T3 MCP injection", () => { + it("always adds the permission bridge and configures MCP when available", () => { + const resolvedArgs = resolvePiLaunchArgs( + "--extension=/home/user/.pi/agent/extensions/demo.ts --session-dir=/tmp/pi-sessions --provider=anthropic --model=claude-sonnet --tools='' --name=-review --extension-flag=kept", + ); + assert.isTrue(resolvedArgs.ok); + if (!resolvedArgs.ok) return; + const launch = buildPiRpcLaunch({ + launchArgs: resolvedArgs.args, + environment: { PATH: "/usr/bin" }, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + runtimeMode: "approval-required", + }); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--extension", + "/home/user/.pi/agent/extensions/demo.ts", + "--session-dir", + "/tmp/pi-sessions", + "--provider", + "anthropic", + "--model", + "claude-sonnet", + "--tools", + "", + "--name", + "-review", + "--extension-flag=kept", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + assert.notInclude(launch.args, "--no-extensions"); + assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); + assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); + assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); + + const permissionOnly = buildPiRpcLaunch({ + launchArgs: [], + environment: { + [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale", + [T3_MCP_BEARER_ENV]: "stale-token", + }, + mcpSession: undefined, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + runtimeMode: "auto-accept-edits", + }); + assert.deepEqual(permissionOnly.args, [ + "--mode", + "rpc", + "--extension", + "/tmp/cache/pi-t3-mcp-extension.ts", + ]); + assert.isFalse(permissionOnly.hasT3Mcp); + assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]); + assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]); + assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits"); + }); + + it("falls back to Pi's first supported mode for legacy auto threads", () => { + const launch = buildPiRpcLaunch({ + launchArgs: [], + environment: {}, + mcpSession: undefined, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + runtimeMode: "auto", + }); + + assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); + }); + + it("forces tools and user extensions off for unattended text generation", () => { + const launch = buildPiRpcLaunch({ + launchArgs: [ + "--tools", + "read,write", + "--extension", + "/home/user/.pi/agent/extensions/demo.ts", + "--extension=./second.ts", + "--provider", + "anthropic", + ], + environment: {}, + mcpSession, + extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", + ephemeral: true, + disableExtensions: true, + disableTools: true, + }); + assert.deepEqual(launch.args, [ + "--mode", + "rpc", + "--no-session", + "--provider", + "anthropic", + "--no-extensions", + "--no-tools", + ]); + assert.isFalse(launch.hasT3Mcp); + assert.deepInclude(resolvePiLaunchArgs("--mode text"), { + ok: false, + message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.", + }); + assert.deepInclude(resolvePiLaunchArgs("--session old.jsonl"), { ok: false }); + assert.deepInclude(resolvePiLaunchArgs("prompt pi immediately"), { ok: false }); + assert.deepInclude(resolvePiLaunchArgs("--plan @instructions.md"), { ok: false }); + }); + + it.effect("materializes the MCP bridge with namespaced tool registration", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-extensions-" }); + const mcpDest = yield* materializePiT3McpExtension(cacheDir); + assert.isTrue(mcpDest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); + const mcpSource = yield* fs.readFileString(mcpDest); + assert.include(mcpSource, "export default async function t3McpExtension"); + assert.include(mcpSource, "before_agent_start"); + assert.include(mcpSource, 'pi.on("tool_call"'); + assert.include(mcpSource, "Allow ${event.toolName}?"); + assert.include(mcpSource, '"mcp-protocol-version"'); + assert.include(mcpSource, '"tools/call"'); + assert.include(mcpSource, "mcp__t3-code__"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts new file mode 100644 index 000000000000..72d12b63ef3a --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -0,0 +1,308 @@ +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; +import { + PI_T3_MCP_EXTENSION_FILENAME, + PI_T3_MCP_EXTENSION_SOURCE, + T3_MCP_BEARER_ENV, + T3_MCP_URL_ENV, + T3_PI_RUNTIME_MODE_ENV, +} from "./piT3McpExtensionSource.ts"; + +export { PI_T3_MCP_EXTENSION_FILENAME, T3_MCP_BEARER_ENV, T3_MCP_URL_ENV, T3_PI_RUNTIME_MODE_ENV }; + +const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ + "--continue", + "-c", + "--export", + "--fork", + "--help", + "-h", + "--list-models", + "--mode", + "--no-session", + "--print", + "-p", + "--resume", + "-r", + "--session", + "--session-id", + "--version", + "-v", +]); + +const PI_ARGUMENTS_WITH_VALUES = new Set([ + "--api-key", + "--append-system-prompt", + "--exclude-tools", + "-xt", + "--extension", + "-e", + "--model", + "--models", + "--name", + "-n", + "--prompt-template", + "--provider", + "--session-dir", + "--skill", + "--system-prompt", + "--theme", + "--thinking", + "--tools", + "-t", + "--tui-mode", + "--use-theme", +]); + +const PI_ARGUMENTS_WITHOUT_VALUES = new Set([ + "--approve", + "-a", + "--no-approve", + "-na", + "--no-builtin-tools", + "-nbt", + "--no-context-files", + "-nc", + "--no-extensions", + "-ne", + "--no-prompt-templates", + "-np", + "--no-skills", + "-ns", + "--no-themes", + "--no-tools", + "-nt", + "--offline", + "--verbose", +]); + +export type PiLaunchArgsResolution = + | { readonly ok: true; readonly args: ReadonlyArray } + | { readonly ok: false; readonly message: string }; + +function reservedPiArgument(arg: string): string | undefined { + for (const reserved of RESERVED_PI_LAUNCH_ARGUMENTS) { + if (arg === reserved || (reserved.startsWith("--") && arg.startsWith(`${reserved}=`))) { + return reserved; + } + } + return undefined; +} + +function normalizePiBuiltInEqualsArguments(args: ReadonlyArray): ReadonlyArray { + return args.flatMap((arg) => { + const equalsIndex = arg.indexOf("="); + if (equalsIndex <= 0) return [arg]; + const option = arg.slice(0, equalsIndex); + return PI_ARGUMENTS_WITH_VALUES.has(option) ? [option, arg.slice(equalsIndex + 1)] : [arg]; + }); +} + +/** + * Pi launch arguments may configure resources, models, tools, trust, and + * storage. T3 owns RPC mode and session identity, so arguments that select a + * different execution mode or native session are rejected before spawn. + */ +export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution { + // Pi parses equals-form tokens only as extension flags, even when their name + // matches a built-in option. Split known built-ins while leaving arbitrary + // extension flags in their native form. + const args = normalizePiBuiltInEqualsArguments(tokenizeCliArgs(launchArgs)); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + const reserved = reservedPiArgument(arg); + if (reserved !== undefined) { + return { + ok: false, + message: `Pi launch argument '${reserved}' is controlled by T3 Code and cannot be overridden.`, + }; + } + if (arg === "--") { + return { + ok: false, + message: "Pi launch arguments cannot include positional prompts.", + }; + } + if (PI_ARGUMENTS_WITH_VALUES.has(arg)) { + const value = args[index + 1]; + if (value === undefined) { + return { ok: false, message: `Pi launch argument '${arg}' requires a value.` }; + } + index += 1; + continue; + } + if (PI_ARGUMENTS_WITHOUT_VALUES.has(arg) || (arg.startsWith("--") && arg.includes("="))) { + continue; + } + if (arg.startsWith("--")) { + // Pi extensions may register arbitrary long flags. Treat one following + // non-flag token as that extension flag's value. + if ( + args[index + 1] !== undefined && + !args[index + 1]!.startsWith("-") && + !args[index + 1]!.startsWith("@") + ) { + index += 1; + } + continue; + } + if (arg.startsWith("-")) { + return { ok: false, message: `Pi launch argument '${arg}' is not supported by T3 Code.` }; + } + return { + ok: false, + message: `Pi launch arguments cannot include positional prompt '${arg}'.`, + }; + } + return { ok: true, args }; +} + +function bearerTokenFromAuthorizationHeader(header: string): string { + return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; +} + +function normalizedPiPath(value: string): string { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function hasExplicitExtension(args: ReadonlyArray, extensionPath: string): boolean { + const wanted = normalizedPiPath(extensionPath); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg !== "--extension" && arg !== "-e") continue; + const configured = args[index + 1]; + if (configured !== undefined && normalizedPiPath(configured) === wanted) return true; + index += 1; + } + return false; +} + +function withoutExplicitExtensions(args: ReadonlyArray): ReadonlyArray { + const filtered: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + if (arg === "--extension" || arg === "-e") { + index += 1; + continue; + } + if (arg.startsWith("--extension=") || arg.startsWith("-e=")) continue; + filtered.push(arg); + } + return filtered; +} + +function withoutToolSelectionArgs(args: ReadonlyArray): ReadonlyArray { + const filtered: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + if (arg === "--tools" || arg === "-t" || arg === "--exclude-tools" || arg === "-xt") { + index += 1; + continue; + } + if ( + arg === "--no-tools" || + arg === "-nt" || + arg === "--no-builtin-tools" || + arg === "-nbt" || + arg.startsWith("--tools=") || + arg.startsWith("-t=") || + arg.startsWith("--exclude-tools=") || + arg.startsWith("-xt=") + ) { + continue; + } + filtered.push(arg); + } + return filtered; +} + +function piT3McpExtensionDestPath(cacheDir: string): string { + return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; +} + +export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( + cacheDir: string, +) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(cacheDir, { recursive: true }); + const dest = piT3McpExtensionDestPath(cacheDir); + const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); + if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { + yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); + } + return dest; +}); + +export function buildPiRpcLaunch(input: { + readonly launchArgs: ReadonlyArray; + readonly environment: NodeJS.ProcessEnv; + readonly mcpSession: McpProviderSessionConfig | undefined; + readonly extensionPath: string | undefined; + readonly ephemeral?: boolean; + readonly disableExtensions?: boolean; + readonly disableTools?: boolean; + readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; +}): { + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly hasT3Mcp: boolean; +} { + const hasT3Extension = input.disableExtensions !== true && input.extensionPath !== undefined; + const hasT3Mcp = hasT3Extension && input.mcpSession !== undefined; + const extensionSafeArgs = + input.disableExtensions === true + ? withoutExplicitExtensions(input.launchArgs) + : input.launchArgs; + const launchArgs = + input.disableTools === true ? withoutToolSelectionArgs(extensionSafeArgs) : extensionSafeArgs; + const args = [ + "--mode", + "rpc", + ...(input.ephemeral === true ? ["--no-session"] : []), + ...launchArgs, + // Restrictions follow user launch args so a configured --tools or + // --extension cannot silently re-enable unattended text-generation code. + ...(input.disableExtensions === true ? ["--no-extensions"] : []), + ...(input.disableTools === true ? ["--no-tools"] : []), + ]; + if ( + hasT3Extension && + input.extensionPath !== undefined && + !hasExplicitExtension(args, input.extensionPath) + ) { + args.push("--extension", input.extensionPath); + } + const environment = { ...input.environment }; + // These values belong to the current T3 session. Never let a Pi child reuse + // credentials inherited from the server or a parent provider process. + delete environment[T3_MCP_URL_ENV]; + delete environment[T3_MCP_BEARER_ENV]; + + return { + args, + env: { + ...environment, + ...(hasT3Extension && input.runtimeMode !== undefined + ? { + [T3_PI_RUNTIME_MODE_ENV]: + input.runtimeMode === "auto" ? "approval-required" : input.runtimeMode, + } + : {}), + ...(hasT3Mcp && input.mcpSession !== undefined + ? { + [T3_MCP_URL_ENV]: input.mcpSession.endpoint, + [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( + input.mcpSession.authorizationHeader, + ), + } + : {}), + }, + hasT3Mcp, + }; +} diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 0e048471da9e..d8d94376d821 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -13,6 +13,7 @@ import { type ProviderSessionId, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -48,6 +49,7 @@ import { type ProviderAdapterV2Shape, } from "./ProviderAdapter.ts"; import { makeSingleLayer as makeProviderAdapterRegistryLayer } from "./ProviderAdapterRegistry.ts"; +import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; import { ProviderSessionManagerV2, layerWithOptions as providerSessionManagerLayerWithOptions, @@ -92,7 +94,7 @@ interface TestProviderRuntimeState { readonly closeCount: number; readonly interruptCount: number; readonly resumeCount: number; - readonly eventQueues: ReadonlyMap>; + readonly eventQueues: ReadonlyMap>; } const emptyState: TestProviderRuntimeState = { @@ -257,7 +259,7 @@ function makeProviderAdapter( ]); } const now = yield* DateTime.now; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const session = makeProviderSession({ providerSessionId: input.providerSessionId, now, @@ -358,6 +360,9 @@ function makeTestLayer(input: { : { hangSessionScopeClose: input.hangSessionScopeClose }), }), ); + const providerEventIngestorTestLayer = providerEventIngestorLayer.pipe( + Layer.provide(Layer.mergeAll(configuredEventSinkLayer, idAllocatorLayer)), + ); return Layer.mergeAll( TestStoresLayer, configuredEventSinkLayer, @@ -372,6 +377,7 @@ function makeTestLayer(input: { registryLayer, configuredEventSinkLayer, idAllocatorLayer, + providerEventIngestorTestLayer, TestMcpRegistryLayer, TestStoresLayer, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), @@ -467,7 +473,7 @@ function makePendingRuntimeRequestEvents(input: { requestId, requestKind: "command" as const, }; - return [ + const events = [ { id: yield* input.idAllocator.allocate.event({ threadId: input.threadId, @@ -505,6 +511,25 @@ function makePendingRuntimeRequestEvents(input: { payload: turnItem, }, ] satisfies ReadonlyArray; + const providerEvents = [ + { + type: "runtime_request.updated" as const, + driver: CODEX_DRIVER, + threadId: input.threadId, + runtimeRequest: request, + }, + { + type: "node.updated" as const, + driver: CODEX_DRIVER, + node, + }, + { + type: "turn_item.updated" as const, + driver: CODEX_DRIVER, + turnItem, + }, + ] satisfies ReadonlyArray; + return { events, providerEvents, requestId, nodeId }; }); } @@ -729,6 +754,74 @@ it.effect("ProviderSessionManagerV2 closes event subscriptions normally on serve }), ); +it.effect("ProviderSessionManagerV2 drains subscribers when the provider stops", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-provider-stop"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const subscription = yield* runtime.subscribeEvents!; + const collected = yield* subscription.events.pipe(Stream.runCollect, Effect.forkScoped); + const adapterQueue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterQueue); + const providerThreadId = idAllocator.derive.providerThread({ + driver: CODEX_DRIVER, + nativeThreadId: "provider-stop-thread", + }); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "provider-stop-turn", + }); + yield* Queue.offer(adapterQueue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId, + providerTurnId, + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Queue.offer(adapterQueue!, { + type: "provider_session.updated", + driver: CODEX_DRIVER, + providerSession: { + ...runtime.providerSession, + status: "stopped", + updatedAt: now, + }, + }); + yield* Queue.end(adapterQueue!); + + const events = Array.from(yield* Fiber.join(collected)); + assert.deepEqual( + events.map((event) => event.type), + ["turn.terminal", "provider_session.updated"], + ); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 }))); + }), +); + it.effect( "ProviderSessionManagerV2 issues MCP credentials before opening and revokes them on close", () => @@ -833,7 +926,11 @@ it.effect( state, idleTimeoutMs: 1_000, mcpConfigs, - serverSettingsLayer: ServerSettings.layerTest({ enableAgentBrowserAccess: false }), + // orDie: the test layer's settings-normalization error cannot + // occur for a literal override and the slot requires error never. + serverSettingsLayer: ServerSettings.layerTest({ + enableAgentBrowserAccess: false, + }).pipe(Layer.orDie), }), ), ); @@ -1943,15 +2040,14 @@ it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on r yield* eventSink.write({ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], }); - yield* eventSink.write({ - events: yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - }), + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, }); + yield* eventSink.write({ events: pendingRequest.events }); yield* manager.open({ threadId, providerSessionId, @@ -1981,6 +2077,91 @@ it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on r }), ); +it.effect("ProviderSessionManagerV2 persists session-scoped runtime requests without a run", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-session-request", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-session-request", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + const afterSequence = yield* eventSink.latestSequence({ threadId }); + const persistedFiber = yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.filter( + (stored) => + stored.event.type === "runtime-request.updated" || + stored.event.type === "node.updated" || + stored.event.type === "turn-item.updated", + ), + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); + const persisted = Array.from(yield* Fiber.join(persistedFiber)); + + assert.sameMembers( + persisted.map((stored) => stored.event.type), + ["runtime-request.updated", "node.updated", "turn-item.updated"], + ); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + const node = projection.nodes.find((candidate) => candidate.id === pendingRequest.nodeId); + const turnItem = projection.turnItems.find( + (candidate) => + candidate.type === "approval_request" && candidate.requestId === pendingRequest.requestId, + ); + assert.equal(request?.status, "pending"); + assert.equal(request?.providerTurnId, null); + assert.equal(node?.runId, null); + assert.equal(node?.status, "waiting"); + assert.equal(turnItem?.runId, null); + assert.equal(turnItem?.status, "waiting"); + }); + + yield* effect.pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1000 }))); + }), +); + it.effect( "ProviderSessionManagerV2 keeps a multi-thread session alive until all turns finish", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 7b3c93169bf9..4206686af257 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -29,6 +29,7 @@ import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import { EventSinkV2 } from "./EventSink.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; +import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts"; import { ProviderAdapterEventStreamError, ProviderAdapterV2RuntimePolicy, @@ -226,6 +227,29 @@ function sessionKey(providerSessionId: ProviderSessionId): string { return String(providerSessionId); } +/** + * Runtime requests with no provider turn belong to the live session itself. + * Their node and transcript item are runless too, so they bypass the normal + * per-run subscriber and are persisted by the session event pump. + */ +function sessionScopedRuntimeRequestThreadId(event: ProviderAdapterV2Event): ThreadId | undefined { + switch (event.type) { + case "runtime_request.updated": + return event.runtimeRequest.providerTurnId === null ? event.threadId : undefined; + case "node.updated": + return event.node.runId === null && event.node.runtimeRequestId !== null + ? event.node.threadId + : undefined; + case "turn_item.updated": + return event.turnItem.runId === null && + (event.turnItem.type === "approval_request" || event.turnItem.type === "user_input_request") + ? event.turnItem.threadId + : undefined; + default: + return undefined; + } +} + function providerThreadRuntimeKey( providerThread: Parameters[0]["providerThread"], ): string { @@ -258,6 +282,7 @@ export const layerWithOptions = ( | IdAllocatorV2 | McpSessionRegistry.McpSessionRegistry | ProjectionStoreV2 + | ProviderEventIngestorV2 | ProviderAdapterRegistryV2 > => Layer.effect( @@ -289,6 +314,7 @@ export const layerWithOptions = ( }); const eventSink = yield* EventSinkV2; const idAllocator = yield* IdAllocatorV2; + const providerEventIngestor = yield* ProviderEventIngestorV2; const projectionStore = yield* ProjectionStoreV2; const layerScope = yield* Effect.scope; const sessions = yield* Ref.make(new Map()); @@ -455,6 +481,17 @@ export const layerWithOptions = ( ); }); + // Preserve already-published terminal events while ending subscriptions. + // Server shutdown intentionally clears them; a provider-announced Stop + // must let consumers drain them before the stream completes. + const endSubscribers = (entry: LiveSessionEntry) => + Effect.gen(function* () { + const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); + yield* Effect.forEach(subscribers.values(), (queue) => Queue.end(queue), { + discard: true, + }); + }); + const cancelIdleFiber = (fiber: Fiber.Fiber | null) => fiber === null ? Effect.void : Fiber.interrupt(fiber).pipe(Effect.ignore); @@ -614,6 +651,7 @@ export const layerWithOptions = ( readonly detail?: string; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; + readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( Ref.modify(sessions, (current) => { @@ -640,7 +678,9 @@ export const layerWithOptions = ( if (input.cancelIdleFiber !== false) { yield* cancelIdleFiber(entry.idleFiber); } - if (input.reason === "server_shutdown") { + if (input.gracefulSubscribers === true) { + yield* endSubscribers(entry); + } else if (input.reason === "server_shutdown") { yield* closeSubscribers(entry); } else { yield* failSubscribers( @@ -1316,10 +1356,17 @@ export const layerWithOptions = ( ), ); - const startEventPump = (entry: LiveSessionEntry) => - entry.runtime.events.pipe( - Stream.runForEach((event) => - observeActivity( + const startEventPump = (entry: LiveSessionEntry) => { + let stoppedByProvider = false; + return entry.runtime.events.pipe( + Stream.runForEach((event) => { + if ( + event.type === "provider_session.updated" && + event.providerSession.status === "stopped" + ) { + stoppedByProvider = true; + } + return observeActivity( entry.runtime.providerSessionId, event.type === "turn.terminal" ? markIdle(entry.runtime.providerSessionId) @@ -1331,10 +1378,37 @@ export const layerWithOptions = ( : Effect.void, ), Effect.andThen( - publishToSubscribers(entry.eventSubscribers, { type: "event", event }), + Effect.gen(function* () { + // Some providers can block before a run subscriber exists + // (project trust, login, or session-switch hooks). Persist + // their runless request artifacts directly so the normal T3 + // request UI can answer them and unblock session setup. + const threadId = sessionScopedRuntimeRequestThreadId(event); + if (threadId !== undefined) { + yield* providerEventIngestor + .ingestNormalized({ + providerSessionId: entry.runtime.providerSessionId, + providerInstanceId: entry.runtime.instanceId, + threadId, + event, + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEventStreamError({ + driver: entry.runtime.driver, + providerSessionId: entry.runtime.providerSessionId, + cause, + }), + ), + ); + return; + } + yield* publishToSubscribers(entry.eventSubscribers, { type: "event", event }); + }), ), - ), - ), + ); + }), Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { @@ -1344,6 +1418,14 @@ export const layerWithOptions = ( if (current?.runtime !== entry.runtime) { return; } + if (stoppedByProvider && Exit.isSuccess(exit)) { + yield* releaseEntry({ + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + gracefulSubscribers: true, + }).pipe(Effect.ignore); + return; + } const cause = Exit.isFailure(exit) ? exit.cause : Cause.fail( @@ -1367,6 +1449,7 @@ export const layerWithOptions = ( ), Effect.forkIn(layerScope), ); + }; const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index fa448480d1e8..93b71c6fcf75 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -255,11 +255,16 @@ export const layer: Layer.Layer< }); } if (providerThread.nativeThreadRef === null) { + // Hand the run's provider thread to the adapter so it adopts this + // row's identity when attaching native state. An adapter that mints + // its own row instead leaves two live rows per app thread, and + // `activeProviderThreadId` then flaps between them on every update. return yield* session.ensureThread({ threadId: projection.thread.id, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, + existingProviderThread: providerThread, }); } const resumed = yield* Effect.result( @@ -279,6 +284,10 @@ export const layer: Layer.Layer< modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, + // The native ref is dropped so the adapter binds a fresh native + // session instead of retrying the resume that just failed, while + // still adopting this row's identity. + existingProviderThread: { ...providerThread, nativeThreadRef: null }, }); if (existingResumeFallback !== undefined) { return replacement; diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts index 63e3a77a5b3f..ecb57bdc4c64 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.test.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.test.ts @@ -21,6 +21,39 @@ describe("userFacingDispatchErrorMessage", () => { ); }); + it("translates policy capability rejections into provider-named prose", () => { + assert.equal( + userFacingDispatchErrorMessage({ + message: "Failed to dispatch orchestration command checkpoint.rollback (command-1).", + cause: { + _tag: "CommandPolicyCapabilityUnsupportedError", + commandId: "command-1", + threadId: "thread-1", + providerInstanceId: "pi", + capability: "rollback_snapshot", + detail: "rollback must return a provider thread snapshot", + message: + "pi cannot satisfy rollback_snapshot for command command-1: rollback must return a provider thread snapshot", + }, + }), + "Pi did not report its rewound conversation state, so the checkpoint was not restored.", + ); + assert.equal( + userFacingDispatchErrorMessage({ + message: "Failed to dispatch orchestration command checkpoint.rollback (command-2).", + cause: { + _tag: "CommandPolicyCapabilityUnsupportedError", + commandId: "command-2", + threadId: "thread-1", + providerInstanceId: "grok", + capability: "rollback", + detail: "provider conversation rollback is unavailable", + }, + }), + "Grok cannot rewind its conversation, so this checkpoint cannot be restored on this thread.", + ); + }); + it("uses explicit detail fields as user-facing messages", () => { assert.equal( userFacingDispatchErrorMessage({ diff --git a/apps/server/src/orchestration-v2/UserFacingErrors.ts b/apps/server/src/orchestration-v2/UserFacingErrors.ts index 3765312da39b..81f12b05dab5 100644 --- a/apps/server/src/orchestration-v2/UserFacingErrors.ts +++ b/apps/server/src/orchestration-v2/UserFacingErrors.ts @@ -1,11 +1,55 @@ +import { PROVIDER_DISPLAY_NAMES, type ProviderDriverKind } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + const GENERIC_ERROR_PREFIXES = [ "Failed to dispatch orchestration V2 command", "Failed to dispatch orchestration command ", "Provider adapter failed while dispatching orchestration command ", ]; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; +/** + * User-facing prose for capability rejections. The internal error messages + * keep command ids and capability codes for logs; the toast should say what + * did not happen and why in the provider's own name. + */ +const CAPABILITY_REJECTION_MESSAGES: Record string> = { + queued_messages: (p) => `${p} cannot queue messages behind an active run.`, + active_steering: (p) => + `${p} cannot redirect an active run. Stop it first, then send the message.`, + interrupt_restart_steering: (p) => + `${p} cannot redirect an active run. Stop it first, then send the message.`, + interrupt: (p) => `${p} cannot stop a run once it has started.`, + native_fork: (p) => `${p} cannot fork this thread natively.`, + fork_from_turn: (p) => `${p} cannot fork from an earlier point in this thread.`, + rollback: (p) => + `${p} cannot rewind its conversation, so this checkpoint cannot be restored on this thread.`, + rollback_snapshot: (p) => + `${p} did not report its rewound conversation state, so the checkpoint was not restored.`, + context_handoff: (p) => `${p} cannot receive the context handoff needed for this switch.`, + strong_terminal_status: (p) => + `${p} cannot confirm when its runs finish reliably enough for this.`, +}; + +function providerDisplayName(instanceId: string): string { + const known = PROVIDER_DISPLAY_NAMES[instanceId as ProviderDriverKind]; + if (known !== undefined) return known; + const trimmed = instanceId.replace(/Agent$/i, "").trim(); + if (trimmed.length === 0) return instanceId; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +/** Friendly translation for command-policy rejections; undefined otherwise. */ +function policyRejectionMessage(value: unknown): string | undefined { + if (!Predicate.isObject(value) || typeof value.providerInstanceId !== "string") return undefined; + const provider = providerDisplayName(value.providerInstanceId); + if (value._tag === "CommandPolicyCapabilityUnsupportedError") { + const capability = typeof value.capability === "string" ? value.capability : ""; + return CAPABILITY_REJECTION_MESSAGES[capability]?.(provider); + } + if (value._tag === "CommandPolicyUnsupportedError") { + return `${provider} cannot deliver a message that way right now.`; + } + return undefined; } function textValue(value: unknown): string | undefined { @@ -16,10 +60,14 @@ function messageFrom(value: unknown): string | undefined { if (typeof value === "string") { return textValue(value); } + const friendly = policyRejectionMessage(value); + if (friendly !== undefined) { + return friendly; + } if (value instanceof Error) { return textValue(value.message); } - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return undefined; } return textValue(value.detail) ?? textValue(value.message); @@ -40,7 +88,7 @@ function collectErrorMessages(value: unknown, seen: Set): ReadonlyArray } const message = messageFrom(value); - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return message === undefined ? [] : [message]; } diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0a..8f9db9248909 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -18,6 +18,7 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "./Adapters/OpenCodeAdapterV2.ts"; +import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; export type BuiltInProviderAdapterDriversV2Env = @@ -26,7 +27,8 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv - | OpenCodeAdapterV2DriverEnv; + | OpenCodeAdapterV2DriverEnv + | PiAdapterV2DriverEnv; export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< AnyProviderAdapterDriver @@ -36,6 +38,7 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< CursorAdapterV2Driver, OpenCodeAdapterV2Driver, GrokAdapterV2Driver, + PiAdapterV2Driver, AcpRegistryAdapterV2Driver, ]; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index b6e24a736efe..2b19e4ce7786 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -96,6 +96,7 @@ const providerSessionManagerProvided = providerSessionManagerLayer.pipe( providerAdapterRegistryProvided, eventSinkProvided, idAllocatorLayer, + providerEventIngestorProvided, projectionStoreLayer, ), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 42cbef3ff8cf..e6ca06b86ab3 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -298,6 +298,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( eventSinkProvided, idAllocatorLayer, mcpSessionRegistryTestLayer, + providerEventIngestorProvided, storesLayer, ), ), diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts new file mode 100644 index 000000000000..07e84af97f9d --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -0,0 +1,182 @@ +/** + * PiDriver — v1 `ProviderDriver` for the Pi coding agent, composing the + * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer + * (`PiProvider`), and Pi-backed text generation. + * + * Pi state (sessions, settings, extensions, auth) lives in the user's own + * `~/.pi/agent`, so continuation identity uses the default instance grouping. + */ +import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; +import { + PiAdapterV2Driver, + type PiAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/PiAdapterV2.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialPiProviderSnapshot, + checkPiProviderStatus, + enrichPiSnapshot, +} from "../Layers/PiProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodePiSettings = Schema.decodeSync(PiSettings); + +const DRIVER_KIND = ProviderDriverKind.make("pi"); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@earendil-works/pi-coding-agent", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type PiDriverEnv = + | PiAdapterV2DriverEnv + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const PiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Pi", + supportsMultipleInstances: true, + }, + configSchema: PiSettings, + defaultConfig: (): PiSettings => decodePiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const { cwd } = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const orchestrationAdapter = yield* PiAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichPiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/PiProvider.test.ts b/apps/server/src/provider/Layers/PiProvider.test.ts new file mode 100644 index 000000000000..4f7a6f6adf1f --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.test.ts @@ -0,0 +1,80 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; + +const encoder = new TextEncoder(); + +function processHandle(input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; +}) { + const bytes = (value: string | undefined) => + value === undefined || value.length === 0 + ? Stream.empty + : Stream.succeed(encoder.encode(value)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(900_000_001), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: bytes(input.stdout), + stderr: bytes(input.stderr), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function piProbeSpawner(version: string) { + return ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + return Effect.succeed( + args.includes("--version") + ? processHandle({ stdout: `pi ${version}\n` }) + : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), + ); + }); +} + +const settings = { + enabled: true, + binaryPath: "pi", + launchArgs: "", + customModels: [], +} as const; + +describe("PiProvider", () => { + it.effect("requires the first published Pi version with entries and settlement hooks", () => + Effect.gen(function* () { + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), + ); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.version, "0.80.3"); + assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps compatible Pi selectable when optional discovery fails", () => + Effect.gen(function* () { + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), + ); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "unknown"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default"], + ); + assert.include(snapshot.message ?? "", "could not refresh its models and commands"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts new file mode 100644 index 000000000000..e95b4464f5e2 --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -0,0 +1,428 @@ +/** + * PiProvider — snapshot/probe layer for the Pi coding agent. + * + * Health is probed with `pi --version`. Models, the user's default model, and + * the user's commands (extension slash commands, prompt templates, skills) + * are discovered through a short-lived ephemeral RPC session + * (`pi --mode rpc --no-session`), so everything the user configured in + * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — + * shows up in T3 without any hardcoded catalog. + */ +import { type PiSettings, type ServerProvider, type ServerProviderModel } from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildPiRpcLaunch, + resolvePiLaunchArgs, +} from "../../orchestration-v2/Adapters/piT3McpInjection.ts"; +import { + makePiRpcConnection, + piRecordField as recordField, + piRecordString as recordString, +} from "../../orchestration-v2/Adapters/PiRpc.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./piThinkingCapabilities.ts"; +import { + parsePiDiscoveredCommands, + withPiBuiltinSlashCommands, + type PiDiscoveredCommands, +} from "../PiCommands.ts"; + +const PI_PRESENTATION = { + displayName: "Pi", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + requiresNewThreadForModelChange: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; +/** + * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. + * Version 0.80.5 was the first published package containing both hooks. T3 + * needs them for rollback boundaries and reliable turn terminalization. + */ +export const MINIMUM_PI_VERSION = "0.80.5"; + +/** Deferring to the user's own settings.json default model. */ +const PI_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Pi default", + isCustom: false, + capabilities: EMPTY_PI_MODEL_CAPABILITIES, +}; + +interface PiDiscovery extends PiDiscoveredCommands { + readonly models: ReadonlyArray; + readonly authenticated: boolean; +} + +function piModelsFromSettings( + customModels: ReadonlyArray | undefined, + discovered: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + [PI_DEFAULT_MODEL, ...discovered], + customModels ?? [], + EMPTY_PI_MODEL_CAPABILITIES, + ); +} + +function parseDiscoveredModels( + data: unknown, + defaultThinkingLevel: unknown, +): ReadonlyArray { + const models = recordField(data, "models"); + if (!Array.isArray(models)) return []; + const seen = new Set(); + const parsed: Array = []; + for (const model of models) { + const provider = recordString(model, "provider"); + const id = recordString(model, "id"); + if (provider === undefined || id === undefined) continue; + const slug = `${provider}/${id}`; + if (seen.has(slug)) continue; + seen.add(slug); + parsed.push({ + slug, + name: recordString(model, "name") ?? slug, + isCustom: false, + capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), + }); + } + return parsed; +} + +const discoverPiViaRpc = ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + cwd?: string, +) => + Effect.gen(function* () { + const launch = buildPiRpcLaunch({ + launchArgs, + environment, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + }); + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + args: launch.args, + cwd, + env: launch.env, + }); + const stateData = yield* connection.request({ type: "get_state" }); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const commandsData = yield* connection + .request({ type: "get_commands" }) + .pipe(Effect.orElseSucceed(() => undefined)); + const discoveredModels = parseDiscoveredModels( + modelsData, + recordString(stateData, "thinkingLevel"), + ); + const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + return { + models: discoveredModels, + slashCommands: withPiBuiltinSlashCommands(slashCommands), + skills, + authenticated: discoveredModels.length > 0, + } satisfies PiDiscovery; + }).pipe(Effect.scoped); + +const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = piSettings.binaryPath || "pi"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export function buildInitialPiProviderSnapshot( + piSettings: PiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = piModelsFromSettings(piSettings.customModels); + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi CLI availability...", + }, + }); + }); +} + +export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = piModelsFromSettings(piSettings.customModels); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." + : "Failed to execute Pi CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but timed out while running `pi --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but failed to run.", + }, + }); + } + + if (version === null) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, + }, + }); + } + + if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, + }, + }); + } + + const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); + if (!resolvedLaunchArgs.ok) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: resolvedLaunchArgs.message, + }, + }); + } + + const discoveryExit = yield* discoverPiViaRpc( + piSettings, + environment, + resolvedLaunchArgs.args, + cwd, + ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Pi RPC discovery failed.", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: + "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: + "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", + }, + }); + } + + const discovery = discoveryExit.value.value; + const models = piModelsFromSettings(piSettings.customModels, discovery.models); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, + probe: { + installed: true, + version, + status: discovery.authenticated ? "ready" : "warning", + auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, + ...(discovery.authenticated + ? {} + : { + message: + "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", + }), + }, + }); +}); + +export const enrichPiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Pi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1bcb53b478c4..46d849b5cc5e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2039,6 +2039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "pi", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts new file mode 100644 index 000000000000..b6474bf2d9ec --- /dev/null +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; + +describe("thinkingCapabilitiesForPiModel", () => { + it("gives every choice its real level id and tags Pi's default", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, + }, + "xhigh", + ); + const descriptors = capabilities.optionDescriptors ?? []; + const thinking = descriptors[0]; + assert.equal(thinking?.id, "thinking"); + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepEqual( + thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), + [ + ["minimal", "Minimal", false], + ["low", "Low", false], + ["medium", "Medium", false], + ["high", "High", false], + ["xhigh", "Extra High", true], + ], + ); + }); + + it("clamps Pi's default to each model's supported levels", () => { + const capabilities = thinkingCapabilitiesForPiModel( + { + reasoning: true, + thinkingLevelMap: { xhigh: "extra_high", max: null }, + }, + "max", + ); + const thinking = capabilities.optionDescriptors?.[0]; + assert.equal(thinking?.type, "select"); + if (thinking?.type !== "select") return; + assert.deepInclude(thinking.options, { + id: "xhigh", + label: "Extra High", + isDefault: true, + }); + assert.notInclude( + thinking.options.map((option) => option.id), + "max", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/piThinkingCapabilities.ts b/apps/server/src/provider/Layers/piThinkingCapabilities.ts new file mode 100644 index 000000000000..a4c6071d23ee --- /dev/null +++ b/apps/server/src/provider/Layers/piThinkingCapabilities.ts @@ -0,0 +1,107 @@ +import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Predicate from "effect/Predicate"; + +/** + * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per + * model via `thinkingLevelMap`; advertising them globally makes + * `set_thinking_level` fail on models that lack them. + */ +export const PI_THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; + +const PI_THINKING_LEVEL_LABELS: Record = { + off: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export function thinkingCapabilitiesForPiModel( + model: unknown, + defaultThinkingLevel: unknown, +): ModelCapabilities { + const levels = supportedPiThinkingLevelsFromModel(model); + if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; + const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); + return createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + options: levels.map( + (level): ProviderOptionChoice => ({ + id: level, + label: PI_THINKING_LEVEL_LABELS[level], + ...(level === defaultLevel ? { isDefault: true } : {}), + }), + ), + }, + ], + }); +} + +/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ +function clampPiThinkingLevel( + input: unknown, + availableLevels: ReadonlyArray, +): PiThinkingLevel | undefined { + if (typeof input !== "string") return undefined; + const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); + if (requestedIndex === -1) return undefined; + const exact = availableLevels.find((level) => level === input); + if (exact !== undefined) return exact; + for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { + const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (higher !== undefined) return higher; + } + for (let index = requestedIndex - 1; index >= 0; index -= 1) { + const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); + if (lower !== undefined) return lower; + } + return availableLevels[0]; +} + +/** + * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. + * + * A reasoning model always exposes off through high unless a map entry is + * `null`. Extra High and Max appear only when the map has a non-null entry. + */ +function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { + if (recordField(model, "reasoning") !== true) return []; + const thinkingLevelMap = thinkingLevelMapFromModel(model); + return PI_THINKING_LEVELS.filter((level) => { + const mapped = thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }); +} + +function thinkingLevelMapFromModel(model: unknown): Record | undefined { + const value = recordField(model, "thinkingLevelMap"); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function recordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} diff --git a/apps/server/src/provider/PiCommands.test.ts b/apps/server/src/provider/PiCommands.test.ts new file mode 100644 index 000000000000..218a1ff338bb --- /dev/null +++ b/apps/server/src/provider/PiCommands.test.ts @@ -0,0 +1,121 @@ +import { expect, it } from "@effect/vitest"; + +import { + expandPiSkillReference, + parsePiCompactCommand, + parsePiDiscoveredCommands, + PI_COMPACT_SLASH_COMMAND, + withPiBuiltinSlashCommands, +} from "./PiCommands.ts"; + +it("maps current Pi skill metadata to T3's user and project skill scopes", () => { + expect( + parsePiDiscoveredCommands({ + commands: [ + { + name: "skill:global-review", + description: "Review changes.", + source: "skill", + sourceInfo: { + path: "/home/test/.agents/skills/global-review/SKILL.md", + scope: "user", + }, + }, + { + name: "skill:project-deploy", + description: "Deploy this project.", + source: "skill", + sourceInfo: { + path: "/workspace/.agents/skills/project-deploy/SKILL.md", + scope: "project", + }, + }, + { name: "hello", description: "Say hello.", source: "extension" }, + ], + }), + ).toEqual({ + skills: [ + { + name: "global-review", + description: "Review changes.", + path: "/home/test/.agents/skills/global-review/SKILL.md", + scope: "user", + enabled: true, + }, + { + name: "project-deploy", + description: "Deploy this project.", + path: "/workspace/.agents/skills/project-deploy/SKILL.md", + scope: "project", + enabled: true, + }, + ], + slashCommands: [{ name: "hello", description: "Say hello." }], + }); +}); + +it("maps Pi global location and interface labels onto T3 skill fields", () => { + expect( + parsePiDiscoveredCommands({ + commands: [ + { + name: "skill:global-review", + description: "Review changes.", + source: "skill", + location: "global", + interface: { + displayName: "Global Review", + shortDescription: "Review diffs.", + }, + }, + ], + }), + ).toEqual({ + skills: [ + { + name: "global-review", + description: "Review changes.", + path: "pi:skill:global-review", + scope: "user", + enabled: true, + displayName: "Global Review", + shortDescription: "Review diffs.", + }, + ], + slashCommands: [], + }); +}); + +it("parses a standalone /compact command and optional instructions", () => { + expect(parsePiCompactCommand("/compact")).toEqual({}); + expect(parsePiCompactCommand(" /compact ")).toEqual({}); + expect(parsePiCompactCommand("/compact keep the auth rewrite")).toEqual({ + customInstructions: "keep the auth rewrite", + }); + expect(parsePiCompactCommand("/compacted")).toBeNull(); + expect(parsePiCompactCommand("/compact-now")).toBeNull(); + expect(parsePiCompactCommand("please /compact")).toBeNull(); +}); + +it("prepends the builtin compact command without duplicating a discovered one", () => { + expect(withPiBuiltinSlashCommands([{ name: "hello", description: "Say hello." }])).toEqual([ + PI_COMPACT_SLASH_COMMAND, + { name: "hello", description: "Say hello." }, + ]); + expect( + withPiBuiltinSlashCommands([ + { name: "compact", description: "Extension compact." }, + { name: "hello" }, + ]), + ).toEqual([PI_COMPACT_SLASH_COMMAND, { name: "hello" }]); +}); + +it("leaves unrelated dollar-prefixed text unchanged", () => { + expect(expandPiSkillReference("Explain $HOME", new Set(["global-review"]))).toBe("Explain $HOME"); +}); + +it("hoists every known $ skill and keeps the rest of the prompt", () => { + expect(expandPiSkillReference("use $alpha then $beta please", new Set(["alpha", "beta"]))).toBe( + "/skill:alpha /skill:beta use then please", + ); +}); diff --git a/apps/server/src/provider/PiCommands.ts b/apps/server/src/provider/PiCommands.ts new file mode 100644 index 000000000000..6465f2ebd265 --- /dev/null +++ b/apps/server/src/provider/PiCommands.ts @@ -0,0 +1,136 @@ +import { type ServerProviderSkill, type ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +// Pi RPC get_commands omits TUI builtins. Advertise /compact so T3 can map it to RPC compact. +export const PI_COMPACT_SLASH_COMMAND: ServerProviderSlashCommand = { + name: "compact", + description: "Summarize the conversation and reduce context usage", + input: { hint: "Optional instructions" }, +}; + +export interface PiCompactCommand { + readonly customInstructions?: string; +} + +export function parsePiCompactCommand(text: string): PiCompactCommand | null { + const trimmed = text.trim(); + if (trimmed === "/compact") return {}; + if (!trimmed.startsWith("/compact")) return null; + const rest = trimmed.slice("/compact".length); + if (rest.length === 0) return {}; + if (!/^\s/.test(rest)) return null; + const customInstructions = rest.trim(); + return customInstructions.length === 0 ? {} : { customInstructions }; +} + +export function withPiBuiltinSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + return [PI_COMPACT_SLASH_COMMAND, ...commands.filter((command) => command.name !== "compact")]; +} + +export interface PiDiscoveredCommands { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} + +function normalizePiSkillScope(scope: string | undefined): string | undefined { + if (scope === undefined) return undefined; + const normalized = scope.trim().toLowerCase(); + if (normalized === "global" || normalized === "personal") return "user"; + if (normalized === "workspace" || normalized === "local") return "project"; + return scope; +} + +/** Maps Pi's `get_commands` payload to T3's shared command and skill surfaces. */ +export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { + const commands = recordField(data, "commands"); + if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; + const slashCommands: Array = []; + const skills: Array = []; + for (const command of commands) { + const commandName = recordString(command, "name"); + if (commandName === undefined || commandName.length === 0) continue; + const description = recordString(command, "description"); + if (recordString(command, "source") === "skill") { + const name = commandName.startsWith("skill:") + ? commandName.slice("skill:".length) + : commandName; + if (name.length === 0) continue; + const sourceInfo = recordField(command, "sourceInfo"); + const commandInterface = recordField(command, "interface"); + const path = + recordString(sourceInfo, "path") ?? recordString(command, "path") ?? `pi:skill:${name}`; + const scope = normalizePiSkillScope( + recordString(sourceInfo, "scope") ?? recordString(command, "location"), + ); + const displayName = + recordString(command, "displayName") ?? + recordString(sourceInfo, "displayName") ?? + recordString(commandInterface, "displayName"); + const shortDescription = + recordString(command, "shortDescription") ?? + recordString(sourceInfo, "shortDescription") ?? + recordString(commandInterface, "shortDescription"); + skills.push({ + name, + path, + enabled: true, + ...(description === undefined ? {} : { description }), + ...(scope === undefined ? {} : { scope }), + ...(displayName === undefined ? {} : { displayName }), + ...(shortDescription === undefined ? {} : { shortDescription }), + }); + continue; + } + slashCommands.push({ + name: commandName, + ...(description === undefined ? {} : { description }), + }); + } + return { slashCommands, skills }; +} + +/** + * Pi expands skills only through leading `/skill:name` commands. T3 stores + * skill chips as `$name`, so hoist every known `$skill` to that native + * command position while preserving the rest of the user's prompt. + */ +export function expandPiSkillReference(text: string, skillNames: ReadonlySet): string { + const references = /(^|\s)\$([^\s]+)(?=\s|$)/g; + const found: Array<{ name: string; start: number; end: number }> = []; + for (const match of text.matchAll(references)) { + const name = match[2]; + if (name === undefined || !skillNames.has(name) || match.index === undefined) continue; + const tokenStart = match.index + (match[1]?.length ?? 0); + found.push({ name, start: tokenStart, end: tokenStart + name.length + 1 }); + } + if (found.length === 0) return text; + + const orderedNames: string[] = []; + const seen = new Set(); + for (const token of found) { + if (seen.has(token.name)) continue; + seen.add(token.name); + orderedNames.push(token.name); + } + + let body = text; + for (let index = found.length - 1; index >= 0; index -= 1) { + const token = found[index]; + if (token === undefined) continue; + body = `${body.slice(0, token.start)}${body.slice(token.end)}`; + } + body = body.replace(/\s+/g, " ").trim(); + const prefix = orderedNames.map((name) => `/skill:${name}`).join(" "); + return body.length === 0 ? prefix : `${prefix} ${body}`; +} + +function recordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +function recordString(input: unknown, key: string): string | undefined { + const value = recordField(input, key); + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2b..f4e6c4e0a7ab 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,6 +26,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -39,7 +40,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | PiDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; readonly requiresNewThreadForModelChange?: boolean; } @@ -236,6 +238,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(input.presentation.supportedRuntimeModes === undefined + ? {} + : { supportedRuntimeModes: [...input.presentation.supportedRuntimeModes] }), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts new file mode 100644 index 000000000000..d735eb5d77df --- /dev/null +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -0,0 +1,255 @@ +/** + * PiTextGeneration — commit messages, PR content, branch names, and thread + * titles generated through an ephemeral `pi --mode rpc --no-session` process. + * No session file is written; the user's Pi configuration (default model, + * auth, custom providers) still applies. + */ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { TextGenerationError, type ModelSelection, type PiSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; +import { + buildPiRpcLaunch, + resolvePiLaunchArgs, +} from "../orchestration-v2/Adapters/piT3McpInjection.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const PI_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runPiJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); + if (!resolvedLaunchArgs.ok) { + return yield* new TextGenerationError({ + operation, + detail: resolvedLaunchArgs.message, + }); + } + const launch = buildPiRpcLaunch({ + launchArgs: resolvedLaunchArgs.args, + environment, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + // No user is present to answer a text-generation extension dialog. + disableExtensions: true, + // Background naming/content helpers must never mutate the workspace. + disableTools: true, + }); + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + // Extensions and tools are disabled because no user is present to + // answer a dialog and background text generation is read-only. User + // model config and auth still apply. + args: launch.args, + cwd, + env: launch.env, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + + if (modelSelection.model !== "default") { + // `customModels` accepts arbitrary strings, so an unusable slug is + // rejected rather than skipped: running Pi's default model here would + // report success for a model the caller never asked for. + const parsed = parsePiModelSlug(modelSelection.model); + if (parsed === null) { + return yield* new TextGenerationError({ + operation, + detail: `Pi model '${modelSelection.model}' must use provider/model format.`, + }); + } + yield* connection.request({ + type: "set_model", + provider: parsed.provider, + modelId: parsed.modelId, + }); + } + + yield* connection.request({ type: "prompt", message: prompt }); + yield* Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(connection.events); + if (event["type"] === "agent_settled") return; + } + }); + const data = yield* connection.request({ type: "get_last_assistant_text" }); + const text = + typeof data === "object" && + data !== null && + typeof (data as { text?: unknown }).text === "string" + ? (data as { text: string }).text.trim() + : ""; + if (!text) { + return yield* new TextGenerationError({ + operation, + detail: "Pi returned empty output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(text)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.timeoutOption(PI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Pi text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("PiTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runPiJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("PiTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runPiJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("PiTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runPiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("PiTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runPiJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..24af04dc69b4 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "pi" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e6ec29804bd0..ca917687f934 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6928,10 +6928,16 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus(); return; } - const nextModelSelection: ModelSelection = { - instanceId, - model: resolvedModel, - }; + // Restore this model's own remembered options; without any, start it + // from its default rather than carrying the previous model's over. + const rememberedOptions = + useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[ + resolvedModel + ]; + const nextModelSelection: ModelSelection = + rememberedOptions !== undefined && rememberedOptions.length > 0 + ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } + : { instanceId, model: resolvedModel }; const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeRuntime !== null, @@ -6952,7 +6958,9 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, - { explicit: true }, + // A complete snapshot: an absent options field means "start from the + // model default", not "keep the previous model's options". + { explicit: true, replaceOptions: true }, ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..e921885fd80c 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -689,13 +689,16 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); export const PiAgentIcon: Icon = ({ className, ...props }) => ( - - + - + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 985c2de40dea..cadefc3c9263 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -342,33 +342,41 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; -const runtimeModeConfig: Record< - RuntimeMode, - { label: string; description: string; icon: LucideIcon } -> = { - "approval-required": { - label: "Supervised", - description: "Ask before commands and file changes.", - icon: LockIcon, - }, - "auto-accept-edits": { +type RuntimeModeOption = { + readonly mode: RuntimeMode; + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; +}; + +const supervisedRuntimeModeOption = { + mode: "approval-required", + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, +} satisfies RuntimeModeOption; + +const runtimeModeOptions = [ + supervisedRuntimeModeOption, + { + mode: "auto-accept-edits", label: "Auto-accept edits", description: "Auto-approve edits, ask before other actions.", icon: PenLineIcon, }, - auto: { + { + mode: "auto", label: "Auto", description: "An AI reviewer approves routine actions; risky ones still ask.", icon: SparklesIcon, }, - "full-access": { + { + mode: "full-access", label: "Full access", description: "Allow commands and edits without prompts.", icon: LockOpenIcon, }, -}; - -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +] satisfies ReadonlyArray; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', '[data-slot="popover-popup"]', @@ -414,11 +422,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; showPlanToggle: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { - const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; + const runtimeModeOption = + props.runtimeModeOptions.find((option) => option.mode === props.runtimeMode) ?? + supervisedRuntimeModeOption; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = props.interactionMode === "plan" @@ -473,11 +484,15 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {runtimeModeOption.label} - {runtimeModeOptions.map((mode) => { - const option = runtimeModeConfig[mode]; + {props.runtimeModeOptions.map((option) => { const OptionIcon = option.icon; return ( - +
@@ -1135,6 +1150,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // disabled. const selectedProvider: ProviderDriverKind = selectedProviderEntry?.driverKind ?? requestedDriverKind; + const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes; + const compatibleRuntimeModeOptions = + supportedRuntimeModes && supportedRuntimeModes.length > 0 + ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode)) + : runtimeModeOptions; + // Older threads can contain a mode their current provider no longer offers. + // Display the provider's first supported mode, which is also its safe legacy + // fallback, without mutating persisted state until the user makes a choice. + const compatibleRuntimeMode = compatibleRuntimeModeOptions.some( + (option) => option.mode === runtimeMode, + ) + ? runtimeMode + : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -1357,20 +1385,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(planModeUiEnabled + ? [ + { + id: "slash:plan", + type: "slash-command" as const, + command: "plan" as const, + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command" as const, + command: "default" as const, + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] + : []), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( selectedProviderStatus?.skills ?? [], @@ -2344,6 +2376,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { + if (!planModeUiEnabled) return false; toggleInteractionMode(); return true; } @@ -4232,7 +4265,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; @@ -64,10 +68,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls props.onRuntimeModeChange(value as RuntimeMode); }} > - Supervised - Auto-accept edits - Auto - Full access + {props.runtimeModeOptions.map((option) => ( + + {option.label} + + ))} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f0c5b9b466d6..1deaf681f029 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon, PiAgentIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("pi")]: PiAgentIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 260d895ff2bf..25fda96b347a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,7 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, GithubCopilotIcon, type Icon } from "../Icons"; import { Dialog, DialogDescription, @@ -88,11 +88,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - }, ]; /** diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index a4e8b7e7a9a7..5a46d546dd28 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial { }); }); +describe("composerDraftStore per-model sticky options", () => { + const threadId = ThreadId.make("thread-per-model-options"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("remembers sticky options per model and restores them on model switch", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "xhigh" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true }, + ); + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4", persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_INSTANCE]).toEqual( + { + "gpt-5.3-codex": toSelections({ reasoningEffort: "xhigh" }), + "gpt-5.4": toSelections({ reasoningEffort: "high" }), + }, + ); + }); + + it("drops the remembered options for a model when its sticky options are cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "xhigh" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true }, + ); + store.setProviderModelOptions(threadRef, CODEX_DRIVER, null, { + instanceId: CODEX_INSTANCE, + persistSticky: true, + }); + + const remembered = useComposerDraftStore.getState().stickyOptionsByModelByProvider; + expect(remembered[CODEX_INSTANCE]?.["gpt-5.3-codex"]).toBeUndefined(); + }); + + it("keeps other models' remembered options when one model is cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "xhigh" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true }, + ); + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4", persistSticky: true }, + ); + store.setProviderModelOptions(threadRef, CODEX_DRIVER, null, { + instanceId: CODEX_INSTANCE, + persistSticky: true, + }); + + expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_INSTANCE]).toEqual( + { + "gpt-5.4": toSelections({ reasoningEffort: "high" }), + }, + ); + }); + + it("does not record options when sticky persistence is omitted", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + ); + + expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider).toEqual({}); + }); + + it("seeds per-model memory from a persisted sticky selection on upgrade", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => Pick< + ReturnType, + "stickyModelSelectionByProvider" | "stickyOptionsByModelByProvider" + >; + }; + }; + const mergedState = persistApi.getOptions().merge( + { + stickyModelSelectionByProvider: { + [CODEX_INSTANCE]: { + instanceId: CODEX_INSTANCE, + model: "gpt-5.3-codex", + options: [{ id: "reasoningEffort", value: "low" }], + }, + }, + }, + useComposerDraftStore.getInitialState(), + ); + + expect(mergedState.stickyOptionsByModelByProvider).toEqual({ + [CODEX_INSTANCE]: { "gpt-5.3-codex": [{ id: "reasoningEffort", value: "low" }] }, + }); + }); +}); + describe("composerDraftStore setModelSelection", () => { const threadId = ThreadId.make("thread-model"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 886c89071d10..ec5842a2cde0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -10,6 +10,7 @@ import { ProviderDriverKind, ProviderOptionSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ProviderOptionSelections, PreviewAnnotationPayloadSchema, type PreviewAnnotationPayload, RuntimeMode, @@ -290,6 +291,9 @@ const PersistedComposerDraftStoreState = Schema.Struct({ stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderInstanceId, ModelSelection), ), + stickyOptionsByModelByProvider: Schema.optionalKey( + Schema.Record(ProviderInstanceId, Schema.Record(Schema.String, ProviderOptionSelections)), + ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -419,6 +423,14 @@ interface ComposerDraftStoreState { logicalProjectDraftThreadKeyByLogicalProjectKey: Record; backgroundSubmissionThreadKeys: Record; stickyModelSelectionByProvider: Partial>; + /** + * Option selections remembered per provider instance and model slug. + * Switching models restores the target model's own last choices instead of + * carrying the previous model's options over. + */ + stickyOptionsByModelByProvider: Partial< + Record>>> + >; stickyActiveProvider: ProviderInstanceId | null; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; @@ -670,11 +682,48 @@ function compactModelSelectionByProvider( return Object.fromEntries(entries) as DeepMutable>; } +function compactStickyOptionsByModel( + optionsByModelByProvider: ComposerDraftStoreState["stickyOptionsByModelByProvider"], +): NonNullable { + const result: Record>> = {}; + for (const [instanceId, optionsByModel] of Object.entries(optionsByModelByProvider)) { + for (const [modelSlug, options] of Object.entries(optionsByModel ?? {})) { + if (options === undefined || options.length === 0) continue; + result[instanceId] ??= {}; + result[instanceId][modelSlug] = options; + } + } + return result as NonNullable; +} + +/** + * Storage written before per-model memory existed carries the last sticky + * selection only. Seed the map from it so an upgrade keeps that model's + * remembered options instead of clearing them on the first switch. + */ +function seedStickyOptionsByModel( + stickySelections: Partial>, +): NonNullable { + const result: Record>> = {}; + for (const [instanceId, selection] of Object.entries(stickySelections)) { + if ( + selection === undefined || + selection.options === undefined || + selection.options.length === 0 + ) { + continue; + } + result[instanceId] = { [selection.model]: selection.options }; + } + return result as NonNullable; +} + const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, + stickyOptionsByModelByProvider: {}, stickyActiveProvider: null, }); @@ -2126,6 +2175,9 @@ function partializeComposerDraftStoreState( stickyModelSelectionByProvider: compactModelSelectionByProvider( state.stickyModelSelectionByProvider, ), + stickyOptionsByModelByProvider: compactStickyOptionsByModel( + state.stickyOptionsByModelByProvider, + ), stickyActiveProvider: state.stickyActiveProvider, }; } @@ -2196,6 +2248,10 @@ function normalizeCurrentPersistedComposerDraftStoreState( draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), + stickyOptionsByModelByProvider: + normalizedPersistedState.stickyOptionsByModelByProvider === undefined + ? seedStickyOptionsByModel(stickyModelSelectionByProvider) + : compactStickyOptionsByModel(normalizedPersistedState.stickyOptionsByModelByProvider), stickyActiveProvider, }; } @@ -2413,6 +2469,7 @@ const composerDraftStore = create()( logicalProjectDraftThreadKeyByLogicalProjectKey: {}, backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, + stickyOptionsByModelByProvider: {}, stickyActiveProvider: null, getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { @@ -2992,6 +3049,7 @@ const composerDraftStore = create()( // Handle sticky persistence let nextStickyMap = state.stickyModelSelectionByProvider; + let nextOptionsByModel = state.stickyOptionsByModelByProvider; let nextStickyActiveProvider = state.stickyActiveProvider; if (options?.persistSticky === true) { nextStickyMap = { ...state.stickyModelSelectionByProvider }; @@ -2999,15 +3057,37 @@ const composerDraftStore = create()( nextStickyMap[instanceKey] ?? base.modelSelectionByProvider[instanceKey] ?? createModelSelection(instanceKey, fallbackModel); + // Memory keys follow the model the caller is configuring + // (TraitsPicker passes it explicitly); the sticky selection + // itself keeps preserving its current model on trait changes. + const rememberedModel = + normalizeModelSlug(options?.model, normalizedProvider) ?? stickyBase.model; if (providerOpts) { nextStickyMap[instanceKey] = createModelSelection( instanceKey, stickyBase.model, providerOpts, ); + // Remember the pick for this model so switching models and + // coming back restores it instead of another model's effort. + nextOptionsByModel = { + ...state.stickyOptionsByModelByProvider, + [instanceKey]: { + ...state.stickyOptionsByModelByProvider[instanceKey], + [rememberedModel]: providerOpts, + }, + }; } else if ((stickyBase.options?.length ?? 0) > 0) { const { options: _, ...rest } = stickyBase; nextStickyMap[instanceKey] = rest as ModelSelection; + const rememberedByModel = { + ...state.stickyOptionsByModelByProvider[instanceKey], + }; + delete rememberedByModel[rememberedModel]; + nextOptionsByModel = { + ...state.stickyOptionsByModelByProvider, + [instanceKey]: rememberedByModel, + }; } nextStickyActiveProvider = options.instanceId ? instanceKey @@ -3017,6 +3097,7 @@ const composerDraftStore = create()( if ( Equal.equals(base.modelSelectionByProvider, nextMap) && Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && + Equal.equals(state.stickyOptionsByModelByProvider, nextOptionsByModel) && state.stickyActiveProvider === nextStickyActiveProvider ) { return state; @@ -3043,6 +3124,7 @@ const composerDraftStore = create()( ...(options?.persistSticky === true ? { stickyModelSelectionByProvider: nextStickyMap, + stickyOptionsByModelByProvider: nextOptionsByModel, stickyActiveProvider: nextStickyActiveProvider, } : {}), @@ -3945,6 +4027,7 @@ const composerDraftStore = create()( logicalProjectDraftThreadKeyByLogicalProjectKey: normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, + stickyOptionsByModelByProvider: normalizedPersisted.stickyOptionsByModelByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index fa90ad8865bf..a324de720251 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -35,6 +35,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "pi": + return "Pi"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index ede265247269..662bcb7a6c3d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -44,6 +44,12 @@ export const PROVIDER_OPTIONS: Array<{ pickerSidebarBadge: "new", }, { value: ProviderDriverKind.make("grok"), label: "Grok", available: true }, + { + value: ProviderDriverKind.make("pi"), + label: "Pi", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index 976b69bca9e0..c786ba48037f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Pi](./user/providers-pi.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 75163f0cc0f4..9ea69e1ab904 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with these entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -16,6 +16,16 @@ orchestration layer does not know which one is behind a thread. | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `pi` | [`Drivers/PiDriver.ts`][pi] | + +The Pi driver speaks Pi's stdio JSONL RPC mode (`pi --mode rpc`) and spawns the user's own `pi` +install, preserving its skills, context files, custom models, auth, extensions, and sessions. Pi +owns native extension discovery. T3 explicitly injects only its namespaced MCP bridge. The shared +`delegate_task` tool owns durable child threads, while results from Pi's installed example +`subagent` extension are projected without synthetic child sessions. Extension UI dialogs surface +as orchestration runtime requests, and optional Pi session statistics ride on the settled provider +turn as `tokenUsage`, which feeds the shared context window meter. Pi 0.80.5 is the minimum +supported protocol version. Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -166,6 +176,7 @@ when a request opens (approval) or user input is requested, via [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts +[pi]: ../../apps/server/src/provider/Drivers/PiDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401a..40228e182d98 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -130,10 +130,37 @@ forking uses portable context when native `session/fork` is unavailable, and subagents use orchestrator-owned child threads. Registry agents do not receive provider-specific extensions; those remain in flavors such as Grok. +### Pi V2 + +Pi core has no MCP client. When a provider session credential exists, the +adapter writes a T3-owned extension into the server cache and spawns +`pi --mode rpc --extension /pi-t3-mcp-extension.ts` with: + +```text +T3_MCP_URL=http://127.0.0.1:/mcp +T3_MCP_BEARER_TOKEN= +``` + +The extension connects to that HTTP endpoint, lists tools, and registers each +one with `pi.registerTool` under a `mcp__t3-code__` namespace +(`mcp__t3-code__delegate_task`, `mcp__t3-code__t3_thread_start`, and the rest). +The bridge calls the original MCP tool name over HTTP. Follow-up requests send +`mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400 +without it. The first turn of a session also receives the shared T3 +orchestration instructions. + +Pi keeps ownership of native extension discovery. T3 does not replace Pi's +`subagent` tool or reproduce Pi's package and project-trust loader. Durable +delegation goes through the namespaced T3 MCP `delegate_task` tool and the +shared orchestration child-thread lifecycle. When Pi's example `subagent` +extension is installed, the adapter observes its documented `details.results` +shape and projects task cards with no child thread id. Unknown result shapes +remain ordinary dynamic tool output. + ### Initial Provider Support -The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, and -Grok plus generic registry agents over ACP. +The V2 provider adapters are Codex, Claude Agent SDK, Cursor Agent SDK, Grok +plus generic registry agents over ACP, OpenCode, OpenCode 2, and Pi. Capability discovery still reports other registered provider instances, but marks them unavailable for orchestration when no V2 adapter exists. This keeps provider selection model-visible without allowing a request that cannot run. diff --git a/docs/user/install.md b/docs/user/install.md index d6d194ee310f..776440aa9efe 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -99,7 +99,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For provider-specific setup, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and +[Pi](./providers-pi.md). ## Next Steps diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md new file mode 100644 index 000000000000..b4435ba9ed3c --- /dev/null +++ b/docs/user/providers-pi.md @@ -0,0 +1,63 @@ +# Pi + +T3 Code can use your existing Pi coding agent installation while keeping Pi's models, auth, +extensions, skills, context files, and native session history. + +## Set Up Pi + +1. Install Pi 0.80.5 or newer on the machine running the T3 Code server. +2. Run Pi once in a terminal and finish the provider login or API-key setup you normally use. +3. Open T3 Code Settings, enable Pi, and refresh the provider. + +If `pi` is not on the server's `PATH`, set Pi's binary path to the executable. Provider environment +variables and launch arguments are also available for installations that need a custom agent +directory, endpoint, or model configuration. T3 Code rejects launch arguments that change Pi's +execution mode or select a session because T3 owns those parts of the process lifecycle. + +## What Carries Over + +T3 Code discovers the models reported by Pi and exposes their supported thinking levels. The +thinking picker marks Pi's current configured level as the default without overriding it. Threads +use Pi's native session files for resume and rollback. Thread forks use T3 Code's portable +conversation context to start a fresh Pi session instead of cloning Pi's active session. Extension +dialogs appear in the T3 Code composer, and the composer context meter updates from Pi's own +context-window statistics after a response settles. + +Pi skills appear in the composer's `$` menu. This includes user skills and project skills that Pi +loads for the current workspace; selecting one uses Pi's native skill expansion. + +Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` +dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as +titles, status lines, and widgets does not have a T3 Code equivalent. + +## Permission Modes + +T3 Code applies the composer permission mode through Pi's blocking tool hook: + +- **Supervised** asks before commands, file changes, and extension tools. Read-only tools continue. +- **Auto-accept edits** allows Pi's edit and write tools, but asks before commands and extension + tools. +- **Full access** allows tools without T3 Code approval prompts. + +The **Auto** option is not shown for Pi because Pi does not expose an AI approval reviewer. +Threads that already stored Auto before Pi support was added behave and display as Supervised. + +Changing the mode restarts the Pi provider session and resumes the same native conversation. The +policy covers Pi tool calls; it is not an operating-system sandbox, and code that a trusted Pi +extension runs outside a tool call remains governed by Pi's own extension trust model. + +T3 Code's `delegate_task` tool creates durable child threads in the shared subagent UI. If the user +installs Pi's example `subagent` extension, T3 Code also shows its task progress and results in that +UI. Pi runs those children without a session, so they cannot be opened or resumed as T3 Code +threads. + +## Troubleshooting + +- If Pi is unavailable, confirm that the configured binary runs on the server machine, then refresh + the provider in Settings. +- If no models appear, open Pi directly and confirm its authentication and model configuration. +- If discovery cannot complete, T3 Code keeps Pi available with the `Pi default` model. Start a + thread to let the interactive Pi session handle any startup prompt. +- If a project extension is missing, approve the project in Pi, then start a fresh provider session. +- If a project skill is missing from the `$` menu, approve the project in Pi and refresh the provider. +- The context meter appears after Pi returns its first usable token snapshot for the thread. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1c8c16a0638b..635e75b0d5a8 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -154,6 +155,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", + [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..6f326d8abc0e 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -38,11 +38,13 @@ describe("ServerProvider", () => { status: "authenticated", }, checkedAt: "2026-04-10T00:00:00.000Z", + supportedRuntimeModes: ["approval-required", "future-mode", "full-access"], models: [], }); expect(parsed.slashCommands).toEqual([]); expect(parsed.skills).toEqual([]); + expect(parsed.supportedRuntimeModes).toEqual(["approval-required", "full-access"]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5f2360761d56..cead19b891fa 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -19,6 +19,7 @@ import { } from "./keybindings.ts"; import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; +import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -170,6 +171,7 @@ export const ServerProvider = Schema.Struct({ badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), + supportedRuntimeModes: Schema.optional(ForwardCompatibleArray(RuntimeMode)), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fef3d9036585..034c87b0b14b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -505,6 +505,39 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const PiSettings = makeProviderSettingsSchema( + { + // Disabled by default while Pi support is Early Access. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("pi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Pi coding agent binary.", + providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to pi --mode rpc on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type PiSettings = typeof PiSettings.Type; + export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; @@ -759,6 +792,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -903,6 +937,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const PiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -951,6 +992,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + pi: Schema.optionalKey(PiSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),