| author | |
| committer | |
| log | 9b0df1358f9f9c3277bcb33777c2435b88ee75c4 |
| tree | aa7e4271478b09d7233e877cab9b191ad04a06e4 |
| parent | 2c1160783ea4b4dcc1c1f1ab04639ffc0aaa289d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>108 files changed, 11705 insertions(+), 11510 deletions(-)
apps/mobile/package.json+2| ... | ... | @@ -69,6 +69,8 @@ |
| 69 | 69 | "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", |
| 70 | 70 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 71 | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | "@t3tools/provider-core": "workspace:*", | |
| 73 | "@t3tools/provider-pi": "workspace:*", | |
| 72 | 74 | "@t3tools/shared": "workspace:*", |
| 73 | 75 | "@tabler/icons-react-native": "^3.44.0", |
| 74 | 76 | "clsx": "^2.1.1", |
apps/mobile/src/components/ProviderIcon.tsx+14-9| ... | ... | @@ -6,6 +6,7 @@ import { useState } from "react"; |
| 6 | 6 | import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; |
| 7 | 7 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 8 | 8 | import { AppText as Text } from "./AppText"; |
| 9 | import { getProviderClient } from "../lib/providerClients"; | |
| 9 | 10 | |
| 10 | 11 | type ProviderIconProps = { |
| 11 | 12 | readonly provider: string | null | undefined; |
| ... | ... | @@ -131,16 +132,20 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 131 | 132 | ); |
| 132 | 133 | } |
| 133 | 134 | |
| 134 | if (props.provider === "pi") { | |
| 135 | const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; | |
| 135 | const packageIcon = getProviderClient(props.provider)?.icon; | |
| 136 | if (packageIcon) { | |
| 137 | const themeFill = (fill: { readonly light: string; readonly dark: string }) => | |
| 138 | isDarkMode ? fill.dark : fill.light; | |
| 136 | 139 | return ( |
| 137 | <Svg width={size} height={size} viewBox="165.29 165.29 469.43 469.43" fill="none"> | |
| 138 | <Path | |
| 139 | fill={foreground} | |
| 140 | fillRule="evenodd" | |
| 141 | d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" | |
| 142 | /> | |
| 143 | <Path fill={foreground} d="M517.36 400H634.72V634.72H517.36Z" /> | |
| 140 | <Svg width={size} height={size} viewBox={packageIcon.viewBox} fill="none"> | |
| 141 | {packageIcon.paths.map((path) => ( | |
| 142 | <Path | |
| 143 | key={path.d} | |
| 144 | d={path.d} | |
| 145 | fillRule={path.fillRule} | |
| 146 | fill={themeFill(path.fill ?? packageIcon.fill)} | |
| 147 | /> | |
| 148 | ))} | |
| 144 | 149 | </Svg> |
| 145 | 150 | ); |
| 146 | 151 | } |
apps/mobile/src/lib/providerClients.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { isProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; | |
| 3 | import { piClient } from "@t3tools/provider-pi/client"; | |
| 4 | ||
| 5 | /** The provider client definitions this mobile build ships. */ | |
| 6 | const providerClients = makeProviderClientRegistry([piClient]); | |
| 7 | ||
| 8 | /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ | |
| 9 | export function getProviderClient(driver: string | null | undefined) { | |
| 10 | return isProviderDriverKind(driver) ? providerClients.get(driver) : undefined; | |
| 11 | } |
apps/server/package.json+1| ... | ... | @@ -42,6 +42,7 @@ |
| 42 | 42 | "@opencode/protocol": "2.0.23", |
| 43 | 43 | "@opencode/schema": "2.0.23", |
| 44 | 44 | "@t3tools/provider-core": "workspace:*", |
| 45 | "@t3tools/provider-pi": "workspace:*", | |
| 45 | 46 | "diff": "8.0.3", |
| 46 | 47 | "effect": "catalog:", |
| 47 | 48 | "jose": "catalog:", |
apps/server/scripts/record-grok-acp-replay-fixture.ts+1-1| ... | ... | @@ -34,7 +34,7 @@ import { |
| 34 | 34 | import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; |
| 35 | 35 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 36 | 36 | import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 37 | import * as ProviderContinuationRequests from "../src/orchestration-v2/ProviderContinuationRequests.ts"; | |
| 37 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 38 | 38 | import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts"; |
| 39 | 39 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; |
| 40 | 40 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
apps/server/scripts/record-pi-rpc-replay-fixture.ts+1-1| ... | ... | @@ -29,7 +29,7 @@ import { |
| 29 | 29 | PiOrchestratorReplayHarness, |
| 30 | 30 | } from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; |
| 31 | 31 | import * as PiAdapterV2Testkit from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; |
| 32 | import { PI_PROVIDER } from "../src/orchestration-v2/Adapters/PiAdapterV2.ts"; | |
| 32 | import { PI_PROVIDER } from "@t3tools/provider-pi/server"; | |
| 33 | 33 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 34 | 34 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; |
| 35 | 35 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+1-1| ... | ... | @@ -64,7 +64,7 @@ import { |
| 64 | 64 | type ProviderAdapterV2TurnInput, |
| 65 | 65 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 66 | 66 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 67 | import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; | |
| 67 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 68 | 68 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 69 | 69 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 70 | 70 | import { |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+1-1| ... | ... | @@ -71,7 +71,7 @@ import { |
| 71 | 71 | type ProviderAdapterV2Event, |
| 72 | 72 | type ProviderAdapterV2TurnInput, |
| 73 | 73 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 74 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 74 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 75 | 75 | import { |
| 76 | 76 | AcpProviderCapabilitiesV2, |
| 77 | 77 | acpProviderItemNativeId, |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+1-1| ... | ... | @@ -100,7 +100,7 @@ import { |
| 100 | 100 | } from "@t3tools/provider-core/server/orchestrationInstructions"; |
| 101 | 101 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; |
| 102 | 102 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 103 | import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 103 | import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 104 | 104 | import { |
| 105 | 105 | type BackgroundWork, |
| 106 | 106 | type BackgroundWorkReport, |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+1-1| ... | ... | @@ -67,7 +67,7 @@ import { |
| 67 | 67 | type ProviderAdapterV2Event, |
| 68 | 68 | type ProviderAdapterV2TurnInput, |
| 69 | 69 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 70 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 70 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 71 | 71 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 72 | 72 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; |
| 73 | 73 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+1-1| ... | ... | @@ -132,7 +132,7 @@ import { |
| 132 | 132 | type ProviderAdapterDriverCreateInput, |
| 133 | 133 | } from "@t3tools/provider-core/server/adapterDriver"; |
| 134 | 134 | import { type BackgroundWorkReport, backgroundWorkNotification } from "../Notification.ts"; |
| 135 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 135 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 136 | 136 | import { |
| 137 | 137 | makeSubagentChildThread, |
| 138 | 138 | makeSubagentConversationArtifacts, |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | |
| 3 | import { historyResponseItems } from "../ContextHandoffBudget.ts"; | |
| 3 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; | |
| 4 | 4 | import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 5 | 5 | import { |
| 6 | 6 | makeProviderTextDeltaCoalescer, |
| ... | ... | @@ -70,7 +70,7 @@ import { |
| 70 | 70 | type ProviderAdapterV2Event, |
| 71 | 71 | type ProviderAdapterV2TurnInput, |
| 72 | 72 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 73 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 73 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 74 | 74 | import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; |
| 75 | 75 | import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; |
| 76 | 76 | import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+2-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; |
| 2 | import { historyResponseItems } from "../ContextHandoffBudget.ts"; | |
| 2 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; | |
| 3 | 3 | import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; |
| 4 | 4 | import { |
| 5 | 5 | mcpToolPresentation, |
| ... | ... | @@ -125,7 +125,7 @@ import { IdAllocatorV2, type IdAllocatorV2Shape } from "@t3tools/provider-core/s |
| 125 | 125 | import { |
| 126 | 126 | type ProviderContinuationRequest, |
| 127 | 127 | ProviderContinuationRequests, |
| 128 | } from "../ProviderContinuationRequests.ts"; | |
| 128 | } from "@t3tools/provider-core/server/continuationRequests"; | |
| 129 | 129 | import { backgroundWorkNotification } from "../Notification.ts"; |
| 130 | 130 | import { |
| 131 | 131 | makeProviderFailure, |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts+1-1| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | } from "../../provider/acp/GrokAcpSupport.ts"; |
| 18 | 18 | import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts"; |
| 19 | 19 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 20 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 20 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 21 | 21 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 22 | 22 | import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; |
| 23 | 23 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+1-1| ... | ... | @@ -58,7 +58,7 @@ import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts" |
| 58 | 58 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 59 | 59 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 60 | 60 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 61 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 61 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 62 | 62 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 63 | 63 | import { |
| 64 | 64 | ProviderAdapterDriverCreateError, |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts+1-1| ... | ... | @@ -41,7 +41,7 @@ import { |
| 41 | 41 | type ProviderAdapterV2Event, |
| 42 | 42 | type ProviderAdapterV2TurnInput, |
| 43 | 43 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 44 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 44 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 45 | 45 | import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; |
| 46 | 46 | |
| 47 | 47 | const testLayer = Layer.mergeAll( |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts+1-1| ... | ... | @@ -37,7 +37,7 @@ import { |
| 37 | 37 | } from "../../provider/museSdk.ts"; |
| 38 | 38 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 39 | 39 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 40 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 40 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 41 | 41 | import { |
| 42 | 42 | makeReplayServerConfig, |
| 43 | 43 | type OrchestratorV2ProviderReplayHarness, |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts+1-1| ... | ... | @@ -96,7 +96,7 @@ import { |
| 96 | 96 | type ProviderAdapterV2TurnMessage, |
| 97 | 97 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 98 | 98 | import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; |
| 99 | import type * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 99 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 100 | 100 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 101 | 101 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; |
| 102 | 102 | import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+2-2| ... | ... | @@ -44,8 +44,8 @@ import type { |
| 44 | 44 | } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 45 | 45 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; |
| 46 | 46 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 47 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 48 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 47 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 48 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 49 | 49 | import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; |
| 50 | 50 | import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; |
| 51 | 51 | import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+1-1| ... | ... | @@ -88,7 +88,7 @@ import { causeErrorTag } from "@t3tools/shared/observability"; |
| 88 | 88 | import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; |
| 89 | 89 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 90 | 90 | import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; |
| 91 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 91 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 92 | 92 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 93 | 93 | import { |
| 94 | 94 | makeSubagentChildThread, |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts deleted-3407| ... | ... | @@ -1,3407 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { | |
| 4 | CheckpointId, | |
| 5 | EnvironmentId, | |
| 6 | NodeId, | |
| 7 | ProviderInstanceId, | |
| 8 | ProviderSessionId, | |
| 9 | ProviderThreadId, | |
| 10 | ProviderTurnId, | |
| 11 | RunAttemptId, | |
| 12 | RunId, | |
| 13 | ThreadId, | |
| 14 | type ChatAttachment, | |
| 15 | type ModelSelection, | |
| 16 | type OrchestrationV2AppThread, | |
| 17 | type OrchestrationV2ProviderThread, | |
| 18 | type OrchestrationV2ProviderTurn, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import * as Cause from "effect/Cause"; | |
| 21 | import * as DateTime from "effect/DateTime"; | |
| 22 | import * as Duration from "effect/Duration"; | |
| 23 | import * as Effect from "effect/Effect"; | |
| 24 | import * as Fiber from "effect/Fiber"; | |
| 25 | import * as FileSystem from "effect/FileSystem"; | |
| 26 | import * as Layer from "effect/Layer"; | |
| 27 | import * as PlatformError from "effect/PlatformError"; | |
| 28 | import * as Queue from "effect/Queue"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Sink from "effect/Sink"; | |
| 31 | import * as Stream from "effect/Stream"; | |
| 32 | import * as TestClock from "effect/testing/TestClock"; | |
| 33 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 34 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 35 | ||
| 36 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 37 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 38 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 39 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 40 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 41 | import { | |
| 42 | ProviderAdapterV2RuntimePolicy, | |
| 43 | type ProviderAdapterV2Event, | |
| 44 | type ProviderAdapterV2SessionRuntime, | |
| 45 | } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 46 | import { handoffBudget } from "../ContextHandoffBudget.ts"; | |
| 47 | import { | |
| 48 | makePiAdapterV2, | |
| 49 | PiAdapterV2Driver, | |
| 50 | PI_PROVIDER, | |
| 51 | type PiAdapterV2Options, | |
| 52 | } from "./PiAdapterV2.ts"; | |
| 53 | import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; | |
| 54 | ||
| 55 | const layerTest = Layer.mergeAll( | |
| 56 | NodeServices.layer, | |
| 57 | IdAllocator.layer, | |
| 58 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 59 | ); | |
| 60 | ||
| 61 | const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 62 | const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 63 | ||
| 64 | const PI_INSTANCE_ID = ProviderInstanceId.make("pi"); | |
| 65 | const THREAD_ID = ThreadId.make("thread-pi-test"); | |
| 66 | const SESSION_ID = ProviderSessionId.make("provider-session-pi-test"); | |
| 67 | const FAKE_SESSION_FILE = "/fake/.pi/agent/sessions/--workspace--/0001_abc.jsonl"; | |
| 68 | /** Deliberately outside the valid pid range so a group-kill can never land. */ | |
| 69 | const FAKE_PID = 999_999_999; | |
| 70 | ||
| 71 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 72 | runtimeMode: "full-access", | |
| 73 | interactionMode: "default", | |
| 74 | cwd: null, | |
| 75 | }); | |
| 76 | ||
| 77 | const modelSelection = (model: string): ModelSelection => ({ | |
| 78 | instanceId: PI_INSTANCE_ID, | |
| 79 | model, | |
| 80 | }); | |
| 81 | ||
| 82 | interface FakePi { | |
| 83 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 84 | readonly emit: (record: PiRpcRecord) => Effect.Effect<void>; | |
| 85 | readonly takeRequest: (type: string) => Effect.Effect<PiRpcRecord>; | |
| 86 | /** Data returned by the next `get_entries` acks, consumed in order. */ | |
| 87 | readonly queueEntries: (data: unknown) => void; | |
| 88 | /** Data returned by the next active-branch `get_messages` acks. */ | |
| 89 | readonly queueMessages: (data: unknown) => void; | |
| 90 | /** Make the next `switch_session` ack report an extension veto. */ | |
| 91 | readonly vetoNextSwitch: () => void; | |
| 92 | /** Fields overriding the recorded idle state in the next `get_state` acks, in order. */ | |
| 93 | readonly queueState: (data: Record<string, unknown>) => void; | |
| 94 | /** Hold the next `get_state` response until the test resolves it. */ | |
| 95 | readonly deferNextState: () => void; | |
| 96 | /** Resolve the held `get_state` request. */ | |
| 97 | readonly resolveDeferredState: (data: unknown) => Effect.Effect<void>; | |
| 98 | /** Reject the next `get_state` request. */ | |
| 99 | readonly failNextState: () => void; | |
| 100 | readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; | |
| 101 | /** Hold a model-select extension hook until its UI request is answered. */ | |
| 102 | readonly deferNextModelSelection: () => void; | |
| 103 | readonly queueModels: (models: ReadonlyArray<unknown>) => void; | |
| 104 | readonly vetoNextNewSession: () => void; | |
| 105 | /** Every request received by the fake process. */ | |
| 106 | readonly allRequests: () => ReadonlyArray<PiRpcRecord>; | |
| 107 | /** Data returned by the next `get_session_stats` acks, consumed in order. */ | |
| 108 | readonly queueStats: (data: unknown) => void; | |
| 109 | /** Data returned by the next `get_commands` acks, consumed in order. */ | |
| 110 | readonly queueCommands: (data: unknown) => void; | |
| 111 | /** Make the next `get_commands` ack fail. */ | |
| 112 | readonly failNextCommands: () => void; | |
| 113 | /** Close the fake process stdout stream. */ | |
| 114 | readonly closeStdout: Effect.Effect<void>; | |
| 115 | readonly lastSpawn: () => { | |
| 116 | readonly args: ReadonlyArray<string>; | |
| 117 | readonly env: NodeJS.ProcessEnv; | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Pi 1.0.0's idle `get_state` reply, taken from the `simple` replay fixture | |
| 123 | * (fixtures/simple/pi_transcript.ndjson) minus the model object. Pi omits | |
| 124 | * `model` when none is selected and `sessionName` until one is set. | |
| 125 | */ | |
| 126 | const recordedIdleState = (sessionFile: string) => ({ | |
| 127 | thinkingLevel: "high", | |
| 128 | isStreaming: false, | |
| 129 | isCompacting: false, | |
| 130 | steeringMode: "one-at-a-time", | |
| 131 | followUpMode: "one-at-a-time", | |
| 132 | sessionFile, | |
| 133 | sessionId: "00000000-0000-4000-8000-000000000002", | |
| 134 | autoCompactionEnabled: true, | |
| 135 | messageCount: 0, | |
| 136 | pendingMessageCount: 0, | |
| 137 | }); | |
| 138 | ||
| 139 | /** | |
| 140 | * In-process fake `pi --mode rpc` for races and failures a live Pi cannot | |
| 141 | * produce on demand: captures every stdin record, auto-acks requests, and lets | |
| 142 | * tests push protocol events to stdout. Behaviour a real Pi can show belongs | |
| 143 | * in a replay fixture instead (see PiAdapterV2.testkit.ts). | |
| 144 | */ | |
| 145 | const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () { | |
| 146 | const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>(); | |
| 147 | const requests = yield* Queue.unbounded<PiRpcRecord>(); | |
| 148 | const entriesQueue: Array<unknown> = []; | |
| 149 | const messagesQueue: Array<unknown> = []; | |
| 150 | const stateQueue: Array<Record<string, unknown>> = []; | |
| 151 | const statsQueue: Array<unknown> = []; | |
| 152 | const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; | |
| 153 | const allRequests: Array<PiRpcRecord> = []; | |
| 154 | let deferState = false; | |
| 155 | let deferredStateRequest: PiRpcRecord | undefined; | |
| 156 | let failState = false; | |
| 157 | let vetoSwitch = false; | |
| 158 | let vetoNewSession = false; | |
| 159 | let deferredLifecycle: string | undefined; | |
| 160 | let sessionFile = FAKE_SESSION_FILE; | |
| 161 | let sessionGeneration = 0; | |
| 162 | let models: ReadonlyArray<unknown> = []; | |
| 163 | let stdinBuffer = ""; | |
| 164 | ||
| 165 | const emit = (record: PiRpcRecord) => | |
| 166 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( | |
| 167 | Effect.asVoid, | |
| 168 | ); | |
| 169 | ||
| 170 | const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { | |
| 171 | if (typeof record["id"] !== "string") return null; | |
| 172 | const base = { | |
| 173 | type: "response", | |
| 174 | id: record["id"], | |
| 175 | command: String(record["type"]), | |
| 176 | success: true, | |
| 177 | }; | |
| 178 | switch (record["type"]) { | |
| 179 | case "get_state": | |
| 180 | if (failState) { | |
| 181 | failState = false; | |
| 182 | return { ...base, success: false, error: "state unavailable" }; | |
| 183 | } | |
| 184 | // Queued data overrides fields of the recorded idle state, so a test | |
| 185 | // that only cares about the session file still gets a real shape. | |
| 186 | return { ...base, data: { ...recordedIdleState(sessionFile), ...stateQueue.shift() } }; | |
| 187 | case "get_available_models": | |
| 188 | return { ...base, data: { models } }; | |
| 189 | case "new_session": { | |
| 190 | const cancelled = vetoNewSession; | |
| 191 | vetoNewSession = false; | |
| 192 | if (!cancelled) sessionFile = `/fake/new-${++sessionGeneration}.jsonl`; | |
| 193 | return { ...base, data: { cancelled } }; | |
| 194 | } | |
| 195 | case "switch_session": { | |
| 196 | const cancelled = vetoSwitch; | |
| 197 | vetoSwitch = false; | |
| 198 | return { ...base, data: { cancelled } }; | |
| 199 | } | |
| 200 | case "get_entries": | |
| 201 | return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; | |
| 202 | case "get_messages": | |
| 203 | return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; | |
| 204 | case "get_session_stats": | |
| 205 | return { ...base, data: statsQueue.shift() ?? {} }; | |
| 206 | case "get_commands": | |
| 207 | return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; | |
| 208 | case "fork": | |
| 209 | return { ...base, data: { text: "Hello pi", cancelled: false } }; | |
| 210 | default: | |
| 211 | return base; | |
| 212 | } | |
| 213 | }; | |
| 214 | ||
| 215 | const handleStdinChunk = (chunk: Uint8Array) => | |
| 216 | Effect.gen(function* () { | |
| 217 | stdinBuffer += new TextDecoder().decode(chunk); | |
| 218 | while (true) { | |
| 219 | const newline = stdinBuffer.indexOf("\n"); | |
| 220 | if (newline === -1) return; | |
| 221 | const line = stdinBuffer.slice(0, newline); | |
| 222 | stdinBuffer = stdinBuffer.slice(newline + 1); | |
| 223 | if (line.length === 0) continue; | |
| 224 | const record = decodeJsonLine(line) as PiRpcRecord; | |
| 225 | allRequests.push(record); | |
| 226 | yield* Queue.offer(requests, record); | |
| 227 | if (record["type"] === "get_state" && deferState) { | |
| 228 | deferState = false; | |
| 229 | deferredStateRequest = record; | |
| 230 | continue; | |
| 231 | } | |
| 232 | if (record["type"] === deferredLifecycle) { | |
| 233 | deferredLifecycle = undefined; | |
| 234 | continue; | |
| 235 | } | |
| 236 | const response = respondTo(record); | |
| 237 | if (response !== null) yield* emit(response); | |
| 238 | } | |
| 239 | }); | |
| 240 | ||
| 241 | let lastSpawn: { readonly args: ReadonlyArray<string>; readonly env: NodeJS.ProcessEnv } = { | |
| 242 | args: [], | |
| 243 | env: {}, | |
| 244 | }; | |
| 245 | const spawner = ChildProcessSpawner.make((command) => | |
| 246 | Effect.sync(() => { | |
| 247 | if (ChildProcess.isStandardCommand(command)) { | |
| 248 | lastSpawn = { | |
| 249 | args: command.args, | |
| 250 | env: command.options.env ?? {}, | |
| 251 | }; | |
| 252 | } | |
| 253 | return ChildProcessSpawner.makeHandle({ | |
| 254 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 255 | exitCode: Effect.never, | |
| 256 | isRunning: Effect.succeed(true), | |
| 257 | kill: () => Effect.void, | |
| 258 | unref: Effect.succeed(Effect.void), | |
| 259 | stdin: Sink.forEach(handleStdinChunk), | |
| 260 | stdout: Stream.fromQueue(stdout), | |
| 261 | stderr: Stream.empty, | |
| 262 | all: Stream.empty, | |
| 263 | getInputFd: () => Sink.drain, | |
| 264 | getOutputFd: () => Stream.empty, | |
| 265 | }); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | const takeRequest = (type: string): Effect.Effect<PiRpcRecord> => | |
| 270 | Effect.gen(function* () { | |
| 271 | while (true) { | |
| 272 | const record = yield* Queue.take(requests); | |
| 273 | if (record["type"] === type) return record; | |
| 274 | } | |
| 275 | }); | |
| 276 | ||
| 277 | return { | |
| 278 | spawner, | |
| 279 | emit, | |
| 280 | takeRequest, | |
| 281 | queueEntries: (data) => entriesQueue.push(data), | |
| 282 | queueMessages: (data) => messagesQueue.push(data), | |
| 283 | deferNextState: () => { | |
| 284 | deferState = true; | |
| 285 | }, | |
| 286 | resolveDeferredState: (data) => | |
| 287 | Effect.gen(function* () { | |
| 288 | const record = deferredStateRequest; | |
| 289 | assert.isDefined(record); | |
| 290 | deferredStateRequest = undefined; | |
| 291 | yield* emit({ | |
| 292 | type: "response", | |
| 293 | id: record!["id"], | |
| 294 | command: "get_state", | |
| 295 | success: true, | |
| 296 | data, | |
| 297 | }); | |
| 298 | }), | |
| 299 | failNextState: () => { | |
| 300 | failState = true; | |
| 301 | }, | |
| 302 | deferNextLifecycle: (type) => { | |
| 303 | deferredLifecycle = type; | |
| 304 | }, | |
| 305 | deferNextModelSelection: () => { | |
| 306 | deferredLifecycle = "set_model"; | |
| 307 | }, | |
| 308 | queueModels: (value) => { | |
| 309 | models = value; | |
| 310 | }, | |
| 311 | vetoNextNewSession: () => { | |
| 312 | vetoNewSession = true; | |
| 313 | }, | |
| 314 | allRequests: () => allRequests, | |
| 315 | vetoNextSwitch: () => { | |
| 316 | vetoSwitch = true; | |
| 317 | }, | |
| 318 | queueState: (data) => stateQueue.push(data), | |
| 319 | queueStats: (data) => statsQueue.push(data), | |
| 320 | queueCommands: (data) => commandsQueue.push({ success: true, data }), | |
| 321 | failNextCommands: () => commandsQueue.push({ success: false }), | |
| 322 | closeStdout: Queue.end(stdout), | |
| 323 | lastSpawn: () => lastSpawn, | |
| 324 | } satisfies FakePi; | |
| 325 | }); | |
| 326 | ||
| 327 | const makeAdapter = Effect.fnUntraced(function* ( | |
| 328 | fake: FakePi, | |
| 329 | launchArgs = "", | |
| 330 | forkFake?: FakePi, | |
| 331 | continuationRequests?: PiAdapterV2Options["continuationRequests"], | |
| 332 | ) { | |
| 333 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 334 | const host = yield* ProviderHost; | |
| 335 | const fileSystem = yield* FileSystem.FileSystem; | |
| 336 | const spawner = | |
| 337 | forkFake === undefined | |
| 338 | ? fake.spawner | |
| 339 | : ChildProcessSpawner.make((command) => | |
| 340 | ChildProcess.isStandardCommand(command) && command.args.includes("--fork") | |
| 341 | ? forkFake.spawner.spawn(command) | |
| 342 | : fake.spawner.spawn(command), | |
| 343 | ); | |
| 344 | if (continuationRequests !== undefined) { | |
| 345 | return yield* PiAdapterV2Driver.create({ | |
| 346 | instanceId: PI_INSTANCE_ID, | |
| 347 | displayName: undefined, | |
| 348 | enabled: true, | |
| 349 | environment: [], | |
| 350 | config: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, | |
| 351 | }).pipe( | |
| 352 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 353 | Effect.provideService(HostProcessEnvironment, {}), | |
| 354 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { | |
| 355 | ...continuationRequests, | |
| 356 | take: Effect.never, | |
| 357 | }), | |
| 358 | ); | |
| 359 | } | |
| 360 | return makePiAdapterV2({ | |
| 361 | instanceId: PI_INSTANCE_ID, | |
| 362 | settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, | |
| 363 | environment: {}, | |
| 364 | spawner, | |
| 365 | fileSystem, | |
| 366 | idAllocator, | |
| 367 | host, | |
| 368 | }); | |
| 369 | }); | |
| 370 | ||
| 371 | const openRuntime = Effect.fnUntraced(function* ( | |
| 372 | fake: FakePi, | |
| 373 | model = "default", | |
| 374 | threadId = THREAD_ID, | |
| 375 | providerSessionId = SESSION_ID, | |
| 376 | forkFake?: FakePi, | |
| 377 | continuationRequests?: PiAdapterV2Options["continuationRequests"], | |
| 378 | ) { | |
| 379 | const adapter = yield* makeAdapter(fake, "", forkFake, continuationRequests); | |
| 380 | const runtime = yield* adapter.openSession({ | |
| 381 | threadId, | |
| 382 | providerSessionId, | |
| 383 | modelSelection: modelSelection(model), | |
| 384 | runtimePolicy, | |
| 385 | }); | |
| 386 | const emitted = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 387 | yield* runtime.events.pipe( | |
| 388 | Stream.runForEach((event) => Queue.offer(emitted, event)), | |
| 389 | Effect.forkScoped, | |
| 390 | ); | |
| 391 | const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => | |
| 392 | Effect.gen(function* () { | |
| 393 | while (true) { | |
| 394 | const event = yield* Queue.take(emitted); | |
| 395 | if (predicate(event)) return event; | |
| 396 | } | |
| 397 | }); | |
| 398 | return { runtime, takeEvent }; | |
| 399 | }); | |
| 400 | ||
| 401 | const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { | |
| 402 | const now = yield* DateTime.now; | |
| 403 | return { | |
| 404 | createdBy: "user", | |
| 405 | creationSource: "web", | |
| 406 | id: threadId, | |
| 407 | projectId: "project:fixture:pi" as OrchestrationV2AppThread["projectId"], | |
| 408 | title: "Pi test thread", | |
| 409 | providerInstanceId: PI_INSTANCE_ID, | |
| 410 | modelSelection: modelSelection(model), | |
| 411 | runtimeMode: "full-access", | |
| 412 | interactionMode: "default", | |
| 413 | branch: null, | |
| 414 | worktreePath: null, | |
| 415 | activeProviderThreadId: null, | |
| 416 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 417 | forkedFrom: null, | |
| 418 | createdAt: now, | |
| 419 | updatedAt: now, | |
| 420 | archivedAt: null, | |
| 421 | settledOverride: null, | |
| 422 | settledAt: null, | |
| 423 | lastVisitedAt: null, | |
| 424 | deletedAt: null, | |
| 425 | } satisfies OrchestrationV2AppThread; | |
| 426 | }); | |
| 427 | ||
| 428 | const startTurn = Effect.fnUntraced(function* ( | |
| 429 | runtime: ProviderAdapterV2SessionRuntime, | |
| 430 | providerThread: OrchestrationV2ProviderThread, | |
| 431 | model = "default", | |
| 432 | attachments: ReadonlyArray<ChatAttachment> = [], | |
| 433 | text = "Hello pi", | |
| 434 | selection?: ModelSelection, | |
| 435 | runOrdinal = 1, | |
| 436 | threadId = THREAD_ID, | |
| 437 | continuation = false, | |
| 438 | ) { | |
| 439 | const appThread = yield* makeAppThread(model, threadId); | |
| 440 | const runId = RunId.make(`run:${threadId}:${runOrdinal}`); | |
| 441 | yield* runtime.startTurn({ | |
| 442 | appThread, | |
| 443 | threadId, | |
| 444 | runId, | |
| 445 | runOrdinal, | |
| 446 | providerTurnOrdinal: runOrdinal, | |
| 447 | attemptId: RunAttemptId.make(`run-attempt:${runId}:1`), | |
| 448 | rootNodeId: NodeId.make(`node:${runId}:root`), | |
| 449 | providerThread, | |
| 450 | message: { | |
| 451 | messageId: `message:${threadId}:${runOrdinal}` as never, | |
| 452 | text, | |
| 453 | attachments, | |
| 454 | createdBy: continuation ? "agent" : "user", | |
| 455 | creationSource: continuation ? "provider" : "web", | |
| 456 | }, | |
| 457 | modelSelection: selection ?? modelSelection(model), | |
| 458 | runtimePolicy, | |
| 459 | }); | |
| 460 | }); | |
| 461 | ||
| 462 | const expectModelFailure = (errorMessage: string) => | |
| 463 | Effect.gen(function* () { | |
| 464 | const fake = yield* makeFakePi; | |
| 465 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 466 | const providerThread = yield* runtime.ensureThread({ | |
| 467 | threadId: THREAD_ID, | |
| 468 | modelSelection: modelSelection("default"), | |
| 469 | runtimePolicy, | |
| 470 | }); | |
| 471 | yield* startTurn(runtime, providerThread); | |
| 472 | yield* fake.takeRequest("prompt"); | |
| 473 | yield* fake.emit({ type: "agent_start" }); | |
| 474 | yield* fake.emit({ | |
| 475 | type: "message_end", | |
| 476 | message: { | |
| 477 | role: "assistant", | |
| 478 | content: [], | |
| 479 | stopReason: "error", | |
| 480 | errorMessage, | |
| 481 | }, | |
| 482 | }); | |
| 483 | yield* fake.emit({ type: "agent_settled" }); | |
| 484 | ||
| 485 | const sessionError = yield* takeEvent( | |
| 486 | (event) => | |
| 487 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 488 | ); | |
| 489 | assert.isTrue( | |
| 490 | sessionError.type === "provider_session.updated" && | |
| 491 | sessionError.providerSession.lastError === errorMessage, | |
| 492 | ); | |
| 493 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 494 | assert.isTrue( | |
| 495 | terminal.type === "turn.terminal" && | |
| 496 | terminal.status === "failed" && | |
| 497 | terminal.failure.message === errorMessage, | |
| 498 | ); | |
| 499 | }).pipe(Effect.scoped, Effect.provide(layerTest)); | |
| 500 | ||
| 501 | describe("PiAdapterV2", () => { | |
| 502 | it.effect.each([false, true])( | |
| 503 | "serializes rollback fork hooks with native wake ownership, wake=%s", | |
| 504 | (nativeWake) => | |
| 505 | Effect.gen(function* () { | |
| 506 | const fake = yield* makeFakePi; | |
| 507 | const offers = | |
| 508 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 509 | const { runtime, takeEvent } = yield* openRuntime( | |
| 510 | fake, | |
| 511 | "default", | |
| 512 | THREAD_ID, | |
| 513 | SESSION_ID, | |
| 514 | undefined, | |
| 515 | { | |
| 516 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 517 | }, | |
| 518 | ); | |
| 519 | const providerThread = yield* runtime.ensureThread({ | |
| 520 | threadId: THREAD_ID, | |
| 521 | modelSelection: modelSelection("default"), | |
| 522 | runtimePolicy, | |
| 523 | }); | |
| 524 | fake.deferNextLifecycle("fork"); | |
| 525 | const rollingBack = yield* runtime | |
| 526 | .rollbackThread({ | |
| 527 | providerThread, | |
| 528 | target: { | |
| 529 | type: "thread_start", | |
| 530 | checkpointId: CheckpointId.make("checkpoint-start"), | |
| 531 | appRunOrdinal: 0, | |
| 532 | }, | |
| 533 | providerThreadTurns: [ | |
| 534 | { | |
| 535 | id: ProviderTurnId.make("previous-turn"), | |
| 536 | providerThreadId: providerThread.id, | |
| 537 | nodeId: NodeId.make("previous-node"), | |
| 538 | runAttemptId: null, | |
| 539 | nativeTurnRef: { | |
| 540 | driver: PI_PROVIDER, | |
| 541 | nativeId: "user-before-wake", | |
| 542 | strength: "strong", | |
| 543 | }, | |
| 544 | ordinal: 1, | |
| 545 | status: "completed", | |
| 546 | startedAt: providerThread.createdAt, | |
| 547 | completedAt: providerThread.createdAt, | |
| 548 | }, | |
| 549 | ], | |
| 550 | }) | |
| 551 | .pipe(Effect.result, Effect.forkScoped); | |
| 552 | const fork = yield* fake.takeRequest("fork"); | |
| 553 | const overlappingTurn = yield* startTurn(runtime, providerThread).pipe(Effect.result); | |
| 554 | assert.equal(overlappingTurn._tag, "Failure"); | |
| 555 | const overlappingRegistration = yield* runtime | |
| 556 | .ensureThread({ | |
| 557 | threadId: THREAD_ID, | |
| 558 | modelSelection: modelSelection("default"), | |
| 559 | runtimePolicy, | |
| 560 | }) | |
| 561 | .pipe(Effect.result); | |
| 562 | assert.equal(overlappingRegistration._tag, "Failure"); | |
| 563 | yield* fake.emit({ | |
| 564 | type: "extension_ui_request", | |
| 565 | id: "fork-hook-dialog", | |
| 566 | method: "confirm", | |
| 567 | title: "Restore this branch?", | |
| 568 | }); | |
| 569 | const forkDialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 570 | assert.isTrue(forkDialog.type === "runtime_request.updated"); | |
| 571 | if (forkDialog.type !== "runtime_request.updated") return; | |
| 572 | assert.isNull(forkDialog.runtimeRequest.providerTurnId); | |
| 573 | yield* runtime.respondToRuntimeRequest({ | |
| 574 | requestId: forkDialog.runtimeRequest.id, | |
| 575 | decision: "accept", | |
| 576 | }); | |
| 577 | yield* fake.takeRequest("extension_ui_response"); | |
| 578 | if (nativeWake) { | |
| 579 | yield* fake.emit({ type: "agent_start" }); | |
| 580 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 581 | yield* fake.emit({ | |
| 582 | type: "message_update", | |
| 583 | assistantMessageEvent: { | |
| 584 | type: "text_end", | |
| 585 | contentIndex: 0, | |
| 586 | content: "Discarded branch wake output", | |
| 587 | }, | |
| 588 | }); | |
| 589 | yield* fake.emit({ type: "agent_settled" }); | |
| 590 | const retired = yield* takeEvent( | |
| 591 | (event) => | |
| 592 | event.type === "provider_session.updated" && | |
| 593 | event.providerSession.status === "stopped", | |
| 594 | ).pipe(Effect.as(true), Effect.raceFirst(Queue.take(offers).pipe(Effect.as(false)))); | |
| 595 | assert.isTrue(retired, "Pi offered native work while rollback was changing its binding"); | |
| 596 | const result = yield* Fiber.join(rollingBack); | |
| 597 | assert.equal(result._tag, "Failure"); | |
| 598 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 599 | const staleTurn = yield* startTurn( | |
| 600 | runtime, | |
| 601 | providerThread, | |
| 602 | "default", | |
| 603 | [], | |
| 604 | "Synthetic wake", | |
| 605 | undefined, | |
| 606 | 1, | |
| 607 | THREAD_ID, | |
| 608 | true, | |
| 609 | ).pipe(Effect.result); | |
| 610 | assert.equal(staleTurn._tag, "Failure"); | |
| 611 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); | |
| 612 | return; | |
| 613 | } | |
| 614 | fake.queueState({ sessionFile: "/fake/rolled-back-session.jsonl" }); | |
| 615 | yield* fake.emit({ | |
| 616 | type: "response", | |
| 617 | id: fork["id"], | |
| 618 | command: "fork", | |
| 619 | success: true, | |
| 620 | data: { cancelled: false }, | |
| 621 | }); | |
| 622 | const result = yield* Fiber.join(rollingBack); | |
| 623 | assert.equal(result._tag, "Success"); | |
| 624 | if (result._tag !== "Success") return; | |
| 625 | assert.equal( | |
| 626 | result.success.providerThread.nativeThreadRef?.nativeId, | |
| 627 | "/fake/rolled-back-session.jsonl", | |
| 628 | ); | |
| 629 | yield* startTurn(runtime, result.success.providerThread); | |
| 630 | const prompt = yield* fake.takeRequest("prompt"); | |
| 631 | assert.equal(prompt["message"], "Hello pi"); | |
| 632 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 633 | yield* fake.emit({ type: "agent_start" }); | |
| 634 | yield* fake.emit({ type: "agent_settled" }); | |
| 635 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 636 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 637 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 638 | ); | |
| 639 | ||
| 640 | it.effect.each([false, true])( | |
| 641 | "retains native wake ownership after a rejected joining prompt, compaction aborted=%s", | |
| 642 | (aborted) => | |
| 643 | Effect.gen(function* () { | |
| 644 | const fake = yield* makeFakePi; | |
| 645 | const offers = | |
| 646 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 647 | const { runtime, takeEvent } = yield* openRuntime( | |
| 648 | fake, | |
| 649 | "default", | |
| 650 | THREAD_ID, | |
| 651 | SESSION_ID, | |
| 652 | undefined, | |
| 653 | { | |
| 654 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 655 | }, | |
| 656 | ); | |
| 657 | const providerThread = yield* runtime.ensureThread({ | |
| 658 | threadId: THREAD_ID, | |
| 659 | modelSelection: modelSelection("default"), | |
| 660 | runtimePolicy, | |
| 661 | }); | |
| 662 | yield* fake.takeRequest("get_state"); | |
| 663 | yield* fake.emit({ type: "agent_start" }); | |
| 664 | yield* Queue.take(offers); | |
| 665 | yield* fake.emit({ type: "agent_settled" }); | |
| 666 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 667 | yield* startTurn(runtime, providerThread); | |
| 668 | yield* fake.takeRequest("prompt"); | |
| 669 | const rejection = "Cannot submit a prompt while compaction is in progress."; | |
| 670 | yield* fake.emit({ type: "response", command: "prompt", success: false, error: rejection }); | |
| 671 | yield* fake.emit({ | |
| 672 | type: "extension_ui_request", | |
| 673 | id: "native-compaction-dialog", | |
| 674 | method: "confirm", | |
| 675 | title: "Continue native work?", | |
| 676 | }); | |
| 677 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 678 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 679 | if (dialog.type !== "runtime_request.updated") return; | |
| 680 | assert.isNotNull(dialog.runtimeRequest.providerTurnId); | |
| 681 | yield* runtime.respondToRuntimeRequest({ | |
| 682 | requestId: dialog.runtimeRequest.id, | |
| 683 | decision: "accept", | |
| 684 | }); | |
| 685 | yield* fake.takeRequest("extension_ui_response"); | |
| 686 | if (aborted) { | |
| 687 | yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); | |
| 688 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 689 | assert.isTrue( | |
| 690 | terminal.type === "turn.terminal" && | |
| 691 | terminal.status === "failed" && | |
| 692 | terminal.failure.message === rejection, | |
| 693 | ); | |
| 694 | return; | |
| 695 | } | |
| 696 | fake.queueState({ isStreaming: true }); | |
| 697 | yield* fake.emit({ | |
| 698 | type: "compaction_end", | |
| 699 | result: { summary: "Native compaction finished" }, | |
| 700 | willRetry: true, | |
| 701 | }); | |
| 702 | yield* fake.emit({ type: "agent_start" }); | |
| 703 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 704 | yield* fake.emit({ | |
| 705 | type: "message_update", | |
| 706 | assistantMessageEvent: { | |
| 707 | type: "text_end", | |
| 708 | contentIndex: 0, | |
| 709 | content: "Native work completed", | |
| 710 | }, | |
| 711 | }); | |
| 712 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 713 | assert.isTrue(message.type === "message.updated" && message.message.runId !== null); | |
| 714 | yield* fake.takeRequest("get_state"); | |
| 715 | yield* fake.emit({ type: "agent_settled" }); | |
| 716 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 717 | assert.isTrue( | |
| 718 | terminal.type === "turn.terminal" && | |
| 719 | terminal.status === "failed" && | |
| 720 | terminal.failure.message === rejection, | |
| 721 | ); | |
| 722 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 723 | ); | |
| 724 | ||
| 725 | it.effect("rejects rollback while native wake output is awaiting ownership", () => | |
| 726 | Effect.gen(function* () { | |
| 727 | const fake = yield* makeFakePi; | |
| 728 | const offers = | |
| 729 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 730 | const { runtime, takeEvent } = yield* openRuntime( | |
| 731 | fake, | |
| 732 | "default", | |
| 733 | THREAD_ID, | |
| 734 | SESSION_ID, | |
| 735 | undefined, | |
| 736 | { | |
| 737 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 738 | }, | |
| 739 | ); | |
| 740 | const providerThread = yield* runtime.ensureThread({ | |
| 741 | threadId: THREAD_ID, | |
| 742 | modelSelection: modelSelection("default"), | |
| 743 | runtimePolicy, | |
| 744 | }); | |
| 745 | yield* fake.emit({ type: "agent_start" }); | |
| 746 | yield* Queue.take(offers); | |
| 747 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 748 | yield* fake.emit({ | |
| 749 | type: "message_update", | |
| 750 | assistantMessageEvent: { | |
| 751 | type: "text_end", | |
| 752 | contentIndex: 0, | |
| 753 | content: "Current branch wake output", | |
| 754 | }, | |
| 755 | }); | |
| 756 | yield* fake.emit({ type: "agent_settled" }); | |
| 757 | yield* takeEvent( | |
| 758 | (event) => | |
| 759 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 760 | ); | |
| 761 | const rolledBack = yield* runtime | |
| 762 | .rollbackThread({ | |
| 763 | providerThread, | |
| 764 | target: { | |
| 765 | type: "thread_start", | |
| 766 | checkpointId: CheckpointId.make("checkpoint-start"), | |
| 767 | appRunOrdinal: 0, | |
| 768 | }, | |
| 769 | providerThreadTurns: [ | |
| 770 | { | |
| 771 | id: ProviderTurnId.make("previous-turn"), | |
| 772 | providerThreadId: providerThread.id, | |
| 773 | nodeId: NodeId.make("previous-node"), | |
| 774 | runAttemptId: null, | |
| 775 | nativeTurnRef: { | |
| 776 | driver: PI_PROVIDER, | |
| 777 | nativeId: "user-before-wake", | |
| 778 | strength: "strong", | |
| 779 | }, | |
| 780 | ordinal: 1, | |
| 781 | status: "completed", | |
| 782 | startedAt: providerThread.createdAt, | |
| 783 | completedAt: providerThread.createdAt, | |
| 784 | }, | |
| 785 | ], | |
| 786 | }) | |
| 787 | .pipe(Effect.result); | |
| 788 | assert.equal( | |
| 789 | rolledBack._tag, | |
| 790 | "Failure", | |
| 791 | "Rollback changed the native session while its wake was still buffered", | |
| 792 | ); | |
| 793 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "fork")); | |
| 794 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 795 | yield* startTurn( | |
| 796 | runtime, | |
| 797 | providerThread, | |
| 798 | "default", | |
| 799 | [], | |
| 800 | "Synthetic wake", | |
| 801 | undefined, | |
| 802 | 1, | |
| 803 | THREAD_ID, | |
| 804 | true, | |
| 805 | ); | |
| 806 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 807 | assert.equal( | |
| 808 | message.type === "message.updated" && message.message.text, | |
| 809 | "Current branch wake output", | |
| 810 | ); | |
| 811 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 812 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 813 | ); | |
| 814 | ||
| 815 | it.effect("answers model-selection dialogs before a user turn claims a native wake", () => | |
| 816 | Effect.gen(function* () { | |
| 817 | const fake = yield* makeFakePi; | |
| 818 | const offers = | |
| 819 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 820 | const { runtime, takeEvent } = yield* openRuntime( | |
| 821 | fake, | |
| 822 | "default", | |
| 823 | THREAD_ID, | |
| 824 | SESSION_ID, | |
| 825 | undefined, | |
| 826 | { offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid) }, | |
| 827 | ); | |
| 828 | const providerThread = yield* runtime.ensureThread({ | |
| 829 | threadId: THREAD_ID, | |
| 830 | modelSelection: modelSelection("default"), | |
| 831 | runtimePolicy, | |
| 832 | }); | |
| 833 | yield* fake.emit({ type: "agent_start" }); | |
| 834 | yield* Queue.take(offers); | |
| 835 | const requests: Array<Extract<ProviderAdapterV2Event, { type: "runtime_request.updated" }>> = | |
| 836 | []; | |
| 837 | fake.deferNextModelSelection(); | |
| 838 | const starting = yield* startTurn( | |
| 839 | runtime, | |
| 840 | providerThread, | |
| 841 | "default", | |
| 842 | [], | |
| 843 | "Continue the review", | |
| 844 | modelSelection("xai/grok-4.6"), | |
| 845 | ).pipe(Effect.forkScoped); | |
| 846 | const modelRequest = yield* fake.takeRequest("set_model"); | |
| 847 | yield* fake.emit({ | |
| 848 | type: "extension_ui_request", | |
| 849 | id: "model-selection-dialog", | |
| 850 | method: "confirm", | |
| 851 | title: "Use this model?", | |
| 852 | }); | |
| 853 | yield* fake.emit({ type: "agent_settled" }); | |
| 854 | yield* takeEvent((event) => { | |
| 855 | if (event.type === "runtime_request.updated") requests.push(event); | |
| 856 | return ( | |
| 857 | event.type === "provider_session.updated" && event.providerSession.status === "ready" | |
| 858 | ); | |
| 859 | }); | |
| 860 | const dialog = requests[0]?.runtimeRequest; | |
| 861 | assert.isDefined(dialog, "Pi buffered the dialog needed to finish model selection"); | |
| 862 | assert.equal(requests[0]?.threadId, THREAD_ID); | |
| 863 | assert.isNull(dialog!.providerTurnId); | |
| 864 | yield* runtime.respondToRuntimeRequest({ requestId: dialog!.id, decision: "accept" }); | |
| 865 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 866 | assert.equal(response["id"], "model-selection-dialog"); | |
| 867 | assert.isTrue(response["confirmed"]); | |
| 868 | yield* fake.emit({ | |
| 869 | type: "response", | |
| 870 | id: modelRequest["id"], | |
| 871 | command: "set_model", | |
| 872 | success: true, | |
| 873 | data: { provider: "xai", id: "grok-4.6" }, | |
| 874 | }); | |
| 875 | yield* Fiber.join(starting); | |
| 876 | const prompt = yield* fake.takeRequest("prompt"); | |
| 877 | assert.equal(prompt["message"], "Continue the review"); | |
| 878 | assert.equal(prompt["streamingBehavior"], "steer"); | |
| 879 | const resolved = yield* takeEvent( | |
| 880 | (event) => | |
| 881 | event.type === "runtime_request.updated" && event.runtimeRequest.id === dialog!.id, | |
| 882 | ); | |
| 883 | assert.isTrue( | |
| 884 | resolved.type === "runtime_request.updated" && | |
| 885 | resolved.runtimeRequest.status === "resolved", | |
| 886 | ); | |
| 887 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 888 | ); | |
| 889 | ||
| 890 | it.effect("adopts a completed native wake without sending a synthetic prompt", () => | |
| 891 | Effect.gen(function* () { | |
| 892 | const fake = yield* makeFakePi; | |
| 893 | const offers = | |
| 894 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 895 | const { runtime, takeEvent } = yield* openRuntime( | |
| 896 | fake, | |
| 897 | "default", | |
| 898 | THREAD_ID, | |
| 899 | SESSION_ID, | |
| 900 | undefined, | |
| 901 | { | |
| 902 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 903 | }, | |
| 904 | ); | |
| 905 | const providerThread = yield* runtime.ensureThread({ | |
| 906 | threadId: THREAD_ID, | |
| 907 | modelSelection: modelSelection("default"), | |
| 908 | runtimePolicy, | |
| 909 | }); | |
| 910 | yield* fake.emit({ type: "agent_start" }); | |
| 911 | const offer = yield* Queue.take(offers).pipe( | |
| 912 | Effect.raceFirst( | |
| 913 | takeEvent( | |
| 914 | (event) => | |
| 915 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 916 | ).pipe( | |
| 917 | Effect.andThen(Effect.sync(() => assert.fail("Pi killed a routable native wake"))), | |
| 918 | ), | |
| 919 | ), | |
| 920 | ); | |
| 921 | assert.equal(offer.threadId, THREAD_ID); | |
| 922 | assert.equal(offer.providerThreadId, providerThread.id); | |
| 923 | assert.equal(offer.delivery, "adapter_buffered"); | |
| 924 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 925 | let dispatchCommitted = false; | |
| 926 | yield* offer.dispatchIfCurrent!( | |
| 927 | Effect.sync(() => { | |
| 928 | dispatchCommitted = true; | |
| 929 | }), | |
| 930 | ); | |
| 931 | assert.isTrue(dispatchCommitted); | |
| 932 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 933 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 934 | yield* fake.emit({ | |
| 935 | type: "message_update", | |
| 936 | assistantMessageEvent: { | |
| 937 | type: "text_delta", | |
| 938 | contentIndex: 0, | |
| 939 | delta: "Background review finished", | |
| 940 | }, | |
| 941 | }); | |
| 942 | yield* fake.emit({ type: "message_end", message: { role: "assistant" } }); | |
| 943 | yield* fake.emit({ type: "agent_settled" }); | |
| 944 | yield* takeEvent( | |
| 945 | (event) => | |
| 946 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 947 | ); | |
| 948 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 949 | yield* startTurn( | |
| 950 | runtime, | |
| 951 | providerThread, | |
| 952 | "default", | |
| 953 | [], | |
| 954 | "Synthetic wake", | |
| 955 | undefined, | |
| 956 | 1, | |
| 957 | THREAD_ID, | |
| 958 | true, | |
| 959 | ); | |
| 960 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 961 | assert.equal( | |
| 962 | message.type === "message.updated" && message.message.text, | |
| 963 | "Background review finished", | |
| 964 | ); | |
| 965 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 966 | assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 0); | |
| 967 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 968 | let dispatched = false; | |
| 969 | yield* offer.dispatchIfCurrent!( | |
| 970 | Effect.sync(() => { | |
| 971 | dispatched = true; | |
| 972 | }), | |
| 973 | ); | |
| 974 | assert.isFalse(dispatched); | |
| 975 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 976 | ); | |
| 977 | ||
| 978 | it.effect.each([false, true])( | |
| 979 | "a user prompt takes the wake without premature settlement, native wake settled=%s", | |
| 980 | (settled) => | |
| 981 | Effect.gen(function* () { | |
| 982 | const fake = yield* makeFakePi; | |
| 983 | const offers = | |
| 984 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 985 | const { runtime, takeEvent } = yield* openRuntime( | |
| 986 | fake, | |
| 987 | "default", | |
| 988 | THREAD_ID, | |
| 989 | SESSION_ID, | |
| 990 | undefined, | |
| 991 | { | |
| 992 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 993 | }, | |
| 994 | ); | |
| 995 | const providerThread = yield* runtime.ensureThread({ | |
| 996 | threadId: THREAD_ID, | |
| 997 | modelSelection: modelSelection("default"), | |
| 998 | runtimePolicy, | |
| 999 | }); | |
| 1000 | yield* fake.emit({ type: "agent_start" }); | |
| 1001 | const offer = yield* Queue.take(offers); | |
| 1002 | if (settled) { | |
| 1003 | yield* fake.emit({ type: "agent_settled" }); | |
| 1004 | yield* takeEvent( | |
| 1005 | (event) => | |
| 1006 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 1007 | ); | |
| 1008 | } | |
| 1009 | yield* startTurn(runtime, providerThread); | |
| 1010 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1011 | assert.equal(prompt["message"], "Hello pi"); | |
| 1012 | assert.equal(prompt["streamingBehavior"], "steer"); | |
| 1013 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1014 | yield* fake.emit({ type: "agent_start" }); | |
| 1015 | yield* fake.emit({ | |
| 1016 | type: "extension_ui_request", | |
| 1017 | id: "owned-dialog", | |
| 1018 | method: "confirm", | |
| 1019 | title: "Continue?", | |
| 1020 | }); | |
| 1021 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 1022 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 1023 | if (dialog.type !== "runtime_request.updated") return; | |
| 1024 | assert.isNotNull(dialog.runtimeRequest.providerTurnId); | |
| 1025 | yield* offer.clearIfCurrent!(); | |
| 1026 | yield* runtime.respondToRuntimeRequest({ | |
| 1027 | requestId: dialog.runtimeRequest.id, | |
| 1028 | decision: "accept", | |
| 1029 | }); | |
| 1030 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 1031 | assert.isTrue(response["confirmed"]); | |
| 1032 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 1033 | yield* fake.emit({ | |
| 1034 | type: "message_update", | |
| 1035 | assistantMessageEvent: { | |
| 1036 | type: "text_end", | |
| 1037 | contentIndex: 0, | |
| 1038 | content: "User prompt completed", | |
| 1039 | }, | |
| 1040 | }); | |
| 1041 | yield* fake.emit({ type: "agent_settled" }); | |
| 1042 | const message = yield* takeEvent( | |
| 1043 | (event) => | |
| 1044 | event.type === "message.updated" && event.message.text === "User prompt completed", | |
| 1045 | ); | |
| 1046 | assert.isTrue(message.type === "message.updated" && message.message.runId !== null); | |
| 1047 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1048 | let dispatched = false; | |
| 1049 | yield* offer.dispatchIfCurrent!( | |
| 1050 | Effect.sync(() => { | |
| 1051 | dispatched = true; | |
| 1052 | }), | |
| 1053 | ); | |
| 1054 | assert.isFalse(dispatched); | |
| 1055 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | it.effect("defers a wake's late settlement until the joined user prompt is acknowledged", () => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | const fake = yield* makeFakePi; | |
| 1061 | const offers = | |
| 1062 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1063 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1064 | fake, | |
| 1065 | "default", | |
| 1066 | THREAD_ID, | |
| 1067 | SESSION_ID, | |
| 1068 | undefined, | |
| 1069 | { | |
| 1070 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1071 | }, | |
| 1072 | ); | |
| 1073 | const providerThread = yield* runtime.ensureThread({ | |
| 1074 | threadId: THREAD_ID, | |
| 1075 | modelSelection: modelSelection("default"), | |
| 1076 | runtimePolicy, | |
| 1077 | }); | |
| 1078 | yield* fake.emit({ type: "agent_start" }); | |
| 1079 | yield* Queue.take(offers); | |
| 1080 | yield* startTurn(runtime, providerThread); | |
| 1081 | yield* fake.takeRequest("prompt"); | |
| 1082 | yield* fake.emit({ type: "agent_settled" }); | |
| 1083 | yield* fake.emit({ | |
| 1084 | type: "extension_ui_request", | |
| 1085 | method: "notify", | |
| 1086 | message: "Ordering barrier", | |
| 1087 | }); | |
| 1088 | yield* takeEvent( | |
| 1089 | (event) => | |
| 1090 | event.type === "turn_item.updated" && | |
| 1091 | event.turnItem.type === "dynamic_tool" && | |
| 1092 | event.turnItem.toolName === "notify", | |
| 1093 | ); | |
| 1094 | assert.equal(runtime.providerSession.status, "running"); | |
| 1095 | fake.queueState({ isStreaming: true }); | |
| 1096 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1097 | yield* fake.takeRequest("get_state"); | |
| 1098 | yield* fake.emit({ type: "agent_start" }); | |
| 1099 | yield* fake.emit({ type: "agent_settled" }); | |
| 1100 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1101 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1102 | ); | |
| 1103 | ||
| 1104 | it.effect("settles a command-only user prompt after taking a completed wake", () => | |
| 1105 | Effect.gen(function* () { | |
| 1106 | const fake = yield* makeFakePi; | |
| 1107 | const offers = | |
| 1108 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1109 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1110 | fake, | |
| 1111 | "default", | |
| 1112 | THREAD_ID, | |
| 1113 | SESSION_ID, | |
| 1114 | undefined, | |
| 1115 | { | |
| 1116 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1117 | }, | |
| 1118 | ); | |
| 1119 | const providerThread = yield* runtime.ensureThread({ | |
| 1120 | threadId: THREAD_ID, | |
| 1121 | modelSelection: modelSelection("default"), | |
| 1122 | runtimePolicy, | |
| 1123 | }); | |
| 1124 | yield* fake.emit({ type: "agent_start" }); | |
| 1125 | yield* Queue.take(offers); | |
| 1126 | yield* fake.emit({ type: "agent_settled" }); | |
| 1127 | yield* takeEvent( | |
| 1128 | (event) => | |
| 1129 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 1130 | ); | |
| 1131 | yield* startTurn(runtime, providerThread, "default", [], "/hello"); | |
| 1132 | yield* fake.takeRequest("prompt"); | |
| 1133 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1134 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1135 | yield* startTurn( | |
| 1136 | runtime, | |
| 1137 | providerThread, | |
| 1138 | "default", | |
| 1139 | [], | |
| 1140 | "Stale continuation", | |
| 1141 | undefined, | |
| 1142 | 2, | |
| 1143 | THREAD_ID, | |
| 1144 | true, | |
| 1145 | ); | |
| 1146 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1147 | assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 1); | |
| 1148 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1149 | ); | |
| 1150 | ||
| 1151 | it.effect("keeps a pending wake on its thread and lets Stop retire its process", () => | |
| 1152 | Effect.gen(function* () { | |
| 1153 | const fake = yield* makeFakePi; | |
| 1154 | const offers = | |
| 1155 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1156 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1157 | fake, | |
| 1158 | "default", | |
| 1159 | THREAD_ID, | |
| 1160 | SESSION_ID, | |
| 1161 | undefined, | |
| 1162 | { | |
| 1163 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1164 | }, | |
| 1165 | ); | |
| 1166 | const providerThread = yield* runtime.ensureThread({ | |
| 1167 | threadId: THREAD_ID, | |
| 1168 | modelSelection: modelSelection("default"), | |
| 1169 | runtimePolicy, | |
| 1170 | }); | |
| 1171 | yield* fake.emit({ type: "agent_start" }); | |
| 1172 | const offer = yield* Queue.take(offers); | |
| 1173 | assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread)); | |
| 1174 | const otherThread = { | |
| 1175 | ...providerThread, | |
| 1176 | appThreadId: ThreadId.make("other-thread"), | |
| 1177 | id: ProviderThreadId.make("other-provider-thread"), | |
| 1178 | }; | |
| 1179 | assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(otherThread)); | |
| 1180 | const failure = yield* runtime | |
| 1181 | .resumeThread({ providerThread: otherThread }) | |
| 1182 | .pipe(Effect.flip); | |
| 1183 | assert.equal(failure._tag, "ProviderAdapterResumeThreadError"); | |
| 1184 | yield* runtime.interruptTurn({ | |
| 1185 | providerThread, | |
| 1186 | providerTurnId: ProviderTurnId.make("settled-turn"), | |
| 1187 | requestRuntimeRestart: true, | |
| 1188 | }); | |
| 1189 | yield* takeEvent( | |
| 1190 | (event) => | |
| 1191 | event.type === "provider_session.updated" && event.providerSession.status === "stopped", | |
| 1192 | ); | |
| 1193 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 1194 | let dispatched = false; | |
| 1195 | yield* offer.dispatchIfCurrent!( | |
| 1196 | Effect.sync(() => { | |
| 1197 | dispatched = true; | |
| 1198 | }), | |
| 1199 | ); | |
| 1200 | assert.isFalse(dispatched); | |
| 1201 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1202 | ); | |
| 1203 | ||
| 1204 | it.effect.each(["archive", "stop", "transport death"] as const)( | |
| 1205 | "retiring a wake by %s cancels visible dialogs and invalidates its offer", | |
| 1206 | (retirement) => | |
| 1207 | Effect.gen(function* () { | |
| 1208 | const fake = yield* makeFakePi; | |
| 1209 | const offers = | |
| 1210 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1211 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1212 | fake, | |
| 1213 | "default", | |
| 1214 | THREAD_ID, | |
| 1215 | SESSION_ID, | |
| 1216 | undefined, | |
| 1217 | { | |
| 1218 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1219 | }, | |
| 1220 | ); | |
| 1221 | const providerThread = yield* runtime.ensureThread({ | |
| 1222 | threadId: THREAD_ID, | |
| 1223 | modelSelection: modelSelection("default"), | |
| 1224 | runtimePolicy, | |
| 1225 | }); | |
| 1226 | yield* fake.emit({ type: "agent_start" }); | |
| 1227 | const offer = yield* Queue.take(offers); | |
| 1228 | yield* fake.emit({ | |
| 1229 | type: "extension_ui_request", | |
| 1230 | id: "wake-dialog", | |
| 1231 | method: "confirm", | |
| 1232 | title: "Continue background work?", | |
| 1233 | }); | |
| 1234 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 1235 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 1236 | if (dialog.type !== "runtime_request.updated") return; | |
| 1237 | assert.isNull(dialog.runtimeRequest.providerTurnId); | |
| 1238 | if (retirement === "archive") yield* offer.clearIfCurrent!(); | |
| 1239 | else if (retirement === "stop") { | |
| 1240 | yield* runtime.interruptTurn({ | |
| 1241 | providerThread, | |
| 1242 | providerTurnId: ProviderTurnId.make("settled-turn"), | |
| 1243 | requestRuntimeRestart: true, | |
| 1244 | }); | |
| 1245 | } else yield* fake.closeStdout; | |
| 1246 | let requestCancelled = false; | |
| 1247 | let nodeCancelled = false; | |
| 1248 | let itemCancelled = false; | |
| 1249 | yield* takeEvent((event) => { | |
| 1250 | if ( | |
| 1251 | event.type === "runtime_request.updated" && | |
| 1252 | event.runtimeRequest.id === dialog.runtimeRequest.id | |
| 1253 | ) | |
| 1254 | requestCancelled = event.runtimeRequest.status === "cancelled"; | |
| 1255 | if (event.type === "node.updated" && event.node.id === dialog.runtimeRequest.nodeId) | |
| 1256 | nodeCancelled = event.node.status === "cancelled"; | |
| 1257 | if ( | |
| 1258 | event.type === "turn_item.updated" && | |
| 1259 | event.turnItem.nodeId === dialog.runtimeRequest.nodeId | |
| 1260 | ) | |
| 1261 | itemCancelled = event.turnItem.status === "cancelled"; | |
| 1262 | return ( | |
| 1263 | event.type === "provider_session.updated" && | |
| 1264 | event.providerSession.status === | |
| 1265 | (retirement === "transport death" ? "error" : "stopped") | |
| 1266 | ); | |
| 1267 | }); | |
| 1268 | assert.isTrue(requestCancelled, "Retired wake left a pending runtime request"); | |
| 1269 | assert.isTrue(nodeCancelled); | |
| 1270 | assert.isTrue(itemCancelled); | |
| 1271 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 1272 | let dispatched = false; | |
| 1273 | yield* offer.dispatchIfCurrent!( | |
| 1274 | Effect.sync(() => { | |
| 1275 | dispatched = true; | |
| 1276 | }), | |
| 1277 | ); | |
| 1278 | assert.isFalse(dispatched); | |
| 1279 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1280 | ); | |
| 1281 | ||
| 1282 | it.effect("stops provider-initiated work that has no T3 turn owner", () => | |
| 1283 | Effect.gen(function* () { | |
| 1284 | const fake = yield* makeFakePi; | |
| 1285 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1286 | yield* runtime.ensureThread({ | |
| 1287 | threadId: THREAD_ID, | |
| 1288 | modelSelection: modelSelection("default"), | |
| 1289 | runtimePolicy, | |
| 1290 | }); | |
| 1291 | ||
| 1292 | yield* fake.emit({ type: "agent_start" }); | |
| 1293 | ||
| 1294 | const sessionError = yield* takeEvent( | |
| 1295 | (event) => | |
| 1296 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1297 | ); | |
| 1298 | assert.isTrue( | |
| 1299 | sessionError.type === "provider_session.updated" && | |
| 1300 | sessionError.providerSession.lastError?.includes("invisible tool execution") === true, | |
| 1301 | ); | |
| 1302 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1303 | ); | |
| 1304 | ||
| 1305 | it.effect("injects the T3 MCP extension and bearer when a session exists", () => | |
| 1306 | Effect.gen(function* () { | |
| 1307 | McpProviderSession.setMcpProviderSession({ | |
| 1308 | environmentId: EnvironmentId.make("environment-pi-mcp"), | |
| 1309 | threadId: THREAD_ID, | |
| 1310 | providerSessionId: "mcp-session-pi", | |
| 1311 | providerInstanceId: PI_INSTANCE_ID, | |
| 1312 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 1313 | authorizationHeader: "Bearer secret-pi-token", | |
| 1314 | browserToolsAvailable: true, | |
| 1315 | }); | |
| 1316 | const fake = yield* makeFakePi; | |
| 1317 | yield* openRuntime(fake); | |
| 1318 | const spawn = fake.lastSpawn(); | |
| 1319 | assert.isTrue(spawn.args.includes("--extension")); | |
| 1320 | const extensions = spawn.args.flatMap((arg, index) => | |
| 1321 | arg === "--extension" ? [spawn.args[index + 1]] : [], | |
| 1322 | ); | |
| 1323 | assert.isFalse(spawn.args.includes("--no-extensions")); | |
| 1324 | assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); | |
| 1325 | assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); | |
| 1326 | assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); | |
| 1327 | assert.equal(spawn.env.T3_PI_RUNTIME_MODE, "full-access"); | |
| 1328 | }).pipe( | |
| 1329 | Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID))), | |
| 1330 | Effect.scoped, | |
| 1331 | Effect.provide(layerTest), | |
| 1332 | ), | |
| 1333 | ); | |
| 1334 | ||
| 1335 | it.effect("rejects a resume while a turn is active", () => | |
| 1336 | Effect.gen(function* () { | |
| 1337 | const fake = yield* makeFakePi; | |
| 1338 | const { runtime } = yield* openRuntime(fake); | |
| 1339 | const providerThread = yield* runtime.ensureThread({ | |
| 1340 | threadId: THREAD_ID, | |
| 1341 | modelSelection: modelSelection("default"), | |
| 1342 | runtimePolicy, | |
| 1343 | }); | |
| 1344 | yield* startTurn(runtime, providerThread); | |
| 1345 | yield* fake.takeRequest("prompt"); | |
| 1346 | const error = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1347 | assert.equal(error._tag, "ProviderAdapterResumeThreadError"); | |
| 1348 | assert.match(String(error.cause), /while a turn is active/); | |
| 1349 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1350 | ); | |
| 1351 | ||
| 1352 | it.effect("waits for a slow Pi resume without starting a replacement", () => | |
| 1353 | Effect.gen(function* () { | |
| 1354 | const fake = yield* makeFakePi; | |
| 1355 | const { runtime } = yield* openRuntime(fake); | |
| 1356 | const providerThread = yield* runtime.ensureThread({ | |
| 1357 | threadId: THREAD_ID, | |
| 1358 | modelSelection: modelSelection("default"), | |
| 1359 | runtimePolicy, | |
| 1360 | }); | |
| 1361 | fake.deferNextLifecycle("switch_session"); | |
| 1362 | const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); | |
| 1363 | const request = yield* fake.takeRequest("switch_session"); | |
| 1364 | yield* TestClock.adjust(Duration.millis(16_820)); | |
| 1365 | yield* fake.emit({ | |
| 1366 | type: "response", | |
| 1367 | id: request.id, | |
| 1368 | command: "switch_session", | |
| 1369 | success: true, | |
| 1370 | data: { cancelled: false }, | |
| 1371 | }); | |
| 1372 | assert.equal((yield* Fiber.join(resumed)).nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1373 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1374 | yield* startTurn(runtime, providerThread, "default"); | |
| 1375 | yield* fake.takeRequest("prompt"); | |
| 1376 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1377 | ); | |
| 1378 | ||
| 1379 | it.effect("creates a distinct native session after a failed resume", () => | |
| 1380 | Effect.gen(function* () { | |
| 1381 | const fake = yield* makeFakePi; | |
| 1382 | const { runtime } = yield* openRuntime(fake); | |
| 1383 | const providerThread = yield* runtime.ensureThread({ | |
| 1384 | threadId: THREAD_ID, | |
| 1385 | modelSelection: modelSelection("default"), | |
| 1386 | runtimePolicy, | |
| 1387 | }); | |
| 1388 | fake.vetoNextSwitch(); | |
| 1389 | yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1390 | const replacement = yield* runtime.ensureThread({ | |
| 1391 | threadId: THREAD_ID, | |
| 1392 | modelSelection: modelSelection("default"), | |
| 1393 | runtimePolicy, | |
| 1394 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1395 | }); | |
| 1396 | assert.equal(replacement.id, providerThread.id); | |
| 1397 | assert.notEqual( | |
| 1398 | replacement.nativeThreadRef?.nativeId, | |
| 1399 | providerThread.nativeThreadRef?.nativeId, | |
| 1400 | ); | |
| 1401 | assert.equal( | |
| 1402 | fake.allRequests().filter((request) => request.type === "new_session").length, | |
| 1403 | 1, | |
| 1404 | ); | |
| 1405 | yield* startTurn(runtime, replacement, "default"); | |
| 1406 | yield* fake.takeRequest("prompt"); | |
| 1407 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1408 | ); | |
| 1409 | ||
| 1410 | it.effect.each(["veto", "same identity"] as const)( | |
| 1411 | "rejects a replacement with %s", | |
| 1412 | (invalidReplacement) => | |
| 1413 | Effect.gen(function* () { | |
| 1414 | const fake = yield* makeFakePi; | |
| 1415 | const { runtime } = yield* openRuntime(fake); | |
| 1416 | const providerThread = yield* runtime.ensureThread({ | |
| 1417 | threadId: THREAD_ID, | |
| 1418 | modelSelection: modelSelection("default"), | |
| 1419 | runtimePolicy, | |
| 1420 | }); | |
| 1421 | if (invalidReplacement === "veto") fake.vetoNextNewSession(); | |
| 1422 | else fake.queueState({ sessionFile: FAKE_SESSION_FILE }); | |
| 1423 | const error = yield* runtime | |
| 1424 | .ensureThread({ | |
| 1425 | threadId: THREAD_ID, | |
| 1426 | modelSelection: modelSelection("default"), | |
| 1427 | runtimePolicy, | |
| 1428 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1429 | }) | |
| 1430 | .pipe(Effect.flip); | |
| 1431 | assert.equal(error._tag, "ProviderAdapterEnsureThreadError"); | |
| 1432 | assert.match( | |
| 1433 | String(error.cause), | |
| 1434 | invalidReplacement === "veto" ? /cancelled/ : /distinct session/, | |
| 1435 | ); | |
| 1436 | yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip); | |
| 1437 | assert.isFalse(fake.allRequests().some((request) => request.type === "prompt")); | |
| 1438 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1439 | ); | |
| 1440 | ||
| 1441 | it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () => | |
| 1442 | Effect.gen(function* () { | |
| 1443 | const fake = yield* makeFakePi; | |
| 1444 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1445 | const providerThread = yield* runtime.ensureThread({ | |
| 1446 | threadId: THREAD_ID, | |
| 1447 | modelSelection: modelSelection("default"), | |
| 1448 | runtimePolicy, | |
| 1449 | }); | |
| 1450 | fake.deferNextLifecycle("switch_session"); | |
| 1451 | const resumed = yield* runtime | |
| 1452 | .resumeThread({ providerThread }) | |
| 1453 | .pipe(Effect.flip, Effect.forkChild); | |
| 1454 | const request = yield* fake.takeRequest("switch_session"); | |
| 1455 | yield* TestClock.adjust(Duration.seconds(60)); | |
| 1456 | const error = yield* Fiber.join(resumed); | |
| 1457 | assert.match(String(error.cause), /timed out after 60000ms/); | |
| 1458 | yield* takeEvent( | |
| 1459 | (event) => | |
| 1460 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1461 | ); | |
| 1462 | yield* fake.emit({ | |
| 1463 | type: "response", | |
| 1464 | id: request.id, | |
| 1465 | command: "switch_session", | |
| 1466 | success: true, | |
| 1467 | data: { cancelled: false }, | |
| 1468 | }); | |
| 1469 | yield* runtime | |
| 1470 | .ensureThread({ | |
| 1471 | threadId: THREAD_ID, | |
| 1472 | modelSelection: modelSelection("default"), | |
| 1473 | runtimePolicy, | |
| 1474 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1475 | }) | |
| 1476 | .pipe(Effect.flip); | |
| 1477 | assert.isFalse( | |
| 1478 | fake | |
| 1479 | .allRequests() | |
| 1480 | .some((request) => request.type === "new_session" || request.type === "prompt"), | |
| 1481 | ); | |
| 1482 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1483 | ); | |
| 1484 | ||
| 1485 | it.effect( | |
| 1486 | "uses the lifecycle deadline for fresh sessions and drops replaced native metadata", | |
| 1487 | () => | |
| 1488 | Effect.gen(function* () { | |
| 1489 | const fake = yield* makeFakePi; | |
| 1490 | const { runtime } = yield* openRuntime(fake); | |
| 1491 | const providerThread = yield* runtime.ensureThread({ | |
| 1492 | threadId: THREAD_ID, | |
| 1493 | modelSelection: modelSelection("default"), | |
| 1494 | runtimePolicy, | |
| 1495 | }); | |
| 1496 | fake.deferNextLifecycle("new_session"); | |
| 1497 | fake.queueState({ sessionFile: "/fake/fresh-after-delay.jsonl" }); | |
| 1498 | const replacing = yield* runtime | |
| 1499 | .ensureThread({ | |
| 1500 | threadId: THREAD_ID, | |
| 1501 | modelSelection: modelSelection("default"), | |
| 1502 | runtimePolicy, | |
| 1503 | existingProviderThread: { | |
| 1504 | ...providerThread, | |
| 1505 | nativeThreadRef: null, | |
| 1506 | nativeConversationHeadRef: { | |
| 1507 | driver: PI_PROVIDER, | |
| 1508 | nativeId: "old-leaf", | |
| 1509 | strength: "strong", | |
| 1510 | }, | |
| 1511 | contextUsage: { usedTokens: 314_551, maxTokens: 1_000_000 }, | |
| 1512 | }, | |
| 1513 | }) | |
| 1514 | .pipe(Effect.forkChild); | |
| 1515 | const request = yield* fake.takeRequest("new_session"); | |
| 1516 | yield* TestClock.adjust(Duration.millis(16_820)); | |
| 1517 | yield* fake.emit({ | |
| 1518 | type: "response", | |
| 1519 | id: request.id, | |
| 1520 | command: "new_session", | |
| 1521 | success: true, | |
| 1522 | data: { cancelled: false }, | |
| 1523 | }); | |
| 1524 | const replacement = yield* Fiber.join(replacing); | |
| 1525 | assert.equal(replacement.nativeThreadRef?.nativeId, "/fake/fresh-after-delay.jsonl"); | |
| 1526 | assert.isNull(replacement.contextUsage); | |
| 1527 | assert.isNull(replacement.nativeConversationHeadRef); | |
| 1528 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1529 | ); | |
| 1530 | ||
| 1531 | it.effect("replaces a native session when the first resume's state refresh fails", () => | |
| 1532 | Effect.gen(function* () { | |
| 1533 | const original = yield* makeFakePi; | |
| 1534 | const originalRuntime = yield* openRuntime(original); | |
| 1535 | const providerThread = yield* originalRuntime.runtime.ensureThread({ | |
| 1536 | threadId: THREAD_ID, | |
| 1537 | modelSelection: modelSelection("default"), | |
| 1538 | runtimePolicy, | |
| 1539 | }); | |
| 1540 | const fake = yield* makeFakePi; | |
| 1541 | const { runtime } = yield* openRuntime(fake); | |
| 1542 | fake.failNextState(); | |
| 1543 | yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1544 | const replacement = yield* runtime.ensureThread({ | |
| 1545 | threadId: THREAD_ID, | |
| 1546 | modelSelection: modelSelection("default"), | |
| 1547 | runtimePolicy, | |
| 1548 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1549 | }); | |
| 1550 | assert.notEqual( | |
| 1551 | replacement.nativeThreadRef?.nativeId, | |
| 1552 | providerThread.nativeThreadRef?.nativeId, | |
| 1553 | ); | |
| 1554 | yield* startTurn(runtime, replacement, "default"); | |
| 1555 | yield* fake.takeRequest("prompt"); | |
| 1556 | assert.isTrue(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1557 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1558 | ); | |
| 1559 | ||
| 1560 | it.effect("retires an interrupted switch before accepting further requests", () => | |
| 1561 | Effect.gen(function* () { | |
| 1562 | const fake = yield* makeFakePi; | |
| 1563 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1564 | const providerThread = yield* runtime.ensureThread({ | |
| 1565 | threadId: THREAD_ID, | |
| 1566 | modelSelection: modelSelection("default"), | |
| 1567 | runtimePolicy, | |
| 1568 | }); | |
| 1569 | fake.deferNextLifecycle("switch_session"); | |
| 1570 | const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); | |
| 1571 | yield* fake.takeRequest("switch_session"); | |
| 1572 | yield* Fiber.interrupt(resumed); | |
| 1573 | yield* takeEvent( | |
| 1574 | (event) => | |
| 1575 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1576 | ); | |
| 1577 | yield* runtime | |
| 1578 | .ensureThread({ | |
| 1579 | threadId: THREAD_ID, | |
| 1580 | modelSelection: modelSelection("default"), | |
| 1581 | runtimePolicy, | |
| 1582 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1583 | }) | |
| 1584 | .pipe(Effect.flip); | |
| 1585 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1586 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1587 | ); | |
| 1588 | ||
| 1589 | it.effect("budgets legacy native history with Pi's selected model capacity", () => | |
| 1590 | Effect.gen(function* () { | |
| 1591 | const fake = yield* makeFakePi; | |
| 1592 | fake.queueState({ | |
| 1593 | sessionFile: FAKE_SESSION_FILE, | |
| 1594 | model: { provider: "anthropic", id: "large", contextWindow: 1_000_000 }, | |
| 1595 | }); | |
| 1596 | fake.queueModels([{ provider: "anthropic", id: "small", contextWindow: 32_000 }]); | |
| 1597 | const { runtime } = yield* openRuntime(fake); | |
| 1598 | const providerThread = yield* runtime.ensureThread({ | |
| 1599 | threadId: THREAD_ID, | |
| 1600 | modelSelection: modelSelection("default"), | |
| 1601 | runtimePolicy, | |
| 1602 | }); | |
| 1603 | const budget = (model: string) => | |
| 1604 | handoffBudget({ | |
| 1605 | tokenCap: 16_000, | |
| 1606 | userText: "$handoff", | |
| 1607 | attachments: [], | |
| 1608 | providerThread, | |
| 1609 | nativeContextEstimate: 307_543, | |
| 1610 | modelContextWindow: runtime.getModelContextWindow?.(modelSelection(model)), | |
| 1611 | }); | |
| 1612 | assert.equal(budget("default"), 16_000); | |
| 1613 | assert.equal(budget("anthropic/large"), 16_000); | |
| 1614 | assert.equal(runtime.getModelContextWindow?.(modelSelection("anthropic/small")), 32_000); | |
| 1615 | assert.equal(budget("anthropic/small"), 0); | |
| 1616 | assert.isUndefined(runtime.getModelContextWindow?.(modelSelection("anthropic/unknown"))); | |
| 1617 | assert.isUndefined( | |
| 1618 | runtime.getModelContextWindow?.({ | |
| 1619 | instanceId: ProviderInstanceId.make("other-pi"), | |
| 1620 | model: "anthropic/large", | |
| 1621 | }), | |
| 1622 | ); | |
| 1623 | // New native sessions have their own default, even within one process. | |
| 1624 | fake.queueState({ | |
| 1625 | sessionFile: "/fake/replacement.jsonl", | |
| 1626 | model: { provider: "anthropic", id: "small", contextWindow: 32_000 }, | |
| 1627 | }); | |
| 1628 | yield* runtime.ensureThread({ | |
| 1629 | threadId: THREAD_ID, | |
| 1630 | modelSelection: modelSelection("default"), | |
| 1631 | runtimePolicy, | |
| 1632 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1633 | }); | |
| 1634 | assert.equal(runtime.getModelContextWindow?.(modelSelection("default")), 32_000); | |
| 1635 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1636 | ); | |
| 1637 | ||
| 1638 | it.effect("adopts the run's provider thread identity instead of minting a second row", () => | |
| 1639 | Effect.gen(function* () { | |
| 1640 | const fake = yield* makeFakePi; | |
| 1641 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1642 | const now = yield* DateTime.now; | |
| 1643 | // The placeholder row the orchestrator creates for a first run: no | |
| 1644 | // native identity yet. The adapter must bind the pi session to this | |
| 1645 | // row instead of registering a second session-file-keyed row, or the | |
| 1646 | // projection ends up with two live rows per app thread. | |
| 1647 | const placeholder: OrchestrationV2ProviderThread = { | |
| 1648 | id: ProviderThreadId.make("thread:provider:pi:native-thread:pending:run:thread-pi-test:1"), | |
| 1649 | driver: PI_PROVIDER, | |
| 1650 | providerInstanceId: PI_INSTANCE_ID, | |
| 1651 | providerSessionId: SESSION_ID, | |
| 1652 | appThreadId: THREAD_ID, | |
| 1653 | ownerNodeId: null, | |
| 1654 | nativeThreadRef: null, | |
| 1655 | nativeConversationHeadRef: null, | |
| 1656 | status: "not_loaded", | |
| 1657 | firstRunOrdinal: 1, | |
| 1658 | lastRunOrdinal: 1, | |
| 1659 | handoffIds: [], | |
| 1660 | forkedFrom: null, | |
| 1661 | createdAt: now, | |
| 1662 | updatedAt: now, | |
| 1663 | }; | |
| 1664 | const providerThread = yield* runtime.ensureThread({ | |
| 1665 | threadId: THREAD_ID, | |
| 1666 | modelSelection: modelSelection("default"), | |
| 1667 | runtimePolicy, | |
| 1668 | existingProviderThread: placeholder, | |
| 1669 | }); | |
| 1670 | assert.equal(providerThread.id, placeholder.id); | |
| 1671 | assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1672 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1673 | const updated = yield* takeEvent((event) => event.type === "provider_thread.updated"); | |
| 1674 | assert.isTrue( | |
| 1675 | updated.type === "provider_thread.updated" && updated.providerThread.id === placeholder.id, | |
| 1676 | ); | |
| 1677 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1678 | ); | |
| 1679 | ||
| 1680 | it.effect("resets applied thinking when returning to Pi default", () => | |
| 1681 | Effect.gen(function* () { | |
| 1682 | const fake = yield* makeFakePi; | |
| 1683 | fake.queueState({ | |
| 1684 | model: { provider: "xai", id: "grok-4.6" }, | |
| 1685 | thinkingLevel: "medium", | |
| 1686 | sessionFile: FAKE_SESSION_FILE, | |
| 1687 | sessionId: "abc", | |
| 1688 | }); | |
| 1689 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1690 | const providerThread = yield* runtime.ensureThread({ | |
| 1691 | threadId: THREAD_ID, | |
| 1692 | modelSelection: modelSelection("default"), | |
| 1693 | runtimePolicy, | |
| 1694 | }); | |
| 1695 | ||
| 1696 | // An explicit effort on a concrete model. | |
| 1697 | yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { | |
| 1698 | instanceId: PI_INSTANCE_ID, | |
| 1699 | model: "xai/grok-4.6", | |
| 1700 | options: [{ id: "thinking", value: "high" }], | |
| 1701 | }); | |
| 1702 | const modelRequest = yield* fake.takeRequest("set_model"); | |
| 1703 | assert.equal(modelRequest["provider"], "xai"); | |
| 1704 | assert.equal(modelRequest["modelId"], "grok-4.6"); | |
| 1705 | const levelRequest = yield* fake.takeRequest("set_thinking_level"); | |
| 1706 | assert.equal(levelRequest["level"], "high"); | |
| 1707 | yield* fake.emit({ type: "agent_start" }); | |
| 1708 | yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); | |
| 1709 | yield* fake.emit({ type: "agent_settled" }); | |
| 1710 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1711 | ||
| 1712 | // Back to Pi default with no explicit thinking choice of its own. | |
| 1713 | yield* startTurn( | |
| 1714 | runtime, | |
| 1715 | providerThread, | |
| 1716 | "default", | |
| 1717 | [], | |
| 1718 | "Hello pi", | |
| 1719 | { | |
| 1720 | instanceId: PI_INSTANCE_ID, | |
| 1721 | model: "default", | |
| 1722 | }, | |
| 1723 | 2, | |
| 1724 | ); | |
| 1725 | const replayModel = yield* fake.takeRequest("set_model"); | |
| 1726 | assert.equal(replayModel["provider"], "xai"); | |
| 1727 | assert.equal(replayModel["modelId"], "grok-4.6"); | |
| 1728 | const resetLevel = yield* fake.takeRequest("set_thinking_level"); | |
| 1729 | assert.equal(resetLevel["level"], "medium"); | |
| 1730 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1731 | ); | |
| 1732 | ||
| 1733 | it.effect("expands a selected $ skill through Pi's native skill command", () => | |
| 1734 | Effect.gen(function* () { | |
| 1735 | const fake = yield* makeFakePi; | |
| 1736 | fake.queueCommands({ | |
| 1737 | commands: [ | |
| 1738 | { | |
| 1739 | name: "skill:repo-review", | |
| 1740 | description: "Review this repository.", | |
| 1741 | source: "skill", | |
| 1742 | sourceInfo: { | |
| 1743 | path: "/workspace/.agents/skills/repo-review/SKILL.md", | |
| 1744 | scope: "project", | |
| 1745 | }, | |
| 1746 | }, | |
| 1747 | ], | |
| 1748 | }); | |
| 1749 | const { runtime } = yield* openRuntime(fake); | |
| 1750 | const providerThread = yield* runtime.ensureThread({ | |
| 1751 | threadId: THREAD_ID, | |
| 1752 | modelSelection: modelSelection("default"), | |
| 1753 | runtimePolicy, | |
| 1754 | }); | |
| 1755 | ||
| 1756 | yield* startTurn( | |
| 1757 | runtime, | |
| 1758 | providerThread, | |
| 1759 | "default", | |
| 1760 | [], | |
| 1761 | "Review this change please $repo-review", | |
| 1762 | ); | |
| 1763 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1764 | assert.equal(prompt["message"], "/skill:repo-review Review this change please"); | |
| 1765 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1766 | ); | |
| 1767 | ||
| 1768 | it.effect("expands every selected $ skill through Pi native skill commands", () => | |
| 1769 | Effect.gen(function* () { | |
| 1770 | const fake = yield* makeFakePi; | |
| 1771 | fake.queueCommands({ | |
| 1772 | commands: [ | |
| 1773 | { | |
| 1774 | name: "skill:repo-review", | |
| 1775 | source: "skill", | |
| 1776 | sourceInfo: { | |
| 1777 | path: "/workspace/.agents/skills/repo-review/SKILL.md", | |
| 1778 | scope: "project", | |
| 1779 | }, | |
| 1780 | }, | |
| 1781 | { | |
| 1782 | name: "skill:deploy", | |
| 1783 | source: "skill", | |
| 1784 | sourceInfo: { | |
| 1785 | path: "/workspace/.agents/skills/deploy/SKILL.md", | |
| 1786 | scope: "project", | |
| 1787 | }, | |
| 1788 | }, | |
| 1789 | ], | |
| 1790 | }); | |
| 1791 | const { runtime } = yield* openRuntime(fake); | |
| 1792 | const providerThread = yield* runtime.ensureThread({ | |
| 1793 | threadId: THREAD_ID, | |
| 1794 | modelSelection: modelSelection("default"), | |
| 1795 | runtimePolicy, | |
| 1796 | }); | |
| 1797 | ||
| 1798 | yield* startTurn(runtime, providerThread, "default", [], "use $repo-review and $deploy"); | |
| 1799 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1800 | assert.equal(prompt["message"], "/skill:repo-review /skill:deploy use and"); | |
| 1801 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1802 | ); | |
| 1803 | ||
| 1804 | it.effect.each([ | |
| 1805 | { historical: false, label: "the latest turn" }, | |
| 1806 | { historical: true, label: "a historical turn" }, | |
| 1807 | ])("natively forks $label into an independent session", ({ historical }) => | |
| 1808 | Effect.gen(function* () { | |
| 1809 | const fake = yield* makeFakePi; | |
| 1810 | const forkFake = yield* makeFakePi; | |
| 1811 | const forkFile = "/fake/forked.jsonl"; | |
| 1812 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1813 | fake, | |
| 1814 | "default", | |
| 1815 | THREAD_ID, | |
| 1816 | SESSION_ID, | |
| 1817 | forkFake, | |
| 1818 | ); | |
| 1819 | const source = yield* runtime.ensureThread({ | |
| 1820 | threadId: THREAD_ID, | |
| 1821 | modelSelection: modelSelection("default"), | |
| 1822 | runtimePolicy, | |
| 1823 | }); | |
| 1824 | const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ | |
| 1825 | id: ProviderTurnId.make(`turn-${ordinal}`), | |
| 1826 | providerThreadId: source.id, | |
| 1827 | nodeId: NodeId.make(`node-${ordinal}`), | |
| 1828 | runAttemptId: null, | |
| 1829 | nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, | |
| 1830 | ordinal, | |
| 1831 | status: "completed", | |
| 1832 | startedAt: null, | |
| 1833 | completedAt: null, | |
| 1834 | }); | |
| 1835 | forkFake.queueState({ sessionFile: forkFile }); | |
| 1836 | fake.queueState({ sessionFile: forkFile }); | |
| 1837 | const target = ThreadId.make("fork-target"); | |
| 1838 | const forked = yield* runtime.forkThread({ | |
| 1839 | sourceProviderThread: source, | |
| 1840 | sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], | |
| 1841 | providerTurnId: turn(1).id, | |
| 1842 | targetThreadId: target, | |
| 1843 | }); | |
| 1844 | assert.equal(forked.appThreadId, target); | |
| 1845 | assert.equal(forked.nativeThreadRef?.nativeId, forkFile); | |
| 1846 | assert.notEqual(forked.id, source.id); | |
| 1847 | assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1848 | const args = forkFake.lastSpawn().args; | |
| 1849 | assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); | |
| 1850 | assert.include(args, "--no-extensions"); | |
| 1851 | assert.include(args, "--no-tools"); | |
| 1852 | assert.notInclude(args, "--no-session"); | |
| 1853 | assert.deepEqual( | |
| 1854 | forkFake | |
| 1855 | .allRequests() | |
| 1856 | .filter((request) => request.type === "fork") | |
| 1857 | .map((request) => request.entryId), | |
| 1858 | historical ? ["u2"] : [], | |
| 1859 | ); | |
| 1860 | assert.isFalse( | |
| 1861 | fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), | |
| 1862 | ); | |
| 1863 | // ProviderTurnStartService adopts the fork into its pending row. | |
| 1864 | const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; | |
| 1865 | yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); | |
| 1866 | yield* fake.emit({ type: "agent_start" }); | |
| 1867 | yield* fake.emit({ type: "agent_settled" }); | |
| 1868 | const updated = yield* takeEvent( | |
| 1869 | (event) => | |
| 1870 | event.type === "provider_thread.updated" && event.providerThread.appThreadId === target, | |
| 1871 | ); | |
| 1872 | assert.isTrue( | |
| 1873 | updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, | |
| 1874 | ); | |
| 1875 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1876 | yield* runtime.resumeThread({ providerThread: adopted }); | |
| 1877 | assert.equal( | |
| 1878 | fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath, | |
| 1879 | forkFile, | |
| 1880 | ); | |
| 1881 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1882 | ); | |
| 1883 | ||
| 1884 | it.effect("presents explicitly namespaced MCP extension tools", () => | |
| 1885 | Effect.gen(function* () { | |
| 1886 | const fake = yield* makeFakePi; | |
| 1887 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1888 | const providerThread = yield* runtime.ensureThread({ | |
| 1889 | threadId: THREAD_ID, | |
| 1890 | modelSelection: modelSelection("default"), | |
| 1891 | runtimePolicy, | |
| 1892 | }); | |
| 1893 | yield* startTurn(runtime, providerThread); | |
| 1894 | yield* fake.takeRequest("prompt"); | |
| 1895 | yield* fake.emit({ type: "agent_start" }); | |
| 1896 | for (const type of ["tool_execution_start", "tool_execution_end"]) { | |
| 1897 | yield* fake.emit({ | |
| 1898 | type, | |
| 1899 | toolCallId: "weather-call", | |
| 1900 | toolName: "mcp__weather__get_weather", | |
| 1901 | args: { city: "Berlin" }, | |
| 1902 | result: { content: [{ type: "text", text: "Sunny" }] }, | |
| 1903 | isError: false, | |
| 1904 | }); | |
| 1905 | const event = yield* takeEvent( | |
| 1906 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 1907 | ); | |
| 1908 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 1909 | return yield* Effect.die("Expected an MCP tool item"); | |
| 1910 | assert.equal(event.turnItem.title, "get weather"); | |
| 1911 | assert.equal( | |
| 1912 | event.turnItem.status, | |
| 1913 | type === "tool_execution_start" ? "running" : "completed", | |
| 1914 | ); | |
| 1915 | assert.deepEqual(event.turnItem.toolSource, { | |
| 1916 | key: "mcp:weather", | |
| 1917 | name: "weather", | |
| 1918 | kind: "integration", | |
| 1919 | }); | |
| 1920 | assert.deepEqual(event.turnItem.input, { city: "Berlin" }); | |
| 1921 | } | |
| 1922 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1923 | ); | |
| 1924 | ||
| 1925 | it.effect("observes official subagent results without inventing child threads", () => | |
| 1926 | Effect.gen(function* () { | |
| 1927 | const fake = yield* makeFakePi; | |
| 1928 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1929 | const providerThread = yield* runtime.ensureThread({ | |
| 1930 | threadId: THREAD_ID, | |
| 1931 | modelSelection: modelSelection("default"), | |
| 1932 | runtimePolicy, | |
| 1933 | }); | |
| 1934 | yield* startTurn(runtime, providerThread); | |
| 1935 | yield* fake.takeRequest("prompt"); | |
| 1936 | yield* fake.emit({ type: "agent_start" }); | |
| 1937 | yield* fake.emit({ | |
| 1938 | type: "tool_execution_update", | |
| 1939 | toolCallId: "call_sub", | |
| 1940 | toolName: "subagent", | |
| 1941 | partialResult: { | |
| 1942 | content: [{ type: "text", text: "(running...)" }], | |
| 1943 | details: { | |
| 1944 | mode: "single", | |
| 1945 | results: [ | |
| 1946 | { | |
| 1947 | agent: "scout", | |
| 1948 | task: "map the repo", | |
| 1949 | exitCode: 0, | |
| 1950 | stderr: "", | |
| 1951 | sessionFile: "/ignored/custom-extension-session.jsonl", | |
| 1952 | messages: [ | |
| 1953 | { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, | |
| 1954 | ], | |
| 1955 | }, | |
| 1956 | ], | |
| 1957 | }, | |
| 1958 | }, | |
| 1959 | }); | |
| 1960 | const running = yield* takeEvent( | |
| 1961 | (event) => event.type === "subagent.updated" && event.subagent.status === "running", | |
| 1962 | ); | |
| 1963 | assert.isTrue( | |
| 1964 | running.type === "subagent.updated" && | |
| 1965 | running.subagent.title === "scout" && | |
| 1966 | running.subagent.prompt === "map the repo" && | |
| 1967 | running.subagent.progress === "scanning files" && | |
| 1968 | running.subagent.childThreadId === null, | |
| 1969 | ); | |
| 1970 | ||
| 1971 | yield* fake.emit({ | |
| 1972 | type: "tool_execution_end", | |
| 1973 | toolCallId: "call_sub", | |
| 1974 | toolName: "subagent", | |
| 1975 | isError: false, | |
| 1976 | result: { | |
| 1977 | content: [{ type: "text", text: "done" }], | |
| 1978 | details: { | |
| 1979 | mode: "single", | |
| 1980 | results: [ | |
| 1981 | { | |
| 1982 | agent: "scout", | |
| 1983 | task: "map the repo", | |
| 1984 | exitCode: 0, | |
| 1985 | stopReason: "stop", | |
| 1986 | stderr: "", | |
| 1987 | messages: [ | |
| 1988 | { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, | |
| 1989 | ], | |
| 1990 | }, | |
| 1991 | ], | |
| 1992 | }, | |
| 1993 | }, | |
| 1994 | }); | |
| 1995 | const doneCard = yield* takeEvent( | |
| 1996 | (event) => event.type === "subagent.updated" && event.subagent.status === "completed", | |
| 1997 | ); | |
| 1998 | assert.isTrue( | |
| 1999 | doneCard.type === "subagent.updated" && | |
| 2000 | doneCard.subagent.result === "repo has one file" && | |
| 2001 | doneCard.subagent.childThreadId === null, | |
| 2002 | ); | |
| 2003 | const subagentItem = yield* takeEvent( | |
| 2004 | (event) => | |
| 2005 | event.type === "turn_item.updated" && | |
| 2006 | event.turnItem.type === "subagent" && | |
| 2007 | event.turnItem.status === "completed", | |
| 2008 | ); | |
| 2009 | assert.isTrue( | |
| 2010 | subagentItem.type === "turn_item.updated" && | |
| 2011 | subagentItem.turnItem.type === "subagent" && | |
| 2012 | subagentItem.turnItem.childThreadId === null, | |
| 2013 | ); | |
| 2014 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2015 | ); | |
| 2016 | ||
| 2017 | it.effect("persists edit patches and write content on file change items", () => | |
| 2018 | Effect.gen(function* () { | |
| 2019 | const fake = yield* makeFakePi; | |
| 2020 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2021 | const providerThread = yield* runtime.ensureThread({ | |
| 2022 | threadId: THREAD_ID, | |
| 2023 | modelSelection: modelSelection("default"), | |
| 2024 | runtimePolicy, | |
| 2025 | }); | |
| 2026 | yield* startTurn(runtime, providerThread); | |
| 2027 | yield* fake.takeRequest("prompt"); | |
| 2028 | yield* fake.emit({ type: "agent_start" }); | |
| 2029 | const patch = "--- a.ts\n+++ a.ts\n@@ -1 +1 @@\n-old\n+new\n"; | |
| 2030 | yield* fake.emit({ | |
| 2031 | type: "tool_execution_start", | |
| 2032 | toolCallId: "call_edit", | |
| 2033 | toolName: "edit", | |
| 2034 | args: { path: "a.ts", edits: [{ oldText: "old", newText: "new" }] }, | |
| 2035 | }); | |
| 2036 | yield* fake.emit({ | |
| 2037 | type: "tool_execution_end", | |
| 2038 | toolCallId: "call_edit", | |
| 2039 | toolName: "edit", | |
| 2040 | isError: false, | |
| 2041 | result: { | |
| 2042 | content: [{ type: "text", text: "Successfully replaced 1 block(s) in a.ts." }], | |
| 2043 | details: { diff: "-1 old\n+1 new", patch, firstChangedLine: 1 }, | |
| 2044 | }, | |
| 2045 | }); | |
| 2046 | const edit = yield* takeEvent( | |
| 2047 | (event) => | |
| 2048 | event.type === "turn_item.updated" && | |
| 2049 | event.turnItem.type === "file_change" && | |
| 2050 | event.turnItem.status === "completed", | |
| 2051 | ); | |
| 2052 | assert.isTrue( | |
| 2053 | edit.type === "turn_item.updated" && | |
| 2054 | edit.turnItem.type === "file_change" && | |
| 2055 | edit.turnItem.fileName === "a.ts" && | |
| 2056 | edit.turnItem.diffStr === patch, | |
| 2057 | ); | |
| 2058 | ||
| 2059 | yield* fake.emit({ | |
| 2060 | type: "tool_execution_start", | |
| 2061 | toolCallId: "call_write", | |
| 2062 | toolName: "write", | |
| 2063 | args: { path: "b.ts", content: "export {};\n" }, | |
| 2064 | }); | |
| 2065 | yield* fake.emit({ | |
| 2066 | type: "tool_execution_end", | |
| 2067 | toolCallId: "call_write", | |
| 2068 | toolName: "write", | |
| 2069 | isError: false, | |
| 2070 | result: { content: [{ type: "text", text: "Successfully wrote to b.ts" }] }, | |
| 2071 | }); | |
| 2072 | const write = yield* takeEvent( | |
| 2073 | (event) => | |
| 2074 | event.type === "turn_item.updated" && | |
| 2075 | event.turnItem.type === "file_change" && | |
| 2076 | event.turnItem.status === "completed", | |
| 2077 | ); | |
| 2078 | assert.isTrue( | |
| 2079 | write.type === "turn_item.updated" && | |
| 2080 | write.turnItem.type === "file_change" && | |
| 2081 | write.turnItem.fileName === "b.ts" && | |
| 2082 | write.turnItem.newStr === "export {};\n", | |
| 2083 | ); | |
| 2084 | ||
| 2085 | // A failed edit has no patch, so it keeps the error to show when expanded. | |
| 2086 | yield* fake.emit({ | |
| 2087 | type: "tool_execution_start", | |
| 2088 | toolCallId: "call_edit_failed", | |
| 2089 | toolName: "edit", | |
| 2090 | args: { path: "c.ts", edits: [{ oldText: "missing", newText: "new" }] }, | |
| 2091 | }); | |
| 2092 | yield* fake.emit({ | |
| 2093 | type: "tool_execution_end", | |
| 2094 | toolCallId: "call_edit_failed", | |
| 2095 | toolName: "edit", | |
| 2096 | isError: true, | |
| 2097 | result: { content: [{ type: "text", text: "Could not find the text in c.ts." }] }, | |
| 2098 | }); | |
| 2099 | const failedEdit = yield* takeEvent( | |
| 2100 | (event) => | |
| 2101 | event.type === "turn_item.updated" && | |
| 2102 | event.turnItem.type === "file_change" && | |
| 2103 | event.turnItem.status === "failed", | |
| 2104 | ); | |
| 2105 | assert.isTrue( | |
| 2106 | failedEdit.type === "turn_item.updated" && | |
| 2107 | failedEdit.turnItem.type === "file_change" && | |
| 2108 | failedEdit.turnItem.diffStr === "Could not find the text in c.ts.", | |
| 2109 | ); | |
| 2110 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2111 | ); | |
| 2112 | ||
| 2113 | it.effect("settles a command-only prompt from its deferred ack and idle probe", () => | |
| 2114 | Effect.gen(function* () { | |
| 2115 | const fake = yield* makeFakePi; | |
| 2116 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2117 | const providerThread = yield* runtime.ensureThread({ | |
| 2118 | threadId: THREAD_ID, | |
| 2119 | modelSelection: modelSelection("default"), | |
| 2120 | runtimePolicy, | |
| 2121 | }); | |
| 2122 | yield* startTurn(runtime, providerThread, "default", [], "/command-only"); | |
| 2123 | yield* fake.takeRequest("prompt"); | |
| 2124 | // A pure extension command: dialog + notify, then the deferred ack — | |
| 2125 | // pi emits no agent_start/agent_settled at all. | |
| 2126 | yield* fake.emit({ | |
| 2127 | type: "extension_ui_request", | |
| 2128 | id: "ui-cmd", | |
| 2129 | method: "notify", | |
| 2130 | message: "done", | |
| 2131 | notifyType: "info", | |
| 2132 | }); | |
| 2133 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 2134 | // The adapter probes get_state (auto-acked idle by the fake), then | |
| 2135 | // settles the turn as completed. | |
| 2136 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2137 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2138 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2139 | ); | |
| 2140 | ||
| 2141 | it.effect("leaves /compacted as an ordinary prompt", () => | |
| 2142 | Effect.gen(function* () { | |
| 2143 | const fake = yield* makeFakePi; | |
| 2144 | const { runtime } = yield* openRuntime(fake); | |
| 2145 | const providerThread = yield* runtime.ensureThread({ | |
| 2146 | threadId: THREAD_ID, | |
| 2147 | modelSelection: modelSelection("default"), | |
| 2148 | runtimePolicy, | |
| 2149 | }); | |
| 2150 | yield* startTurn(runtime, providerThread, "default", [], "/compacted please"); | |
| 2151 | const prompt = yield* fake.takeRequest("prompt"); | |
| 2152 | assert.equal(prompt["message"], "/compacted please"); | |
| 2153 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "compact")); | |
| 2154 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2155 | ); | |
| 2156 | ||
| 2157 | it.effect("fails a compact that never started", () => | |
| 2158 | Effect.gen(function* () { | |
| 2159 | const fake = yield* makeFakePi; | |
| 2160 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2161 | const providerThread = yield* runtime.ensureThread({ | |
| 2162 | threadId: THREAD_ID, | |
| 2163 | modelSelection: modelSelection("default"), | |
| 2164 | runtimePolicy, | |
| 2165 | }); | |
| 2166 | yield* startTurn(runtime, providerThread, "default", [], "/compact"); | |
| 2167 | yield* fake.takeRequest("compact"); | |
| 2168 | yield* fake.emit({ | |
| 2169 | type: "response", | |
| 2170 | command: "compact", | |
| 2171 | success: false, | |
| 2172 | error: "Nothing to compact (session too small)", | |
| 2173 | }); | |
| 2174 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2175 | assert.isTrue( | |
| 2176 | terminal.type === "turn.terminal" && | |
| 2177 | terminal.status === "failed" && | |
| 2178 | terminal.failure.message === "Nothing to compact (session too small)", | |
| 2179 | ); | |
| 2180 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2181 | ); | |
| 2182 | ||
| 2183 | it.effect("restarts Pi when Stop interrupts a user compact", () => | |
| 2184 | Effect.gen(function* () { | |
| 2185 | const fake = yield* makeFakePi; | |
| 2186 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2187 | const providerThread = yield* runtime.ensureThread({ | |
| 2188 | threadId: THREAD_ID, | |
| 2189 | modelSelection: modelSelection("default"), | |
| 2190 | runtimePolicy, | |
| 2191 | }); | |
| 2192 | yield* startTurn(runtime, providerThread, "default", [], "/compact"); | |
| 2193 | yield* fake.takeRequest("compact"); | |
| 2194 | const running = yield* takeEvent( | |
| 2195 | (event) => | |
| 2196 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 2197 | ); | |
| 2198 | const providerTurnId = | |
| 2199 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 2200 | assert.isDefined(providerTurnId); | |
| 2201 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2202 | yield* takeEvent( | |
| 2203 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2204 | ); | |
| 2205 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 2206 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); | |
| 2207 | yield* fake.closeStdout; | |
| 2208 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2209 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 2210 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2211 | ); | |
| 2212 | ||
| 2213 | it.effect("steers /compact as RPC compact instead of a prompt", () => | |
| 2214 | Effect.gen(function* () { | |
| 2215 | const fake = yield* makeFakePi; | |
| 2216 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2217 | const providerThread = yield* runtime.ensureThread({ | |
| 2218 | threadId: THREAD_ID, | |
| 2219 | modelSelection: modelSelection("default"), | |
| 2220 | runtimePolicy, | |
| 2221 | }); | |
| 2222 | yield* startTurn(runtime, providerThread); | |
| 2223 | yield* fake.takeRequest("prompt"); | |
| 2224 | const running = yield* takeEvent( | |
| 2225 | (event) => | |
| 2226 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 2227 | ); | |
| 2228 | const providerTurnId = | |
| 2229 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 2230 | yield* fake.emit({ type: "agent_start" }); | |
| 2231 | yield* runtime.steerTurn({ | |
| 2232 | threadId: THREAD_ID, | |
| 2233 | runId: RunId.make("run:thread-pi-test:1"), | |
| 2234 | providerThread, | |
| 2235 | providerTurnId: providerTurnId!, | |
| 2236 | message: { | |
| 2237 | messageId: "message:thread-pi-test:steer-compact" as never, | |
| 2238 | text: "/compact keep the tests", | |
| 2239 | attachments: [], | |
| 2240 | createdBy: "user", | |
| 2241 | creationSource: "web", | |
| 2242 | }, | |
| 2243 | }); | |
| 2244 | const compact = yield* fake.takeRequest("compact"); | |
| 2245 | assert.equal(compact["customInstructions"], "keep the tests"); | |
| 2246 | assert.isUndefined(compact["streamingBehavior"]); | |
| 2247 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2248 | yield* takeEvent( | |
| 2249 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2250 | ); | |
| 2251 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 2252 | yield* fake.emit({ | |
| 2253 | type: "compaction_end", | |
| 2254 | reason: "manual", | |
| 2255 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 2256 | aborted: false, | |
| 2257 | willRetry: false, | |
| 2258 | }); | |
| 2259 | yield* takeEvent( | |
| 2260 | (event) => | |
| 2261 | event.type === "turn_item.updated" && | |
| 2262 | event.turnItem.type === "compaction" && | |
| 2263 | event.turnItem.status === "completed", | |
| 2264 | ); | |
| 2265 | yield* fake.emit({ type: "response", command: "compact", success: true }); | |
| 2266 | yield* fake.emit({ type: "agent_settled" }); | |
| 2267 | yield* fake.takeRequest("get_state"); | |
| 2268 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2269 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2270 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2271 | ); | |
| 2272 | ||
| 2273 | it.effect("persists current xAI capacity text for the thread error banner", () => | |
| 2274 | expectModelFailure("The model is currently at capacity due to high demand."), | |
| 2275 | ); | |
| 2276 | ||
| 2277 | it.effect("persists extension-normalized xAI capacity text for the thread error banner", () => | |
| 2278 | expectModelFailure( | |
| 2279 | "Provider overloaded: The model is currently at capacity due to high demand.", | |
| 2280 | ), | |
| 2281 | ); | |
| 2282 | ||
| 2283 | it.effect("emits session-start dialogs before a turn exists", () => | |
| 2284 | Effect.gen(function* () { | |
| 2285 | const fake = yield* makeFakePi; | |
| 2286 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2287 | yield* runtime.ensureThread({ | |
| 2288 | threadId: THREAD_ID, | |
| 2289 | modelSelection: modelSelection("default"), | |
| 2290 | runtimePolicy, | |
| 2291 | }); | |
| 2292 | // Project-trust style prompt before any turn exists. | |
| 2293 | yield* fake.emit({ | |
| 2294 | type: "extension_ui_request", | |
| 2295 | id: "ui-trust", | |
| 2296 | method: "confirm", | |
| 2297 | title: "Run project extensions?", | |
| 2298 | message: "This project has .pi/extensions.", | |
| 2299 | }); | |
| 2300 | const pending = yield* takeEvent( | |
| 2301 | (event) => | |
| 2302 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2303 | ); | |
| 2304 | const requestId = | |
| 2305 | pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; | |
| 2306 | yield* runtime.respondToRuntimeRequest({ requestId: requestId!, decision: "accept" }); | |
| 2307 | const uiResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2308 | assert.equal(uiResponse["id"], "ui-trust"); | |
| 2309 | assert.equal(uiResponse["confirmed"], true); | |
| 2310 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2311 | ); | |
| 2312 | ||
| 2313 | it.effect("remembers session approvals only for identical confirmation content", () => | |
| 2314 | Effect.gen(function* () { | |
| 2315 | const fake = yield* makeFakePi; | |
| 2316 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2317 | yield* runtime.ensureThread({ | |
| 2318 | threadId: THREAD_ID, | |
| 2319 | modelSelection: modelSelection("default"), | |
| 2320 | runtimePolicy, | |
| 2321 | }); | |
| 2322 | // Project-trust style prompt before any turn exists. | |
| 2323 | yield* fake.emit({ | |
| 2324 | type: "extension_ui_request", | |
| 2325 | id: "ui-trust", | |
| 2326 | method: "confirm", | |
| 2327 | title: "Run project extensions?", | |
| 2328 | message: "This project has .pi/extensions.", | |
| 2329 | }); | |
| 2330 | const pending = yield* takeEvent( | |
| 2331 | (event) => | |
| 2332 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2333 | ); | |
| 2334 | const requestId = | |
| 2335 | pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; | |
| 2336 | yield* runtime.respondToRuntimeRequest({ | |
| 2337 | requestId: requestId!, | |
| 2338 | decision: "acceptForSession", | |
| 2339 | }); | |
| 2340 | const uiResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2341 | assert.equal(uiResponse["id"], "ui-trust"); | |
| 2342 | assert.equal(uiResponse["confirmed"], true); | |
| 2343 | yield* fake.emit({ | |
| 2344 | type: "extension_ui_request", | |
| 2345 | id: "ui-trust-again", | |
| 2346 | method: "confirm", | |
| 2347 | title: "Run project extensions?", | |
| 2348 | message: "This project has .pi/extensions.", | |
| 2349 | }); | |
| 2350 | assert.equal((yield* fake.takeRequest("extension_ui_response"))["id"], "ui-trust-again"); | |
| 2351 | yield* fake.emit({ | |
| 2352 | type: "extension_ui_request", | |
| 2353 | id: "ui-other", | |
| 2354 | method: "confirm", | |
| 2355 | title: "Run project extensions?", | |
| 2356 | message: "A different project.", | |
| 2357 | }); | |
| 2358 | const other = yield* takeEvent( | |
| 2359 | (event) => | |
| 2360 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2361 | ); | |
| 2362 | assert.isTrue( | |
| 2363 | other.type === "runtime_request.updated" && | |
| 2364 | other.runtimeRequest.nativeRequestRef?.nativeId === "ui-other", | |
| 2365 | ); | |
| 2366 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2367 | ); | |
| 2368 | ||
| 2369 | it.effect("offers an explicit empty value for extension input dialogs", () => | |
| 2370 | Effect.gen(function* () { | |
| 2371 | const fake = yield* makeFakePi; | |
| 2372 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2373 | yield* runtime.ensureThread({ | |
| 2374 | threadId: THREAD_ID, | |
| 2375 | modelSelection: modelSelection("default"), | |
| 2376 | runtimePolicy, | |
| 2377 | }); | |
| 2378 | yield* fake.emit({ | |
| 2379 | type: "extension_ui_request", | |
| 2380 | id: "ui-input", | |
| 2381 | method: "input", | |
| 2382 | title: "Optional value", | |
| 2383 | }); | |
| 2384 | const event = yield* takeEvent( | |
| 2385 | (event) => | |
| 2386 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2387 | ); | |
| 2388 | assert.isTrue( | |
| 2389 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2390 | ); | |
| 2391 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") | |
| 2392 | return; | |
| 2393 | assert.equal(event.turnItem.questions[0]?.options[0]?.value, ""); | |
| 2394 | yield* runtime.respondToRuntimeRequest({ | |
| 2395 | requestId: event.turnItem.requestId, | |
| 2396 | answers: { "ui-input": "" }, | |
| 2397 | }); | |
| 2398 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 2399 | assert.equal(response["value"], ""); | |
| 2400 | assert.isUndefined(response["cancelled"]); | |
| 2401 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2402 | ); | |
| 2403 | ||
| 2404 | it.effect("keeps the complete editor prefill editable and returns exact edited text", () => | |
| 2405 | Effect.gen(function* () { | |
| 2406 | const fake = yield* makeFakePi; | |
| 2407 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2408 | yield* runtime.ensureThread({ | |
| 2409 | threadId: THREAD_ID, | |
| 2410 | modelSelection: modelSelection("default"), | |
| 2411 | runtimePolicy, | |
| 2412 | }); | |
| 2413 | const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `; | |
| 2414 | yield* fake.emit({ | |
| 2415 | type: "extension_ui_request", | |
| 2416 | id: "ui-editor", | |
| 2417 | method: "editor", | |
| 2418 | title: "Commit message", | |
| 2419 | prefill, | |
| 2420 | }); | |
| 2421 | const event = yield* takeEvent( | |
| 2422 | (event) => | |
| 2423 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2424 | ); | |
| 2425 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") | |
| 2426 | return; | |
| 2427 | assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); | |
| 2428 | assert.deepEqual(event.turnItem.questions[0]?.options, []); | |
| 2429 | assert.equal(event.turnItem.questions[0]?.question, "Commit message"); | |
| 2430 | const edited = " fix(server): edited commit message\n\n"; | |
| 2431 | yield* runtime.respondToRuntimeRequest({ | |
| 2432 | requestId: event.turnItem.requestId, | |
| 2433 | answers: { "ui-editor": edited }, | |
| 2434 | }); | |
| 2435 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 2436 | assert.equal(response["value"], edited); | |
| 2437 | assert.isUndefined(response["cancelled"]); | |
| 2438 | yield* fake.emit({ | |
| 2439 | type: "extension_ui_request", | |
| 2440 | id: "ui-editor-cleared", | |
| 2441 | method: "editor", | |
| 2442 | title: "Commit message", | |
| 2443 | prefill, | |
| 2444 | }); | |
| 2445 | const cleared = yield* takeEvent( | |
| 2446 | (event) => | |
| 2447 | event.type === "turn_item.updated" && | |
| 2448 | event.turnItem.type === "user_input_request" && | |
| 2449 | event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"), | |
| 2450 | ); | |
| 2451 | if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request") | |
| 2452 | return; | |
| 2453 | yield* runtime.respondToRuntimeRequest({ | |
| 2454 | requestId: cleared.turnItem.requestId, | |
| 2455 | answers: { "ui-editor-cleared": "" }, | |
| 2456 | }); | |
| 2457 | const emptyResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2458 | assert.equal(emptyResponse["value"], ""); | |
| 2459 | assert.isUndefined(emptyResponse["cancelled"]); | |
| 2460 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2461 | ); | |
| 2462 | ||
| 2463 | it.effect("raises bridge edit confirmations as file-change approvals", () => | |
| 2464 | Effect.gen(function* () { | |
| 2465 | const fake = yield* makeFakePi; | |
| 2466 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2467 | const providerThread = yield* runtime.ensureThread({ | |
| 2468 | threadId: THREAD_ID, | |
| 2469 | modelSelection: modelSelection("default"), | |
| 2470 | runtimePolicy, | |
| 2471 | }); | |
| 2472 | yield* startTurn(runtime, providerThread); | |
| 2473 | yield* fake.takeRequest("prompt"); | |
| 2474 | for (const [id, title, requestKind] of [ | |
| 2475 | ["ui-edit", "Allow edit?", "file-change"], | |
| 2476 | ["ui-bash", "Allow bash?", "command"], | |
| 2477 | ["ui-ext", "Deploy to staging?", "command"], | |
| 2478 | ] as const) { | |
| 2479 | yield* fake.emit({ type: "extension_ui_request", id, method: "confirm", title }); | |
| 2480 | const item = yield* takeEvent( | |
| 2481 | (event) => | |
| 2482 | event.type === "turn_item.updated" && event.turnItem.type === "approval_request", | |
| 2483 | ); | |
| 2484 | assert.isTrue( | |
| 2485 | item.type === "turn_item.updated" && | |
| 2486 | item.turnItem.type === "approval_request" && | |
| 2487 | item.turnItem.requestKind === requestKind, | |
| 2488 | `${title} should be ${requestKind}`, | |
| 2489 | ); | |
| 2490 | } | |
| 2491 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2492 | ); | |
| 2493 | ||
| 2494 | it.effect("reads a thread snapshot from pi's active branch", () => | |
| 2495 | Effect.gen(function* () { | |
| 2496 | const fake = yield* makeFakePi; | |
| 2497 | const { runtime } = yield* openRuntime(fake); | |
| 2498 | const providerThread = yield* runtime.ensureThread({ | |
| 2499 | threadId: THREAD_ID, | |
| 2500 | modelSelection: modelSelection("default"), | |
| 2501 | runtimePolicy, | |
| 2502 | }); | |
| 2503 | fake.queueMessages({ | |
| 2504 | messages: [ | |
| 2505 | { | |
| 2506 | role: "user", | |
| 2507 | content: "hello pi", | |
| 2508 | timestamp: 1700000000000, | |
| 2509 | }, | |
| 2510 | { | |
| 2511 | role: "assistant", | |
| 2512 | content: [{ type: "text", text: "hello back" }], | |
| 2513 | timestamp: 1700000001000, | |
| 2514 | }, | |
| 2515 | { role: "toolResult", content: [] }, | |
| 2516 | ], | |
| 2517 | }); | |
| 2518 | const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 2519 | assert.equal(snapshot.messages.length, 2); | |
| 2520 | assert.equal(snapshot.messages[0]!.role, "user"); | |
| 2521 | assert.equal(snapshot.messages[0]!.text, "hello pi"); | |
| 2522 | assert.equal(snapshot.messages[1]!.role, "assistant"); | |
| 2523 | assert.equal(snapshot.messages[1]!.text, "hello back"); | |
| 2524 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2525 | ); | |
| 2526 | ||
| 2527 | it.effect("keeps snapshot message identities distinct across native sessions", () => | |
| 2528 | Effect.gen(function* () { | |
| 2529 | const fake = yield* makeFakePi; | |
| 2530 | const { runtime } = yield* openRuntime(fake); | |
| 2531 | const first = yield* runtime.ensureThread({ | |
| 2532 | threadId: THREAD_ID, | |
| 2533 | modelSelection: modelSelection("default"), | |
| 2534 | runtimePolicy, | |
| 2535 | }); | |
| 2536 | const messages = { | |
| 2537 | messages: [{ role: "user", content: "same text", timestamp: 1700000000000 }], | |
| 2538 | }; | |
| 2539 | fake.queueMessages(messages); | |
| 2540 | const a = yield* runtime.readThreadSnapshot({ providerThread: first }); | |
| 2541 | fake.queueState({ sessionFile: "/fake/another-session.jsonl" }); | |
| 2542 | const second = yield* runtime.ensureThread({ | |
| 2543 | threadId: ThreadId.make("second-thread"), | |
| 2544 | modelSelection: modelSelection("default"), | |
| 2545 | runtimePolicy, | |
| 2546 | existingProviderThread: { | |
| 2547 | ...first, | |
| 2548 | nativeThreadRef: { | |
| 2549 | driver: PI_PROVIDER, | |
| 2550 | nativeId: "/fake/another-session.jsonl", | |
| 2551 | strength: "strong", | |
| 2552 | }, | |
| 2553 | }, | |
| 2554 | }); | |
| 2555 | fake.queueMessages(messages); | |
| 2556 | const b = yield* runtime.readThreadSnapshot({ providerThread: second }); | |
| 2557 | assert.notEqual(a.messages[0]!.id, b.messages[0]!.id); | |
| 2558 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2559 | ); | |
| 2560 | ||
| 2561 | it.effect("rejects a nonpersistent session UUID instead of treating it as a resumable path", () => | |
| 2562 | Effect.gen(function* () { | |
| 2563 | const fake = yield* makeFakePi; | |
| 2564 | const { runtime } = yield* openRuntime(fake); | |
| 2565 | // A --no-session Pi keeps its session in memory and reports no file. | |
| 2566 | fake.queueState({ sessionFile: undefined }); | |
| 2567 | const result = yield* runtime | |
| 2568 | .ensureThread({ | |
| 2569 | threadId: THREAD_ID, | |
| 2570 | modelSelection: modelSelection("default"), | |
| 2571 | runtimePolicy, | |
| 2572 | }) | |
| 2573 | .pipe(Effect.result); | |
| 2574 | assert.equal(result._tag, "Failure"); | |
| 2575 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2576 | ); | |
| 2577 | ||
| 2578 | it.effect("keeps a settled turn's late prompt rejection off the next turn", () => | |
| 2579 | Effect.gen(function* () { | |
| 2580 | const fake = yield* makeFakePi; | |
| 2581 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2582 | const providerThread = yield* runtime.ensureThread({ | |
| 2583 | threadId: THREAD_ID, | |
| 2584 | modelSelection: modelSelection("default"), | |
| 2585 | runtimePolicy, | |
| 2586 | }); | |
| 2587 | // An extension command can hold its prompt ack open past settlement. | |
| 2588 | yield* startTurn(runtime, providerThread, "default", [], "/my-command"); | |
| 2589 | yield* fake.takeRequest("prompt"); | |
| 2590 | yield* fake.emit({ type: "agent_start" }); | |
| 2591 | yield* fake.emit({ type: "agent_settled" }); | |
| 2592 | const firstTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2593 | assert.isTrue(firstTerminal.type === "turn.terminal" && firstTerminal.status === "completed"); | |
| 2594 | ||
| 2595 | yield* startTurn(runtime, providerThread, "default", [], "Second turn", undefined, 2); | |
| 2596 | yield* fake.takeRequest("prompt"); | |
| 2597 | // The rejection answers the first turn's prompt. It must not consume or | |
| 2598 | // fail the second turn's prompt acknowledgement. | |
| 2599 | yield* fake.emit({ | |
| 2600 | type: "response", | |
| 2601 | command: "prompt", | |
| 2602 | success: false, | |
| 2603 | error: "late command rejection", | |
| 2604 | }); | |
| 2605 | yield* fake.emit({ type: "agent_start" }); | |
| 2606 | yield* fake.emit({ type: "agent_settled" }); | |
| 2607 | const secondTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2608 | assert.isTrue( | |
| 2609 | secondTerminal.type === "turn.terminal" && secondTerminal.status === "completed", | |
| 2610 | ); | |
| 2611 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2612 | ); | |
| 2613 | ||
| 2614 | it.effect("shows compaction progress and completes the same activity row", () => | |
| 2615 | Effect.gen(function* () { | |
| 2616 | const fake = yield* makeFakePi; | |
| 2617 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2618 | const providerThread = yield* runtime.ensureThread({ | |
| 2619 | threadId: THREAD_ID, | |
| 2620 | modelSelection: modelSelection("default"), | |
| 2621 | runtimePolicy, | |
| 2622 | }); | |
| 2623 | yield* startTurn(runtime, providerThread); | |
| 2624 | yield* fake.takeRequest("prompt"); | |
| 2625 | yield* fake.emit({ type: "agent_start" }); | |
| 2626 | yield* fake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2627 | ||
| 2628 | const runningNode = yield* takeEvent( | |
| 2629 | (event) => event.type === "node.updated" && event.node.kind === "system", | |
| 2630 | ); | |
| 2631 | const runningItem = yield* takeEvent( | |
| 2632 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2633 | ); | |
| 2634 | assert.isTrue( | |
| 2635 | runningNode.type === "node.updated" && | |
| 2636 | runningNode.node.status === "running" && | |
| 2637 | runningItem.type === "turn_item.updated" && | |
| 2638 | runningItem.turnItem.type === "compaction" && | |
| 2639 | runningItem.turnItem.status === "running" && | |
| 2640 | runningItem.turnItem.title === "Compacting context...", | |
| 2641 | ); | |
| 2642 | ||
| 2643 | yield* fake.emit({ | |
| 2644 | type: "compaction_end", | |
| 2645 | reason: "threshold", | |
| 2646 | result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, | |
| 2647 | aborted: false, | |
| 2648 | willRetry: false, | |
| 2649 | }); | |
| 2650 | const completedNode = yield* takeEvent( | |
| 2651 | (event) => event.type === "node.updated" && event.node.kind === "system", | |
| 2652 | ); | |
| 2653 | const completedItem = yield* takeEvent( | |
| 2654 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2655 | ); | |
| 2656 | assert.isTrue( | |
| 2657 | runningNode.type === "node.updated" && | |
| 2658 | completedNode.type === "node.updated" && | |
| 2659 | runningItem.type === "turn_item.updated" && | |
| 2660 | runningItem.turnItem.type === "compaction" && | |
| 2661 | completedItem.type === "turn_item.updated" && | |
| 2662 | completedItem.turnItem.type === "compaction" && | |
| 2663 | completedNode.node.id === runningNode.node.id && | |
| 2664 | completedNode.node.status === "completed" && | |
| 2665 | completedItem.turnItem.id === runningItem.turnItem.id && | |
| 2666 | completedItem.turnItem.ordinal === runningItem.turnItem.ordinal && | |
| 2667 | completedItem.turnItem.startedAt === runningItem.turnItem.startedAt && | |
| 2668 | completedItem.turnItem.status === "completed" && | |
| 2669 | completedItem.turnItem.title === "Context compacted" && | |
| 2670 | completedItem.turnItem.beforeTokenCount === 200_000 && | |
| 2671 | completedItem.turnItem.afterTokenCount === 3_400, | |
| 2672 | ); | |
| 2673 | ||
| 2674 | yield* fake.emit({ type: "agent_settled" }); | |
| 2675 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2676 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2677 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2678 | ); | |
| 2679 | ||
| 2680 | it.effect("uses distinct compaction IDs for first turns in separate threads", () => | |
| 2681 | Effect.gen(function* () { | |
| 2682 | const firstFake = yield* makeFakePi; | |
| 2683 | const { runtime: firstRuntime, takeEvent: takeFirstEvent } = yield* openRuntime(firstFake); | |
| 2684 | const firstProviderThread = yield* firstRuntime.ensureThread({ | |
| 2685 | threadId: THREAD_ID, | |
| 2686 | modelSelection: modelSelection("default"), | |
| 2687 | runtimePolicy, | |
| 2688 | }); | |
| 2689 | yield* startTurn(firstRuntime, firstProviderThread); | |
| 2690 | yield* firstFake.takeRequest("prompt"); | |
| 2691 | yield* firstFake.emit({ type: "agent_start" }); | |
| 2692 | yield* firstFake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2693 | const first = yield* takeFirstEvent( | |
| 2694 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2695 | ); | |
| 2696 | ||
| 2697 | const secondThreadId = ThreadId.make("thread-pi-test-second"); | |
| 2698 | const secondFake = yield* makeFakePi; | |
| 2699 | const { runtime: secondRuntime, takeEvent: takeSecondEvent } = yield* openRuntime( | |
| 2700 | secondFake, | |
| 2701 | "default", | |
| 2702 | secondThreadId, | |
| 2703 | ProviderSessionId.make("provider-session-pi-test-second"), | |
| 2704 | ); | |
| 2705 | const secondProviderThread = yield* secondRuntime.ensureThread({ | |
| 2706 | threadId: secondThreadId, | |
| 2707 | modelSelection: modelSelection("default"), | |
| 2708 | runtimePolicy, | |
| 2709 | }); | |
| 2710 | yield* startTurn( | |
| 2711 | secondRuntime, | |
| 2712 | secondProviderThread, | |
| 2713 | "default", | |
| 2714 | [], | |
| 2715 | "Hello from another thread", | |
| 2716 | undefined, | |
| 2717 | 1, | |
| 2718 | secondThreadId, | |
| 2719 | ); | |
| 2720 | yield* secondFake.takeRequest("prompt"); | |
| 2721 | yield* secondFake.emit({ type: "agent_start" }); | |
| 2722 | yield* secondFake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2723 | const second = yield* takeSecondEvent( | |
| 2724 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2725 | ); | |
| 2726 | ||
| 2727 | assert.isTrue( | |
| 2728 | first.type === "turn_item.updated" && | |
| 2729 | first.turnItem.type === "compaction" && | |
| 2730 | second.type === "turn_item.updated" && | |
| 2731 | second.turnItem.type === "compaction" && | |
| 2732 | first.turnItem.ordinal === second.turnItem.ordinal && | |
| 2733 | first.turnItem.id !== second.turnItem.id, | |
| 2734 | ); | |
| 2735 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2736 | ); | |
| 2737 | ||
| 2738 | it.effect("shows aborted compactions as stopped", () => | |
| 2739 | Effect.gen(function* () { | |
| 2740 | const fake = yield* makeFakePi; | |
| 2741 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2742 | const providerThread = yield* runtime.ensureThread({ | |
| 2743 | threadId: THREAD_ID, | |
| 2744 | modelSelection: modelSelection("default"), | |
| 2745 | runtimePolicy, | |
| 2746 | }); | |
| 2747 | yield* startTurn(runtime, providerThread); | |
| 2748 | yield* fake.takeRequest("prompt"); | |
| 2749 | yield* fake.emit({ type: "agent_start" }); | |
| 2750 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2751 | const running = yield* takeEvent( | |
| 2752 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2753 | ); | |
| 2754 | yield* fake.emit({ | |
| 2755 | type: "compaction_end", | |
| 2756 | reason: "manual", | |
| 2757 | aborted: true, | |
| 2758 | willRetry: false, | |
| 2759 | }); | |
| 2760 | const stopped = yield* takeEvent( | |
| 2761 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2762 | ); | |
| 2763 | assert.isTrue( | |
| 2764 | running.type === "turn_item.updated" && | |
| 2765 | running.turnItem.type === "compaction" && | |
| 2766 | stopped.type === "turn_item.updated" && | |
| 2767 | stopped.turnItem.type === "compaction" && | |
| 2768 | stopped.turnItem.id === running.turnItem.id && | |
| 2769 | stopped.turnItem.status === "cancelled" && | |
| 2770 | stopped.turnItem.title === "Context compaction stopped", | |
| 2771 | ); | |
| 2772 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2773 | ); | |
| 2774 | ||
| 2775 | it.effect("keeps the turn open and updates one retry row through final failure", () => | |
| 2776 | Effect.gen(function* () { | |
| 2777 | const fake = yield* makeFakePi; | |
| 2778 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2779 | const providerThread = yield* runtime.ensureThread({ | |
| 2780 | threadId: THREAD_ID, | |
| 2781 | modelSelection: modelSelection("default"), | |
| 2782 | runtimePolicy, | |
| 2783 | }); | |
| 2784 | yield* startTurn(runtime, providerThread); | |
| 2785 | yield* fake.takeRequest("prompt"); | |
| 2786 | ||
| 2787 | yield* fake.emit({ type: "agent_start" }); | |
| 2788 | yield* fake.emit({ type: "agent_end", messages: [], willRetry: true }); | |
| 2789 | yield* fake.emit({ | |
| 2790 | type: "auto_retry_start", | |
| 2791 | attempt: 1, | |
| 2792 | maxAttempts: 3, | |
| 2793 | delayMs: 3_000, | |
| 2794 | errorMessage: "529 overloaded", | |
| 2795 | }); | |
| 2796 | const firstRetry = yield* takeEvent( | |
| 2797 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2798 | ); | |
| 2799 | assert.isTrue( | |
| 2800 | firstRetry.type === "turn_item.updated" && | |
| 2801 | firstRetry.turnItem.type === "error" && | |
| 2802 | firstRetry.turnItem.status === "running" && | |
| 2803 | firstRetry.turnItem.title === "Provider retry" && | |
| 2804 | firstRetry.turnItem.failure.retryable === true && | |
| 2805 | firstRetry.turnItem.retry?.attempt === 1 && | |
| 2806 | firstRetry.turnItem.retry.maxAttempts === 3 && | |
| 2807 | firstRetry.turnItem.retry.retryDelayMs === 3_000, | |
| 2808 | ); | |
| 2809 | ||
| 2810 | yield* fake.emit({ | |
| 2811 | type: "auto_retry_start", | |
| 2812 | attempt: 3, | |
| 2813 | maxAttempts: 3, | |
| 2814 | delayMs: 12_000, | |
| 2815 | errorMessage: "529 still overloaded", | |
| 2816 | }); | |
| 2817 | const lastRetry = yield* takeEvent( | |
| 2818 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2819 | ); | |
| 2820 | assert.isTrue( | |
| 2821 | firstRetry.type === "turn_item.updated" && | |
| 2822 | firstRetry.turnItem.type === "error" && | |
| 2823 | lastRetry.type === "turn_item.updated" && | |
| 2824 | lastRetry.turnItem.type === "error" && | |
| 2825 | lastRetry.turnItem.id === firstRetry.turnItem.id && | |
| 2826 | lastRetry.turnItem.startedAt === firstRetry.turnItem.startedAt && | |
| 2827 | lastRetry.turnItem.retry?.attempt === 3, | |
| 2828 | ); | |
| 2829 | ||
| 2830 | yield* fake.emit({ | |
| 2831 | type: "auto_retry_end", | |
| 2832 | success: false, | |
| 2833 | attempt: 3, | |
| 2834 | finalError: "529 overloaded", | |
| 2835 | }); | |
| 2836 | const failedRetry = yield* takeEvent( | |
| 2837 | (event) => | |
| 2838 | event.type === "turn_item.updated" && | |
| 2839 | event.turnItem.type === "error" && | |
| 2840 | event.turnItem.status === "failed", | |
| 2841 | ); | |
| 2842 | assert.isTrue( | |
| 2843 | firstRetry.type === "turn_item.updated" && | |
| 2844 | firstRetry.turnItem.type === "error" && | |
| 2845 | failedRetry.type === "turn_item.updated" && | |
| 2846 | failedRetry.turnItem.type === "error" && | |
| 2847 | failedRetry.turnItem.id === firstRetry.turnItem.id && | |
| 2848 | failedRetry.turnItem.title === "Provider error" && | |
| 2849 | failedRetry.turnItem.failure.retryable === false && | |
| 2850 | failedRetry.turnItem.retry?.attempt === 3 && | |
| 2851 | failedRetry.turnItem.retry.maxAttempts === 3, | |
| 2852 | ); | |
| 2853 | yield* fake.emit({ type: "agent_settled" }); | |
| 2854 | ||
| 2855 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2856 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "failed"); | |
| 2857 | assert.isTrue( | |
| 2858 | firstRetry.type === "turn_item.updated" && | |
| 2859 | firstRetry.turnItem.type === "error" && | |
| 2860 | terminal.type === "turn.terminal" && | |
| 2861 | terminal.status === "failed" && | |
| 2862 | terminal.failure.message.includes("overloaded") && | |
| 2863 | terminal.retry?.attempt === 3 && | |
| 2864 | terminal.retry.maxAttempts === 3 && | |
| 2865 | terminal.retryStartedAt === firstRetry.turnItem.startedAt, | |
| 2866 | ); | |
| 2867 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2868 | ); | |
| 2869 | ||
| 2870 | it.effect("preserves exhausted retry failure through non-retrying compaction", () => | |
| 2871 | Effect.gen(function* () { | |
| 2872 | const fake = yield* makeFakePi; | |
| 2873 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2874 | const providerThread = yield* runtime.ensureThread({ | |
| 2875 | threadId: THREAD_ID, | |
| 2876 | modelSelection: modelSelection("default"), | |
| 2877 | runtimePolicy, | |
| 2878 | }); | |
| 2879 | yield* startTurn(runtime, providerThread); | |
| 2880 | yield* fake.takeRequest("prompt"); | |
| 2881 | yield* fake.emit({ type: "agent_start" }); | |
| 2882 | yield* fake.emit({ | |
| 2883 | type: "auto_retry_start", | |
| 2884 | attempt: 5, | |
| 2885 | maxAttempts: 5, | |
| 2886 | delayMs: 48_000, | |
| 2887 | errorMessage: "socket timed out", | |
| 2888 | }); | |
| 2889 | yield* takeEvent( | |
| 2890 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2891 | ); | |
| 2892 | yield* fake.emit({ | |
| 2893 | type: "auto_retry_end", | |
| 2894 | success: false, | |
| 2895 | attempt: 5, | |
| 2896 | finalError: "socket timed out", | |
| 2897 | }); | |
| 2898 | yield* takeEvent( | |
| 2899 | (event) => | |
| 2900 | event.type === "turn_item.updated" && | |
| 2901 | event.turnItem.type === "error" && | |
| 2902 | event.turnItem.status === "failed", | |
| 2903 | ); | |
| 2904 | yield* fake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2905 | yield* takeEvent( | |
| 2906 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2907 | ); | |
| 2908 | yield* fake.emit({ | |
| 2909 | type: "compaction_end", | |
| 2910 | reason: "threshold", | |
| 2911 | result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, | |
| 2912 | aborted: false, | |
| 2913 | willRetry: false, | |
| 2914 | }); | |
| 2915 | yield* takeEvent( | |
| 2916 | (event) => | |
| 2917 | event.type === "turn_item.updated" && | |
| 2918 | event.turnItem.type === "compaction" && | |
| 2919 | event.turnItem.status === "completed", | |
| 2920 | ); | |
| 2921 | yield* fake.emit({ type: "agent_settled" }); | |
| 2922 | ||
| 2923 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2924 | assert.isTrue( | |
| 2925 | terminal.type === "turn.terminal" && | |
| 2926 | terminal.status === "failed" && | |
| 2927 | terminal.failure.message === "socket timed out" && | |
| 2928 | terminal.retry?.attempt === 5 && | |
| 2929 | terminal.retry.maxAttempts === 5, | |
| 2930 | ); | |
| 2931 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2932 | ); | |
| 2933 | ||
| 2934 | it.effect("marks retry progress recovered when Pi succeeds", () => | |
| 2935 | Effect.gen(function* () { | |
| 2936 | const fake = yield* makeFakePi; | |
| 2937 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2938 | const providerThread = yield* runtime.ensureThread({ | |
| 2939 | threadId: THREAD_ID, | |
| 2940 | modelSelection: modelSelection("default"), | |
| 2941 | runtimePolicy, | |
| 2942 | }); | |
| 2943 | yield* startTurn(runtime, providerThread); | |
| 2944 | yield* fake.takeRequest("prompt"); | |
| 2945 | yield* fake.emit({ type: "agent_start" }); | |
| 2946 | yield* fake.emit({ | |
| 2947 | type: "message_end", | |
| 2948 | message: { | |
| 2949 | role: "assistant", | |
| 2950 | content: [], | |
| 2951 | stopReason: "error", | |
| 2952 | errorMessage: "temporary network failure", | |
| 2953 | }, | |
| 2954 | }); | |
| 2955 | yield* fake.emit({ | |
| 2956 | type: "auto_retry_start", | |
| 2957 | attempt: 1, | |
| 2958 | maxAttempts: 5, | |
| 2959 | delayMs: 3_000, | |
| 2960 | errorMessage: "temporary network failure", | |
| 2961 | }); | |
| 2962 | const running = yield* takeEvent( | |
| 2963 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2964 | ); | |
| 2965 | yield* fake.emit({ type: "auto_retry_end", success: true, attempt: 1 }); | |
| 2966 | const recovered = yield* takeEvent( | |
| 2967 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2968 | ); | |
| 2969 | assert.isTrue( | |
| 2970 | running.type === "turn_item.updated" && | |
| 2971 | running.turnItem.type === "error" && | |
| 2972 | recovered.type === "turn_item.updated" && | |
| 2973 | recovered.turnItem.type === "error" && | |
| 2974 | recovered.turnItem.id === running.turnItem.id && | |
| 2975 | recovered.turnItem.status === "completed" && | |
| 2976 | recovered.turnItem.title === "Provider recovered", | |
| 2977 | ); | |
| 2978 | ||
| 2979 | yield* fake.emit({ type: "agent_settled" }); | |
| 2980 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2981 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2982 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2983 | ); | |
| 2984 | ||
| 2985 | it.effect("stops active retry progress when the turn is interrupted", () => | |
| 2986 | Effect.gen(function* () { | |
| 2987 | const fake = yield* makeFakePi; | |
| 2988 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2989 | const providerThread = yield* runtime.ensureThread({ | |
| 2990 | threadId: THREAD_ID, | |
| 2991 | modelSelection: modelSelection("default"), | |
| 2992 | runtimePolicy, | |
| 2993 | }); | |
| 2994 | yield* startTurn(runtime, providerThread); | |
| 2995 | yield* fake.takeRequest("prompt"); | |
| 2996 | yield* fake.emit({ type: "agent_start" }); | |
| 2997 | const runningTurn = yield* takeEvent( | |
| 2998 | (event) => | |
| 2999 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3000 | ); | |
| 3001 | const providerTurnId = | |
| 3002 | runningTurn.type === "provider_turn.updated" ? runningTurn.providerTurn.id : undefined; | |
| 3003 | assert.isDefined(providerTurnId); | |
| 3004 | ||
| 3005 | yield* fake.emit({ | |
| 3006 | type: "auto_retry_start", | |
| 3007 | attempt: 2, | |
| 3008 | maxAttempts: 5, | |
| 3009 | delayMs: 6_000, | |
| 3010 | errorMessage: "temporary network failure", | |
| 3011 | }); | |
| 3012 | const retrying = yield* takeEvent( | |
| 3013 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 3014 | ); | |
| 3015 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 3016 | yield* fake.takeRequest("abort"); | |
| 3017 | yield* fake.emit({ type: "agent_settled" }); | |
| 3018 | ||
| 3019 | const stopped = yield* takeEvent( | |
| 3020 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 3021 | ); | |
| 3022 | assert.isTrue( | |
| 3023 | retrying.type === "turn_item.updated" && | |
| 3024 | retrying.turnItem.type === "error" && | |
| 3025 | stopped.type === "turn_item.updated" && | |
| 3026 | stopped.turnItem.type === "error" && | |
| 3027 | stopped.turnItem.id === retrying.turnItem.id && | |
| 3028 | stopped.turnItem.status === "interrupted" && | |
| 3029 | stopped.turnItem.title === "Provider retry stopped", | |
| 3030 | ); | |
| 3031 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3032 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 3033 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3034 | ); | |
| 3035 | ||
| 3036 | it.effect("keeps extension-started compaction and recovery in the settled turn", () => | |
| 3037 | Effect.gen(function* () { | |
| 3038 | const fake = yield* makeFakePi; | |
| 3039 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3040 | const providerThread = yield* runtime.ensureThread({ | |
| 3041 | threadId: THREAD_ID, | |
| 3042 | modelSelection: modelSelection("default"), | |
| 3043 | runtimePolicy, | |
| 3044 | }); | |
| 3045 | yield* startTurn(runtime, providerThread); | |
| 3046 | yield* fake.takeRequest("prompt"); | |
| 3047 | yield* fake.emit({ type: "agent_start" }); | |
| 3048 | ||
| 3049 | // Extension ctx.compact() waits for this first settlement, then starts | |
| 3050 | // compaction in a detached continuation. | |
| 3051 | fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); | |
| 3052 | yield* fake.emit({ type: "agent_settled" }); | |
| 3053 | yield* fake.takeRequest("get_state"); | |
| 3054 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3055 | yield* takeEvent( | |
| 3056 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3057 | ); | |
| 3058 | ||
| 3059 | fake.queueState({ isStreaming: true, isCompacting: false, pendingMessageCount: 0 }); | |
| 3060 | yield* fake.emit({ | |
| 3061 | type: "compaction_end", | |
| 3062 | reason: "manual", | |
| 3063 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 3064 | aborted: false, | |
| 3065 | willRetry: false, | |
| 3066 | }); | |
| 3067 | yield* fake.emit({ type: "agent_start" }); | |
| 3068 | yield* takeEvent( | |
| 3069 | (event) => | |
| 3070 | event.type === "turn_item.updated" && | |
| 3071 | event.turnItem.type === "compaction" && | |
| 3072 | event.turnItem.status === "completed", | |
| 3073 | ); | |
| 3074 | yield* fake.takeRequest("get_state"); | |
| 3075 | ||
| 3076 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3077 | yield* fake.emit({ type: "agent_settled" }); | |
| 3078 | yield* fake.takeRequest("get_state"); | |
| 3079 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3080 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3081 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3082 | ); | |
| 3083 | ||
| 3084 | it.effect("keeps working after a settle probe fails before detached compaction", () => | |
| 3085 | Effect.gen(function* () { | |
| 3086 | const fake = yield* makeFakePi; | |
| 3087 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3088 | const providerThread = yield* runtime.ensureThread({ | |
| 3089 | threadId: THREAD_ID, | |
| 3090 | modelSelection: modelSelection("default"), | |
| 3091 | runtimePolicy, | |
| 3092 | }); | |
| 3093 | yield* startTurn(runtime, providerThread); | |
| 3094 | yield* fake.takeRequest("prompt"); | |
| 3095 | yield* fake.emit({ type: "agent_start" }); | |
| 3096 | ||
| 3097 | fake.failNextState(); | |
| 3098 | yield* fake.emit({ type: "agent_settled" }); | |
| 3099 | yield* fake.takeRequest("get_state"); | |
| 3100 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3101 | yield* takeEvent( | |
| 3102 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3103 | ); | |
| 3104 | ||
| 3105 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3106 | yield* fake.emit({ | |
| 3107 | type: "compaction_end", | |
| 3108 | reason: "manual", | |
| 3109 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 3110 | aborted: false, | |
| 3111 | willRetry: false, | |
| 3112 | }); | |
| 3113 | yield* takeEvent( | |
| 3114 | (event) => | |
| 3115 | event.type === "turn_item.updated" && | |
| 3116 | event.turnItem.type === "compaction" && | |
| 3117 | event.turnItem.status === "completed", | |
| 3118 | ); | |
| 3119 | yield* fake.takeRequest("get_state"); | |
| 3120 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3121 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3122 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3123 | ); | |
| 3124 | ||
| 3125 | it.effect("restarts Pi when Stop interrupts detached compaction", () => | |
| 3126 | Effect.gen(function* () { | |
| 3127 | const fake = yield* makeFakePi; | |
| 3128 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3129 | const providerThread = yield* runtime.ensureThread({ | |
| 3130 | threadId: THREAD_ID, | |
| 3131 | modelSelection: modelSelection("default"), | |
| 3132 | runtimePolicy, | |
| 3133 | }); | |
| 3134 | yield* startTurn(runtime, providerThread); | |
| 3135 | yield* fake.takeRequest("prompt"); | |
| 3136 | const running = yield* takeEvent( | |
| 3137 | (event) => | |
| 3138 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3139 | ); | |
| 3140 | assert.equal(running.type, "provider_turn.updated"); | |
| 3141 | const providerTurnId = | |
| 3142 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 3143 | assert.isDefined(providerTurnId); | |
| 3144 | yield* fake.emit({ type: "agent_start" }); | |
| 3145 | ||
| 3146 | fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); | |
| 3147 | yield* fake.emit({ type: "agent_settled" }); | |
| 3148 | yield* fake.takeRequest("get_state"); | |
| 3149 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3150 | yield* takeEvent( | |
| 3151 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3152 | ); | |
| 3153 | ||
| 3154 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 3155 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); | |
| 3156 | yield* fake.closeStdout; | |
| 3157 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3158 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 3159 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3160 | ); | |
| 3161 | ||
| 3162 | it.effect("ignores an idle snapshot made stale by a steer", () => | |
| 3163 | Effect.gen(function* () { | |
| 3164 | const fake = yield* makeFakePi; | |
| 3165 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3166 | const providerThread = yield* runtime.ensureThread({ | |
| 3167 | threadId: THREAD_ID, | |
| 3168 | modelSelection: modelSelection("default"), | |
| 3169 | runtimePolicy, | |
| 3170 | }); | |
| 3171 | yield* startTurn(runtime, providerThread); | |
| 3172 | yield* fake.takeRequest("prompt"); | |
| 3173 | const running = yield* takeEvent( | |
| 3174 | (event) => | |
| 3175 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3176 | ); | |
| 3177 | assert.equal(running.type, "provider_turn.updated"); | |
| 3178 | const providerTurnId = | |
| 3179 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 3180 | assert.isDefined(providerTurnId); | |
| 3181 | yield* fake.emit({ type: "agent_start" }); | |
| 3182 | ||
| 3183 | fake.deferNextState(); | |
| 3184 | yield* fake.emit({ type: "agent_settled" }); | |
| 3185 | yield* fake.takeRequest("get_state"); | |
| 3186 | yield* runtime.steerTurn({ | |
| 3187 | threadId: THREAD_ID, | |
| 3188 | runId: RunId.make("run:thread-pi-test:1"), | |
| 3189 | providerThread, | |
| 3190 | providerTurnId: providerTurnId!, | |
| 3191 | message: { | |
| 3192 | messageId: "message:thread-pi-test:late-steer" as never, | |
| 3193 | text: "Continue after settlement", | |
| 3194 | attachments: [], | |
| 3195 | createdBy: "user", | |
| 3196 | creationSource: "web", | |
| 3197 | }, | |
| 3198 | }); | |
| 3199 | yield* fake.takeRequest("prompt"); | |
| 3200 | yield* fake.resolveDeferredState({ | |
| 3201 | isStreaming: false, | |
| 3202 | isCompacting: false, | |
| 3203 | pendingMessageCount: 0, | |
| 3204 | }); | |
| 3205 | ||
| 3206 | yield* fake.emit({ type: "agent_start" }); | |
| 3207 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 3208 | yield* fake.emit({ | |
| 3209 | type: "message_update", | |
| 3210 | assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Recovered" }, | |
| 3211 | }); | |
| 3212 | yield* fake.emit({ | |
| 3213 | type: "message_end", | |
| 3214 | message: { | |
| 3215 | role: "assistant", | |
| 3216 | content: [{ type: "text", text: "Recovered" }], | |
| 3217 | stopReason: "stop", | |
| 3218 | }, | |
| 3219 | }); | |
| 3220 | const assistantItem = yield* takeEvent( | |
| 3221 | (event) => | |
| 3222 | event.type === "turn_item.updated" && | |
| 3223 | event.turnItem.type === "assistant_message" && | |
| 3224 | event.turnItem.streaming === false, | |
| 3225 | ); | |
| 3226 | assert.isTrue( | |
| 3227 | assistantItem.type === "turn_item.updated" && | |
| 3228 | assistantItem.turnItem.type === "assistant_message" && | |
| 3229 | assistantItem.turnItem.text === "Recovered", | |
| 3230 | ); | |
| 3231 | ||
| 3232 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3233 | yield* fake.emit({ type: "agent_settled" }); | |
| 3234 | yield* fake.takeRequest("get_state"); | |
| 3235 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3236 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3237 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3238 | ); | |
| 3239 | }); | |
| 3240 | ||
| 3241 | describe("PiRpc framing", () => { | |
| 3242 | it.effect("reassembles records across chunk boundaries and strips CR", () => | |
| 3243 | Effect.gen(function* () { | |
| 3244 | const stdout = yield* Queue.unbounded<Uint8Array>(); | |
| 3245 | const spawner = ChildProcessSpawner.make(() => | |
| 3246 | Effect.succeed( | |
| 3247 | ChildProcessSpawner.makeHandle({ | |
| 3248 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3249 | exitCode: Effect.never, | |
| 3250 | isRunning: Effect.succeed(true), | |
| 3251 | kill: () => Effect.void, | |
| 3252 | unref: Effect.succeed(Effect.void), | |
| 3253 | stdin: Sink.drain, | |
| 3254 | stdout: Stream.fromQueue(stdout), | |
| 3255 | stderr: Stream.empty, | |
| 3256 | all: Stream.empty, | |
| 3257 | getInputFd: () => Sink.drain, | |
| 3258 | getOutputFd: () => Stream.empty, | |
| 3259 | }), | |
| 3260 | ), | |
| 3261 | ); | |
| 3262 | const connection = yield* makePiRpcConnection({ | |
| 3263 | command: "pi", | |
| 3264 | args: ["--mode", "rpc"], | |
| 3265 | cwd: undefined, | |
| 3266 | env: {}, | |
| 3267 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3268 | ||
| 3269 | const push = (text: string) => | |
| 3270 | Queue.offer(stdout, new TextEncoder().encode(text)).pipe(Effect.asVoid); | |
| 3271 | yield* push('{"type":"agent_'); | |
| 3272 | yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); | |
| 3273 | ||
| 3274 | yield* push("x".repeat(8 * 1024 * 1024)); | |
| 3275 | yield* push('x{"type":"must_not_emit"}\n{"type":"after_oversized"}\n'); | |
| 3276 | ||
| 3277 | const first = yield* Queue.take(connection.events); | |
| 3278 | assert.equal(first["type"], "agent_start"); | |
| 3279 | const second = yield* Queue.take(connection.events); | |
| 3280 | assert.equal(second["type"], "agent_settled"); | |
| 3281 | const third = yield* Queue.take(connection.events); | |
| 3282 | assert.equal(third["type"], "queue_update"); | |
| 3283 | assert.equal((yield* Queue.take(connection.events))["type"], "after_oversized"); | |
| 3284 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3285 | ); | |
| 3286 | }); | |
| 3287 | ||
| 3288 | // This fails before a provider transcript exists, so a replay fixture is not | |
| 3289 | // an honest fit. The boundary is the stdio transport seeing stdout end. | |
| 3290 | describe("PiRpc early process exit", () => { | |
| 3291 | const makeHandle = (options: { | |
| 3292 | readonly exitCode: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 3293 | readonly stderr: Stream.Stream<Uint8Array>; | |
| 3294 | }) => | |
| 3295 | ChildProcessSpawner.makeHandle({ | |
| 3296 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3297 | exitCode: options.exitCode, | |
| 3298 | isRunning: Effect.succeed(true), | |
| 3299 | kill: () => Effect.void, | |
| 3300 | unref: Effect.succeed(Effect.void), | |
| 3301 | stdin: Sink.drain, | |
| 3302 | stdout: Stream.empty, | |
| 3303 | stderr: options.stderr, | |
| 3304 | all: Stream.empty, | |
| 3305 | getInputFd: () => Sink.drain, | |
| 3306 | getOutputFd: () => Stream.empty, | |
| 3307 | }); | |
| 3308 | ||
| 3309 | it.effect("reports a nonzero exit code instead of an unexplained stdout close", () => | |
| 3310 | Effect.gen(function* () { | |
| 3311 | const secret = "API_KEY=super-secret\n"; | |
| 3312 | const spawner = ChildProcessSpawner.make(() => | |
| 3313 | Effect.succeed( | |
| 3314 | makeHandle({ | |
| 3315 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 3316 | stderr: Stream.fromIterable([new TextEncoder().encode(secret)]), | |
| 3317 | }), | |
| 3318 | ), | |
| 3319 | ); | |
| 3320 | const connection = yield* makePiRpcConnection({ | |
| 3321 | command: "pi", | |
| 3322 | args: ["--mode", "rpc"], | |
| 3323 | cwd: undefined, | |
| 3324 | env: {}, | |
| 3325 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3326 | ||
| 3327 | const error = yield* Queue.take(connection.events).pipe(Effect.flip); | |
| 3328 | assert.equal(error._tag, "PiRpcError"); | |
| 3329 | assert.equal(error.operation, "read"); | |
| 3330 | assert.equal(error.detail, "pi process exited with code 1"); | |
| 3331 | assert.isFalse((error.detail ?? "").includes("API_KEY")); | |
| 3332 | assert.isFalse((error.detail ?? "").includes("super-secret")); | |
| 3333 | assert.isFalse(error.message.includes("API_KEY")); | |
| 3334 | assert.isFalse(error.message.includes("super-secret")); | |
| 3335 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3336 | ); | |
| 3337 | ||
| 3338 | it.effect("keeps the unexplained stdout-close message when the process has not exited", () => | |
| 3339 | Effect.gen(function* () { | |
| 3340 | const spawner = ChildProcessSpawner.make(() => | |
| 3341 | Effect.succeed( | |
| 3342 | makeHandle({ | |
| 3343 | exitCode: Effect.never, | |
| 3344 | stderr: Stream.empty, | |
| 3345 | }), | |
| 3346 | ), | |
| 3347 | ); | |
| 3348 | const connection = yield* makePiRpcConnection({ | |
| 3349 | command: "pi", | |
| 3350 | args: ["--mode", "rpc"], | |
| 3351 | cwd: undefined, | |
| 3352 | env: {}, | |
| 3353 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3354 | ||
| 3355 | const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); | |
| 3356 | yield* TestClock.adjust(Duration.millis(300)); | |
| 3357 | const error = yield* Fiber.join(fiber); | |
| 3358 | assert.equal(error._tag, "PiRpcError"); | |
| 3359 | assert.equal(error.operation, "read"); | |
| 3360 | assert.equal(error.detail, "pi process closed stdout"); | |
| 3361 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3362 | ); | |
| 3363 | ||
| 3364 | it.effect("keeps the exit-code diagnosis when stdin breaks while exit is still pending", () => | |
| 3365 | Effect.gen(function* () { | |
| 3366 | const spawner = ChildProcessSpawner.make(() => | |
| 3367 | Effect.succeed( | |
| 3368 | ChildProcessSpawner.makeHandle({ | |
| 3369 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3370 | exitCode: Effect.sleep(Duration.millis(50)).pipe( | |
| 3371 | Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(1))), | |
| 3372 | ), | |
| 3373 | isRunning: Effect.succeed(true), | |
| 3374 | kill: () => Effect.void, | |
| 3375 | unref: Effect.succeed(Effect.void), | |
| 3376 | stdin: Sink.fail( | |
| 3377 | PlatformError.systemError({ | |
| 3378 | _tag: "Unknown", | |
| 3379 | module: "ChildProcess", | |
| 3380 | method: "stdin", | |
| 3381 | description: "broken pipe", | |
| 3382 | }), | |
| 3383 | ), | |
| 3384 | stdout: Stream.empty, | |
| 3385 | stderr: Stream.empty, | |
| 3386 | all: Stream.empty, | |
| 3387 | getInputFd: () => Sink.drain, | |
| 3388 | getOutputFd: () => Stream.empty, | |
| 3389 | }), | |
| 3390 | ), | |
| 3391 | ); | |
| 3392 | const connection = yield* makePiRpcConnection({ | |
| 3393 | command: "pi", | |
| 3394 | args: ["--mode", "rpc"], | |
| 3395 | cwd: undefined, | |
| 3396 | env: {}, | |
| 3397 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3398 | ||
| 3399 | const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); | |
| 3400 | yield* TestClock.adjust(Duration.millis(300)); | |
| 3401 | const error = yield* Fiber.join(fiber); | |
| 3402 | assert.equal(error._tag, "PiRpcError"); | |
| 3403 | assert.equal(error.operation, "read"); | |
| 3404 | assert.equal(error.detail, "pi process exited with code 1"); | |
| 3405 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3406 | ); | |
| 3407 | }); |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 35 | 35 | import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; |
| 36 | 36 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 37 | 37 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
| 38 | import { PI_PROVIDER, PiAdapterV2Driver } from "./PiAdapterV2.ts"; | |
| 38 | import { PI_PROVIDER, PiAdapterV2Driver } from "@t3tools/provider-pi/server"; | |
| 39 | 39 | |
| 40 | 40 | export const PI_RPC_REPLAY_PROTOCOL = "pi.rpc-jsonl"; |
| 41 | 41 | export const PI_REPLAY_ANY = "<any>"; |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts deleted-3265| ... | ... | @@ -1,3265 +0,0 @@ |
| 1 | /** | |
| 2 | * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent | |
| 3 | * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. | |
| 4 | * | |
| 5 | * Design intent: honor the user's Pi customizations. The process is spawned | |
| 6 | * with no `--no-*` flags, so the user's extensions, skills, prompt templates, | |
| 7 | * AGENTS.md / SYSTEM.md context, settings.json, custom models, and auth all | |
| 8 | * load exactly as they do in the `pi` TUI. Sessions are stored by Pi itself | |
| 9 | * (default `~/.pi/agent/sessions/`), and the session file path is the durable | |
| 10 | * `nativeThreadRef`, so a thread started in T3 can be resumed from the TUI | |
| 11 | * and vice versa. | |
| 12 | * | |
| 13 | * Turn lifecycle: `agent_settled` is the only terminal signal. `agent_end` | |
| 14 | * merely closes one low-level run — compaction retries, auto-retries, and | |
| 15 | * queued continuations may still follow it, so the turn stays open until Pi | |
| 16 | * reports the session settled. An extension can start detached compaction as | |
| 17 | * that signal unwinds, so the adapter confirms Pi is idle before terminalizing. | |
| 18 | * | |
| 19 | * Extension UI: Pi extensions raise dialogs through `extension_ui_request`. | |
| 20 | * Dialog methods become v2 runtime requests (`confirm` → approval_request, | |
| 21 | * `select`/`input`/`editor` → user_input_request); answers travel back as | |
| 22 | * `extension_ui_response`. `notify` becomes a completed activity item. | |
| 23 | * Terminal-only decoration such as status, widget, title, and editor-text | |
| 24 | * updates has no matching T3 surface and is ignored. | |
| 25 | */ | |
| 26 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 27 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 28 | import { | |
| 29 | defaultInstanceIdForDriver, | |
| 30 | PiSettings, | |
| 31 | ProviderDriverKind, | |
| 32 | type ChatAttachment, | |
| 33 | type ModelSelection, | |
| 34 | type OrchestrationV2ExecutionNode, | |
| 35 | type OrchestrationV2ProviderCapabilities, | |
| 36 | type OrchestrationV2ProviderFailure, | |
| 37 | type OrchestrationV2ProviderRef, | |
| 38 | type OrchestrationV2ProviderRetry, | |
| 39 | type OrchestrationV2ProviderSession, | |
| 40 | type OrchestrationV2ProviderThread, | |
| 41 | type OrchestrationV2ProviderTurn, | |
| 42 | type OrchestrationV2RuntimeRequest, | |
| 43 | type OrchestrationV2TurnItem, | |
| 44 | type OrchestrationV2UserInputQuestion, | |
| 45 | type ProviderApprovalDecision, | |
| 46 | type ProviderInstanceId, | |
| 47 | type OrchestrationV2ProviderTurnTokenUsage, | |
| 48 | } from "@t3tools/contracts"; | |
| 49 | import * as Cause from "effect/Cause"; | |
| 50 | import * as DateTime from "effect/DateTime"; | |
| 51 | import * as Deferred from "effect/Deferred"; | |
| 52 | import * as Option from "effect/Option"; | |
| 53 | import * as Duration from "effect/Duration"; | |
| 54 | import * as Effect from "effect/Effect"; | |
| 55 | import * as FileSystem from "effect/FileSystem"; | |
| 56 | import * as Layer from "effect/Layer"; | |
| 57 | import * as Queue from "effect/Queue"; | |
| 58 | import * as Schema from "effect/Schema"; | |
| 59 | import * as Semaphore from "effect/Semaphore"; | |
| 60 | import * as Stream from "effect/Stream"; | |
| 61 | import { ChildProcessSpawner } from "effect/process"; | |
| 62 | ||
| 63 | import { ProviderHost, type ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; | |
| 64 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 65 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 66 | import { | |
| 67 | expandPiSkillReference, | |
| 68 | parsePiCompactCommand, | |
| 69 | parsePiDiscoveredCommands, | |
| 70 | type PiCompactCommand, | |
| 71 | } from "../../provider/PiCommands.ts"; | |
| 72 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 73 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 74 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 75 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 76 | import { | |
| 77 | ProviderAdapterDriverCreateError, | |
| 78 | type ProviderAdapterDriver, | |
| 79 | type ProviderAdapterDriverCreateInput, | |
| 80 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 81 | import { | |
| 82 | makeProviderFailure, | |
| 83 | makeProviderRetryTurnItem, | |
| 84 | } from "@t3tools/provider-core/server/failure"; | |
| 85 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 86 | import { | |
| 87 | makePiRpcConnection, | |
| 88 | parsePiModelSlug, | |
| 89 | piRecordField as recordField, | |
| 90 | piRecordNumber as recordNumber, | |
| 91 | piRecordString as recordString, | |
| 92 | type PiRpcConnection, | |
| 93 | type PiRpcRecord, | |
| 94 | } from "./PiRpc.ts"; | |
| 95 | import { | |
| 96 | buildPiRpcLaunch, | |
| 97 | materializePiT3McpExtension, | |
| 98 | resolvePiLaunchArgs, | |
| 99 | } from "./piT3McpInjection.ts"; | |
| 100 | import { PI_FILE_CHANGE_TOOLS } from "./piT3McpExtensionSource.ts"; | |
| 101 | ||
| 102 | export const PI_PROVIDER = ProviderDriverKind.make("pi"); | |
| 103 | const PI_DRIVER_KIND = PI_PROVIDER; | |
| 104 | const PI_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(PI_DRIVER_KIND); | |
| 105 | const DEFAULT_PI_SETTINGS = Schema.decodeSync(PiSettings)({}); | |
| 106 | ||
| 107 | /** | |
| 108 | * Sentinel model slug meaning "do not call set_model": Pi resolves the model | |
| 109 | * from the user's own settings.json (`defaultProvider`/`defaultModel`). | |
| 110 | */ | |
| 111 | const PI_INHERIT_MODEL_SLUG = "default"; | |
| 112 | ||
| 113 | const STREAM_FLUSH_MS = 50; | |
| 114 | const PI_REQUEST_TIMEOUT_MS = 15_000; | |
| 115 | // Session lifecycle hooks reload extensions, MCP servers and language servers. | |
| 116 | const PI_SESSION_TIMEOUT_MS = 60_000; | |
| 117 | const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; | |
| 118 | const PI_UNSOLICITED_ACTIVITY_ERROR = | |
| 119 | "Pi started agent work outside an active T3 turn. The session was stopped to prevent invisible tool execution."; | |
| 120 | const SETTLE_PROBE_MAX_ATTEMPTS = 3; | |
| 121 | const SETTLE_PROBE_RETRY_DELAY = Duration.millis(100); | |
| 122 | ||
| 123 | const PiProviderCapabilitiesV2 = { | |
| 124 | runtimePolicy: { enforcement: "client-boundary" }, | |
| 125 | sessions: { | |
| 126 | supportsMultipleProviderThreadsPerSession: false, | |
| 127 | supportsModelSwitchInSession: true, | |
| 128 | supportsProviderSwitchingViaHandoff: true, | |
| 129 | // Mode changes restart this process so the injected permission hook gets | |
| 130 | // one immutable policy for its whole lifetime. | |
| 131 | supportsRuntimeModeSwitchInSession: false, | |
| 132 | pendingRequestsSurviveRestart: false, | |
| 133 | }, | |
| 134 | threads: { | |
| 135 | canCreateEmptyThread: true, | |
| 136 | canReadThreadSnapshot: true, | |
| 137 | canRollbackThread: true, | |
| 138 | canForkThread: true, | |
| 139 | canForkFromTurn: true, | |
| 140 | canForkFromSubagentThread: false, | |
| 141 | exposesNativeThreadId: true, | |
| 142 | }, | |
| 143 | turns: { | |
| 144 | exposesNativeTurnId: false, | |
| 145 | emitsTurnStarted: true, | |
| 146 | emitsTurnCompleted: true, | |
| 147 | supportsInterrupt: true, | |
| 148 | supportsActiveSteering: true, | |
| 149 | supportsSteeringByInterruptRestart: false, | |
| 150 | supportsQueuedMessages: true, | |
| 151 | terminalStatusQuality: "strong", | |
| 152 | }, | |
| 153 | streaming: { | |
| 154 | streamsAssistantText: true, | |
| 155 | streamsReasoning: true, | |
| 156 | streamsToolOutput: true, | |
| 157 | streamsPlanText: false, | |
| 158 | emitsMessageCompleted: true, | |
| 159 | }, | |
| 160 | tools: { | |
| 161 | exposesToolItemIds: true, | |
| 162 | emitsToolStarted: true, | |
| 163 | emitsToolCompleted: true, | |
| 164 | emitsToolOutput: true, | |
| 165 | supportsMcpTools: true, | |
| 166 | supportsDynamicToolCallbacks: false, | |
| 167 | }, | |
| 168 | approvals: { | |
| 169 | // Pi exposes a blocking tool_call extension hook. The T3 bridge uses it | |
| 170 | // for supervised and auto-accept modes and forwards its confirmations | |
| 171 | // through the same extension UI protocol as user-installed extensions. | |
| 172 | supportsCommandApproval: true, | |
| 173 | supportsFileReadApproval: false, | |
| 174 | supportsFileChangeApproval: true, | |
| 175 | supportsApplyPatchApproval: false, | |
| 176 | approvalsHaveNativeRequestIds: true, | |
| 177 | approvalCallbacksAreLiveOnly: true, | |
| 178 | approvalsCanOriginateFromSubagents: false, | |
| 179 | }, | |
| 180 | planning: { | |
| 181 | emitsPlanUpdated: false, | |
| 182 | emitsTodoList: false, | |
| 183 | emitsProposedPlan: false, | |
| 184 | supportsStructuredQuestions: true, | |
| 185 | planDeltasHaveItemIds: false, | |
| 186 | }, | |
| 187 | subagents: { | |
| 188 | // T3 delegation uses the shared MCP `delegate_task` path. Installed Pi | |
| 189 | // subagent extensions are observed best-effort, but their official tool | |
| 190 | // runs children with --no-session and exposes no resumable child id. | |
| 191 | supportsSubagents: true, | |
| 192 | exposesSubagentThreadIds: false, | |
| 193 | emitsSubagentLifecycle: true, | |
| 194 | canWaitForSubagents: false, | |
| 195 | canCloseSubagents: false, | |
| 196 | canForkSubagentThread: false, | |
| 197 | }, | |
| 198 | context: { | |
| 199 | acceptsSystemContext: false, | |
| 200 | acceptsDeveloperContext: false, | |
| 201 | acceptsSyntheticUserContext: true, | |
| 202 | canGenerateSummaries: false, | |
| 203 | canConsumeHandoffSummaries: true, | |
| 204 | // T3 delivers both full and delta handoffs through Pi's normal user-message | |
| 205 | // input, so neither strategy depends on a Pi-specific context hook. | |
| 206 | supportsDeltaHandoff: true, | |
| 207 | supportsFullThreadHandoff: true, | |
| 208 | maxRecommendedHandoffChars: null, | |
| 209 | }, | |
| 210 | checkpointing: { | |
| 211 | appCanCheckpointFilesystem: true, | |
| 212 | supportsNestedCheckpointScopes: false, | |
| 213 | providerCanRollbackConversation: true, | |
| 214 | // CommandPolicy.ensureRollback requires the snapshot whenever provider | |
| 215 | // rollback is enabled; rollbackThread returns the updated provider thread. | |
| 216 | providerRollbackReturnsSnapshot: true, | |
| 217 | providerCanReadConversationSnapshot: true, | |
| 218 | }, | |
| 219 | identity: { | |
| 220 | nativeThreadIds: "strong", | |
| 221 | nativeTurnIds: "weak", | |
| 222 | nativeItemIds: "strong", | |
| 223 | nativeRequestIds: "strong", | |
| 224 | }, | |
| 225 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 226 | ||
| 227 | export interface PiAdapterV2Options { | |
| 228 | readonly instanceId: ProviderInstanceId; | |
| 229 | readonly settings: PiSettings; | |
| 230 | readonly environment: NodeJS.ProcessEnv; | |
| 231 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 232 | readonly fileSystem: FileSystem.FileSystem; | |
| 233 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; | |
| 234 | readonly host: ProviderHostShape; | |
| 235 | readonly continuationRequests?: { | |
| 236 | readonly offer: ( | |
| 237 | request: ProviderContinuationRequests.ProviderContinuationRequest, | |
| 238 | ) => Effect.Effect<void>; | |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | /** Concatenate the `text` fields of a Pi content-block array. */ | |
| 243 | function contentText(content: unknown): string { | |
| 244 | if (!Array.isArray(content)) { | |
| 245 | return typeof content === "string" ? content : ""; | |
| 246 | } | |
| 247 | return content | |
| 248 | .map((block) => { | |
| 249 | if (recordField(block, "type") === "text") return recordString(block, "text") ?? ""; | |
| 250 | return ""; | |
| 251 | }) | |
| 252 | .join(""); | |
| 253 | } | |
| 254 | ||
| 255 | function providerRef( | |
| 256 | nativeId: string, | |
| 257 | strength: "strong" | "weak" = "strong", | |
| 258 | ): OrchestrationV2ProviderRef { | |
| 259 | return { driver: PI_PROVIDER, nativeId, strength }; | |
| 260 | } | |
| 261 | ||
| 262 | const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); | |
| 263 | ||
| 264 | // ── per-session state ───────────────────────────────────────── | |
| 265 | ||
| 266 | interface PiStreamItemState { | |
| 267 | readonly nativeItemId: string; | |
| 268 | readonly kind: "assistant_message" | "reasoning"; | |
| 269 | text: string; | |
| 270 | completed: boolean; | |
| 271 | flushScheduled: boolean; | |
| 272 | readonly startedAt: DateTime.Utc; | |
| 273 | } | |
| 274 | ||
| 275 | type PiCompactionStatus = "running" | "completed" | "failed" | "cancelled"; | |
| 276 | ||
| 277 | interface PiCompactionState { | |
| 278 | readonly nativeItemId: string; | |
| 279 | readonly startedAt: DateTime.Utc; | |
| 280 | } | |
| 281 | ||
| 282 | interface PiProviderRetryState { | |
| 283 | readonly retry: OrchestrationV2ProviderRetry; | |
| 284 | readonly failure: OrchestrationV2ProviderFailure; | |
| 285 | readonly startedAt: DateTime.Utc; | |
| 286 | readonly itemOrdinal: number; | |
| 287 | } | |
| 288 | ||
| 289 | function compactionTitle(status: PiCompactionStatus): string { | |
| 290 | switch (status) { | |
| 291 | case "running": | |
| 292 | return "Compacting context..."; | |
| 293 | case "completed": | |
| 294 | return "Context compacted"; | |
| 295 | case "failed": | |
| 296 | return "Context compaction failed"; | |
| 297 | case "cancelled": | |
| 298 | return "Context compaction stopped"; | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | interface ActivePiTurn { | |
| 303 | readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; | |
| 304 | readonly providerTurn: OrchestrationV2ProviderTurn; | |
| 305 | readonly startedAt: DateTime.Utc; | |
| 306 | readonly itemOrdinals: Map<string, number>; | |
| 307 | nextItemOrdinal: number; | |
| 308 | /** Increments on assistant `message_start` so content indexes stay unique. */ | |
| 309 | messageOrdinal: number; | |
| 310 | readonly streamItems: Map<string, PiStreamItemState>; | |
| 311 | readonly toolArgs: Map<string, unknown>; | |
| 312 | /** | |
| 313 | * First-seen time per `toolCallId`. Later update/end events reuse it so a | |
| 314 | * tool keeps one start timestamp and reports a real duration. | |
| 315 | */ | |
| 316 | readonly toolStartedAt: Map<string, DateTime.Utc>; | |
| 317 | interrupted: boolean; | |
| 318 | /** | |
| 319 | * Whether any agent run activity was observed. Command-only prompts (pure | |
| 320 | * extension slash commands) never start an agent run and never emit | |
| 321 | * `agent_settled`; their deferred prompt ack plus an idle probe settles | |
| 322 | * the turn instead. | |
| 323 | */ | |
| 324 | sawAgentActivity: boolean; | |
| 325 | /** A user prompt joined provider-native work whose settlement may already be queued. */ | |
| 326 | adoptedWake: boolean; | |
| 327 | /** Only slash-command prompts can complete without starting an agent run. */ | |
| 328 | readonly promptMayBeCommandOnly: boolean; | |
| 329 | /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ | |
| 330 | latestCompactionAfterTokens: number | null; | |
| 331 | /** Last streamed usage total already emitted on the running turn. */ | |
| 332 | lastLiveUsedTokens: number | null; | |
| 333 | /** Invalidates idle snapshots when new work starts after a settle probe. */ | |
| 334 | settleProbeGeneration: number; | |
| 335 | /** An extension may start compaction immediately after Pi emits agent_settled. */ | |
| 336 | settleWhenIdle: boolean; | |
| 337 | sawCompaction: boolean; | |
| 338 | /** RPC compact is in flight; Pi abort does not cancel it. */ | |
| 339 | manualCompactInFlight: boolean; | |
| 340 | activeCompaction: PiCompactionState | null; | |
| 341 | activeProviderRetry: PiProviderRetryState | null; | |
| 342 | failure: ReturnType<typeof makeProviderFailure> | null; | |
| 343 | /** Native retry can recover its work without accepting a rejected joining prompt. */ | |
| 344 | rejectedPromptFailure: ReturnType<typeof makeProviderFailure> | null; | |
| 345 | /** Session-tree refs read just before Stop terminates Pi, when no read is possible later. */ | |
| 346 | stopTreeRefs?: PiTurnTreeRefs | null; | |
| 347 | } | |
| 348 | ||
| 349 | interface PiTurnTreeRefs { | |
| 350 | readonly turnStartEntryId: string | null; | |
| 351 | readonly leafId: string | null; | |
| 352 | } | |
| 353 | ||
| 354 | interface PendingPiPrompt { | |
| 355 | readonly nativeRequestId: string; | |
| 356 | readonly method: "select" | "confirm" | "input" | "editor"; | |
| 357 | readonly questionId: string; | |
| 358 | readonly approvalKey: string; | |
| 359 | runtimeRequest: OrchestrationV2RuntimeRequest; | |
| 360 | readonly node: OrchestrationV2ExecutionNode; | |
| 361 | readonly turnItem: OrchestrationV2TurnItem; | |
| 362 | } | |
| 363 | ||
| 364 | /** | |
| 365 | * The T3 bridge confirms tool calls as `Allow <tool>?`. Edits surface as | |
| 366 | * file-change approvals so clients render them like other providers' edits; | |
| 367 | * every other confirmation, including ones from user extensions, is a command. | |
| 368 | */ | |
| 369 | function piApprovalRequestKind(title: string): "command" | "file-change" { | |
| 370 | const toolName = /^Allow (\S+)\?$/.exec(title)?.[1]; | |
| 371 | return toolName !== undefined && | |
| 372 | (PI_FILE_CHANGE_TOOLS as ReadonlyArray<string>).includes(toolName) | |
| 373 | ? "file-change" | |
| 374 | : "command"; | |
| 375 | } | |
| 376 | ||
| 377 | interface PiThreadState { | |
| 378 | providerThread: OrchestrationV2ProviderThread; | |
| 379 | activeTurn: ActivePiTurn | null; | |
| 380 | } | |
| 381 | ||
| 382 | interface PiWake { | |
| 383 | readonly state: PiThreadState; | |
| 384 | readonly events: Array<PiRpcRecord>; | |
| 385 | } | |
| 386 | ||
| 387 | // ── adapter ─────────────────────────────────────────────────── | |
| 388 | ||
| 389 | export function makePiAdapterV2( | |
| 390 | options: PiAdapterV2Options, | |
| 391 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 392 | const { idAllocator } = options; | |
| 393 | ||
| 394 | const protocolError = (detail: string, payload?: unknown) => | |
| 395 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 396 | driver: PI_PROVIDER, | |
| 397 | detail, | |
| 398 | ...(payload === undefined ? {} : { payload }), | |
| 399 | }); | |
| 400 | ||
| 401 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 402 | instanceId: options.instanceId, | |
| 403 | driver: PI_PROVIDER, | |
| 404 | getCapabilities: () => Effect.succeed(PiProviderCapabilitiesV2), | |
| 405 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 406 | openSession: Effect.fn("PiAdapterV2.openSession")(function* ( | |
| 407 | input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, | |
| 408 | ) { | |
| 409 | const scope = yield* Effect.scope; | |
| 410 | const cwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; | |
| 411 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 412 | const provideCacheFs = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => | |
| 413 | effect.pipe( | |
| 414 | Effect.provideService(FileSystem.FileSystem, options.fileSystem), | |
| 415 | Effect.mapError( | |
| 416 | (cause) => | |
| 417 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 418 | driver: PI_PROVIDER, | |
| 419 | providerSessionId: input.providerSessionId, | |
| 420 | cause, | |
| 421 | }), | |
| 422 | ), | |
| 423 | ); | |
| 424 | // The extension owns both the optional MCP bridge and Pi's permission | |
| 425 | // hook. Materialize it even when this session has no MCP credential so | |
| 426 | // Supervised never silently degrades to unrestricted tool execution. | |
| 427 | const extensionPath = yield* provideCacheFs( | |
| 428 | materializePiT3McpExtension(options.host.paths.providerStatusCacheDir), | |
| 429 | ); | |
| 430 | const resolvedLaunchArgs = resolvePiLaunchArgs(options.settings.launchArgs); | |
| 431 | if (!resolvedLaunchArgs.ok) { | |
| 432 | return yield* protocolError(resolvedLaunchArgs.message); | |
| 433 | } | |
| 434 | const launch = buildPiRpcLaunch({ | |
| 435 | launchArgs: resolvedLaunchArgs.args, | |
| 436 | environment: options.environment, | |
| 437 | mcpSession, | |
| 438 | extensionPath, | |
| 439 | runtimeMode: input.runtimePolicy.runtimeMode, | |
| 440 | }); | |
| 441 | const connection: PiRpcConnection = yield* makePiRpcConnection({ | |
| 442 | command: options.settings.binaryPath || "pi", | |
| 443 | args: launch.args, | |
| 444 | cwd, | |
| 445 | env: launch.env, | |
| 446 | }).pipe( | |
| 447 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), | |
| 448 | Effect.mapError( | |
| 449 | (cause) => | |
| 450 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 451 | driver: PI_PROVIDER, | |
| 452 | providerSessionId: input.providerSessionId, | |
| 453 | cause, | |
| 454 | }), | |
| 455 | ), | |
| 456 | ); | |
| 457 | const discoverSkillNames = connection | |
| 458 | .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) | |
| 459 | .pipe( | |
| 460 | Effect.map( | |
| 461 | (data) => new Set(parsePiDiscoveredCommands(data).skills.map((skill) => skill.name)), | |
| 462 | ), | |
| 463 | ); | |
| 464 | let skillNames: Set<string> | null = null; | |
| 465 | ||
| 466 | const now = yield* DateTime.now; | |
| 467 | let sessionEntity: OrchestrationV2ProviderSession = { | |
| 468 | id: input.providerSessionId, | |
| 469 | driver: PI_PROVIDER, | |
| 470 | providerInstanceId: options.instanceId, | |
| 471 | status: "ready", | |
| 472 | cwd, | |
| 473 | model: input.modelSelection.model, | |
| 474 | capabilities: PiProviderCapabilitiesV2, | |
| 475 | createdAt: now, | |
| 476 | updatedAt: now, | |
| 477 | lastError: null, | |
| 478 | }; | |
| 479 | const events = yield* Queue.unbounded< | |
| 480 | ProviderAdapter.ProviderAdapterV2Event, | |
| 481 | ProviderAdapter.ProviderAdapterV2Error | Cause.Done | |
| 482 | >(); | |
| 483 | const pendingPrompts = new Map<string, PendingPiPrompt>(); | |
| 484 | const sessionApprovals = new Set<string>(); | |
| 485 | // Answering a dialog and terminalizing a turn both publish lifecycle | |
| 486 | // events. Pi can settle immediately after `extension_ui_response`, so | |
| 487 | // serialize the two paths to stop `turn.terminal` from overtaking the | |
| 488 | // dialog's own resolution updates. | |
| 489 | const sessionEventPermit = yield* Semaphore.make(1); | |
| 490 | let threadState: PiThreadState | null = null; | |
| 491 | let registrationAttempted = false; | |
| 492 | let lastNativeThreadId: string | null = null; | |
| 493 | // User Stop intentionally tears down this RPC process after aborting. | |
| 494 | // Keep that intent beyond turn finalization so the later stdout close is | |
| 495 | // not mistaken for an unexpected transport failure. | |
| 496 | let stopRequested = false; | |
| 497 | // Native work is held until an orchestrator-owned continuation or user | |
| 498 | // turn attaches. The guard remains for sessions with no routable owner. | |
| 499 | let unsolicitedActivityDetected = false; | |
| 500 | let pendingWake: PiWake | null = null; | |
| 501 | let rollbackBarrier: Deferred.Deferred< | |
| 502 | void, | |
| 503 | ProviderAdapter.ProviderAdapterProtocolError | |
| 504 | > | null = null; | |
| 505 | let closed = false; | |
| 506 | yield* Effect.addFinalizer(() => | |
| 507 | Effect.gen(function* () { | |
| 508 | closed = true; | |
| 509 | pendingWake = null; | |
| 510 | if (rollbackBarrier !== null) | |
| 511 | yield* Deferred.fail( | |
| 512 | rollbackBarrier, | |
| 513 | protocolError("Pi session closed during rollback"), | |
| 514 | ); | |
| 515 | }), | |
| 516 | ); | |
| 517 | let appliedModel: string | null = null; | |
| 518 | let appliedThinking: string | null = null; | |
| 519 | /** Last thread title synced into pi's session name (`/resume` listing). */ | |
| 520 | let appliedSessionName: string | null = null; | |
| 521 | /** Extension failures raised during startup are attached to the next turn. */ | |
| 522 | const outOfTurnExtensionErrors: Array<PiRpcRecord> = []; | |
| 523 | /** | |
| 524 | * Leaf entry id of the pi session tree as of the last turn boundary. | |
| 525 | * Turn-start user entries are located relative to it, giving each | |
| 526 | * provider turn a durable native ref for session-tree rollback. | |
| 527 | */ | |
| 528 | let lastKnownLeaf: string | null = null; | |
| 529 | /** | |
| 530 | * Set when a `get_entries` capture failed. Pi may have advanced past | |
| 531 | * `lastKnownLeaf` since, so the cursor no longer bounds a single turn | |
| 532 | * and the next capture re-syncs it instead of trusting it. | |
| 533 | */ | |
| 534 | let leafCursorStale = false; | |
| 535 | // Pi's own configured defaults, captured from the first `get_state` so | |
| 536 | // that selecting the displayed "Pi default" again can restore them. Pi | |
| 537 | // has no "unset" commands, so the baselines have to be replayed | |
| 538 | // explicitly. | |
| 539 | let baselineModel: { provider: string; modelId: string } | null = null; | |
| 540 | let baselineThinking: string | null = null; | |
| 541 | /** Context window of the model Pi currently runs, from get_state and set_model. */ | |
| 542 | let contextWindow: number | null = null; | |
| 543 | const modelContextWindows = new Map<string, number>(); | |
| 544 | let modelsDiscovered = false; | |
| 545 | // Prompt responses carry no id. Keep their session-wide send order and | |
| 546 | // owner so a late ack from a settled turn cannot affect the next turn. | |
| 547 | const pendingPromptResponses: Array<{ | |
| 548 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 549 | readonly kind: "turn_start" | "steer"; | |
| 550 | }> = []; | |
| 551 | const pendingCompactResponses: Array<{ | |
| 552 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 553 | readonly kind: "turn_start" | "steer"; | |
| 554 | }> = []; | |
| 555 | ||
| 556 | const compactRpcRecord = (command: PiCompactCommand): PiRpcRecord => | |
| 557 | command.customInstructions === undefined | |
| 558 | ? { type: "compact" } | |
| 559 | : { type: "compact", customInstructions: command.customInstructions }; | |
| 560 | ||
| 561 | const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 562 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 563 | ||
| 564 | const updateProviderSession = ( | |
| 565 | status: OrchestrationV2ProviderSession["status"], | |
| 566 | lastError: string | null = sessionEntity.lastError, | |
| 567 | ) => | |
| 568 | Effect.gen(function* () { | |
| 569 | const updatedAt = yield* DateTime.now; | |
| 570 | sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; | |
| 571 | yield* emit({ | |
| 572 | type: "provider_session.updated", | |
| 573 | driver: PI_PROVIDER, | |
| 574 | providerSession: sessionEntity, | |
| 575 | }); | |
| 576 | }); | |
| 577 | ||
| 578 | const updateProviderThread = ( | |
| 579 | state: PiThreadState, | |
| 580 | patch: Partial<OrchestrationV2ProviderThread>, | |
| 581 | ) => | |
| 582 | Effect.gen(function* () { | |
| 583 | const updatedAt = yield* DateTime.now; | |
| 584 | state.providerThread = { ...state.providerThread, ...patch, updatedAt }; | |
| 585 | yield* emit({ | |
| 586 | type: "provider_thread.updated", | |
| 587 | driver: PI_PROVIDER, | |
| 588 | providerThread: state.providerThread, | |
| 589 | }); | |
| 590 | }); | |
| 591 | ||
| 592 | const itemOrdinal = (turn: ActivePiTurn, nativeItemId: string): number => { | |
| 593 | const existing = turn.itemOrdinals.get(nativeItemId); | |
| 594 | if (existing !== undefined) return existing; | |
| 595 | const ordinal = turn.nextItemOrdinal++; | |
| 596 | turn.itemOrdinals.set(nativeItemId, ordinal); | |
| 597 | return ordinal; | |
| 598 | }; | |
| 599 | ||
| 600 | const request = (record: PiRpcRecord, timeoutMs = PI_REQUEST_TIMEOUT_MS) => | |
| 601 | connection.request(record, timeoutMs); | |
| 602 | ||
| 603 | const nonNegativeInteger = (input: unknown, key: string): number | undefined => { | |
| 604 | const value = recordNumber(input, key); | |
| 605 | return value === undefined ? undefined : Math.max(0, Math.trunc(value)); | |
| 606 | }; | |
| 607 | ||
| 608 | const rememberModelContextWindow = (model: unknown): number | null => { | |
| 609 | const provider = recordString(model, "provider"); | |
| 610 | const id = recordString(model, "id"); | |
| 611 | const capacity = nonNegativeInteger(model, "contextWindow"); | |
| 612 | if (provider !== undefined && id !== undefined && capacity !== undefined && capacity > 0) { | |
| 613 | modelContextWindows.set(`${provider}/${id}`, capacity); | |
| 614 | return capacity; | |
| 615 | } | |
| 616 | return null; | |
| 617 | }; | |
| 618 | ||
| 619 | const lifecycleRequest = (record: PiRpcRecord) => | |
| 620 | request(record, PI_SESSION_TIMEOUT_MS).pipe( | |
| 621 | // A local timeout does not cancel Pi's lifecycle hook. Retire the | |
| 622 | // process before fallback can race its eventual switch/new-session. | |
| 623 | Effect.tapError((error) => | |
| 624 | Effect.logWarning("Pi session lifecycle request failed", { | |
| 625 | providerSessionId: input.providerSessionId, | |
| 626 | operation: record["type"], | |
| 627 | errorTag: error._tag, | |
| 628 | }), | |
| 629 | ), | |
| 630 | Effect.catchTags({ | |
| 631 | PiRpcTimeoutError: (error) => | |
| 632 | connection.terminate.pipe(Effect.andThen(Effect.fail(error))), | |
| 633 | }), | |
| 634 | Effect.onInterrupt(() => connection.terminate), | |
| 635 | ); | |
| 636 | ||
| 637 | const tokenUsageFromStats = ( | |
| 638 | stats: unknown, | |
| 639 | fallbackUsedTokens: number | null, | |
| 640 | updatedAt: DateTime.Utc, | |
| 641 | ): OrchestrationV2ProviderTurnTokenUsage | undefined => { | |
| 642 | const contextUsage = recordField(stats, "contextUsage"); | |
| 643 | const maxTokens = nonNegativeInteger(contextUsage, "contextWindow"); | |
| 644 | const usedTokens = | |
| 645 | nonNegativeInteger(contextUsage, "tokens") ?? fallbackUsedTokens ?? undefined; | |
| 646 | if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) | |
| 647 | return undefined; | |
| 648 | ||
| 649 | const totals = recordField(stats, "tokens"); | |
| 650 | const inputTokens = nonNegativeInteger(totals, "input"); | |
| 651 | const cachedInputTokens = nonNegativeInteger(totals, "cacheRead"); | |
| 652 | const outputTokens = nonNegativeInteger(totals, "output"); | |
| 653 | return { | |
| 654 | usedTokens, | |
| 655 | maxTokens, | |
| 656 | ...(inputTokens === undefined ? {} : { inputTokens }), | |
| 657 | ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), | |
| 658 | ...(outputTokens === undefined ? {} : { outputTokens }), | |
| 659 | updatedAt: DateTime.formatIso(updatedAt), | |
| 660 | }; | |
| 661 | }; | |
| 662 | ||
| 663 | /** | |
| 664 | * Pi only reports context usage through `get_session_stats`, so the | |
| 665 | * settled turn carries it on the base's per-turn `tokenUsage` (#8144). | |
| 666 | * Usage is secondary telemetry: the request is bounded and a provider | |
| 667 | * version without stats simply leaves the turn without a report, which | |
| 668 | * keeps the meter on the last turn that had one. | |
| 669 | */ | |
| 670 | const readTokenUsage = (fallbackUsedTokens: number | null, updatedAt: DateTime.Utc) => | |
| 671 | request({ type: "get_session_stats" }, 2_000).pipe( | |
| 672 | Effect.map((stats) => tokenUsageFromStats(stats, fallbackUsedTokens, updatedAt)), | |
| 673 | Effect.orElseSucceed(() => undefined), | |
| 674 | ); | |
| 675 | ||
| 676 | /** | |
| 677 | * Pi attaches the current message's cumulative usage to every streaming | |
| 678 | * update (0.84.2+). Emit it on the running turn only when the total | |
| 679 | * changes, so the meter moves live without a burst of no-op updates. | |
| 680 | */ | |
| 681 | const reportLiveUsage = (turn: ActivePiTurn, usage: unknown) => | |
| 682 | Effect.gen(function* () { | |
| 683 | const usedTokens = nonNegativeInteger(usage, "totalTokens"); | |
| 684 | if ( | |
| 685 | usedTokens === undefined || | |
| 686 | usedTokens === 0 || | |
| 687 | usedTokens === turn.lastLiveUsedTokens || | |
| 688 | contextWindow === null || | |
| 689 | contextWindow === 0 | |
| 690 | ) { | |
| 691 | return; | |
| 692 | } | |
| 693 | turn.lastLiveUsedTokens = usedTokens; | |
| 694 | const inputTokens = nonNegativeInteger(usage, "input"); | |
| 695 | const cachedInputTokens = nonNegativeInteger(usage, "cacheRead"); | |
| 696 | const outputTokens = nonNegativeInteger(usage, "output"); | |
| 697 | const updatedAt = yield* DateTime.now; | |
| 698 | yield* emit({ | |
| 699 | type: "provider_turn.updated", | |
| 700 | driver: PI_PROVIDER, | |
| 701 | threadId: turn.turnInput.threadId, | |
| 702 | providerTurn: { | |
| 703 | ...turn.providerTurn, | |
| 704 | tokenUsage: { | |
| 705 | usedTokens, | |
| 706 | maxTokens: contextWindow, | |
| 707 | ...(inputTokens === undefined ? {} : { inputTokens }), | |
| 708 | ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), | |
| 709 | ...(outputTokens === undefined ? {} : { outputTokens }), | |
| 710 | updatedAt: DateTime.formatIso(updatedAt), | |
| 711 | }, | |
| 712 | }, | |
| 713 | }); | |
| 714 | }); | |
| 715 | ||
| 716 | const baseItemFields = ( | |
| 717 | turn: ActivePiTurn, | |
| 718 | nativeItemId: string, | |
| 719 | startedAt: DateTime.Utc, | |
| 720 | updatedAt: DateTime.Utc, | |
| 721 | ) => ({ | |
| 722 | id: idAllocator.derive.turnItemFromProviderItem({ | |
| 723 | driver: PI_PROVIDER, | |
| 724 | nativeItemId, | |
| 725 | }), | |
| 726 | threadId: turn.turnInput.threadId, | |
| 727 | runId: turn.turnInput.runId, | |
| 728 | nodeId: idAllocator.derive.nodeFromProviderItem({ | |
| 729 | driver: PI_PROVIDER, | |
| 730 | nativeItemId, | |
| 731 | }), | |
| 732 | providerThreadId: turn.turnInput.providerThread.id, | |
| 733 | providerTurnId: turn.providerTurn.id, | |
| 734 | nativeItemRef: providerRef(nativeItemId), | |
| 735 | parentItemId: null, | |
| 736 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 737 | startedAt, | |
| 738 | updatedAt, | |
| 739 | }); | |
| 740 | ||
| 741 | const emitItemNode = ( | |
| 742 | turn: ActivePiTurn, | |
| 743 | nativeItemId: string, | |
| 744 | kind: OrchestrationV2ExecutionNode["kind"], | |
| 745 | status: OrchestrationV2ExecutionNode["status"], | |
| 746 | startedAt: DateTime.Utc, | |
| 747 | completedAt: DateTime.Utc | null, | |
| 748 | ) => | |
| 749 | emit({ | |
| 750 | type: "node.updated", | |
| 751 | driver: PI_PROVIDER, | |
| 752 | node: { | |
| 753 | id: idAllocator.derive.nodeFromProviderItem({ | |
| 754 | driver: PI_PROVIDER, | |
| 755 | nativeItemId, | |
| 756 | }), | |
| 757 | threadId: turn.turnInput.threadId, | |
| 758 | runId: turn.turnInput.runId, | |
| 759 | parentNodeId: turn.turnInput.rootNodeId, | |
| 760 | rootNodeId: turn.turnInput.rootNodeId, | |
| 761 | kind, | |
| 762 | status, | |
| 763 | countsForRun: false, | |
| 764 | providerThreadId: turn.turnInput.providerThread.id, | |
| 765 | providerTurnId: turn.providerTurn.id, | |
| 766 | nativeItemRef: providerRef(nativeItemId), | |
| 767 | runtimeRequestId: null, | |
| 768 | checkpointScopeId: null, | |
| 769 | startedAt, | |
| 770 | completedAt, | |
| 771 | }, | |
| 772 | }); | |
| 773 | ||
| 774 | const emitProviderRetry = Effect.fnUntraced(function* ( | |
| 775 | turn: ActivePiTurn, | |
| 776 | providerRetry: PiProviderRetryState, | |
| 777 | status: "running" | "completed" | "failed" | "interrupted" | "cancelled", | |
| 778 | updatedAt: DateTime.Utc, | |
| 779 | ) { | |
| 780 | yield* emit({ | |
| 781 | type: "turn_item.updated", | |
| 782 | driver: PI_PROVIDER, | |
| 783 | turnItem: makeProviderRetryTurnItem({ | |
| 784 | idAllocator, | |
| 785 | driver: PI_PROVIDER, | |
| 786 | threadId: turn.turnInput.threadId, | |
| 787 | runId: turn.turnInput.runId, | |
| 788 | nodeId: turn.turnInput.rootNodeId, | |
| 789 | providerThreadId: turn.turnInput.providerThread.id, | |
| 790 | providerTurnId: turn.providerTurn.id, | |
| 791 | itemOrdinal: providerRetry.itemOrdinal, | |
| 792 | failure: providerRetry.failure, | |
| 793 | retry: providerRetry.retry, | |
| 794 | status, | |
| 795 | startedAt: providerRetry.startedAt, | |
| 796 | updatedAt, | |
| 797 | }), | |
| 798 | }); | |
| 799 | }); | |
| 800 | ||
| 801 | const compactionNativeItemId = (turn: ActivePiTurn): string => | |
| 802 | `compaction:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; | |
| 803 | ||
| 804 | const emitCompaction = Effect.fnUntraced(function* ( | |
| 805 | turn: ActivePiTurn, | |
| 806 | compaction: PiCompactionState, | |
| 807 | status: PiCompactionStatus, | |
| 808 | details: { | |
| 809 | readonly summary?: string; | |
| 810 | readonly beforeTokenCount?: number; | |
| 811 | readonly afterTokenCount?: number; | |
| 812 | } = {}, | |
| 813 | ) { | |
| 814 | const emittedAt = yield* DateTime.now; | |
| 815 | const completedAt = status === "running" ? null : emittedAt; | |
| 816 | yield* emitItemNode( | |
| 817 | turn, | |
| 818 | compaction.nativeItemId, | |
| 819 | "system", | |
| 820 | status, | |
| 821 | compaction.startedAt, | |
| 822 | completedAt, | |
| 823 | ); | |
| 824 | yield* emit({ | |
| 825 | type: "turn_item.updated", | |
| 826 | driver: PI_PROVIDER, | |
| 827 | turnItem: { | |
| 828 | ...baseItemFields(turn, compaction.nativeItemId, compaction.startedAt, emittedAt), | |
| 829 | status, | |
| 830 | title: compactionTitle(status), | |
| 831 | completedAt, | |
| 832 | type: "compaction", | |
| 833 | driver: PI_PROVIDER, | |
| 834 | ...details, | |
| 835 | }, | |
| 836 | }); | |
| 837 | }); | |
| 838 | ||
| 839 | // ── streaming text / reasoning ──────────────────────── | |
| 840 | ||
| 841 | const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => | |
| 842 | Effect.gen(function* () { | |
| 843 | const emittedAt = yield* DateTime.now; | |
| 844 | const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); | |
| 845 | yield* emitItemNode( | |
| 846 | turn, | |
| 847 | item.nativeItemId, | |
| 848 | item.kind, | |
| 849 | streaming ? "running" : "completed", | |
| 850 | item.startedAt, | |
| 851 | streaming ? null : emittedAt, | |
| 852 | ); | |
| 853 | if (item.kind === "assistant_message") { | |
| 854 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 855 | driver: PI_PROVIDER, | |
| 856 | nativeItemId: item.nativeItemId, | |
| 857 | }); | |
| 858 | yield* emit({ | |
| 859 | type: "turn_item.updated", | |
| 860 | driver: PI_PROVIDER, | |
| 861 | turnItem: { | |
| 862 | ...base, | |
| 863 | status: streaming ? "running" : "completed", | |
| 864 | title: null, | |
| 865 | completedAt: streaming ? null : emittedAt, | |
| 866 | type: "assistant_message", | |
| 867 | messageId, | |
| 868 | text: item.text, | |
| 869 | streaming, | |
| 870 | }, | |
| 871 | }); | |
| 872 | yield* emit({ | |
| 873 | type: "message.updated", | |
| 874 | driver: PI_PROVIDER, | |
| 875 | message: { | |
| 876 | id: messageId, | |
| 877 | threadId: turn.turnInput.threadId, | |
| 878 | runId: turn.turnInput.runId, | |
| 879 | nodeId: idAllocator.derive.nodeFromProviderItem({ | |
| 880 | driver: PI_PROVIDER, | |
| 881 | nativeItemId: item.nativeItemId, | |
| 882 | }), | |
| 883 | role: "assistant", | |
| 884 | text: item.text, | |
| 885 | attachments: [], | |
| 886 | streaming, | |
| 887 | createdBy: "agent", | |
| 888 | creationSource: "provider", | |
| 889 | createdAt: item.startedAt, | |
| 890 | updatedAt: emittedAt, | |
| 891 | }, | |
| 892 | }); | |
| 893 | return; | |
| 894 | } | |
| 895 | yield* emit({ | |
| 896 | type: "turn_item.updated", | |
| 897 | driver: PI_PROVIDER, | |
| 898 | turnItem: { | |
| 899 | ...base, | |
| 900 | status: streaming ? "running" : "completed", | |
| 901 | title: null, | |
| 902 | completedAt: streaming ? null : emittedAt, | |
| 903 | type: "reasoning", | |
| 904 | text: item.text, | |
| 905 | streaming, | |
| 906 | }, | |
| 907 | }); | |
| 908 | }); | |
| 909 | ||
| 910 | const scheduleStreamFlush = (turn: ActivePiTurn, item: PiStreamItemState) => | |
| 911 | Effect.gen(function* () { | |
| 912 | if (item.flushScheduled || item.completed) return; | |
| 913 | item.flushScheduled = true; | |
| 914 | yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( | |
| 915 | Effect.andThen( | |
| 916 | Effect.suspend(() => { | |
| 917 | item.flushScheduled = false; | |
| 918 | return item.completed ? Effect.void : emitStreamItem(turn, item, true); | |
| 919 | }), | |
| 920 | ), | |
| 921 | Effect.forkIn(scope), | |
| 922 | ); | |
| 923 | }); | |
| 924 | ||
| 925 | const streamItemFor = Effect.fnUntraced(function* ( | |
| 926 | turn: ActivePiTurn, | |
| 927 | kind: PiStreamItemState["kind"], | |
| 928 | contentIndex: number, | |
| 929 | ) { | |
| 930 | const nativeItemId = `${turn.providerTurn.id}:m${turn.messageOrdinal}:c${contentIndex}`; | |
| 931 | const existing = turn.streamItems.get(nativeItemId); | |
| 932 | if (existing !== undefined) return existing; | |
| 933 | const startedAt = yield* DateTime.now; | |
| 934 | const item: PiStreamItemState = { | |
| 935 | nativeItemId, | |
| 936 | kind, | |
| 937 | text: "", | |
| 938 | completed: false, | |
| 939 | flushScheduled: false, | |
| 940 | startedAt, | |
| 941 | }; | |
| 942 | turn.streamItems.set(nativeItemId, item); | |
| 943 | // Ordinal reserved on first delta so items appear in stream order. | |
| 944 | itemOrdinal(turn, nativeItemId); | |
| 945 | return item; | |
| 946 | }); | |
| 947 | ||
| 948 | const completeStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, text?: string) => | |
| 949 | Effect.suspend(() => { | |
| 950 | if (item.completed) return Effect.void; | |
| 951 | item.completed = true; | |
| 952 | if (text !== undefined && text.length > 0) item.text = text; | |
| 953 | return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); | |
| 954 | }); | |
| 955 | ||
| 956 | const completeOpenStreamItems = (turn: ActivePiTurn) => | |
| 957 | Effect.forEach( | |
| 958 | Array.from(turn.streamItems.values()).filter((item) => !item.completed), | |
| 959 | (item) => completeStreamItem(turn, item), | |
| 960 | { discard: true }, | |
| 961 | ); | |
| 962 | ||
| 963 | // ── tools ───────────────────────────────────────────── | |
| 964 | ||
| 965 | const emitToolItem = Effect.fnUntraced(function* ( | |
| 966 | turn: ActivePiTurn, | |
| 967 | event: PiRpcRecord, | |
| 968 | phase: "start" | "update" | "end", | |
| 969 | ) { | |
| 970 | const toolCallId = recordString(event, "toolCallId"); | |
| 971 | const toolName = recordString(event, "toolName") ?? "tool"; | |
| 972 | if (toolCallId === undefined) return; | |
| 973 | if (phase === "start") { | |
| 974 | turn.toolArgs.set(toolCallId, event["args"]); | |
| 975 | } | |
| 976 | const args = event["args"] ?? turn.toolArgs.get(toolCallId); | |
| 977 | const emittedAt = yield* DateTime.now; | |
| 978 | const startedAt = turn.toolStartedAt.get(toolCallId) ?? emittedAt; | |
| 979 | turn.toolStartedAt.set(toolCallId, startedAt); | |
| 980 | const completed = phase === "end"; | |
| 981 | const isError = event["isError"] === true; | |
| 982 | const resultRecord = completed ? event["result"] : event["partialResult"]; | |
| 983 | const outputText = contentText(recordField(resultRecord, "content")); | |
| 984 | // A Stop aborts in-flight tools, and pi reports those as error ends. | |
| 985 | // Present them as interrupted (matching the run) rather than failed. | |
| 986 | const status = completed | |
| 987 | ? isError | |
| 988 | ? turn.interrupted | |
| 989 | ? "interrupted" | |
| 990 | : "failed" | |
| 991 | : "completed" | |
| 992 | : "running"; | |
| 993 | const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); | |
| 994 | yield* emitItemNode( | |
| 995 | turn, | |
| 996 | toolCallId, | |
| 997 | "tool_call", | |
| 998 | status, | |
| 999 | startedAt, | |
| 1000 | completed ? emittedAt : null, | |
| 1001 | ); | |
| 1002 | const shared = { | |
| 1003 | ...base, | |
| 1004 | status, | |
| 1005 | completedAt: completed ? emittedAt : null, | |
| 1006 | } as const; | |
| 1007 | if (toolName === "bash") { | |
| 1008 | const exitCode = recordNumber(recordField(resultRecord, "details"), "exitCode"); | |
| 1009 | yield* emit({ | |
| 1010 | type: "turn_item.updated", | |
| 1011 | driver: PI_PROVIDER, | |
| 1012 | turnItem: { | |
| 1013 | ...shared, | |
| 1014 | title: toolName, | |
| 1015 | type: "command_execution", | |
| 1016 | input: recordString(args, "command") ?? "", | |
| 1017 | ...(outputText.length > 0 ? { output: outputText } : {}), | |
| 1018 | ...(exitCode === undefined ? {} : { exitCode }), | |
| 1019 | }, | |
| 1020 | }); | |
| 1021 | return; | |
| 1022 | } | |
| 1023 | if (toolName === "edit" || toolName === "write") { | |
| 1024 | const fileName = recordString(args, "path") ?? recordString(args, "file_path"); | |
| 1025 | if (fileName !== undefined) { | |
| 1026 | // edit reports a unified patch in its result details; write only | |
| 1027 | // carries the new content in its args. A failed call keeps its error. | |
| 1028 | const diffStr = | |
| 1029 | recordString(recordField(resultRecord, "details"), "patch") ?? | |
| 1030 | (isError && outputText.trim().length > 0 ? outputText : undefined); | |
| 1031 | const newStr = toolName === "write" ? recordString(args, "content") : undefined; | |
| 1032 | yield* emit({ | |
| 1033 | type: "turn_item.updated", | |
| 1034 | driver: PI_PROVIDER, | |
| 1035 | turnItem: { | |
| 1036 | ...shared, | |
| 1037 | title: toolName, | |
| 1038 | type: "file_change", | |
| 1039 | fileName, | |
| 1040 | ...(diffStr === undefined ? {} : { diffStr }), | |
| 1041 | ...(newStr === undefined ? {} : { newStr }), | |
| 1042 | }, | |
| 1043 | }); | |
| 1044 | return; | |
| 1045 | } | |
| 1046 | } | |
| 1047 | yield* emit({ | |
| 1048 | type: "turn_item.updated", | |
| 1049 | driver: PI_PROVIDER, | |
| 1050 | turnItem: { | |
| 1051 | ...shared, | |
| 1052 | title: toolName, | |
| 1053 | type: "dynamic_tool", | |
| 1054 | ...mcpToolPresentation({ toolName }), | |
| 1055 | toolName, | |
| 1056 | input: args ?? {}, | |
| 1057 | ...(outputText.length > 0 ? { output: outputText } : {}), | |
| 1058 | }, | |
| 1059 | }); | |
| 1060 | if (toolName === "subagent") { | |
| 1061 | yield* emitSubagentTasks(turn, toolCallId, resultRecord, completed); | |
| 1062 | } | |
| 1063 | }); | |
| 1064 | ||
| 1065 | /** | |
| 1066 | * Observe the result shape from Pi's official example subagent extension. | |
| 1067 | * The extension runs children with --no-session, so these entries are | |
| 1068 | * visible in T3's shared subagent UI without inventing a child thread. | |
| 1069 | * Unknown or changed result shapes stay ordinary dynamic tool output. | |
| 1070 | */ | |
| 1071 | const emitSubagentTasks = Effect.fnUntraced(function* ( | |
| 1072 | turn: ActivePiTurn, | |
| 1073 | toolCallId: string, | |
| 1074 | resultRecord: unknown, | |
| 1075 | completed: boolean, | |
| 1076 | ) { | |
| 1077 | const results = recordField(recordField(resultRecord, "details"), "results"); | |
| 1078 | if (!Array.isArray(results)) return; | |
| 1079 | const emittedAt = yield* DateTime.now; | |
| 1080 | const parentNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1081 | driver: PI_PROVIDER, | |
| 1082 | nativeItemId: toolCallId, | |
| 1083 | }); | |
| 1084 | for (const [index, result] of results.entries()) { | |
| 1085 | const agent = recordString(result, "agent"); | |
| 1086 | const task = recordString(result, "task"); | |
| 1087 | if (agent === undefined || task === undefined) continue; | |
| 1088 | const nativeTaskId = `${toolCallId}:subagent:${recordNumber(result, "step") ?? index}`; | |
| 1089 | const subagentId = idAllocator.derive.nodeFromProviderItem({ | |
| 1090 | driver: PI_PROVIDER, | |
| 1091 | nativeItemId: nativeTaskId, | |
| 1092 | }); | |
| 1093 | const startedAt = turn.toolStartedAt.get(nativeTaskId) ?? emittedAt; | |
| 1094 | turn.toolStartedAt.set(nativeTaskId, startedAt); | |
| 1095 | const finished = completed || recordField(result, "finished") === true; | |
| 1096 | const stopReason = recordString(result, "stopReason"); | |
| 1097 | const interrupted = finished && stopReason === "aborted"; | |
| 1098 | const failed = | |
| 1099 | finished && | |
| 1100 | !interrupted && | |
| 1101 | ((recordNumber(result, "exitCode") ?? 0) !== 0 || stopReason === "error"); | |
| 1102 | const status = interrupted | |
| 1103 | ? "interrupted" | |
| 1104 | : failed | |
| 1105 | ? "failed" | |
| 1106 | : finished | |
| 1107 | ? "completed" | |
| 1108 | : "running"; | |
| 1109 | const outputText = piSubagentOutput(result); | |
| 1110 | const progress = | |
| 1111 | !finished && outputText.length > 0 ? { progress: outputText.slice(0, 200) } : {}; | |
| 1112 | const resultText = finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null; | |
| 1113 | yield* emit({ | |
| 1114 | type: "subagent.updated", | |
| 1115 | driver: PI_PROVIDER, | |
| 1116 | subagent: { | |
| 1117 | id: subagentId, | |
| 1118 | threadId: turn.turnInput.threadId, | |
| 1119 | runId: turn.turnInput.runId, | |
| 1120 | parentNodeId, | |
| 1121 | origin: "provider_native", | |
| 1122 | createdBy: "agent", | |
| 1123 | driver: PI_PROVIDER, | |
| 1124 | providerInstanceId: options.instanceId, | |
| 1125 | providerThreadId: turn.turnInput.providerThread.id, | |
| 1126 | childThreadId: null, | |
| 1127 | nativeTaskRef: providerRef(nativeTaskId), | |
| 1128 | prompt: task, | |
| 1129 | title: agent, | |
| 1130 | model: recordString(result, "model") ?? null, | |
| 1131 | status, | |
| 1132 | ...progress, | |
| 1133 | result: resultText, | |
| 1134 | startedAt, | |
| 1135 | completedAt: finished ? emittedAt : null, | |
| 1136 | updatedAt: emittedAt, | |
| 1137 | }, | |
| 1138 | }); | |
| 1139 | yield* emit({ | |
| 1140 | type: "turn_item.updated", | |
| 1141 | driver: PI_PROVIDER, | |
| 1142 | turnItem: { | |
| 1143 | ...baseItemFields(turn, nativeTaskId, startedAt, emittedAt), | |
| 1144 | status, | |
| 1145 | title: agent, | |
| 1146 | completedAt: finished ? emittedAt : null, | |
| 1147 | type: "subagent", | |
| 1148 | subagentId, | |
| 1149 | origin: "provider_native", | |
| 1150 | driver: PI_PROVIDER, | |
| 1151 | providerInstanceId: options.instanceId, | |
| 1152 | childThreadId: null, | |
| 1153 | prompt: task, | |
| 1154 | ...progress, | |
| 1155 | result: resultText, | |
| 1156 | }, | |
| 1157 | }); | |
| 1158 | } | |
| 1159 | }); | |
| 1160 | ||
| 1161 | // ── extension UI prompts ────────────────────────────── | |
| 1162 | ||
| 1163 | const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => | |
| 1164 | Effect.gen(function* () { | |
| 1165 | yield* connection | |
| 1166 | .send({ | |
| 1167 | type: "extension_ui_response", | |
| 1168 | id: pending.nativeRequestId, | |
| 1169 | cancelled: true, | |
| 1170 | }) | |
| 1171 | .pipe(Effect.ignore); | |
| 1172 | pending.runtimeRequest = { | |
| 1173 | ...pending.runtimeRequest, | |
| 1174 | status: "cancelled", | |
| 1175 | resolvedAt, | |
| 1176 | }; | |
| 1177 | yield* emit({ | |
| 1178 | type: "runtime_request.updated", | |
| 1179 | driver: PI_PROVIDER, | |
| 1180 | threadId: pending.node.threadId, | |
| 1181 | runtimeRequest: pending.runtimeRequest, | |
| 1182 | }); | |
| 1183 | yield* emit({ | |
| 1184 | type: "node.updated", | |
| 1185 | driver: PI_PROVIDER, | |
| 1186 | node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, | |
| 1187 | }); | |
| 1188 | yield* emit({ | |
| 1189 | type: "turn_item.updated", | |
| 1190 | driver: PI_PROVIDER, | |
| 1191 | turnItem: { | |
| 1192 | ...pending.turnItem, | |
| 1193 | status: "cancelled", | |
| 1194 | completedAt: resolvedAt, | |
| 1195 | updatedAt: resolvedAt, | |
| 1196 | }, | |
| 1197 | }); | |
| 1198 | }); | |
| 1199 | ||
| 1200 | const cancelPendingPrompts = (resolvedAt: DateTime.Utc) => | |
| 1201 | Effect.gen(function* () { | |
| 1202 | const pending = Array.from(pendingPrompts.values()); | |
| 1203 | pendingPrompts.clear(); | |
| 1204 | yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { | |
| 1205 | discard: true, | |
| 1206 | }); | |
| 1207 | }); | |
| 1208 | ||
| 1209 | const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1210 | const method = recordString(event, "method"); | |
| 1211 | const nativeRequestId = recordString(event, "id"); | |
| 1212 | if (method === undefined) return; | |
| 1213 | if (method === "notify") { | |
| 1214 | const state = threadState; | |
| 1215 | const turn = state?.activeTurn ?? null; | |
| 1216 | const message = recordString(event, "message") ?? ""; | |
| 1217 | if (turn === null || message.length === 0) return; | |
| 1218 | const emittedAt = yield* DateTime.now; | |
| 1219 | const nativeItemId = `notify:${turn.nextItemOrdinal}`; | |
| 1220 | yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); | |
| 1221 | yield* emit({ | |
| 1222 | type: "turn_item.updated", | |
| 1223 | driver: PI_PROVIDER, | |
| 1224 | turnItem: { | |
| 1225 | ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), | |
| 1226 | status: "completed", | |
| 1227 | completedAt: emittedAt, | |
| 1228 | title: "notify", | |
| 1229 | type: "dynamic_tool", | |
| 1230 | toolName: "notify", | |
| 1231 | input: { | |
| 1232 | message, | |
| 1233 | notifyType: recordString(event, "notifyType") ?? "info", | |
| 1234 | }, | |
| 1235 | }, | |
| 1236 | }); | |
| 1237 | return; | |
| 1238 | } | |
| 1239 | if ( | |
| 1240 | method !== "select" && | |
| 1241 | method !== "confirm" && | |
| 1242 | method !== "input" && | |
| 1243 | method !== "editor" | |
| 1244 | ) { | |
| 1245 | // Terminal decoration has no matching T3 surface. | |
| 1246 | yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); | |
| 1247 | return; | |
| 1248 | } | |
| 1249 | if (nativeRequestId === undefined) return; | |
| 1250 | const approvalTitle = recordString(event, "title") ?? ""; | |
| 1251 | const approvalKey = `${approvalTitle.length}:${approvalTitle}${recordString(event, "message") ?? ""}`; | |
| 1252 | if (method === "confirm" && sessionApprovals.has(approvalKey)) { | |
| 1253 | yield* connection.send({ | |
| 1254 | type: "extension_ui_response", | |
| 1255 | id: nativeRequestId, | |
| 1256 | confirmed: true, | |
| 1257 | }); | |
| 1258 | return; | |
| 1259 | } | |
| 1260 | const state = threadState; | |
| 1261 | const turn = state?.activeTurn ?? null; | |
| 1262 | const createdAt = yield* DateTime.now; | |
| 1263 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1264 | driver: PI_PROVIDER, | |
| 1265 | ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), | |
| 1266 | nativeRequestId, | |
| 1267 | }); | |
| 1268 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1269 | const title = recordString(event, "title") ?? method; | |
| 1270 | const threadId = | |
| 1271 | turn?.turnInput.threadId ?? state?.providerThread.appThreadId ?? input.threadId; | |
| 1272 | const providerThreadId = state?.providerThread.id ?? null; | |
| 1273 | const providerTurnId = turn?.providerTurn.id ?? null; | |
| 1274 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1275 | id: requestId, | |
| 1276 | nodeId, | |
| 1277 | providerTurnId, | |
| 1278 | nativeRequestRef: providerRef(nativeRequestId), | |
| 1279 | kind: method === "confirm" ? "command" : "user_input", | |
| 1280 | status: "pending", | |
| 1281 | responseCapability: { type: "live", providerSessionId: input.providerSessionId }, | |
| 1282 | createdAt, | |
| 1283 | resolvedAt: null, | |
| 1284 | }; | |
| 1285 | const node: OrchestrationV2ExecutionNode = { | |
| 1286 | id: nodeId, | |
| 1287 | threadId, | |
| 1288 | runId: turn?.turnInput.runId ?? null, | |
| 1289 | parentNodeId: turn?.turnInput.rootNodeId ?? null, | |
| 1290 | rootNodeId: turn?.turnInput.rootNodeId ?? nodeId, | |
| 1291 | kind: method === "confirm" ? "approval_request" : "user_input_request", | |
| 1292 | status: "waiting", | |
| 1293 | countsForRun: false, | |
| 1294 | providerThreadId, | |
| 1295 | providerTurnId, | |
| 1296 | nativeItemRef: providerRef(nativeRequestId), | |
| 1297 | runtimeRequestId: requestId, | |
| 1298 | checkpointScopeId: null, | |
| 1299 | startedAt: createdAt, | |
| 1300 | completedAt: null, | |
| 1301 | }; | |
| 1302 | const itemBase = { | |
| 1303 | id: idAllocator.derive.approvalTurnItem({ requestId }), | |
| 1304 | threadId, | |
| 1305 | runId: turn?.turnInput.runId ?? null, | |
| 1306 | nodeId, | |
| 1307 | providerThreadId, | |
| 1308 | providerTurnId, | |
| 1309 | nativeItemRef: providerRef(nativeRequestId), | |
| 1310 | parentItemId: null, | |
| 1311 | // Runless startup/session-switch requests are normalized into the | |
| 1312 | // thread-level ordinal range by TurnItemPositionStore. | |
| 1313 | ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), | |
| 1314 | status: "waiting" as const, | |
| 1315 | title, | |
| 1316 | startedAt: createdAt, | |
| 1317 | completedAt: null, | |
| 1318 | updatedAt: createdAt, | |
| 1319 | }; | |
| 1320 | const turnItem: OrchestrationV2TurnItem = | |
| 1321 | method === "confirm" | |
| 1322 | ? { | |
| 1323 | ...itemBase, | |
| 1324 | type: "approval_request", | |
| 1325 | requestId, | |
| 1326 | requestKind: piApprovalRequestKind(title), | |
| 1327 | prompt: recordString(event, "message") ?? title, | |
| 1328 | } | |
| 1329 | : { | |
| 1330 | ...itemBase, | |
| 1331 | type: "user_input_request", | |
| 1332 | requestId, | |
| 1333 | questions: [piQuestion(nativeRequestId, method, title, event)], | |
| 1334 | }; | |
| 1335 | pendingPrompts.set(String(requestId), { | |
| 1336 | nativeRequestId, | |
| 1337 | method, | |
| 1338 | questionId: nativeRequestId, | |
| 1339 | approvalKey, | |
| 1340 | runtimeRequest, | |
| 1341 | node, | |
| 1342 | turnItem, | |
| 1343 | }); | |
| 1344 | yield* emit({ | |
| 1345 | type: "runtime_request.updated", | |
| 1346 | driver: PI_PROVIDER, | |
| 1347 | threadId, | |
| 1348 | runtimeRequest, | |
| 1349 | }); | |
| 1350 | yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); | |
| 1351 | yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); | |
| 1352 | }); | |
| 1353 | ||
| 1354 | const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1355 | const state = threadState; | |
| 1356 | const turn = state?.activeTurn ?? null; | |
| 1357 | if (turn === null) { | |
| 1358 | outOfTurnExtensionErrors.push(event); | |
| 1359 | return; | |
| 1360 | } | |
| 1361 | const emittedAt = yield* DateTime.now; | |
| 1362 | const nativeItemId = `extension-error:${turn.nextItemOrdinal}`; | |
| 1363 | const extensionName = piExtensionDisplayName(recordString(event, "extensionPath")); | |
| 1364 | const extensionEvent = recordString(event, "event"); | |
| 1365 | const detail = recordString(event, "error")?.trim(); | |
| 1366 | const message = [ | |
| 1367 | `${extensionName} failed${extensionEvent === undefined ? "" : ` during ${extensionEvent}`}.`, | |
| 1368 | detail === undefined || detail.length === 0 ? undefined : detail.slice(0, 2_000), | |
| 1369 | ] | |
| 1370 | .filter((part): part is string => part !== undefined) | |
| 1371 | .join("\n\n"); | |
| 1372 | const failure = makeProviderFailure({ | |
| 1373 | message, | |
| 1374 | class: "provider_error", | |
| 1375 | retryable: false, | |
| 1376 | }); | |
| 1377 | yield* emitItemNode(turn, nativeItemId, "system", "failed", emittedAt, emittedAt); | |
| 1378 | yield* emit({ | |
| 1379 | type: "turn_item.updated", | |
| 1380 | driver: PI_PROVIDER, | |
| 1381 | turnItem: { | |
| 1382 | ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), | |
| 1383 | status: "failed", | |
| 1384 | title: extensionName, | |
| 1385 | completedAt: emittedAt, | |
| 1386 | type: "error", | |
| 1387 | failure, | |
| 1388 | }, | |
| 1389 | }); | |
| 1390 | }); | |
| 1391 | ||
| 1392 | // ── turn lifecycle ──────────────────────────────────── | |
| 1393 | ||
| 1394 | /** | |
| 1395 | * Locate this turn's first user entry and the new leaf in pi's session | |
| 1396 | * tree. The user-entry id becomes the provider turn's native ref (the | |
| 1397 | * point `fork` rolls back to); the leaf becomes the conversation head. | |
| 1398 | * Pure bookkeeping: failures degrade to the synthetic refs. | |
| 1399 | */ | |
| 1400 | const captureTurnTreeRefs = Effect.fnUntraced(function* ( | |
| 1401 | timeoutMs = PI_REQUEST_TIMEOUT_MS, | |
| 1402 | ): Effect.fn.Return<PiTurnTreeRefs | null> { | |
| 1403 | const cursorWasStale = leafCursorStale; | |
| 1404 | const cursor = cursorWasStale ? null : lastKnownLeaf; | |
| 1405 | const data = yield* request( | |
| 1406 | { | |
| 1407 | type: "get_entries", | |
| 1408 | ...(cursor === null ? {} : { since: cursor }), | |
| 1409 | }, | |
| 1410 | timeoutMs, | |
| 1411 | ).pipe(Effect.orElseSucceed(() => undefined)); | |
| 1412 | if (data === undefined) { | |
| 1413 | // Pi may have advanced past `lastKnownLeaf` while this failed, so the | |
| 1414 | // cursor can no longer be trusted to bound a single turn. | |
| 1415 | leafCursorStale = true; | |
| 1416 | return null; | |
| 1417 | } | |
| 1418 | const entries = recordField(data, "entries"); | |
| 1419 | const leafId = recordString(data, "leafId"); | |
| 1420 | if (leafId !== undefined) lastKnownLeaf = leafId; | |
| 1421 | // Without a trustworthy cursor this window spans more than one turn, so | |
| 1422 | // its first user entry belongs to an earlier turn. Re-sync the cursor | |
| 1423 | // and skip the turn-start ref rather than pointing rollback too far | |
| 1424 | // back; the next turn gets an accurate ref again. | |
| 1425 | leafCursorStale = false; | |
| 1426 | const firstUserEntryId = cursorWasStale | |
| 1427 | ? undefined | |
| 1428 | : Array.isArray(entries) | |
| 1429 | ? entries | |
| 1430 | .filter( | |
| 1431 | (entry) => | |
| 1432 | recordField(entry, "type") === "message" && | |
| 1433 | recordString(recordField(entry, "message"), "role") === "user", | |
| 1434 | ) | |
| 1435 | .map((entry) => recordString(entry, "id")) | |
| 1436 | .find((id) => id !== undefined) | |
| 1437 | : undefined; | |
| 1438 | return { | |
| 1439 | turnStartEntryId: firstUserEntryId ?? null, | |
| 1440 | leafId: leafId ?? null, | |
| 1441 | }; | |
| 1442 | }); | |
| 1443 | ||
| 1444 | const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState, readUsage = true) { | |
| 1445 | const turn = state.activeTurn; | |
| 1446 | if (turn === null) return; | |
| 1447 | state.activeTurn = null; | |
| 1448 | const completedAt = yield* DateTime.now; | |
| 1449 | yield* completeOpenStreamItems(turn); | |
| 1450 | if (turn.activeCompaction !== null) { | |
| 1451 | const status = turn.interrupted | |
| 1452 | ? "cancelled" | |
| 1453 | : turn.failure === null | |
| 1454 | ? "completed" | |
| 1455 | : "failed"; | |
| 1456 | yield* emitCompaction(turn, turn.activeCompaction, status); | |
| 1457 | turn.activeCompaction = null; | |
| 1458 | } | |
| 1459 | if (turn.activeProviderRetry !== null) { | |
| 1460 | if (turn.interrupted) { | |
| 1461 | yield* emitProviderRetry(turn, turn.activeProviderRetry, "interrupted", completedAt); | |
| 1462 | turn.activeProviderRetry = null; | |
| 1463 | } else if (turn.failure === null) { | |
| 1464 | yield* emitProviderRetry(turn, turn.activeProviderRetry, "completed", completedAt); | |
| 1465 | turn.activeProviderRetry = null; | |
| 1466 | } | |
| 1467 | } | |
| 1468 | yield* cancelPendingPrompts(completedAt); | |
| 1469 | const treeRefs = | |
| 1470 | turn.stopTreeRefs !== undefined ? turn.stopTreeRefs : yield* captureTurnTreeRefs(); | |
| 1471 | const tokenUsage = readUsage | |
| 1472 | ? yield* readTokenUsage(turn.latestCompactionAfterTokens, completedAt) | |
| 1473 | : undefined; | |
| 1474 | const failure = turn.interrupted ? null : (turn.rejectedPromptFailure ?? turn.failure); | |
| 1475 | yield* emit({ | |
| 1476 | type: "provider_turn.updated", | |
| 1477 | driver: PI_PROVIDER, | |
| 1478 | threadId: turn.turnInput.threadId, | |
| 1479 | providerTurn: { | |
| 1480 | ...turn.providerTurn, | |
| 1481 | ...(treeRefs?.turnStartEntryId == null | |
| 1482 | ? {} | |
| 1483 | : { nativeTurnRef: providerRef(treeRefs.turnStartEntryId) }), | |
| 1484 | status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", | |
| 1485 | completedAt, | |
| 1486 | ...(tokenUsage === undefined ? {} : { tokenUsage }), | |
| 1487 | }, | |
| 1488 | }); | |
| 1489 | yield* updateProviderThread(state, { | |
| 1490 | status: "idle", | |
| 1491 | ...(treeRefs?.leafId == null | |
| 1492 | ? {} | |
| 1493 | : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), | |
| 1494 | }); | |
| 1495 | yield* updateProviderSession( | |
| 1496 | failure !== null ? "error" : "ready", | |
| 1497 | failure?.message ?? null, | |
| 1498 | ); | |
| 1499 | if (failure !== null) { | |
| 1500 | const failureItemId = `terminal-failure:${turn.providerTurn.id}`; | |
| 1501 | if (turn.activeProviderRetry !== null) { | |
| 1502 | yield* emitProviderRetry( | |
| 1503 | turn, | |
| 1504 | { ...turn.activeProviderRetry, failure }, | |
| 1505 | "failed", | |
| 1506 | completedAt, | |
| 1507 | ); | |
| 1508 | } else { | |
| 1509 | yield* emit({ | |
| 1510 | type: "turn_item.updated", | |
| 1511 | driver: PI_PROVIDER, | |
| 1512 | turnItem: { | |
| 1513 | ...baseItemFields(turn, failureItemId, completedAt, completedAt), | |
| 1514 | status: "failed", | |
| 1515 | title: null, | |
| 1516 | completedAt, | |
| 1517 | type: "error", | |
| 1518 | failure, | |
| 1519 | }, | |
| 1520 | }); | |
| 1521 | } | |
| 1522 | yield* emit({ | |
| 1523 | type: "turn.terminal", | |
| 1524 | driver: PI_PROVIDER, | |
| 1525 | providerThreadId: state.providerThread.id, | |
| 1526 | providerTurnId: turn.providerTurn.id, | |
| 1527 | runOrdinal: turn.turnInput.runOrdinal, | |
| 1528 | failureItemOrdinal: itemOrdinal(turn, failureItemId), | |
| 1529 | status: "failed", | |
| 1530 | failure, | |
| 1531 | ...(turn.activeProviderRetry === null | |
| 1532 | ? {} | |
| 1533 | : { | |
| 1534 | retry: turn.activeProviderRetry.retry, | |
| 1535 | retryStartedAt: turn.activeProviderRetry.startedAt, | |
| 1536 | }), | |
| 1537 | threadDisposition: "reusable", | |
| 1538 | }); | |
| 1539 | } else { | |
| 1540 | yield* emit({ | |
| 1541 | type: "turn.terminal", | |
| 1542 | driver: PI_PROVIDER, | |
| 1543 | providerThreadId: state.providerThread.id, | |
| 1544 | providerTurnId: turn.providerTurn.id, | |
| 1545 | runOrdinal: turn.turnInput.runOrdinal, | |
| 1546 | status: turn.interrupted ? "interrupted" : "completed", | |
| 1547 | failure: null, | |
| 1548 | threadDisposition: "reusable", | |
| 1549 | }); | |
| 1550 | } | |
| 1551 | }); | |
| 1552 | ||
| 1553 | // ── event pump ──────────────────────────────────────── | |
| 1554 | ||
| 1555 | const scheduleSettleProbe = ( | |
| 1556 | turn: ActivePiTurn, | |
| 1557 | settleAfterAgentActivity = false, | |
| 1558 | attempt = 1, | |
| 1559 | ) => { | |
| 1560 | const providerTurnId = turn.providerTurn.id; | |
| 1561 | const settleProbeGeneration = turn.settleProbeGeneration; | |
| 1562 | return request({ type: "get_state" }, 2_000).pipe( | |
| 1563 | Effect.matchEffect({ | |
| 1564 | onSuccess: (data) => | |
| 1565 | Queue.offer(connection.events, { | |
| 1566 | type: "t3.settle_probe", | |
| 1567 | providerTurnId, | |
| 1568 | settleAfterAgentActivity, | |
| 1569 | settleProbeGeneration, | |
| 1570 | attempt, | |
| 1571 | data, | |
| 1572 | }), | |
| 1573 | // A failed probe still has to reach the pump. Dropping it would | |
| 1574 | // leave a command-only turn active forever, because Pi never emits | |
| 1575 | // agent events for one. | |
| 1576 | onFailure: () => | |
| 1577 | Queue.offer(connection.events, { | |
| 1578 | type: "t3.settle_probe", | |
| 1579 | providerTurnId, | |
| 1580 | settleAfterAgentActivity, | |
| 1581 | settleProbeGeneration, | |
| 1582 | attempt, | |
| 1583 | probeFailed: true, | |
| 1584 | }), | |
| 1585 | }), | |
| 1586 | Effect.ignore, | |
| 1587 | Effect.forkIn(scope), | |
| 1588 | ); | |
| 1589 | }; | |
| 1590 | ||
| 1591 | const stopPendingWake = Effect.fnUntraced(function* (wake: PiWake) { | |
| 1592 | if (pendingWake !== wake || threadState !== wake.state || closed) return; | |
| 1593 | pendingWake = null; | |
| 1594 | stopRequested = true; | |
| 1595 | yield* cancelPendingPrompts(yield* DateTime.now); | |
| 1596 | yield* connection.terminate; | |
| 1597 | }); | |
| 1598 | ||
| 1599 | const handleSessionEvent = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1600 | // Model-selection hooks can await a dialog before startTurn can claim | |
| 1601 | // a wake. Keep interactive requests session-scoped until a turn owns | |
| 1602 | // them; buffer native output and notifications for that turn. | |
| 1603 | const interactiveUiRequest = | |
| 1604 | event["type"] === "extension_ui_request" && | |
| 1605 | ["select", "confirm", "input", "editor"].includes(String(event["method"])); | |
| 1606 | if ( | |
| 1607 | threadState?.activeTurn === null && | |
| 1608 | pendingWake !== null && | |
| 1609 | !interactiveUiRequest && | |
| 1610 | !String(event["type"]).startsWith("t3.") && | |
| 1611 | event["type"] !== "response" | |
| 1612 | ) { | |
| 1613 | pendingWake.events.push(event); | |
| 1614 | if (event["type"] === "agent_start") yield* updateProviderSession("running", null); | |
| 1615 | if (event["type"] === "agent_settled") yield* updateProviderSession("ready", null); | |
| 1616 | return; | |
| 1617 | } | |
| 1618 | const state = threadState; | |
| 1619 | const turn = state?.activeTurn ?? null; | |
| 1620 | switch (event["type"]) { | |
| 1621 | case "agent_start": { | |
| 1622 | if ( | |
| 1623 | turn === null && | |
| 1624 | state?.providerThread.appThreadId != null && | |
| 1625 | options.continuationRequests !== undefined && | |
| 1626 | !closed && | |
| 1627 | !stopRequested | |
| 1628 | ) { | |
| 1629 | const wake: PiWake = { state, events: [event] }; | |
| 1630 | pendingWake = wake; | |
| 1631 | if (rollbackBarrier !== null) { | |
| 1632 | yield* stopPendingWake(wake); | |
| 1633 | return; | |
| 1634 | } | |
| 1635 | yield* updateProviderSession("running", null); | |
| 1636 | yield* options.continuationRequests | |
| 1637 | .offer({ | |
| 1638 | threadId: state.providerThread.appThreadId, | |
| 1639 | providerThreadId: state.providerThread.id, | |
| 1640 | driver: PI_PROVIDER, | |
| 1641 | detail: null, | |
| 1642 | delivery: "adapter_buffered", | |
| 1643 | dispatchIfCurrent: (dispatch) => | |
| 1644 | sessionEventPermit.withPermits(1)( | |
| 1645 | Effect.suspend(() => | |
| 1646 | pendingWake === wake && threadState === state && !closed | |
| 1647 | ? dispatch.pipe( | |
| 1648 | Effect.map(Option.some), | |
| 1649 | Effect.tapError(() => stopPendingWake(wake)), | |
| 1650 | ) | |
| 1651 | : Effect.succeed(Option.none()), | |
| 1652 | ), | |
| 1653 | ), | |
| 1654 | clearIfCurrent: () => sessionEventPermit.withPermits(1)(stopPendingWake(wake)), | |
| 1655 | }) | |
| 1656 | .pipe(Effect.tapError(() => stopPendingWake(wake))); | |
| 1657 | return; | |
| 1658 | } | |
| 1659 | if (turn === null) { | |
| 1660 | unsolicitedActivityDetected = true; | |
| 1661 | yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); | |
| 1662 | yield* connection.terminate; | |
| 1663 | return; | |
| 1664 | } | |
| 1665 | turn.sawAgentActivity = true; | |
| 1666 | turn.settleProbeGeneration += 1; | |
| 1667 | return; | |
| 1668 | } | |
| 1669 | case "message_start": { | |
| 1670 | if (turn !== null && recordString(event["message"], "role") === "assistant") { | |
| 1671 | turn.sawAgentActivity = true; | |
| 1672 | turn.messageOrdinal += 1; | |
| 1673 | } | |
| 1674 | return; | |
| 1675 | } | |
| 1676 | case "message_update": { | |
| 1677 | if (turn === null) return; | |
| 1678 | turn.sawAgentActivity = true; | |
| 1679 | yield* reportLiveUsage(turn, event["usage"]); | |
| 1680 | const delta = event["assistantMessageEvent"]; | |
| 1681 | const deltaType = recordString(delta, "type"); | |
| 1682 | const contentIndex = recordNumber(delta, "contentIndex") ?? 0; | |
| 1683 | if (deltaType === "text_delta" || deltaType === "thinking_delta") { | |
| 1684 | const item = yield* streamItemFor( | |
| 1685 | turn, | |
| 1686 | deltaType === "text_delta" ? "assistant_message" : "reasoning", | |
| 1687 | contentIndex, | |
| 1688 | ); | |
| 1689 | item.text += recordString(delta, "delta") ?? ""; | |
| 1690 | yield* scheduleStreamFlush(turn, item); | |
| 1691 | return; | |
| 1692 | } | |
| 1693 | if (deltaType === "text_end" || deltaType === "thinking_end") { | |
| 1694 | const item = yield* streamItemFor( | |
| 1695 | turn, | |
| 1696 | deltaType === "text_end" ? "assistant_message" : "reasoning", | |
| 1697 | contentIndex, | |
| 1698 | ); | |
| 1699 | yield* completeStreamItem( | |
| 1700 | turn, | |
| 1701 | item, | |
| 1702 | recordString(delta, "content") ?? recordString(delta, "thinking"), | |
| 1703 | ); | |
| 1704 | return; | |
| 1705 | } | |
| 1706 | return; | |
| 1707 | } | |
| 1708 | case "message_end": { | |
| 1709 | if (turn === null) return; | |
| 1710 | const message = event["message"]; | |
| 1711 | if (recordString(message, "role") !== "assistant") return; | |
| 1712 | yield* completeOpenStreamItems(turn); | |
| 1713 | if (recordString(message, "stopReason") === "error" && turn.failure === null) { | |
| 1714 | turn.failure = makeProviderFailure({ | |
| 1715 | message: recordString(message, "errorMessage") ?? "Pi reported a model error.", | |
| 1716 | class: "provider_error", | |
| 1717 | }); | |
| 1718 | } | |
| 1719 | return; | |
| 1720 | } | |
| 1721 | case "tool_execution_start": | |
| 1722 | if (turn !== null) { | |
| 1723 | turn.sawAgentActivity = true; | |
| 1724 | yield* emitToolItem(turn, event, "start"); | |
| 1725 | } | |
| 1726 | return; | |
| 1727 | case "tool_execution_update": | |
| 1728 | if (turn !== null) yield* emitToolItem(turn, event, "update"); | |
| 1729 | return; | |
| 1730 | case "tool_execution_end": | |
| 1731 | if (turn !== null) yield* emitToolItem(turn, event, "end"); | |
| 1732 | return; | |
| 1733 | case "compaction_start": { | |
| 1734 | if (turn === null) return; | |
| 1735 | turn.settleProbeGeneration += 1; | |
| 1736 | turn.sawCompaction = true; | |
| 1737 | if (turn.activeCompaction !== null) { | |
| 1738 | yield* emitCompaction(turn, turn.activeCompaction, "cancelled"); | |
| 1739 | } | |
| 1740 | const startedAt = yield* DateTime.now; | |
| 1741 | const compaction = { | |
| 1742 | nativeItemId: compactionNativeItemId(turn), | |
| 1743 | startedAt, | |
| 1744 | } satisfies PiCompactionState; | |
| 1745 | turn.activeCompaction = compaction; | |
| 1746 | yield* emitCompaction(turn, compaction, "running"); | |
| 1747 | return; | |
| 1748 | } | |
| 1749 | case "compaction_end": { | |
| 1750 | if (turn === null) return; | |
| 1751 | const observedAt = yield* DateTime.now; | |
| 1752 | const compaction = turn.activeCompaction ?? { | |
| 1753 | nativeItemId: compactionNativeItemId(turn), | |
| 1754 | startedAt: observedAt, | |
| 1755 | }; | |
| 1756 | turn.activeCompaction = null; | |
| 1757 | const result = event["result"]; | |
| 1758 | if (result === null || result === undefined) { | |
| 1759 | if (event["aborted"] === true) { | |
| 1760 | yield* emitCompaction(turn, compaction, "cancelled"); | |
| 1761 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1762 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1763 | } | |
| 1764 | return; | |
| 1765 | } | |
| 1766 | const errorMessage = | |
| 1767 | recordString(event, "errorMessage") ?? "Pi context compaction failed."; | |
| 1768 | yield* emitCompaction(turn, compaction, "failed", { | |
| 1769 | summary: errorMessage.slice(0, 1_000), | |
| 1770 | }); | |
| 1771 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1772 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1773 | } | |
| 1774 | return; | |
| 1775 | } | |
| 1776 | // An overflow can surface as a model error (`message_end` with | |
| 1777 | // stopReason error) before Pi compacts and retries the turn. Clear | |
| 1778 | // that failure only when Pi confirms that compaction will retry; | |
| 1779 | // a successful non-retrying compaction must not erase an exhausted | |
| 1780 | // provider retry. | |
| 1781 | if (event["willRetry"] === true) turn.failure = null; | |
| 1782 | turn.latestCompactionAfterTokens = | |
| 1783 | nonNegativeInteger(result, "estimatedTokensAfter") ?? null; | |
| 1784 | const summary = recordString(result, "summary"); | |
| 1785 | const beforeTokenCount = nonNegativeInteger(result, "tokensBefore"); | |
| 1786 | const afterTokenCount = nonNegativeInteger(result, "estimatedTokensAfter"); | |
| 1787 | yield* emitCompaction(turn, compaction, "completed", { | |
| 1788 | ...(summary === undefined ? {} : { summary }), | |
| 1789 | ...(beforeTokenCount === undefined ? {} : { beforeTokenCount }), | |
| 1790 | ...(afterTokenCount === undefined ? {} : { afterTokenCount }), | |
| 1791 | }); | |
| 1792 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1793 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1794 | } | |
| 1795 | return; | |
| 1796 | } | |
| 1797 | case "auto_retry_start": { | |
| 1798 | if (turn === null) return; | |
| 1799 | const emittedAt = yield* DateTime.now; | |
| 1800 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 1801 | const maxAttempts = Math.max( | |
| 1802 | attempt, | |
| 1803 | Math.trunc(recordNumber(event, "maxAttempts") ?? attempt), | |
| 1804 | ); | |
| 1805 | const retryDelayMs = Math.max(0, Math.trunc(recordNumber(event, "delayMs") ?? 0)); | |
| 1806 | const failure = makeProviderFailure({ | |
| 1807 | message: recordString(event, "errorMessage") ?? "Pi provider request failed.", | |
| 1808 | class: "provider_error", | |
| 1809 | retryable: true, | |
| 1810 | }); | |
| 1811 | const current = turn.activeProviderRetry; | |
| 1812 | const providerRetry = { | |
| 1813 | retry: { attempt, maxAttempts, retryDelayMs }, | |
| 1814 | failure, | |
| 1815 | startedAt: current?.startedAt ?? emittedAt, | |
| 1816 | itemOrdinal: | |
| 1817 | current?.itemOrdinal ?? | |
| 1818 | itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1819 | } satisfies PiProviderRetryState; | |
| 1820 | turn.activeProviderRetry = providerRetry; | |
| 1821 | yield* emitProviderRetry(turn, providerRetry, "running", emittedAt); | |
| 1822 | return; | |
| 1823 | } | |
| 1824 | case "auto_retry_end": { | |
| 1825 | if (turn === null) return; | |
| 1826 | const emittedAt = yield* DateTime.now; | |
| 1827 | if (event["success"] === true) { | |
| 1828 | // The retry recovered. Pi emits the erroring `message_end` | |
| 1829 | // before retrying, so leaving that failure in place would make | |
| 1830 | // `agent_settled` terminalize a successful turn as failed. | |
| 1831 | if (turn.activeProviderRetry !== null) { | |
| 1832 | const attempt = Math.max( | |
| 1833 | 1, | |
| 1834 | Math.trunc( | |
| 1835 | recordNumber(event, "attempt") ?? turn.activeProviderRetry.retry.attempt, | |
| 1836 | ), | |
| 1837 | ); | |
| 1838 | const recoveredRetry = { | |
| 1839 | ...turn.activeProviderRetry, | |
| 1840 | retry: { ...turn.activeProviderRetry.retry, attempt }, | |
| 1841 | }; | |
| 1842 | yield* emitProviderRetry(turn, recoveredRetry, "completed", emittedAt); | |
| 1843 | turn.activeProviderRetry = null; | |
| 1844 | } | |
| 1845 | turn.failure = null; | |
| 1846 | return; | |
| 1847 | } | |
| 1848 | const failure = makeProviderFailure({ | |
| 1849 | message: recordString(event, "finalError") ?? "Pi auto-retry failed.", | |
| 1850 | class: "provider_error", | |
| 1851 | retryable: false, | |
| 1852 | }); | |
| 1853 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 1854 | const current = turn.activeProviderRetry; | |
| 1855 | const providerRetry = { | |
| 1856 | retry: { | |
| 1857 | attempt, | |
| 1858 | maxAttempts: current?.retry.maxAttempts ?? attempt, | |
| 1859 | retryDelayMs: current?.retry.retryDelayMs ?? null, | |
| 1860 | }, | |
| 1861 | failure, | |
| 1862 | startedAt: current?.startedAt ?? emittedAt, | |
| 1863 | itemOrdinal: | |
| 1864 | current?.itemOrdinal ?? | |
| 1865 | itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1866 | } satisfies PiProviderRetryState; | |
| 1867 | turn.activeProviderRetry = providerRetry; | |
| 1868 | turn.failure = failure; | |
| 1869 | yield* emitProviderRetry(turn, providerRetry, "failed", emittedAt); | |
| 1870 | return; | |
| 1871 | } | |
| 1872 | case "extension_ui_request": | |
| 1873 | yield* handleExtensionUiRequest(event); | |
| 1874 | return; | |
| 1875 | case "extension_error": { | |
| 1876 | yield* emitExtensionError(event); | |
| 1877 | return; | |
| 1878 | } | |
| 1879 | case "agent_settled": { | |
| 1880 | if (turn?.interrupted === true) { | |
| 1881 | if (state !== null) yield* finalizeTurn(state); | |
| 1882 | return; | |
| 1883 | } | |
| 1884 | if (turn !== null) { | |
| 1885 | turn.settleWhenIdle = true; | |
| 1886 | turn.settleProbeGeneration += 1; | |
| 1887 | // A wake can settle while the newly joined user prompt is still | |
| 1888 | // expanding. Its acknowledgement opens the idle-probe barrier. | |
| 1889 | if ( | |
| 1890 | turn.adoptedWake && | |
| 1891 | pendingPromptResponses.some( | |
| 1892 | (pending) => | |
| 1893 | pending.providerTurnId === turn.providerTurn.id && | |
| 1894 | pending.kind === "turn_start", | |
| 1895 | ) | |
| 1896 | ) | |
| 1897 | return; | |
| 1898 | yield* scheduleSettleProbe(turn, true); | |
| 1899 | } | |
| 1900 | return; | |
| 1901 | } | |
| 1902 | case "response": { | |
| 1903 | // Correlated responses never reach the pump; an id-less response | |
| 1904 | // is the deferred ack of a fire-and-forget prompt/steer/compact. | |
| 1905 | const command = recordString(event, "command"); | |
| 1906 | if (command === "compact") { | |
| 1907 | const pendingCompact = pendingCompactResponses.shift(); | |
| 1908 | const compactTurn = | |
| 1909 | pendingCompact?.providerTurnId === turn?.providerTurn.id ? turn : null; | |
| 1910 | if (compactTurn !== null) compactTurn.manualCompactInFlight = false; | |
| 1911 | if (event["success"] === true) { | |
| 1912 | if ( | |
| 1913 | pendingCompact?.kind === "turn_start" && | |
| 1914 | compactTurn !== null && | |
| 1915 | compactTurn.promptMayBeCommandOnly && | |
| 1916 | !compactTurn.sawAgentActivity | |
| 1917 | ) { | |
| 1918 | yield* scheduleSettleProbe(compactTurn); | |
| 1919 | } | |
| 1920 | return; | |
| 1921 | } | |
| 1922 | if (event["success"] !== false) return; | |
| 1923 | if (compactTurn === null) return; | |
| 1924 | if (compactTurn.activeCompaction !== null) return; | |
| 1925 | if (pendingCompact?.kind === "steer") { | |
| 1926 | yield* Effect.logWarning("Pi rejected a compact steer.", { | |
| 1927 | errorLength: recordString(event, "error")?.length, | |
| 1928 | }); | |
| 1929 | return; | |
| 1930 | } | |
| 1931 | if (!compactTurn.sawCompaction) { | |
| 1932 | compactTurn.failure = makeProviderFailure({ | |
| 1933 | message: recordString(event, "error") ?? "Pi compact failed.", | |
| 1934 | class: "provider_error", | |
| 1935 | }); | |
| 1936 | if (state !== null) yield* finalizeTurn(state); | |
| 1937 | return; | |
| 1938 | } | |
| 1939 | if (!compactTurn.sawAgentActivity) { | |
| 1940 | yield* scheduleSettleProbe(compactTurn); | |
| 1941 | } | |
| 1942 | return; | |
| 1943 | } | |
| 1944 | const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; | |
| 1945 | const responseTurn = | |
| 1946 | pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; | |
| 1947 | if (event["success"] === true) { | |
| 1948 | // Deferred success ack. Command-only prompts (pure extension | |
| 1949 | // slash commands) never start an agent run and never emit | |
| 1950 | // `agent_settled`, so probe for idleness. The probe result is | |
| 1951 | // re-queued behind any events Pi emitted before answering | |
| 1952 | // get_state, which keeps the check stream-ordered. | |
| 1953 | if ( | |
| 1954 | pendingPrompt?.kind === "turn_start" && | |
| 1955 | responseTurn !== null && | |
| 1956 | ((responseTurn.promptMayBeCommandOnly && !responseTurn.sawAgentActivity) || | |
| 1957 | (responseTurn.adoptedWake && | |
| 1958 | (responseTurn.settleWhenIdle || responseTurn.promptMayBeCommandOnly))) | |
| 1959 | ) { | |
| 1960 | yield* scheduleSettleProbe(responseTurn, responseTurn.adoptedWake); | |
| 1961 | } | |
| 1962 | return; | |
| 1963 | } | |
| 1964 | if (event["success"] !== false) return; | |
| 1965 | if (command === "steer" || pendingPrompt?.kind === "steer") { | |
| 1966 | // A rejected steer only means that one message was refused. The | |
| 1967 | // turn it was aimed at is still running on Pi, so terminalizing | |
| 1968 | // here would report a failure while output keeps streaming. | |
| 1969 | yield* Effect.logWarning("Pi rejected a steer message.", { | |
| 1970 | errorLength: recordString(event, "error")?.length, | |
| 1971 | }); | |
| 1972 | return; | |
| 1973 | } | |
| 1974 | const failedTurn = | |
| 1975 | command === "prompt" && pendingPrompt?.kind === "turn_start" | |
| 1976 | ? responseTurn | |
| 1977 | : command === "parse" | |
| 1978 | ? turn | |
| 1979 | : null; | |
| 1980 | if (failedTurn !== null) { | |
| 1981 | const failure = makeProviderFailure({ | |
| 1982 | message: recordString(event, "error") ?? "Pi rejected the prompt.", | |
| 1983 | class: "provider_error", | |
| 1984 | }); | |
| 1985 | if (failedTurn.adoptedWake && command === "prompt") { | |
| 1986 | failedTurn.rejectedPromptFailure = failure; | |
| 1987 | failedTurn.settleWhenIdle = true; | |
| 1988 | yield* scheduleSettleProbe(failedTurn, true); | |
| 1989 | } else { | |
| 1990 | failedTurn.failure = failure; | |
| 1991 | if (state !== null) yield* finalizeTurn(state); | |
| 1992 | } | |
| 1993 | } | |
| 1994 | return; | |
| 1995 | } | |
| 1996 | case "t3.flush_extension_errors": { | |
| 1997 | // Startup extension failures are informational and do not block | |
| 1998 | // Pi, so attach them to the next real turn instead of creating a | |
| 1999 | // standalone failed run. | |
| 2000 | for (const extensionError of outOfTurnExtensionErrors.splice(0)) { | |
| 2001 | yield* emitExtensionError(extensionError); | |
| 2002 | } | |
| 2003 | return; | |
| 2004 | } | |
| 2005 | case "t3.rollback_barrier": { | |
| 2006 | if (rollbackBarrier !== null && event["barrier"] === rollbackBarrier) | |
| 2007 | yield* Deferred.succeed(rollbackBarrier, undefined); | |
| 2008 | return; | |
| 2009 | } | |
| 2010 | case "t3.settle_probe": { | |
| 2011 | // New work increments the generation before the pump can consume | |
| 2012 | // a stale idle snapshot, so only a current snapshot may settle. | |
| 2013 | const data = event["data"]; | |
| 2014 | const probeFailed = event["probeFailed"] === true; | |
| 2015 | const settleAfterAgentActivity = event["settleAfterAgentActivity"] === true; | |
| 2016 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 2017 | if ( | |
| 2018 | turn === null || | |
| 2019 | turn.providerTurn.id !== event["providerTurnId"] || | |
| 2020 | turn.settleProbeGeneration !== event["settleProbeGeneration"] || | |
| 2021 | (!settleAfterAgentActivity && turn.sawAgentActivity) || | |
| 2022 | turn.activeCompaction !== null | |
| 2023 | ) { | |
| 2024 | return; | |
| 2025 | } | |
| 2026 | if (probeFailed) { | |
| 2027 | if (!settleAfterAgentActivity) { | |
| 2028 | if (state !== null) yield* finalizeTurn(state); | |
| 2029 | return; | |
| 2030 | } | |
| 2031 | if (attempt < SETTLE_PROBE_MAX_ATTEMPTS) { | |
| 2032 | yield* Effect.sleep(SETTLE_PROBE_RETRY_DELAY).pipe( | |
| 2033 | Effect.andThen(scheduleSettleProbe(turn, true, attempt + 1)), | |
| 2034 | Effect.forkIn(scope), | |
| 2035 | ); | |
| 2036 | return; | |
| 2037 | } | |
| 2038 | stopRequested = true; | |
| 2039 | yield* connection.terminate; | |
| 2040 | return; | |
| 2041 | } | |
| 2042 | if ( | |
| 2043 | recordField(data, "isStreaming") !== true && | |
| 2044 | recordField(data, "isCompacting") !== true && | |
| 2045 | (recordNumber(data, "pendingMessageCount") ?? 0) === 0 | |
| 2046 | ) { | |
| 2047 | turn.settleWhenIdle = false; | |
| 2048 | if (state !== null) yield* finalizeTurn(state); | |
| 2049 | } | |
| 2050 | return; | |
| 2051 | } | |
| 2052 | default: | |
| 2053 | return; | |
| 2054 | } | |
| 2055 | }); | |
| 2056 | ||
| 2057 | yield* Effect.gen(function* () { | |
| 2058 | while (true) { | |
| 2059 | const event = yield* Queue.take(connection.events); | |
| 2060 | yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); | |
| 2061 | } | |
| 2062 | }).pipe( | |
| 2063 | Effect.catchCause((cause) => | |
| 2064 | sessionEventPermit.withPermits(1)( | |
| 2065 | Effect.gen(function* () { | |
| 2066 | // Transport death finalizes any live turn. Stop-with-restart | |
| 2067 | // closes the provider stream cleanly; only an unexpected death | |
| 2068 | // is surfaced as an event-stream failure. | |
| 2069 | const state = threadState; | |
| 2070 | if (rollbackBarrier !== null) | |
| 2071 | yield* Deferred.fail( | |
| 2072 | rollbackBarrier, | |
| 2073 | protocolError("Pi transport closed during rollback"), | |
| 2074 | ); | |
| 2075 | const hadPendingWake = pendingWake !== null; | |
| 2076 | pendingWake = null; | |
| 2077 | const interrupted = state?.activeTurn?.interrupted === true; | |
| 2078 | if (state?.activeTurn != null) { | |
| 2079 | state.activeTurn.failure = interrupted | |
| 2080 | ? null | |
| 2081 | : makeProviderFailure({ | |
| 2082 | cause, | |
| 2083 | message: "Pi process exited unexpectedly.", | |
| 2084 | class: "transport_error", | |
| 2085 | }); | |
| 2086 | yield* finalizeTurn(state, false); | |
| 2087 | } else if (hadPendingWake) { | |
| 2088 | yield* cancelPendingPrompts(yield* DateTime.now); | |
| 2089 | } | |
| 2090 | if (unsolicitedActivityDetected) { | |
| 2091 | yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); | |
| 2092 | yield* Queue.end(events); | |
| 2093 | } else if (stopRequested) { | |
| 2094 | yield* updateProviderSession("stopped", null); | |
| 2095 | yield* Queue.end(events); | |
| 2096 | } else { | |
| 2097 | yield* updateProviderSession( | |
| 2098 | "error", | |
| 2099 | interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", | |
| 2100 | ); | |
| 2101 | yield* Queue.fail( | |
| 2102 | events, | |
| 2103 | new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 2104 | driver: PI_PROVIDER, | |
| 2105 | providerSessionId: input.providerSessionId, | |
| 2106 | cause, | |
| 2107 | }), | |
| 2108 | ); | |
| 2109 | } | |
| 2110 | }), | |
| 2111 | ), | |
| 2112 | ), | |
| 2113 | Effect.forkIn(scope), | |
| 2114 | ); | |
| 2115 | ||
| 2116 | // Discovery can invoke extension code and therefore raise a blocking | |
| 2117 | // UI request. Start it only after the event pump exists, and never hold | |
| 2118 | // session opening on it; startup requests are persisted at session | |
| 2119 | // scope and can be answered before a turn begins. | |
| 2120 | yield* discoverSkillNames.pipe( | |
| 2121 | Effect.tap((discovered) => Effect.sync(() => (skillNames = discovered))), | |
| 2122 | Effect.ignore, | |
| 2123 | Effect.forkIn(scope), | |
| 2124 | ); | |
| 2125 | ||
| 2126 | // ── session runtime ─────────────────────────────────── | |
| 2127 | ||
| 2128 | const registerThread = Effect.fnUntraced(function* ( | |
| 2129 | threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, | |
| 2130 | publish = true, | |
| 2131 | ) { | |
| 2132 | if (rollbackBarrier !== null) | |
| 2133 | return yield* protocolError("Cannot register a Pi thread during rollback"); | |
| 2134 | if (threadState !== null && threadState.activeTurn !== null) { | |
| 2135 | return yield* protocolError("Cannot register a Pi thread while a turn is active"); | |
| 2136 | } | |
| 2137 | if (pendingWake !== null) { | |
| 2138 | const state = threadState; | |
| 2139 | if ( | |
| 2140 | state !== null && | |
| 2141 | state === pendingWake.state && | |
| 2142 | state.providerThread.appThreadId === threadInput.threadId && | |
| 2143 | (threadInput.existingProviderThread === undefined || | |
| 2144 | threadInput.existingProviderThread.nativeThreadRef?.nativeId === | |
| 2145 | state.providerThread.nativeThreadRef?.nativeId) | |
| 2146 | ) | |
| 2147 | return state.providerThread; | |
| 2148 | return yield* protocolError( | |
| 2149 | "Cannot switch Pi threads while native work is awaiting a turn", | |
| 2150 | ); | |
| 2151 | } | |
| 2152 | const existing = threadInput.existingProviderThread; | |
| 2153 | const resumeId = existing?.nativeThreadRef?.nativeId; | |
| 2154 | const needsNewSession = resumeId == null && registrationAttempted; | |
| 2155 | registrationAttempted = true; | |
| 2156 | if (resumeId != null || needsNewSession) { | |
| 2157 | lastNativeThreadId = resumeId ?? lastNativeThreadId; | |
| 2158 | // Even a failed lifecycle operation can change Pi's native session. | |
| 2159 | // Never leave the old app binding or model defaults usable afterward. | |
| 2160 | threadState = null; | |
| 2161 | appliedModel = null; | |
| 2162 | appliedThinking = null; | |
| 2163 | appliedSessionName = null; | |
| 2164 | baselineModel = null; | |
| 2165 | baselineThinking = null; | |
| 2166 | contextWindow = null; | |
| 2167 | const result = yield* lifecycleRequest( | |
| 2168 | resumeId != null | |
| 2169 | ? { type: "switch_session", sessionPath: resumeId } | |
| 2170 | : { type: "new_session" }, | |
| 2171 | ); | |
| 2172 | if (recordField(result, "cancelled") === true) { | |
| 2173 | return yield* protocolError("A Pi extension cancelled the session switch"); | |
| 2174 | } | |
| 2175 | } | |
| 2176 | const stateData = yield* request({ type: "get_state" }); | |
| 2177 | if (!modelsDiscovered) { | |
| 2178 | const modelsData = yield* request({ type: "get_available_models" }).pipe( | |
| 2179 | Effect.orElseSucceed(() => undefined), | |
| 2180 | ); | |
| 2181 | const models = recordField(modelsData, "models"); | |
| 2182 | if (Array.isArray(models)) { | |
| 2183 | for (const model of models) rememberModelContextWindow(model); | |
| 2184 | modelsDiscovered = true; | |
| 2185 | } | |
| 2186 | } | |
| 2187 | contextWindow = rememberModelContextWindow(recordField(stateData, "model")); | |
| 2188 | // Each baseline is captured independently, and only while nothing has | |
| 2189 | // been applied yet, so a `get_state` that arrives after our own | |
| 2190 | // selection cannot record that selection as Pi's default. | |
| 2191 | if (baselineModel === null && appliedModel === null) { | |
| 2192 | const stateModel = recordField(stateData, "model"); | |
| 2193 | const provider = recordString(stateModel, "provider"); | |
| 2194 | const modelId = recordString(stateModel, "id"); | |
| 2195 | if (provider !== undefined && modelId !== undefined) { | |
| 2196 | baselineModel = { provider, modelId }; | |
| 2197 | } | |
| 2198 | } | |
| 2199 | if (baselineThinking === null && appliedThinking === null) { | |
| 2200 | baselineThinking = recordString(stateData, "thinkingLevel") ?? null; | |
| 2201 | } | |
| 2202 | // switch_session accepts a file path, not Pi's display session UUID. | |
| 2203 | const nativeId = recordString(stateData, "sessionFile"); | |
| 2204 | if (nativeId === undefined) { | |
| 2205 | return yield* protocolError("get_state returned no persisted sessionFile", stateData); | |
| 2206 | } | |
| 2207 | if (needsNewSession && nativeId === lastNativeThreadId) { | |
| 2208 | return yield* protocolError("Pi did not create a distinct session file"); | |
| 2209 | } | |
| 2210 | lastNativeThreadId = nativeId; | |
| 2211 | const createdAt = yield* DateTime.now; | |
| 2212 | const providerThread: OrchestrationV2ProviderThread = | |
| 2213 | existing !== undefined | |
| 2214 | ? { | |
| 2215 | ...existing, | |
| 2216 | providerSessionId: input.providerSessionId, | |
| 2217 | nativeThreadRef: providerRef(nativeId), | |
| 2218 | ...(needsNewSession ? { nativeConversationHeadRef: null, contextUsage: null } : {}), | |
| 2219 | status: "idle", | |
| 2220 | updatedAt: createdAt, | |
| 2221 | } | |
| 2222 | : { | |
| 2223 | id: idAllocator.derive.providerThread({ | |
| 2224 | driver: PI_PROVIDER, | |
| 2225 | nativeThreadId: nativeId, | |
| 2226 | }), | |
| 2227 | driver: PI_PROVIDER, | |
| 2228 | providerInstanceId: options.instanceId, | |
| 2229 | providerSessionId: input.providerSessionId, | |
| 2230 | appThreadId: threadInput.threadId, | |
| 2231 | ownerNodeId: null, | |
| 2232 | nativeThreadRef: providerRef(nativeId), | |
| 2233 | nativeConversationHeadRef: null, | |
| 2234 | status: "idle", | |
| 2235 | firstRunOrdinal: null, | |
| 2236 | lastRunOrdinal: null, | |
| 2237 | handoffIds: [], | |
| 2238 | forkedFrom: null, | |
| 2239 | pendingBackgroundTasks: [], | |
| 2240 | createdAt, | |
| 2241 | updatedAt: createdAt, | |
| 2242 | }; | |
| 2243 | threadState = { providerThread, activeTurn: null }; | |
| 2244 | // Baseline the session-tree leaf so the first turn's user entry can | |
| 2245 | // be located with a `since` cursor instead of a full entry scan. | |
| 2246 | const baselineEntries = yield* request({ type: "get_entries" }).pipe( | |
| 2247 | Effect.orElseSucceed(() => undefined), | |
| 2248 | ); | |
| 2249 | lastKnownLeaf = recordString(baselineEntries, "leafId") ?? null; | |
| 2250 | // A successful full baseline makes the cursor trustworthy again. Only | |
| 2251 | // a failed one leaves it stale, so a recovered session does not keep | |
| 2252 | // skipping turn refs. An empty tree is a success with no leafId. | |
| 2253 | leafCursorStale = baselineEntries === undefined; | |
| 2254 | if (publish) | |
| 2255 | yield* emit({ | |
| 2256 | type: "provider_thread.updated", | |
| 2257 | driver: PI_PROVIDER, | |
| 2258 | providerThread, | |
| 2259 | }); | |
| 2260 | return providerThread; | |
| 2261 | }); | |
| 2262 | ||
| 2263 | const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { | |
| 2264 | const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); | |
| 2265 | if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { | |
| 2266 | // Returning to "Pi default" after an explicit pick has to replay the | |
| 2267 | // captured baseline, otherwise Pi stays on the last model applied. | |
| 2268 | if (appliedModel !== null && baselineModel !== null) { | |
| 2269 | const restoredModel = yield* request({ type: "set_model", ...baselineModel }); | |
| 2270 | contextWindow = rememberModelContextWindow(restoredModel); | |
| 2271 | appliedModel = null; | |
| 2272 | const updatedAt = yield* DateTime.now; | |
| 2273 | sessionEntity = { ...sessionEntity, model: PI_INHERIT_MODEL_SLUG, updatedAt }; | |
| 2274 | yield* emit({ | |
| 2275 | type: "provider_session.updated", | |
| 2276 | driver: PI_PROVIDER, | |
| 2277 | providerSession: sessionEntity, | |
| 2278 | }); | |
| 2279 | } | |
| 2280 | // The "Pi default" model advertises no thinking choices of its own, | |
| 2281 | // so an unqualified return also restores Pi's configured level | |
| 2282 | // instead of silently keeping the effort a previous pick applied. | |
| 2283 | if ( | |
| 2284 | thinking === undefined && | |
| 2285 | appliedThinking !== null && | |
| 2286 | baselineThinking !== null && | |
| 2287 | appliedThinking !== baselineThinking | |
| 2288 | ) { | |
| 2289 | yield* request({ type: "set_thinking_level", level: baselineThinking }); | |
| 2290 | appliedThinking = null; | |
| 2291 | } | |
| 2292 | } else if (modelSelection.model !== appliedModel) { | |
| 2293 | const parsed = parsePiModelSlug(modelSelection.model); | |
| 2294 | if (parsed === null) { | |
| 2295 | return yield* protocolError( | |
| 2296 | `Pi model '${modelSelection.model}' must use provider/model format`, | |
| 2297 | ); | |
| 2298 | } | |
| 2299 | const selectedModel = yield* request({ | |
| 2300 | type: "set_model", | |
| 2301 | provider: parsed.provider, | |
| 2302 | modelId: parsed.modelId, | |
| 2303 | }); | |
| 2304 | contextWindow = rememberModelContextWindow(selectedModel); | |
| 2305 | appliedModel = modelSelection.model; | |
| 2306 | const updatedAt = yield* DateTime.now; | |
| 2307 | sessionEntity = { ...sessionEntity, model: modelSelection.model, updatedAt }; | |
| 2308 | yield* emit({ | |
| 2309 | type: "provider_session.updated", | |
| 2310 | driver: PI_PROVIDER, | |
| 2311 | providerSession: sessionEntity, | |
| 2312 | }); | |
| 2313 | } | |
| 2314 | if ( | |
| 2315 | thinking !== undefined && | |
| 2316 | thinking !== appliedThinking && | |
| 2317 | PI_THINKING_LEVELS.has(thinking) | |
| 2318 | ) { | |
| 2319 | yield* request({ type: "set_thinking_level", level: thinking }); | |
| 2320 | appliedThinking = thinking; | |
| 2321 | } | |
| 2322 | }); | |
| 2323 | ||
| 2324 | const resolvePromptPayload = Effect.fnUntraced(function* ( | |
| 2325 | text: string, | |
| 2326 | attachments: ReadonlyArray<ChatAttachment>, | |
| 2327 | ) { | |
| 2328 | // Provider discovery and the live session are separate Pi processes. | |
| 2329 | // Retry a failed session-local lookup once at first use so a transient | |
| 2330 | // startup failure cannot leave a visible $ skill inert for this session. | |
| 2331 | if (skillNames === null && text.includes("$")) { | |
| 2332 | skillNames = yield* discoverSkillNames.pipe( | |
| 2333 | Effect.orElseSucceed(() => new Set<string>()), | |
| 2334 | ); | |
| 2335 | } | |
| 2336 | const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); | |
| 2337 | const images: Array<{ type: "image"; data: string; mimeType: string }> = []; | |
| 2338 | const extraLines: Array<string> = []; | |
| 2339 | for (const attachment of attachments) { | |
| 2340 | const path = options.host.resolveAttachmentPath(attachment); | |
| 2341 | if (path === null) continue; | |
| 2342 | if (attachment.mimeType.startsWith("image/")) { | |
| 2343 | const bytes = yield* options.fileSystem.readFile(path); | |
| 2344 | images.push({ | |
| 2345 | type: "image", | |
| 2346 | data: Buffer.from(bytes).toString("base64"), | |
| 2347 | mimeType: attachment.mimeType, | |
| 2348 | }); | |
| 2349 | } else { | |
| 2350 | extraLines.push(`[Attachment saved at ${path}]`); | |
| 2351 | } | |
| 2352 | } | |
| 2353 | const message = | |
| 2354 | extraLines.length === 0 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; | |
| 2355 | return { message, images }; | |
| 2356 | }); | |
| 2357 | ||
| 2358 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 2359 | instanceId: options.instanceId, | |
| 2360 | driver: PI_PROVIDER, | |
| 2361 | providerSessionId: input.providerSessionId, | |
| 2362 | get providerSession() { | |
| 2363 | return sessionEntity; | |
| 2364 | }, | |
| 2365 | events: Stream.fromQueue(events), | |
| 2366 | hasPendingBackgroundWork: Effect.sync(() => pendingWake !== null), | |
| 2367 | hasPendingBackgroundWorkForThread: (providerThread) => | |
| 2368 | Effect.sync( | |
| 2369 | () => pendingWake !== null && pendingWake.state.providerThread.id === providerThread.id, | |
| 2370 | ), | |
| 2371 | getModelContextWindow: (selection) => { | |
| 2372 | if (selection.instanceId !== options.instanceId) return undefined; | |
| 2373 | const slug = | |
| 2374 | selection.model === PI_INHERIT_MODEL_SLUG | |
| 2375 | ? baselineModel === null | |
| 2376 | ? undefined | |
| 2377 | : `${baselineModel.provider}/${baselineModel.modelId}` | |
| 2378 | : selection.model; | |
| 2379 | return slug === undefined ? undefined : modelContextWindows.get(slug); | |
| 2380 | }, | |
| 2381 | ensureThread: (threadInput) => | |
| 2382 | registerThread(threadInput).pipe( | |
| 2383 | Effect.mapError( | |
| 2384 | (cause) => | |
| 2385 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 2386 | driver: PI_PROVIDER, | |
| 2387 | threadId: threadInput.threadId, | |
| 2388 | cause, | |
| 2389 | }), | |
| 2390 | ), | |
| 2391 | ), | |
| 2392 | resumeThread: (threadInput) => | |
| 2393 | registerThread({ | |
| 2394 | threadId: | |
| 2395 | threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, | |
| 2396 | modelSelection: threadInput.modelSelection ?? input.modelSelection, | |
| 2397 | runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, | |
| 2398 | existingProviderThread: threadInput.providerThread, | |
| 2399 | }).pipe( | |
| 2400 | Effect.mapError( | |
| 2401 | (cause) => | |
| 2402 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 2403 | driver: PI_PROVIDER, | |
| 2404 | providerSessionId: input.providerSessionId, | |
| 2405 | providerThreadId: threadInput.providerThread.id, | |
| 2406 | cause, | |
| 2407 | }), | |
| 2408 | ), | |
| 2409 | ), | |
| 2410 | // The run executor routes a bare `/compact` here instead of startTurn. | |
| 2411 | // Pi's start path already turns that text into the RPC compact call. | |
| 2412 | compactThread: (turnInput) => | |
| 2413 | runtime.startTurn({ | |
| 2414 | ...turnInput, | |
| 2415 | message: { ...turnInput.message, text: "/compact" }, | |
| 2416 | }), | |
| 2417 | startTurn: (turnInput) => | |
| 2418 | Effect.gen(function* () { | |
| 2419 | if (rollbackBarrier !== null) | |
| 2420 | return yield* protocolError("Cannot start a Pi turn during rollback"); | |
| 2421 | const state = threadState; | |
| 2422 | if (state === null) { | |
| 2423 | return yield* protocolError("Pi session has no registered thread"); | |
| 2424 | } | |
| 2425 | if (state.activeTurn !== null) { | |
| 2426 | return yield* protocolError( | |
| 2427 | `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 2428 | ); | |
| 2429 | } | |
| 2430 | if ( | |
| 2431 | state.providerThread.nativeThreadRef?.nativeId !== | |
| 2432 | turnInput.providerThread.nativeThreadRef?.nativeId | |
| 2433 | ) { | |
| 2434 | return yield* protocolError("Pi turn requested for a different native session"); | |
| 2435 | } | |
| 2436 | // The orchestrator adopts a fork under its already-allocated row. | |
| 2437 | // Future session updates must retain that authoritative identity. | |
| 2438 | const continuation = | |
| 2439 | turnInput.message.createdBy === "agent" && | |
| 2440 | turnInput.message.creationSource === "provider"; | |
| 2441 | if (!continuation) yield* applySelection(turnInput.modelSelection); | |
| 2442 | // Mirror the thread title into pi's session name so the session | |
| 2443 | // stays identifiable in pi's own /resume listing. Best-effort: | |
| 2444 | // naming must never block a turn. | |
| 2445 | if (turnInput.appThread.title !== appliedSessionName) { | |
| 2446 | yield* request({ | |
| 2447 | type: "set_session_name", | |
| 2448 | name: turnInput.appThread.title, | |
| 2449 | }).pipe( | |
| 2450 | Effect.tap(() => | |
| 2451 | Effect.sync(() => (appliedSessionName = turnInput.appThread.title)), | |
| 2452 | ), | |
| 2453 | Effect.ignore, | |
| 2454 | ); | |
| 2455 | } | |
| 2456 | // Resolved before the turn is installed: a failure here (an | |
| 2457 | // unreadable attachment) must not leave `activeTurn` set, which | |
| 2458 | // would reject every later turn as already active. | |
| 2459 | // Orchestration instructions reach pi through the T3 MCP | |
| 2460 | // extension's before_agent_start system-prompt hook, never by | |
| 2461 | // wrapping the user text: a wrapped first message would no | |
| 2462 | // longer start with "/" and slash commands would stop expanding. | |
| 2463 | const compactCommand = continuation | |
| 2464 | ? null | |
| 2465 | : parsePiCompactCommand(turnInput.message.text); | |
| 2466 | const payload = | |
| 2467 | !continuation && compactCommand === null | |
| 2468 | ? yield* resolvePromptPayload(turnInput.message.text, turnInput.message.attachments) | |
| 2469 | : null; | |
| 2470 | const startedAt = yield* DateTime.now; | |
| 2471 | const syntheticNativeTurnId = `${state.providerThread.id}:attempt:${turnInput.attemptId}`; | |
| 2472 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 2473 | id: idAllocator.derive.providerTurn({ | |
| 2474 | driver: PI_PROVIDER, | |
| 2475 | nativeTurnId: syntheticNativeTurnId, | |
| 2476 | }), | |
| 2477 | providerThreadId: turnInput.providerThread.id, | |
| 2478 | nodeId: turnInput.rootNodeId, | |
| 2479 | runAttemptId: turnInput.attemptId, | |
| 2480 | nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), | |
| 2481 | ordinal: turnInput.providerTurnOrdinal, | |
| 2482 | status: "running", | |
| 2483 | startedAt, | |
| 2484 | completedAt: null, | |
| 2485 | }; | |
| 2486 | const activeTurn: ActivePiTurn = { | |
| 2487 | turnInput, | |
| 2488 | providerTurn, | |
| 2489 | startedAt, | |
| 2490 | itemOrdinals: new Map(), | |
| 2491 | nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, | |
| 2492 | messageOrdinal: 0, | |
| 2493 | streamItems: new Map(), | |
| 2494 | toolArgs: new Map(), | |
| 2495 | toolStartedAt: new Map(), | |
| 2496 | interrupted: false, | |
| 2497 | sawAgentActivity: false, | |
| 2498 | adoptedWake: false, | |
| 2499 | promptMayBeCommandOnly: | |
| 2500 | compactCommand !== null || (payload?.message.trimStart().startsWith("/") ?? false), | |
| 2501 | latestCompactionAfterTokens: null, | |
| 2502 | lastLiveUsedTokens: null, | |
| 2503 | settleProbeGeneration: 0, | |
| 2504 | settleWhenIdle: false, | |
| 2505 | sawCompaction: false, | |
| 2506 | manualCompactInFlight: compactCommand !== null, | |
| 2507 | activeCompaction: null, | |
| 2508 | activeProviderRetry: null, | |
| 2509 | failure: null, | |
| 2510 | rejectedPromptFailure: null, | |
| 2511 | }; | |
| 2512 | // Only the install/send/start-event boundary excludes the event | |
| 2513 | // pump. Earlier correlated requests must leave the pump free so | |
| 2514 | // project trust, login, and session-switch dialogs can be shown | |
| 2515 | // and answered instead of deadlocking the caller. | |
| 2516 | yield* Effect.gen(function* () { | |
| 2517 | if ( | |
| 2518 | threadState !== state || | |
| 2519 | state.activeTurn !== null || | |
| 2520 | closed || | |
| 2521 | stopRequested || | |
| 2522 | rollbackBarrier !== null || | |
| 2523 | state.providerThread.nativeThreadRef?.nativeId !== | |
| 2524 | turnInput.providerThread.nativeThreadRef?.nativeId | |
| 2525 | ) { | |
| 2526 | return yield* protocolError("Pi turn ownership changed before it could start"); | |
| 2527 | } | |
| 2528 | const wake = pendingWake?.state === state ? pendingWake : null; | |
| 2529 | if (wake !== null && wake.state.providerThread.appThreadId !== turnInput.threadId) { | |
| 2530 | return yield* protocolError("Pi native wake belongs to a different app thread"); | |
| 2531 | } | |
| 2532 | pendingWake = null; | |
| 2533 | state.providerThread = turnInput.providerThread; | |
| 2534 | state.activeTurn = activeTurn; | |
| 2535 | activeTurn.adoptedWake = wake !== null && !continuation; | |
| 2536 | if (compactCommand !== null) { | |
| 2537 | yield* connection.send(compactRpcRecord(compactCommand)); | |
| 2538 | pendingCompactResponses.push({ | |
| 2539 | providerTurnId: providerTurn.id, | |
| 2540 | kind: "turn_start", | |
| 2541 | }); | |
| 2542 | } else if (payload !== null) { | |
| 2543 | yield* connection.send({ | |
| 2544 | type: "prompt", | |
| 2545 | message: payload.message, | |
| 2546 | ...(wake === null ? {} : { streamingBehavior: "steer" }), | |
| 2547 | ...(payload.images.length === 0 ? {} : { images: payload.images }), | |
| 2548 | }); | |
| 2549 | pendingPromptResponses.push({ | |
| 2550 | providerTurnId: providerTurn.id, | |
| 2551 | kind: "turn_start", | |
| 2552 | }); | |
| 2553 | } | |
| 2554 | yield* emit({ | |
| 2555 | type: "provider_turn.updated", | |
| 2556 | driver: PI_PROVIDER, | |
| 2557 | threadId: turnInput.threadId, | |
| 2558 | providerTurn, | |
| 2559 | }); | |
| 2560 | yield* updateProviderThread(state, { | |
| 2561 | status: "active", | |
| 2562 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 2563 | lastRunOrdinal: turnInput.runOrdinal, | |
| 2564 | }); | |
| 2565 | yield* updateProviderSession("running", null); | |
| 2566 | if (wake !== null) { | |
| 2567 | for (const event of wake.events) { | |
| 2568 | if (!continuation && event["type"] === "agent_settled") { | |
| 2569 | activeTurn.settleWhenIdle = true; | |
| 2570 | continue; | |
| 2571 | } | |
| 2572 | yield* handleSessionEvent(event); | |
| 2573 | } | |
| 2574 | } else if (continuation) { | |
| 2575 | // A user turn, teardown, or newer binding already took the wake. | |
| 2576 | yield* finalizeTurn(state); | |
| 2577 | } | |
| 2578 | if (outOfTurnExtensionErrors.length > 0) { | |
| 2579 | yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); | |
| 2580 | } | |
| 2581 | }).pipe( | |
| 2582 | sessionEventPermit.withPermits(1), | |
| 2583 | Effect.tapError(() => | |
| 2584 | Effect.sync(() => { | |
| 2585 | if (state.activeTurn === activeTurn) state.activeTurn = null; | |
| 2586 | }), | |
| 2587 | ), | |
| 2588 | ); | |
| 2589 | // Pi acks `prompt` only after slash-command expansion completes, | |
| 2590 | // and extension commands may block on user dialogs indefinitely. | |
| 2591 | // Rejections therefore return later as id-less response records | |
| 2592 | // handled by the event pump. | |
| 2593 | }).pipe( | |
| 2594 | Effect.mapError( | |
| 2595 | (cause) => | |
| 2596 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 2597 | driver: PI_PROVIDER, | |
| 2598 | threadId: turnInput.threadId, | |
| 2599 | providerThreadId: turnInput.providerThread.id, | |
| 2600 | runId: turnInput.runId, | |
| 2601 | cause, | |
| 2602 | }), | |
| 2603 | ), | |
| 2604 | ), | |
| 2605 | steerTurn: (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) => | |
| 2606 | Effect.gen(function* () { | |
| 2607 | const turn = threadState?.activeTurn ?? null; | |
| 2608 | if (turn === null || turn.providerTurn.id !== steerInput.providerTurnId) { | |
| 2609 | return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); | |
| 2610 | } | |
| 2611 | const compactCommand = parsePiCompactCommand(steerInput.message.text); | |
| 2612 | const payload = | |
| 2613 | compactCommand === null | |
| 2614 | ? yield* resolvePromptPayload( | |
| 2615 | steerInput.message.text, | |
| 2616 | steerInput.message.attachments, | |
| 2617 | ) | |
| 2618 | : null; | |
| 2619 | // Prompt with streamingBehavior steer is atomic on Pi's side: it | |
| 2620 | // queues during an active run and starts a new run if settlement | |
| 2621 | // won the race. A direct `steer` sent after Pi became idle would | |
| 2622 | // remain queued forever. Send fire-and-forget under the session | |
| 2623 | // permit so a slash-command dialog cannot block the turn, and so | |
| 2624 | // settlement cannot overtake the active-turn check. | |
| 2625 | // /compact is not a prompt: Pi's compact RPC aborts the agent first. | |
| 2626 | yield* sessionEventPermit.withPermits(1)( | |
| 2627 | Effect.gen(function* () { | |
| 2628 | if (threadState?.activeTurn !== turn) { | |
| 2629 | return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); | |
| 2630 | } | |
| 2631 | if (compactCommand !== null) { | |
| 2632 | turn.manualCompactInFlight = true; | |
| 2633 | yield* connection.send(compactRpcRecord(compactCommand)); | |
| 2634 | pendingCompactResponses.push({ | |
| 2635 | providerTurnId: turn.providerTurn.id, | |
| 2636 | kind: "steer", | |
| 2637 | }); | |
| 2638 | } else if (payload !== null) { | |
| 2639 | yield* connection.send({ | |
| 2640 | type: "prompt", | |
| 2641 | message: payload.message, | |
| 2642 | streamingBehavior: "steer", | |
| 2643 | ...(payload.images.length === 0 ? {} : { images: payload.images }), | |
| 2644 | }); | |
| 2645 | pendingPromptResponses.push({ | |
| 2646 | providerTurnId: turn.providerTurn.id, | |
| 2647 | kind: "steer", | |
| 2648 | }); | |
| 2649 | } | |
| 2650 | turn.settleProbeGeneration += 1; | |
| 2651 | }), | |
| 2652 | ); | |
| 2653 | }).pipe( | |
| 2654 | Effect.mapError( | |
| 2655 | (cause) => | |
| 2656 | new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 2657 | driver: PI_PROVIDER, | |
| 2658 | providerThreadId: steerInput.providerThread.id, | |
| 2659 | providerTurnId: steerInput.providerTurnId, | |
| 2660 | cause, | |
| 2661 | }), | |
| 2662 | ), | |
| 2663 | ), | |
| 2664 | interruptTurn: (interruptInput) => | |
| 2665 | Effect.gen(function* () { | |
| 2666 | const turn = threadState?.activeTurn ?? null; | |
| 2667 | // Stop on a settled turn: Pi runs nothing between prompts, so | |
| 2668 | // nothing of that turn is left to stop. | |
| 2669 | if (turn === null && interruptInput.requestRuntimeRestart === true) { | |
| 2670 | if (pendingWake !== null) | |
| 2671 | yield* sessionEventPermit.withPermits(1)(stopPendingWake(pendingWake)); | |
| 2672 | return; | |
| 2673 | } | |
| 2674 | if (turn === null || turn.providerTurn.id !== interruptInput.providerTurnId) { | |
| 2675 | return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); | |
| 2676 | } | |
| 2677 | turn.interrupted = true; | |
| 2678 | if ( | |
| 2679 | interruptInput.requestRuntimeRestart === true || | |
| 2680 | turn.settleWhenIdle || | |
| 2681 | turn.activeCompaction !== null || | |
| 2682 | turn.manualCompactInFlight | |
| 2683 | ) { | |
| 2684 | // Pi's generic abort does not cancel manual compaction. Terminate | |
| 2685 | // so Stop covers user /compact as well as detached recovery compact. | |
| 2686 | stopRequested = true; | |
| 2687 | if (interruptInput.requestRuntimeRestart === true && !turn.settleWhenIdle) { | |
| 2688 | yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); | |
| 2689 | } | |
| 2690 | // Terminating fails every later request, so read the stopped | |
| 2691 | // turn's session-tree refs first: rolling back past this turn | |
| 2692 | // forks at its user entry. Holding the event permit also lets a | |
| 2693 | // finalize that is already reading them finish before the kill. | |
| 2694 | yield* sessionEventPermit.withPermits(1)( | |
| 2695 | Effect.gen(function* () { | |
| 2696 | if (threadState?.activeTurn === turn && turn.stopTreeRefs === undefined) { | |
| 2697 | turn.stopTreeRefs = yield* captureTurnTreeRefs(2_000); | |
| 2698 | } | |
| 2699 | yield* connection.terminate; | |
| 2700 | }), | |
| 2701 | ); | |
| 2702 | return; | |
| 2703 | } | |
| 2704 | yield* request({ type: "abort" }).pipe( | |
| 2705 | Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), | |
| 2706 | ); | |
| 2707 | }).pipe( | |
| 2708 | Effect.mapError( | |
| 2709 | (cause) => | |
| 2710 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 2711 | driver: PI_PROVIDER, | |
| 2712 | providerThreadId: interruptInput.providerThread.id, | |
| 2713 | providerTurnId: interruptInput.providerTurnId, | |
| 2714 | cause, | |
| 2715 | }), | |
| 2716 | ), | |
| 2717 | ), | |
| 2718 | respondToRuntimeRequest: (requestInput) => | |
| 2719 | Effect.gen(function* () { | |
| 2720 | const pending = pendingPrompts.get(String(requestInput.requestId)); | |
| 2721 | if (pending === undefined) { | |
| 2722 | return yield* protocolError( | |
| 2723 | `No pending Pi extension request ${requestInput.requestId}`, | |
| 2724 | ); | |
| 2725 | } | |
| 2726 | const response = piUiResponse(pending, requestInput.decision, requestInput.answers); | |
| 2727 | yield* connection.send({ | |
| 2728 | type: "extension_ui_response", | |
| 2729 | id: pending.nativeRequestId, | |
| 2730 | ...response, | |
| 2731 | }); | |
| 2732 | // Dropped only once Pi has the answer, so a failed send leaves the | |
| 2733 | // request retryable and still cancellable during teardown. | |
| 2734 | pendingPrompts.delete(String(requestInput.requestId)); | |
| 2735 | if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { | |
| 2736 | sessionApprovals.add(pending.approvalKey); | |
| 2737 | } | |
| 2738 | const resolvedAt = yield* DateTime.now; | |
| 2739 | pending.runtimeRequest = { | |
| 2740 | ...pending.runtimeRequest, | |
| 2741 | status: "resolved", | |
| 2742 | resolvedAt, | |
| 2743 | }; | |
| 2744 | yield* emit({ | |
| 2745 | type: "runtime_request.updated", | |
| 2746 | driver: PI_PROVIDER, | |
| 2747 | threadId: pending.node.threadId, | |
| 2748 | runtimeRequest: pending.runtimeRequest, | |
| 2749 | }); | |
| 2750 | yield* emit({ | |
| 2751 | type: "node.updated", | |
| 2752 | driver: PI_PROVIDER, | |
| 2753 | node: { ...pending.node, status: "completed", completedAt: resolvedAt }, | |
| 2754 | }); | |
| 2755 | yield* emit({ | |
| 2756 | type: "turn_item.updated", | |
| 2757 | driver: PI_PROVIDER, | |
| 2758 | turnItem: { | |
| 2759 | ...pending.turnItem, | |
| 2760 | status: "completed", | |
| 2761 | completedAt: resolvedAt, | |
| 2762 | updatedAt: resolvedAt, | |
| 2763 | }, | |
| 2764 | }); | |
| 2765 | }).pipe( | |
| 2766 | sessionEventPermit.withPermits(1), | |
| 2767 | Effect.mapError( | |
| 2768 | (cause) => | |
| 2769 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 2770 | driver: PI_PROVIDER, | |
| 2771 | requestId: requestInput.requestId, | |
| 2772 | cause, | |
| 2773 | }), | |
| 2774 | ), | |
| 2775 | ), | |
| 2776 | readThreadSnapshot: (snapshotInput) => | |
| 2777 | Effect.gen(function* () { | |
| 2778 | const state = threadState; | |
| 2779 | const boundNativeId = state?.providerThread.nativeThreadRef?.nativeId; | |
| 2780 | const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; | |
| 2781 | if (state === null || wantedNativeId == null || boundNativeId !== wantedNativeId) { | |
| 2782 | return yield* protocolError( | |
| 2783 | "Pi snapshot requested for a thread this session does not host", | |
| 2784 | ); | |
| 2785 | } | |
| 2786 | // get_messages is Pi's active-branch view. get_entries returns the | |
| 2787 | // whole session tree, including abandoned branches after /tree or | |
| 2788 | // fork, which would leak discarded conversation into handoffs. | |
| 2789 | const messagesData = yield* request({ type: "get_messages" }); | |
| 2790 | const activeMessages = recordField(messagesData, "messages"); | |
| 2791 | const threadId = state.providerThread.appThreadId ?? input.threadId; | |
| 2792 | const messages = (Array.isArray(activeMessages) ? activeMessages : []).flatMap( | |
| 2793 | (message, index) => { | |
| 2794 | const role = recordString(message, "role"); | |
| 2795 | if (role !== "user" && role !== "assistant") return []; | |
| 2796 | const text = contentText(recordField(message, "content")); | |
| 2797 | if (text.length === 0) return []; | |
| 2798 | const timestamp = recordNumber(message, "timestamp"); | |
| 2799 | const at = Option.getOrElse( | |
| 2800 | DateTime.make(timestamp ?? Number.NaN), | |
| 2801 | () => state.providerThread.createdAt, | |
| 2802 | ); | |
| 2803 | return [ | |
| 2804 | { | |
| 2805 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2806 | driver: PI_PROVIDER, | |
| 2807 | // RPC messages do not expose session-tree entry ids. The | |
| 2808 | // active-branch index is stable for the lifetime of this | |
| 2809 | // snapshot and keeps abandoned branch ids out of it. | |
| 2810 | nativeItemId: `${wantedNativeId}:snapshot-message:${index}`, | |
| 2811 | }), | |
| 2812 | threadId, | |
| 2813 | runId: null, | |
| 2814 | nodeId: null, | |
| 2815 | role: role as "user" | "assistant", | |
| 2816 | text, | |
| 2817 | attachments: [], | |
| 2818 | streaming: false, | |
| 2819 | createdBy: role === "user" ? ("user" as const) : ("agent" as const), | |
| 2820 | creationSource: "provider" as const, | |
| 2821 | createdAt: at, | |
| 2822 | updatedAt: at, | |
| 2823 | }, | |
| 2824 | ]; | |
| 2825 | }, | |
| 2826 | ); | |
| 2827 | return { | |
| 2828 | providerThread: state.providerThread, | |
| 2829 | providerTurns: [], | |
| 2830 | messages, | |
| 2831 | runtimeRequests: [], | |
| 2832 | }; | |
| 2833 | }).pipe( | |
| 2834 | Effect.mapError( | |
| 2835 | (cause) => | |
| 2836 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 2837 | driver: PI_PROVIDER, | |
| 2838 | providerThreadId: snapshotInput.providerThread.id, | |
| 2839 | cause, | |
| 2840 | }), | |
| 2841 | ), | |
| 2842 | ), | |
| 2843 | rollbackThread: (rollbackInput) => | |
| 2844 | Effect.gen(function* () { | |
| 2845 | const state = threadState; | |
| 2846 | if (state === null) { | |
| 2847 | return yield* protocolError("Pi session has no registered thread"); | |
| 2848 | } | |
| 2849 | if (state.providerThread.id !== rollbackInput.providerThread.id) { | |
| 2850 | return yield* protocolError( | |
| 2851 | "Pi rollback requested for a thread this session does not host", | |
| 2852 | ); | |
| 2853 | } | |
| 2854 | if (state.activeTurn !== null) { | |
| 2855 | return yield* protocolError("Cannot roll back while a Pi turn is active"); | |
| 2856 | } | |
| 2857 | if (pendingWake !== null) { | |
| 2858 | return yield* protocolError("Cannot roll back while native Pi work awaits a turn"); | |
| 2859 | } | |
| 2860 | // `fork(entryId)` re-roots the active branch before that user | |
| 2861 | // message, so the rollback boundary is the first user entry of | |
| 2862 | // the earliest turn being discarded. | |
| 2863 | const forkEntryId = piRollbackForkEntry(rollbackInput); | |
| 2864 | if (forkEntryId === null) { | |
| 2865 | // Nothing after the target: the conversation is already there. | |
| 2866 | return piThreadSnapshot(state.providerThread); | |
| 2867 | } | |
| 2868 | if (forkEntryId === undefined) { | |
| 2869 | return yield* protocolError("Pi rollback target has no captured session-tree entry"); | |
| 2870 | } | |
| 2871 | return yield* Effect.acquireUseRelease( | |
| 2872 | sessionEventPermit.withPermits(1)( | |
| 2873 | Effect.gen(function* () { | |
| 2874 | if ( | |
| 2875 | rollbackBarrier !== null || | |
| 2876 | threadState !== state || | |
| 2877 | state.activeTurn !== null || | |
| 2878 | pendingWake !== null || | |
| 2879 | closed || | |
| 2880 | stopRequested | |
| 2881 | ) | |
| 2882 | return yield* protocolError( | |
| 2883 | "Pi rollback ownership changed before its fork could start", | |
| 2884 | ); | |
| 2885 | const barrier = yield* Deferred.make< | |
| 2886 | void, | |
| 2887 | ProviderAdapter.ProviderAdapterProtocolError | |
| 2888 | >(); | |
| 2889 | rollbackBarrier = barrier; | |
| 2890 | return barrier; | |
| 2891 | }), | |
| 2892 | ), | |
| 2893 | (barrier) => | |
| 2894 | Effect.gen(function* () { | |
| 2895 | const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); | |
| 2896 | if (recordField(forkData, "cancelled") === true) | |
| 2897 | return yield* protocolError("A Pi extension cancelled the session fork"); | |
| 2898 | const forkState = yield* request({ type: "get_state" }).pipe( | |
| 2899 | Effect.onError(() => | |
| 2900 | Effect.sync(() => { | |
| 2901 | threadState = null; | |
| 2902 | }), | |
| 2903 | ), | |
| 2904 | ); | |
| 2905 | const forkSessionFile = recordString(forkState, "sessionFile"); | |
| 2906 | if (forkSessionFile === undefined) { | |
| 2907 | threadState = null; | |
| 2908 | return yield* protocolError("Pi fork did not return a persisted session file"); | |
| 2909 | } | |
| 2910 | const entriesData = yield* request({ type: "get_entries" }).pipe( | |
| 2911 | Effect.orElseSucceed(() => undefined), | |
| 2912 | ); | |
| 2913 | const leafId = recordString(entriesData, "leafId") ?? null; | |
| 2914 | // RPC responses bypass the pump. Drain preceding native events | |
| 2915 | // before deciding whether this fork can replace their binding. | |
| 2916 | const queued = yield* Queue.offer(connection.events, { | |
| 2917 | type: "t3.rollback_barrier", | |
| 2918 | barrier, | |
| 2919 | }); | |
| 2920 | if (!queued) return yield* protocolError("Pi transport closed during rollback"); | |
| 2921 | yield* Deferred.await(barrier); | |
| 2922 | return yield* sessionEventPermit.withPermits(1)( | |
| 2923 | Effect.gen(function* () { | |
| 2924 | if (pendingWake !== null) { | |
| 2925 | yield* stopPendingWake(pendingWake); | |
| 2926 | return yield* protocolError( | |
| 2927 | "Native Pi work started while rollback was changing the session", | |
| 2928 | ); | |
| 2929 | } | |
| 2930 | if ( | |
| 2931 | threadState !== state || | |
| 2932 | state.activeTurn !== null || | |
| 2933 | closed || | |
| 2934 | stopRequested | |
| 2935 | ) | |
| 2936 | return yield* protocolError( | |
| 2937 | "Pi rollback ownership changed during its fork", | |
| 2938 | ); | |
| 2939 | lastNativeThreadId = forkSessionFile; | |
| 2940 | appliedModel = null; | |
| 2941 | appliedThinking = null; | |
| 2942 | appliedSessionName = null; | |
| 2943 | lastKnownLeaf = leafId; | |
| 2944 | leafCursorStale = entriesData === undefined; | |
| 2945 | yield* updateProviderThread(state, { | |
| 2946 | nativeThreadRef: providerRef(forkSessionFile), | |
| 2947 | nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), | |
| 2948 | }); | |
| 2949 | rollbackBarrier = null; | |
| 2950 | return piThreadSnapshot(state.providerThread); | |
| 2951 | }), | |
| 2952 | ); | |
| 2953 | }).pipe(Effect.onInterrupt(() => connection.terminate)), | |
| 2954 | (barrier) => | |
| 2955 | Effect.sync(() => { | |
| 2956 | if (rollbackBarrier === barrier) rollbackBarrier = null; | |
| 2957 | }), | |
| 2958 | ); | |
| 2959 | }).pipe( | |
| 2960 | Effect.mapError( | |
| 2961 | (cause) => | |
| 2962 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 2963 | driver: PI_PROVIDER, | |
| 2964 | providerThreadId: rollbackInput.providerThread.id, | |
| 2965 | checkpointId: rollbackInput.target.checkpointId, | |
| 2966 | cause, | |
| 2967 | }), | |
| 2968 | ), | |
| 2969 | ), | |
| 2970 | forkThread: (forkInput) => | |
| 2971 | Effect.gen(function* () { | |
| 2972 | if (threadState?.activeTurn != null) { | |
| 2973 | return yield* protocolError("Cannot fork while a Pi turn is active"); | |
| 2974 | } | |
| 2975 | const source = forkInput.sourceProviderThread; | |
| 2976 | const sourceFile = source.nativeThreadRef?.nativeId; | |
| 2977 | if (sourceFile == null) | |
| 2978 | return yield* protocolError("Pi fork source has no session file"); | |
| 2979 | const sourceTurns = (forkInput.sourceProviderTurns ?? []).filter( | |
| 2980 | (turn) => turn.providerThreadId === source.id, | |
| 2981 | ); | |
| 2982 | const target = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); | |
| 2983 | if (forkInput.providerTurnId !== undefined && target === undefined) { | |
| 2984 | return yield* protocolError("Pi fork target turn is missing"); | |
| 2985 | } | |
| 2986 | const beforeEntry = | |
| 2987 | target === undefined | |
| 2988 | ? null | |
| 2989 | : piRollbackForkEntry({ | |
| 2990 | target: { type: "provider_turn", providerTurn: target }, | |
| 2991 | providerThreadTurns: sourceTurns, | |
| 2992 | }); | |
| 2993 | if (beforeEntry === undefined) { | |
| 2994 | return yield* protocolError("Pi fork boundary has no captured session-tree entry"); | |
| 2995 | } | |
| 2996 | // CLI --fork uses Pi's own session format and sets the destination | |
| 2997 | // cwd. RPC switch_session/clone alone would retain the source cwd. | |
| 2998 | // This short-lived process never prompts or runs user extensions. | |
| 2999 | const forkLaunch = buildPiRpcLaunch({ | |
| 3000 | launchArgs: resolvedLaunchArgs.args, | |
| 3001 | environment: options.environment, | |
| 3002 | mcpSession: undefined, | |
| 3003 | extensionPath: undefined, | |
| 3004 | disableExtensions: true, | |
| 3005 | disableTools: true, | |
| 3006 | }); | |
| 3007 | const nativeId = yield* Effect.scoped( | |
| 3008 | Effect.gen(function* () { | |
| 3009 | const forkConnection = yield* makePiRpcConnection({ | |
| 3010 | command: options.settings.binaryPath || "pi", | |
| 3011 | args: [...forkLaunch.args, "--fork", sourceFile], | |
| 3012 | cwd, | |
| 3013 | env: forkLaunch.env, | |
| 3014 | }); | |
| 3015 | yield* Stream.fromQueue(forkConnection.events).pipe( | |
| 3016 | Stream.runDrain, | |
| 3017 | Effect.ignore, | |
| 3018 | Effect.forkScoped, | |
| 3019 | ); | |
| 3020 | if (beforeEntry !== null) { | |
| 3021 | const result = yield* forkConnection.request({ | |
| 3022 | type: "fork", | |
| 3023 | entryId: beforeEntry, | |
| 3024 | }); | |
| 3025 | if (recordField(result, "cancelled") === true) { | |
| 3026 | return yield* protocolError("A Pi extension cancelled the session fork"); | |
| 3027 | } | |
| 3028 | } | |
| 3029 | const state = yield* forkConnection.request({ type: "get_state" }); | |
| 3030 | const file = recordString(state, "sessionFile"); | |
| 3031 | if (file === undefined || file === sourceFile) { | |
| 3032 | return yield* protocolError("Pi fork did not create a distinct session file"); | |
| 3033 | } | |
| 3034 | return file; | |
| 3035 | }), | |
| 3036 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner)); | |
| 3037 | const now = yield* DateTime.now; | |
| 3038 | return yield* registerThread( | |
| 3039 | { | |
| 3040 | threadId: forkInput.targetThreadId, | |
| 3041 | modelSelection: forkInput.modelSelection ?? input.modelSelection, | |
| 3042 | runtimePolicy: forkInput.runtimePolicy ?? input.runtimePolicy, | |
| 3043 | existingProviderThread: { | |
| 3044 | ...source, | |
| 3045 | id: idAllocator.derive.providerThread({ | |
| 3046 | driver: PI_PROVIDER, | |
| 3047 | nativeThreadId: nativeId, | |
| 3048 | }), | |
| 3049 | appThreadId: forkInput.targetThreadId, | |
| 3050 | providerSessionId: input.providerSessionId, | |
| 3051 | providerInstanceId: options.instanceId, | |
| 3052 | ownerNodeId: forkInput.ownerNodeId ?? null, | |
| 3053 | nativeThreadRef: providerRef(nativeId), | |
| 3054 | nativeConversationHeadRef: null, | |
| 3055 | firstRunOrdinal: null, | |
| 3056 | lastRunOrdinal: null, | |
| 3057 | handoffIds: [], | |
| 3058 | pendingBackgroundTasks: [], | |
| 3059 | forkedFrom: { | |
| 3060 | providerThreadId: source.id, | |
| 3061 | ...(forkInput.providerTurnId === undefined | |
| 3062 | ? {} | |
| 3063 | : { providerTurnId: forkInput.providerTurnId }), | |
| 3064 | }, | |
| 3065 | createdAt: now, | |
| 3066 | updatedAt: now, | |
| 3067 | }, | |
| 3068 | }, | |
| 3069 | false, | |
| 3070 | ); | |
| 3071 | }).pipe( | |
| 3072 | Effect.mapError( | |
| 3073 | (cause) => | |
| 3074 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 3075 | driver: PI_PROVIDER, | |
| 3076 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3077 | cause, | |
| 3078 | }), | |
| 3079 | ), | |
| 3080 | ), | |
| 3081 | }; | |
| 3082 | return runtime; | |
| 3083 | }), | |
| 3084 | }); | |
| 3085 | } | |
| 3086 | ||
| 3087 | /** | |
| 3088 | * Resolve the pi session-tree entry `fork` should re-root at for a rollback. | |
| 3089 | * Returns `null` when no turns follow the target (nothing to discard) and | |
| 3090 | * `undefined` when the boundary turn has no captured entry ref (only | |
| 3091 | * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). | |
| 3092 | */ | |
| 3093 | function piRollbackForkEntry(input: { | |
| 3094 | readonly target: | |
| 3095 | | { readonly type: "thread_start" } | |
| 3096 | | { readonly type: "provider_turn"; readonly providerTurn: OrchestrationV2ProviderTurn }; | |
| 3097 | readonly providerThreadTurns: ReadonlyArray<OrchestrationV2ProviderTurn>; | |
| 3098 | }): string | null | undefined { | |
| 3099 | const boundaryOrdinal = | |
| 3100 | input.target.type === "thread_start" ? 0 : input.target.providerTurn.ordinal; | |
| 3101 | const discarded = input.providerThreadTurns | |
| 3102 | .filter((turn) => turn.ordinal > boundaryOrdinal) | |
| 3103 | .sort((a, b) => a.ordinal - b.ordinal); | |
| 3104 | const boundary = discarded[0]; | |
| 3105 | if (boundary === undefined) return null; | |
| 3106 | const ref = boundary.nativeTurnRef; | |
| 3107 | if (ref === null || ref.strength !== "strong" || ref.nativeId === null) return undefined; | |
| 3108 | return ref.nativeId; | |
| 3109 | } | |
| 3110 | ||
| 3111 | /** | |
| 3112 | * Human-readable output for one subagent-extension task result: the last | |
| 3113 | * assistant text from its transcript, or the error/stderr when it failed. | |
| 3114 | */ | |
| 3115 | function piSubagentOutput(result: unknown): string { | |
| 3116 | const stopReason = recordString(result, "stopReason"); | |
| 3117 | const failed = | |
| 3118 | (recordNumber(result, "exitCode") ?? 0) !== 0 || | |
| 3119 | stopReason === "error" || | |
| 3120 | stopReason === "aborted"; | |
| 3121 | if (failed) { | |
| 3122 | // Falsy fallback, not `??`: an empty `errorMessage` must not suppress a | |
| 3123 | // non-empty `stderr`, which is often the only description of the failure. | |
| 3124 | const failure = recordString(result, "errorMessage") || recordString(result, "stderr"); | |
| 3125 | if (failure !== undefined && failure.length > 0) return failure; | |
| 3126 | } | |
| 3127 | const messages = recordField(result, "messages"); | |
| 3128 | if (!Array.isArray(messages)) return ""; | |
| 3129 | for (let index = messages.length - 1; index >= 0; index -= 1) { | |
| 3130 | const message = messages[index]; | |
| 3131 | if (recordString(message, "role") !== "assistant") continue; | |
| 3132 | const text = contentText(recordField(message, "content")); | |
| 3133 | if (text.length > 0) return text; | |
| 3134 | } | |
| 3135 | return ""; | |
| 3136 | } | |
| 3137 | ||
| 3138 | function piExtensionDisplayName(extensionPath: string | undefined): string { | |
| 3139 | if (extensionPath === undefined) return "Pi extension"; | |
| 3140 | const normalized = extensionPath.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 3141 | const name = normalized.slice(normalized.lastIndexOf("/") + 1).replace(/\.[^.]+$/, ""); | |
| 3142 | return name.length === 0 ? "Pi extension" : name; | |
| 3143 | } | |
| 3144 | ||
| 3145 | function piThreadSnapshot( | |
| 3146 | providerThread: OrchestrationV2ProviderThread, | |
| 3147 | ): ProviderAdapter.ProviderAdapterV2ThreadSnapshot { | |
| 3148 | return { providerThread, providerTurns: [], messages: [], runtimeRequests: [] }; | |
| 3149 | } | |
| 3150 | ||
| 3151 | function piQuestion( | |
| 3152 | questionId: string, | |
| 3153 | method: "select" | "input" | "editor", | |
| 3154 | title: string, | |
| 3155 | event: PiRpcRecord, | |
| 3156 | ): OrchestrationV2UserInputQuestion { | |
| 3157 | const options = | |
| 3158 | method === "select" && Array.isArray(event["options"]) | |
| 3159 | ? event["options"] | |
| 3160 | .filter((option): option is string => typeof option === "string") | |
| 3161 | .map((option) => ({ label: option || "Empty value", description: option, value: option })) | |
| 3162 | : method === "editor" | |
| 3163 | ? [] | |
| 3164 | : [ | |
| 3165 | { | |
| 3166 | label: "Submit empty value", | |
| 3167 | description: "Send an empty string to the extension.", | |
| 3168 | value: "", | |
| 3169 | }, | |
| 3170 | ]; | |
| 3171 | const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; | |
| 3172 | return { | |
| 3173 | id: questionId, | |
| 3174 | header: title, | |
| 3175 | question, | |
| 3176 | ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), | |
| 3177 | options, | |
| 3178 | }; | |
| 3179 | } | |
| 3180 | ||
| 3181 | function piUiResponse( | |
| 3182 | pending: PendingPiPrompt, | |
| 3183 | decision: ProviderApprovalDecision | undefined, | |
| 3184 | answers: Record<string, unknown> | undefined, | |
| 3185 | ): PiRpcRecord { | |
| 3186 | if (pending.method === "confirm") { | |
| 3187 | if (decision === "accept" || decision === "acceptForSession") return { confirmed: true }; | |
| 3188 | if (decision === "decline") return { confirmed: false }; | |
| 3189 | return { cancelled: true }; | |
| 3190 | } | |
| 3191 | const answer = answers?.[pending.questionId]; | |
| 3192 | // An empty string is a valid dialog value per the RPC spec (the extension | |
| 3193 | // receives ""), distinct from cancelling (the extension receives undefined). | |
| 3194 | if (typeof answer === "string") return { value: answer }; | |
| 3195 | return { cancelled: true }; | |
| 3196 | } | |
| 3197 | ||
| 3198 | // ── driver ──────────────────────────────────────────────────── | |
| 3199 | ||
| 3200 | export type PiAdapterV2DriverEnv = | |
| 3201 | | ChildProcessSpawner.ChildProcessSpawner | |
| 3202 | | FileSystem.FileSystem | |
| 3203 | | IdAllocator.IdAllocatorV2 | |
| 3204 | | ProviderHost; | |
| 3205 | ||
| 3206 | export const PiAdapterV2Driver: ProviderAdapterDriver<PiSettings, PiAdapterV2DriverEnv> = { | |
| 3207 | driverKind: PI_DRIVER_KIND, | |
| 3208 | configSchema: PiSettings, | |
| 3209 | defaultConfig: (): PiSettings => DEFAULT_PI_SETTINGS, | |
| 3210 | create: Effect.fn("PiAdapterV2Driver.create")( | |
| 3211 | function* (input: ProviderAdapterDriverCreateInput<PiSettings>) { | |
| 3212 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3213 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 3215 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3216 | const host = yield* ProviderHost; | |
| 3217 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 3218 | return makePiAdapterV2({ | |
| 3219 | instanceId: input.instanceId, | |
| 3220 | settings: { ...input.config, enabled: input.enabled }, | |
| 3221 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 3222 | spawner, | |
| 3223 | fileSystem, | |
| 3224 | idAllocator, | |
| 3225 | host, | |
| 3226 | continuationRequests, | |
| 3227 | }); | |
| 3228 | }, | |
| 3229 | (effect, input) => | |
| 3230 | effect.pipe( | |
| 3231 | Effect.mapError( | |
| 3232 | (cause) => | |
| 3233 | new ProviderAdapterDriverCreateError({ | |
| 3234 | driver: PI_DRIVER_KIND, | |
| 3235 | instanceId: input.instanceId, | |
| 3236 | detail: "Failed to create Pi adapter.", | |
| 3237 | cause, | |
| 3238 | }), | |
| 3239 | ), | |
| 3240 | ), | |
| 3241 | ), | |
| 3242 | }; | |
| 3243 | ||
| 3244 | const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, PiAdapterV2DriverEnv> = | |
| 3245 | Layer.effect( | |
| 3246 | ProviderAdapter.ProviderAdapterV2, | |
| 3247 | Effect.gen(function* () { | |
| 3248 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3249 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3250 | const fileSystem = yield* FileSystem.FileSystem; | |
| 3251 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3252 | const host = yield* ProviderHost; | |
| 3253 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 3254 | return makePiAdapterV2({ | |
| 3255 | instanceId: PI_DEFAULT_INSTANCE_ID, | |
| 3256 | settings: DEFAULT_PI_SETTINGS, | |
| 3257 | environment: hostEnvironment, | |
| 3258 | spawner, | |
| 3259 | fileSystem, | |
| 3260 | idAllocator, | |
| 3261 | host, | |
| 3262 | continuationRequests, | |
| 3263 | }); | |
| 3264 | }), | |
| 3265 | ); |
apps/server/src/orchestration-v2/Adapters/PiRpc.ts deleted-484| ... | ... | @@ -1,484 +0,0 @@ |
| 1 | /** | |
| 2 | * PiRpc — stdio JSONL transport for the Pi coding agent's RPC mode. | |
| 3 | * | |
| 4 | * Spawns `pi --mode rpc` and speaks Pi's line-delimited JSON protocol: | |
| 5 | * requests go to stdin as `{"type": "...", "id": "..."}` records, responses | |
| 6 | * come back as `{"type": "response", "id": ..., "success": ...}` and are | |
| 7 | * correlated by `id`; every other stdout record is a session event and is | |
| 8 | * surfaced on the `events` queue in arrival order. | |
| 9 | * | |
| 10 | * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` | |
| 11 | * stripped. Lines are split manually (never with `readline`, which also | |
| 12 | * splits on U+2028/U+2029 and would corrupt frames). Records that fail to | |
| 13 | * parse as JSON are dropped with a debug log rather than failing the | |
| 14 | * transport, so a chatty extension cannot take the session down. | |
| 15 | * | |
| 16 | * Used by `PiAdapterV2` for sessions and by `PiTextGeneration` / | |
| 17 | * `PiProvider` for ephemeral one-shot processes. | |
| 18 | */ | |
| 19 | import * as Clock from "effect/Clock"; | |
| 20 | import * as Deferred from "effect/Deferred"; | |
| 21 | import * as Duration from "effect/Duration"; | |
| 22 | import * as Effect from "effect/Effect"; | |
| 23 | import * as Option from "effect/Option"; | |
| 24 | import * as Predicate from "effect/Predicate"; | |
| 25 | import * as Queue from "effect/Queue"; | |
| 26 | import * as Scope from "effect/Scope"; | |
| 27 | import * as Schema from "effect/Schema"; | |
| 28 | import * as Stream from "effect/Stream"; | |
| 29 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 30 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 31 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 32 | ||
| 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 34 | ||
| 35 | export class PiRpcError extends Schema.TaggedError<PiRpcError>()("PiRpcError", { | |
| 36 | operation: Schema.String, | |
| 37 | detail: Schema.optional(Schema.String), | |
| 38 | cause: Schema.optional(Schema.Defect()), | |
| 39 | }) { | |
| 40 | override get message(): string { | |
| 41 | return `Pi RPC ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export class PiRpcTimeoutError extends Schema.TaggedError<PiRpcTimeoutError>()( | |
| 46 | "PiRpcTimeoutError", | |
| 47 | { | |
| 48 | operation: Schema.String, | |
| 49 | timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), | |
| 50 | }, | |
| 51 | ) { | |
| 52 | override get message(): string { | |
| 53 | return `Pi RPC ${this.operation} failed: timed out after ${this.timeoutMs}ms.`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | export type PiRpcRecord = Record<string, unknown>; | |
| 58 | ||
| 59 | export function piRecordField(input: unknown, key: string): unknown { | |
| 60 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 61 | } | |
| 62 | ||
| 63 | export function piRecordString(input: unknown, key: string): string | undefined { | |
| 64 | const value = piRecordField(input, key); | |
| 65 | return Predicate.isString(value) ? value : undefined; | |
| 66 | } | |
| 67 | ||
| 68 | export function piRecordNumber(input: unknown, key: string): number | undefined { | |
| 69 | const value = piRecordField(input, key); | |
| 70 | return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Splits a `provider/model` slug into the two fields `set_model` expects. | |
| 75 | * Returns null for slugs without a usable separator so callers can reject the | |
| 76 | * selection instead of silently leaving Pi on its configured default. | |
| 77 | */ | |
| 78 | export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { | |
| 79 | const separator = slug.indexOf("/"); | |
| 80 | if (separator <= 0 || separator === slug.length - 1) return null; | |
| 81 | return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; | |
| 82 | } | |
| 83 | ||
| 84 | export interface PiRpcSpawnOptions { | |
| 85 | readonly command: string; | |
| 86 | readonly args: ReadonlyArray<string>; | |
| 87 | readonly cwd: string | undefined; | |
| 88 | readonly env: NodeJS.ProcessEnv; | |
| 89 | } | |
| 90 | ||
| 91 | export interface PiRpcConnection { | |
| 92 | /** Fire-and-forget write (used for `extension_ui_response`). */ | |
| 93 | readonly send: (record: PiRpcRecord) => Effect.Effect<void, PiRpcError>; | |
| 94 | /** | |
| 95 | * Correlated request: assigns an `id`, waits for the matching response | |
| 96 | * record, and returns its `data` (undefined when the command carries none). | |
| 97 | * Fails on `success: false`, transport death, or timeout. | |
| 98 | */ | |
| 99 | readonly request: ( | |
| 100 | record: PiRpcRecord, | |
| 101 | timeoutMs?: number, | |
| 102 | ) => Effect.Effect<unknown, PiRpcError | PiRpcTimeoutError>; | |
| 103 | /** | |
| 104 | * Session events (every non-response stdout record) in arrival order. The | |
| 105 | * full queue is exposed so consumers can append order-preserving synthetic | |
| 106 | * records of their own (see PiAdapterV2's settle probe). | |
| 107 | */ | |
| 108 | readonly events: Queue.Queue<PiRpcRecord, PiRpcError>; | |
| 109 | /** Resolves when the process has exited, with its exit code. */ | |
| 110 | readonly exited: Effect.Effect<number, PiRpcError>; | |
| 111 | /** | |
| 112 | * Kill the pi process group immediately (SIGTERM, grace, SIGKILL). Used by | |
| 113 | * Stop-with-restart when the process may be wedged and `abort` cannot be | |
| 114 | * trusted to land. The transport fails and the session manager respawns a | |
| 115 | * fresh process on the next turn. | |
| 116 | */ | |
| 117 | readonly terminate: Effect.Effect<void>; | |
| 118 | } | |
| 119 | ||
| 120 | const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; | |
| 121 | const TERMINATION_GRACE = Duration.seconds(1); | |
| 122 | ||
| 123 | interface PendingPiRequest { | |
| 124 | readonly deferred: Deferred.Deferred<unknown, PiRpcError>; | |
| 125 | } | |
| 126 | ||
| 127 | const MAX_PI_RECORD_CHARS = 8 * 1024 * 1024; | |
| 128 | ||
| 129 | function makeJsonlFramer() { | |
| 130 | let buffer = ""; | |
| 131 | let dropping = false; | |
| 132 | return (chunk: string): ReadonlyArray<string> => { | |
| 133 | const lines: string[] = []; | |
| 134 | let start = 0; | |
| 135 | while (start < chunk.length) { | |
| 136 | const newline = chunk.indexOf("\n", start); | |
| 137 | const end = newline < 0 ? chunk.length : newline; | |
| 138 | if (!dropping) { | |
| 139 | if (buffer.length + end - start > MAX_PI_RECORD_CHARS) { | |
| 140 | buffer = ""; | |
| 141 | dropping = true; | |
| 142 | } else { | |
| 143 | buffer += chunk.slice(start, end); | |
| 144 | } | |
| 145 | } | |
| 146 | if (newline < 0) break; | |
| 147 | if (!dropping && buffer.length > 0) { | |
| 148 | lines.push(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer); | |
| 149 | } | |
| 150 | buffer = ""; | |
| 151 | dropping = false; | |
| 152 | start = newline + 1; | |
| 153 | } | |
| 154 | return lines; | |
| 155 | }; | |
| 156 | } | |
| 157 | ||
| 158 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | |
| 159 | const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); | |
| 160 | const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); | |
| 161 | ||
| 162 | const PI_ERROR_DETAIL_MAX_CHARS = 200; | |
| 163 | ||
| 164 | /** | |
| 165 | * Bounded, human-readable summary of a failed response's `error` payload. | |
| 166 | * The untruncated value stays on the error's `cause`, so `message` never | |
| 167 | * carries unbounded remote text while logs keep something diagnostic. | |
| 168 | */ | |
| 169 | function summarizePiError(error: unknown): string { | |
| 170 | const text = typeof error === "string" ? error : JSON.stringify(error); | |
| 171 | if (text === undefined) return "unknown error"; | |
| 172 | return text.length > PI_ERROR_DETAIL_MAX_CHARS | |
| 173 | ? `${text.slice(0, PI_ERROR_DETAIL_MAX_CHARS)}…` | |
| 174 | : text; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Stdout closure is not enough to diagnose an early crash: Pi 0.84+ can exit | |
| 179 | * 1 on import before writing any protocol line. The numeric exit code is | |
| 180 | * sanitized; stderr stays out of public details because it can carry | |
| 181 | * credentials or prompt text. | |
| 182 | */ | |
| 183 | function describePiStdoutClosure(exitCode: number | undefined): string { | |
| 184 | if (exitCode === undefined || !Number.isFinite(exitCode)) { | |
| 185 | return "pi process closed stdout"; | |
| 186 | } | |
| 187 | return `pi process exited with code ${exitCode}`; | |
| 188 | } | |
| 189 | ||
| 190 | function parsePiRecord(line: string): PiRpcRecord | undefined { | |
| 191 | try { | |
| 192 | const parsed: unknown = decodeJsonLine(line); | |
| 193 | return Predicate.isObject(parsed) ? parsed : undefined; | |
| 194 | } catch { | |
| 195 | return undefined; | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Kill the pi process group: SIGTERM, short grace, then SIGKILL. | |
| 201 | * | |
| 202 | * `hasExited` is consulted before each signal. Once the original child is | |
| 203 | * gone its pid/pgid can be recycled by the OS, so escalating blindly could | |
| 204 | * deliver SIGKILL to an unrelated process. | |
| 205 | */ | |
| 206 | const terminatePiProcess = (kill: (signal: NodeJS.Signals) => boolean, hasExited: () => boolean) => | |
| 207 | Effect.gen(function* () { | |
| 208 | if (hasExited()) return; | |
| 209 | if (!kill("SIGTERM")) return; | |
| 210 | yield* Effect.sleep(TERMINATION_GRACE); | |
| 211 | if (hasExited()) return; | |
| 212 | kill("SIGKILL"); | |
| 213 | }); | |
| 214 | ||
| 215 | export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSpawnOptions) { | |
| 216 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 217 | const platform = yield* HostProcessPlatform; | |
| 218 | const scope = yield* Effect.scope; | |
| 219 | ||
| 220 | const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { | |
| 221 | env: options.env, | |
| 222 | }).pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); | |
| 223 | const child = yield* spawner | |
| 224 | .spawn( | |
| 225 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 226 | ...(options.cwd === undefined ? {} : { cwd: options.cwd }), | |
| 227 | env: options.env, | |
| 228 | extendEnv: false, | |
| 229 | shell: spawnCommand.shell, | |
| 230 | detached: platform !== "win32", | |
| 231 | }), | |
| 232 | ) | |
| 233 | .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); | |
| 234 | ||
| 235 | let childExited = false; | |
| 236 | let diagnosingStdoutClose = false; | |
| 237 | ||
| 238 | const killProcessGroup = (signal: NodeJS.Signals): boolean => { | |
| 239 | try { | |
| 240 | if (platform === "win32") { | |
| 241 | process.kill(Number(child.pid), signal); | |
| 242 | } else { | |
| 243 | signalProcessGroup(Number(child.pid), signal); | |
| 244 | } | |
| 245 | return true; | |
| 246 | } catch { | |
| 247 | return false; | |
| 248 | } | |
| 249 | }; | |
| 250 | ||
| 251 | /** Signal 0 probes liveness without delivering anything. */ | |
| 252 | const hasExited = (): boolean => { | |
| 253 | if (childExited) return true; | |
| 254 | try { | |
| 255 | if (platform === "win32") process.kill(Number(child.pid), 0); | |
| 256 | else signalProcessGroup(Number(child.pid), 0); | |
| 257 | return false; | |
| 258 | } catch { | |
| 259 | return true; | |
| 260 | } | |
| 261 | }; | |
| 262 | ||
| 263 | /** | |
| 264 | * Windows has no process groups, so `process.kill` reaches only pi itself | |
| 265 | * and leaves extension subprocesses running with inherited stdio handles. | |
| 266 | * `taskkill /T` reaps the whole tree. | |
| 267 | */ | |
| 268 | const terminateWindowsTree = Effect.gen(function* () { | |
| 269 | if (hasExited()) return; | |
| 270 | const taskkill = yield* spawner.spawn( | |
| 271 | ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), | |
| 272 | ); | |
| 273 | yield* taskkill.exitCode; | |
| 274 | }).pipe(Effect.scoped, Effect.ignore); | |
| 275 | ||
| 276 | const terminateProcess = | |
| 277 | platform === "win32" ? terminateWindowsTree : terminatePiProcess(killProcessGroup, hasExited); | |
| 278 | ||
| 279 | // Registered before any further setup: an interrupt or failure between the | |
| 280 | // spawn and the rest of this constructor would otherwise leak a detached | |
| 281 | // pi process with no finalizer to reap it. | |
| 282 | yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); | |
| 283 | ||
| 284 | const pendingRequests = new Map<string, PendingPiRequest>(); | |
| 285 | const events = yield* Queue.unbounded<PiRpcRecord, PiRpcError>(); | |
| 286 | const outgoing = yield* Queue.unbounded<Uint8Array, PiRpcError>(); | |
| 287 | const transportDown = yield* Deferred.make<never, PiRpcError>(); | |
| 288 | const exitDeferred = yield* Deferred.make<number, PiRpcError>(); | |
| 289 | let nextRequestId = 0; | |
| 290 | ||
| 291 | const failTransport = (error: PiRpcError) => | |
| 292 | Effect.gen(function* () { | |
| 293 | const claimed = yield* Deferred.fail(transportDown, error); | |
| 294 | if (!claimed) return; | |
| 295 | for (const [key, pending] of pendingRequests) { | |
| 296 | pendingRequests.delete(key); | |
| 297 | yield* Deferred.fail(pending.deferred, error); | |
| 298 | } | |
| 299 | // Closing `outgoing` is what makes `send` non-racy: once the writer is | |
| 300 | // gone every later offer is refused rather than silently buffered. | |
| 301 | yield* Queue.fail(outgoing, error); | |
| 302 | yield* Queue.fail(events, error); | |
| 303 | }); | |
| 304 | ||
| 305 | const routeRecord = (record: PiRpcRecord) => | |
| 306 | Effect.gen(function* () { | |
| 307 | if (record["type"] === "response" && typeof record["id"] === "string") { | |
| 308 | const pending = pendingRequests.get(record["id"]); | |
| 309 | if (pending !== undefined) { | |
| 310 | pendingRequests.delete(record["id"]); | |
| 311 | if (record["success"] === true) { | |
| 312 | yield* Deferred.succeed(pending.deferred, record["data"]); | |
| 313 | } else { | |
| 314 | yield* Deferred.fail( | |
| 315 | pending.deferred, | |
| 316 | new PiRpcError({ | |
| 317 | operation: String(record["command"] ?? "request"), | |
| 318 | detail: summarizePiError(record["error"]), | |
| 319 | ...(record["error"] === undefined ? {} : { cause: record["error"] }), | |
| 320 | }), | |
| 321 | ); | |
| 322 | } | |
| 323 | return; | |
| 324 | } | |
| 325 | } | |
| 326 | yield* Queue.offer(events, record); | |
| 327 | }); | |
| 328 | ||
| 329 | // Watch exit before the reader so an immediate crash can populate | |
| 330 | // `exitDeferred` before stdout-close diagnosis runs. | |
| 331 | yield* child.exitCode.pipe( | |
| 332 | Effect.matchEffect({ | |
| 333 | onFailure: (cause) => | |
| 334 | Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), | |
| 335 | onSuccess: (code) => | |
| 336 | Effect.suspend(() => { | |
| 337 | childExited = true; | |
| 338 | return Deferred.succeed(exitDeferred, Number(code)); | |
| 339 | }), | |
| 340 | }), | |
| 341 | Effect.forkIn(scope), | |
| 342 | ); | |
| 343 | ||
| 344 | // Reader: decode stdout into LF-delimited JSON records. | |
| 345 | yield* Effect.gen(function* () { | |
| 346 | const frame = makeJsonlFramer(); | |
| 347 | yield* child.stdout.pipe( | |
| 348 | Stream.decodeText(), | |
| 349 | Stream.runForEach((chunk) => | |
| 350 | Effect.gen(function* () { | |
| 351 | const lines = frame(chunk); | |
| 352 | for (const line of lines) { | |
| 353 | const record = parsePiRecord(line); | |
| 354 | if (record === undefined) { | |
| 355 | yield* Effect.logDebug("Dropping non-JSON pi stdout line.", { | |
| 356 | lineLength: line.length, | |
| 357 | }); | |
| 358 | continue; | |
| 359 | } | |
| 360 | yield* routeRecord(record); | |
| 361 | } | |
| 362 | }), | |
| 363 | ), | |
| 364 | ); | |
| 365 | for (const line of frame("\n")) { | |
| 366 | const trailing = parsePiRecord(line); | |
| 367 | if (trailing !== undefined) yield* routeRecord(trailing); | |
| 368 | } | |
| 369 | }).pipe( | |
| 370 | Effect.matchCauseEffect({ | |
| 371 | onFailure: (cause) => failTransport(new PiRpcError({ operation: "read", cause })), | |
| 372 | onSuccess: () => | |
| 373 | Effect.gen(function* () { | |
| 374 | // Claim the stdout-close path before waiting so a broken stdin | |
| 375 | // writer cannot replace the exit diagnosis with a write error. | |
| 376 | diagnosingStdoutClose = true; | |
| 377 | const polled = yield* Deferred.poll(exitDeferred); | |
| 378 | const maybeExitCode = Option.isSome(polled) | |
| 379 | ? yield* polled.value.pipe( | |
| 380 | Effect.map(Option.some), | |
| 381 | Effect.orElseSucceed(() => Option.none<number>()), | |
| 382 | ) | |
| 383 | : yield* Deferred.await(exitDeferred).pipe( | |
| 384 | Effect.timeoutOption(Duration.millis(250)), | |
| 385 | // Adapter tests run under TestClock. A planned stdout close | |
| 386 | // without an exit, for example Stop-with-restart, must not wait | |
| 387 | // on that clock or the transport never fails. | |
| 388 | Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), | |
| 389 | Effect.orElseSucceed(() => Option.none<number>()), | |
| 390 | ); | |
| 391 | return yield* failTransport( | |
| 392 | new PiRpcError({ | |
| 393 | operation: "read", | |
| 394 | detail: describePiStdoutClosure( | |
| 395 | Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, | |
| 396 | ), | |
| 397 | }), | |
| 398 | ); | |
| 399 | }), | |
| 400 | }), | |
| 401 | Effect.forkIn(scope), | |
| 402 | ); | |
| 403 | ||
| 404 | // Surface stderr as debug logs; pi reserves stdout for the protocol. | |
| 405 | yield* child.stderr.pipe( | |
| 406 | Stream.decodeText(), | |
| 407 | Stream.runForEach((chunk) => | |
| 408 | chunk.trim().length === 0 | |
| 409 | ? Effect.void | |
| 410 | : // Length only: pi's stderr is unbounded remote output and can carry | |
| 411 | // credentials or prompt text, so it never enters a log annotation. | |
| 412 | Effect.logDebug("pi stderr", { stderrLength: chunk.length }), | |
| 413 | ), | |
| 414 | Effect.ignore, | |
| 415 | Effect.forkIn(scope), | |
| 416 | ); | |
| 417 | ||
| 418 | // Writer starts after the reader so an already-closed stdout can mark | |
| 419 | // diagnosis before a broken-pipe stdin claims the transport. | |
| 420 | yield* Stream.fromQueue(outgoing).pipe( | |
| 421 | Stream.run(child.stdin), | |
| 422 | Effect.catchCause((cause) => | |
| 423 | diagnosingStdoutClose | |
| 424 | ? Effect.void | |
| 425 | : failTransport(new PiRpcError({ operation: "write", cause })), | |
| 426 | ), | |
| 427 | Effect.forkIn(scope), | |
| 428 | ); | |
| 429 | ||
| 430 | const send = (record: PiRpcRecord): Effect.Effect<void, PiRpcError> => | |
| 431 | Effect.gen(function* () { | |
| 432 | const accepted = yield* Queue.offer( | |
| 433 | outgoing, | |
| 434 | new TextEncoder().encode(`${encodeJsonLine(record)}\n`), | |
| 435 | ); | |
| 436 | // A refused offer means `failTransport` already closed the queue, so the | |
| 437 | // write can never land; surface the transport error instead of | |
| 438 | // reporting a success the caller cannot rely on. | |
| 439 | if (!accepted) { | |
| 440 | return yield* Deferred.await(transportDown); | |
| 441 | } | |
| 442 | }); | |
| 443 | ||
| 444 | const request = ( | |
| 445 | record: PiRpcRecord, | |
| 446 | timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, | |
| 447 | ): Effect.Effect<unknown, PiRpcError | PiRpcTimeoutError> => | |
| 448 | Effect.gen(function* () { | |
| 449 | const id = `t3-${nextRequestId++}`; | |
| 450 | const deferred = yield* Deferred.make<unknown, PiRpcError>(); | |
| 451 | pendingRequests.set(id, { deferred }); | |
| 452 | yield* send({ ...record, id }).pipe( | |
| 453 | Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 454 | ); | |
| 455 | // Raced against the transport: a death that lands after this request was | |
| 456 | // registered (or between `send`'s check and its enqueue) would otherwise | |
| 457 | // leave the caller waiting out the full timeout for a reply that is | |
| 458 | // never coming. | |
| 459 | return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( | |
| 460 | Effect.timeoutOrElse({ | |
| 461 | duration: Duration.millis(timeoutMs), | |
| 462 | orElse: () => | |
| 463 | Effect.fail( | |
| 464 | new PiRpcTimeoutError({ | |
| 465 | operation: String(record["type"] ?? "request"), | |
| 466 | timeoutMs, | |
| 467 | }), | |
| 468 | ), | |
| 469 | }), | |
| 470 | Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 471 | Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 472 | ); | |
| 473 | }); | |
| 474 | ||
| 475 | return { | |
| 476 | send, | |
| 477 | request, | |
| 478 | events, | |
| 479 | exited: Deferred.await(exitDeferred), | |
| 480 | terminate: failTransport( | |
| 481 | new PiRpcError({ operation: "terminate", detail: "pi process was stopped" }), | |
| 482 | ).pipe(Effect.andThen(terminateProcess), Effect.ignore, Effect.uninterruptible), | |
| 483 | } satisfies PiRpcConnection; | |
| 484 | }); |
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts deleted-361| ... | ... | @@ -1,361 +0,0 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | import * as NodeVM from "node:vm"; | |
| 3 | import { assert, describe, it } from "@effect/vitest"; | |
| 4 | ||
| 5 | import { PI_T3_MCP_EXTENSION_SOURCE } from "./piT3McpExtensionSource.ts"; | |
| 6 | ||
| 7 | type RequestHook = ( | |
| 8 | event: { payload: unknown }, | |
| 9 | ctx: { model: { provider: string } }, | |
| 10 | ) => Record<string, unknown> | undefined; | |
| 11 | ||
| 12 | interface RegisteredTool { | |
| 13 | readonly name: string; | |
| 14 | readonly description: string; | |
| 15 | readonly parameters: unknown; | |
| 16 | readonly exposure?: string; | |
| 17 | readonly promptSnippet?: string; | |
| 18 | readonly promptGuidelines?: ReadonlyArray<string>; | |
| 19 | readonly execute: ( | |
| 20 | id: string, | |
| 21 | args: Record<string, unknown>, | |
| 22 | signal?: AbortSignal, | |
| 23 | ) => Promise<{ | |
| 24 | readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; | |
| 25 | }>; | |
| 26 | } | |
| 27 | ||
| 28 | type AgentStartHook = ( | |
| 29 | event: { systemPrompt: string }, | |
| 30 | ctx: { ui: { notify: (message: string, severity: string) => void } }, | |
| 31 | ) => Promise<{ systemPrompt: string }>; | |
| 32 | ||
| 33 | async function loadMcpBridge( | |
| 34 | options: { | |
| 35 | readonly modern?: boolean; | |
| 36 | readonly toolSearchAvailable?: boolean; | |
| 37 | readonly toolSearchDisabled?: boolean; | |
| 38 | readonly allowsTool?: (name: string) => boolean; | |
| 39 | } = {}, | |
| 40 | ) { | |
| 41 | const handlers = new Map<string, AgentStartHook>(); | |
| 42 | const tools: RegisteredTool[] = []; | |
| 43 | const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; | |
| 44 | let activeTools = ["read"]; | |
| 45 | const transports: Array<{ | |
| 46 | readonly url: string; | |
| 47 | readonly authorization: string; | |
| 48 | readonly signal: AbortSignal | undefined; | |
| 49 | }> = []; | |
| 50 | const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = []; | |
| 51 | const catalog = [ | |
| 52 | { name: "orchestrator_capabilities", description: "Discover available providers and models." }, | |
| 53 | { name: "delegate_task", description: "Delegate work to another agent." }, | |
| 54 | { name: "task_status", description: "Check delegated work." }, | |
| 55 | { name: "preview_snapshot", description: "Inspect the collaborative browser." }, | |
| 56 | ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); | |
| 57 | const source = NodeModule.stripTypeScriptTypes( | |
| 58 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 59 | "export default async function", | |
| 60 | "async function", | |
| 61 | ), | |
| 62 | ); | |
| 63 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 64 | process: { | |
| 65 | env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, | |
| 66 | }, | |
| 67 | AbortSignal, | |
| 68 | Type: { Unsafe: (schema: unknown) => schema }, | |
| 69 | fetch: async ( | |
| 70 | url: string, | |
| 71 | options: { body: string; headers: Record<string, string>; signal?: AbortSignal }, | |
| 72 | ) => { | |
| 73 | transports.push({ | |
| 74 | url, | |
| 75 | authorization: options.headers.authorization!, | |
| 76 | signal: options.signal, | |
| 77 | }); | |
| 78 | const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; | |
| 79 | requests.push(request); | |
| 80 | const result = | |
| 81 | request.method === "tools/list" | |
| 82 | ? { tools: catalog } | |
| 83 | : request.method === "tools/call" | |
| 84 | ? { content: [{ type: "text", text: "browser snapshot" }] } | |
| 85 | : {}; | |
| 86 | return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { | |
| 87 | headers: { "content-type": "application/json" }, | |
| 88 | }); | |
| 89 | }, | |
| 90 | pi: { | |
| 91 | on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), | |
| 92 | registerTool: (tool: RegisteredTool) => { | |
| 93 | if (options.allowsTool && !options.allowsTool(tool.name)) return; | |
| 94 | const index = tools.findIndex((current) => current.name === tool.name); | |
| 95 | if (index === -1) tools.push(tool); | |
| 96 | else tools[index] = tool; | |
| 97 | }, | |
| 98 | getActiveTools: () => activeTools, | |
| 99 | setActiveTools: (names: string[]) => { | |
| 100 | activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); | |
| 101 | }, | |
| 102 | getAllTools: () => | |
| 103 | options.toolSearchAvailable && | |
| 104 | !options.toolSearchDisabled && | |
| 105 | (options.allowsTool?.("tool_search") ?? true) | |
| 106 | ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] | |
| 107 | : [], | |
| 108 | ...(options.modern | |
| 109 | ? { | |
| 110 | registerMcpServer: (name: string, config: Record<string, unknown>) => | |
| 111 | servers.push({ name, config }), | |
| 112 | unregisterMcpServer: () => servers.splice(0), | |
| 113 | } | |
| 114 | : {}), | |
| 115 | }, | |
| 116 | }); | |
| 117 | return { | |
| 118 | handlers, | |
| 119 | tools, | |
| 120 | requests, | |
| 121 | servers, | |
| 122 | transports, | |
| 123 | getActiveTools: () => activeTools, | |
| 124 | restoreActiveTools: (names: string[]) => { | |
| 125 | activeTools = names; | |
| 126 | }, | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | describe("Pi MCP tool exposure", () => { | |
| 131 | it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { | |
| 132 | const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); | |
| 133 | await bridge.handlers.get("session_start")!( | |
| 134 | { systemPrompt: "" }, | |
| 135 | { ui: { notify: () => undefined } }, | |
| 136 | ); | |
| 137 | const start = bridge.handlers.get("before_agent_start"); | |
| 138 | assert.isDefined(start); | |
| 139 | const prompt = await start!( | |
| 140 | { systemPrompt: "Pi system prompt" }, | |
| 141 | { ui: { notify: () => undefined } }, | |
| 142 | ); | |
| 143 | assert.include(prompt.systemPrompt, "orchestrator_capabilities"); | |
| 144 | assert.equal(bridge.servers.length, 0); | |
| 145 | assert.equal(bridge.tools.length, 8); | |
| 146 | assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]); | |
| 147 | assert.deepEqual( | |
| 148 | bridge.tools | |
| 149 | .filter((tool) => tool.exposure !== "hidden") | |
| 150 | .map((tool) => [tool.name, tool.exposure]), | |
| 151 | [ | |
| 152 | ["mcp__t3-code__orchestrator_capabilities", "direct"], | |
| 153 | ["mcp__t3-code__delegate_task", "direct"], | |
| 154 | ["mcp__t3-code__task_status", "direct"], | |
| 155 | ["mcp__t3-code__preview_snapshot", "deferred"], | |
| 156 | ], | |
| 157 | ); | |
| 158 | assert.deepEqual( | |
| 159 | bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name), | |
| 160 | [ | |
| 161 | "mcp__t3_code__orchestrator_capabilities", | |
| 162 | "mcp__t3_code__delegate_task", | |
| 163 | "mcp__t3_code__task_status", | |
| 164 | "mcp__t3_code__preview_snapshot", | |
| 165 | ], | |
| 166 | ); | |
| 167 | const result = await bridge.tools | |
| 168 | .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! | |
| 169 | .execute("call-1", { depth: 2 }); | |
| 170 | assert.equal(result.content[0]?.text, "browser snapshot"); | |
| 171 | assert.equal(bridge.requests.at(-1)?.method, "tools/call"); | |
| 172 | }); | |
| 173 | ||
| 174 | it.each(["legacy Pi", "disabled tool search"])( | |
| 175 | "keeps tool execution available with %s", | |
| 176 | async (mode) => { | |
| 177 | const bridge = await loadMcpBridge({ | |
| 178 | modern: mode !== "legacy Pi", | |
| 179 | toolSearchAvailable: mode === "disabled tool search", | |
| 180 | toolSearchDisabled: mode === "disabled tool search", | |
| 181 | }); | |
| 182 | if (mode !== "legacy Pi") { | |
| 183 | const start = bridge.handlers.get("session_start"); | |
| 184 | await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } }); | |
| 185 | } | |
| 186 | assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); | |
| 187 | assert.isTrue( | |
| 188 | bridge.tools | |
| 189 | .filter((tool) => tool.exposure !== "hidden") | |
| 190 | .every((tool) => tool.exposure === undefined || tool.exposure === "direct"), | |
| 191 | ); | |
| 192 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); | |
| 193 | assert.isDefined(tool); | |
| 194 | const controller = new AbortController(); | |
| 195 | const result = await tool!.execute("call-1", { depth: 2 }, controller.signal); | |
| 196 | assert.equal(result.content[0]?.text, "browser snapshot"); | |
| 197 | assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal); | |
| 198 | assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp"); | |
| 199 | assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token"); | |
| 200 | assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), { | |
| 201 | jsonrpc: "2.0", | |
| 202 | id: 3, | |
| 203 | method: "tools/call", | |
| 204 | params: { name: "preview_snapshot", arguments: { depth: 2 } }, | |
| 205 | }); | |
| 206 | assert.isUndefined(tool?.promptSnippet); | |
| 207 | assert.isUndefined(tool?.promptGuidelines); | |
| 208 | }, | |
| 209 | ); | |
| 210 | ||
| 211 | it("preserves legacy wildcard tool selection", async () => { | |
| 212 | const bridge = await loadMcpBridge({ | |
| 213 | modern: true, | |
| 214 | toolSearchAvailable: true, | |
| 215 | allowsTool: (name) => | |
| 216 | name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"), | |
| 217 | }); | |
| 218 | await bridge.handlers.get("session_start")!( | |
| 219 | { systemPrompt: "" }, | |
| 220 | { ui: { notify: () => undefined } }, | |
| 221 | ); | |
| 222 | assert.equal(bridge.tools.length, 4); | |
| 223 | assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3); | |
| 224 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); | |
| 225 | assert.isDefined(tool); | |
| 226 | assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot"); | |
| 227 | }); | |
| 228 | ||
| 229 | it.each([false, true])( | |
| 230 | "reconciles tree loadouts while honoring search exclusion: %s", | |
| 231 | async (excludeSearch) => { | |
| 232 | const bridge = await loadMcpBridge({ | |
| 233 | modern: true, | |
| 234 | toolSearchAvailable: true, | |
| 235 | allowsTool: (name) => | |
| 236 | name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"), | |
| 237 | }); | |
| 238 | await bridge.handlers.get("session_start")!( | |
| 239 | { systemPrompt: "" }, | |
| 240 | { ui: { notify: () => undefined } }, | |
| 241 | ); | |
| 242 | bridge.restoreActiveTools([ | |
| 243 | "read", | |
| 244 | "mcp__t3-code__task_status", | |
| 245 | "mcp__t3-code__preview_snapshot", | |
| 246 | ]); | |
| 247 | const tree = bridge.handlers.get("session_tree"); | |
| 248 | assert.isDefined(tree); | |
| 249 | await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } }); | |
| 250 | assert.deepEqual(bridge.getActiveTools(), [ | |
| 251 | "read", | |
| 252 | "mcp__t3-code__task_status", | |
| 253 | "mcp__t3-code__preview_snapshot", | |
| 254 | ...(!excludeSearch ? ["tool_search"] : []), | |
| 255 | ]); | |
| 256 | assert.isFalse( | |
| 257 | bridge.tools.some( | |
| 258 | (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"), | |
| 259 | ), | |
| 260 | ); | |
| 261 | }, | |
| 262 | ); | |
| 263 | }); | |
| 264 | ||
| 265 | describe("Pi tool discovery permissions", () => { | |
| 266 | it("allows discovery without confirmation and still gates the discovered tool", async () => { | |
| 267 | type ToolCallHook = ( | |
| 268 | event: { toolName: string; input: unknown }, | |
| 269 | ctx: { ui: { confirm: (title: string, detail: string) => Promise<boolean> } }, | |
| 270 | ) => Promise<{ block: true; reason: string } | undefined>; | |
| 271 | let toolCall: ToolCallHook | undefined; | |
| 272 | let searchPath = "builtin:tool-search"; | |
| 273 | const source = NodeModule.stripTypeScriptTypes( | |
| 274 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 275 | "export default async function", | |
| 276 | "async function", | |
| 277 | ), | |
| 278 | ); | |
| 279 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 280 | process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } }, | |
| 281 | pi: { | |
| 282 | on: (name: string, handler: ToolCallHook) => { | |
| 283 | if (name === "tool_call") toolCall = handler; | |
| 284 | }, | |
| 285 | getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }], | |
| 286 | }, | |
| 287 | }); | |
| 288 | assert.isDefined(toolCall); | |
| 289 | const confirmations: string[] = []; | |
| 290 | const ctx = { | |
| 291 | ui: { | |
| 292 | confirm: async (title: string) => { | |
| 293 | confirmations.push(title); | |
| 294 | return false; | |
| 295 | }, | |
| 296 | }, | |
| 297 | }; | |
| 298 | assert.isUndefined( | |
| 299 | await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx), | |
| 300 | ); | |
| 301 | assert.equal(confirmations.length, 0); | |
| 302 | const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx); | |
| 303 | assert.equal(result?.block, true); | |
| 304 | assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]); | |
| 305 | ||
| 306 | // An extension that replaces the search builtin is not known to be read-only. | |
| 307 | searchPath = "/extensions/custom-search.ts"; | |
| 308 | const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx); | |
| 309 | assert.equal(replaced?.block, true); | |
| 310 | assert.equal(confirmations.at(-1), "Allow tool_search?"); | |
| 311 | }); | |
| 312 | }); | |
| 313 | ||
| 314 | async function loadRequestHook(): Promise<RequestHook> { | |
| 315 | const handlers = new Map<string, RequestHook>(); | |
| 316 | // Execute the shipped extension with MCP disabled; this path needs no Typebox. | |
| 317 | const source = NodeModule.stripTypeScriptTypes( | |
| 318 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 319 | "export default async function", | |
| 320 | "async function", | |
| 321 | ), | |
| 322 | ); | |
| 323 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 324 | process: { env: {} }, | |
| 325 | pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) }, | |
| 326 | }); | |
| 327 | const hook = handlers.get("before_provider_request"); | |
| 328 | assert.isDefined(hook); | |
| 329 | return hook!; | |
| 330 | } | |
| 331 | ||
| 332 | describe("Pi upstream output-budget workaround", () => { | |
| 333 | it.each(["max_tokens", "max_completion_tokens"])( | |
| 334 | "caps %s without changing the conversation or tools", | |
| 335 | async (key) => { | |
| 336 | const hook = await loadRequestHook(); | |
| 337 | const payload = { | |
| 338 | model: "moonshotai/kimi-k2.6", | |
| 339 | messages: [{ role: "user", content: "hello" }], | |
| 340 | tools: [{ type: "function", function: { name: "read" } }], | |
| 341 | [key]: 231_969, | |
| 342 | }; | |
| 343 | const result = hook({ payload }, { model: { provider: "openrouter" } }); | |
| 344 | assert.equal(result?.[key], 32_768); | |
| 345 | assert.strictEqual(result?.messages, payload.messages); | |
| 346 | assert.strictEqual(result?.tools, payload.tools); | |
| 347 | assert.equal(result?.model, payload.model); | |
| 348 | assert.equal(payload[key], 231_969); | |
| 349 | }, | |
| 350 | ); | |
| 351 | ||
| 352 | it("preserves smaller budgets and other providers' payloads", async () => { | |
| 353 | const hook = await loadRequestHook(); | |
| 354 | for (const payload of [{ max_tokens: 8192 }, { max_completion_tokens: 32_768 }, {}, null]) { | |
| 355 | assert.isUndefined(hook({ payload }, { model: { provider: "openrouter" } })); | |
| 356 | } | |
| 357 | assert.isUndefined( | |
| 358 | hook({ payload: { max_tokens: 231_969 } }, { model: { provider: "anthropic" } }), | |
| 359 | ); | |
| 360 | }); | |
| 361 | }); |
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts deleted-368| ... | ... | @@ -1,368 +0,0 @@ |
| 1 | /** | |
| 2 | * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. | |
| 3 | * | |
| 4 | * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions | |
| 5 | * keep them directly available. Pi loads this TypeScript via `--extension`; the | |
| 6 | * server writes it to a cache so packaged builds need no sibling .ts file. | |
| 7 | * | |
| 8 | * Do not import t3code modules from the string body. The Pi process resolves | |
| 9 | * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. | |
| 10 | */ | |
| 11 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 12 | ||
| 13 | export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; | |
| 14 | ||
| 15 | export const T3_MCP_URL_ENV = "T3_MCP_URL"; | |
| 16 | export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; | |
| 17 | export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; | |
| 18 | ||
| 19 | /** | |
| 20 | * Pi tools whose confirmations the bridge raises as file-change approvals. | |
| 21 | * Auto-accept edits skips them; the adapter keys the approval kind off them. | |
| 22 | */ | |
| 23 | export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; | |
| 24 | ||
| 25 | export const PI_T3_MCP_EXTENSION_SOURCE = `\ | |
| 26 | import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; | |
| 27 | import { Type } from "typebox"; | |
| 28 | ||
| 29 | const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; | |
| 30 | const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; | |
| 31 | const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; | |
| 32 | const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; | |
| 33 | const PROTOCOL = "2025-06-18"; | |
| 34 | const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); | |
| 35 | const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); | |
| 36 | ||
| 37 | type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; | |
| 38 | ||
| 39 | type JsonRpcResponse = { | |
| 40 | readonly id?: number | string; | |
| 41 | readonly result?: unknown; | |
| 42 | readonly error?: { readonly message?: string }; | |
| 43 | }; | |
| 44 | ||
| 45 | type McpTool = { | |
| 46 | readonly name: string; | |
| 47 | readonly description?: string; | |
| 48 | readonly inputSchema?: Record<string, unknown>; | |
| 49 | }; | |
| 50 | ||
| 51 | function env(name: string): string | undefined { | |
| 52 | const value = process.env[name]; | |
| 53 | return value && value.length > 0 ? value : undefined; | |
| 54 | } | |
| 55 | ||
| 56 | function runtimeMode(): RuntimeMode { | |
| 57 | const value = env(RUNTIME_MODE_ENV); | |
| 58 | return value === "approval-required" || | |
| 59 | value === "auto-accept-edits" || | |
| 60 | value === "auto" || | |
| 61 | value === "full-access" | |
| 62 | ? value | |
| 63 | : "full-access"; | |
| 64 | } | |
| 65 | ||
| 66 | function toolInputSummary(input: unknown): string { | |
| 67 | try { | |
| 68 | return JSON.stringify(input, null, 2).slice(0, 4_000); | |
| 69 | } catch { | |
| 70 | return String(input).slice(0, 4_000); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | function parseSseOrJson(body: string, contentType: string): JsonRpcResponse { | |
| 75 | if (contentType.includes("text/event-stream")) { | |
| 76 | for (const line of body.split("\\n")) { | |
| 77 | const trimmed = line.startsWith("data:") ? line.slice(5).trim() : ""; | |
| 78 | if (trimmed.length === 0) continue; | |
| 79 | const parsed = JSON.parse(trimmed) as JsonRpcResponse; | |
| 80 | if (parsed.id !== undefined || parsed.result !== undefined || parsed.error !== undefined) { | |
| 81 | return parsed; | |
| 82 | } | |
| 83 | } | |
| 84 | throw new Error("MCP SSE response had no JSON-RPC payload."); | |
| 85 | } | |
| 86 | return JSON.parse(body) as JsonRpcResponse; | |
| 87 | } | |
| 88 | ||
| 89 | function jsonSchemaToTypebox(schema: Record<string, unknown> | undefined) { | |
| 90 | const unsafe = (Type as { Unsafe?: (value: unknown) => unknown }).Unsafe; | |
| 91 | if (typeof unsafe === "function" && schema !== undefined) { | |
| 92 | return unsafe(schema); | |
| 93 | } | |
| 94 | return Type.Object({}, { additionalProperties: true }); | |
| 95 | } | |
| 96 | ||
| 97 | function formatMcpContent(result: unknown): string { | |
| 98 | if (result === null || result === undefined) return ""; | |
| 99 | if (typeof result !== "object") return String(result); | |
| 100 | const record = result as { | |
| 101 | readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; | |
| 102 | readonly structuredContent?: unknown; | |
| 103 | readonly isError?: boolean; | |
| 104 | }; | |
| 105 | const texts: string[] = []; | |
| 106 | if (Array.isArray(record.content)) { | |
| 107 | for (const part of record.content) { | |
| 108 | if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); | |
| 109 | } | |
| 110 | } | |
| 111 | // Most T3 tools mirror structuredContent in a text block. Repeating it would | |
| 112 | // leave T3's own output parsing two JSON documents instead of one. | |
| 113 | if (record.structuredContent !== undefined) { | |
| 114 | const structured = JSON.stringify(record.structuredContent); | |
| 115 | if (!texts.includes(structured)) texts.push(structured); | |
| 116 | } | |
| 117 | if (texts.length > 0) return texts.join("\\n"); | |
| 118 | return JSON.stringify(result); | |
| 119 | } | |
| 120 | ||
| 121 | function isMcpToolError(result: unknown): boolean { | |
| 122 | return ( | |
| 123 | typeof result === "object" && | |
| 124 | result !== null && | |
| 125 | "isError" in result && | |
| 126 | result.isError === true | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | function createMcpClient(endpoint: string, token: string) { | |
| 131 | let nextId = 1; | |
| 132 | let sessionId: string | undefined; | |
| 133 | ||
| 134 | const headers = (): Record<string, string> => { | |
| 135 | const next: Record<string, string> = { | |
| 136 | accept: "application/json, text/event-stream", | |
| 137 | authorization: token.startsWith("Bearer ") ? token : \`Bearer \${token}\`, | |
| 138 | "content-type": "application/json", | |
| 139 | // Effect's HTTP MCP rejects post-initialize requests without this | |
| 140 | // (400). The worktree client in McpHttpServer tests sends the same | |
| 141 | // header; initialize itself does not require it. | |
| 142 | "mcp-protocol-version": PROTOCOL, | |
| 143 | }; | |
| 144 | if (sessionId !== undefined) next["mcp-session-id"] = sessionId; | |
| 145 | return next; | |
| 146 | }; | |
| 147 | ||
| 148 | const request = async (method: string, params?: unknown, signal?: AbortSignal) => { | |
| 149 | const id = nextId++; | |
| 150 | const response = await fetch(endpoint, { | |
| 151 | method: "POST", | |
| 152 | headers: headers(), | |
| 153 | body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), | |
| 154 | signal, | |
| 155 | }); | |
| 156 | const nextSession = response.headers.get("mcp-session-id"); | |
| 157 | if (nextSession) sessionId = nextSession; | |
| 158 | const body = await response.text(); | |
| 159 | if (!response.ok) { | |
| 160 | throw new Error(\`MCP \${method} failed (\${response.status}): \${body.slice(0, 400)}\`); | |
| 161 | } | |
| 162 | if (body.length === 0) return undefined; | |
| 163 | const parsed = parseSseOrJson(body, response.headers.get("content-type") ?? ""); | |
| 164 | if (parsed.error) { | |
| 165 | throw new Error(parsed.error.message ?? \`MCP \${method} returned an error\`); | |
| 166 | } | |
| 167 | return parsed.result; | |
| 168 | }; | |
| 169 | ||
| 170 | const notify = async (method: string, params?: unknown, signal?: AbortSignal) => { | |
| 171 | await fetch(endpoint, { | |
| 172 | method: "POST", | |
| 173 | headers: headers(), | |
| 174 | body: JSON.stringify({ jsonrpc: "2.0", method, params }), | |
| 175 | signal, | |
| 176 | }); | |
| 177 | }; | |
| 178 | ||
| 179 | return { | |
| 180 | async connect(signal?: AbortSignal) { | |
| 181 | await request( | |
| 182 | "initialize", | |
| 183 | { | |
| 184 | protocolVersion: PROTOCOL, | |
| 185 | capabilities: {}, | |
| 186 | clientInfo: { name: "t3-pi-mcp", version: "1.0.0" }, | |
| 187 | }, | |
| 188 | signal, | |
| 189 | ); | |
| 190 | await notify("notifications/initialized", {}, signal).catch(() => undefined); | |
| 191 | }, | |
| 192 | async listTools(signal?: AbortSignal) { | |
| 193 | const tools: McpTool[] = []; | |
| 194 | let cursor: string | undefined; | |
| 195 | do { | |
| 196 | const result = (await request( | |
| 197 | "tools/list", | |
| 198 | cursor === undefined ? {} : { cursor }, | |
| 199 | signal, | |
| 200 | )) as { tools?: McpTool[]; nextCursor?: string } | undefined; | |
| 201 | tools.push(...(result?.tools ?? [])); | |
| 202 | cursor = result?.nextCursor; | |
| 203 | } while (cursor); | |
| 204 | return tools; | |
| 205 | }, | |
| 206 | async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal) { | |
| 207 | return request("tools/call", { name, arguments: args }, signal); | |
| 208 | }, | |
| 209 | }; | |
| 210 | } | |
| 211 | ||
| 212 | export default async function t3McpExtension(pi: ExtensionAPI) { | |
| 213 | // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the | |
| 214 | // previous response's usage even when a fork's instructions/tools differ, | |
| 215 | // then reserves almost all remaining context for output. OpenRouter can | |
| 216 | // reject even a short conversation. Remove this cap when Pi accounts for | |
| 217 | // the current request prefix reliably (api/simple-options + utils/estimate). | |
| 218 | pi.on("before_provider_request", (event, ctx) => { | |
| 219 | if (ctx.model?.provider !== "openrouter") return; | |
| 220 | const payload = event.payload; | |
| 221 | if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return; | |
| 222 | const replacement = { ...payload } as Record<string, unknown>; | |
| 223 | let changed = false; | |
| 224 | for (const key of ["max_tokens", "max_completion_tokens"]) { | |
| 225 | const limit = replacement[key]; | |
| 226 | if (typeof limit === "number" && Number.isFinite(limit) && limit > 32_768) { | |
| 227 | replacement[key] = 32_768; | |
| 228 | changed = true; | |
| 229 | } | |
| 230 | } | |
| 231 | if (changed) return replacement; | |
| 232 | }); | |
| 233 | ||
| 234 | // Pi deliberately leaves permission policy to extensions. T3's injected | |
| 235 | // bridge uses Pi's public blocking tool hook so the shared runtime modes | |
| 236 | // keep their normal meaning without replacing or shadowing Pi's runtime. | |
| 237 | // Only Pi's own search is known to be read-only. An extension that replaces | |
| 238 | // it keeps the name, and cannot discover deferred tools either. | |
| 239 | const hasBuiltinToolSearch = () => | |
| 240 | typeof pi.getAllTools === "function" && | |
| 241 | pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search"); | |
| 242 | ||
| 243 | pi.on("tool_call", async (event, ctx) => { | |
| 244 | const mode = runtimeMode(); | |
| 245 | if (mode === "full-access") return; | |
| 246 | if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) { | |
| 247 | return; | |
| 248 | } | |
| 249 | if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) { | |
| 250 | return; | |
| 251 | } | |
| 252 | const approved = await ctx.ui.confirm( | |
| 253 | \`Allow \${event.toolName}?\`, | |
| 254 | toolInputSummary(event.input), | |
| 255 | ); | |
| 256 | if (!approved) { | |
| 257 | return { block: true, reason: \`\${event.toolName} was declined in T3 Code.\` }; | |
| 258 | } | |
| 259 | }); | |
| 260 | ||
| 261 | const endpoint = env(URL_ENV); | |
| 262 | const token = env(TOKEN_ENV); | |
| 263 | if (endpoint === undefined || token === undefined) { | |
| 264 | pi.on("session_start", async (_event, ctx) => { | |
| 265 | ctx.ui.notify( | |
| 266 | "t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.", | |
| 267 | "warning", | |
| 268 | ); | |
| 269 | }); | |
| 270 | return; | |
| 271 | } | |
| 272 | ||
| 273 | // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP | |
| 274 | // bridge as the credential owner: mcp.json overrides native registrations. | |
| 275 | const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function"; | |
| 276 | const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]); | |
| 277 | let deferOptionalTools = supportsExposure; | |
| 278 | let catalog: ReadonlyArray<McpTool> = []; | |
| 279 | ||
| 280 | const client = createMcpClient(endpoint, token); | |
| 281 | let started: Promise<void> | undefined; | |
| 282 | ||
| 283 | const registerTools = () => { | |
| 284 | // Preserve public names for saved loadouts and tool selectors. Hidden | |
| 285 | // canonical names reserve ownership against Pi's configured MCP servers. | |
| 286 | const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"]; | |
| 287 | for (const tool of catalog) { | |
| 288 | const name = tool.name; | |
| 289 | for (const prefix of prefixes) { | |
| 290 | const exposure = prefix === "mcp__t3_code__" ? "hidden" : | |
| 291 | deferOptionalTools && !directTools.has(name) ? "deferred" : "direct"; | |
| 292 | pi.registerTool({ | |
| 293 | name: \`\${prefix}\${name}\`, | |
| 294 | label: name, | |
| 295 | description: tool.description ?? name, | |
| 296 | parameters: jsonSchemaToTypebox(tool.inputSchema), | |
| 297 | ...(supportsExposure ? { exposure } : {}), | |
| 298 | async execute(_toolCallId, params, signal) { | |
| 299 | const result = await client.callTool( | |
| 300 | name, | |
| 301 | (params ?? {}) as Record<string, unknown>, | |
| 302 | signal, | |
| 303 | ); | |
| 304 | const text = formatMcpContent(result); | |
| 305 | return { | |
| 306 | content: [{ type: "text", text }], | |
| 307 | details: { server: "t3-code", tool: name }, | |
| 308 | ...(isMcpToolError(result) ? { isError: true } : {}), | |
| 309 | }; | |
| 310 | }, | |
| 311 | }); | |
| 312 | } | |
| 313 | } | |
| 314 | }; | |
| 315 | ||
| 316 | const ensureStarted = () => { | |
| 317 | if (started !== undefined) return started; | |
| 318 | const attempt = (async () => { | |
| 319 | const signal = AbortSignal.timeout(10_000); | |
| 320 | await client.connect(signal); | |
| 321 | catalog = await client.listTools(signal); | |
| 322 | registerTools(); | |
| 323 | })(); | |
| 324 | started = attempt; | |
| 325 | void attempt.catch(() => { | |
| 326 | if (started === attempt) started = undefined; | |
| 327 | }); | |
| 328 | return attempt; | |
| 329 | }; | |
| 330 | ||
| 331 | // CLI extensions load before builtins, and Pi keeps the first registration | |
| 332 | // of a tool name. Register now so the bridge owns the T3 namespace even when | |
| 333 | // mcp.json configures it; retry a failed connection at session_start. | |
| 334 | await ensureStarted().catch(() => undefined); | |
| 335 | ||
| 336 | const reconcileDiscovery = () => { | |
| 337 | if (!supportsExposure) return; | |
| 338 | // A disabled or replaced search builtin cannot discover deferred tools. | |
| 339 | const hasToolSearch = hasBuiltinToolSearch(); | |
| 340 | const exposureChanged = deferOptionalTools !== hasToolSearch; | |
| 341 | deferOptionalTools = hasToolSearch; | |
| 342 | if (exposureChanged) registerTools(); | |
| 343 | if (hasToolSearch) { | |
| 344 | const active = pi.getActiveTools(); | |
| 345 | if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]); | |
| 346 | } | |
| 347 | }; | |
| 348 | ||
| 349 | // Tree navigation restores its saved loadout after session_start. | |
| 350 | pi.on("session_tree", reconcileDiscovery); | |
| 351 | pi.on("session_start", async (_event, ctx) => { | |
| 352 | reconcileDiscovery(); | |
| 353 | try { | |
| 354 | await ensureStarted(); | |
| 355 | } catch (error) { | |
| 356 | const message = error instanceof Error ? error.message : String(error); | |
| 357 | ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); | |
| 358 | } | |
| 359 | }); | |
| 360 | ||
| 361 | // Deliver orchestration guidance through pi's real system-prompt channel. | |
| 362 | // Wrapping the first user message instead would stop it from starting | |
| 363 | // with "/" and silently break slash-command expansion. | |
| 364 | pi.on("before_agent_start", (event) => ({ | |
| 365 | systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS, | |
| 366 | })); | |
| 367 | } | |
| 368 | `; |
apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts deleted-168| ... | ... | @@ -1,168 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | ||
| 7 | import { | |
| 8 | PI_T3_MCP_EXTENSION_FILENAME, | |
| 9 | T3_MCP_BEARER_ENV, | |
| 10 | T3_MCP_URL_ENV, | |
| 11 | T3_PI_RUNTIME_MODE_ENV, | |
| 12 | } from "./piT3McpExtensionSource.ts"; | |
| 13 | import { | |
| 14 | buildPiRpcLaunch, | |
| 15 | materializePiT3McpExtension, | |
| 16 | resolvePiLaunchArgs, | |
| 17 | } from "./piT3McpInjection.ts"; | |
| 18 | ||
| 19 | const threadId = ThreadId.make("thread-pi-t3-mcp"); | |
| 20 | ||
| 21 | const mcpSession = { | |
| 22 | environmentId: EnvironmentId.make("environment-pi-t3-mcp"), | |
| 23 | threadId, | |
| 24 | providerSessionId: "mcp-session-pi", | |
| 25 | providerInstanceId: ProviderInstanceId.make("pi"), | |
| 26 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 27 | authorizationHeader: "Bearer secret-pi-token", | |
| 28 | browserToolsAvailable: true, | |
| 29 | }; | |
| 30 | ||
| 31 | describe("pi T3 MCP injection", () => { | |
| 32 | it("always adds the permission bridge and configures MCP when available", () => { | |
| 33 | const resolvedArgs = resolvePiLaunchArgs( | |
| 34 | "--extension=/home/user/.pi/agent/extensions/demo.ts --session-dir=/tmp/pi-sessions --provider=anthropic --model=claude-sonnet --tools='' --name=-review --extension-flag=kept", | |
| 35 | ); | |
| 36 | assert.isTrue(resolvedArgs.ok); | |
| 37 | if (!resolvedArgs.ok) return; | |
| 38 | const launch = buildPiRpcLaunch({ | |
| 39 | launchArgs: resolvedArgs.args, | |
| 40 | environment: { PATH: "/usr/bin" }, | |
| 41 | mcpSession, | |
| 42 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 43 | runtimeMode: "approval-required", | |
| 44 | }); | |
| 45 | assert.deepEqual(launch.args, [ | |
| 46 | "--mode", | |
| 47 | "rpc", | |
| 48 | "--extension", | |
| 49 | "/home/user/.pi/agent/extensions/demo.ts", | |
| 50 | "--session-dir", | |
| 51 | "/tmp/pi-sessions", | |
| 52 | "--provider", | |
| 53 | "anthropic", | |
| 54 | "--model", | |
| 55 | "claude-sonnet", | |
| 56 | "--tools", | |
| 57 | "", | |
| 58 | "--name", | |
| 59 | "-review", | |
| 60 | "--extension-flag=kept", | |
| 61 | "--extension", | |
| 62 | "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 63 | ]); | |
| 64 | assert.notInclude(launch.args, "--no-extensions"); | |
| 65 | assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); | |
| 66 | assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); | |
| 67 | assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); | |
| 68 | ||
| 69 | const permissionOnly = buildPiRpcLaunch({ | |
| 70 | launchArgs: [], | |
| 71 | environment: { | |
| 72 | [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale", | |
| 73 | [T3_MCP_BEARER_ENV]: "stale-token", | |
| 74 | }, | |
| 75 | mcpSession: undefined, | |
| 76 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 77 | runtimeMode: "auto-accept-edits", | |
| 78 | }); | |
| 79 | assert.deepEqual(permissionOnly.args, [ | |
| 80 | "--mode", | |
| 81 | "rpc", | |
| 82 | "--extension", | |
| 83 | "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 84 | ]); | |
| 85 | assert.isFalse(permissionOnly.hasT3Mcp); | |
| 86 | assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]); | |
| 87 | assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]); | |
| 88 | assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits"); | |
| 89 | }); | |
| 90 | ||
| 91 | it("falls back to Pi's first supported mode for legacy auto threads", () => { | |
| 92 | const launch = buildPiRpcLaunch({ | |
| 93 | launchArgs: [], | |
| 94 | environment: {}, | |
| 95 | mcpSession: undefined, | |
| 96 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 97 | runtimeMode: "auto", | |
| 98 | }); | |
| 99 | ||
| 100 | assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); | |
| 101 | }); | |
| 102 | ||
| 103 | it("forces tools and user extensions off for unattended text generation", () => { | |
| 104 | const launch = buildPiRpcLaunch({ | |
| 105 | launchArgs: [ | |
| 106 | "--tools", | |
| 107 | "read,write", | |
| 108 | "--extension", | |
| 109 | "/home/user/.pi/agent/extensions/demo.ts", | |
| 110 | "--extension=./second.ts", | |
| 111 | "--provider", | |
| 112 | "anthropic", | |
| 113 | ], | |
| 114 | environment: {}, | |
| 115 | mcpSession, | |
| 116 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 117 | ephemeral: true, | |
| 118 | disableExtensions: true, | |
| 119 | disableTools: true, | |
| 120 | }); | |
| 121 | assert.deepEqual(launch.args, [ | |
| 122 | "--mode", | |
| 123 | "rpc", | |
| 124 | "--no-session", | |
| 125 | "--provider", | |
| 126 | "anthropic", | |
| 127 | "--no-extensions", | |
| 128 | "--no-tools", | |
| 129 | ]); | |
| 130 | assert.isFalse(launch.hasT3Mcp); | |
| 131 | assert.deepInclude(resolvePiLaunchArgs("--mode text"), { | |
| 132 | ok: false, | |
| 133 | message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.", | |
| 134 | }); | |
| 135 | assert.deepInclude(resolvePiLaunchArgs("--session old.jsonl"), { ok: false }); | |
| 136 | assert.deepInclude(resolvePiLaunchArgs("prompt pi immediately"), { ok: false }); | |
| 137 | assert.deepInclude(resolvePiLaunchArgs("--plan @instructions.md"), { ok: false }); | |
| 138 | }); | |
| 139 | ||
| 140 | it("rejects --provider without --model, which Pi 1.0 refuses at startup", () => { | |
| 141 | const rejection = { | |
| 142 | ok: false, | |
| 143 | message: "Pi launch argument '--provider' requires '--model'.", | |
| 144 | }; | |
| 145 | assert.deepInclude(resolvePiLaunchArgs("--provider openrouter"), rejection); | |
| 146 | assert.deepInclude(resolvePiLaunchArgs("--provider=openrouter --models gpt-6"), rejection); | |
| 147 | assert.isTrue(resolvePiLaunchArgs("--provider openrouter --model=deepseek/v4").ok); | |
| 148 | assert.isTrue(resolvePiLaunchArgs("--model deepseek/v4").ok); | |
| 149 | }); | |
| 150 | ||
| 151 | it.effect("materializes the MCP bridge with namespaced tool registration", () => | |
| 152 | Effect.gen(function* () { | |
| 153 | const fs = yield* FileSystem.FileSystem; | |
| 154 | const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-extensions-" }); | |
| 155 | const mcpDest = yield* materializePiT3McpExtension(cacheDir); | |
| 156 | assert.isTrue(mcpDest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); | |
| 157 | const mcpSource = yield* fs.readFileString(mcpDest); | |
| 158 | assert.include(mcpSource, "export default async function t3McpExtension"); | |
| 159 | assert.include(mcpSource, "before_agent_start"); | |
| 160 | assert.include(mcpSource, 'pi.on("tool_call"'); | |
| 161 | assert.include(mcpSource, "Allow ${event.toolName}?"); | |
| 162 | assert.include(mcpSource, '"mcp-protocol-version"'); | |
| 163 | assert.include(mcpSource, '"tools/call"'); | |
| 164 | assert.include(mcpSource, "mcp__t3-code__"); | |
| 165 | assert.include(mcpSource, "mcp__t3_code__"); | |
| 166 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 167 | ); | |
| 168 | }); |
apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts deleted-316| ... | ... | @@ -1,316 +0,0 @@ |
| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | ||
| 5 | import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; | |
| 6 | import { | |
| 7 | PI_T3_MCP_EXTENSION_FILENAME, | |
| 8 | PI_T3_MCP_EXTENSION_SOURCE, | |
| 9 | T3_MCP_BEARER_ENV, | |
| 10 | T3_MCP_URL_ENV, | |
| 11 | T3_PI_RUNTIME_MODE_ENV, | |
| 12 | } from "./piT3McpExtensionSource.ts"; | |
| 13 | ||
| 14 | const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ | |
| 15 | "--continue", | |
| 16 | "-c", | |
| 17 | "--export", | |
| 18 | "--fork", | |
| 19 | "--help", | |
| 20 | "-h", | |
| 21 | "--list-models", | |
| 22 | "--mode", | |
| 23 | "--no-session", | |
| 24 | "--print", | |
| 25 | "-p", | |
| 26 | "--resume", | |
| 27 | "-r", | |
| 28 | "--session", | |
| 29 | "--session-id", | |
| 30 | "--version", | |
| 31 | "-v", | |
| 32 | ]); | |
| 33 | ||
| 34 | const PI_ARGUMENTS_WITH_VALUES = new Set([ | |
| 35 | "--api-key", | |
| 36 | "--append-system-prompt", | |
| 37 | "--exclude-tools", | |
| 38 | "-xt", | |
| 39 | "--extension", | |
| 40 | "-e", | |
| 41 | "--model", | |
| 42 | "--models", | |
| 43 | "--name", | |
| 44 | "-n", | |
| 45 | "--prompt-template", | |
| 46 | "--provider", | |
| 47 | "--session-dir", | |
| 48 | "--skill", | |
| 49 | "--system-prompt", | |
| 50 | "--theme", | |
| 51 | "--thinking", | |
| 52 | "--tools", | |
| 53 | "-t", | |
| 54 | "--tui-mode", | |
| 55 | "--use-theme", | |
| 56 | ]); | |
| 57 | ||
| 58 | const PI_ARGUMENTS_WITHOUT_VALUES = new Set([ | |
| 59 | "--approve", | |
| 60 | "-a", | |
| 61 | "--no-approve", | |
| 62 | "-na", | |
| 63 | "--no-builtin-tools", | |
| 64 | "-nbt", | |
| 65 | "--no-context-files", | |
| 66 | "-nc", | |
| 67 | "--no-extensions", | |
| 68 | "-ne", | |
| 69 | "--no-prompt-templates", | |
| 70 | "-np", | |
| 71 | "--no-skills", | |
| 72 | "-ns", | |
| 73 | "--no-themes", | |
| 74 | "--no-tools", | |
| 75 | "-nt", | |
| 76 | "--offline", | |
| 77 | "--verbose", | |
| 78 | ]); | |
| 79 | ||
| 80 | export type PiLaunchArgsResolution = | |
| 81 | | { readonly ok: true; readonly args: ReadonlyArray<string> } | |
| 82 | | { readonly ok: false; readonly message: string }; | |
| 83 | ||
| 84 | function reservedPiArgument(arg: string): string | undefined { | |
| 85 | for (const reserved of RESERVED_PI_LAUNCH_ARGUMENTS) { | |
| 86 | if (arg === reserved || (reserved.startsWith("--") && arg.startsWith(`${reserved}=`))) { | |
| 87 | return reserved; | |
| 88 | } | |
| 89 | } | |
| 90 | return undefined; | |
| 91 | } | |
| 92 | ||
| 93 | function normalizePiBuiltInEqualsArguments(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 94 | return args.flatMap((arg) => { | |
| 95 | const equalsIndex = arg.indexOf("="); | |
| 96 | if (equalsIndex <= 0) return [arg]; | |
| 97 | const option = arg.slice(0, equalsIndex); | |
| 98 | return PI_ARGUMENTS_WITH_VALUES.has(option) ? [option, arg.slice(equalsIndex + 1)] : [arg]; | |
| 99 | }); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Pi launch arguments may configure resources, models, tools, trust, and | |
| 104 | * storage. T3 owns RPC mode and session identity, so arguments that select a | |
| 105 | * different execution mode or native session are rejected before spawn. | |
| 106 | */ | |
| 107 | export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution { | |
| 108 | // Pi parses equals-form tokens only as extension flags, even when their name | |
| 109 | // matches a built-in option. Split known built-ins while leaving arbitrary | |
| 110 | // extension flags in their native form. | |
| 111 | const args = normalizePiBuiltInEqualsArguments(tokenizeCliArgs(launchArgs)); | |
| 112 | let hasProvider = false; | |
| 113 | let hasModel = false; | |
| 114 | for (let index = 0; index < args.length; index += 1) { | |
| 115 | const arg = args[index]; | |
| 116 | if (arg === undefined) continue; | |
| 117 | const reserved = reservedPiArgument(arg); | |
| 118 | if (reserved !== undefined) { | |
| 119 | return { | |
| 120 | ok: false, | |
| 121 | message: `Pi launch argument '${reserved}' is controlled by T3 Code and cannot be overridden.`, | |
| 122 | }; | |
| 123 | } | |
| 124 | if (arg === "--") { | |
| 125 | return { | |
| 126 | ok: false, | |
| 127 | message: "Pi launch arguments cannot include positional prompts.", | |
| 128 | }; | |
| 129 | } | |
| 130 | if (PI_ARGUMENTS_WITH_VALUES.has(arg)) { | |
| 131 | const value = args[index + 1]; | |
| 132 | if (value === undefined) { | |
| 133 | return { ok: false, message: `Pi launch argument '${arg}' requires a value.` }; | |
| 134 | } | |
| 135 | if (arg === "--provider") hasProvider = true; | |
| 136 | if (arg === "--model") hasModel = true; | |
| 137 | index += 1; | |
| 138 | continue; | |
| 139 | } | |
| 140 | if (PI_ARGUMENTS_WITHOUT_VALUES.has(arg) || (arg.startsWith("--") && arg.includes("="))) { | |
| 141 | continue; | |
| 142 | } | |
| 143 | if (arg.startsWith("--")) { | |
| 144 | // Pi extensions may register arbitrary long flags. Treat one following | |
| 145 | // non-flag token as that extension flag's value. | |
| 146 | if ( | |
| 147 | args[index + 1] !== undefined && | |
| 148 | !args[index + 1]!.startsWith("-") && | |
| 149 | !args[index + 1]!.startsWith("@") | |
| 150 | ) { | |
| 151 | index += 1; | |
| 152 | } | |
| 153 | continue; | |
| 154 | } | |
| 155 | if (arg.startsWith("-")) { | |
| 156 | return { ok: false, message: `Pi launch argument '${arg}' is not supported by T3 Code.` }; | |
| 157 | } | |
| 158 | return { | |
| 159 | ok: false, | |
| 160 | message: `Pi launch arguments cannot include positional prompt '${arg}'.`, | |
| 161 | }; | |
| 162 | } | |
| 163 | // Pi 1.0 exits at startup on `--provider` without `--model`; older versions | |
| 164 | // silently ran another provider's default model. Rejecting it here makes | |
| 165 | // every spawn site report the reason instead of a bare exit code. | |
| 166 | if (hasProvider && !hasModel) { | |
| 167 | return { ok: false, message: "Pi launch argument '--provider' requires '--model'." }; | |
| 168 | } | |
| 169 | return { ok: true, args }; | |
| 170 | } | |
| 171 | ||
| 172 | function bearerTokenFromAuthorizationHeader(header: string): string { | |
| 173 | return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; | |
| 174 | } | |
| 175 | ||
| 176 | function normalizedPiPath(value: string): string { | |
| 177 | return value.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 178 | } | |
| 179 | ||
| 180 | function hasExplicitExtension(args: ReadonlyArray<string>, extensionPath: string): boolean { | |
| 181 | const wanted = normalizedPiPath(extensionPath); | |
| 182 | for (let index = 0; index < args.length; index += 1) { | |
| 183 | const arg = args[index]; | |
| 184 | if (arg !== "--extension" && arg !== "-e") continue; | |
| 185 | const configured = args[index + 1]; | |
| 186 | if (configured !== undefined && normalizedPiPath(configured) === wanted) return true; | |
| 187 | index += 1; | |
| 188 | } | |
| 189 | return false; | |
| 190 | } | |
| 191 | ||
| 192 | function withoutExplicitExtensions(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 193 | const filtered: string[] = []; | |
| 194 | for (let index = 0; index < args.length; index += 1) { | |
| 195 | const arg = args[index]; | |
| 196 | if (arg === undefined) continue; | |
| 197 | if (arg === "--extension" || arg === "-e") { | |
| 198 | index += 1; | |
| 199 | continue; | |
| 200 | } | |
| 201 | if (arg.startsWith("--extension=") || arg.startsWith("-e=")) continue; | |
| 202 | filtered.push(arg); | |
| 203 | } | |
| 204 | return filtered; | |
| 205 | } | |
| 206 | ||
| 207 | function withoutToolSelectionArgs(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 208 | const filtered: string[] = []; | |
| 209 | for (let index = 0; index < args.length; index += 1) { | |
| 210 | const arg = args[index]; | |
| 211 | if (arg === undefined) continue; | |
| 212 | if (arg === "--tools" || arg === "-t" || arg === "--exclude-tools" || arg === "-xt") { | |
| 213 | index += 1; | |
| 214 | continue; | |
| 215 | } | |
| 216 | if ( | |
| 217 | arg === "--no-tools" || | |
| 218 | arg === "-nt" || | |
| 219 | arg === "--no-builtin-tools" || | |
| 220 | arg === "-nbt" || | |
| 221 | arg.startsWith("--tools=") || | |
| 222 | arg.startsWith("-t=") || | |
| 223 | arg.startsWith("--exclude-tools=") || | |
| 224 | arg.startsWith("-xt=") | |
| 225 | ) { | |
| 226 | continue; | |
| 227 | } | |
| 228 | filtered.push(arg); | |
| 229 | } | |
| 230 | return filtered; | |
| 231 | } | |
| 232 | ||
| 233 | function piT3McpExtensionDestPath(cacheDir: string): string { | |
| 234 | return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; | |
| 235 | } | |
| 236 | ||
| 237 | export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( | |
| 238 | cacheDir: string, | |
| 239 | ) { | |
| 240 | const fs = yield* FileSystem.FileSystem; | |
| 241 | yield* fs.makeDirectory(cacheDir, { recursive: true }); | |
| 242 | const dest = piT3McpExtensionDestPath(cacheDir); | |
| 243 | const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); | |
| 244 | if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { | |
| 245 | yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); | |
| 246 | } | |
| 247 | return dest; | |
| 248 | }); | |
| 249 | ||
| 250 | export function buildPiRpcLaunch(input: { | |
| 251 | readonly launchArgs: ReadonlyArray<string>; | |
| 252 | readonly environment: NodeJS.ProcessEnv; | |
| 253 | readonly mcpSession: McpProviderSessionConfig | undefined; | |
| 254 | readonly extensionPath: string | undefined; | |
| 255 | readonly ephemeral?: boolean; | |
| 256 | readonly disableExtensions?: boolean; | |
| 257 | readonly disableTools?: boolean; | |
| 258 | readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; | |
| 259 | }): { | |
| 260 | readonly args: ReadonlyArray<string>; | |
| 261 | readonly env: NodeJS.ProcessEnv; | |
| 262 | readonly hasT3Mcp: boolean; | |
| 263 | } { | |
| 264 | const hasT3Extension = input.disableExtensions !== true && input.extensionPath !== undefined; | |
| 265 | const hasT3Mcp = hasT3Extension && input.mcpSession !== undefined; | |
| 266 | const extensionSafeArgs = | |
| 267 | input.disableExtensions === true | |
| 268 | ? withoutExplicitExtensions(input.launchArgs) | |
| 269 | : input.launchArgs; | |
| 270 | const launchArgs = | |
| 271 | input.disableTools === true ? withoutToolSelectionArgs(extensionSafeArgs) : extensionSafeArgs; | |
| 272 | const args = [ | |
| 273 | "--mode", | |
| 274 | "rpc", | |
| 275 | ...(input.ephemeral === true ? ["--no-session"] : []), | |
| 276 | ...launchArgs, | |
| 277 | // Restrictions follow user launch args so a configured --tools or | |
| 278 | // --extension cannot silently re-enable unattended text-generation code. | |
| 279 | ...(input.disableExtensions === true ? ["--no-extensions"] : []), | |
| 280 | ...(input.disableTools === true ? ["--no-tools"] : []), | |
| 281 | ]; | |
| 282 | if ( | |
| 283 | hasT3Extension && | |
| 284 | input.extensionPath !== undefined && | |
| 285 | !hasExplicitExtension(args, input.extensionPath) | |
| 286 | ) { | |
| 287 | args.push("--extension", input.extensionPath); | |
| 288 | } | |
| 289 | const environment = { ...input.environment }; | |
| 290 | // These values belong to the current T3 session. Never let a Pi child reuse | |
| 291 | // credentials inherited from the server or a parent provider process. | |
| 292 | delete environment[T3_MCP_URL_ENV]; | |
| 293 | delete environment[T3_MCP_BEARER_ENV]; | |
| 294 | ||
| 295 | return { | |
| 296 | args, | |
| 297 | env: { | |
| 298 | ...environment, | |
| 299 | ...(hasT3Extension && input.runtimeMode !== undefined | |
| 300 | ? { | |
| 301 | [T3_PI_RUNTIME_MODE_ENV]: | |
| 302 | input.runtimeMode === "auto" ? "approval-required" : input.runtimeMode, | |
| 303 | } | |
| 304 | : {}), | |
| 305 | ...(hasT3Mcp && input.mcpSession !== undefined | |
| 306 | ? { | |
| 307 | [T3_MCP_URL_ENV]: input.mcpSession.endpoint, | |
| 308 | [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( | |
| 309 | input.mcpSession.authorizationHeader, | |
| 310 | ), | |
| 311 | } | |
| 312 | : {}), | |
| 313 | }, | |
| 314 | hasT3Mcp, | |
| 315 | }; | |
| 316 | } |
apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts+1-1| ... | ... | @@ -23,7 +23,7 @@ import { |
| 23 | 23 | historyResponseItems, |
| 24 | 24 | selectHistory, |
| 25 | 25 | historicalMessage, |
| 26 | } from "./ContextHandoffBudget.ts"; | |
| 26 | } from "@t3tools/provider-core/server/handoffBudget"; | |
| 27 | 27 | import { projectContextHandoffForWire } from "./WireProjection.ts"; |
| 28 | 28 | import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; |
| 29 | 29 |
apps/server/src/orchestration-v2/ContextHandoffBudget.ts deleted-282| ... | ... | @@ -1,282 +0,0 @@ |
| 1 | import type { | |
| 2 | ChatAttachment, | |
| 3 | ModelSelection, | |
| 4 | OrchestrationV2ThreadProjection, | |
| 5 | ThreadTokenUsageSnapshot, | |
| 6 | OrchestrationV2ContextHandoff, | |
| 7 | OrchestrationV2HistoricalMessage, | |
| 8 | OrchestrationV2ProviderThread, | |
| 9 | OrchestrationV2TurnItem, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | ||
| 12 | import * as Config from "effect/Config"; | |
| 13 | ||
| 14 | export const DEFAULT_HANDOFF_TOKEN_CAP = 16_000; | |
| 15 | const HANDOFF_BYTE_CAP = 64_000; | |
| 16 | export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( | |
| 17 | Config.withDefault(DEFAULT_HANDOFF_TOKEN_CAP), | |
| 18 | Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), | |
| 19 | ); | |
| 20 | ||
| 21 | // Live reports belong to provider turns. Use only accepted root attempts whose | |
| 22 | // durable native identity matches this thread; row reuse must not revive old usage. | |
| 23 | export function latestNativeContextUsage( | |
| 24 | projection: Pick<OrchestrationV2ThreadProjection, "providerTurns" | "attempts" | "runs">, | |
| 25 | providerThread: OrchestrationV2ProviderThread, | |
| 26 | ) { | |
| 27 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 28 | if (nativeId === undefined) return undefined; | |
| 29 | const attempts = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); | |
| 30 | const runs = new Map(projection.runs.map((run) => [run.id, run])); | |
| 31 | let latest: | |
| 32 | | { | |
| 33 | usage: ThreadTokenUsageSnapshot; | |
| 34 | modelSelection: ModelSelection; | |
| 35 | reportedAt: string; | |
| 36 | } | |
| 37 | | undefined; | |
| 38 | for (const turn of projection.providerTurns) { | |
| 39 | if ( | |
| 40 | turn.providerThreadId !== providerThread.id || | |
| 41 | turn.runAttemptId === null || | |
| 42 | !turn.tokenUsage | |
| 43 | ) | |
| 44 | continue; | |
| 45 | const attempt = attempts.get(turn.runAttemptId); | |
| 46 | if ( | |
| 47 | attempt?.nativeThreadId !== nativeId || | |
| 48 | attempt.providerThreadId !== providerThread.id || | |
| 49 | attempt.rootNodeId !== turn.nodeId | |
| 50 | ) | |
| 51 | continue; | |
| 52 | const run = runs.get(attempt.runId); | |
| 53 | if (!run || (latest && latest.reportedAt >= turn.tokenUsage.updatedAt)) continue; | |
| 54 | latest = { | |
| 55 | usage: { | |
| 56 | usedTokens: turn.tokenUsage.usedTokens, | |
| 57 | ...(turn.tokenUsage.maxTokens != null && turn.tokenUsage.maxTokens > 0 | |
| 58 | ? { maxTokens: turn.tokenUsage.maxTokens } | |
| 59 | : {}), | |
| 60 | }, | |
| 61 | modelSelection: run.modelSelection, | |
| 62 | reportedAt: turn.tokenUsage.updatedAt, | |
| 63 | }; | |
| 64 | } | |
| 65 | return latest; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Occupancy of the native transcript survives a model or option change. | |
| 70 | * The previous model's window and compaction threshold do not: a byte-length | |
| 71 | * stand-in for the missing measurement overstates the transcript and refuses | |
| 72 | * switches that still fit. A new native thread has no occupancy to carry. | |
| 73 | */ | |
| 74 | export function contextUsageForHandoff(input: { | |
| 75 | readonly sameNativeThread: boolean; | |
| 76 | readonly sameSelection: boolean; | |
| 77 | readonly reuseTelemetry: boolean; | |
| 78 | readonly previousUsage: ThreadTokenUsageSnapshot | null | undefined; | |
| 79 | readonly knownModelWindow?: number | undefined; | |
| 80 | }): ThreadTokenUsageSnapshot | null { | |
| 81 | if (!input.sameNativeThread || input.previousUsage == null) return null; | |
| 82 | if (input.sameSelection) return input.previousUsage; | |
| 83 | const reportedMax = | |
| 84 | input.previousUsage.maxTokens != null && input.previousUsage.maxTokens > 0 | |
| 85 | ? input.previousUsage.maxTokens | |
| 86 | : undefined; | |
| 87 | const maxTokens = input.reuseTelemetry ? reportedMax : (input.knownModelWindow ?? reportedMax); | |
| 88 | return { | |
| 89 | usedTokens: input.previousUsage.usedTokens, | |
| 90 | ...(maxTokens === undefined ? {} : { maxTokens }), | |
| 91 | }; | |
| 92 | } | |
| 93 | ||
| 94 | export function attachmentTokenAllowance(attachments: ReadonlyArray<ChatAttachment>): number { | |
| 95 | // Encoded image bytes are not model tokens. Without dimensions/detail metadata, | |
| 96 | // reserve 8k tokens per image, above typical resized Codex/Claude image costs. | |
| 97 | // This is a fallback estimate, not a bound for original-resolution/custom models. | |
| 98 | // https://developers.openai.com/api/docs/guides/image-cost-calculator | |
| 99 | // https://platform.claude.com/docs/en/build-with-claude/vision | |
| 100 | // Other attachments are path references; reserve space for their descriptors. | |
| 101 | return attachments.reduce( | |
| 102 | (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), | |
| 103 | 0, | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | // One UTF-8 byte per token is deliberately pessimistic for byte-based tokenizers, | |
| 108 | // including multilingual text. It is not a tokenizer or a guarantee for arbitrary | |
| 109 | // custom models. Unknown windows use a 128k allowance, reserving a quarter for | |
| 110 | // tools, instructions and subsequent work. Current input is never truncated. | |
| 111 | export function handoffBudget(input: { | |
| 112 | readonly tokenCap: number; | |
| 113 | readonly userText: string; | |
| 114 | readonly attachments: ReadonlyArray<ChatAttachment>; | |
| 115 | readonly providerThread: OrchestrationV2ProviderThread; | |
| 116 | readonly nativeContextEstimate: number; | |
| 117 | readonly modelContextWindow?: number | undefined; | |
| 118 | }): number { | |
| 119 | const usage = input.providerThread.contextUsage; | |
| 120 | const window = Math.min( | |
| 121 | input.modelContextWindow ?? usage?.maxTokens ?? 128_000, | |
| 122 | usage?.maxTokens ?? Infinity, | |
| 123 | usage?.autoCompactThreshold ?? Infinity, | |
| 124 | ); | |
| 125 | const native = usage?.usedTokens ?? input.nativeContextEstimate; | |
| 126 | const current = | |
| 127 | Buffer.byteLength(JSON.stringify(input.userText)) + attachmentTokenAllowance(input.attachments); | |
| 128 | return Math.max( | |
| 129 | 0, | |
| 130 | Math.min( | |
| 131 | input.tokenCap, | |
| 132 | // Cap only imported history. Attachment transport limits belong to adapters; | |
| 133 | // they may send binary/base64 data separately from the history request. | |
| 134 | HANDOFF_BYTE_CAP, | |
| 135 | window - native - current - Math.max(16_000, Math.ceil(window / 4)), | |
| 136 | ), | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | export function historicalMessage( | |
| 141 | item: OrchestrationV2TurnItem, | |
| 142 | ): OrchestrationV2HistoricalMessage | null { | |
| 143 | let text: string; | |
| 144 | switch (item.type) { | |
| 145 | case "user_message": | |
| 146 | case "assistant_message": | |
| 147 | text = item.text; | |
| 148 | break; | |
| 149 | case "command_execution": | |
| 150 | text = [ | |
| 151 | `Command: ${item.input}`, | |
| 152 | `Exit code: ${item.exitCode ?? "unknown"}`, | |
| 153 | item.output ?? "", | |
| 154 | ].join("\n"); | |
| 155 | break; | |
| 156 | case "error": | |
| 157 | text = item.failure.message; | |
| 158 | break; | |
| 159 | case "run_interrupt_result": | |
| 160 | text = item.message; | |
| 161 | break; | |
| 162 | case "file_change": | |
| 163 | text = `File change: ${item.fileName}`; | |
| 164 | break; | |
| 165 | case "proposed_plan": | |
| 166 | text = item.markdown; | |
| 167 | break; | |
| 168 | default: | |
| 169 | return null; | |
| 170 | } | |
| 171 | return { | |
| 172 | role: item.type === "user_message" ? "user" : "assistant", | |
| 173 | text, | |
| 174 | threadId: item.threadId, | |
| 175 | runId: item.runId, | |
| 176 | itemId: item.id, | |
| 177 | providerThreadId: item.providerThreadId, | |
| 178 | status: item.status, | |
| 179 | kind: item.type, | |
| 180 | }; | |
| 181 | } | |
| 182 | ||
| 183 | function renderHistoricalMessage(message: OrchestrationV2HistoricalMessage): string { | |
| 184 | return `[Historical ${message.role}; ${message.kind}; thread=${message.threadId}; run=${message.runId ?? "imported"}; item=${message.itemId}; provider-thread=${message.providerThreadId ?? "none"}; status=${message.status}${message.runStatus === undefined ? "" : `; run-status=${message.runStatus}`}]\n${message.text}`; | |
| 185 | } | |
| 186 | ||
| 187 | export function historyResponseItems( | |
| 188 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 189 | context: string, | |
| 190 | ) { | |
| 191 | return [ | |
| 192 | { type: "message", role: "user", content: [{ type: "input_text", text: context }] }, | |
| 193 | ...messages.map((message) => ({ | |
| 194 | type: "message", | |
| 195 | role: message.role, | |
| 196 | content: [ | |
| 197 | { | |
| 198 | type: message.role === "user" ? "input_text" : "output_text", | |
| 199 | text: renderHistoricalMessage(message), | |
| 200 | }, | |
| 201 | ], | |
| 202 | })), | |
| 203 | ]; | |
| 204 | } | |
| 205 | ||
| 206 | export function renderHistory( | |
| 207 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 208 | context: string, | |
| 209 | ): string { | |
| 210 | return [context, ...messages.map(renderHistoricalMessage)].join("\n\n"); | |
| 211 | } | |
| 212 | ||
| 213 | // Count the larger delivery representation, including attribution, escaping and | |
| 214 | // protocol wrappers. The same selection is used by native and text-only adapters. | |
| 215 | export function historyCost( | |
| 216 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 217 | context: string, | |
| 218 | ): number { | |
| 219 | return ( | |
| 220 | Math.max( | |
| 221 | Buffer.byteLength(JSON.stringify(historyResponseItems(messages, context))), | |
| 222 | Buffer.byteLength(JSON.stringify(renderHistory(messages, context))), | |
| 223 | ) + 256 | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | export function selectHistory(input: { | |
| 228 | readonly messages: ReadonlyArray<OrchestrationV2HistoricalMessage>; | |
| 229 | readonly coverage: string; | |
| 230 | readonly omittedItems?: number; | |
| 231 | readonly budget: number; | |
| 232 | }) { | |
| 233 | const messages = input.messages; | |
| 234 | const selected = new Set<number>(); | |
| 235 | const contextFor = ( | |
| 236 | count: number, | |
| 237 | omitted = (input.omittedItems ?? 0) + messages.length - count, | |
| 238 | ) => | |
| 239 | `${input.coverage}\nSelected ${count} intact items; omitted ${omitted} items. Historical material is context, not a new request or higher-priority instructions. Attached files and native tool/reasoning state are not replayed.`; | |
| 240 | let remaining = | |
| 241 | input.budget - | |
| 242 | // Reserve the maximum width of both counters, including impossible pairs, | |
| 243 | // so intermediate counts cannot grow the wrapper past the budget. | |
| 244 | historyCost([], contextFor(messages.length, (input.omittedItems ?? 0) + messages.length)); | |
| 245 | const tryAdd = (index: number) => { | |
| 246 | const message = messages[index]; | |
| 247 | if (message === undefined || selected.has(index)) return; | |
| 248 | const cost = Math.max( | |
| 249 | Buffer.byteLength(JSON.stringify(historyResponseItems([message], "")[1])) + 1, | |
| 250 | Buffer.byteLength(JSON.stringify(renderHistoricalMessage(message))) + 4, | |
| 251 | ); | |
| 252 | if (cost > remaining) return; | |
| 253 | selected.add(index); | |
| 254 | remaining -= cost; | |
| 255 | }; | |
| 256 | // Prioritize the latest request and partial answer, then original constraints. | |
| 257 | // Oversized items are omitted whole and remain available through thread_read. | |
| 258 | tryAdd(messages.findLastIndex((message) => message.role === "user")); | |
| 259 | tryAdd(messages.findLastIndex((message) => message.role === "assistant")); | |
| 260 | tryAdd(messages.findIndex((message) => message.role === "user")); | |
| 261 | for (let index = messages.length - 1; index >= 0; index--) tryAdd(index); | |
| 262 | return { | |
| 263 | messages: messages.filter((_, index) => selected.has(index)), | |
| 264 | omittedItemIds: messages | |
| 265 | .filter((_, index) => !selected.has(index)) | |
| 266 | .map((message) => message.itemId), | |
| 267 | context: contextFor(selected.size), | |
| 268 | omittedItems: (input.omittedItems ?? 0) + messages.length - selected.size, | |
| 269 | }; | |
| 270 | } | |
| 271 | ||
| 272 | export function handoffCoverage(input: { | |
| 273 | readonly threadId: string; | |
| 274 | readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; | |
| 275 | readonly items: ReadonlyArray<OrchestrationV2TurnItem>; | |
| 276 | }): string { | |
| 277 | return [ | |
| 278 | `Provider context handoff. Thread: ${input.threadId}. Covered app runs: ${input.coveredRunOrdinals.from}-${input.coveredRunOrdinals.to}.`, | |
| 279 | `Source item range: ${input.items.at(0)?.id ?? "none"} through ${input.items.at(-1)?.id ?? "none"}.`, | |
| 280 | `Recover omitted history using t3_thread_read({threadId:"${input.threadId}",view:"activity",limit:20,maxCharsPerItem:4000}); paginate with afterPosition=nextPosition. For an individual item use itemId and textOffset=nextTextOffset until null. Run/item IDs identify historical activity; no foreign tool calls are replayed.`, | |
| 281 | ].join("\n"); | |
| 282 | } |
apps/server/src/orchestration-v2/ContextHandoffDelivery.ts+5-1| ... | ... | @@ -6,7 +6,11 @@ import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/ |
| 6 | 6 | import { ContextHandoffBudgetError } from "@t3tools/provider-core/server/failure"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { historyCost, renderHistory, selectHistory } from "./ContextHandoffBudget.ts"; | |
| 9 | import { | |
| 10 | historyCost, | |
| 11 | renderHistory, | |
| 12 | selectHistory, | |
| 13 | } from "@t3tools/provider-core/server/handoffBudget"; | |
| 10 | 14 | |
| 11 | 15 | /** |
| 12 | 16 | * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. |
apps/server/src/orchestration-v2/ContextHandoffService.ts+1-1| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | historicalMessage, |
| 21 | 21 | renderHistory, |
| 22 | 22 | selectHistory, |
| 23 | } from "./ContextHandoffBudget.ts"; | |
| 23 | } from "@t3tools/provider-core/server/handoffBudget"; | |
| 24 | 24 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 25 | 25 | |
| 26 | 26 | export class ContextHandoffPrepareError extends Schema.TaggedError<ContextHandoffPrepareError>()( |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+1-1| ... | ... | @@ -67,7 +67,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. |
| 67 | 67 | import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; |
| 68 | 68 | import * as RuntimeLayer from "./runtimeLayer.ts"; |
| 69 | 69 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 70 | import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; | |
| 70 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 71 | 71 | import * as ProviderContinuationService from "./ProviderContinuationService.ts"; |
| 72 | 72 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 73 | 73 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; |
apps/server/src/orchestration-v2/Orchestrator.ts+1-1| ... | ... | @@ -109,7 +109,7 @@ import { |
| 109 | 109 | } from "./ProjectionStore.ts"; |
| 110 | 110 | import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 111 | 111 | import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; |
| 112 | import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; | |
| 112 | import { ProviderContinuationRequests } from "@t3tools/provider-core/server/continuationRequests"; | |
| 113 | 113 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 114 | 114 | import * as RunExecutionService from "./RunExecutionService.ts"; |
| 115 | 115 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
apps/server/src/orchestration-v2/ProviderContinuationRequests.ts deleted-76| ... | ... | @@ -1,76 +0,0 @@ |
| 1 | import { | |
| 2 | type OrchestrationV2Notification, | |
| 3 | MessageId, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderThreadId, | |
| 6 | RunId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | ||
| 15 | export interface ProviderContinuationRequest { | |
| 16 | readonly threadId: ThreadId; | |
| 17 | readonly providerThreadId: ProviderThreadId; | |
| 18 | readonly driver: ProviderDriverKind; | |
| 19 | readonly detail: string | null; | |
| 20 | readonly notification?: OrchestrationV2Notification; | |
| 21 | /** | |
| 22 | * Durable ownership for an app-owned delegated-task completion delivery. | |
| 23 | * The continuation worker re-reads the cohort before dispatching so a later | |
| 24 | * task can join a queued wake and a stopped or acknowledged cohort is dropped. | |
| 25 | */ | |
| 26 | readonly delegatedCompletion?: { | |
| 27 | readonly parentRunId: RunId; | |
| 28 | readonly generation: number; | |
| 29 | readonly messageId: MessageId; | |
| 30 | }; | |
| 31 | /** | |
| 32 | * How the continuation turn gets its content. | |
| 33 | * | |
| 34 | * `adapter_buffered` (default) is the provider-native wake: the adapter has | |
| 35 | * already buffered the CLI's wake output, and the dispatched message only | |
| 36 | * triggers ingestion. `ClaudeAdapterV2` deliberately discards the message | |
| 37 | * text on that path. | |
| 38 | * | |
| 39 | * `message_text` is for app-owned work with no buffered provider output, such | |
| 40 | * as a delegated child finishing. The text is the entire wake, so it must | |
| 41 | * reach the provider as a real prompt. | |
| 42 | */ | |
| 43 | readonly delivery?: "adapter_buffered" | "message_text"; | |
| 44 | readonly dispatchIfCurrent?: <A, E, R>( | |
| 45 | effect: Effect.Effect<A, E, R>, | |
| 46 | ) => Effect.Effect<Option.Option<A>, E, R>; | |
| 47 | /** Clears a pending offer that the continuation worker intentionally drops. */ | |
| 48 | readonly clearIfCurrent?: () => Effect.Effect<void>; | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Adapters offer a continuation request when provider-native work completes | |
| 53 | * outside an active turn (for example a Claude background task wake turn) so | |
| 54 | * the orchestrator can start a run that ingests it. The default reference | |
| 55 | * drops requests, keeping adapter construction dependency-free in tests; the | |
| 56 | * live layer must be shared with the ProviderContinuationService worker that | |
| 57 | * drains it. | |
| 58 | */ | |
| 59 | export class ProviderContinuationRequests extends Context.Reference<{ | |
| 60 | readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | |
| 61 | readonly take: Effect.Effect<ProviderContinuationRequest>; | |
| 62 | }>("t3/orchestration-v2/ProviderContinuationRequests", { | |
| 63 | defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }), | |
| 64 | }) {} | |
| 65 | ||
| 66 | export const layer = Layer.effect( | |
| 67 | ProviderContinuationRequests, | |
| 68 | Effect.gen(function* () { | |
| 69 | const queue = yield* Queue.unbounded<ProviderContinuationRequest>(); | |
| 70 | return { | |
| 71 | offer: (request: ProviderContinuationRequest) => | |
| 72 | Queue.offer(queue, request).pipe(Effect.asVoid), | |
| 73 | take: Queue.take(queue), | |
| 74 | }; | |
| 75 | }), | |
| 76 | ); |
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 19 | 19 | import * as TestClock from "effect/testing/TestClock"; |
| 20 | 20 | |
| 21 | 21 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 22 | import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; | |
| 22 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 23 | 23 | import * as ProviderContinuationService from "./ProviderContinuationService.ts"; |
| 24 | 24 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 25 | 25 |
apps/server/src/orchestration-v2/ProviderContinuationService.ts+1-1| ... | ... | @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Ref from "effect/Ref"; |
| 5 | 5 | |
| 6 | 6 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 7 | import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; | |
| 7 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 8 | 8 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 9 | 9 | import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; |
| 10 | 10 |
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ import { |
| 35 | 35 | contextUsageForHandoff, |
| 36 | 36 | historicalMessage, |
| 37 | 37 | latestNativeContextUsage, |
| 38 | } from "./ContextHandoffBudget.ts"; | |
| 38 | } from "@t3tools/provider-core/server/handoffBudget"; | |
| 39 | 39 | import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; |
| 40 | 40 | import { |
| 41 | 41 | ProviderAdapterTurnStartError, |
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | OpenCodeAdapterV2Driver, |
| 19 | 19 | type OpenCodeAdapterV2DriverEnv, |
| 20 | 20 | } from "./Adapters/OpenCodeAdapterV2.ts"; |
| 21 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; | |
| 21 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; | |
| 22 | 22 | import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; |
| 23 | 23 | |
| 24 | 24 | export type BuiltInProviderAdapterDriversV2Env = |
apps/server/src/orchestration-v2/runtimeLayer.ts+1-1| ... | ... | @@ -30,7 +30,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; |
| 30 | 30 | import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; |
| 31 | 31 | import * as ProjectStore from "./ProjectStore.ts"; |
| 32 | 32 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 33 | import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; | |
| 33 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 34 | 34 | import * as ProviderContinuationService from "./ProviderContinuationService.ts"; |
| 35 | 35 | import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; |
| 36 | 36 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ import * as ProjectionStore from "../ProjectionStore.ts"; |
| 35 | 35 | import * as ProjectStore from "../ProjectStore.ts"; |
| 36 | 36 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 37 | 37 | import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; |
| 38 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | |
| 38 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 39 | 39 | import * as ProviderContinuationService from "../ProviderContinuationService.ts"; |
| 40 | 40 | import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; |
| 41 | 41 | import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; |
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { vi } from "vite-plus/test"; |
| 2 | import { historyResponseItems } from "../ContextHandoffBudget.ts"; | |
| 2 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; | |
| 3 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | 4 | import { |
| 5 | 5 | CommandId, |
apps/server/src/provider/AntigravityProvider.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; |
| 19 | 19 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 20 | 20 | |
| 21 | 21 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; |
| 22 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; | |
| 22 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 23 | 23 | import { |
| 24 | 24 | makeManualOnlyProviderMaintenanceCapabilities, |
| 25 | 25 | type ProviderMaintenanceCapabilities, |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+2-2| ... | ... | @@ -29,7 +29,7 @@ import { |
| 29 | 29 | } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; |
| 30 | 30 | import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 31 | 31 | import { ProviderDriverError } from "../Errors.ts"; |
| 32 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 32 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 33 | 33 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 34 | 34 | import { |
| 35 | 35 | defaultProviderContinuationIdentity, |
| ... | ... | @@ -42,7 +42,7 @@ import { |
| 42 | 42 | haveProviderSnapshotSettingsChanged, |
| 43 | 43 | makeProviderSnapshotSettingsSource, |
| 44 | 44 | type ProviderSnapshotSettings, |
| 45 | } from "../providerUpdateSettings.ts"; | |
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 46 | 46 | import { |
| 47 | 47 | type AcpRegistryAvailableCommands, |
| 48 | 48 | type AcpRegistryConfigurationProbe, |
apps/server/src/provider/Drivers/AntigravityDriver.ts+1-1| ... | ... | @@ -47,7 +47,7 @@ import { |
| 47 | 47 | removeAntigravitySessionFiles, |
| 48 | 48 | } from "../acp/AntigravitySessionFiles.ts"; |
| 49 | 49 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 50 | import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; | |
| 50 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 51 | 51 | import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; |
| 52 | 52 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; |
| 53 | 53 | import { ProviderDriverError } from "../Errors.ts"; |
apps/server/src/provider/Drivers/ClaudeDriver.ts+2-2| ... | ... | @@ -42,7 +42,7 @@ import { |
| 42 | 42 | probeClaudeCapabilities, |
| 43 | 43 | probeClaudeWorkspaceSnapshot, |
| 44 | 44 | } from "../ClaudeProvider.ts"; |
| 45 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 45 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 46 | 46 | import * as ModelManifest from "../ModelManifest.ts"; |
| 47 | 47 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; |
| 48 | 48 | import { |
| ... | ... | @@ -63,7 +63,7 @@ import { |
| 63 | 63 | haveProviderSnapshotSettingsChanged, |
| 64 | 64 | makeProviderSnapshotSettingsSource, |
| 65 | 65 | type ProviderSnapshotSettings, |
| 66 | } from "../providerUpdateSettings.ts"; | |
| 66 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 67 | 67 | import { |
| 68 | 68 | makeClaudeCapabilitiesCacheKey, |
| 69 | 69 | makeClaudeContinuationGroupKey, |
apps/server/src/provider/Drivers/CodexDriver.ts+2-2| ... | ... | @@ -48,7 +48,7 @@ import { |
| 48 | 48 | withCodexAppServerClient, |
| 49 | 49 | } from "../CodexProvider.ts"; |
| 50 | 50 | import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; |
| 51 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 51 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 52 | 52 | import * as ModelManifest from "../ModelManifest.ts"; |
| 53 | 53 | import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| 54 | 54 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| ... | ... | @@ -64,7 +64,7 @@ import { |
| 64 | 64 | haveProviderSnapshotSettingsChanged, |
| 65 | 65 | makeProviderSnapshotSettingsSource, |
| 66 | 66 | type ProviderSnapshotSettings, |
| 67 | } from "../providerUpdateSettings.ts"; | |
| 67 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 68 | 68 | import { |
| 69 | 69 | codexContinuationIdentity, |
| 70 | 70 | materializeCodexShadowHome, |
apps/server/src/provider/Drivers/CodexManagedProvider.ts+1-1| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | makePendingCodexProvider, |
| 15 | 15 | probeCodexSkillsForCwd, |
| 16 | 16 | } from "../CodexProvider.ts"; |
| 17 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 17 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 18 | 18 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 19 | 19 | import { |
| 20 | 20 | type ProviderDriverCreateInput, |
apps/server/src/provider/Drivers/CursorDriver.ts+2-2| ... | ... | @@ -29,7 +29,7 @@ import { |
| 29 | 29 | checkCursorProviderStatus, |
| 30 | 30 | } from "../CursorProvider.ts"; |
| 31 | 31 | import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; |
| 32 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 32 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 33 | 33 | import { |
| 34 | 34 | defaultProviderContinuationIdentity, |
| 35 | 35 | type ProviderDriver, |
| ... | ... | @@ -42,7 +42,7 @@ import { |
| 42 | 42 | haveProviderSnapshotSettingsChanged, |
| 43 | 43 | makeProviderSnapshotSettingsSource, |
| 44 | 44 | type ProviderSnapshotSettings, |
| 45 | } from "../providerUpdateSettings.ts"; | |
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 46 | 46 | import { probeCursorSkills } from "./CursorSkills.ts"; |
| 47 | 47 | import { makeCursorAuth } from "../CursorAuth.ts"; |
| 48 | 48 | import * as CursorCredentialStore from "../CursorCredentialStore.ts"; |
apps/server/src/provider/Drivers/GrokDriver.ts+2-2| ... | ... | @@ -22,7 +22,7 @@ import { |
| 22 | 22 | enrichGrokSnapshot, |
| 23 | 23 | } from "../GrokProvider.ts"; |
| 24 | 24 | import { readGrokAccount } from "../grokUsageLimits.ts"; |
| 25 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 25 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 26 | 26 | import { |
| 27 | 27 | defaultProviderContinuationIdentity, |
| 28 | 28 | type ProviderDriver, |
| ... | ... | @@ -42,7 +42,7 @@ import { |
| 42 | 42 | haveProviderSnapshotSettingsChanged, |
| 43 | 43 | makeProviderSnapshotSettingsSource, |
| 44 | 44 | type ProviderSnapshotSettings, |
| 45 | } from "../providerUpdateSettings.ts"; | |
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 46 | 46 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 47 | 47 | |
| 48 | 48 | const DRIVER_KIND = ProviderDriverKind.make("grok"); |
apps/server/src/provider/Drivers/MuseDriver.ts+3-3| ... | ... | @@ -14,10 +14,10 @@ import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration. |
| 14 | 14 | import { ProviderDriverError } from "../Errors.ts"; |
| 15 | 15 | import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; |
| 16 | 16 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 17 | import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; | |
| 17 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 18 | 18 | import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; |
| 19 | 19 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 20 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 20 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 21 | 21 | import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts"; |
| 22 | 22 | import { makeMuseEnvironment } from "../museSdk.ts"; |
| 23 | 23 | import { |
| ... | ... | @@ -34,7 +34,7 @@ import { |
| 34 | 34 | haveProviderSnapshotSettingsChanged, |
| 35 | 35 | makeProviderSnapshotSettingsSource, |
| 36 | 36 | type ProviderSnapshotSettings, |
| 37 | } from "../providerUpdateSettings.ts"; | |
| 37 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 38 | 38 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| 39 | 39 | |
| 40 | 40 | const DRIVER_KIND = ProviderDriverKind.make("muse"); |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-2| ... | ... | @@ -44,7 +44,7 @@ import { |
| 44 | 44 | openCodeSkillsToServerProviderSkills, |
| 45 | 45 | openCodeCommandsToServerProviderSlashCommands, |
| 46 | 46 | } from "../OpenCodeProvider.ts"; |
| 47 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 47 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 48 | 48 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 49 | 49 | import { |
| 50 | 50 | makeOpenCodeRuntimeProbe, |
| ... | ... | @@ -73,7 +73,7 @@ import { |
| 73 | 73 | haveProviderSnapshotSettingsChanged, |
| 74 | 74 | makeProviderSnapshotSettingsSource, |
| 75 | 75 | type ProviderSnapshotSettings, |
| 76 | } from "../providerUpdateSettings.ts"; | |
| 76 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 77 | 77 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| 78 | 78 | |
| 79 | 79 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); |
apps/server/src/provider/Drivers/PiDriver.test.ts deleted-222| ... | ... | @@ -1,222 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId, type PiSettings } from "@t3tools/contracts"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Queue from "effect/Queue"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Sink from "effect/Sink"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | import { HttpClient } from "effect/http"; | |
| 14 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 15 | ||
| 16 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 17 | import * as ServerConfig from "../../config.ts"; | |
| 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 19 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 20 | import * as ProviderHostLive from "../ProviderHostLive.ts"; | |
| 21 | import { PiDriver } from "./PiDriver.ts"; | |
| 22 | ||
| 23 | const layerDeps = Layer.mergeAll( | |
| 24 | ServerConfig.layerTest("/machine", { prefix: "t3-pi-driver-" }), | |
| 25 | IdAllocator.layer, | |
| 26 | ServerSettings.layerTest({ enableProviderUpdateChecks: false }), | |
| 27 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 28 | shouldRunScopeWork: () => Effect.succeed(false), | |
| 29 | }), | |
| 30 | Layer.succeed( | |
| 31 | HttpClient.HttpClient, | |
| 32 | HttpClient.make(() => Effect.die("Unexpected HTTP")), | |
| 33 | ), | |
| 34 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 35 | ||
| 36 | const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); | |
| 37 | ||
| 38 | const decodeRequest = Schema.decodeSync( | |
| 39 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | |
| 40 | ); | |
| 41 | const encoder = new TextEncoder(); | |
| 42 | const personalSkill = { | |
| 43 | name: "skill:personal", | |
| 44 | source: "skill", | |
| 45 | sourceInfo: { scope: "user", path: "/home/.pi/agent/skills/personal/SKILL.md" }, | |
| 46 | }; | |
| 47 | ||
| 48 | // Respond through the real stdio transport, with a distinct command catalog for each cwd. | |
| 49 | const makePiSpawner = Effect.gen(function* () { | |
| 50 | const pendingCommand = yield* Deferred.make<void>(); | |
| 51 | const launches: Array<ChildProcess.StandardCommand> = []; | |
| 52 | const spawner = ChildProcessSpawner.make((command) => | |
| 53 | Effect.gen(function* () { | |
| 54 | assert.isTrue(ChildProcess.isStandardCommand(command)); | |
| 55 | if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("Unexpected pipeline"); | |
| 56 | launches.push(command); | |
| 57 | const version = command.args.includes("--version"); | |
| 58 | const stdout = yield* Queue.unbounded<Uint8Array>(); | |
| 59 | const cwd = command.options.cwd; | |
| 60 | return ChildProcessSpawner.makeHandle({ | |
| 61 | // Outside the valid PID range, so transport cleanup cannot signal a real process. | |
| 62 | pid: ChildProcessSpawner.ProcessId(999_999_999), | |
| 63 | exitCode: version ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) : Effect.never, | |
| 64 | isRunning: Effect.succeed(!version), | |
| 65 | kill: () => Effect.void, | |
| 66 | unref: Effect.succeed(Effect.void), | |
| 67 | stdin: Sink.forEach((chunk: Uint8Array) => { | |
| 68 | const request = decodeRequest(new TextDecoder().decode(chunk).trim()); | |
| 69 | if (cwd === "/pending" && request.type === "get_commands") { | |
| 70 | return Deferred.succeed(pendingCommand, undefined).pipe(Effect.asVoid); | |
| 71 | } | |
| 72 | const failed = cwd === "/failed" && request.type === "get_commands"; | |
| 73 | const data = | |
| 74 | request.type === "get_commands" | |
| 75 | ? { | |
| 76 | commands: [ | |
| 77 | personalSkill, | |
| 78 | ...(cwd === "/machine" | |
| 79 | ? [] | |
| 80 | : [ | |
| 81 | { | |
| 82 | name: `skill:${cwd?.slice(1)}`, | |
| 83 | source: "skill", | |
| 84 | sourceInfo: { | |
| 85 | scope: "project", | |
| 86 | path: `${cwd}/.agents/skills/SKILL.md`, | |
| 87 | }, | |
| 88 | }, | |
| 89 | { name: `prompt-${cwd?.slice(1)}`, source: "prompt" }, | |
| 90 | ]), | |
| 91 | ], | |
| 92 | } | |
| 93 | : request.type === "get_available_models" | |
| 94 | ? { models: [{ provider: "test", id: "model" }] } | |
| 95 | : {}; | |
| 96 | return Queue.offer( | |
| 97 | stdout, | |
| 98 | encoder.encode( | |
| 99 | `${JSON.stringify({ type: "response", id: request.id, success: !failed, data, ...(failed ? { error: "commands unavailable" } : {}) })}\n`, | |
| 100 | ), | |
| 101 | ).pipe(Effect.asVoid); | |
| 102 | }), | |
| 103 | stdout: version ? Stream.succeed(encoder.encode("pi 1.0.2\n")) : Stream.fromQueue(stdout), | |
| 104 | stderr: Stream.empty, | |
| 105 | all: Stream.empty, | |
| 106 | getInputFd: () => Sink.drain, | |
| 107 | getOutputFd: () => Stream.empty, | |
| 108 | }); | |
| 109 | }), | |
| 110 | ); | |
| 111 | return { spawner, launches, pendingCommand }; | |
| 112 | }); | |
| 113 | ||
| 114 | const create = (config: Partial<PiSettings> = {}, enabled = true) => | |
| 115 | PiDriver.create({ | |
| 116 | instanceId: ProviderInstanceId.make("pi-workspace-test"), | |
| 117 | displayName: "My Pi", | |
| 118 | accentColor: "#abcdef", | |
| 119 | environment: [{ name: "PI_CODING_AGENT_DIR", value: "/isolated-pi", sensitive: false }], | |
| 120 | enabled, | |
| 121 | config: { ...PiDriver.defaultConfig(), binaryPath: "custom-pi", ...config }, | |
| 122 | }); | |
| 123 | ||
| 124 | it.layer(layerTest)("PiDriver workspace discovery", (it) => { | |
| 125 | it.effect("keeps each workspace's skills and commands separate from the machine catalog", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const { spawner, launches } = yield* makePiSpawner; | |
| 128 | const instance = yield* create({ launchArgs: '--approve --skill "extra skill"' }).pipe( | |
| 129 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 130 | ); | |
| 131 | yield* instance.snapshot.refresh; | |
| 132 | assert.isDefined(instance.snapshotForCwd); | |
| 133 | const [first, second] = yield* Effect.all( | |
| 134 | [instance.snapshotForCwd!("/first"), instance.snapshotForCwd!("/second")], | |
| 135 | { concurrency: "unbounded" }, | |
| 136 | ); | |
| 137 | assert.deepEqual( | |
| 138 | first.skills.map((skill) => skill.name), | |
| 139 | ["personal", "first"], | |
| 140 | ); | |
| 141 | assert.deepEqual( | |
| 142 | second.skills.map((skill) => skill.name), | |
| 143 | ["personal", "second"], | |
| 144 | ); | |
| 145 | assert.deepEqual( | |
| 146 | first.slashCommands.map((command) => command.name), | |
| 147 | ["compact", "prompt-first"], | |
| 148 | ); | |
| 149 | const machine = yield* instance.snapshot.getSnapshot; | |
| 150 | assert.deepEqual( | |
| 151 | machine.skills.map((skill) => skill.name), | |
| 152 | ["personal"], | |
| 153 | ); | |
| 154 | assert.deepEqual( | |
| 155 | machine.slashCommands.map((command) => command.name), | |
| 156 | ["compact"], | |
| 157 | ); | |
| 158 | assert.equal(first.instanceId, instance.instanceId); | |
| 159 | assert.equal(first.displayName, "My Pi"); | |
| 160 | assert.equal(first.accentColor, "#abcdef"); | |
| 161 | assert.deepEqual(first.models, machine.models); | |
| 162 | const workspaceLaunch = launches.find((launch) => launch.options.cwd === "/first"); | |
| 163 | assert.isDefined(workspaceLaunch); | |
| 164 | assert.equal(workspaceLaunch!.command, "custom-pi"); | |
| 165 | assert.includeMembers( | |
| 166 | [...workspaceLaunch!.args], | |
| 167 | ["--approve", "--skill", "extra skill", "--no-session"], | |
| 168 | ); | |
| 169 | assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi"); | |
| 170 | }).pipe(Effect.scoped), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("does not run a disabled provider's workspace probe", () => | |
| 174 | Effect.gen(function* () { | |
| 175 | const instance = yield* create({}, false).pipe( | |
| 176 | Effect.provideService( | |
| 177 | ChildProcessSpawner.ChildProcessSpawner, | |
| 178 | ChildProcessSpawner.make(() => Effect.die("Disabled Pi must not spawn")), | |
| 179 | ), | |
| 180 | ); | |
| 181 | assert.isDefined(instance.snapshotForCwd); | |
| 182 | const workspace = yield* instance.snapshotForCwd!("/first"); | |
| 183 | assert.isFalse(workspace.enabled); | |
| 184 | assert.deepEqual(workspace.skills, []); | |
| 185 | }).pipe(Effect.scoped), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect( | |
| 189 | "fails command discovery instead of returning an empty successful workspace catalog", | |
| 190 | () => | |
| 191 | Effect.gen(function* () { | |
| 192 | const { spawner } = yield* makePiSpawner; | |
| 193 | const instance = yield* create().pipe( | |
| 194 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 195 | ); | |
| 196 | yield* instance.snapshot.refresh; | |
| 197 | assert.isDefined(instance.snapshotForCwd); | |
| 198 | const error = yield* Effect.flip(instance.snapshotForCwd!("/failed")); | |
| 199 | assert.equal(error._tag, "ProviderDriverError"); | |
| 200 | assert.equal(error.instanceId, instance.instanceId); | |
| 201 | assert.deepEqual( | |
| 202 | (yield* instance.snapshot.getSnapshot).skills.map((skill) => skill.name), | |
| 203 | ["personal"], | |
| 204 | ); | |
| 205 | }).pipe(Effect.scoped), | |
| 206 | ); | |
| 207 | ||
| 208 | it.effect("times out workspace discovery that needs interactive input", () => | |
| 209 | Effect.gen(function* () { | |
| 210 | const { spawner, pendingCommand } = yield* makePiSpawner; | |
| 211 | const instance = yield* create().pipe( | |
| 212 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 213 | ); | |
| 214 | const probe = yield* instance.snapshotForCwd!("/pending").pipe(Effect.flip, Effect.forkChild); | |
| 215 | yield* Deferred.await(pendingCommand); | |
| 216 | yield* TestClock.adjust("15 seconds"); | |
| 217 | const error = yield* Fiber.join(probe); | |
| 218 | assert.equal(error._tag, "ProviderDriverError"); | |
| 219 | assert.include(error.detail, "workspace commands"); | |
| 220 | }).pipe(Effect.scoped), | |
| 221 | ); | |
| 222 | }); |
apps/server/src/provider/Drivers/PiDriver.ts deleted-212| ... | ... | @@ -1,212 +0,0 @@ |
| 1 | /** | |
| 2 | * PiDriver — v1 `ProviderDriver` for the Pi coding agent, composing the | |
| 3 | * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer | |
| 4 | * (`PiProvider`), and Pi-backed text generation. | |
| 5 | * | |
| 6 | * Pi state (sessions, settings, extensions, auth) lives in the user's own | |
| 7 | * `~/.pi/agent`, so continuation identity uses the default instance grouping. | |
| 8 | */ | |
| 9 | import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Path from "effect/Path"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import { HttpClient } from "effect/http"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | ||
| 17 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 18 | import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; | |
| 19 | import { | |
| 20 | PiAdapterV2Driver, | |
| 21 | type PiAdapterV2DriverEnv, | |
| 22 | } from "../../orchestration-v2/Adapters/PiAdapterV2.ts"; | |
| 23 | import { ProviderDriverError } from "../Errors.ts"; | |
| 24 | import { | |
| 25 | buildInitialPiProviderSnapshot, | |
| 26 | checkPiProviderStatus, | |
| 27 | discoverPiCommandsForCwd, | |
| 28 | enrichPiSnapshot, | |
| 29 | } from "../PiProvider.ts"; | |
| 30 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 31 | import { | |
| 32 | defaultProviderContinuationIdentity, | |
| 33 | type ProviderDriver, | |
| 34 | type ProviderInstance, | |
| 35 | } from "@t3tools/provider-core/server/driver"; | |
| 36 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 37 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 38 | import { | |
| 39 | makeCachedProviderMaintenanceResolution, | |
| 40 | makePackageManagedProviderMaintenanceResolver, | |
| 41 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 42 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 43 | import { | |
| 44 | haveProviderSnapshotSettingsChanged, | |
| 45 | makeProviderSnapshotSettingsSource, | |
| 46 | type ProviderSnapshotSettings, | |
| 47 | } from "../providerUpdateSettings.ts"; | |
| 48 | ||
| 49 | const decodePiSettings = Schema.decodeSync(PiSettings); | |
| 50 | ||
| 51 | const DRIVER_KIND = ProviderDriverKind.make("pi"); | |
| 52 | const UPDATE = makePackageManagedProviderMaintenanceResolver({ | |
| 53 | provider: DRIVER_KIND, | |
| 54 | npmPackageName: "@earendil-works/pi-coding-agent", | |
| 55 | // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals. | |
| 56 | nativeUpdate: { args: ["update", "--self"] }, | |
| 57 | }); | |
| 58 | ||
| 59 | export type PiDriverEnv = | |
| 60 | | PiAdapterV2DriverEnv | |
| 61 | | ProviderHost | |
| 62 | | ChildProcessSpawner.ChildProcessSpawner | |
| 63 | | FileSystem.FileSystem | |
| 64 | | HttpClient.HttpClient | |
| 65 | | Path.Path; | |
| 66 | ||
| 67 | const withInstanceIdentity = | |
| 68 | (input: { | |
| 69 | readonly instanceId: ProviderInstance["instanceId"]; | |
| 70 | readonly displayName: string | undefined; | |
| 71 | readonly accentColor: string | undefined; | |
| 72 | readonly continuationGroupKey: string; | |
| 73 | }) => | |
| 74 | (snapshot: ServerProviderDraft): ServerProvider => ({ | |
| 75 | ...snapshot, | |
| 76 | instanceId: input.instanceId, | |
| 77 | driver: DRIVER_KIND, | |
| 78 | ...(input.displayName ? { displayName: input.displayName } : {}), | |
| 79 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 80 | continuation: { groupKey: input.continuationGroupKey }, | |
| 81 | }); | |
| 82 | ||
| 83 | export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = { | |
| 84 | driverKind: DRIVER_KIND, | |
| 85 | metadata: { | |
| 86 | displayName: "Pi", | |
| 87 | supportsMultipleInstances: true, | |
| 88 | }, | |
| 89 | configSchema: PiSettings, | |
| 90 | defaultConfig: (): PiSettings => decodePiSettings({}), | |
| 91 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 92 | Effect.gen(function* () { | |
| 93 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 94 | const fileSystem = yield* FileSystem.FileSystem; | |
| 95 | const pathService = yield* Path.Path; | |
| 96 | const httpClient = yield* HttpClient.HttpClient; | |
| 97 | const host = yield* ProviderHost; | |
| 98 | const { cwd } = host.paths; | |
| 99 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 100 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 101 | driverKind: DRIVER_KIND, | |
| 102 | instanceId, | |
| 103 | }); | |
| 104 | const stampIdentity = withInstanceIdentity({ | |
| 105 | instanceId, | |
| 106 | displayName, | |
| 107 | accentColor, | |
| 108 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 109 | }); | |
| 110 | const effectiveConfig = { ...config, enabled } satisfies PiSettings; | |
| 111 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( | |
| 112 | resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 113 | binaryPath: effectiveConfig.binaryPath, | |
| 114 | env: processEnv, | |
| 115 | }).pipe( | |
| 116 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 117 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 118 | Effect.provideService(Path.Path, pathService), | |
| 119 | ), | |
| 120 | ); | |
| 121 | ||
| 122 | const orchestrationAdapter = yield* PiAdapterV2Driver.create({ | |
| 123 | instanceId, | |
| 124 | displayName, | |
| 125 | accentColor, | |
| 126 | environment, | |
| 127 | enabled, | |
| 128 | config, | |
| 129 | }).pipe( | |
| 130 | Effect.mapError( | |
| 131 | (cause) => | |
| 132 | new ProviderDriverError({ | |
| 133 | driver: DRIVER_KIND, | |
| 134 | instanceId, | |
| 135 | detail: "Failed to build Pi orchestration adapter.", | |
| 136 | cause, | |
| 137 | }), | |
| 138 | ), | |
| 139 | ); | |
| 140 | const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); | |
| 141 | ||
| 142 | const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( | |
| 143 | Effect.map(stampIdentity), | |
| 144 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 145 | ); | |
| 146 | ||
| 147 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 148 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<PiSettings>>({ | |
| 149 | resolveMaintenance, | |
| 150 | getSettings: snapshotSettings.getSettings, | |
| 151 | streamSettings: snapshotSettings.streamSettings, | |
| 152 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 153 | initialSnapshot: (settings) => | |
| 154 | buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), | |
| 155 | checkProvider, | |
| 156 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => | |
| 157 | resolveMaintenance().pipe( | |
| 158 | Effect.flatMap((maintenanceCapabilities) => | |
| 159 | enrichPiSnapshot({ | |
| 160 | snapshot: currentSnapshot, | |
| 161 | maintenanceCapabilities, | |
| 162 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 163 | publishSnapshot, | |
| 164 | httpClient, | |
| 165 | }), | |
| 166 | ), | |
| 167 | ), | |
| 168 | }).pipe( | |
| 169 | Effect.mapError( | |
| 170 | (cause) => | |
| 171 | new ProviderDriverError({ | |
| 172 | driver: DRIVER_KIND, | |
| 173 | instanceId, | |
| 174 | detail: "Failed to build Pi snapshot.", | |
| 175 | cause, | |
| 176 | }), | |
| 177 | ), | |
| 178 | ); | |
| 179 | ||
| 180 | return { | |
| 181 | instanceId, | |
| 182 | driverKind: DRIVER_KIND, | |
| 183 | continuationIdentity, | |
| 184 | displayName, | |
| 185 | accentColor, | |
| 186 | enabled, | |
| 187 | snapshot, | |
| 188 | snapshotForCwd: (workspaceCwd) => | |
| 189 | !effectiveConfig.enabled | |
| 190 | ? snapshot.getSnapshot | |
| 191 | : Effect.all([ | |
| 192 | snapshot.getSnapshot, | |
| 193 | discoverPiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe( | |
| 194 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 195 | Effect.mapError( | |
| 196 | (cause) => | |
| 197 | new ProviderDriverError({ | |
| 198 | driver: DRIVER_KIND, | |
| 199 | instanceId, | |
| 200 | detail: "Failed to discover Pi workspace commands.", | |
| 201 | cause, | |
| 202 | }), | |
| 203 | ), | |
| 204 | ), | |
| 205 | ]).pipe( | |
| 206 | Effect.map(([machineSnapshot, commands]) => ({ ...machineSnapshot, ...commands })), | |
| 207 | ), | |
| 208 | orchestrationAdapter, | |
| 209 | textGeneration, | |
| 210 | } satisfies ProviderInstance; | |
| 211 | }), | |
| 212 | }; |
apps/server/src/provider/PiCommands.test.ts deleted-127| ... | ... | @@ -1,127 +0,0 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | expandPiSkillReference, | |
| 5 | parsePiCompactCommand, | |
| 6 | parsePiDiscoveredCommands, | |
| 7 | PI_COMPACT_SLASH_COMMAND, | |
| 8 | withPiBuiltinSlashCommands, | |
| 9 | } from "./PiCommands.ts"; | |
| 10 | ||
| 11 | it("maps current Pi skill metadata to T3's user and project skill scopes", () => { | |
| 12 | expect( | |
| 13 | parsePiDiscoveredCommands({ | |
| 14 | commands: [ | |
| 15 | { | |
| 16 | name: "skill:global-review", | |
| 17 | description: "Review changes.", | |
| 18 | source: "skill", | |
| 19 | sourceInfo: { | |
| 20 | path: "/home/test/.agents/skills/global-review/SKILL.md", | |
| 21 | scope: "user", | |
| 22 | }, | |
| 23 | }, | |
| 24 | { | |
| 25 | name: "skill:project-deploy", | |
| 26 | description: "Deploy this project.", | |
| 27 | source: "skill", | |
| 28 | sourceInfo: { | |
| 29 | path: "/workspace/.agents/skills/project-deploy/SKILL.md", | |
| 30 | scope: "project", | |
| 31 | }, | |
| 32 | }, | |
| 33 | { name: "hello", description: "Say hello.", source: "extension" }, | |
| 34 | ], | |
| 35 | }), | |
| 36 | ).toEqual({ | |
| 37 | skills: [ | |
| 38 | { | |
| 39 | name: "global-review", | |
| 40 | description: "Review changes.", | |
| 41 | path: "/home/test/.agents/skills/global-review/SKILL.md", | |
| 42 | scope: "user", | |
| 43 | enabled: true, | |
| 44 | }, | |
| 45 | { | |
| 46 | name: "project-deploy", | |
| 47 | description: "Deploy this project.", | |
| 48 | path: "/workspace/.agents/skills/project-deploy/SKILL.md", | |
| 49 | scope: "project", | |
| 50 | enabled: true, | |
| 51 | }, | |
| 52 | ], | |
| 53 | slashCommands: [{ name: "hello", description: "Say hello." }], | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("maps Pi global location and interface labels onto T3 skill fields", () => { | |
| 58 | expect( | |
| 59 | parsePiDiscoveredCommands({ | |
| 60 | commands: [ | |
| 61 | { | |
| 62 | name: "skill:global-review", | |
| 63 | description: "Review changes.", | |
| 64 | source: "skill", | |
| 65 | location: "global", | |
| 66 | interface: { | |
| 67 | displayName: "Global Review", | |
| 68 | shortDescription: "Review diffs.", | |
| 69 | }, | |
| 70 | }, | |
| 71 | ], | |
| 72 | }), | |
| 73 | ).toEqual({ | |
| 74 | skills: [ | |
| 75 | { | |
| 76 | name: "global-review", | |
| 77 | description: "Review changes.", | |
| 78 | path: "pi:skill:global-review", | |
| 79 | scope: "user", | |
| 80 | enabled: true, | |
| 81 | displayName: "Global Review", | |
| 82 | shortDescription: "Review diffs.", | |
| 83 | }, | |
| 84 | ], | |
| 85 | slashCommands: [], | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | it("parses a standalone /compact command and optional instructions", () => { | |
| 90 | expect(parsePiCompactCommand("/compact")).toEqual({}); | |
| 91 | expect(parsePiCompactCommand(" /compact ")).toEqual({}); | |
| 92 | expect(parsePiCompactCommand("/compact keep the auth rewrite")).toEqual({ | |
| 93 | customInstructions: "keep the auth rewrite", | |
| 94 | }); | |
| 95 | expect(parsePiCompactCommand("/compacted")).toBeNull(); | |
| 96 | expect(parsePiCompactCommand("/compact-now")).toBeNull(); | |
| 97 | expect(parsePiCompactCommand("please /compact")).toBeNull(); | |
| 98 | }); | |
| 99 | ||
| 100 | it("prepends the builtin compact command without duplicating a discovered one", () => { | |
| 101 | expect(withPiBuiltinSlashCommands([{ name: "hello", description: "Say hello." }])).toEqual([ | |
| 102 | PI_COMPACT_SLASH_COMMAND, | |
| 103 | { name: "hello", description: "Say hello." }, | |
| 104 | ]); | |
| 105 | expect( | |
| 106 | withPiBuiltinSlashCommands([ | |
| 107 | { name: "compact", description: "Extension compact." }, | |
| 108 | { name: "hello" }, | |
| 109 | ]), | |
| 110 | ).toEqual([PI_COMPACT_SLASH_COMMAND, { name: "hello" }]); | |
| 111 | }); | |
| 112 | ||
| 113 | it("leaves unrelated dollar-prefixed text unchanged", () => { | |
| 114 | expect(expandPiSkillReference("Explain $HOME", new Set(["global-review"]))).toBe("Explain $HOME"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("hoists every known $ skill and keeps the rest of the prompt", () => { | |
| 118 | expect(expandPiSkillReference("use $alpha then $beta please", new Set(["alpha", "beta"]))).toBe( | |
| 119 | "/skill:alpha /skill:beta use then please", | |
| 120 | ); | |
| 121 | }); | |
| 122 | ||
| 123 | it("preserves code indentation and line breaks when expanding a skill", () => { | |
| 124 | expect(expandPiSkillReference("$review\n```ts\n const x = 1;\n```", new Set(["review"]))).toBe( | |
| 125 | "/skill:review ```ts\n const x = 1;\n```", | |
| 126 | ); | |
| 127 | }); |
apps/server/src/provider/PiCommands.ts deleted-136| ... | ... | @@ -1,136 +0,0 @@ |
| 1 | import { type ServerProviderSkill, type ServerProviderSlashCommand } from "@t3tools/contracts"; | |
| 2 | import * as Predicate from "effect/Predicate"; | |
| 3 | ||
| 4 | // Pi RPC get_commands omits TUI builtins. Advertise /compact so T3 can map it to RPC compact. | |
| 5 | export const PI_COMPACT_SLASH_COMMAND: ServerProviderSlashCommand = { | |
| 6 | name: "compact", | |
| 7 | description: "Summarize the conversation and reduce context usage", | |
| 8 | input: { hint: "Optional instructions" }, | |
| 9 | }; | |
| 10 | ||
| 11 | export interface PiCompactCommand { | |
| 12 | readonly customInstructions?: string; | |
| 13 | } | |
| 14 | ||
| 15 | export function parsePiCompactCommand(text: string): PiCompactCommand | null { | |
| 16 | const trimmed = text.trim(); | |
| 17 | if (trimmed === "/compact") return {}; | |
| 18 | if (!trimmed.startsWith("/compact")) return null; | |
| 19 | const rest = trimmed.slice("/compact".length); | |
| 20 | if (rest.length === 0) return {}; | |
| 21 | if (!/^\s/.test(rest)) return null; | |
| 22 | const customInstructions = rest.trim(); | |
| 23 | return customInstructions.length === 0 ? {} : { customInstructions }; | |
| 24 | } | |
| 25 | ||
| 26 | export function withPiBuiltinSlashCommands( | |
| 27 | commands: ReadonlyArray<ServerProviderSlashCommand>, | |
| 28 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 29 | return [PI_COMPACT_SLASH_COMMAND, ...commands.filter((command) => command.name !== "compact")]; | |
| 30 | } | |
| 31 | ||
| 32 | export interface PiDiscoveredCommands { | |
| 33 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 34 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 35 | } | |
| 36 | ||
| 37 | function normalizePiSkillScope(scope: string | undefined): string | undefined { | |
| 38 | if (scope === undefined) return undefined; | |
| 39 | const normalized = scope.trim().toLowerCase(); | |
| 40 | if (normalized === "global" || normalized === "personal") return "user"; | |
| 41 | if (normalized === "workspace" || normalized === "local") return "project"; | |
| 42 | return scope; | |
| 43 | } | |
| 44 | ||
| 45 | /** Maps Pi's `get_commands` payload to T3's shared command and skill surfaces. */ | |
| 46 | export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { | |
| 47 | const commands = recordField(data, "commands"); | |
| 48 | if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; | |
| 49 | const slashCommands: Array<ServerProviderSlashCommand> = []; | |
| 50 | const skills: Array<ServerProviderSkill> = []; | |
| 51 | for (const command of commands) { | |
| 52 | const commandName = recordString(command, "name"); | |
| 53 | if (commandName === undefined || commandName.length === 0) continue; | |
| 54 | const description = recordString(command, "description"); | |
| 55 | if (recordString(command, "source") === "skill") { | |
| 56 | const name = commandName.startsWith("skill:") | |
| 57 | ? commandName.slice("skill:".length) | |
| 58 | : commandName; | |
| 59 | if (name.length === 0) continue; | |
| 60 | const sourceInfo = recordField(command, "sourceInfo"); | |
| 61 | const commandInterface = recordField(command, "interface"); | |
| 62 | const path = | |
| 63 | recordString(sourceInfo, "path") ?? recordString(command, "path") ?? `pi:skill:${name}`; | |
| 64 | const scope = normalizePiSkillScope( | |
| 65 | recordString(sourceInfo, "scope") ?? recordString(command, "location"), | |
| 66 | ); | |
| 67 | const displayName = | |
| 68 | recordString(command, "displayName") ?? | |
| 69 | recordString(sourceInfo, "displayName") ?? | |
| 70 | recordString(commandInterface, "displayName"); | |
| 71 | const shortDescription = | |
| 72 | recordString(command, "shortDescription") ?? | |
| 73 | recordString(sourceInfo, "shortDescription") ?? | |
| 74 | recordString(commandInterface, "shortDescription"); | |
| 75 | skills.push({ | |
| 76 | name, | |
| 77 | path, | |
| 78 | enabled: true, | |
| 79 | ...(description === undefined ? {} : { description }), | |
| 80 | ...(scope === undefined ? {} : { scope }), | |
| 81 | ...(displayName === undefined ? {} : { displayName }), | |
| 82 | ...(shortDescription === undefined ? {} : { shortDescription }), | |
| 83 | }); | |
| 84 | continue; | |
| 85 | } | |
| 86 | slashCommands.push({ | |
| 87 | name: commandName, | |
| 88 | ...(description === undefined ? {} : { description }), | |
| 89 | }); | |
| 90 | } | |
| 91 | return { slashCommands, skills }; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Pi expands skills only through leading `/skill:name` commands. T3 stores | |
| 96 | * skill chips as `$name`, so hoist every known `$skill` to that native | |
| 97 | * command position while preserving the rest of the user's prompt. | |
| 98 | */ | |
| 99 | export function expandPiSkillReference(text: string, skillNames: ReadonlySet<string>): string { | |
| 100 | const references = /(^|\s)\$([^\s]+)(?=\s|$)/g; | |
| 101 | const found: Array<{ name: string; start: number; end: number }> = []; | |
| 102 | for (const match of text.matchAll(references)) { | |
| 103 | const name = match[2]; | |
| 104 | if (name === undefined || !skillNames.has(name) || match.index === undefined) continue; | |
| 105 | const tokenStart = match.index + (match[1]?.length ?? 0); | |
| 106 | found.push({ name, start: tokenStart, end: tokenStart + name.length + 1 }); | |
| 107 | } | |
| 108 | if (found.length === 0) return text; | |
| 109 | ||
| 110 | const orderedNames: string[] = []; | |
| 111 | const seen = new Set<string>(); | |
| 112 | for (const token of found) { | |
| 113 | if (seen.has(token.name)) continue; | |
| 114 | seen.add(token.name); | |
| 115 | orderedNames.push(token.name); | |
| 116 | } | |
| 117 | ||
| 118 | let body = text; | |
| 119 | for (let index = found.length - 1; index >= 0; index -= 1) { | |
| 120 | const token = found[index]; | |
| 121 | if (token === undefined) continue; | |
| 122 | body = `${body.slice(0, token.start)}${body.slice(token.end)}`; | |
| 123 | } | |
| 124 | body = body.trim(); | |
| 125 | const prefix = orderedNames.map((name) => `/skill:${name}`).join(" "); | |
| 126 | return body.length === 0 ? prefix : `${prefix} ${body}`; | |
| 127 | } | |
| 128 | ||
| 129 | function recordField(input: unknown, key: string): unknown { | |
| 130 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 131 | } | |
| 132 | ||
| 133 | function recordString(input: unknown, key: string): string | undefined { | |
| 134 | const value = recordField(input, key); | |
| 135 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | |
| 136 | } |
apps/server/src/provider/PiProvider.test.ts deleted-80| ... | ... | @@ -1,80 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Sink from "effect/Sink"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 7 | ||
| 8 | import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; | |
| 9 | ||
| 10 | const encoder = new TextEncoder(); | |
| 11 | ||
| 12 | function processHandle(input: { | |
| 13 | readonly stdout?: string; | |
| 14 | readonly stderr?: string; | |
| 15 | readonly exitCode?: number; | |
| 16 | }) { | |
| 17 | const bytes = (value: string | undefined) => | |
| 18 | value === undefined || value.length === 0 | |
| 19 | ? Stream.empty | |
| 20 | : Stream.succeed(encoder.encode(value)); | |
| 21 | return ChildProcessSpawner.makeHandle({ | |
| 22 | pid: ChildProcessSpawner.ProcessId(900_000_001), | |
| 23 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | |
| 24 | isRunning: Effect.succeed(false), | |
| 25 | kill: () => Effect.void, | |
| 26 | unref: Effect.succeed(Effect.void), | |
| 27 | stdin: Sink.drain, | |
| 28 | stdout: bytes(input.stdout), | |
| 29 | stderr: bytes(input.stderr), | |
| 30 | all: Stream.empty, | |
| 31 | getInputFd: () => Sink.drain, | |
| 32 | getOutputFd: () => Stream.empty, | |
| 33 | }); | |
| 34 | } | |
| 35 | ||
| 36 | function piProbeSpawner(version: string) { | |
| 37 | return ChildProcessSpawner.make((command) => { | |
| 38 | const args = ChildProcess.isStandardCommand(command) ? command.args : []; | |
| 39 | return Effect.succeed( | |
| 40 | args.includes("--version") | |
| 41 | ? processHandle({ stdout: `pi ${version}\n` }) | |
| 42 | : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), | |
| 43 | ); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | const settings = { | |
| 48 | enabled: true, | |
| 49 | binaryPath: "pi", | |
| 50 | launchArgs: "", | |
| 51 | customModels: [], | |
| 52 | } as const; | |
| 53 | ||
| 54 | describe("PiProvider", () => { | |
| 55 | it.effect("requires the first published Pi version with entries and settlement hooks", () => | |
| 56 | Effect.gen(function* () { | |
| 57 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | |
| 58 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), | |
| 59 | ); | |
| 60 | assert.equal(snapshot.status, "error"); | |
| 61 | assert.equal(snapshot.version, "0.80.3"); | |
| 62 | assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); | |
| 63 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 64 | ); | |
| 65 | ||
| 66 | it.effect("keeps compatible Pi selectable when optional discovery fails", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | |
| 69 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), | |
| 70 | ); | |
| 71 | assert.equal(snapshot.status, "ready"); | |
| 72 | assert.equal(snapshot.auth.status, "unknown"); | |
| 73 | assert.deepEqual( | |
| 74 | snapshot.models.map((model) => model.slug), | |
| 75 | ["default"], | |
| 76 | ); | |
| 77 | assert.include(snapshot.message ?? "", "could not refresh its models and commands"); | |
| 78 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 79 | ); | |
| 80 | }); |
apps/server/src/provider/PiProvider.ts deleted-482| ... | ... | @@ -1,482 +0,0 @@ |
| 1 | /** | |
| 2 | * PiProvider — snapshot/probe layer for the Pi coding agent. | |
| 3 | * | |
| 4 | * Health is probed with `pi --version`. Models, the user's default model, and | |
| 5 | * the user's commands (extension slash commands, prompt templates, skills) | |
| 6 | * are discovered through a short-lived ephemeral RPC session | |
| 7 | * (`pi --mode rpc --no-session`), so everything the user configured in | |
| 8 | * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — | |
| 9 | * shows up in T3 without any hardcoded catalog. | |
| 10 | */ | |
| 11 | import { | |
| 12 | type CustomModelSetting, | |
| 13 | type PiSettings, | |
| 14 | type ServerProvider, | |
| 15 | type ServerProviderModel, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 19 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 20 | import * as DateTime from "effect/DateTime"; | |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | import * as Stream from "effect/Stream"; | |
| 23 | import * as Exit from "effect/Exit"; | |
| 24 | import * as Option from "effect/Option"; | |
| 25 | import * as Result from "effect/Result"; | |
| 26 | import { HttpClient } from "effect/http"; | |
| 27 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 28 | ||
| 29 | import { | |
| 30 | buildPiRpcLaunch, | |
| 31 | resolvePiLaunchArgs, | |
| 32 | } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; | |
| 33 | import { | |
| 34 | makePiRpcConnection, | |
| 35 | PiRpcError, | |
| 36 | PiRpcTimeoutError, | |
| 37 | piRecordField as recordField, | |
| 38 | piRecordString as recordString, | |
| 39 | } from "../orchestration-v2/Adapters/PiRpc.ts"; | |
| 40 | import { | |
| 41 | buildServerProvider, | |
| 42 | isCommandMissingCause, | |
| 43 | parseGenericCliVersion, | |
| 44 | providerModelsFromSettings, | |
| 45 | spawnAndCollect, | |
| 46 | type ServerProviderDraft, | |
| 47 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 48 | import { | |
| 49 | enrichProviderSnapshotWithVersionAdvisory, | |
| 50 | type ProviderMaintenanceCapabilities, | |
| 51 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 52 | import { | |
| 53 | EMPTY_PI_MODEL_CAPABILITIES, | |
| 54 | thinkingCapabilitiesForPiModel, | |
| 55 | } from "./piThinkingCapabilities.ts"; | |
| 56 | import { | |
| 57 | parsePiDiscoveredCommands, | |
| 58 | withPiBuiltinSlashCommands, | |
| 59 | type PiDiscoveredCommands, | |
| 60 | } from "./PiCommands.ts"; | |
| 61 | ||
| 62 | const PI_PRESENTATION = { | |
| 63 | displayName: "Pi", | |
| 64 | showInteractionModeToggle: false, | |
| 65 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], | |
| 66 | // The adapter reports context usage from Pi's streaming usage while a | |
| 67 | // turn runs, so clients can reserve the meter before the first settle. | |
| 68 | reportsContextWindow: true, | |
| 69 | requiresNewThreadForModelChange: false, | |
| 70 | } as const; | |
| 71 | ||
| 72 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | |
| 73 | const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; | |
| 74 | /** | |
| 75 | * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. | |
| 76 | * Version 0.80.5 was the first published package containing both hooks. T3 | |
| 77 | * needs them for rollback boundaries and reliable turn terminalization. | |
| 78 | */ | |
| 79 | export const MINIMUM_PI_VERSION = "0.80.5"; | |
| 80 | ||
| 81 | /** Deferring to the user's own settings.json default model. */ | |
| 82 | const PI_DEFAULT_MODEL: ServerProviderModel = { | |
| 83 | slug: "default", | |
| 84 | name: "Pi default", | |
| 85 | isCustom: false, | |
| 86 | capabilities: EMPTY_PI_MODEL_CAPABILITIES, | |
| 87 | }; | |
| 88 | ||
| 89 | interface PiDiscovery extends PiDiscoveredCommands { | |
| 90 | readonly models: ReadonlyArray<ServerProviderModel>; | |
| 91 | readonly authenticated: boolean; | |
| 92 | } | |
| 93 | ||
| 94 | function piModelsFromSettings( | |
| 95 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | |
| 96 | discovered: ReadonlyArray<ServerProviderModel> = [], | |
| 97 | ): ReadonlyArray<ServerProviderModel> { | |
| 98 | return providerModelsFromSettings( | |
| 99 | [PI_DEFAULT_MODEL, ...discovered], | |
| 100 | customModels ?? [], | |
| 101 | EMPTY_PI_MODEL_CAPABILITIES, | |
| 102 | ); | |
| 103 | } | |
| 104 | ||
| 105 | function parseDiscoveredModels( | |
| 106 | data: unknown, | |
| 107 | defaultThinkingLevel: unknown, | |
| 108 | ): ReadonlyArray<ServerProviderModel> { | |
| 109 | const models = recordField(data, "models"); | |
| 110 | if (!Array.isArray(models)) return []; | |
| 111 | const seen = new Set<string>(); | |
| 112 | const parsed: Array<ServerProviderModel> = []; | |
| 113 | for (const model of models) { | |
| 114 | const provider = recordString(model, "provider"); | |
| 115 | const id = recordString(model, "id"); | |
| 116 | if (provider === undefined || id === undefined) continue; | |
| 117 | const slug = `${provider}/${id}`; | |
| 118 | if (seen.has(slug)) continue; | |
| 119 | seen.add(slug); | |
| 120 | parsed.push({ | |
| 121 | slug, | |
| 122 | name: recordString(model, "name") ?? slug, | |
| 123 | subProvider: provider, | |
| 124 | isCustom: false, | |
| 125 | capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), | |
| 126 | }); | |
| 127 | } | |
| 128 | return parsed; | |
| 129 | } | |
| 130 | ||
| 131 | const makePiDiscoveryConnection = Effect.fnUntraced(function* ( | |
| 132 | piSettings: PiSettings, | |
| 133 | environment: NodeJS.ProcessEnv, | |
| 134 | launchArgs: ReadonlyArray<string>, | |
| 135 | cwd?: string, | |
| 136 | ) { | |
| 137 | const launch = buildPiRpcLaunch({ | |
| 138 | launchArgs, | |
| 139 | environment, | |
| 140 | mcpSession: undefined, | |
| 141 | extensionPath: undefined, | |
| 142 | ephemeral: true, | |
| 143 | }); | |
| 144 | const connection = yield* makePiRpcConnection({ | |
| 145 | command: piSettings.binaryPath || "pi", | |
| 146 | args: launch.args, | |
| 147 | cwd, | |
| 148 | env: launch.env, | |
| 149 | }); | |
| 150 | yield* Stream.fromQueue(connection.events).pipe( | |
| 151 | Stream.runDrain, | |
| 152 | Effect.ignore, | |
| 153 | Effect.forkScoped, | |
| 154 | ); | |
| 155 | return connection; | |
| 156 | }); | |
| 157 | ||
| 158 | const discoverPiViaRpc = ( | |
| 159 | piSettings: PiSettings, | |
| 160 | environment: NodeJS.ProcessEnv, | |
| 161 | launchArgs: ReadonlyArray<string>, | |
| 162 | cwd?: string, | |
| 163 | ) => | |
| 164 | Effect.gen(function* () { | |
| 165 | const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); | |
| 166 | const stateData = yield* connection.request({ type: "get_state" }); | |
| 167 | const modelsData = yield* connection.request({ type: "get_available_models" }); | |
| 168 | const commandsData = yield* connection | |
| 169 | .request({ type: "get_commands" }) | |
| 170 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 171 | const discoveredModels = parseDiscoveredModels( | |
| 172 | modelsData, | |
| 173 | recordString(stateData, "thinkingLevel"), | |
| 174 | ); | |
| 175 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | |
| 176 | return { | |
| 177 | models: discoveredModels, | |
| 178 | slashCommands: withPiBuiltinSlashCommands(slashCommands), | |
| 179 | skills, | |
| 180 | authenticated: discoveredModels.length > 0, | |
| 181 | } satisfies PiDiscovery; | |
| 182 | }).pipe(Effect.scoped); | |
| 183 | ||
| 184 | /** Probe the full command catalog Pi exposes in a workspace without changing machine health. */ | |
| 185 | export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( | |
| 186 | function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { | |
| 187 | const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 188 | if (!launchArgs.ok) { | |
| 189 | return yield* new PiRpcError({ operation: "launch", detail: launchArgs.message }); | |
| 190 | } | |
| 191 | const connection = yield* makePiDiscoveryConnection( | |
| 192 | piSettings, | |
| 193 | environment, | |
| 194 | launchArgs.args, | |
| 195 | cwd, | |
| 196 | ); | |
| 197 | // A failed read must not replace a previously usable workspace catalog with an empty one. | |
| 198 | const commandsData = yield* connection.request({ type: "get_commands" }); | |
| 199 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | |
| 200 | return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; | |
| 201 | }, | |
| 202 | Effect.scoped, | |
| 203 | Effect.timeoutOrElse({ | |
| 204 | duration: PI_RPC_DISCOVERY_TIMEOUT_MS, | |
| 205 | orElse: () => | |
| 206 | Effect.fail( | |
| 207 | new PiRpcTimeoutError({ operation: "discovery", timeoutMs: PI_RPC_DISCOVERY_TIMEOUT_MS }), | |
| 208 | ), | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => | |
| 213 | Effect.gen(function* () { | |
| 214 | const command = piSettings.binaryPath || "pi"; | |
| 215 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { | |
| 216 | env: environment, | |
| 217 | }); | |
| 218 | return yield* spawnAndCollect( | |
| 219 | command, | |
| 220 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 221 | env: environment, | |
| 222 | shell: spawnCommand.shell, | |
| 223 | }), | |
| 224 | ); | |
| 225 | }); | |
| 226 | ||
| 227 | export function buildInitialPiProviderSnapshot( | |
| 228 | piSettings: PiSettings, | |
| 229 | ): Effect.Effect<ServerProviderDraft> { | |
| 230 | return Effect.gen(function* () { | |
| 231 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 232 | const models = piModelsFromSettings(piSettings.customModels); | |
| 233 | if (!piSettings.enabled) { | |
| 234 | return buildServerProvider({ | |
| 235 | presentation: PI_PRESENTATION, | |
| 236 | enabled: false, | |
| 237 | checkedAt, | |
| 238 | models, | |
| 239 | probe: { | |
| 240 | installed: false, | |
| 241 | version: null, | |
| 242 | status: "warning", | |
| 243 | auth: { status: "unknown" }, | |
| 244 | message: "Pi is disabled in T3 Code settings.", | |
| 245 | }, | |
| 246 | }); | |
| 247 | } | |
| 248 | return buildServerProvider({ | |
| 249 | presentation: PI_PRESENTATION, | |
| 250 | enabled: true, | |
| 251 | checkedAt, | |
| 252 | models, | |
| 253 | probe: { | |
| 254 | installed: true, | |
| 255 | version: null, | |
| 256 | status: "warning", | |
| 257 | auth: { status: "unknown" }, | |
| 258 | message: "Checking Pi CLI availability...", | |
| 259 | }, | |
| 260 | }); | |
| 261 | }); | |
| 262 | } | |
| 263 | ||
| 264 | export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( | |
| 265 | piSettings: PiSettings, | |
| 266 | environment: NodeJS.ProcessEnv = process.env, | |
| 267 | cwd?: string, | |
| 268 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 269 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 270 | const fallbackModels = piModelsFromSettings(piSettings.customModels); | |
| 271 | ||
| 272 | if (!piSettings.enabled) { | |
| 273 | return buildServerProvider({ | |
| 274 | presentation: PI_PRESENTATION, | |
| 275 | enabled: false, | |
| 276 | checkedAt, | |
| 277 | models: fallbackModels, | |
| 278 | probe: { | |
| 279 | installed: false, | |
| 280 | version: null, | |
| 281 | status: "warning", | |
| 282 | auth: { status: "unknown" }, | |
| 283 | message: "Pi is disabled in T3 Code settings.", | |
| 284 | }, | |
| 285 | }); | |
| 286 | } | |
| 287 | ||
| 288 | const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( | |
| 289 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | |
| 290 | Effect.result, | |
| 291 | ); | |
| 292 | ||
| 293 | if (Result.isFailure(versionResult)) { | |
| 294 | const error = versionResult.failure; | |
| 295 | yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); | |
| 296 | return buildServerProvider({ | |
| 297 | presentation: PI_PRESENTATION, | |
| 298 | enabled: piSettings.enabled, | |
| 299 | checkedAt, | |
| 300 | models: fallbackModels, | |
| 301 | probe: { | |
| 302 | installed: !isCommandMissingCause(error), | |
| 303 | version: null, | |
| 304 | status: "error", | |
| 305 | auth: { status: "unknown" }, | |
| 306 | message: isCommandMissingCause(error) | |
| 307 | ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." | |
| 308 | : "Failed to execute Pi CLI health check.", | |
| 309 | }, | |
| 310 | }); | |
| 311 | } | |
| 312 | ||
| 313 | if (Option.isNone(versionResult.success)) { | |
| 314 | return buildServerProvider({ | |
| 315 | presentation: PI_PRESENTATION, | |
| 316 | enabled: piSettings.enabled, | |
| 317 | checkedAt, | |
| 318 | models: fallbackModels, | |
| 319 | probe: { | |
| 320 | installed: true, | |
| 321 | version: null, | |
| 322 | status: "error", | |
| 323 | auth: { status: "unknown" }, | |
| 324 | message: "Pi CLI is installed but timed out while running `pi --version`.", | |
| 325 | }, | |
| 326 | }); | |
| 327 | } | |
| 328 | ||
| 329 | const versionOutput = versionResult.success.value; | |
| 330 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | |
| 331 | if (versionOutput.code !== 0) { | |
| 332 | return buildServerProvider({ | |
| 333 | presentation: PI_PRESENTATION, | |
| 334 | enabled: piSettings.enabled, | |
| 335 | checkedAt, | |
| 336 | models: fallbackModels, | |
| 337 | probe: { | |
| 338 | installed: true, | |
| 339 | version, | |
| 340 | status: "error", | |
| 341 | auth: { status: "unknown" }, | |
| 342 | message: "Pi CLI is installed but failed to run.", | |
| 343 | }, | |
| 344 | }); | |
| 345 | } | |
| 346 | ||
| 347 | if (version === null) { | |
| 348 | return buildServerProvider({ | |
| 349 | presentation: PI_PRESENTATION, | |
| 350 | enabled: piSettings.enabled, | |
| 351 | checkedAt, | |
| 352 | models: fallbackModels, | |
| 353 | probe: { | |
| 354 | installed: true, | |
| 355 | version: null, | |
| 356 | status: "error", | |
| 357 | auth: { status: "unknown" }, | |
| 358 | message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, | |
| 359 | }, | |
| 360 | }); | |
| 361 | } | |
| 362 | ||
| 363 | if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { | |
| 364 | return buildServerProvider({ | |
| 365 | presentation: PI_PRESENTATION, | |
| 366 | enabled: piSettings.enabled, | |
| 367 | checkedAt, | |
| 368 | models: fallbackModels, | |
| 369 | probe: { | |
| 370 | installed: true, | |
| 371 | version, | |
| 372 | status: "error", | |
| 373 | auth: { status: "unknown" }, | |
| 374 | message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, | |
| 375 | }, | |
| 376 | }); | |
| 377 | } | |
| 378 | ||
| 379 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 380 | if (!resolvedLaunchArgs.ok) { | |
| 381 | return buildServerProvider({ | |
| 382 | presentation: PI_PRESENTATION, | |
| 383 | enabled: piSettings.enabled, | |
| 384 | checkedAt, | |
| 385 | models: fallbackModels, | |
| 386 | probe: { | |
| 387 | installed: true, | |
| 388 | version, | |
| 389 | status: "error", | |
| 390 | auth: { status: "unknown" }, | |
| 391 | message: resolvedLaunchArgs.message, | |
| 392 | }, | |
| 393 | }); | |
| 394 | } | |
| 395 | ||
| 396 | const discoveryExit = yield* discoverPiViaRpc( | |
| 397 | piSettings, | |
| 398 | environment, | |
| 399 | resolvedLaunchArgs.args, | |
| 400 | cwd, | |
| 401 | ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); | |
| 402 | if (Exit.isFailure(discoveryExit)) { | |
| 403 | yield* Effect.logWarning("Pi RPC discovery failed.", { | |
| 404 | errorTag: causeErrorTag(discoveryExit.cause), | |
| 405 | }); | |
| 406 | return buildServerProvider({ | |
| 407 | presentation: PI_PRESENTATION, | |
| 408 | enabled: piSettings.enabled, | |
| 409 | checkedAt, | |
| 410 | models: fallbackModels, | |
| 411 | probe: { | |
| 412 | installed: true, | |
| 413 | version, | |
| 414 | status: "ready", | |
| 415 | auth: { status: "unknown" }, | |
| 416 | message: | |
| 417 | "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", | |
| 418 | }, | |
| 419 | }); | |
| 420 | } | |
| 421 | if (Option.isNone(discoveryExit.value)) { | |
| 422 | return buildServerProvider({ | |
| 423 | presentation: PI_PRESENTATION, | |
| 424 | enabled: piSettings.enabled, | |
| 425 | checkedAt, | |
| 426 | models: fallbackModels, | |
| 427 | probe: { | |
| 428 | installed: true, | |
| 429 | version, | |
| 430 | status: "ready", | |
| 431 | auth: { status: "unknown" }, | |
| 432 | message: | |
| 433 | "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", | |
| 434 | }, | |
| 435 | }); | |
| 436 | } | |
| 437 | ||
| 438 | const discovery = discoveryExit.value.value; | |
| 439 | const models = piModelsFromSettings(piSettings.customModels, discovery.models); | |
| 440 | return buildServerProvider({ | |
| 441 | presentation: PI_PRESENTATION, | |
| 442 | enabled: piSettings.enabled, | |
| 443 | checkedAt, | |
| 444 | models, | |
| 445 | slashCommands: discovery.slashCommands, | |
| 446 | skills: discovery.skills, | |
| 447 | probe: { | |
| 448 | installed: true, | |
| 449 | version, | |
| 450 | status: discovery.authenticated ? "ready" : "warning", | |
| 451 | auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, | |
| 452 | ...(discovery.authenticated | |
| 453 | ? {} | |
| 454 | : { | |
| 455 | message: | |
| 456 | "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", | |
| 457 | }), | |
| 458 | }, | |
| 459 | }); | |
| 460 | }); | |
| 461 | ||
| 462 | export const enrichPiSnapshot = (input: { | |
| 463 | readonly snapshot: ServerProvider; | |
| 464 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | |
| 465 | readonly enableProviderUpdateChecks?: boolean; | |
| 466 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | |
| 467 | readonly httpClient: HttpClient.HttpClient; | |
| 468 | }): Effect.Effect<void> => { | |
| 469 | const { snapshot, publishSnapshot } = input; | |
| 470 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | |
| 471 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | |
| 472 | }).pipe( | |
| 473 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | |
| 474 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 475 | Effect.catchCause((cause) => | |
| 476 | Effect.logWarning("Pi version advisory enrichment failed", { | |
| 477 | errorTag: causeErrorTag(cause), | |
| 478 | }), | |
| 479 | ), | |
| 480 | Effect.asVoid, | |
| 481 | ); | |
| 482 | }; |
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+1-1| ... | ... | @@ -4,7 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t |
| 4 | 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 5 | 5 | import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; |
| 6 | 6 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 7 | import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; | |
| 7 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 8 | 8 | |
| 9 | 9 | export type ProviderOrchestrationAdapterInfrastructure = |
| 10 | 10 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner |
apps/server/src/provider/builtInDrivers.ts+1-1| ... | ... | @@ -28,7 +28,7 @@ import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 28 | 28 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 29 | 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 30 | 30 | import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; |
| 31 | import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; | |
| 31 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; | |
| 32 | 32 | import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; |
| 33 | 33 | |
| 34 | 34 | /** |
apps/server/src/provider/makeManagedServerProvider.test.ts deleted-683| ... | ... | @@ -1,683 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, it, assert } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | DEFAULT_SERVER_SETTINGS, | |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | type ServerProvider, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 10 | import * as DateTime from "effect/DateTime"; | |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 12 | import * as Duration from "effect/Duration"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Fiber from "effect/Fiber"; | |
| 15 | import * as Layer from "effect/Layer"; | |
| 16 | import * as PubSub from "effect/PubSub"; | |
| 17 | import * as Ref from "effect/Ref"; | |
| 18 | import * as Stream from "effect/Stream"; | |
| 19 | import { TestClock } from "effect/testing"; | |
| 20 | ||
| 21 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 22 | import * as ServerConfig from "../config.ts"; | |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | |
| 24 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; | |
| 25 | import * as ProviderHostLive from "./ProviderHostLive.ts"; | |
| 26 | ||
| 27 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | |
| 28 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | |
| 29 | const fastModeCapabilities = createModelCapabilities({ | |
| 30 | optionDescriptors: [ | |
| 31 | { | |
| 32 | id: "fastMode", | |
| 33 | label: "Fast Mode", | |
| 34 | type: "boolean", | |
| 35 | }, | |
| 36 | ], | |
| 37 | }); | |
| 38 | ||
| 39 | interface TestSettings { | |
| 40 | readonly enabled: boolean; | |
| 41 | } | |
| 42 | ||
| 43 | const maintenanceCapabilities = { | |
| 44 | provider: ProviderDriverKind.make("codex"), | |
| 45 | packageName: "@openai/codex", | |
| 46 | update: { | |
| 47 | command: "npm install -g @openai/codex@latest", | |
| 48 | ||
| 49 | executable: "npm", | |
| 50 | ||
| 51 | args: ["install", "-g", "@openai/codex@latest"], | |
| 52 | ||
| 53 | lockKey: "npm-global", | |
| 54 | }, | |
| 55 | } as const; | |
| 56 | ||
| 57 | const initialSnapshot: ServerProvider = { | |
| 58 | instanceId: ProviderInstanceId.make("codex"), | |
| 59 | driver: ProviderDriverKind.make("codex"), | |
| 60 | enabled: true, | |
| 61 | installed: true, | |
| 62 | version: null, | |
| 63 | status: "warning", | |
| 64 | auth: { status: "unknown" }, | |
| 65 | checkedAt: "2026-04-10T00:00:00.000Z", | |
| 66 | message: "Checking provider availability...", | |
| 67 | models: [], | |
| 68 | slashCommands: [], | |
| 69 | skills: [], | |
| 70 | }; | |
| 71 | ||
| 72 | const refreshedSnapshot: ServerProvider = { | |
| 73 | instanceId: ProviderInstanceId.make("codex"), | |
| 74 | driver: ProviderDriverKind.make("codex"), | |
| 75 | enabled: true, | |
| 76 | installed: true, | |
| 77 | version: "1.0.0", | |
| 78 | status: "ready", | |
| 79 | auth: { status: "authenticated" }, | |
| 80 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 81 | models: [], | |
| 82 | slashCommands: [], | |
| 83 | skills: [], | |
| 84 | }; | |
| 85 | ||
| 86 | const enrichedSnapshot: ServerProvider = { | |
| 87 | ...refreshedSnapshot, | |
| 88 | checkedAt: "2026-04-10T00:00:02.000Z", | |
| 89 | models: [ | |
| 90 | { | |
| 91 | slug: "composer-2", | |
| 92 | name: "Composer 2", | |
| 93 | isCustom: false, | |
| 94 | capabilities: fastModeCapabilities, | |
| 95 | }, | |
| 96 | ], | |
| 97 | }; | |
| 98 | ||
| 99 | const refreshedSnapshotSecond: ServerProvider = { | |
| 100 | ...refreshedSnapshot, | |
| 101 | checkedAt: "2026-04-10T00:00:03.000Z", | |
| 102 | message: "Refreshed provider availability again.", | |
| 103 | }; | |
| 104 | ||
| 105 | function layerBackgroundPolicy(shouldRunScopeWork: boolean) { | |
| 106 | return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 107 | reportClientActivity: () => Effect.void, | |
| 108 | removeRpcClient: () => Effect.void, | |
| 109 | reportHostPowerState: () => Effect.void, | |
| 110 | snapshot: Effect.succeed({ | |
| 111 | hostPower: { | |
| 112 | source: "unknown", | |
| 113 | idle: "unknown", | |
| 114 | idleSeconds: null, | |
| 115 | locked: "unknown", | |
| 116 | suspended: false, | |
| 117 | onBattery: "unknown", | |
| 118 | lowPowerMode: "unknown", | |
| 119 | thermalState: "unknown", | |
| 120 | stale: true, | |
| 121 | updatedAt: TEST_EPOCH, | |
| 122 | }, | |
| 123 | leases: [], | |
| 124 | activeForegroundLeaseCount: 0, | |
| 125 | activeScopeKeys: [], | |
| 126 | shouldRunOpportunisticWork: true, | |
| 127 | updatedAt: TEST_EPOCH, | |
| 128 | }), | |
| 129 | streamChanges: Stream.empty, | |
| 130 | hasDemand: () => Effect.succeed(shouldRunScopeWork), | |
| 131 | shouldRunScopeWork: () => Effect.succeed(shouldRunScopeWork), | |
| 132 | shouldRunOpportunisticWork: Effect.succeed(shouldRunScopeWork), | |
| 133 | }); | |
| 134 | } | |
| 135 | ||
| 136 | const layerBackgroundPolicyAlwaysRun = layerBackgroundPolicy(true); | |
| 137 | const layerBackgroundPolicyNeverRun = layerBackgroundPolicy(false); | |
| 138 | const layerServerSettingsTest = ServerSettings.layerTest(); | |
| 139 | const layerServerConfigTest = ServerConfig.layerTest(process.cwd(), { | |
| 140 | prefix: "t3-managed-provider-test-", | |
| 141 | }).pipe(Layer.provide(NodeServices.layer)); | |
| 142 | ||
| 143 | function layerProviderHost<BE, BR, SE, SR>( | |
| 144 | layerBackground: Layer.Layer<BackgroundPolicy.BackgroundPolicy, BE, BR>, | |
| 145 | layerSettings: Layer.Layer<ServerSettings.ServerSettingsService, SE, SR>, | |
| 146 | ) { | |
| 147 | return ProviderHostLive.layer.pipe( | |
| 148 | Layer.provideMerge(layerBackground), | |
| 149 | Layer.provideMerge(layerSettings), | |
| 150 | Layer.provide(layerServerConfigTest), | |
| 151 | ); | |
| 152 | } | |
| 153 | ||
| 154 | const layerAlwaysRunTest = layerProviderHost( | |
| 155 | layerBackgroundPolicyAlwaysRun, | |
| 156 | layerServerSettingsTest, | |
| 157 | ); | |
| 158 | const layerNeverRunTest = layerProviderHost(layerBackgroundPolicyNeverRun, layerServerSettingsTest); | |
| 159 | ||
| 160 | const enrichedSnapshotSecond: ServerProvider = { | |
| 161 | ...refreshedSnapshotSecond, | |
| 162 | checkedAt: "2026-04-10T00:00:04.000Z", | |
| 163 | models: [ | |
| 164 | { | |
| 165 | slug: "gpt-5.4", | |
| 166 | name: "GPT-5.4", | |
| 167 | isCustom: false, | |
| 168 | capabilities: emptyCapabilities, | |
| 169 | }, | |
| 170 | ], | |
| 171 | }; | |
| 172 | ||
| 173 | describe("makeManagedServerProvider", () => { | |
| 174 | it.effect( | |
| 175 | "runs the initial provider check in the background and streams the refreshed snapshot", | |
| 176 | () => | |
| 177 | Effect.scoped( | |
| 178 | Effect.gen(function* () { | |
| 179 | const checkCalls = yield* Ref.make(0); | |
| 180 | const releaseCheck = yield* Deferred.make<void>(); | |
| 181 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 182 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 183 | getSettings: Effect.succeed({ enabled: true }), | |
| 184 | streamSettings: Stream.empty, | |
| 185 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 186 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 187 | checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( | |
| 188 | Effect.flatMap(() => Deferred.await(releaseCheck)), | |
| 189 | Effect.as(refreshedSnapshot), | |
| 190 | ), | |
| 191 | refreshInterval: "1 hour", | |
| 192 | }); | |
| 193 | ||
| 194 | const initial = yield* provider.getSnapshot; | |
| 195 | assert.deepStrictEqual(initial, initialSnapshot); | |
| 196 | ||
| 197 | const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 198 | Stream.runCollect, | |
| 199 | Effect.forkChild, | |
| 200 | ); | |
| 201 | yield* Effect.yieldNow; | |
| 202 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 203 | ||
| 204 | yield* Deferred.succeed(releaseCheck, undefined); | |
| 205 | ||
| 206 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 207 | const latest = yield* provider.getSnapshot; | |
| 208 | ||
| 209 | assert.deepStrictEqual(updates, [refreshedSnapshot]); | |
| 210 | assert.deepStrictEqual(latest, refreshedSnapshot); | |
| 211 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 212 | }), | |
| 213 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 214 | ); | |
| 215 | ||
| 216 | it.effect("skips periodic provider refreshes without foreground provider-status demand", () => | |
| 217 | Effect.scoped( | |
| 218 | Effect.gen(function* () { | |
| 219 | const checkCalls = yield* Ref.make(0); | |
| 220 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 221 | yield* makeManagedServerProvider<TestSettings>({ | |
| 222 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 223 | getSettings: Effect.succeed({ enabled: true }), | |
| 224 | streamSettings: Stream.empty, | |
| 225 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 226 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 227 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 228 | Effect.tap((count) => | |
| 229 | count === 1 | |
| 230 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 231 | : Effect.void, | |
| 232 | ), | |
| 233 | Effect.as(refreshedSnapshot), | |
| 234 | ), | |
| 235 | refreshInterval: "1 second", | |
| 236 | }); | |
| 237 | ||
| 238 | yield* Deferred.await(initialCheckDone); | |
| 239 | yield* TestClock.adjust("1 second"); | |
| 240 | yield* Effect.yieldNow; | |
| 241 | ||
| 242 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 243 | }), | |
| 244 | ).pipe(Effect.provide(Layer.mergeAll(layerNeverRunTest, TestClock.layer()))), | |
| 245 | ); | |
| 246 | ||
| 247 | it.effect("disables periodic provider refreshes when the explicit interval is zero", () => | |
| 248 | Effect.scoped( | |
| 249 | Effect.gen(function* () { | |
| 250 | const checkCalls = yield* Ref.make(0); | |
| 251 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 252 | yield* makeManagedServerProvider<TestSettings>({ | |
| 253 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 254 | getSettings: Effect.succeed({ enabled: true }), | |
| 255 | streamSettings: Stream.empty, | |
| 256 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 257 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 258 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 259 | Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), | |
| 260 | Effect.as(refreshedSnapshot), | |
| 261 | ), | |
| 262 | refreshInterval: 0, | |
| 263 | }); | |
| 264 | ||
| 265 | yield* Deferred.await(initialCheckDone); | |
| 266 | yield* TestClock.adjust("5 minutes"); | |
| 267 | yield* Effect.yieldNow; | |
| 268 | ||
| 269 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 270 | }), | |
| 271 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 272 | ); | |
| 273 | ||
| 274 | it.effect("keeps manual refresh when interval refresh is disabled", () => | |
| 275 | Effect.scoped( | |
| 276 | Effect.gen(function* () { | |
| 277 | const checkCalls = yield* Ref.make(0); | |
| 278 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 279 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 280 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 281 | getSettings: Effect.succeed({ enabled: true }), | |
| 282 | streamSettings: Stream.empty, | |
| 283 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 284 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 285 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 286 | Effect.tap((count) => | |
| 287 | count === 1 | |
| 288 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 289 | : Effect.void, | |
| 290 | ), | |
| 291 | Effect.as(refreshedSnapshot), | |
| 292 | ), | |
| 293 | refreshInterval: "1 second", | |
| 294 | refreshOnInterval: false, | |
| 295 | }); | |
| 296 | ||
| 297 | yield* Deferred.await(initialCheckDone); | |
| 298 | yield* TestClock.adjust("5 minutes"); | |
| 299 | yield* Effect.yieldNow; | |
| 300 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 301 | ||
| 302 | yield* provider.refresh; | |
| 303 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 304 | }), | |
| 305 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 306 | ); | |
| 307 | ||
| 308 | it.effect("wakes a sleeping provider refresh loop when its interval changes", () => | |
| 309 | Effect.scoped( | |
| 310 | Effect.gen(function* () { | |
| 311 | const initialServerSettings = { | |
| 312 | ...DEFAULT_SERVER_SETTINGS, | |
| 313 | providerHealthRefreshInterval: Duration.hours(1), | |
| 314 | }; | |
| 315 | const serverSettingsRef = yield* Ref.make(initialServerSettings); | |
| 316 | const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>(); | |
| 317 | const layerServerSettings = Layer.succeed( | |
| 318 | ServerSettings.ServerSettingsService, | |
| 319 | ServerSettings.ServerSettingsService.of({ | |
| 320 | start: Effect.void, | |
| 321 | ready: Effect.void, | |
| 322 | getSettings: Ref.get(serverSettingsRef), | |
| 323 | updateSettings: () => Effect.die(new Error("unused in this test")), | |
| 324 | updateProviderInstance: () => Effect.die(new Error("unused in this test")), | |
| 325 | withSettingsSnapshot: (use) => Ref.get(serverSettingsRef).pipe(Effect.flatMap(use)), | |
| 326 | streamChanges: Stream.empty, | |
| 327 | subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( | |
| 328 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 329 | ), | |
| 330 | }), | |
| 331 | ); | |
| 332 | const checkCalls = yield* Ref.make(0); | |
| 333 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 334 | const periodicCheckDone = yield* Deferred.make<void>(); | |
| 335 | ||
| 336 | yield* makeManagedServerProvider<TestSettings>({ | |
| 337 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 338 | getSettings: Effect.succeed({ enabled: true }), | |
| 339 | streamSettings: Stream.empty, | |
| 340 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 341 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 342 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 343 | Effect.tap((count) => | |
| 344 | count === 1 | |
| 345 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 346 | : Deferred.succeed(periodicCheckDone, undefined).pipe(Effect.ignore), | |
| 347 | ), | |
| 348 | Effect.as(refreshedSnapshot), | |
| 349 | ), | |
| 350 | }).pipe( | |
| 351 | Effect.provide(layerProviderHost(layerBackgroundPolicyAlwaysRun, layerServerSettings)), | |
| 352 | ); | |
| 353 | ||
| 354 | yield* Deferred.await(initialCheckDone); | |
| 355 | const nextServerSettings = { | |
| 356 | ...initialServerSettings, | |
| 357 | providerHealthRefreshInterval: Duration.seconds(1), | |
| 358 | }; | |
| 359 | yield* Ref.set(serverSettingsRef, nextServerSettings); | |
| 360 | yield* PubSub.publish(serverSettingsChanges, nextServerSettings); | |
| 361 | yield* Effect.yieldNow; | |
| 362 | ||
| 363 | yield* TestClock.adjust("999 millis"); | |
| 364 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 365 | yield* TestClock.adjust("1 millis"); | |
| 366 | yield* Deferred.await(periodicCheckDone); | |
| 367 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 368 | }), | |
| 369 | ).pipe(Effect.provide(TestClock.layer())), | |
| 370 | ); | |
| 371 | ||
| 372 | it.effect("reruns the provider check when streamed settings change", () => | |
| 373 | Effect.scoped( | |
| 374 | Effect.gen(function* () { | |
| 375 | const settingsRef = yield* Ref.make<TestSettings>({ enabled: true }); | |
| 376 | const settingsChanges = yield* PubSub.unbounded<TestSettings>(); | |
| 377 | const checkCalls = yield* Ref.make(0); | |
| 378 | const releaseInitialCheck = yield* Deferred.make<void>(); | |
| 379 | const releaseSettingsCheck = yield* Deferred.make<void>(); | |
| 380 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 381 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 382 | getSettings: Ref.get(settingsRef), | |
| 383 | streamSettings: Stream.fromPubSub(settingsChanges), | |
| 384 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 385 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 386 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 387 | Effect.flatMap((count) => | |
| 388 | count === 1 | |
| 389 | ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) | |
| 390 | : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), | |
| 391 | ), | |
| 392 | ), | |
| 393 | refreshInterval: "1 hour", | |
| 394 | }); | |
| 395 | ||
| 396 | const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( | |
| 397 | Stream.runCollect, | |
| 398 | Effect.forkChild, | |
| 399 | ); | |
| 400 | yield* Effect.yieldNow; | |
| 401 | ||
| 402 | yield* Deferred.succeed(releaseInitialCheck, undefined); | |
| 403 | yield* Ref.set(settingsRef, { enabled: false }); | |
| 404 | yield* PubSub.publish(settingsChanges, { enabled: false }); | |
| 405 | yield* Deferred.succeed(releaseSettingsCheck, undefined); | |
| 406 | ||
| 407 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 408 | const latest = yield* provider.getSnapshot; | |
| 409 | ||
| 410 | assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]); | |
| 411 | assert.deepStrictEqual(latest, refreshedSnapshotSecond); | |
| 412 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 413 | }), | |
| 414 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 415 | ); | |
| 416 | ||
| 417 | it.effect("can update settings and disable periodic checks without probing again", () => | |
| 418 | Effect.scoped( | |
| 419 | Effect.gen(function* () { | |
| 420 | const settingsChanges = yield* PubSub.unbounded<TestSettings>(); | |
| 421 | const checkCalls = yield* Ref.make(0); | |
| 422 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 423 | const enrichmentCalls = yield* Ref.make(0); | |
| 424 | yield* makeManagedServerProvider<TestSettings>({ | |
| 425 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 426 | getSettings: Effect.succeed({ enabled: true }), | |
| 427 | streamSettings: Stream.fromPubSub(settingsChanges), | |
| 428 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 429 | checkProviderOnSettingsChange: () => false, | |
| 430 | refreshOnInterval: false, | |
| 431 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 432 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 433 | Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), | |
| 434 | Effect.as(refreshedSnapshot), | |
| 435 | ), | |
| 436 | enrichSnapshot: () => Ref.update(enrichmentCalls, (count) => count + 1), | |
| 437 | refreshInterval: "1 second", | |
| 438 | }); | |
| 439 | ||
| 440 | yield* Deferred.await(initialCheckDone); | |
| 441 | yield* PubSub.publish(settingsChanges, { enabled: false }); | |
| 442 | yield* Effect.yieldNow; | |
| 443 | yield* TestClock.adjust("1 second"); | |
| 444 | yield* Effect.yieldNow; | |
| 445 | ||
| 446 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 447 | assert.strictEqual(yield* Ref.get(enrichmentCalls), 2); | |
| 448 | }), | |
| 449 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 450 | ); | |
| 451 | ||
| 452 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => | |
| 453 | Effect.scoped( | |
| 454 | Effect.gen(function* () { | |
| 455 | const releaseEnrichment = yield* Deferred.make<void>(); | |
| 456 | const releaseCheck = yield* Deferred.make<void>(); | |
| 457 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 458 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 459 | getSettings: Effect.succeed({ enabled: true }), | |
| 460 | streamSettings: Stream.empty, | |
| 461 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 462 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 463 | checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), | |
| 464 | enrichSnapshot: ({ publishSnapshot }) => | |
| 465 | Deferred.await(releaseEnrichment).pipe( | |
| 466 | Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), | |
| 467 | ), | |
| 468 | refreshInterval: "1 hour", | |
| 469 | }); | |
| 470 | ||
| 471 | const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( | |
| 472 | Stream.runCollect, | |
| 473 | Effect.forkChild, | |
| 474 | ); | |
| 475 | yield* Effect.yieldNow; | |
| 476 | ||
| 477 | yield* Deferred.succeed(releaseCheck, undefined); | |
| 478 | ||
| 479 | yield* Deferred.succeed(releaseEnrichment, undefined); | |
| 480 | ||
| 481 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 482 | const latest = yield* provider.getSnapshot; | |
| 483 | ||
| 484 | assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); | |
| 485 | assert.deepStrictEqual(latest, enrichedSnapshot); | |
| 486 | }), | |
| 487 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 488 | ); | |
| 489 | ||
| 490 | it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => | |
| 491 | Effect.scoped( | |
| 492 | Effect.gen(function* () { | |
| 493 | const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect<void>> = []; | |
| 494 | const refreshCount = yield* Ref.make(0); | |
| 495 | const firstCallbackReady = yield* Deferred.make<void>(); | |
| 496 | const secondCallbackReady = yield* Deferred.make<void>(); | |
| 497 | const allowFirstRefresh = yield* Deferred.make<void>(); | |
| 498 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 499 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 500 | getSettings: Effect.succeed({ enabled: true }), | |
| 501 | streamSettings: Stream.empty, | |
| 502 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 503 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 504 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | |
| 505 | Effect.flatMap((count) => | |
| 506 | count === 1 | |
| 507 | ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) | |
| 508 | : Effect.succeed(refreshedSnapshotSecond), | |
| 509 | ), | |
| 510 | ), | |
| 511 | enrichSnapshot: ({ publishSnapshot }) => | |
| 512 | Effect.gen(function* () { | |
| 513 | publishCallbacks.push(publishSnapshot); | |
| 514 | if (publishCallbacks.length === 1) { | |
| 515 | yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore); | |
| 516 | } else if (publishCallbacks.length === 2) { | |
| 517 | yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore); | |
| 518 | } | |
| 519 | }), | |
| 520 | refreshInterval: "1 hour", | |
| 521 | }); | |
| 522 | ||
| 523 | const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe( | |
| 524 | Stream.runCollect, | |
| 525 | Effect.forkChild, | |
| 526 | ); | |
| 527 | yield* Effect.yieldNow; | |
| 528 | ||
| 529 | yield* Deferred.succeed(allowFirstRefresh, undefined); | |
| 530 | yield* Deferred.await(firstCallbackReady); | |
| 531 | ||
| 532 | yield* provider.refresh; | |
| 533 | yield* Deferred.await(secondCallbackReady); | |
| 534 | ||
| 535 | yield* publishCallbacks[0]!(enrichedSnapshot); | |
| 536 | yield* publishCallbacks[1]!(enrichedSnapshotSecond); | |
| 537 | ||
| 538 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 539 | const latest = yield* provider.getSnapshot; | |
| 540 | ||
| 541 | assert.deepStrictEqual(updates, [ | |
| 542 | refreshedSnapshot, | |
| 543 | refreshedSnapshotSecond, | |
| 544 | enrichedSnapshotSecond, | |
| 545 | ]); | |
| 546 | assert.deepStrictEqual(latest, enrichedSnapshotSecond); | |
| 547 | }), | |
| 548 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 549 | ); | |
| 550 | ||
| 551 | it.effect("applies runtime usage updates onto the published snapshot", () => | |
| 552 | Effect.scoped( | |
| 553 | Effect.gen(function* () { | |
| 554 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 555 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 556 | getSettings: Effect.succeed({ enabled: true }), | |
| 557 | streamSettings: Stream.empty, | |
| 558 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 559 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 560 | checkProvider: Effect.succeed({ | |
| 561 | ...refreshedSnapshot, | |
| 562 | usageLimits: { | |
| 563 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 564 | windows: [ | |
| 565 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 566 | { | |
| 567 | id: "seven_day", | |
| 568 | kind: "weekly", | |
| 569 | label: "Weekly", | |
| 570 | usedPercent: 20, | |
| 571 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 572 | }, | |
| 573 | ], | |
| 574 | }, | |
| 575 | } satisfies ServerProvider), | |
| 576 | refreshInterval: "1 hour", | |
| 577 | }); | |
| 578 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 579 | ||
| 580 | const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 581 | Stream.runCollect, | |
| 582 | Effect.forkChild, | |
| 583 | ); | |
| 584 | yield* Effect.yieldNow; | |
| 585 | ||
| 586 | // Percent-only weekly update: keeps the probe's reset time. | |
| 587 | yield* provider.applyUsageLimits({ | |
| 588 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 589 | windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], | |
| 590 | }); | |
| 591 | // No windows: nothing to merge, nothing published. | |
| 592 | yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); | |
| 593 | ||
| 594 | const [update] = Array.from(yield* Fiber.join(updatesFiber)); | |
| 595 | assert.deepStrictEqual(update?.usageLimits, { | |
| 596 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 597 | windows: [ | |
| 598 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 599 | { | |
| 600 | id: "seven_day", | |
| 601 | kind: "weekly", | |
| 602 | label: "Weekly", | |
| 603 | usedPercent: 25, | |
| 604 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 605 | }, | |
| 606 | ], | |
| 607 | }); | |
| 608 | assert.deepStrictEqual(yield* provider.getSnapshot, update); | |
| 609 | }), | |
| 610 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 611 | ); | |
| 612 | ||
| 613 | it.effect("keeps live usage windows across a failed probe and a stale enrichment", () => | |
| 614 | Effect.scoped( | |
| 615 | Effect.gen(function* () { | |
| 616 | const releaseEnrichment = yield* Deferred.make<void>(); | |
| 617 | const refreshCount = yield* Ref.make(0); | |
| 618 | const probedLimits = { | |
| 619 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 620 | windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }], | |
| 621 | } as const; | |
| 622 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 623 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 624 | getSettings: Effect.succeed({ enabled: true }), | |
| 625 | streamSettings: Stream.empty, | |
| 626 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 627 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 628 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | |
| 629 | Effect.map((count) => | |
| 630 | count === 1 | |
| 631 | ? { ...refreshedSnapshot, usageLimits: probedLimits } | |
| 632 | : { | |
| 633 | ...refreshedSnapshotSecond, | |
| 634 | usageLimits: { | |
| 635 | checkedAt: "2026-04-10T00:00:03.000Z", | |
| 636 | windows: [], | |
| 637 | unavailable: { reason: "probeFailed" }, | |
| 638 | }, | |
| 639 | }, | |
| 640 | ), | |
| 641 | ), | |
| 642 | enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 643 | Deferred.await(releaseEnrichment).pipe( | |
| 644 | Effect.flatMap(() => | |
| 645 | publishSnapshot({ | |
| 646 | ...enrichedSnapshot, | |
| 647 | ...snapshot, | |
| 648 | models: enrichedSnapshot.models, | |
| 649 | }), | |
| 650 | ), | |
| 651 | ), | |
| 652 | refreshInterval: "1 hour", | |
| 653 | }); | |
| 654 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 655 | ||
| 656 | const liveWindow = { | |
| 657 | id: "primary", | |
| 658 | kind: "session", | |
| 659 | label: "Session", | |
| 660 | usedPercent: 60, | |
| 661 | } as const; | |
| 662 | yield* provider.applyUsageLimits({ | |
| 663 | checkedAt: "2026-04-10T00:00:02.000Z", | |
| 664 | windows: [liveWindow], | |
| 665 | }); | |
| 666 | ||
| 667 | // Enrichment computed from the pre-update snapshot lands afterwards. | |
| 668 | yield* Deferred.succeed(releaseEnrichment, undefined); | |
| 669 | const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 670 | Stream.runCollect, | |
| 671 | Effect.map((chunk) => Array.from(chunk)[0]!), | |
| 672 | ); | |
| 673 | assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); | |
| 674 | assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); | |
| 675 | ||
| 676 | // A probe that could not read usage keeps the last good windows. | |
| 677 | const refreshed = yield* provider.refresh; | |
| 678 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); | |
| 679 | assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); | |
| 680 | }), | |
| 681 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 682 | ); | |
| 683 | }); |
apps/server/src/provider/makeManagedServerProvider.ts deleted-292| ... | ... | @@ -1,292 +0,0 @@ |
| 1 | import { | |
| 2 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 3 | type ServerProvider, | |
| 4 | ServerSettingsError, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Equal from "effect/Equal"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as PubSub from "effect/PubSub"; | |
| 12 | import * as Queue from "effect/Queue"; | |
| 13 | import * as Ref from "effect/Ref"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | ||
| 18 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 19 | import { | |
| 20 | applyUsageLimitsUpdate, | |
| 21 | resolveUsageLimitsAfterProbe, | |
| 22 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 23 | import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; | |
| 24 | ||
| 25 | interface ProviderSnapshotState { | |
| 26 | readonly snapshot: ServerProvider; | |
| 27 | readonly enrichmentGeneration: number; | |
| 28 | } | |
| 29 | ||
| 30 | function withUsageLimits( | |
| 31 | snapshot: ServerProvider, | |
| 32 | usageLimits: ServerProvider["usageLimits"], | |
| 33 | ): ServerProvider { | |
| 34 | if (snapshot.usageLimits === usageLimits) { | |
| 35 | return snapshot; | |
| 36 | } | |
| 37 | const { usageLimits: _previous, ...rest } = snapshot; | |
| 38 | return usageLimits ? { ...rest, usageLimits } : rest; | |
| 39 | } | |
| 40 | ||
| 41 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < | |
| 42 | Settings, | |
| 43 | >(input: { | |
| 44 | readonly resolveMaintenance: ServerProviderShape["resolveMaintenance"]; | |
| 45 | readonly getSettings: Effect.Effect<Settings, ServerSettingsError>; | |
| 46 | readonly streamSettings: Stream.Stream<Settings>; | |
| 47 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; | |
| 48 | readonly initialSnapshot: (settings: Settings) => Effect.Effect<ServerProvider>; | |
| 49 | readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>; | |
| 50 | readonly enrichSnapshot?: (input: { | |
| 51 | readonly settings: Settings; | |
| 52 | readonly snapshot: ServerProvider; | |
| 53 | readonly getSnapshot: Effect.Effect<ServerProvider>; | |
| 54 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | |
| 55 | }) => Effect.Effect<void>; | |
| 56 | readonly refreshInterval?: Duration.Input; | |
| 57 | readonly refreshOnInterval?: boolean; | |
| 58 | readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; | |
| 59 | }): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope | ProviderHost> { | |
| 60 | const host = yield* ProviderHost; | |
| 61 | const refreshSemaphore = yield* Semaphore.make(1); | |
| 62 | const changesPubSub = yield* Effect.acquireRelease( | |
| 63 | PubSub.unbounded<ServerProvider>(), | |
| 64 | PubSub.shutdown, | |
| 65 | ); | |
| 66 | const initialSettings = yield* input.getSettings; | |
| 67 | const initialSnapshot = yield* input.initialSnapshot(initialSettings); | |
| 68 | const snapshotStateRef = yield* Ref.make<ProviderSnapshotState>({ | |
| 69 | snapshot: initialSnapshot, | |
| 70 | enrichmentGeneration: 0, | |
| 71 | }); | |
| 72 | const settingsRef = yield* Ref.make(initialSettings); | |
| 73 | const enrichmentFiberRef = yield* Ref.make<Fiber.Fiber<void, unknown> | null>(null); | |
| 74 | const scope = yield* Effect.scope; | |
| 75 | ||
| 76 | const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( | |
| 77 | generation: number, | |
| 78 | nextSnapshot: ServerProvider, | |
| 79 | ) { | |
| 80 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 81 | if (state.enrichmentGeneration !== generation) { | |
| 82 | return [null, state] as const; | |
| 83 | } | |
| 84 | // Enrichment derives from the snapshot it was handed; a runtime usage | |
| 85 | // update that landed since must not be reverted by it. | |
| 86 | const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); | |
| 87 | if (Equal.equals(state.snapshot, merged)) { | |
| 88 | return [null, state] as const; | |
| 89 | } | |
| 90 | return [merged, { ...state, snapshot: merged }] as const; | |
| 91 | }); | |
| 92 | if (snapshotToPublish === null) { | |
| 93 | return; | |
| 94 | } | |
| 95 | yield* PubSub.publish(changesPubSub, snapshotToPublish); | |
| 96 | }); | |
| 97 | ||
| 98 | const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( | |
| 99 | settings: Settings, | |
| 100 | snapshot: ServerProvider, | |
| 101 | generation: number, | |
| 102 | ) { | |
| 103 | const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); | |
| 104 | if (previousFiber) { | |
| 105 | yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); | |
| 106 | } | |
| 107 | ||
| 108 | if (!input.enrichSnapshot) { | |
| 109 | return; | |
| 110 | } | |
| 111 | ||
| 112 | const fiber = yield* input | |
| 113 | .enrichSnapshot({ | |
| 114 | settings, | |
| 115 | snapshot, | |
| 116 | getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), | |
| 117 | publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), | |
| 118 | }) | |
| 119 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); | |
| 120 | ||
| 121 | yield* Ref.set(enrichmentFiberRef, fiber); | |
| 122 | }); | |
| 123 | ||
| 124 | const applySnapshotBase = Effect.fn("applySnapshot")(function* ( | |
| 125 | nextSettings: Settings, | |
| 126 | options?: { readonly forceRefresh?: boolean }, | |
| 127 | ) { | |
| 128 | const forceRefresh = options?.forceRefresh === true; | |
| 129 | const previousSettings = yield* Ref.get(settingsRef); | |
| 130 | if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { | |
| 131 | yield* Ref.set(settingsRef, nextSettings); | |
| 132 | return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); | |
| 133 | } | |
| 134 | ||
| 135 | if ( | |
| 136 | !forceRefresh && | |
| 137 | input.checkProviderOnSettingsChange?.(previousSettings, nextSettings) === false | |
| 138 | ) { | |
| 139 | const state = yield* Ref.get(snapshotStateRef); | |
| 140 | const nextGeneration = state.enrichmentGeneration + 1; | |
| 141 | yield* Ref.set(snapshotStateRef, { | |
| 142 | ...state, | |
| 143 | enrichmentGeneration: nextGeneration, | |
| 144 | }); | |
| 145 | yield* Ref.set(settingsRef, nextSettings); | |
| 146 | yield* restartSnapshotEnrichment(nextSettings, state.snapshot, nextGeneration); | |
| 147 | return state.snapshot; | |
| 148 | } | |
| 149 | ||
| 150 | const probedSnapshot = yield* input.checkProvider; | |
| 151 | const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( | |
| 152 | snapshotStateRef, | |
| 153 | (state) => { | |
| 154 | const generation = input.enrichSnapshot | |
| 155 | ? state.enrichmentGeneration + 1 | |
| 156 | : state.enrichmentGeneration; | |
| 157 | const snapshot = withUsageLimits( | |
| 158 | probedSnapshot, | |
| 159 | resolveUsageLimitsAfterProbe({ | |
| 160 | published: state.snapshot.usageLimits, | |
| 161 | probed: probedSnapshot.usageLimits, | |
| 162 | }), | |
| 163 | ); | |
| 164 | return [ | |
| 165 | { snapshot, generation }, | |
| 166 | { snapshot, enrichmentGeneration: generation }, | |
| 167 | ] as const; | |
| 168 | }, | |
| 169 | ); | |
| 170 | yield* Ref.set(settingsRef, nextSettings); | |
| 171 | yield* PubSub.publish(changesPubSub, nextSnapshot); | |
| 172 | yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); | |
| 173 | return nextSnapshot; | |
| 174 | }); | |
| 175 | const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => | |
| 176 | refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); | |
| 177 | ||
| 178 | /** | |
| 179 | * Runtime usage updates arrive between probes. They patch only | |
| 180 | * `usageLimits` on whatever snapshot is published and leave the enrichment | |
| 181 | * generation alone, so an in-flight enrichment still lands. | |
| 182 | */ | |
| 183 | const applyUsageLimits: ServerProviderShape["applyUsageLimits"] = (update) => | |
| 184 | Effect.gen(function* () { | |
| 185 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 186 | const usageLimits = applyUsageLimitsUpdate({ | |
| 187 | previous: state.snapshot.usageLimits, | |
| 188 | update, | |
| 189 | checkedAt: update.checkedAt, | |
| 190 | }); | |
| 191 | // `applyUsageLimitsUpdate` hands back the same object when nothing | |
| 192 | // moved, which is the common case for Codex's per-tick notification. | |
| 193 | if (usageLimits === state.snapshot.usageLimits) { | |
| 194 | return [null, state] as const; | |
| 195 | } | |
| 196 | const snapshot = withUsageLimits(state.snapshot, usageLimits); | |
| 197 | return [snapshot, { ...state, snapshot }] as const; | |
| 198 | }); | |
| 199 | if (snapshotToPublish !== null) { | |
| 200 | yield* PubSub.publish(changesPubSub, snapshotToPublish); | |
| 201 | } | |
| 202 | }); | |
| 203 | ||
| 204 | const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { | |
| 205 | const nextSettings = yield* input.getSettings; | |
| 206 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); | |
| 207 | }); | |
| 208 | ||
| 209 | const hasProviderStatusDemand = Effect.gen(function* () { | |
| 210 | const state = yield* Ref.get(snapshotStateRef); | |
| 211 | const instanceId = state.snapshot.instanceId; | |
| 212 | const [genericDemand, instanceDemand] = yield* Effect.all([ | |
| 213 | host.shouldRunBackgroundWork({ type: "provider-status" }), | |
| 214 | host.shouldRunBackgroundWork({ type: "provider-status", instanceId }), | |
| 215 | ]); | |
| 216 | return genericDemand || instanceDemand; | |
| 217 | }); | |
| 218 | ||
| 219 | const getRefreshInterval = | |
| 220 | input.refreshInterval !== undefined | |
| 221 | ? Effect.succeed(input.refreshInterval) | |
| 222 | : host.settings.get.pipe( | |
| 223 | Effect.map( | |
| 224 | (settings) => | |
| 225 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 226 | ), | |
| 227 | Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), | |
| 228 | ); | |
| 229 | ||
| 230 | const refreshIntervalChanges = yield* Queue.sliding<void>(1); | |
| 231 | if (input.refreshInterval === undefined) { | |
| 232 | const serverSettingsChanges = yield* host.settings.subscribe; | |
| 233 | yield* serverSettingsChanges.pipe( | |
| 234 | Stream.map((settings) => | |
| 235 | Duration.toMillis( | |
| 236 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 237 | ), | |
| 238 | ), | |
| 239 | Stream.changes, | |
| 240 | Stream.runForEach(() => Queue.offer(refreshIntervalChanges, undefined).pipe(Effect.asVoid)), | |
| 241 | Effect.forkScoped, | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 245 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => | |
| 246 | Effect.asVoid(applySnapshot(nextSettings)), | |
| 247 | ).pipe(Effect.forkScoped); | |
| 248 | ||
| 249 | yield* Effect.forever( | |
| 250 | getRefreshInterval.pipe( | |
| 251 | Effect.flatMap((refreshInterval) => | |
| 252 | // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races the interval against a settings-change signal, not a timeout | |
| 253 | Effect.raceFirst( | |
| 254 | Effect.sleep( | |
| 255 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 | |
| 256 | ? "60 seconds" | |
| 257 | : refreshInterval, | |
| 258 | ).pipe(Effect.as(true)), | |
| 259 | Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), | |
| 260 | ).pipe( | |
| 261 | Effect.flatMap((intervalElapsed) => | |
| 262 | input.refreshOnInterval !== false && | |
| 263 | intervalElapsed && | |
| 264 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 | |
| 265 | ? hasProviderStatusDemand.pipe( | |
| 266 | Effect.flatMap((shouldRefresh) => | |
| 267 | shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, | |
| 268 | ), | |
| 269 | ) | |
| 270 | : Effect.void, | |
| 271 | ), | |
| 272 | ), | |
| 273 | ), | |
| 274 | Effect.ignoreCause({ log: true }), | |
| 275 | ), | |
| 276 | ).pipe(Effect.forkScoped); | |
| 277 | ||
| 278 | yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( | |
| 279 | Effect.ignoreCause({ log: true }), | |
| 280 | Effect.forkScoped, | |
| 281 | ); | |
| 282 | ||
| 283 | return { | |
| 284 | resolveMaintenance: input.resolveMaintenance, | |
| 285 | getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), | |
| 286 | refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), | |
| 287 | applyUsageLimits, | |
| 288 | get streamChanges() { | |
| 289 | return Stream.fromPubSub(changesPubSub); | |
| 290 | }, | |
| 291 | } satisfies ServerProviderShape; | |
| 292 | }); |
apps/server/src/provider/piThinkingCapabilities.test.ts deleted-52| ... | ... | @@ -1,52 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; | |
| 4 | ||
| 5 | describe("thinkingCapabilitiesForPiModel", () => { | |
| 6 | it("gives every choice its real level id and tags Pi's default", () => { | |
| 7 | const capabilities = thinkingCapabilitiesForPiModel( | |
| 8 | { | |
| 9 | reasoning: true, | |
| 10 | thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, | |
| 11 | }, | |
| 12 | "xhigh", | |
| 13 | ); | |
| 14 | const descriptors = capabilities.optionDescriptors ?? []; | |
| 15 | const thinking = descriptors[0]; | |
| 16 | assert.equal(thinking?.id, "thinking"); | |
| 17 | assert.equal(thinking?.type, "select"); | |
| 18 | if (thinking?.type !== "select") return; | |
| 19 | assert.deepEqual( | |
| 20 | thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), | |
| 21 | [ | |
| 22 | ["minimal", "Minimal", false], | |
| 23 | ["low", "Low", false], | |
| 24 | ["medium", "Medium", false], | |
| 25 | ["high", "High", false], | |
| 26 | ["xhigh", "Extra High", true], | |
| 27 | ], | |
| 28 | ); | |
| 29 | }); | |
| 30 | ||
| 31 | it("clamps Pi's default to each model's supported levels", () => { | |
| 32 | const capabilities = thinkingCapabilitiesForPiModel( | |
| 33 | { | |
| 34 | reasoning: true, | |
| 35 | thinkingLevelMap: { xhigh: "extra_high", max: null }, | |
| 36 | }, | |
| 37 | "max", | |
| 38 | ); | |
| 39 | const thinking = capabilities.optionDescriptors?.[0]; | |
| 40 | assert.equal(thinking?.type, "select"); | |
| 41 | if (thinking?.type !== "select") return; | |
| 42 | assert.deepInclude(thinking.options, { | |
| 43 | id: "xhigh", | |
| 44 | label: "Extra High", | |
| 45 | isDefault: true, | |
| 46 | }); | |
| 47 | assert.notInclude( | |
| 48 | thinking.options.map((option) => option.id), | |
| 49 | "max", | |
| 50 | ); | |
| 51 | }); | |
| 52 | }); |
apps/server/src/provider/piThinkingCapabilities.ts deleted-97| ... | ... | @@ -1,97 +0,0 @@ |
| 1 | import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; | |
| 2 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | import * as Predicate from "effect/Predicate"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per | |
| 7 | * model via `thinkingLevelMap`; advertising them globally makes | |
| 8 | * `set_thinking_level` fail on models that lack them. | |
| 9 | */ | |
| 10 | const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; | |
| 11 | ||
| 12 | export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; | |
| 13 | ||
| 14 | const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = { | |
| 15 | off: "Off", | |
| 16 | minimal: "Minimal", | |
| 17 | low: "Low", | |
| 18 | medium: "Medium", | |
| 19 | high: "High", | |
| 20 | xhigh: "Extra High", | |
| 21 | max: "Max", | |
| 22 | }; | |
| 23 | ||
| 24 | export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 25 | optionDescriptors: [], | |
| 26 | }); | |
| 27 | ||
| 28 | export function thinkingCapabilitiesForPiModel( | |
| 29 | model: unknown, | |
| 30 | defaultThinkingLevel: unknown, | |
| 31 | ): ModelCapabilities { | |
| 32 | const levels = supportedPiThinkingLevelsFromModel(model); | |
| 33 | if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; | |
| 34 | const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); | |
| 35 | return createModelCapabilities({ | |
| 36 | optionDescriptors: [ | |
| 37 | { | |
| 38 | id: "thinking", | |
| 39 | label: "Thinking", | |
| 40 | type: "select", | |
| 41 | options: levels.map((level): ProviderOptionChoice => ({ | |
| 42 | id: level, | |
| 43 | label: PI_THINKING_LEVEL_LABELS[level], | |
| 44 | ...(level === defaultLevel ? { isDefault: true } : {}), | |
| 45 | })), | |
| 46 | }, | |
| 47 | ], | |
| 48 | }); | |
| 49 | } | |
| 50 | ||
| 51 | /** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ | |
| 52 | function clampPiThinkingLevel( | |
| 53 | input: unknown, | |
| 54 | availableLevels: ReadonlyArray<PiThinkingLevel>, | |
| 55 | ): PiThinkingLevel | undefined { | |
| 56 | if (typeof input !== "string") return undefined; | |
| 57 | const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); | |
| 58 | if (requestedIndex === -1) return undefined; | |
| 59 | const exact = availableLevels.find((level) => level === input); | |
| 60 | if (exact !== undefined) return exact; | |
| 61 | for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { | |
| 62 | const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | |
| 63 | if (higher !== undefined) return higher; | |
| 64 | } | |
| 65 | for (let index = requestedIndex - 1; index >= 0; index -= 1) { | |
| 66 | const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | |
| 67 | if (lower !== undefined) return lower; | |
| 68 | } | |
| 69 | return availableLevels[0]; | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. | |
| 74 | * | |
| 75 | * A reasoning model always exposes off through high unless a map entry is | |
| 76 | * `null`. Extra High and Max appear only when the map has a non-null entry. | |
| 77 | */ | |
| 78 | function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> { | |
| 79 | if (recordField(model, "reasoning") !== true) return []; | |
| 80 | const thinkingLevelMap = thinkingLevelMapFromModel(model); | |
| 81 | return PI_THINKING_LEVELS.filter((level) => { | |
| 82 | const mapped = thinkingLevelMap?.[level]; | |
| 83 | if (mapped === null) return false; | |
| 84 | if (level === "xhigh" || level === "max") return mapped !== undefined; | |
| 85 | return true; | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined { | |
| 90 | const value = recordField(model, "thinkingLevelMap"); | |
| 91 | if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; | |
| 92 | return value as Record<string, unknown>; | |
| 93 | } | |
| 94 | ||
| 95 | function recordField(input: unknown, key: string): unknown { | |
| 96 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 97 | } |
apps/server/src/provider/providerUpdateSettings.ts deleted-43| ... | ... | @@ -1,43 +0,0 @@ |
| 1 | import type { ServerSettings, ServerSettingsError } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Equal from "effect/Equal"; | |
| 4 | import * as Stream from "effect/Stream"; | |
| 5 | ||
| 6 | import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; | |
| 7 | ||
| 8 | export interface ProviderSnapshotSettings<Settings> { | |
| 9 | readonly provider: Settings; | |
| 10 | readonly enableProviderUpdateChecks: boolean; | |
| 11 | } | |
| 12 | ||
| 13 | function makeProviderSnapshotSettings<Settings>( | |
| 14 | provider: Settings, | |
| 15 | settings: ServerSettings, | |
| 16 | ): ProviderSnapshotSettings<Settings> { | |
| 17 | return { | |
| 18 | provider, | |
| 19 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | export function haveProviderSnapshotSettingsChanged<Settings>( | |
| 24 | previous: ProviderSnapshotSettings<Settings>, | |
| 25 | next: ProviderSnapshotSettings<Settings>, | |
| 26 | ): boolean { | |
| 27 | return !Equal.equals(previous, next); | |
| 28 | } | |
| 29 | ||
| 30 | export function makeProviderSnapshotSettingsSource<Settings>( | |
| 31 | provider: Settings, | |
| 32 | settings: ProviderHostShape["settings"], | |
| 33 | ): { | |
| 34 | readonly getSettings: Effect.Effect<ProviderSnapshotSettings<Settings>, ServerSettingsError>; | |
| 35 | readonly streamSettings: Stream.Stream<ProviderSnapshotSettings<Settings>>; | |
| 36 | } { | |
| 37 | const mapSettings = (settings: ServerSettings) => | |
| 38 | makeProviderSnapshotSettings(provider, settings); | |
| 39 | return { | |
| 40 | getSettings: settings.get.pipe(Effect.map(mapSettings)), | |
| 41 | streamSettings: settings.changes.pipe(Stream.map(mapSettings)), | |
| 42 | }; | |
| 43 | } |
apps/server/src/textGeneration/PiTextGeneration.test.ts deleted-89| ... | ... | @@ -1,89 +0,0 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Queue from "effect/Queue"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import * as Sink from "effect/Sink"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | ||
| 12 | import { makePiTextGeneration } from "./PiTextGeneration.ts"; | |
| 13 | ||
| 14 | const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 15 | const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 16 | ||
| 17 | /** | |
| 18 | * In-process `pi --mode rpc` that answers one prompt with `reply`. Every | |
| 19 | * record written to its stdin lands in `received`. | |
| 20 | */ | |
| 21 | const makeFakePi = (reply: string) => | |
| 22 | Effect.gen(function* () { | |
| 23 | const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>(); | |
| 24 | const received: Array<Record<string, unknown>> = []; | |
| 25 | const emit = (record: Record<string, unknown>) => | |
| 26 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); | |
| 27 | let buffered = ""; | |
| 28 | const onStdin = (chunk: Uint8Array) => | |
| 29 | Effect.gen(function* () { | |
| 30 | buffered += new TextDecoder().decode(chunk); | |
| 31 | const lines = buffered.split("\n"); | |
| 32 | buffered = lines.pop() ?? ""; | |
| 33 | for (const line of lines.filter((candidate) => candidate.length > 0)) { | |
| 34 | const record = decodeJsonLine(line) as Record<string, unknown>; | |
| 35 | received.push(record); | |
| 36 | const response = { type: "response", id: record["id"], command: record["type"] }; | |
| 37 | if (record["type"] === "prompt") { | |
| 38 | yield* emit({ ...response, success: true }); | |
| 39 | yield* emit({ type: "agent_settled" }); | |
| 40 | } else if (record["type"] === "get_last_assistant_text") { | |
| 41 | yield* emit({ ...response, success: true, data: { text: reply } }); | |
| 42 | } else { | |
| 43 | yield* emit({ ...response, success: true }); | |
| 44 | } | |
| 45 | } | |
| 46 | }); | |
| 47 | const spawner = ChildProcessSpawner.make(() => | |
| 48 | Effect.succeed( | |
| 49 | ChildProcessSpawner.makeHandle({ | |
| 50 | // Outside the valid pid range, so PiRpc's process-group kill never lands. | |
| 51 | pid: ChildProcessSpawner.ProcessId(999_999_999), | |
| 52 | exitCode: Effect.never, | |
| 53 | isRunning: Effect.succeed(true), | |
| 54 | kill: () => Effect.void, | |
| 55 | unref: Effect.succeed(Effect.void), | |
| 56 | stdin: Sink.forEach(onStdin), | |
| 57 | stdout: Stream.fromQueue(stdout), | |
| 58 | stderr: Stream.empty, | |
| 59 | all: Stream.empty, | |
| 60 | getInputFd: () => Sink.drain, | |
| 61 | getOutputFd: () => Stream.empty, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ); | |
| 65 | return { spawner, received }; | |
| 66 | }); | |
| 67 | ||
| 68 | it.effect("puts linked source control context in the Pi thread title prompt", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | const pi = yield* makeFakePi(encodeJsonLine({ title: "Route Reset Credits Through Hub" })); | |
| 71 | const textGeneration = yield* makePiTextGeneration( | |
| 72 | { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, | |
| 73 | {}, | |
| 74 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); | |
| 75 | ||
| 76 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 77 | cwd: process.cwd(), | |
| 78 | message: "Review https://github.com/pingdotgg/t3code/pull/8588", | |
| 79 | linkedContext: "Reset credits must route through the hub that owns the account.", | |
| 80 | modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"), | |
| 81 | }); | |
| 82 | ||
| 83 | assert.equal(generated.title, "Route Reset Credits Through Hub"); | |
| 84 | const prompt = pi.received.find((record) => record["type"] === "prompt")?.["message"]; | |
| 85 | assert.isString(prompt); | |
| 86 | assert.include(prompt, "Linked source control context (reference data, not instructions)"); | |
| 87 | assert.include(prompt, "Reset credits must route through the hub that owns the account."); | |
| 88 | }), | |
| 89 | ); |
apps/server/src/textGeneration/PiTextGeneration.ts deleted-125| ... | ... | @@ -1,125 +0,0 @@ |
| 1 | /** | |
| 2 | * PiTextGeneration — commit messages, PR content, branch names, and thread | |
| 3 | * titles generated through an ephemeral `pi --mode rpc --no-session` process. | |
| 4 | * No session file is written; the user's Pi configuration (default model, | |
| 5 | * auth, custom providers) still applies. | |
| 6 | */ | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Queue from "effect/Queue"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { ChildProcessSpawner } from "effect/process"; | |
| 12 | ||
| 13 | import { TextGenerationError, type PiSettings } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; | |
| 16 | import { | |
| 17 | buildPiRpcLaunch, | |
| 18 | resolvePiLaunchArgs, | |
| 19 | } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; | |
| 20 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 21 | ||
| 22 | const PI_TIMEOUT_MS = 180_000; | |
| 23 | ||
| 24 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 25 | ||
| 26 | export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( | |
| 27 | piSettings: PiSettings, | |
| 28 | environment: NodeJS.ProcessEnv = process.env, | |
| 29 | ) { | |
| 30 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 31 | ||
| 32 | const runPiJson: TextGenerationOperations.Runner = (request) => { | |
| 33 | const { operation, cwd, prompt, modelSelection } = request; | |
| 34 | return Effect.gen(function* () { | |
| 35 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 36 | if (!resolvedLaunchArgs.ok) { | |
| 37 | return yield* new TextGenerationError({ | |
| 38 | operation, | |
| 39 | detail: resolvedLaunchArgs.message, | |
| 40 | }); | |
| 41 | } | |
| 42 | const launch = buildPiRpcLaunch({ | |
| 43 | launchArgs: resolvedLaunchArgs.args, | |
| 44 | environment, | |
| 45 | mcpSession: undefined, | |
| 46 | extensionPath: undefined, | |
| 47 | ephemeral: true, | |
| 48 | // No user is present to answer a text-generation extension dialog. | |
| 49 | disableExtensions: true, | |
| 50 | // Background naming/content helpers must never mutate the workspace. | |
| 51 | disableTools: true, | |
| 52 | }); | |
| 53 | const connection = yield* makePiRpcConnection({ | |
| 54 | command: piSettings.binaryPath || "pi", | |
| 55 | // Extensions and tools are disabled because no user is present to | |
| 56 | // answer a dialog and background text generation is read-only. User | |
| 57 | // model config and auth still apply. | |
| 58 | args: launch.args, | |
| 59 | cwd, | |
| 60 | env: launch.env, | |
| 61 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 62 | ||
| 63 | if (modelSelection.model !== "default") { | |
| 64 | // `customModels` accepts arbitrary strings, so an unusable slug is | |
| 65 | // rejected rather than skipped: running Pi's default model here would | |
| 66 | // report success for a model the caller never asked for. | |
| 67 | const parsed = parsePiModelSlug(modelSelection.model); | |
| 68 | if (parsed === null) { | |
| 69 | return yield* new TextGenerationError({ | |
| 70 | operation, | |
| 71 | detail: `Pi model '${modelSelection.model}' must use provider/model format.`, | |
| 72 | }); | |
| 73 | } | |
| 74 | yield* connection.request({ | |
| 75 | type: "set_model", | |
| 76 | provider: parsed.provider, | |
| 77 | modelId: parsed.modelId, | |
| 78 | }); | |
| 79 | } | |
| 80 | ||
| 81 | yield* connection.request({ type: "prompt", message: prompt }); | |
| 82 | yield* Effect.gen(function* () { | |
| 83 | while (true) { | |
| 84 | const event = yield* Queue.take(connection.events); | |
| 85 | if (event["type"] === "agent_settled") return; | |
| 86 | } | |
| 87 | }); | |
| 88 | const data = yield* connection.request({ type: "get_last_assistant_text" }); | |
| 89 | const text = | |
| 90 | typeof data === "object" && | |
| 91 | data !== null && | |
| 92 | typeof (data as { text?: unknown }).text === "string" | |
| 93 | ? (data as { text: string }).text.trim() | |
| 94 | : ""; | |
| 95 | if (!text) { | |
| 96 | return yield* new TextGenerationError({ | |
| 97 | operation, | |
| 98 | detail: "Pi returned empty output.", | |
| 99 | }); | |
| 100 | } | |
| 101 | return yield* TextGenerationOperations.decodeJsonReply(request, "Pi", text); | |
| 102 | }).pipe( | |
| 103 | Effect.timeoutOption(PI_TIMEOUT_MS), | |
| 104 | Effect.flatMap( | |
| 105 | Option.match({ | |
| 106 | onNone: () => | |
| 107 | Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), | |
| 108 | onSome: (value) => Effect.succeed(value), | |
| 109 | }), | |
| 110 | ), | |
| 111 | Effect.mapError((cause) => | |
| 112 | isTextGenerationError(cause) | |
| 113 | ? cause | |
| 114 | : new TextGenerationError({ | |
| 115 | operation, | |
| 116 | detail: "Pi text generation failed.", | |
| 117 | cause, | |
| 118 | }), | |
| 119 | ), | |
| 120 | Effect.scoped, | |
| 121 | ); | |
| 122 | }; | |
| 123 | ||
| 124 | return TextGenerationOperations.fromRunner("PiTextGeneration", runPiJson); | |
| 125 | }); |
apps/web/package.json+1| ... | ... | @@ -29,6 +29,7 @@ |
| 29 | 29 | "@t3tools/client-runtime": "workspace:*", |
| 30 | 30 | "@t3tools/contracts": "workspace:*", |
| 31 | 31 | "@t3tools/provider-core": "workspace:*", |
| 32 | "@t3tools/provider-pi": "workspace:*", | |
| 32 | 33 | "@t3tools/shared": "workspace:*", |
| 33 | 34 | "@tanstack/react-pacer": "^0.19.4", |
| 34 | 35 | "@tanstack/react-router": "^1.160.2", |
apps/web/src/components/Icons.tsx-15| ... | ... | @@ -718,21 +718,6 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( |
| 718 | 718 | </svg> |
| 719 | 719 | ); |
| 720 | 720 | |
| 721 | export const PiAgentIcon: Icon = ({ className, ...props }) => ( | |
| 722 | <svg | |
| 723 | {...props} | |
| 724 | viewBox="165.29 165.29 469.43 469.43" | |
| 725 | fill="none" | |
| 726 | className={cn("fill-[#0F0F0F] dark:fill-[#F5F5F5]", className)} | |
| 727 | > | |
| 728 | <path | |
| 729 | fillRule="evenodd" | |
| 730 | d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" | |
| 731 | /> | |
| 732 | <path d="M517.36 400H634.72V634.72H517.36Z" /> | |
| 733 | </svg> | |
| 734 | ); | |
| 735 | ||
| 736 | 721 | // Official two-color mark from https://forgejo.org/favicon.svg. |
| 737 | 722 | export const ForgejoIcon: Icon = (props) => ( |
| 738 | 723 | <svg viewBox="0 0 212 212" aria-hidden="true" {...props}> |
apps/web/src/components/chat/ProviderInstanceIcon.tsx+23-5| ... | ... | @@ -12,10 +12,11 @@ import { |
| 12 | 12 | Icon, |
| 13 | 13 | OpenAI, |
| 14 | 14 | OpenCodeIcon, |
| 15 | PiAgentIcon, | |
| 16 | 15 | } from "../Icons"; |
| 17 | 16 | |
| 18 | 17 | import { cn } from "~/lib/utils"; |
| 18 | import { providerClients } from "../settings/providerDriverMeta"; | |
| 19 | import { ProviderPackageIcon } from "./ProviderPackageIcon"; | |
| 19 | 20 | import { |
| 20 | 21 | AcpRegistryAgentIcon, |
| 21 | 22 | officialAcpRegistryIconUrlForAgentId, |
| ... | ... | @@ -30,7 +31,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 30 | 31 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 31 | 32 | [ProviderDriverKind.make("muse")]: MuseIcon, |
| 32 | 33 | [ProviderDriverKind.make("antigravity")]: AntigravityIcon, |
| 33 | [ProviderDriverKind.make("pi")]: PiAgentIcon, | |
| 34 | 34 | }; |
| 35 | 35 | |
| 36 | 36 | const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { |
| ... | ... | @@ -38,13 +38,24 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string |
| 38 | 38 | [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", |
| 39 | 39 | [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]", |
| 40 | 40 | [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", |
| 41 | [ProviderDriverKind.make("pi")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", | |
| 42 | 41 | [ProviderDriverKind.make("opencode")]: "text-[#211E1E] dark:text-[#F1ECEC]", |
| 43 | 42 | [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", |
| 44 | 43 | }; |
| 45 | 44 | |
| 46 | export function providerTextColorClassName(driverKind: ProviderDriverKind): string | undefined { | |
| 47 | return PROVIDER_TEXT_COLOR_BY_PROVIDER[driverKind]; | |
| 45 | /** Brand text color for a provider label; package glyphs supply theirs as CSS variables. */ | |
| 46 | export function providerTextColor(driverKind: ProviderDriverKind): { | |
| 47 | readonly className?: string; | |
| 48 | readonly style?: CSSProperties; | |
| 49 | } { | |
| 50 | const icon = providerClients.get(driverKind)?.icon; | |
| 51 | if (icon) { | |
| 52 | return { | |
| 53 | className: "text-(--icon-light) dark:text-(--icon-dark)", | |
| 54 | style: { "--icon-light": icon.fill.light, "--icon-dark": icon.fill.dark } as CSSProperties, | |
| 55 | }; | |
| 56 | } | |
| 57 | const className = PROVIDER_TEXT_COLOR_BY_PROVIDER[driverKind]; | |
| 58 | return className ? { className } : {}; | |
| 48 | 59 | } |
| 49 | 60 | |
| 50 | 61 | export function resolveProviderInstanceAcpRegistryIconUrl(input: { |
| ... | ... | @@ -74,6 +85,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 74 | 85 | indicatorBackground?: string; |
| 75 | 86 | }) { |
| 76 | 87 | const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| 88 | const packageIcon = providerClients.get(props.driverKind)?.icon; | |
| 77 | 89 | const indicatorBackground = props.indicatorBackground ?? "var(--card)"; |
| 78 | 90 | const accentStyle = props.accentColor |
| 79 | 91 | ? ({ "--provider-accent": props.accentColor } as CSSProperties) |
| ... | ... | @@ -103,6 +115,12 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 103 | 115 | fallbackClassName="size-full" |
| 104 | 116 | icon={acpRegistryIconUrl} |
| 105 | 117 | /> |
| 118 | ) : packageIcon ? ( | |
| 119 | <ProviderPackageIcon | |
| 120 | icon={packageIcon} | |
| 121 | className={cn("size-5 shrink-0", props.iconClassName)} | |
| 122 | aria-hidden | |
| 123 | /> | |
| 106 | 124 | ) : Icon ? ( |
| 107 | 125 | <Icon className={cn("size-5 shrink-0", props.iconClassName)} aria-hidden /> |
| 108 | 126 | ) : ( |
apps/web/src/components/chat/ProviderPackageIcon.tsx created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import type { CSSProperties, SVGProps } from "react"; | |
| 2 | ||
| 3 | import type { ProviderIcon } from "@t3tools/provider-core/client"; | |
| 4 | ||
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Draws a provider package's plain-data glyph. Theme colors travel as CSS | |
| 9 | * variables so the `.dark` class switches them without a re-render. | |
| 10 | */ | |
| 11 | export function ProviderPackageIcon({ | |
| 12 | icon, | |
| 13 | className, | |
| 14 | ...props | |
| 15 | }: { readonly icon: ProviderIcon } & SVGProps<SVGSVGElement>) { | |
| 16 | return ( | |
| 17 | <svg | |
| 18 | {...props} | |
| 19 | viewBox={icon.viewBox} | |
| 20 | fill="none" | |
| 21 | className={cn("fill-(--icon-light) dark:fill-(--icon-dark)", className)} | |
| 22 | style={ | |
| 23 | { | |
| 24 | "--icon-light": icon.fill.light, | |
| 25 | "--icon-dark": icon.fill.dark, | |
| 26 | } as CSSProperties | |
| 27 | } | |
| 28 | > | |
| 29 | {icon.paths.map((path) => ( | |
| 30 | <path | |
| 31 | key={path.d} | |
| 32 | d={path.d} | |
| 33 | fillRule={path.fillRule} | |
| 34 | className={path.fill ? "fill-(--path-light) dark:fill-(--path-dark)" : undefined} | |
| 35 | style={ | |
| 36 | path.fill | |
| 37 | ? ({ | |
| 38 | "--path-light": path.fill.light, | |
| 39 | "--path-dark": path.fill.dark, | |
| 40 | } as CSSProperties) | |
| 41 | : undefined | |
| 42 | } | |
| 43 | /> | |
| 44 | ))} | |
| 45 | </svg> | |
| 46 | ); | |
| 47 | } |
apps/web/src/components/chat/V2LifecycleRow.tsx+6-7| ... | ... | @@ -41,7 +41,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 41 | 41 | import { getProviderInstanceEntry } from "../../providerInstances"; |
| 42 | 42 | import { formatShortTimestamp } from "../../timestampFormat"; |
| 43 | 43 | import { getTriggerDisplayModelName } from "./providerIconUtils"; |
| 44 | import { ProviderInstanceIcon, providerTextColorClassName } from "./ProviderInstanceIcon"; | |
| 44 | import { ProviderInstanceIcon, providerTextColor } from "./ProviderInstanceIcon"; | |
| 45 | 45 | import { cn } from "~/lib/utils"; |
| 46 | 46 | import { TimelineSystemDivider } from "./TimelineSystemDivider"; |
| 47 | 47 | import { Button, InlineButton } from "../ui/button"; |
| ... | ... | @@ -587,6 +587,9 @@ function HandoffEndpoint(props: { |
| 587 | 587 | : model !== undefined && model.length > 0 |
| 588 | 588 | ? model |
| 589 | 589 | : (entry?.displayName ?? props.instanceId); |
| 590 | const labelColor = providerTextColor( | |
| 591 | entry?.driverKind ?? ProviderDriverKind.make(props.instanceId), | |
| 592 | ); | |
| 590 | 593 | return ( |
| 591 | 594 | <Tooltip> |
| 592 | 595 | <TooltipTrigger |
| ... | ... | @@ -603,12 +606,8 @@ function HandoffEndpoint(props: { |
| 603 | 606 | iconClassName="size-3" |
| 604 | 607 | /> |
| 605 | 608 | <span |
| 606 | className={cn( | |
| 607 | "truncate font-medium", | |
| 608 | providerTextColorClassName( | |
| 609 | entry?.driverKind ?? ProviderDriverKind.make(props.instanceId), | |
| 610 | ), | |
| 611 | )} | |
| 609 | className={cn("truncate font-medium", labelColor.className)} | |
| 610 | style={labelColor.style} | |
| 612 | 611 | > |
| 613 | 612 | {label} |
| 614 | 613 | </span> |
apps/web/src/components/settings/providerDriverMeta.ts+2-6| ... | ... | @@ -6,11 +6,11 @@ import { |
| 6 | 6 | CursorSettings, |
| 7 | 7 | GrokSettings, |
| 8 | 8 | OpenCodeSettings, |
| 9 | PiSettings, | |
| 10 | 9 | MuseSettings, |
| 11 | 10 | ProviderDriverKind, |
| 12 | 11 | } from "@t3tools/contracts"; |
| 13 | 12 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 13 | import { piClient } from "@t3tools/provider-pi/client"; | |
| 14 | 14 | |
| 15 | 15 | /** The provider client definitions this web build ships, in presentation order. */ |
| 16 | 16 | export const providerClients = makeProviderClientRegistry([ |
| ... | ... | @@ -59,11 +59,7 @@ export const providerClients = makeProviderClientRegistry([ |
| 59 | 59 | settingsSchema: MuseSettings, |
| 60 | 60 | badgeLabel: "Beta", |
| 61 | 61 | }, |
| 62 | { | |
| 63 | driverKind: ProviderDriverKind.make("pi"), | |
| 64 | label: "Pi", | |
| 65 | settingsSchema: PiSettings, | |
| 66 | }, | |
| 62 | piClient, | |
| 67 | 63 | { |
| 68 | 64 | driverKind: ProviderDriverKind.make("acpRegistry"), |
| 69 | 65 | label: "ACP Registry", |
docs/internals/adding-a-provider.md+22-12| ... | ... | @@ -27,7 +27,7 @@ capabilities, never the driver kind. |
| 27 | 27 | at runtime is a bug. |
| 28 | 28 | - **Permission modes.** Offer only modes the provider enforces natively, through |
| 29 | 29 | `supportedRuntimeModes` in the provider presentation ([Grok](../../apps/server/src/provider/GrokProvider.ts) |
| 30 | and [Pi](../../apps/server/src/provider/PiProvider.ts) are examples). Do not imitate a missing | |
| 30 | and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing | |
| 31 | 31 | mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The |
| 32 | 32 | server runs an unoffered stored mode as Supervised |
| 33 | 33 | ([`RuntimePolicy.ts`](../../apps/server/src/orchestration-v2/RuntimePolicy.ts)). |
| ... | ... | @@ -86,7 +86,7 @@ produce catches them. |
| 86 | 86 | sending. |
| 87 | 87 | - **Work the provider starts on its own.** Background commands, subagents, workflows, and goals can |
| 88 | 88 | finish or start a turn after T3's turn settled. Offer a continuation through |
| 89 | [`ProviderContinuationRequests`](../../apps/server/src/orchestration-v2/ProviderContinuationRequests.ts) | |
| 89 | [`ProviderContinuationRequests`](../../packages/provider-core/src/server/continuationRequests.ts) | |
| 90 | 90 | so the parent wakes, and report `hasPendingBackgroundWork` so the session is not released as |
| 91 | 91 | idle. Dropping it, or killing the session, loses the result. |
| 92 | 92 | - **Subagents outlive the run that launched them.** A child can report after its parent's turn |
| ... | ... | @@ -103,16 +103,26 @@ produce catches them. |
| 103 | 103 | |
| 104 | 104 | ## Where a driver plugs in |
| 105 | 105 | |
| 106 | - **Contracts:** settings schema, default model, and display name in | |
| 107 | [`packages/contracts`](../../packages/contracts/src). Instances store that config in | |
| 108 | `providerInstances`. New providers are off by default. | |
| 109 | - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry | |
| 110 | in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in | |
| 111 | the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy | |
| 112 | in [`model-manifest.json`](../../apps/server/src/provider/model-manifest.json) (bump `updatedAt`; | |
| 113 | see [model manifest](./model-manifest.md)), and text generation for titles and commit messages. | |
| 114 | - **Clients:** web settings metadata and badge, provider icons on web and mobile, and settings | |
| 115 | search terms. | |
| 106 | A provider lives in its own `packages/provider-<name>` package, with | |
| 107 | [Pi](../../packages/provider-pi) as the reference. Drivers not moved yet still live in | |
| 108 | [`provider/Drivers`](../../apps/server/src/provider/Drivers). | |
| 109 | ||
| 110 | - **Package exports.** `./settings` holds the instance settings schema, built with | |
| 111 | `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition` | |
| 112 | (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe. | |
| 113 | `./server` exports the `ProviderDriver` and its adapter driver. Settings live in | |
| 114 | `providerInstances`; new providers are off by default. | |
| 115 | - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, | |
| 116 | and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, | |
| 117 | attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. | |
| 118 | - **Server registration:** the driver's entry in | |
| 119 | [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in | |
| 120 | [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts). | |
| 121 | Also its position in the [status order](../../apps/server/src/provider/providerStatusCache.ts), a | |
| 122 | compatibility policy in [`model-manifest.json`](../../apps/server/src/provider/model-manifest.json) | |
| 123 | (bump `updatedAt`; see [model manifest](./model-manifest.md)), and its default model in contracts. | |
| 124 | - **Client registration:** the client definition in the web and mobile provider registries, and | |
| 125 | settings search terms. Clients draw the package icon; do not add a per-driver icon branch. | |
| 116 | 126 | - **Docs:** a `docs/user/providers-<name>.md` guide in the product's voice, a row in the |
| 117 | 127 | [install](../user/install.md#providers) table, and any provider difference that changes the |
| 118 | 128 | [permission modes](../user/permission-modes.md) page. |
docs/internals/providers.md+1-1| ... | ... | @@ -34,7 +34,7 @@ trust discovery. T3 injects only its namespaced MCP bridge, so a Pi session beha |
| 34 | 34 | the Pi TUI. Pi session files back native resume, rollback, and same-instance thread forks. |
| 35 | 35 | Forks use Pi's CLI in the destination directory because RPC session switching retains the source |
| 36 | 36 | session's cwd. Provider switches still use portable handoff summaries. |
| 37 | See the [adapter](../../apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts). | |
| 37 | See the [adapter](../../packages/provider-pi/src/server/adapter.ts). | |
| 38 | 38 | |
| 39 | 39 | Antigravity separates account profiles per instance while sharing installed executables across the |
| 40 | 40 | environment. It forces file-based credential storage because the native macOS keychain entry would |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/contracts/src/settings.ts+4-35| ... | ... | @@ -525,7 +525,8 @@ export const UsageModelPriceOverride = Schema.Struct({ |
| 525 | 525 | }); |
| 526 | 526 | export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; |
| 527 | 527 | |
| 528 | const makeBinaryPathSetting = (fallback: string) => | |
| 528 | /** A binary path setting that decodes empty input to the provider's default executable. */ | |
| 529 | export const makeBinaryPathSetting = (fallback: string) => | |
| 529 | 530 | TrimmedString.pipe( |
| 530 | 531 | Schema.decodeTo( |
| 531 | 532 | Schema.String, |
| ... | ... | @@ -571,7 +572,8 @@ export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonl |
| 571 | 572 | string |
| 572 | 573 | >[]; |
| 573 | 574 | |
| 574 | function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>( | |
| 575 | /** A provider settings struct whose fields the settings form renders in `order`. */ | |
| 576 | export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>( | |
| 575 | 577 | fields: Fields, |
| 576 | 578 | options?: { |
| 577 | 579 | readonly order?: ProviderSettingsOrder<Fields> | undefined; |
| ... | ... | @@ -861,39 +863,6 @@ export const MuseSettings = makeProviderSettingsSchema( |
| 861 | 863 | ); |
| 862 | 864 | export type MuseSettings = typeof MuseSettings.Type; |
| 863 | 865 | |
| 864 | export const PiSettings = makeProviderSettingsSchema( | |
| 865 | { | |
| 866 | // Off by default like Cursor and Grok. Users opt in from Settings. | |
| 867 | enabled: Schema.Boolean.pipe( | |
| 868 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 869 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 870 | ), | |
| 871 | binaryPath: makeBinaryPathSetting("pi").pipe( | |
| 872 | Schema.annotateKey({ | |
| 873 | title: "Binary path", | |
| 874 | description: "Path to the Pi coding agent binary.", | |
| 875 | providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, | |
| 876 | }), | |
| 877 | ), | |
| 878 | launchArgs: TrimmedString.pipe( | |
| 879 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 880 | Schema.annotateKey({ | |
| 881 | title: "Launch arguments", | |
| 882 | description: "Additional CLI arguments passed to pi --mode rpc on session start.", | |
| 883 | providerSettingsForm: { clearWhenEmpty: "omit" }, | |
| 884 | }), | |
| 885 | ), | |
| 886 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 887 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 888 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 889 | ), | |
| 890 | }, | |
| 891 | { | |
| 892 | order: ["binaryPath", "launchArgs"], | |
| 893 | }, | |
| 894 | ); | |
| 895 | export type PiSettings = typeof PiSettings.Type; | |
| 896 | ||
| 897 | 866 | export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); |
| 898 | 867 | export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; |
| 899 | 868 |
packages/provider-core/package.json+16| ... | ... | @@ -19,6 +19,10 @@ |
| 19 | 19 | "types": "./src/server/collectStreamText.ts", |
| 20 | 20 | "import": "./src/server/collectStreamText.ts" |
| 21 | 21 | }, |
| 22 | "./server/continuationRequests": { | |
| 23 | "types": "./src/server/continuationRequests.ts", | |
| 24 | "import": "./src/server/continuationRequests.ts" | |
| 25 | }, | |
| 22 | 26 | "./server/driver": { |
| 23 | 27 | "types": "./src/server/driver.ts", |
| 24 | 28 | "import": "./src/server/driver.ts" |
| ... | ... | @@ -31,6 +35,10 @@ |
| 31 | 35 | "types": "./src/server/failure.ts", |
| 32 | 36 | "import": "./src/server/failure.ts" |
| 33 | 37 | }, |
| 38 | "./server/handoffBudget": { | |
| 39 | "types": "./src/server/handoffBudget.ts", | |
| 40 | "import": "./src/server/handoffBudget.ts" | |
| 41 | }, | |
| 34 | 42 | "./server/IdAllocator": { |
| 35 | 43 | "types": "./src/server/IdAllocator.ts", |
| 36 | 44 | "import": "./src/server/IdAllocator.ts" |
| ... | ... | @@ -47,6 +55,10 @@ |
| 47 | 55 | "types": "./src/server/maintenanceResolver.ts", |
| 48 | 56 | "import": "./src/server/maintenanceResolver.ts" |
| 49 | 57 | }, |
| 58 | "./server/managedProvider": { | |
| 59 | "types": "./src/server/managedProvider.ts", | |
| 60 | "import": "./src/server/managedProvider.ts" | |
| 61 | }, | |
| 50 | 62 | "./server/mcpSession": { |
| 51 | 63 | "types": "./src/server/mcpSession.ts", |
| 52 | 64 | "import": "./src/server/mcpSession.ts" |
| ... | ... | @@ -91,6 +103,10 @@ |
| 91 | 103 | "types": "./src/server/snapshotProbe.ts", |
| 92 | 104 | "import": "./src/server/snapshotProbe.ts" |
| 93 | 105 | }, |
| 106 | "./server/snapshotSettings": { | |
| 107 | "types": "./src/server/snapshotSettings.ts", | |
| 108 | "import": "./src/server/snapshotSettings.ts" | |
| 109 | }, | |
| 94 | 110 | "./server/textGeneration": { |
| 95 | 111 | "types": "./src/server/textGeneration.ts", |
| 96 | 112 | "import": "./src/server/textGeneration.ts" |
packages/provider-core/src/client.ts+17| ... | ... | @@ -31,9 +31,26 @@ export interface ProviderEnvironmentField { |
| 31 | 31 | readonly sensitive?: boolean; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | /** | |
| 35 | * A provider glyph as plain data, so web and mobile can each draw it with | |
| 36 | * their own SVG renderer. Colors are per theme; a path without a fill uses | |
| 37 | * the icon's `fill`. | |
| 38 | */ | |
| 39 | export interface ProviderIcon { | |
| 40 | readonly viewBox: string; | |
| 41 | readonly fill: { readonly light: string; readonly dark: string }; | |
| 42 | readonly paths: ReadonlyArray<{ | |
| 43 | readonly d: string; | |
| 44 | readonly fillRule?: "evenodd" | "nonzero"; | |
| 45 | readonly fill?: { readonly light: string; readonly dark: string }; | |
| 46 | }>; | |
| 47 | } | |
| 48 | ||
| 34 | 49 | export interface ProviderClientDefinition { |
| 35 | 50 | readonly driverKind: ProviderDriverKind; |
| 36 | 51 | readonly label: string; |
| 52 | /** Omitted for drivers whose glyph a client still draws itself. */ | |
| 53 | readonly icon?: ProviderIcon; | |
| 37 | 54 | readonly settingsSchema: ProviderSettingsSchema; |
| 38 | 55 | readonly environmentFields?: ReadonlyArray<ProviderEnvironmentField>; |
| 39 | 56 | /** Whether the driver has a default instance backed by legacy `providers.<kind>` settings. */ |
packages/provider-core/src/server/continuationRequests.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import { | |
| 2 | type OrchestrationV2Notification, | |
| 3 | MessageId, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderThreadId, | |
| 6 | RunId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | ||
| 15 | export interface ProviderContinuationRequest { | |
| 16 | readonly threadId: ThreadId; | |
| 17 | readonly providerThreadId: ProviderThreadId; | |
| 18 | readonly driver: ProviderDriverKind; | |
| 19 | readonly detail: string | null; | |
| 20 | readonly notification?: OrchestrationV2Notification; | |
| 21 | /** | |
| 22 | * Durable ownership for an app-owned delegated-task completion delivery. | |
| 23 | * The continuation worker re-reads the cohort before dispatching so a later | |
| 24 | * task can join a queued wake and a stopped or acknowledged cohort is dropped. | |
| 25 | */ | |
| 26 | readonly delegatedCompletion?: { | |
| 27 | readonly parentRunId: RunId; | |
| 28 | readonly generation: number; | |
| 29 | readonly messageId: MessageId; | |
| 30 | }; | |
| 31 | /** | |
| 32 | * How the continuation turn gets its content. | |
| 33 | * | |
| 34 | * `adapter_buffered` (default) is the provider-native wake: the adapter has | |
| 35 | * already buffered the CLI's wake output, and the dispatched message only | |
| 36 | * triggers ingestion. `ClaudeAdapterV2` deliberately discards the message | |
| 37 | * text on that path. | |
| 38 | * | |
| 39 | * `message_text` is for app-owned work with no buffered provider output, such | |
| 40 | * as a delegated child finishing. The text is the entire wake, so it must | |
| 41 | * reach the provider as a real prompt. | |
| 42 | */ | |
| 43 | readonly delivery?: "adapter_buffered" | "message_text"; | |
| 44 | readonly dispatchIfCurrent?: <A, E, R>( | |
| 45 | effect: Effect.Effect<A, E, R>, | |
| 46 | ) => Effect.Effect<Option.Option<A>, E, R>; | |
| 47 | /** Clears a pending offer that the continuation worker intentionally drops. */ | |
| 48 | readonly clearIfCurrent?: () => Effect.Effect<void>; | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Adapters offer a continuation request when provider-native work completes | |
| 53 | * outside an active turn (for example a Claude background task wake turn) so | |
| 54 | * the orchestrator can start a run that ingests it. The default reference | |
| 55 | * drops requests, keeping adapter construction dependency-free in tests; the | |
| 56 | * live layer must be shared with the ProviderContinuationService worker that | |
| 57 | * drains it. | |
| 58 | */ | |
| 59 | export class ProviderContinuationRequests extends Context.Reference<{ | |
| 60 | readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | |
| 61 | readonly take: Effect.Effect<ProviderContinuationRequest>; | |
| 62 | }>("@t3tools/provider-core/server/continuationRequests/ProviderContinuationRequests", { | |
| 63 | defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }), | |
| 64 | }) {} | |
| 65 | ||
| 66 | export const layer = Layer.effect( | |
| 67 | ProviderContinuationRequests, | |
| 68 | Effect.gen(function* () { | |
| 69 | const queue = yield* Queue.unbounded<ProviderContinuationRequest>(); | |
| 70 | return { | |
| 71 | offer: (request: ProviderContinuationRequest) => | |
| 72 | Queue.offer(queue, request).pipe(Effect.asVoid), | |
| 73 | take: Queue.take(queue), | |
| 74 | }; | |
| 75 | }), | |
| 76 | ); |
packages/provider-core/src/server/handoffBudget.ts created+282| ... | ... | @@ -0,0 +1,282 @@ |
| 1 | import type { | |
| 2 | ChatAttachment, | |
| 3 | ModelSelection, | |
| 4 | OrchestrationV2ThreadProjection, | |
| 5 | ThreadTokenUsageSnapshot, | |
| 6 | OrchestrationV2ContextHandoff, | |
| 7 | OrchestrationV2HistoricalMessage, | |
| 8 | OrchestrationV2ProviderThread, | |
| 9 | OrchestrationV2TurnItem, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | ||
| 12 | import * as Config from "effect/Config"; | |
| 13 | ||
| 14 | export const DEFAULT_HANDOFF_TOKEN_CAP = 16_000; | |
| 15 | const HANDOFF_BYTE_CAP = 64_000; | |
| 16 | export const handoffTokenCapConfig = Config.Int("T3CODE_CONTEXT_HANDOFF_TOKEN_CAP").pipe( | |
| 17 | Config.withDefault(DEFAULT_HANDOFF_TOKEN_CAP), | |
| 18 | Config.map((value) => Math.max(1_024, Math.min(HANDOFF_BYTE_CAP, value))), | |
| 19 | ); | |
| 20 | ||
| 21 | // Live reports belong to provider turns. Use only accepted root attempts whose | |
| 22 | // durable native identity matches this thread; row reuse must not revive old usage. | |
| 23 | export function latestNativeContextUsage( | |
| 24 | projection: Pick<OrchestrationV2ThreadProjection, "providerTurns" | "attempts" | "runs">, | |
| 25 | providerThread: OrchestrationV2ProviderThread, | |
| 26 | ) { | |
| 27 | const nativeId = providerThread.nativeThreadRef?.nativeId; | |
| 28 | if (nativeId === undefined) return undefined; | |
| 29 | const attempts = new Map(projection.attempts.map((attempt) => [attempt.id, attempt])); | |
| 30 | const runs = new Map(projection.runs.map((run) => [run.id, run])); | |
| 31 | let latest: | |
| 32 | | { | |
| 33 | usage: ThreadTokenUsageSnapshot; | |
| 34 | modelSelection: ModelSelection; | |
| 35 | reportedAt: string; | |
| 36 | } | |
| 37 | | undefined; | |
| 38 | for (const turn of projection.providerTurns) { | |
| 39 | if ( | |
| 40 | turn.providerThreadId !== providerThread.id || | |
| 41 | turn.runAttemptId === null || | |
| 42 | !turn.tokenUsage | |
| 43 | ) | |
| 44 | continue; | |
| 45 | const attempt = attempts.get(turn.runAttemptId); | |
| 46 | if ( | |
| 47 | attempt?.nativeThreadId !== nativeId || | |
| 48 | attempt.providerThreadId !== providerThread.id || | |
| 49 | attempt.rootNodeId !== turn.nodeId | |
| 50 | ) | |
| 51 | continue; | |
| 52 | const run = runs.get(attempt.runId); | |
| 53 | if (!run || (latest && latest.reportedAt >= turn.tokenUsage.updatedAt)) continue; | |
| 54 | latest = { | |
| 55 | usage: { | |
| 56 | usedTokens: turn.tokenUsage.usedTokens, | |
| 57 | ...(turn.tokenUsage.maxTokens != null && turn.tokenUsage.maxTokens > 0 | |
| 58 | ? { maxTokens: turn.tokenUsage.maxTokens } | |
| 59 | : {}), | |
| 60 | }, | |
| 61 | modelSelection: run.modelSelection, | |
| 62 | reportedAt: turn.tokenUsage.updatedAt, | |
| 63 | }; | |
| 64 | } | |
| 65 | return latest; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Occupancy of the native transcript survives a model or option change. | |
| 70 | * The previous model's window and compaction threshold do not: a byte-length | |
| 71 | * stand-in for the missing measurement overstates the transcript and refuses | |
| 72 | * switches that still fit. A new native thread has no occupancy to carry. | |
| 73 | */ | |
| 74 | export function contextUsageForHandoff(input: { | |
| 75 | readonly sameNativeThread: boolean; | |
| 76 | readonly sameSelection: boolean; | |
| 77 | readonly reuseTelemetry: boolean; | |
| 78 | readonly previousUsage: ThreadTokenUsageSnapshot | null | undefined; | |
| 79 | readonly knownModelWindow?: number | undefined; | |
| 80 | }): ThreadTokenUsageSnapshot | null { | |
| 81 | if (!input.sameNativeThread || input.previousUsage == null) return null; | |
| 82 | if (input.sameSelection) return input.previousUsage; | |
| 83 | const reportedMax = | |
| 84 | input.previousUsage.maxTokens != null && input.previousUsage.maxTokens > 0 | |
| 85 | ? input.previousUsage.maxTokens | |
| 86 | : undefined; | |
| 87 | const maxTokens = input.reuseTelemetry ? reportedMax : (input.knownModelWindow ?? reportedMax); | |
| 88 | return { | |
| 89 | usedTokens: input.previousUsage.usedTokens, | |
| 90 | ...(maxTokens === undefined ? {} : { maxTokens }), | |
| 91 | }; | |
| 92 | } | |
| 93 | ||
| 94 | export function attachmentTokenAllowance(attachments: ReadonlyArray<ChatAttachment>): number { | |
| 95 | // Encoded image bytes are not model tokens. Without dimensions/detail metadata, | |
| 96 | // reserve 8k tokens per image, above typical resized Codex/Claude image costs. | |
| 97 | // This is a fallback estimate, not a bound for original-resolution/custom models. | |
| 98 | // https://developers.openai.com/api/docs/guides/image-cost-calculator | |
| 99 | // https://platform.claude.com/docs/en/build-with-claude/vision | |
| 100 | // Other attachments are path references; reserve space for their descriptors. | |
| 101 | return attachments.reduce( | |
| 102 | (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), | |
| 103 | 0, | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | // One UTF-8 byte per token is deliberately pessimistic for byte-based tokenizers, | |
| 108 | // including multilingual text. It is not a tokenizer or a guarantee for arbitrary | |
| 109 | // custom models. Unknown windows use a 128k allowance, reserving a quarter for | |
| 110 | // tools, instructions and subsequent work. Current input is never truncated. | |
| 111 | export function handoffBudget(input: { | |
| 112 | readonly tokenCap: number; | |
| 113 | readonly userText: string; | |
| 114 | readonly attachments: ReadonlyArray<ChatAttachment>; | |
| 115 | readonly providerThread: OrchestrationV2ProviderThread; | |
| 116 | readonly nativeContextEstimate: number; | |
| 117 | readonly modelContextWindow?: number | undefined; | |
| 118 | }): number { | |
| 119 | const usage = input.providerThread.contextUsage; | |
| 120 | const window = Math.min( | |
| 121 | input.modelContextWindow ?? usage?.maxTokens ?? 128_000, | |
| 122 | usage?.maxTokens ?? Infinity, | |
| 123 | usage?.autoCompactThreshold ?? Infinity, | |
| 124 | ); | |
| 125 | const native = usage?.usedTokens ?? input.nativeContextEstimate; | |
| 126 | const current = | |
| 127 | Buffer.byteLength(JSON.stringify(input.userText)) + attachmentTokenAllowance(input.attachments); | |
| 128 | return Math.max( | |
| 129 | 0, | |
| 130 | Math.min( | |
| 131 | input.tokenCap, | |
| 132 | // Cap only imported history. Attachment transport limits belong to adapters; | |
| 133 | // they may send binary/base64 data separately from the history request. | |
| 134 | HANDOFF_BYTE_CAP, | |
| 135 | window - native - current - Math.max(16_000, Math.ceil(window / 4)), | |
| 136 | ), | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | export function historicalMessage( | |
| 141 | item: OrchestrationV2TurnItem, | |
| 142 | ): OrchestrationV2HistoricalMessage | null { | |
| 143 | let text: string; | |
| 144 | switch (item.type) { | |
| 145 | case "user_message": | |
| 146 | case "assistant_message": | |
| 147 | text = item.text; | |
| 148 | break; | |
| 149 | case "command_execution": | |
| 150 | text = [ | |
| 151 | `Command: ${item.input}`, | |
| 152 | `Exit code: ${item.exitCode ?? "unknown"}`, | |
| 153 | item.output ?? "", | |
| 154 | ].join("\n"); | |
| 155 | break; | |
| 156 | case "error": | |
| 157 | text = item.failure.message; | |
| 158 | break; | |
| 159 | case "run_interrupt_result": | |
| 160 | text = item.message; | |
| 161 | break; | |
| 162 | case "file_change": | |
| 163 | text = `File change: ${item.fileName}`; | |
| 164 | break; | |
| 165 | case "proposed_plan": | |
| 166 | text = item.markdown; | |
| 167 | break; | |
| 168 | default: | |
| 169 | return null; | |
| 170 | } | |
| 171 | return { | |
| 172 | role: item.type === "user_message" ? "user" : "assistant", | |
| 173 | text, | |
| 174 | threadId: item.threadId, | |
| 175 | runId: item.runId, | |
| 176 | itemId: item.id, | |
| 177 | providerThreadId: item.providerThreadId, | |
| 178 | status: item.status, | |
| 179 | kind: item.type, | |
| 180 | }; | |
| 181 | } | |
| 182 | ||
| 183 | function renderHistoricalMessage(message: OrchestrationV2HistoricalMessage): string { | |
| 184 | return `[Historical ${message.role}; ${message.kind}; thread=${message.threadId}; run=${message.runId ?? "imported"}; item=${message.itemId}; provider-thread=${message.providerThreadId ?? "none"}; status=${message.status}${message.runStatus === undefined ? "" : `; run-status=${message.runStatus}`}]\n${message.text}`; | |
| 185 | } | |
| 186 | ||
| 187 | export function historyResponseItems( | |
| 188 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 189 | context: string, | |
| 190 | ) { | |
| 191 | return [ | |
| 192 | { type: "message", role: "user", content: [{ type: "input_text", text: context }] }, | |
| 193 | ...messages.map((message) => ({ | |
| 194 | type: "message", | |
| 195 | role: message.role, | |
| 196 | content: [ | |
| 197 | { | |
| 198 | type: message.role === "user" ? "input_text" : "output_text", | |
| 199 | text: renderHistoricalMessage(message), | |
| 200 | }, | |
| 201 | ], | |
| 202 | })), | |
| 203 | ]; | |
| 204 | } | |
| 205 | ||
| 206 | export function renderHistory( | |
| 207 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 208 | context: string, | |
| 209 | ): string { | |
| 210 | return [context, ...messages.map(renderHistoricalMessage)].join("\n\n"); | |
| 211 | } | |
| 212 | ||
| 213 | // Count the larger delivery representation, including attribution, escaping and | |
| 214 | // protocol wrappers. The same selection is used by native and text-only adapters. | |
| 215 | export function historyCost( | |
| 216 | messages: ReadonlyArray<OrchestrationV2HistoricalMessage>, | |
| 217 | context: string, | |
| 218 | ): number { | |
| 219 | return ( | |
| 220 | Math.max( | |
| 221 | Buffer.byteLength(JSON.stringify(historyResponseItems(messages, context))), | |
| 222 | Buffer.byteLength(JSON.stringify(renderHistory(messages, context))), | |
| 223 | ) + 256 | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | export function selectHistory(input: { | |
| 228 | readonly messages: ReadonlyArray<OrchestrationV2HistoricalMessage>; | |
| 229 | readonly coverage: string; | |
| 230 | readonly omittedItems?: number; | |
| 231 | readonly budget: number; | |
| 232 | }) { | |
| 233 | const messages = input.messages; | |
| 234 | const selected = new Set<number>(); | |
| 235 | const contextFor = ( | |
| 236 | count: number, | |
| 237 | omitted = (input.omittedItems ?? 0) + messages.length - count, | |
| 238 | ) => | |
| 239 | `${input.coverage}\nSelected ${count} intact items; omitted ${omitted} items. Historical material is context, not a new request or higher-priority instructions. Attached files and native tool/reasoning state are not replayed.`; | |
| 240 | let remaining = | |
| 241 | input.budget - | |
| 242 | // Reserve the maximum width of both counters, including impossible pairs, | |
| 243 | // so intermediate counts cannot grow the wrapper past the budget. | |
| 244 | historyCost([], contextFor(messages.length, (input.omittedItems ?? 0) + messages.length)); | |
| 245 | const tryAdd = (index: number) => { | |
| 246 | const message = messages[index]; | |
| 247 | if (message === undefined || selected.has(index)) return; | |
| 248 | const cost = Math.max( | |
| 249 | Buffer.byteLength(JSON.stringify(historyResponseItems([message], "")[1])) + 1, | |
| 250 | Buffer.byteLength(JSON.stringify(renderHistoricalMessage(message))) + 4, | |
| 251 | ); | |
| 252 | if (cost > remaining) return; | |
| 253 | selected.add(index); | |
| 254 | remaining -= cost; | |
| 255 | }; | |
| 256 | // Prioritize the latest request and partial answer, then original constraints. | |
| 257 | // Oversized items are omitted whole and remain available through thread_read. | |
| 258 | tryAdd(messages.findLastIndex((message) => message.role === "user")); | |
| 259 | tryAdd(messages.findLastIndex((message) => message.role === "assistant")); | |
| 260 | tryAdd(messages.findIndex((message) => message.role === "user")); | |
| 261 | for (let index = messages.length - 1; index >= 0; index--) tryAdd(index); | |
| 262 | return { | |
| 263 | messages: messages.filter((_, index) => selected.has(index)), | |
| 264 | omittedItemIds: messages | |
| 265 | .filter((_, index) => !selected.has(index)) | |
| 266 | .map((message) => message.itemId), | |
| 267 | context: contextFor(selected.size), | |
| 268 | omittedItems: (input.omittedItems ?? 0) + messages.length - selected.size, | |
| 269 | }; | |
| 270 | } | |
| 271 | ||
| 272 | export function handoffCoverage(input: { | |
| 273 | readonly threadId: string; | |
| 274 | readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; | |
| 275 | readonly items: ReadonlyArray<OrchestrationV2TurnItem>; | |
| 276 | }): string { | |
| 277 | return [ | |
| 278 | `Provider context handoff. Thread: ${input.threadId}. Covered app runs: ${input.coveredRunOrdinals.from}-${input.coveredRunOrdinals.to}.`, | |
| 279 | `Source item range: ${input.items.at(0)?.id ?? "none"} through ${input.items.at(-1)?.id ?? "none"}.`, | |
| 280 | `Recover omitted history using t3_thread_read({threadId:"${input.threadId}",view:"activity",limit:20,maxCharsPerItem:4000}); paginate with afterPosition=nextPosition. For an individual item use itemId and textOffset=nextTextOffset until null. Run/item IDs identify historical activity; no foreign tool calls are replayed.`, | |
| 281 | ].join("\n"); | |
| 282 | } |
packages/provider-core/src/server/managedProvider.test.ts created+642| ... | ... | @@ -0,0 +1,642 @@ |
| 1 | import { describe, it, assert } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderInstanceId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 9 | import * as Deferred from "effect/Deferred"; | |
| 10 | import * as Duration from "effect/Duration"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as PubSub from "effect/PubSub"; | |
| 15 | import * as Ref from "effect/Ref"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import { TestClock } from "effect/testing"; | |
| 18 | ||
| 19 | import { ProviderHost, type ProviderHostShape } from "./ProviderHost.ts"; | |
| 20 | import { makeManagedServerProvider } from "./managedProvider.ts"; | |
| 21 | ||
| 22 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | |
| 23 | const fastModeCapabilities = createModelCapabilities({ | |
| 24 | optionDescriptors: [ | |
| 25 | { | |
| 26 | id: "fastMode", | |
| 27 | label: "Fast Mode", | |
| 28 | type: "boolean", | |
| 29 | }, | |
| 30 | ], | |
| 31 | }); | |
| 32 | ||
| 33 | interface TestSettings { | |
| 34 | readonly enabled: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | const maintenanceCapabilities = { | |
| 38 | provider: ProviderDriverKind.make("codex"), | |
| 39 | packageName: "@openai/codex", | |
| 40 | update: { | |
| 41 | command: "npm install -g @openai/codex@latest", | |
| 42 | ||
| 43 | executable: "npm", | |
| 44 | ||
| 45 | args: ["install", "-g", "@openai/codex@latest"], | |
| 46 | ||
| 47 | lockKey: "npm-global", | |
| 48 | }, | |
| 49 | } as const; | |
| 50 | ||
| 51 | const initialSnapshot: ServerProvider = { | |
| 52 | instanceId: ProviderInstanceId.make("codex"), | |
| 53 | driver: ProviderDriverKind.make("codex"), | |
| 54 | enabled: true, | |
| 55 | installed: true, | |
| 56 | version: null, | |
| 57 | status: "warning", | |
| 58 | auth: { status: "unknown" }, | |
| 59 | checkedAt: "2026-04-10T00:00:00.000Z", | |
| 60 | message: "Checking provider availability...", | |
| 61 | models: [], | |
| 62 | slashCommands: [], | |
| 63 | skills: [], | |
| 64 | }; | |
| 65 | ||
| 66 | const refreshedSnapshot: ServerProvider = { | |
| 67 | instanceId: ProviderInstanceId.make("codex"), | |
| 68 | driver: ProviderDriverKind.make("codex"), | |
| 69 | enabled: true, | |
| 70 | installed: true, | |
| 71 | version: "1.0.0", | |
| 72 | status: "ready", | |
| 73 | auth: { status: "authenticated" }, | |
| 74 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 75 | models: [], | |
| 76 | slashCommands: [], | |
| 77 | skills: [], | |
| 78 | }; | |
| 79 | ||
| 80 | const enrichedSnapshot: ServerProvider = { | |
| 81 | ...refreshedSnapshot, | |
| 82 | checkedAt: "2026-04-10T00:00:02.000Z", | |
| 83 | models: [ | |
| 84 | { | |
| 85 | slug: "composer-2", | |
| 86 | name: "Composer 2", | |
| 87 | isCustom: false, | |
| 88 | capabilities: fastModeCapabilities, | |
| 89 | }, | |
| 90 | ], | |
| 91 | }; | |
| 92 | ||
| 93 | const refreshedSnapshotSecond: ServerProvider = { | |
| 94 | ...refreshedSnapshot, | |
| 95 | checkedAt: "2026-04-10T00:00:03.000Z", | |
| 96 | message: "Refreshed provider availability again.", | |
| 97 | }; | |
| 98 | ||
| 99 | /** A host whose settings never change and whose background demand is fixed. */ | |
| 100 | function layerProviderHost(input: { | |
| 101 | readonly runBackgroundWork: boolean; | |
| 102 | readonly settings?: Pick<ProviderHostShape, "settings">["settings"]; | |
| 103 | }) { | |
| 104 | return Layer.succeed( | |
| 105 | ProviderHost, | |
| 106 | ProviderHost.of({ | |
| 107 | paths: { | |
| 108 | cwd: process.cwd(), | |
| 109 | baseDir: "/t3", | |
| 110 | stateDir: "/t3/userdata", | |
| 111 | providerStatusCacheDir: "/t3/caches", | |
| 112 | }, | |
| 113 | settings: input.settings ?? { | |
| 114 | get: Effect.succeed(DEFAULT_SERVER_SETTINGS), | |
| 115 | changes: Stream.empty, | |
| 116 | subscribe: Effect.succeed(Stream.empty), | |
| 117 | }, | |
| 118 | shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), | |
| 119 | resolveAttachmentPath: () => null, | |
| 120 | }), | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | const layerAlwaysRunTest = layerProviderHost({ runBackgroundWork: true }); | |
| 125 | const layerNeverRunTest = layerProviderHost({ runBackgroundWork: false }); | |
| 126 | ||
| 127 | const enrichedSnapshotSecond: ServerProvider = { | |
| 128 | ...refreshedSnapshotSecond, | |
| 129 | checkedAt: "2026-04-10T00:00:04.000Z", | |
| 130 | models: [ | |
| 131 | { | |
| 132 | slug: "gpt-5.4", | |
| 133 | name: "GPT-5.4", | |
| 134 | isCustom: false, | |
| 135 | capabilities: emptyCapabilities, | |
| 136 | }, | |
| 137 | ], | |
| 138 | }; | |
| 139 | ||
| 140 | describe("makeManagedServerProvider", () => { | |
| 141 | it.effect( | |
| 142 | "runs the initial provider check in the background and streams the refreshed snapshot", | |
| 143 | () => | |
| 144 | Effect.scoped( | |
| 145 | Effect.gen(function* () { | |
| 146 | const checkCalls = yield* Ref.make(0); | |
| 147 | const releaseCheck = yield* Deferred.make<void>(); | |
| 148 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 149 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 150 | getSettings: Effect.succeed({ enabled: true }), | |
| 151 | streamSettings: Stream.empty, | |
| 152 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 153 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 154 | checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( | |
| 155 | Effect.flatMap(() => Deferred.await(releaseCheck)), | |
| 156 | Effect.as(refreshedSnapshot), | |
| 157 | ), | |
| 158 | refreshInterval: "1 hour", | |
| 159 | }); | |
| 160 | ||
| 161 | const initial = yield* provider.getSnapshot; | |
| 162 | assert.deepStrictEqual(initial, initialSnapshot); | |
| 163 | ||
| 164 | const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 165 | Stream.runCollect, | |
| 166 | Effect.forkChild, | |
| 167 | ); | |
| 168 | yield* Effect.yieldNow; | |
| 169 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 170 | ||
| 171 | yield* Deferred.succeed(releaseCheck, undefined); | |
| 172 | ||
| 173 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 174 | const latest = yield* provider.getSnapshot; | |
| 175 | ||
| 176 | assert.deepStrictEqual(updates, [refreshedSnapshot]); | |
| 177 | assert.deepStrictEqual(latest, refreshedSnapshot); | |
| 178 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 179 | }), | |
| 180 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 181 | ); | |
| 182 | ||
| 183 | it.effect("skips periodic provider refreshes without foreground provider-status demand", () => | |
| 184 | Effect.scoped( | |
| 185 | Effect.gen(function* () { | |
| 186 | const checkCalls = yield* Ref.make(0); | |
| 187 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 188 | yield* makeManagedServerProvider<TestSettings>({ | |
| 189 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 190 | getSettings: Effect.succeed({ enabled: true }), | |
| 191 | streamSettings: Stream.empty, | |
| 192 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 193 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 194 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 195 | Effect.tap((count) => | |
| 196 | count === 1 | |
| 197 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 198 | : Effect.void, | |
| 199 | ), | |
| 200 | Effect.as(refreshedSnapshot), | |
| 201 | ), | |
| 202 | refreshInterval: "1 second", | |
| 203 | }); | |
| 204 | ||
| 205 | yield* Deferred.await(initialCheckDone); | |
| 206 | yield* TestClock.adjust("1 second"); | |
| 207 | yield* Effect.yieldNow; | |
| 208 | ||
| 209 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 210 | }), | |
| 211 | ).pipe(Effect.provide(Layer.mergeAll(layerNeverRunTest, TestClock.layer()))), | |
| 212 | ); | |
| 213 | ||
| 214 | it.effect("disables periodic provider refreshes when the explicit interval is zero", () => | |
| 215 | Effect.scoped( | |
| 216 | Effect.gen(function* () { | |
| 217 | const checkCalls = yield* Ref.make(0); | |
| 218 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 219 | yield* makeManagedServerProvider<TestSettings>({ | |
| 220 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 221 | getSettings: Effect.succeed({ enabled: true }), | |
| 222 | streamSettings: Stream.empty, | |
| 223 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 224 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 225 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 226 | Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), | |
| 227 | Effect.as(refreshedSnapshot), | |
| 228 | ), | |
| 229 | refreshInterval: 0, | |
| 230 | }); | |
| 231 | ||
| 232 | yield* Deferred.await(initialCheckDone); | |
| 233 | yield* TestClock.adjust("5 minutes"); | |
| 234 | yield* Effect.yieldNow; | |
| 235 | ||
| 236 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 237 | }), | |
| 238 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 239 | ); | |
| 240 | ||
| 241 | it.effect("keeps manual refresh when interval refresh is disabled", () => | |
| 242 | Effect.scoped( | |
| 243 | Effect.gen(function* () { | |
| 244 | const checkCalls = yield* Ref.make(0); | |
| 245 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 246 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 247 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 248 | getSettings: Effect.succeed({ enabled: true }), | |
| 249 | streamSettings: Stream.empty, | |
| 250 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 251 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 252 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 253 | Effect.tap((count) => | |
| 254 | count === 1 | |
| 255 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 256 | : Effect.void, | |
| 257 | ), | |
| 258 | Effect.as(refreshedSnapshot), | |
| 259 | ), | |
| 260 | refreshInterval: "1 second", | |
| 261 | refreshOnInterval: false, | |
| 262 | }); | |
| 263 | ||
| 264 | yield* Deferred.await(initialCheckDone); | |
| 265 | yield* TestClock.adjust("5 minutes"); | |
| 266 | yield* Effect.yieldNow; | |
| 267 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 268 | ||
| 269 | yield* provider.refresh; | |
| 270 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 271 | }), | |
| 272 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 273 | ); | |
| 274 | ||
| 275 | it.effect("wakes a sleeping provider refresh loop when its interval changes", () => | |
| 276 | Effect.scoped( | |
| 277 | Effect.gen(function* () { | |
| 278 | const initialServerSettings = { | |
| 279 | ...DEFAULT_SERVER_SETTINGS, | |
| 280 | providerHealthRefreshInterval: Duration.hours(1), | |
| 281 | }; | |
| 282 | const serverSettingsRef = yield* Ref.make(initialServerSettings); | |
| 283 | const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>(); | |
| 284 | const hostSettings: ProviderHostShape["settings"] = { | |
| 285 | get: Ref.get(serverSettingsRef), | |
| 286 | changes: Stream.empty, | |
| 287 | subscribe: PubSub.subscribe(serverSettingsChanges).pipe( | |
| 288 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 289 | ), | |
| 290 | }; | |
| 291 | const checkCalls = yield* Ref.make(0); | |
| 292 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 293 | const periodicCheckDone = yield* Deferred.make<void>(); | |
| 294 | ||
| 295 | yield* makeManagedServerProvider<TestSettings>({ | |
| 296 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 297 | getSettings: Effect.succeed({ enabled: true }), | |
| 298 | streamSettings: Stream.empty, | |
| 299 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 300 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 301 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 302 | Effect.tap((count) => | |
| 303 | count === 1 | |
| 304 | ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) | |
| 305 | : Deferred.succeed(periodicCheckDone, undefined).pipe(Effect.ignore), | |
| 306 | ), | |
| 307 | Effect.as(refreshedSnapshot), | |
| 308 | ), | |
| 309 | }).pipe( | |
| 310 | Effect.provide(layerProviderHost({ runBackgroundWork: true, settings: hostSettings })), | |
| 311 | ); | |
| 312 | ||
| 313 | yield* Deferred.await(initialCheckDone); | |
| 314 | const nextServerSettings = { | |
| 315 | ...initialServerSettings, | |
| 316 | providerHealthRefreshInterval: Duration.seconds(1), | |
| 317 | }; | |
| 318 | yield* Ref.set(serverSettingsRef, nextServerSettings); | |
| 319 | yield* PubSub.publish(serverSettingsChanges, nextServerSettings); | |
| 320 | yield* Effect.yieldNow; | |
| 321 | ||
| 322 | yield* TestClock.adjust("999 millis"); | |
| 323 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 324 | yield* TestClock.adjust("1 millis"); | |
| 325 | yield* Deferred.await(periodicCheckDone); | |
| 326 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 327 | }), | |
| 328 | ).pipe(Effect.provide(TestClock.layer())), | |
| 329 | ); | |
| 330 | ||
| 331 | it.effect("reruns the provider check when streamed settings change", () => | |
| 332 | Effect.scoped( | |
| 333 | Effect.gen(function* () { | |
| 334 | const settingsRef = yield* Ref.make<TestSettings>({ enabled: true }); | |
| 335 | const settingsChanges = yield* PubSub.unbounded<TestSettings>(); | |
| 336 | const checkCalls = yield* Ref.make(0); | |
| 337 | const releaseInitialCheck = yield* Deferred.make<void>(); | |
| 338 | const releaseSettingsCheck = yield* Deferred.make<void>(); | |
| 339 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 340 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 341 | getSettings: Ref.get(settingsRef), | |
| 342 | streamSettings: Stream.fromPubSub(settingsChanges), | |
| 343 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 344 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 345 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 346 | Effect.flatMap((count) => | |
| 347 | count === 1 | |
| 348 | ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) | |
| 349 | : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), | |
| 350 | ), | |
| 351 | ), | |
| 352 | refreshInterval: "1 hour", | |
| 353 | }); | |
| 354 | ||
| 355 | const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( | |
| 356 | Stream.runCollect, | |
| 357 | Effect.forkChild, | |
| 358 | ); | |
| 359 | yield* Effect.yieldNow; | |
| 360 | ||
| 361 | yield* Deferred.succeed(releaseInitialCheck, undefined); | |
| 362 | yield* Ref.set(settingsRef, { enabled: false }); | |
| 363 | yield* PubSub.publish(settingsChanges, { enabled: false }); | |
| 364 | yield* Deferred.succeed(releaseSettingsCheck, undefined); | |
| 365 | ||
| 366 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 367 | const latest = yield* provider.getSnapshot; | |
| 368 | ||
| 369 | assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]); | |
| 370 | assert.deepStrictEqual(latest, refreshedSnapshotSecond); | |
| 371 | assert.strictEqual(yield* Ref.get(checkCalls), 2); | |
| 372 | }), | |
| 373 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 374 | ); | |
| 375 | ||
| 376 | it.effect("can update settings and disable periodic checks without probing again", () => | |
| 377 | Effect.scoped( | |
| 378 | Effect.gen(function* () { | |
| 379 | const settingsChanges = yield* PubSub.unbounded<TestSettings>(); | |
| 380 | const checkCalls = yield* Ref.make(0); | |
| 381 | const initialCheckDone = yield* Deferred.make<void>(); | |
| 382 | const enrichmentCalls = yield* Ref.make(0); | |
| 383 | yield* makeManagedServerProvider<TestSettings>({ | |
| 384 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 385 | getSettings: Effect.succeed({ enabled: true }), | |
| 386 | streamSettings: Stream.fromPubSub(settingsChanges), | |
| 387 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 388 | checkProviderOnSettingsChange: () => false, | |
| 389 | refreshOnInterval: false, | |
| 390 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 391 | checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( | |
| 392 | Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), | |
| 393 | Effect.as(refreshedSnapshot), | |
| 394 | ), | |
| 395 | enrichSnapshot: () => Ref.update(enrichmentCalls, (count) => count + 1), | |
| 396 | refreshInterval: "1 second", | |
| 397 | }); | |
| 398 | ||
| 399 | yield* Deferred.await(initialCheckDone); | |
| 400 | yield* PubSub.publish(settingsChanges, { enabled: false }); | |
| 401 | yield* Effect.yieldNow; | |
| 402 | yield* TestClock.adjust("1 second"); | |
| 403 | yield* Effect.yieldNow; | |
| 404 | ||
| 405 | assert.strictEqual(yield* Ref.get(checkCalls), 1); | |
| 406 | assert.strictEqual(yield* Ref.get(enrichmentCalls), 2); | |
| 407 | }), | |
| 408 | ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), | |
| 409 | ); | |
| 410 | ||
| 411 | it.effect("streams supplemental snapshot updates after the base provider check completes", () => | |
| 412 | Effect.scoped( | |
| 413 | Effect.gen(function* () { | |
| 414 | const releaseEnrichment = yield* Deferred.make<void>(); | |
| 415 | const releaseCheck = yield* Deferred.make<void>(); | |
| 416 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 417 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 418 | getSettings: Effect.succeed({ enabled: true }), | |
| 419 | streamSettings: Stream.empty, | |
| 420 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 421 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 422 | checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), | |
| 423 | enrichSnapshot: ({ publishSnapshot }) => | |
| 424 | Deferred.await(releaseEnrichment).pipe( | |
| 425 | Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), | |
| 426 | ), | |
| 427 | refreshInterval: "1 hour", | |
| 428 | }); | |
| 429 | ||
| 430 | const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( | |
| 431 | Stream.runCollect, | |
| 432 | Effect.forkChild, | |
| 433 | ); | |
| 434 | yield* Effect.yieldNow; | |
| 435 | ||
| 436 | yield* Deferred.succeed(releaseCheck, undefined); | |
| 437 | ||
| 438 | yield* Deferred.succeed(releaseEnrichment, undefined); | |
| 439 | ||
| 440 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 441 | const latest = yield* provider.getSnapshot; | |
| 442 | ||
| 443 | assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); | |
| 444 | assert.deepStrictEqual(latest, enrichedSnapshot); | |
| 445 | }), | |
| 446 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 447 | ); | |
| 448 | ||
| 449 | it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => | |
| 450 | Effect.scoped( | |
| 451 | Effect.gen(function* () { | |
| 452 | const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect<void>> = []; | |
| 453 | const refreshCount = yield* Ref.make(0); | |
| 454 | const firstCallbackReady = yield* Deferred.make<void>(); | |
| 455 | const secondCallbackReady = yield* Deferred.make<void>(); | |
| 456 | const allowFirstRefresh = yield* Deferred.make<void>(); | |
| 457 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 458 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 459 | getSettings: Effect.succeed({ enabled: true }), | |
| 460 | streamSettings: Stream.empty, | |
| 461 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 462 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 463 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | |
| 464 | Effect.flatMap((count) => | |
| 465 | count === 1 | |
| 466 | ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) | |
| 467 | : Effect.succeed(refreshedSnapshotSecond), | |
| 468 | ), | |
| 469 | ), | |
| 470 | enrichSnapshot: ({ publishSnapshot }) => | |
| 471 | Effect.gen(function* () { | |
| 472 | publishCallbacks.push(publishSnapshot); | |
| 473 | if (publishCallbacks.length === 1) { | |
| 474 | yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore); | |
| 475 | } else if (publishCallbacks.length === 2) { | |
| 476 | yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore); | |
| 477 | } | |
| 478 | }), | |
| 479 | refreshInterval: "1 hour", | |
| 480 | }); | |
| 481 | ||
| 482 | const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe( | |
| 483 | Stream.runCollect, | |
| 484 | Effect.forkChild, | |
| 485 | ); | |
| 486 | yield* Effect.yieldNow; | |
| 487 | ||
| 488 | yield* Deferred.succeed(allowFirstRefresh, undefined); | |
| 489 | yield* Deferred.await(firstCallbackReady); | |
| 490 | ||
| 491 | yield* provider.refresh; | |
| 492 | yield* Deferred.await(secondCallbackReady); | |
| 493 | ||
| 494 | yield* publishCallbacks[0]!(enrichedSnapshot); | |
| 495 | yield* publishCallbacks[1]!(enrichedSnapshotSecond); | |
| 496 | ||
| 497 | const updates = Array.from(yield* Fiber.join(updatesFiber)); | |
| 498 | const latest = yield* provider.getSnapshot; | |
| 499 | ||
| 500 | assert.deepStrictEqual(updates, [ | |
| 501 | refreshedSnapshot, | |
| 502 | refreshedSnapshotSecond, | |
| 503 | enrichedSnapshotSecond, | |
| 504 | ]); | |
| 505 | assert.deepStrictEqual(latest, enrichedSnapshotSecond); | |
| 506 | }), | |
| 507 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 508 | ); | |
| 509 | ||
| 510 | it.effect("applies runtime usage updates onto the published snapshot", () => | |
| 511 | Effect.scoped( | |
| 512 | Effect.gen(function* () { | |
| 513 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 514 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 515 | getSettings: Effect.succeed({ enabled: true }), | |
| 516 | streamSettings: Stream.empty, | |
| 517 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 518 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 519 | checkProvider: Effect.succeed({ | |
| 520 | ...refreshedSnapshot, | |
| 521 | usageLimits: { | |
| 522 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 523 | windows: [ | |
| 524 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 525 | { | |
| 526 | id: "seven_day", | |
| 527 | kind: "weekly", | |
| 528 | label: "Weekly", | |
| 529 | usedPercent: 20, | |
| 530 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 531 | }, | |
| 532 | ], | |
| 533 | }, | |
| 534 | } satisfies ServerProvider), | |
| 535 | refreshInterval: "1 hour", | |
| 536 | }); | |
| 537 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 538 | ||
| 539 | const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 540 | Stream.runCollect, | |
| 541 | Effect.forkChild, | |
| 542 | ); | |
| 543 | yield* Effect.yieldNow; | |
| 544 | ||
| 545 | // Percent-only weekly update: keeps the probe's reset time. | |
| 546 | yield* provider.applyUsageLimits({ | |
| 547 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 548 | windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], | |
| 549 | }); | |
| 550 | // No windows: nothing to merge, nothing published. | |
| 551 | yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); | |
| 552 | ||
| 553 | const [update] = Array.from(yield* Fiber.join(updatesFiber)); | |
| 554 | assert.deepStrictEqual(update?.usageLimits, { | |
| 555 | checkedAt: "2026-04-10T00:05:00.000Z", | |
| 556 | windows: [ | |
| 557 | { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, | |
| 558 | { | |
| 559 | id: "seven_day", | |
| 560 | kind: "weekly", | |
| 561 | label: "Weekly", | |
| 562 | usedPercent: 25, | |
| 563 | resetsAt: "2026-04-17T00:00:00.000Z", | |
| 564 | }, | |
| 565 | ], | |
| 566 | }); | |
| 567 | assert.deepStrictEqual(yield* provider.getSnapshot, update); | |
| 568 | }), | |
| 569 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 570 | ); | |
| 571 | ||
| 572 | it.effect("keeps live usage windows across a failed probe and a stale enrichment", () => | |
| 573 | Effect.scoped( | |
| 574 | Effect.gen(function* () { | |
| 575 | const releaseEnrichment = yield* Deferred.make<void>(); | |
| 576 | const refreshCount = yield* Ref.make(0); | |
| 577 | const probedLimits = { | |
| 578 | checkedAt: "2026-04-10T00:00:01.000Z", | |
| 579 | windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }], | |
| 580 | } as const; | |
| 581 | const provider = yield* makeManagedServerProvider<TestSettings>({ | |
| 582 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | |
| 583 | getSettings: Effect.succeed({ enabled: true }), | |
| 584 | streamSettings: Stream.empty, | |
| 585 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | |
| 586 | initialSnapshot: () => Effect.succeed(initialSnapshot), | |
| 587 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | |
| 588 | Effect.map((count) => | |
| 589 | count === 1 | |
| 590 | ? { ...refreshedSnapshot, usageLimits: probedLimits } | |
| 591 | : { | |
| 592 | ...refreshedSnapshotSecond, | |
| 593 | usageLimits: { | |
| 594 | checkedAt: "2026-04-10T00:00:03.000Z", | |
| 595 | windows: [], | |
| 596 | unavailable: { reason: "probeFailed" }, | |
| 597 | }, | |
| 598 | }, | |
| 599 | ), | |
| 600 | ), | |
| 601 | enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 602 | Deferred.await(releaseEnrichment).pipe( | |
| 603 | Effect.flatMap(() => | |
| 604 | publishSnapshot({ | |
| 605 | ...enrichedSnapshot, | |
| 606 | ...snapshot, | |
| 607 | models: enrichedSnapshot.models, | |
| 608 | }), | |
| 609 | ), | |
| 610 | ), | |
| 611 | refreshInterval: "1 hour", | |
| 612 | }); | |
| 613 | yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); | |
| 614 | ||
| 615 | const liveWindow = { | |
| 616 | id: "primary", | |
| 617 | kind: "session", | |
| 618 | label: "Session", | |
| 619 | usedPercent: 60, | |
| 620 | } as const; | |
| 621 | yield* provider.applyUsageLimits({ | |
| 622 | checkedAt: "2026-04-10T00:00:02.000Z", | |
| 623 | windows: [liveWindow], | |
| 624 | }); | |
| 625 | ||
| 626 | // Enrichment computed from the pre-update snapshot lands afterwards. | |
| 627 | yield* Deferred.succeed(releaseEnrichment, undefined); | |
| 628 | const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( | |
| 629 | Stream.runCollect, | |
| 630 | Effect.map((chunk) => Array.from(chunk)[0]!), | |
| 631 | ); | |
| 632 | assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); | |
| 633 | assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); | |
| 634 | ||
| 635 | // A probe that could not read usage keeps the last good windows. | |
| 636 | const refreshed = yield* provider.refresh; | |
| 637 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); | |
| 638 | assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); | |
| 639 | }), | |
| 640 | ).pipe(Effect.provide(layerAlwaysRunTest)), | |
| 641 | ); | |
| 642 | }); |
packages/provider-core/src/server/managedProvider.ts created+289| ... | ... | @@ -0,0 +1,289 @@ |
| 1 | import { | |
| 2 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | |
| 3 | type ServerProvider, | |
| 4 | ServerSettingsError, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 7 | import * as Duration from "effect/Duration"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Equal from "effect/Equal"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as PubSub from "effect/PubSub"; | |
| 12 | import * as Queue from "effect/Queue"; | |
| 13 | import * as Ref from "effect/Ref"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | ||
| 18 | import { ProviderHost } from "./ProviderHost.ts"; | |
| 19 | import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; | |
| 20 | import type { ServerProviderShape } from "./snapshot.ts"; | |
| 21 | ||
| 22 | interface ProviderSnapshotState { | |
| 23 | readonly snapshot: ServerProvider; | |
| 24 | readonly enrichmentGeneration: number; | |
| 25 | } | |
| 26 | ||
| 27 | function withUsageLimits( | |
| 28 | snapshot: ServerProvider, | |
| 29 | usageLimits: ServerProvider["usageLimits"], | |
| 30 | ): ServerProvider { | |
| 31 | if (snapshot.usageLimits === usageLimits) { | |
| 32 | return snapshot; | |
| 33 | } | |
| 34 | const { usageLimits: _previous, ...rest } = snapshot; | |
| 35 | return usageLimits ? { ...rest, usageLimits } : rest; | |
| 36 | } | |
| 37 | ||
| 38 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < | |
| 39 | Settings, | |
| 40 | >(input: { | |
| 41 | readonly resolveMaintenance: ServerProviderShape["resolveMaintenance"]; | |
| 42 | readonly getSettings: Effect.Effect<Settings, ServerSettingsError>; | |
| 43 | readonly streamSettings: Stream.Stream<Settings>; | |
| 44 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; | |
| 45 | readonly initialSnapshot: (settings: Settings) => Effect.Effect<ServerProvider>; | |
| 46 | readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>; | |
| 47 | readonly enrichSnapshot?: (input: { | |
| 48 | readonly settings: Settings; | |
| 49 | readonly snapshot: ServerProvider; | |
| 50 | readonly getSnapshot: Effect.Effect<ServerProvider>; | |
| 51 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | |
| 52 | }) => Effect.Effect<void>; | |
| 53 | readonly refreshInterval?: Duration.Input; | |
| 54 | readonly refreshOnInterval?: boolean; | |
| 55 | readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; | |
| 56 | }): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope | ProviderHost> { | |
| 57 | const host = yield* ProviderHost; | |
| 58 | const refreshSemaphore = yield* Semaphore.make(1); | |
| 59 | const changesPubSub = yield* Effect.acquireRelease( | |
| 60 | PubSub.unbounded<ServerProvider>(), | |
| 61 | PubSub.shutdown, | |
| 62 | ); | |
| 63 | const initialSettings = yield* input.getSettings; | |
| 64 | const initialSnapshot = yield* input.initialSnapshot(initialSettings); | |
| 65 | const snapshotStateRef = yield* Ref.make<ProviderSnapshotState>({ | |
| 66 | snapshot: initialSnapshot, | |
| 67 | enrichmentGeneration: 0, | |
| 68 | }); | |
| 69 | const settingsRef = yield* Ref.make(initialSettings); | |
| 70 | const enrichmentFiberRef = yield* Ref.make<Fiber.Fiber<void, unknown> | null>(null); | |
| 71 | const scope = yield* Effect.scope; | |
| 72 | ||
| 73 | const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( | |
| 74 | generation: number, | |
| 75 | nextSnapshot: ServerProvider, | |
| 76 | ) { | |
| 77 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 78 | if (state.enrichmentGeneration !== generation) { | |
| 79 | return [null, state] as const; | |
| 80 | } | |
| 81 | // Enrichment derives from the snapshot it was handed; a runtime usage | |
| 82 | // update that landed since must not be reverted by it. | |
| 83 | const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); | |
| 84 | if (Equal.equals(state.snapshot, merged)) { | |
| 85 | return [null, state] as const; | |
| 86 | } | |
| 87 | return [merged, { ...state, snapshot: merged }] as const; | |
| 88 | }); | |
| 89 | if (snapshotToPublish === null) { | |
| 90 | return; | |
| 91 | } | |
| 92 | yield* PubSub.publish(changesPubSub, snapshotToPublish); | |
| 93 | }); | |
| 94 | ||
| 95 | const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( | |
| 96 | settings: Settings, | |
| 97 | snapshot: ServerProvider, | |
| 98 | generation: number, | |
| 99 | ) { | |
| 100 | const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); | |
| 101 | if (previousFiber) { | |
| 102 | yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); | |
| 103 | } | |
| 104 | ||
| 105 | if (!input.enrichSnapshot) { | |
| 106 | return; | |
| 107 | } | |
| 108 | ||
| 109 | const fiber = yield* input | |
| 110 | .enrichSnapshot({ | |
| 111 | settings, | |
| 112 | snapshot, | |
| 113 | getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), | |
| 114 | publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), | |
| 115 | }) | |
| 116 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); | |
| 117 | ||
| 118 | yield* Ref.set(enrichmentFiberRef, fiber); | |
| 119 | }); | |
| 120 | ||
| 121 | const applySnapshotBase = Effect.fn("applySnapshot")(function* ( | |
| 122 | nextSettings: Settings, | |
| 123 | options?: { readonly forceRefresh?: boolean }, | |
| 124 | ) { | |
| 125 | const forceRefresh = options?.forceRefresh === true; | |
| 126 | const previousSettings = yield* Ref.get(settingsRef); | |
| 127 | if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { | |
| 128 | yield* Ref.set(settingsRef, nextSettings); | |
| 129 | return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); | |
| 130 | } | |
| 131 | ||
| 132 | if ( | |
| 133 | !forceRefresh && | |
| 134 | input.checkProviderOnSettingsChange?.(previousSettings, nextSettings) === false | |
| 135 | ) { | |
| 136 | const state = yield* Ref.get(snapshotStateRef); | |
| 137 | const nextGeneration = state.enrichmentGeneration + 1; | |
| 138 | yield* Ref.set(snapshotStateRef, { | |
| 139 | ...state, | |
| 140 | enrichmentGeneration: nextGeneration, | |
| 141 | }); | |
| 142 | yield* Ref.set(settingsRef, nextSettings); | |
| 143 | yield* restartSnapshotEnrichment(nextSettings, state.snapshot, nextGeneration); | |
| 144 | return state.snapshot; | |
| 145 | } | |
| 146 | ||
| 147 | const probedSnapshot = yield* input.checkProvider; | |
| 148 | const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( | |
| 149 | snapshotStateRef, | |
| 150 | (state) => { | |
| 151 | const generation = input.enrichSnapshot | |
| 152 | ? state.enrichmentGeneration + 1 | |
| 153 | : state.enrichmentGeneration; | |
| 154 | const snapshot = withUsageLimits( | |
| 155 | probedSnapshot, | |
| 156 | resolveUsageLimitsAfterProbe({ | |
| 157 | published: state.snapshot.usageLimits, | |
| 158 | probed: probedSnapshot.usageLimits, | |
| 159 | }), | |
| 160 | ); | |
| 161 | return [ | |
| 162 | { snapshot, generation }, | |
| 163 | { snapshot, enrichmentGeneration: generation }, | |
| 164 | ] as const; | |
| 165 | }, | |
| 166 | ); | |
| 167 | yield* Ref.set(settingsRef, nextSettings); | |
| 168 | yield* PubSub.publish(changesPubSub, nextSnapshot); | |
| 169 | yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); | |
| 170 | return nextSnapshot; | |
| 171 | }); | |
| 172 | const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => | |
| 173 | refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); | |
| 174 | ||
| 175 | /** | |
| 176 | * Runtime usage updates arrive between probes. They patch only | |
| 177 | * `usageLimits` on whatever snapshot is published and leave the enrichment | |
| 178 | * generation alone, so an in-flight enrichment still lands. | |
| 179 | */ | |
| 180 | const applyUsageLimits: ServerProviderShape["applyUsageLimits"] = (update) => | |
| 181 | Effect.gen(function* () { | |
| 182 | const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { | |
| 183 | const usageLimits = applyUsageLimitsUpdate({ | |
| 184 | previous: state.snapshot.usageLimits, | |
| 185 | update, | |
| 186 | checkedAt: update.checkedAt, | |
| 187 | }); | |
| 188 | // `applyUsageLimitsUpdate` hands back the same object when nothing | |
| 189 | // moved, which is the common case for Codex's per-tick notification. | |
| 190 | if (usageLimits === state.snapshot.usageLimits) { | |
| 191 | return [null, state] as const; | |
| 192 | } | |
| 193 | const snapshot = withUsageLimits(state.snapshot, usageLimits); | |
| 194 | return [snapshot, { ...state, snapshot }] as const; | |
| 195 | }); | |
| 196 | if (snapshotToPublish !== null) { | |
| 197 | yield* PubSub.publish(changesPubSub, snapshotToPublish); | |
| 198 | } | |
| 199 | }); | |
| 200 | ||
| 201 | const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { | |
| 202 | const nextSettings = yield* input.getSettings; | |
| 203 | return yield* applySnapshot(nextSettings, { forceRefresh: true }); | |
| 204 | }); | |
| 205 | ||
| 206 | const hasProviderStatusDemand = Effect.gen(function* () { | |
| 207 | const state = yield* Ref.get(snapshotStateRef); | |
| 208 | const instanceId = state.snapshot.instanceId; | |
| 209 | const [genericDemand, instanceDemand] = yield* Effect.all([ | |
| 210 | host.shouldRunBackgroundWork({ type: "provider-status" }), | |
| 211 | host.shouldRunBackgroundWork({ type: "provider-status", instanceId }), | |
| 212 | ]); | |
| 213 | return genericDemand || instanceDemand; | |
| 214 | }); | |
| 215 | ||
| 216 | const getRefreshInterval = | |
| 217 | input.refreshInterval !== undefined | |
| 218 | ? Effect.succeed(input.refreshInterval) | |
| 219 | : host.settings.get.pipe( | |
| 220 | Effect.map( | |
| 221 | (settings) => | |
| 222 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 223 | ), | |
| 224 | Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), | |
| 225 | ); | |
| 226 | ||
| 227 | const refreshIntervalChanges = yield* Queue.sliding<void>(1); | |
| 228 | if (input.refreshInterval === undefined) { | |
| 229 | const serverSettingsChanges = yield* host.settings.subscribe; | |
| 230 | yield* serverSettingsChanges.pipe( | |
| 231 | Stream.map((settings) => | |
| 232 | Duration.toMillis( | |
| 233 | resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, | |
| 234 | ), | |
| 235 | ), | |
| 236 | Stream.changes, | |
| 237 | Stream.runForEach(() => Queue.offer(refreshIntervalChanges, undefined).pipe(Effect.asVoid)), | |
| 238 | Effect.forkScoped, | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | yield* Stream.runForEach(input.streamSettings, (nextSettings) => | |
| 243 | Effect.asVoid(applySnapshot(nextSettings)), | |
| 244 | ).pipe(Effect.forkScoped); | |
| 245 | ||
| 246 | yield* Effect.forever( | |
| 247 | getRefreshInterval.pipe( | |
| 248 | Effect.flatMap((refreshInterval) => | |
| 249 | // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races the interval against a settings-change signal, not a timeout | |
| 250 | Effect.raceFirst( | |
| 251 | Effect.sleep( | |
| 252 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 | |
| 253 | ? "60 seconds" | |
| 254 | : refreshInterval, | |
| 255 | ).pipe(Effect.as(true)), | |
| 256 | Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), | |
| 257 | ).pipe( | |
| 258 | Effect.flatMap((intervalElapsed) => | |
| 259 | input.refreshOnInterval !== false && | |
| 260 | intervalElapsed && | |
| 261 | Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 | |
| 262 | ? hasProviderStatusDemand.pipe( | |
| 263 | Effect.flatMap((shouldRefresh) => | |
| 264 | shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, | |
| 265 | ), | |
| 266 | ) | |
| 267 | : Effect.void, | |
| 268 | ), | |
| 269 | ), | |
| 270 | ), | |
| 271 | Effect.ignoreCause({ log: true }), | |
| 272 | ), | |
| 273 | ).pipe(Effect.forkScoped); | |
| 274 | ||
| 275 | yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( | |
| 276 | Effect.ignoreCause({ log: true }), | |
| 277 | Effect.forkScoped, | |
| 278 | ); | |
| 279 | ||
| 280 | return { | |
| 281 | resolveMaintenance: input.resolveMaintenance, | |
| 282 | getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), | |
| 283 | refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), | |
| 284 | applyUsageLimits, | |
| 285 | get streamChanges() { | |
| 286 | return Stream.fromPubSub(changesPubSub); | |
| 287 | }, | |
| 288 | } satisfies ServerProviderShape; | |
| 289 | }); |
packages/provider-core/src/server/snapshotSettings.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import type { ServerSettings, ServerSettingsError } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Equal from "effect/Equal"; | |
| 4 | import * as Stream from "effect/Stream"; | |
| 5 | ||
| 6 | import type { ProviderHostShape } from "./ProviderHost.ts"; | |
| 7 | ||
| 8 | export interface ProviderSnapshotSettings<Settings> { | |
| 9 | readonly provider: Settings; | |
| 10 | readonly enableProviderUpdateChecks: boolean; | |
| 11 | } | |
| 12 | ||
| 13 | function makeProviderSnapshotSettings<Settings>( | |
| 14 | provider: Settings, | |
| 15 | settings: ServerSettings, | |
| 16 | ): ProviderSnapshotSettings<Settings> { | |
| 17 | return { | |
| 18 | provider, | |
| 19 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | export function haveProviderSnapshotSettingsChanged<Settings>( | |
| 24 | previous: ProviderSnapshotSettings<Settings>, | |
| 25 | next: ProviderSnapshotSettings<Settings>, | |
| 26 | ): boolean { | |
| 27 | return !Equal.equals(previous, next); | |
| 28 | } | |
| 29 | ||
| 30 | export function makeProviderSnapshotSettingsSource<Settings>( | |
| 31 | provider: Settings, | |
| 32 | settings: ProviderHostShape["settings"], | |
| 33 | ): { | |
| 34 | readonly getSettings: Effect.Effect<ProviderSnapshotSettings<Settings>, ServerSettingsError>; | |
| 35 | readonly streamSettings: Stream.Stream<ProviderSnapshotSettings<Settings>>; | |
| 36 | } { | |
| 37 | const mapSettings = (settings: ServerSettings) => | |
| 38 | makeProviderSnapshotSettings(provider, settings); | |
| 39 | return { | |
| 40 | getSettings: settings.get.pipe(Effect.map(mapSettings)), | |
| 41 | streamSettings: settings.changes.pipe(Stream.map(mapSettings)), | |
| 42 | }; | |
| 43 | } |
packages/provider-pi/package.json created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/provider-pi", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./client": { | |
| 7 | "types": "./src/client.ts", | |
| 8 | "import": "./src/client.ts" | |
| 9 | }, | |
| 10 | "./settings": { | |
| 11 | "types": "./src/settings.ts", | |
| 12 | "import": "./src/settings.ts" | |
| 13 | }, | |
| 14 | "./server": { | |
| 15 | "types": "./src/server.ts", | |
| 16 | "import": "./src/server.ts" | |
| 17 | } | |
| 18 | }, | |
| 19 | "scripts": { | |
| 20 | "typecheck": "tsc --noEmit", | |
| 21 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 22 | }, | |
| 23 | "dependencies": { | |
| 24 | "@t3tools/contracts": "workspace:*", | |
| 25 | "@t3tools/provider-core": "workspace:*", | |
| 26 | "@t3tools/shared": "workspace:*", | |
| 27 | "effect": "catalog:" | |
| 28 | }, | |
| 29 | "devDependencies": { | |
| 30 | "@effect/platform-node": "catalog:", | |
| 31 | "@effect/vitest": "catalog:", | |
| 32 | "@t3tools/provider-testing": "workspace:*", | |
| 33 | "@types/node": "catalog:", | |
| 34 | "vite-plus": "catalog:" | |
| 35 | } | |
| 36 | } |
packages/provider-pi/src/client.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | /** | |
| 2 | * Pi's client definition: settings form, label, and glyph. Browser- and | |
| 3 | * React Native-safe. | |
| 4 | * | |
| 5 | * @module provider-pi/client | |
| 6 | */ | |
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 8 | import { defineProviderClient } from "@t3tools/provider-core/client"; | |
| 9 | ||
| 10 | import { PiSettings } from "./settings.ts"; | |
| 11 | ||
| 12 | export const piClient = defineProviderClient({ | |
| 13 | driverKind: ProviderDriverKind.make("pi"), | |
| 14 | label: "Pi", | |
| 15 | settingsSchema: PiSettings, | |
| 16 | icon: { | |
| 17 | viewBox: "165.29 165.29 469.43 469.43", | |
| 18 | fill: { light: "#0F0F0F", dark: "#F5F5F5" }, | |
| 19 | paths: [ | |
| 20 | { | |
| 21 | d: "M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z", | |
| 22 | fillRule: "evenodd", | |
| 23 | }, | |
| 24 | { d: "M517.36 400H634.72V634.72H517.36Z" }, | |
| 25 | ], | |
| 26 | }, | |
| 27 | }); |
packages/provider-pi/src/server.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | /** | |
| 2 | * Pi's server entry: the driver the server registers, plus the adapter | |
| 3 | * driver used by replay tests and the fixture recorder. | |
| 4 | * | |
| 5 | * @module provider-pi/server | |
| 6 | */ | |
| 7 | export { PiDriver, type PiDriverEnv } from "./server/driver.ts"; | |
| 8 | export { PI_PROVIDER, PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./server/adapter.ts"; |
packages/provider-pi/src/server/adapter.test.ts created+3407| ... | ... | @@ -0,0 +1,3407 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { | |
| 4 | CheckpointId, | |
| 5 | EnvironmentId, | |
| 6 | NodeId, | |
| 7 | ProviderInstanceId, | |
| 8 | ProviderSessionId, | |
| 9 | ProviderThreadId, | |
| 10 | ProviderTurnId, | |
| 11 | RunAttemptId, | |
| 12 | RunId, | |
| 13 | ThreadId, | |
| 14 | type ChatAttachment, | |
| 15 | type ModelSelection, | |
| 16 | type OrchestrationV2AppThread, | |
| 17 | type OrchestrationV2ProviderThread, | |
| 18 | type OrchestrationV2ProviderTurn, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import * as Cause from "effect/Cause"; | |
| 21 | import * as DateTime from "effect/DateTime"; | |
| 22 | import * as Duration from "effect/Duration"; | |
| 23 | import * as Effect from "effect/Effect"; | |
| 24 | import * as Fiber from "effect/Fiber"; | |
| 25 | import * as FileSystem from "effect/FileSystem"; | |
| 26 | import * as Layer from "effect/Layer"; | |
| 27 | import * as PlatformError from "effect/PlatformError"; | |
| 28 | import * as Queue from "effect/Queue"; | |
| 29 | import * as Schema from "effect/Schema"; | |
| 30 | import * as Sink from "effect/Sink"; | |
| 31 | import * as Stream from "effect/Stream"; | |
| 32 | import * as TestClock from "effect/testing/TestClock"; | |
| 33 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 34 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 35 | ||
| 36 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 37 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 38 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 39 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 40 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 41 | import { | |
| 42 | ProviderAdapterV2RuntimePolicy, | |
| 43 | type ProviderAdapterV2Event, | |
| 44 | type ProviderAdapterV2SessionRuntime, | |
| 45 | } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 46 | import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; | |
| 47 | import { | |
| 48 | makePiAdapterV2, | |
| 49 | PiAdapterV2Driver, | |
| 50 | PI_PROVIDER, | |
| 51 | type PiAdapterV2Options, | |
| 52 | } from "./adapter.ts"; | |
| 53 | import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; | |
| 54 | ||
| 55 | const layerTest = Layer.mergeAll( | |
| 56 | NodeServices.layer, | |
| 57 | IdAllocator.layer, | |
| 58 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 59 | ); | |
| 60 | ||
| 61 | const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 62 | const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 63 | ||
| 64 | const PI_INSTANCE_ID = ProviderInstanceId.make("pi"); | |
| 65 | const THREAD_ID = ThreadId.make("thread-pi-test"); | |
| 66 | const SESSION_ID = ProviderSessionId.make("provider-session-pi-test"); | |
| 67 | const FAKE_SESSION_FILE = "/fake/.pi/agent/sessions/--workspace--/0001_abc.jsonl"; | |
| 68 | /** Deliberately outside the valid pid range so a group-kill can never land. */ | |
| 69 | const FAKE_PID = 999_999_999; | |
| 70 | ||
| 71 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 72 | runtimeMode: "full-access", | |
| 73 | interactionMode: "default", | |
| 74 | cwd: null, | |
| 75 | }); | |
| 76 | ||
| 77 | const modelSelection = (model: string): ModelSelection => ({ | |
| 78 | instanceId: PI_INSTANCE_ID, | |
| 79 | model, | |
| 80 | }); | |
| 81 | ||
| 82 | interface FakePi { | |
| 83 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 84 | readonly emit: (record: PiRpcRecord) => Effect.Effect<void>; | |
| 85 | readonly takeRequest: (type: string) => Effect.Effect<PiRpcRecord>; | |
| 86 | /** Data returned by the next `get_entries` acks, consumed in order. */ | |
| 87 | readonly queueEntries: (data: unknown) => void; | |
| 88 | /** Data returned by the next active-branch `get_messages` acks. */ | |
| 89 | readonly queueMessages: (data: unknown) => void; | |
| 90 | /** Make the next `switch_session` ack report an extension veto. */ | |
| 91 | readonly vetoNextSwitch: () => void; | |
| 92 | /** Fields overriding the recorded idle state in the next `get_state` acks, in order. */ | |
| 93 | readonly queueState: (data: Record<string, unknown>) => void; | |
| 94 | /** Hold the next `get_state` response until the test resolves it. */ | |
| 95 | readonly deferNextState: () => void; | |
| 96 | /** Resolve the held `get_state` request. */ | |
| 97 | readonly resolveDeferredState: (data: unknown) => Effect.Effect<void>; | |
| 98 | /** Reject the next `get_state` request. */ | |
| 99 | readonly failNextState: () => void; | |
| 100 | readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; | |
| 101 | /** Hold a model-select extension hook until its UI request is answered. */ | |
| 102 | readonly deferNextModelSelection: () => void; | |
| 103 | readonly queueModels: (models: ReadonlyArray<unknown>) => void; | |
| 104 | readonly vetoNextNewSession: () => void; | |
| 105 | /** Every request received by the fake process. */ | |
| 106 | readonly allRequests: () => ReadonlyArray<PiRpcRecord>; | |
| 107 | /** Data returned by the next `get_session_stats` acks, consumed in order. */ | |
| 108 | readonly queueStats: (data: unknown) => void; | |
| 109 | /** Data returned by the next `get_commands` acks, consumed in order. */ | |
| 110 | readonly queueCommands: (data: unknown) => void; | |
| 111 | /** Make the next `get_commands` ack fail. */ | |
| 112 | readonly failNextCommands: () => void; | |
| 113 | /** Close the fake process stdout stream. */ | |
| 114 | readonly closeStdout: Effect.Effect<void>; | |
| 115 | readonly lastSpawn: () => { | |
| 116 | readonly args: ReadonlyArray<string>; | |
| 117 | readonly env: NodeJS.ProcessEnv; | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Pi 1.0.0's idle `get_state` reply, taken from the `simple` replay fixture | |
| 123 | * (fixtures/simple/pi_transcript.ndjson) minus the model object. Pi omits | |
| 124 | * `model` when none is selected and `sessionName` until one is set. | |
| 125 | */ | |
| 126 | const recordedIdleState = (sessionFile: string) => ({ | |
| 127 | thinkingLevel: "high", | |
| 128 | isStreaming: false, | |
| 129 | isCompacting: false, | |
| 130 | steeringMode: "one-at-a-time", | |
| 131 | followUpMode: "one-at-a-time", | |
| 132 | sessionFile, | |
| 133 | sessionId: "00000000-0000-4000-8000-000000000002", | |
| 134 | autoCompactionEnabled: true, | |
| 135 | messageCount: 0, | |
| 136 | pendingMessageCount: 0, | |
| 137 | }); | |
| 138 | ||
| 139 | /** | |
| 140 | * In-process fake `pi --mode rpc` for races and failures a live Pi cannot | |
| 141 | * produce on demand: captures every stdin record, auto-acks requests, and lets | |
| 142 | * tests push protocol events to stdout. Behaviour a real Pi can show belongs | |
| 143 | * in a replay fixture instead (see PiAdapterV2.testkit.ts). | |
| 144 | */ | |
| 145 | const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () { | |
| 146 | const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>(); | |
| 147 | const requests = yield* Queue.unbounded<PiRpcRecord>(); | |
| 148 | const entriesQueue: Array<unknown> = []; | |
| 149 | const messagesQueue: Array<unknown> = []; | |
| 150 | const stateQueue: Array<Record<string, unknown>> = []; | |
| 151 | const statsQueue: Array<unknown> = []; | |
| 152 | const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; | |
| 153 | const allRequests: Array<PiRpcRecord> = []; | |
| 154 | let deferState = false; | |
| 155 | let deferredStateRequest: PiRpcRecord | undefined; | |
| 156 | let failState = false; | |
| 157 | let vetoSwitch = false; | |
| 158 | let vetoNewSession = false; | |
| 159 | let deferredLifecycle: string | undefined; | |
| 160 | let sessionFile = FAKE_SESSION_FILE; | |
| 161 | let sessionGeneration = 0; | |
| 162 | let models: ReadonlyArray<unknown> = []; | |
| 163 | let stdinBuffer = ""; | |
| 164 | ||
| 165 | const emit = (record: PiRpcRecord) => | |
| 166 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( | |
| 167 | Effect.asVoid, | |
| 168 | ); | |
| 169 | ||
| 170 | const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { | |
| 171 | if (typeof record["id"] !== "string") return null; | |
| 172 | const base = { | |
| 173 | type: "response", | |
| 174 | id: record["id"], | |
| 175 | command: String(record["type"]), | |
| 176 | success: true, | |
| 177 | }; | |
| 178 | switch (record["type"]) { | |
| 179 | case "get_state": | |
| 180 | if (failState) { | |
| 181 | failState = false; | |
| 182 | return { ...base, success: false, error: "state unavailable" }; | |
| 183 | } | |
| 184 | // Queued data overrides fields of the recorded idle state, so a test | |
| 185 | // that only cares about the session file still gets a real shape. | |
| 186 | return { ...base, data: { ...recordedIdleState(sessionFile), ...stateQueue.shift() } }; | |
| 187 | case "get_available_models": | |
| 188 | return { ...base, data: { models } }; | |
| 189 | case "new_session": { | |
| 190 | const cancelled = vetoNewSession; | |
| 191 | vetoNewSession = false; | |
| 192 | if (!cancelled) sessionFile = `/fake/new-${++sessionGeneration}.jsonl`; | |
| 193 | return { ...base, data: { cancelled } }; | |
| 194 | } | |
| 195 | case "switch_session": { | |
| 196 | const cancelled = vetoSwitch; | |
| 197 | vetoSwitch = false; | |
| 198 | return { ...base, data: { cancelled } }; | |
| 199 | } | |
| 200 | case "get_entries": | |
| 201 | return { ...base, data: entriesQueue.shift() ?? { entries: [], leafId: null } }; | |
| 202 | case "get_messages": | |
| 203 | return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; | |
| 204 | case "get_session_stats": | |
| 205 | return { ...base, data: statsQueue.shift() ?? {} }; | |
| 206 | case "get_commands": | |
| 207 | return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; | |
| 208 | case "fork": | |
| 209 | return { ...base, data: { text: "Hello pi", cancelled: false } }; | |
| 210 | default: | |
| 211 | return base; | |
| 212 | } | |
| 213 | }; | |
| 214 | ||
| 215 | const handleStdinChunk = (chunk: Uint8Array) => | |
| 216 | Effect.gen(function* () { | |
| 217 | stdinBuffer += new TextDecoder().decode(chunk); | |
| 218 | while (true) { | |
| 219 | const newline = stdinBuffer.indexOf("\n"); | |
| 220 | if (newline === -1) return; | |
| 221 | const line = stdinBuffer.slice(0, newline); | |
| 222 | stdinBuffer = stdinBuffer.slice(newline + 1); | |
| 223 | if (line.length === 0) continue; | |
| 224 | const record = decodeJsonLine(line) as PiRpcRecord; | |
| 225 | allRequests.push(record); | |
| 226 | yield* Queue.offer(requests, record); | |
| 227 | if (record["type"] === "get_state" && deferState) { | |
| 228 | deferState = false; | |
| 229 | deferredStateRequest = record; | |
| 230 | continue; | |
| 231 | } | |
| 232 | if (record["type"] === deferredLifecycle) { | |
| 233 | deferredLifecycle = undefined; | |
| 234 | continue; | |
| 235 | } | |
| 236 | const response = respondTo(record); | |
| 237 | if (response !== null) yield* emit(response); | |
| 238 | } | |
| 239 | }); | |
| 240 | ||
| 241 | let lastSpawn: { readonly args: ReadonlyArray<string>; readonly env: NodeJS.ProcessEnv } = { | |
| 242 | args: [], | |
| 243 | env: {}, | |
| 244 | }; | |
| 245 | const spawner = ChildProcessSpawner.make((command) => | |
| 246 | Effect.sync(() => { | |
| 247 | if (ChildProcess.isStandardCommand(command)) { | |
| 248 | lastSpawn = { | |
| 249 | args: command.args, | |
| 250 | env: command.options.env ?? {}, | |
| 251 | }; | |
| 252 | } | |
| 253 | return ChildProcessSpawner.makeHandle({ | |
| 254 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 255 | exitCode: Effect.never, | |
| 256 | isRunning: Effect.succeed(true), | |
| 257 | kill: () => Effect.void, | |
| 258 | unref: Effect.succeed(Effect.void), | |
| 259 | stdin: Sink.forEach(handleStdinChunk), | |
| 260 | stdout: Stream.fromQueue(stdout), | |
| 261 | stderr: Stream.empty, | |
| 262 | all: Stream.empty, | |
| 263 | getInputFd: () => Sink.drain, | |
| 264 | getOutputFd: () => Stream.empty, | |
| 265 | }); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | const takeRequest = (type: string): Effect.Effect<PiRpcRecord> => | |
| 270 | Effect.gen(function* () { | |
| 271 | while (true) { | |
| 272 | const record = yield* Queue.take(requests); | |
| 273 | if (record["type"] === type) return record; | |
| 274 | } | |
| 275 | }); | |
| 276 | ||
| 277 | return { | |
| 278 | spawner, | |
| 279 | emit, | |
| 280 | takeRequest, | |
| 281 | queueEntries: (data) => entriesQueue.push(data), | |
| 282 | queueMessages: (data) => messagesQueue.push(data), | |
| 283 | deferNextState: () => { | |
| 284 | deferState = true; | |
| 285 | }, | |
| 286 | resolveDeferredState: (data) => | |
| 287 | Effect.gen(function* () { | |
| 288 | const record = deferredStateRequest; | |
| 289 | assert.isDefined(record); | |
| 290 | deferredStateRequest = undefined; | |
| 291 | yield* emit({ | |
| 292 | type: "response", | |
| 293 | id: record!["id"], | |
| 294 | command: "get_state", | |
| 295 | success: true, | |
| 296 | data, | |
| 297 | }); | |
| 298 | }), | |
| 299 | failNextState: () => { | |
| 300 | failState = true; | |
| 301 | }, | |
| 302 | deferNextLifecycle: (type) => { | |
| 303 | deferredLifecycle = type; | |
| 304 | }, | |
| 305 | deferNextModelSelection: () => { | |
| 306 | deferredLifecycle = "set_model"; | |
| 307 | }, | |
| 308 | queueModels: (value) => { | |
| 309 | models = value; | |
| 310 | }, | |
| 311 | vetoNextNewSession: () => { | |
| 312 | vetoNewSession = true; | |
| 313 | }, | |
| 314 | allRequests: () => allRequests, | |
| 315 | vetoNextSwitch: () => { | |
| 316 | vetoSwitch = true; | |
| 317 | }, | |
| 318 | queueState: (data) => stateQueue.push(data), | |
| 319 | queueStats: (data) => statsQueue.push(data), | |
| 320 | queueCommands: (data) => commandsQueue.push({ success: true, data }), | |
| 321 | failNextCommands: () => commandsQueue.push({ success: false }), | |
| 322 | closeStdout: Queue.end(stdout), | |
| 323 | lastSpawn: () => lastSpawn, | |
| 324 | } satisfies FakePi; | |
| 325 | }); | |
| 326 | ||
| 327 | const makeAdapter = Effect.fnUntraced(function* ( | |
| 328 | fake: FakePi, | |
| 329 | launchArgs = "", | |
| 330 | forkFake?: FakePi, | |
| 331 | continuationRequests?: PiAdapterV2Options["continuationRequests"], | |
| 332 | ) { | |
| 333 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 334 | const host = yield* ProviderHost; | |
| 335 | const fileSystem = yield* FileSystem.FileSystem; | |
| 336 | const spawner = | |
| 337 | forkFake === undefined | |
| 338 | ? fake.spawner | |
| 339 | : ChildProcessSpawner.make((command) => | |
| 340 | ChildProcess.isStandardCommand(command) && command.args.includes("--fork") | |
| 341 | ? forkFake.spawner.spawn(command) | |
| 342 | : fake.spawner.spawn(command), | |
| 343 | ); | |
| 344 | if (continuationRequests !== undefined) { | |
| 345 | return yield* PiAdapterV2Driver.create({ | |
| 346 | instanceId: PI_INSTANCE_ID, | |
| 347 | displayName: undefined, | |
| 348 | enabled: true, | |
| 349 | environment: [], | |
| 350 | config: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, | |
| 351 | }).pipe( | |
| 352 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 353 | Effect.provideService(HostProcessEnvironment, {}), | |
| 354 | Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { | |
| 355 | ...continuationRequests, | |
| 356 | take: Effect.never, | |
| 357 | }), | |
| 358 | ); | |
| 359 | } | |
| 360 | return makePiAdapterV2({ | |
| 361 | instanceId: PI_INSTANCE_ID, | |
| 362 | settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, | |
| 363 | environment: {}, | |
| 364 | spawner, | |
| 365 | fileSystem, | |
| 366 | idAllocator, | |
| 367 | host, | |
| 368 | }); | |
| 369 | }); | |
| 370 | ||
| 371 | const openRuntime = Effect.fnUntraced(function* ( | |
| 372 | fake: FakePi, | |
| 373 | model = "default", | |
| 374 | threadId = THREAD_ID, | |
| 375 | providerSessionId = SESSION_ID, | |
| 376 | forkFake?: FakePi, | |
| 377 | continuationRequests?: PiAdapterV2Options["continuationRequests"], | |
| 378 | ) { | |
| 379 | const adapter = yield* makeAdapter(fake, "", forkFake, continuationRequests); | |
| 380 | const runtime = yield* adapter.openSession({ | |
| 381 | threadId, | |
| 382 | providerSessionId, | |
| 383 | modelSelection: modelSelection(model), | |
| 384 | runtimePolicy, | |
| 385 | }); | |
| 386 | const emitted = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 387 | yield* runtime.events.pipe( | |
| 388 | Stream.runForEach((event) => Queue.offer(emitted, event)), | |
| 389 | Effect.forkScoped, | |
| 390 | ); | |
| 391 | const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => | |
| 392 | Effect.gen(function* () { | |
| 393 | while (true) { | |
| 394 | const event = yield* Queue.take(emitted); | |
| 395 | if (predicate(event)) return event; | |
| 396 | } | |
| 397 | }); | |
| 398 | return { runtime, takeEvent }; | |
| 399 | }); | |
| 400 | ||
| 401 | const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { | |
| 402 | const now = yield* DateTime.now; | |
| 403 | return { | |
| 404 | createdBy: "user", | |
| 405 | creationSource: "web", | |
| 406 | id: threadId, | |
| 407 | projectId: "project:fixture:pi" as OrchestrationV2AppThread["projectId"], | |
| 408 | title: "Pi test thread", | |
| 409 | providerInstanceId: PI_INSTANCE_ID, | |
| 410 | modelSelection: modelSelection(model), | |
| 411 | runtimeMode: "full-access", | |
| 412 | interactionMode: "default", | |
| 413 | branch: null, | |
| 414 | worktreePath: null, | |
| 415 | activeProviderThreadId: null, | |
| 416 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 417 | forkedFrom: null, | |
| 418 | createdAt: now, | |
| 419 | updatedAt: now, | |
| 420 | archivedAt: null, | |
| 421 | settledOverride: null, | |
| 422 | settledAt: null, | |
| 423 | lastVisitedAt: null, | |
| 424 | deletedAt: null, | |
| 425 | } satisfies OrchestrationV2AppThread; | |
| 426 | }); | |
| 427 | ||
| 428 | const startTurn = Effect.fnUntraced(function* ( | |
| 429 | runtime: ProviderAdapterV2SessionRuntime, | |
| 430 | providerThread: OrchestrationV2ProviderThread, | |
| 431 | model = "default", | |
| 432 | attachments: ReadonlyArray<ChatAttachment> = [], | |
| 433 | text = "Hello pi", | |
| 434 | selection?: ModelSelection, | |
| 435 | runOrdinal = 1, | |
| 436 | threadId = THREAD_ID, | |
| 437 | continuation = false, | |
| 438 | ) { | |
| 439 | const appThread = yield* makeAppThread(model, threadId); | |
| 440 | const runId = RunId.make(`run:${threadId}:${runOrdinal}`); | |
| 441 | yield* runtime.startTurn({ | |
| 442 | appThread, | |
| 443 | threadId, | |
| 444 | runId, | |
| 445 | runOrdinal, | |
| 446 | providerTurnOrdinal: runOrdinal, | |
| 447 | attemptId: RunAttemptId.make(`run-attempt:${runId}:1`), | |
| 448 | rootNodeId: NodeId.make(`node:${runId}:root`), | |
| 449 | providerThread, | |
| 450 | message: { | |
| 451 | messageId: `message:${threadId}:${runOrdinal}` as never, | |
| 452 | text, | |
| 453 | attachments, | |
| 454 | createdBy: continuation ? "agent" : "user", | |
| 455 | creationSource: continuation ? "provider" : "web", | |
| 456 | }, | |
| 457 | modelSelection: selection ?? modelSelection(model), | |
| 458 | runtimePolicy, | |
| 459 | }); | |
| 460 | }); | |
| 461 | ||
| 462 | const expectModelFailure = (errorMessage: string) => | |
| 463 | Effect.gen(function* () { | |
| 464 | const fake = yield* makeFakePi; | |
| 465 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 466 | const providerThread = yield* runtime.ensureThread({ | |
| 467 | threadId: THREAD_ID, | |
| 468 | modelSelection: modelSelection("default"), | |
| 469 | runtimePolicy, | |
| 470 | }); | |
| 471 | yield* startTurn(runtime, providerThread); | |
| 472 | yield* fake.takeRequest("prompt"); | |
| 473 | yield* fake.emit({ type: "agent_start" }); | |
| 474 | yield* fake.emit({ | |
| 475 | type: "message_end", | |
| 476 | message: { | |
| 477 | role: "assistant", | |
| 478 | content: [], | |
| 479 | stopReason: "error", | |
| 480 | errorMessage, | |
| 481 | }, | |
| 482 | }); | |
| 483 | yield* fake.emit({ type: "agent_settled" }); | |
| 484 | ||
| 485 | const sessionError = yield* takeEvent( | |
| 486 | (event) => | |
| 487 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 488 | ); | |
| 489 | assert.isTrue( | |
| 490 | sessionError.type === "provider_session.updated" && | |
| 491 | sessionError.providerSession.lastError === errorMessage, | |
| 492 | ); | |
| 493 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 494 | assert.isTrue( | |
| 495 | terminal.type === "turn.terminal" && | |
| 496 | terminal.status === "failed" && | |
| 497 | terminal.failure.message === errorMessage, | |
| 498 | ); | |
| 499 | }).pipe(Effect.scoped, Effect.provide(layerTest)); | |
| 500 | ||
| 501 | describe("PiAdapterV2", () => { | |
| 502 | it.effect.each([false, true])( | |
| 503 | "serializes rollback fork hooks with native wake ownership, wake=%s", | |
| 504 | (nativeWake) => | |
| 505 | Effect.gen(function* () { | |
| 506 | const fake = yield* makeFakePi; | |
| 507 | const offers = | |
| 508 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 509 | const { runtime, takeEvent } = yield* openRuntime( | |
| 510 | fake, | |
| 511 | "default", | |
| 512 | THREAD_ID, | |
| 513 | SESSION_ID, | |
| 514 | undefined, | |
| 515 | { | |
| 516 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 517 | }, | |
| 518 | ); | |
| 519 | const providerThread = yield* runtime.ensureThread({ | |
| 520 | threadId: THREAD_ID, | |
| 521 | modelSelection: modelSelection("default"), | |
| 522 | runtimePolicy, | |
| 523 | }); | |
| 524 | fake.deferNextLifecycle("fork"); | |
| 525 | const rollingBack = yield* runtime | |
| 526 | .rollbackThread({ | |
| 527 | providerThread, | |
| 528 | target: { | |
| 529 | type: "thread_start", | |
| 530 | checkpointId: CheckpointId.make("checkpoint-start"), | |
| 531 | appRunOrdinal: 0, | |
| 532 | }, | |
| 533 | providerThreadTurns: [ | |
| 534 | { | |
| 535 | id: ProviderTurnId.make("previous-turn"), | |
| 536 | providerThreadId: providerThread.id, | |
| 537 | nodeId: NodeId.make("previous-node"), | |
| 538 | runAttemptId: null, | |
| 539 | nativeTurnRef: { | |
| 540 | driver: PI_PROVIDER, | |
| 541 | nativeId: "user-before-wake", | |
| 542 | strength: "strong", | |
| 543 | }, | |
| 544 | ordinal: 1, | |
| 545 | status: "completed", | |
| 546 | startedAt: providerThread.createdAt, | |
| 547 | completedAt: providerThread.createdAt, | |
| 548 | }, | |
| 549 | ], | |
| 550 | }) | |
| 551 | .pipe(Effect.result, Effect.forkScoped); | |
| 552 | const fork = yield* fake.takeRequest("fork"); | |
| 553 | const overlappingTurn = yield* startTurn(runtime, providerThread).pipe(Effect.result); | |
| 554 | assert.equal(overlappingTurn._tag, "Failure"); | |
| 555 | const overlappingRegistration = yield* runtime | |
| 556 | .ensureThread({ | |
| 557 | threadId: THREAD_ID, | |
| 558 | modelSelection: modelSelection("default"), | |
| 559 | runtimePolicy, | |
| 560 | }) | |
| 561 | .pipe(Effect.result); | |
| 562 | assert.equal(overlappingRegistration._tag, "Failure"); | |
| 563 | yield* fake.emit({ | |
| 564 | type: "extension_ui_request", | |
| 565 | id: "fork-hook-dialog", | |
| 566 | method: "confirm", | |
| 567 | title: "Restore this branch?", | |
| 568 | }); | |
| 569 | const forkDialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 570 | assert.isTrue(forkDialog.type === "runtime_request.updated"); | |
| 571 | if (forkDialog.type !== "runtime_request.updated") return; | |
| 572 | assert.isNull(forkDialog.runtimeRequest.providerTurnId); | |
| 573 | yield* runtime.respondToRuntimeRequest({ | |
| 574 | requestId: forkDialog.runtimeRequest.id, | |
| 575 | decision: "accept", | |
| 576 | }); | |
| 577 | yield* fake.takeRequest("extension_ui_response"); | |
| 578 | if (nativeWake) { | |
| 579 | yield* fake.emit({ type: "agent_start" }); | |
| 580 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 581 | yield* fake.emit({ | |
| 582 | type: "message_update", | |
| 583 | assistantMessageEvent: { | |
| 584 | type: "text_end", | |
| 585 | contentIndex: 0, | |
| 586 | content: "Discarded branch wake output", | |
| 587 | }, | |
| 588 | }); | |
| 589 | yield* fake.emit({ type: "agent_settled" }); | |
| 590 | const retired = yield* takeEvent( | |
| 591 | (event) => | |
| 592 | event.type === "provider_session.updated" && | |
| 593 | event.providerSession.status === "stopped", | |
| 594 | ).pipe(Effect.as(true), Effect.raceFirst(Queue.take(offers).pipe(Effect.as(false)))); | |
| 595 | assert.isTrue(retired, "Pi offered native work while rollback was changing its binding"); | |
| 596 | const result = yield* Fiber.join(rollingBack); | |
| 597 | assert.equal(result._tag, "Failure"); | |
| 598 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 599 | const staleTurn = yield* startTurn( | |
| 600 | runtime, | |
| 601 | providerThread, | |
| 602 | "default", | |
| 603 | [], | |
| 604 | "Synthetic wake", | |
| 605 | undefined, | |
| 606 | 1, | |
| 607 | THREAD_ID, | |
| 608 | true, | |
| 609 | ).pipe(Effect.result); | |
| 610 | assert.equal(staleTurn._tag, "Failure"); | |
| 611 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); | |
| 612 | return; | |
| 613 | } | |
| 614 | fake.queueState({ sessionFile: "/fake/rolled-back-session.jsonl" }); | |
| 615 | yield* fake.emit({ | |
| 616 | type: "response", | |
| 617 | id: fork["id"], | |
| 618 | command: "fork", | |
| 619 | success: true, | |
| 620 | data: { cancelled: false }, | |
| 621 | }); | |
| 622 | const result = yield* Fiber.join(rollingBack); | |
| 623 | assert.equal(result._tag, "Success"); | |
| 624 | if (result._tag !== "Success") return; | |
| 625 | assert.equal( | |
| 626 | result.success.providerThread.nativeThreadRef?.nativeId, | |
| 627 | "/fake/rolled-back-session.jsonl", | |
| 628 | ); | |
| 629 | yield* startTurn(runtime, result.success.providerThread); | |
| 630 | const prompt = yield* fake.takeRequest("prompt"); | |
| 631 | assert.equal(prompt["message"], "Hello pi"); | |
| 632 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 633 | yield* fake.emit({ type: "agent_start" }); | |
| 634 | yield* fake.emit({ type: "agent_settled" }); | |
| 635 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 636 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 637 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 638 | ); | |
| 639 | ||
| 640 | it.effect.each([false, true])( | |
| 641 | "retains native wake ownership after a rejected joining prompt, compaction aborted=%s", | |
| 642 | (aborted) => | |
| 643 | Effect.gen(function* () { | |
| 644 | const fake = yield* makeFakePi; | |
| 645 | const offers = | |
| 646 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 647 | const { runtime, takeEvent } = yield* openRuntime( | |
| 648 | fake, | |
| 649 | "default", | |
| 650 | THREAD_ID, | |
| 651 | SESSION_ID, | |
| 652 | undefined, | |
| 653 | { | |
| 654 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 655 | }, | |
| 656 | ); | |
| 657 | const providerThread = yield* runtime.ensureThread({ | |
| 658 | threadId: THREAD_ID, | |
| 659 | modelSelection: modelSelection("default"), | |
| 660 | runtimePolicy, | |
| 661 | }); | |
| 662 | yield* fake.takeRequest("get_state"); | |
| 663 | yield* fake.emit({ type: "agent_start" }); | |
| 664 | yield* Queue.take(offers); | |
| 665 | yield* fake.emit({ type: "agent_settled" }); | |
| 666 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 667 | yield* startTurn(runtime, providerThread); | |
| 668 | yield* fake.takeRequest("prompt"); | |
| 669 | const rejection = "Cannot submit a prompt while compaction is in progress."; | |
| 670 | yield* fake.emit({ type: "response", command: "prompt", success: false, error: rejection }); | |
| 671 | yield* fake.emit({ | |
| 672 | type: "extension_ui_request", | |
| 673 | id: "native-compaction-dialog", | |
| 674 | method: "confirm", | |
| 675 | title: "Continue native work?", | |
| 676 | }); | |
| 677 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 678 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 679 | if (dialog.type !== "runtime_request.updated") return; | |
| 680 | assert.isNotNull(dialog.runtimeRequest.providerTurnId); | |
| 681 | yield* runtime.respondToRuntimeRequest({ | |
| 682 | requestId: dialog.runtimeRequest.id, | |
| 683 | decision: "accept", | |
| 684 | }); | |
| 685 | yield* fake.takeRequest("extension_ui_response"); | |
| 686 | if (aborted) { | |
| 687 | yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); | |
| 688 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 689 | assert.isTrue( | |
| 690 | terminal.type === "turn.terminal" && | |
| 691 | terminal.status === "failed" && | |
| 692 | terminal.failure.message === rejection, | |
| 693 | ); | |
| 694 | return; | |
| 695 | } | |
| 696 | fake.queueState({ isStreaming: true }); | |
| 697 | yield* fake.emit({ | |
| 698 | type: "compaction_end", | |
| 699 | result: { summary: "Native compaction finished" }, | |
| 700 | willRetry: true, | |
| 701 | }); | |
| 702 | yield* fake.emit({ type: "agent_start" }); | |
| 703 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 704 | yield* fake.emit({ | |
| 705 | type: "message_update", | |
| 706 | assistantMessageEvent: { | |
| 707 | type: "text_end", | |
| 708 | contentIndex: 0, | |
| 709 | content: "Native work completed", | |
| 710 | }, | |
| 711 | }); | |
| 712 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 713 | assert.isTrue(message.type === "message.updated" && message.message.runId !== null); | |
| 714 | yield* fake.takeRequest("get_state"); | |
| 715 | yield* fake.emit({ type: "agent_settled" }); | |
| 716 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 717 | assert.isTrue( | |
| 718 | terminal.type === "turn.terminal" && | |
| 719 | terminal.status === "failed" && | |
| 720 | terminal.failure.message === rejection, | |
| 721 | ); | |
| 722 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 723 | ); | |
| 724 | ||
| 725 | it.effect("rejects rollback while native wake output is awaiting ownership", () => | |
| 726 | Effect.gen(function* () { | |
| 727 | const fake = yield* makeFakePi; | |
| 728 | const offers = | |
| 729 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 730 | const { runtime, takeEvent } = yield* openRuntime( | |
| 731 | fake, | |
| 732 | "default", | |
| 733 | THREAD_ID, | |
| 734 | SESSION_ID, | |
| 735 | undefined, | |
| 736 | { | |
| 737 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 738 | }, | |
| 739 | ); | |
| 740 | const providerThread = yield* runtime.ensureThread({ | |
| 741 | threadId: THREAD_ID, | |
| 742 | modelSelection: modelSelection("default"), | |
| 743 | runtimePolicy, | |
| 744 | }); | |
| 745 | yield* fake.emit({ type: "agent_start" }); | |
| 746 | yield* Queue.take(offers); | |
| 747 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 748 | yield* fake.emit({ | |
| 749 | type: "message_update", | |
| 750 | assistantMessageEvent: { | |
| 751 | type: "text_end", | |
| 752 | contentIndex: 0, | |
| 753 | content: "Current branch wake output", | |
| 754 | }, | |
| 755 | }); | |
| 756 | yield* fake.emit({ type: "agent_settled" }); | |
| 757 | yield* takeEvent( | |
| 758 | (event) => | |
| 759 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 760 | ); | |
| 761 | const rolledBack = yield* runtime | |
| 762 | .rollbackThread({ | |
| 763 | providerThread, | |
| 764 | target: { | |
| 765 | type: "thread_start", | |
| 766 | checkpointId: CheckpointId.make("checkpoint-start"), | |
| 767 | appRunOrdinal: 0, | |
| 768 | }, | |
| 769 | providerThreadTurns: [ | |
| 770 | { | |
| 771 | id: ProviderTurnId.make("previous-turn"), | |
| 772 | providerThreadId: providerThread.id, | |
| 773 | nodeId: NodeId.make("previous-node"), | |
| 774 | runAttemptId: null, | |
| 775 | nativeTurnRef: { | |
| 776 | driver: PI_PROVIDER, | |
| 777 | nativeId: "user-before-wake", | |
| 778 | strength: "strong", | |
| 779 | }, | |
| 780 | ordinal: 1, | |
| 781 | status: "completed", | |
| 782 | startedAt: providerThread.createdAt, | |
| 783 | completedAt: providerThread.createdAt, | |
| 784 | }, | |
| 785 | ], | |
| 786 | }) | |
| 787 | .pipe(Effect.result); | |
| 788 | assert.equal( | |
| 789 | rolledBack._tag, | |
| 790 | "Failure", | |
| 791 | "Rollback changed the native session while its wake was still buffered", | |
| 792 | ); | |
| 793 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "fork")); | |
| 794 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 795 | yield* startTurn( | |
| 796 | runtime, | |
| 797 | providerThread, | |
| 798 | "default", | |
| 799 | [], | |
| 800 | "Synthetic wake", | |
| 801 | undefined, | |
| 802 | 1, | |
| 803 | THREAD_ID, | |
| 804 | true, | |
| 805 | ); | |
| 806 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 807 | assert.equal( | |
| 808 | message.type === "message.updated" && message.message.text, | |
| 809 | "Current branch wake output", | |
| 810 | ); | |
| 811 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 812 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 813 | ); | |
| 814 | ||
| 815 | it.effect("answers model-selection dialogs before a user turn claims a native wake", () => | |
| 816 | Effect.gen(function* () { | |
| 817 | const fake = yield* makeFakePi; | |
| 818 | const offers = | |
| 819 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 820 | const { runtime, takeEvent } = yield* openRuntime( | |
| 821 | fake, | |
| 822 | "default", | |
| 823 | THREAD_ID, | |
| 824 | SESSION_ID, | |
| 825 | undefined, | |
| 826 | { offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid) }, | |
| 827 | ); | |
| 828 | const providerThread = yield* runtime.ensureThread({ | |
| 829 | threadId: THREAD_ID, | |
| 830 | modelSelection: modelSelection("default"), | |
| 831 | runtimePolicy, | |
| 832 | }); | |
| 833 | yield* fake.emit({ type: "agent_start" }); | |
| 834 | yield* Queue.take(offers); | |
| 835 | const requests: Array<Extract<ProviderAdapterV2Event, { type: "runtime_request.updated" }>> = | |
| 836 | []; | |
| 837 | fake.deferNextModelSelection(); | |
| 838 | const starting = yield* startTurn( | |
| 839 | runtime, | |
| 840 | providerThread, | |
| 841 | "default", | |
| 842 | [], | |
| 843 | "Continue the review", | |
| 844 | modelSelection("xai/grok-4.6"), | |
| 845 | ).pipe(Effect.forkScoped); | |
| 846 | const modelRequest = yield* fake.takeRequest("set_model"); | |
| 847 | yield* fake.emit({ | |
| 848 | type: "extension_ui_request", | |
| 849 | id: "model-selection-dialog", | |
| 850 | method: "confirm", | |
| 851 | title: "Use this model?", | |
| 852 | }); | |
| 853 | yield* fake.emit({ type: "agent_settled" }); | |
| 854 | yield* takeEvent((event) => { | |
| 855 | if (event.type === "runtime_request.updated") requests.push(event); | |
| 856 | return ( | |
| 857 | event.type === "provider_session.updated" && event.providerSession.status === "ready" | |
| 858 | ); | |
| 859 | }); | |
| 860 | const dialog = requests[0]?.runtimeRequest; | |
| 861 | assert.isDefined(dialog, "Pi buffered the dialog needed to finish model selection"); | |
| 862 | assert.equal(requests[0]?.threadId, THREAD_ID); | |
| 863 | assert.isNull(dialog!.providerTurnId); | |
| 864 | yield* runtime.respondToRuntimeRequest({ requestId: dialog!.id, decision: "accept" }); | |
| 865 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 866 | assert.equal(response["id"], "model-selection-dialog"); | |
| 867 | assert.isTrue(response["confirmed"]); | |
| 868 | yield* fake.emit({ | |
| 869 | type: "response", | |
| 870 | id: modelRequest["id"], | |
| 871 | command: "set_model", | |
| 872 | success: true, | |
| 873 | data: { provider: "xai", id: "grok-4.6" }, | |
| 874 | }); | |
| 875 | yield* Fiber.join(starting); | |
| 876 | const prompt = yield* fake.takeRequest("prompt"); | |
| 877 | assert.equal(prompt["message"], "Continue the review"); | |
| 878 | assert.equal(prompt["streamingBehavior"], "steer"); | |
| 879 | const resolved = yield* takeEvent( | |
| 880 | (event) => | |
| 881 | event.type === "runtime_request.updated" && event.runtimeRequest.id === dialog!.id, | |
| 882 | ); | |
| 883 | assert.isTrue( | |
| 884 | resolved.type === "runtime_request.updated" && | |
| 885 | resolved.runtimeRequest.status === "resolved", | |
| 886 | ); | |
| 887 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 888 | ); | |
| 889 | ||
| 890 | it.effect("adopts a completed native wake without sending a synthetic prompt", () => | |
| 891 | Effect.gen(function* () { | |
| 892 | const fake = yield* makeFakePi; | |
| 893 | const offers = | |
| 894 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 895 | const { runtime, takeEvent } = yield* openRuntime( | |
| 896 | fake, | |
| 897 | "default", | |
| 898 | THREAD_ID, | |
| 899 | SESSION_ID, | |
| 900 | undefined, | |
| 901 | { | |
| 902 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 903 | }, | |
| 904 | ); | |
| 905 | const providerThread = yield* runtime.ensureThread({ | |
| 906 | threadId: THREAD_ID, | |
| 907 | modelSelection: modelSelection("default"), | |
| 908 | runtimePolicy, | |
| 909 | }); | |
| 910 | yield* fake.emit({ type: "agent_start" }); | |
| 911 | const offer = yield* Queue.take(offers).pipe( | |
| 912 | Effect.raceFirst( | |
| 913 | takeEvent( | |
| 914 | (event) => | |
| 915 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 916 | ).pipe( | |
| 917 | Effect.andThen(Effect.sync(() => assert.fail("Pi killed a routable native wake"))), | |
| 918 | ), | |
| 919 | ), | |
| 920 | ); | |
| 921 | assert.equal(offer.threadId, THREAD_ID); | |
| 922 | assert.equal(offer.providerThreadId, providerThread.id); | |
| 923 | assert.equal(offer.delivery, "adapter_buffered"); | |
| 924 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 925 | let dispatchCommitted = false; | |
| 926 | yield* offer.dispatchIfCurrent!( | |
| 927 | Effect.sync(() => { | |
| 928 | dispatchCommitted = true; | |
| 929 | }), | |
| 930 | ); | |
| 931 | assert.isTrue(dispatchCommitted); | |
| 932 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 933 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 934 | yield* fake.emit({ | |
| 935 | type: "message_update", | |
| 936 | assistantMessageEvent: { | |
| 937 | type: "text_delta", | |
| 938 | contentIndex: 0, | |
| 939 | delta: "Background review finished", | |
| 940 | }, | |
| 941 | }); | |
| 942 | yield* fake.emit({ type: "message_end", message: { role: "assistant" } }); | |
| 943 | yield* fake.emit({ type: "agent_settled" }); | |
| 944 | yield* takeEvent( | |
| 945 | (event) => | |
| 946 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 947 | ); | |
| 948 | assert.isTrue(yield* runtime.hasPendingBackgroundWork!); | |
| 949 | yield* startTurn( | |
| 950 | runtime, | |
| 951 | providerThread, | |
| 952 | "default", | |
| 953 | [], | |
| 954 | "Synthetic wake", | |
| 955 | undefined, | |
| 956 | 1, | |
| 957 | THREAD_ID, | |
| 958 | true, | |
| 959 | ); | |
| 960 | const message = yield* takeEvent((event) => event.type === "message.updated"); | |
| 961 | assert.equal( | |
| 962 | message.type === "message.updated" && message.message.text, | |
| 963 | "Background review finished", | |
| 964 | ); | |
| 965 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 966 | assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 0); | |
| 967 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 968 | let dispatched = false; | |
| 969 | yield* offer.dispatchIfCurrent!( | |
| 970 | Effect.sync(() => { | |
| 971 | dispatched = true; | |
| 972 | }), | |
| 973 | ); | |
| 974 | assert.isFalse(dispatched); | |
| 975 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 976 | ); | |
| 977 | ||
| 978 | it.effect.each([false, true])( | |
| 979 | "a user prompt takes the wake without premature settlement, native wake settled=%s", | |
| 980 | (settled) => | |
| 981 | Effect.gen(function* () { | |
| 982 | const fake = yield* makeFakePi; | |
| 983 | const offers = | |
| 984 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 985 | const { runtime, takeEvent } = yield* openRuntime( | |
| 986 | fake, | |
| 987 | "default", | |
| 988 | THREAD_ID, | |
| 989 | SESSION_ID, | |
| 990 | undefined, | |
| 991 | { | |
| 992 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 993 | }, | |
| 994 | ); | |
| 995 | const providerThread = yield* runtime.ensureThread({ | |
| 996 | threadId: THREAD_ID, | |
| 997 | modelSelection: modelSelection("default"), | |
| 998 | runtimePolicy, | |
| 999 | }); | |
| 1000 | yield* fake.emit({ type: "agent_start" }); | |
| 1001 | const offer = yield* Queue.take(offers); | |
| 1002 | if (settled) { | |
| 1003 | yield* fake.emit({ type: "agent_settled" }); | |
| 1004 | yield* takeEvent( | |
| 1005 | (event) => | |
| 1006 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 1007 | ); | |
| 1008 | } | |
| 1009 | yield* startTurn(runtime, providerThread); | |
| 1010 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1011 | assert.equal(prompt["message"], "Hello pi"); | |
| 1012 | assert.equal(prompt["streamingBehavior"], "steer"); | |
| 1013 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1014 | yield* fake.emit({ type: "agent_start" }); | |
| 1015 | yield* fake.emit({ | |
| 1016 | type: "extension_ui_request", | |
| 1017 | id: "owned-dialog", | |
| 1018 | method: "confirm", | |
| 1019 | title: "Continue?", | |
| 1020 | }); | |
| 1021 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 1022 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 1023 | if (dialog.type !== "runtime_request.updated") return; | |
| 1024 | assert.isNotNull(dialog.runtimeRequest.providerTurnId); | |
| 1025 | yield* offer.clearIfCurrent!(); | |
| 1026 | yield* runtime.respondToRuntimeRequest({ | |
| 1027 | requestId: dialog.runtimeRequest.id, | |
| 1028 | decision: "accept", | |
| 1029 | }); | |
| 1030 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 1031 | assert.isTrue(response["confirmed"]); | |
| 1032 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 1033 | yield* fake.emit({ | |
| 1034 | type: "message_update", | |
| 1035 | assistantMessageEvent: { | |
| 1036 | type: "text_end", | |
| 1037 | contentIndex: 0, | |
| 1038 | content: "User prompt completed", | |
| 1039 | }, | |
| 1040 | }); | |
| 1041 | yield* fake.emit({ type: "agent_settled" }); | |
| 1042 | const message = yield* takeEvent( | |
| 1043 | (event) => | |
| 1044 | event.type === "message.updated" && event.message.text === "User prompt completed", | |
| 1045 | ); | |
| 1046 | assert.isTrue(message.type === "message.updated" && message.message.runId !== null); | |
| 1047 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1048 | let dispatched = false; | |
| 1049 | yield* offer.dispatchIfCurrent!( | |
| 1050 | Effect.sync(() => { | |
| 1051 | dispatched = true; | |
| 1052 | }), | |
| 1053 | ); | |
| 1054 | assert.isFalse(dispatched); | |
| 1055 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | it.effect("defers a wake's late settlement until the joined user prompt is acknowledged", () => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | const fake = yield* makeFakePi; | |
| 1061 | const offers = | |
| 1062 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1063 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1064 | fake, | |
| 1065 | "default", | |
| 1066 | THREAD_ID, | |
| 1067 | SESSION_ID, | |
| 1068 | undefined, | |
| 1069 | { | |
| 1070 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1071 | }, | |
| 1072 | ); | |
| 1073 | const providerThread = yield* runtime.ensureThread({ | |
| 1074 | threadId: THREAD_ID, | |
| 1075 | modelSelection: modelSelection("default"), | |
| 1076 | runtimePolicy, | |
| 1077 | }); | |
| 1078 | yield* fake.emit({ type: "agent_start" }); | |
| 1079 | yield* Queue.take(offers); | |
| 1080 | yield* startTurn(runtime, providerThread); | |
| 1081 | yield* fake.takeRequest("prompt"); | |
| 1082 | yield* fake.emit({ type: "agent_settled" }); | |
| 1083 | yield* fake.emit({ | |
| 1084 | type: "extension_ui_request", | |
| 1085 | method: "notify", | |
| 1086 | message: "Ordering barrier", | |
| 1087 | }); | |
| 1088 | yield* takeEvent( | |
| 1089 | (event) => | |
| 1090 | event.type === "turn_item.updated" && | |
| 1091 | event.turnItem.type === "dynamic_tool" && | |
| 1092 | event.turnItem.toolName === "notify", | |
| 1093 | ); | |
| 1094 | assert.equal(runtime.providerSession.status, "running"); | |
| 1095 | fake.queueState({ isStreaming: true }); | |
| 1096 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1097 | yield* fake.takeRequest("get_state"); | |
| 1098 | yield* fake.emit({ type: "agent_start" }); | |
| 1099 | yield* fake.emit({ type: "agent_settled" }); | |
| 1100 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1101 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1102 | ); | |
| 1103 | ||
| 1104 | it.effect("settles a command-only user prompt after taking a completed wake", () => | |
| 1105 | Effect.gen(function* () { | |
| 1106 | const fake = yield* makeFakePi; | |
| 1107 | const offers = | |
| 1108 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1109 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1110 | fake, | |
| 1111 | "default", | |
| 1112 | THREAD_ID, | |
| 1113 | SESSION_ID, | |
| 1114 | undefined, | |
| 1115 | { | |
| 1116 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1117 | }, | |
| 1118 | ); | |
| 1119 | const providerThread = yield* runtime.ensureThread({ | |
| 1120 | threadId: THREAD_ID, | |
| 1121 | modelSelection: modelSelection("default"), | |
| 1122 | runtimePolicy, | |
| 1123 | }); | |
| 1124 | yield* fake.emit({ type: "agent_start" }); | |
| 1125 | yield* Queue.take(offers); | |
| 1126 | yield* fake.emit({ type: "agent_settled" }); | |
| 1127 | yield* takeEvent( | |
| 1128 | (event) => | |
| 1129 | event.type === "provider_session.updated" && event.providerSession.status === "ready", | |
| 1130 | ); | |
| 1131 | yield* startTurn(runtime, providerThread, "default", [], "/hello"); | |
| 1132 | yield* fake.takeRequest("prompt"); | |
| 1133 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 1134 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1135 | yield* startTurn( | |
| 1136 | runtime, | |
| 1137 | providerThread, | |
| 1138 | "default", | |
| 1139 | [], | |
| 1140 | "Stale continuation", | |
| 1141 | undefined, | |
| 1142 | 2, | |
| 1143 | THREAD_ID, | |
| 1144 | true, | |
| 1145 | ); | |
| 1146 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1147 | assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 1); | |
| 1148 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1149 | ); | |
| 1150 | ||
| 1151 | it.effect("keeps a pending wake on its thread and lets Stop retire its process", () => | |
| 1152 | Effect.gen(function* () { | |
| 1153 | const fake = yield* makeFakePi; | |
| 1154 | const offers = | |
| 1155 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1156 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1157 | fake, | |
| 1158 | "default", | |
| 1159 | THREAD_ID, | |
| 1160 | SESSION_ID, | |
| 1161 | undefined, | |
| 1162 | { | |
| 1163 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1164 | }, | |
| 1165 | ); | |
| 1166 | const providerThread = yield* runtime.ensureThread({ | |
| 1167 | threadId: THREAD_ID, | |
| 1168 | modelSelection: modelSelection("default"), | |
| 1169 | runtimePolicy, | |
| 1170 | }); | |
| 1171 | yield* fake.emit({ type: "agent_start" }); | |
| 1172 | const offer = yield* Queue.take(offers); | |
| 1173 | assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread)); | |
| 1174 | const otherThread = { | |
| 1175 | ...providerThread, | |
| 1176 | appThreadId: ThreadId.make("other-thread"), | |
| 1177 | id: ProviderThreadId.make("other-provider-thread"), | |
| 1178 | }; | |
| 1179 | assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(otherThread)); | |
| 1180 | const failure = yield* runtime | |
| 1181 | .resumeThread({ providerThread: otherThread }) | |
| 1182 | .pipe(Effect.flip); | |
| 1183 | assert.equal(failure._tag, "ProviderAdapterResumeThreadError"); | |
| 1184 | yield* runtime.interruptTurn({ | |
| 1185 | providerThread, | |
| 1186 | providerTurnId: ProviderTurnId.make("settled-turn"), | |
| 1187 | requestRuntimeRestart: true, | |
| 1188 | }); | |
| 1189 | yield* takeEvent( | |
| 1190 | (event) => | |
| 1191 | event.type === "provider_session.updated" && event.providerSession.status === "stopped", | |
| 1192 | ); | |
| 1193 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 1194 | let dispatched = false; | |
| 1195 | yield* offer.dispatchIfCurrent!( | |
| 1196 | Effect.sync(() => { | |
| 1197 | dispatched = true; | |
| 1198 | }), | |
| 1199 | ); | |
| 1200 | assert.isFalse(dispatched); | |
| 1201 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1202 | ); | |
| 1203 | ||
| 1204 | it.effect.each(["archive", "stop", "transport death"] as const)( | |
| 1205 | "retiring a wake by %s cancels visible dialogs and invalidates its offer", | |
| 1206 | (retirement) => | |
| 1207 | Effect.gen(function* () { | |
| 1208 | const fake = yield* makeFakePi; | |
| 1209 | const offers = | |
| 1210 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 1211 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1212 | fake, | |
| 1213 | "default", | |
| 1214 | THREAD_ID, | |
| 1215 | SESSION_ID, | |
| 1216 | undefined, | |
| 1217 | { | |
| 1218 | offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), | |
| 1219 | }, | |
| 1220 | ); | |
| 1221 | const providerThread = yield* runtime.ensureThread({ | |
| 1222 | threadId: THREAD_ID, | |
| 1223 | modelSelection: modelSelection("default"), | |
| 1224 | runtimePolicy, | |
| 1225 | }); | |
| 1226 | yield* fake.emit({ type: "agent_start" }); | |
| 1227 | const offer = yield* Queue.take(offers); | |
| 1228 | yield* fake.emit({ | |
| 1229 | type: "extension_ui_request", | |
| 1230 | id: "wake-dialog", | |
| 1231 | method: "confirm", | |
| 1232 | title: "Continue background work?", | |
| 1233 | }); | |
| 1234 | const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); | |
| 1235 | assert.isTrue(dialog.type === "runtime_request.updated"); | |
| 1236 | if (dialog.type !== "runtime_request.updated") return; | |
| 1237 | assert.isNull(dialog.runtimeRequest.providerTurnId); | |
| 1238 | if (retirement === "archive") yield* offer.clearIfCurrent!(); | |
| 1239 | else if (retirement === "stop") { | |
| 1240 | yield* runtime.interruptTurn({ | |
| 1241 | providerThread, | |
| 1242 | providerTurnId: ProviderTurnId.make("settled-turn"), | |
| 1243 | requestRuntimeRestart: true, | |
| 1244 | }); | |
| 1245 | } else yield* fake.closeStdout; | |
| 1246 | let requestCancelled = false; | |
| 1247 | let nodeCancelled = false; | |
| 1248 | let itemCancelled = false; | |
| 1249 | yield* takeEvent((event) => { | |
| 1250 | if ( | |
| 1251 | event.type === "runtime_request.updated" && | |
| 1252 | event.runtimeRequest.id === dialog.runtimeRequest.id | |
| 1253 | ) | |
| 1254 | requestCancelled = event.runtimeRequest.status === "cancelled"; | |
| 1255 | if (event.type === "node.updated" && event.node.id === dialog.runtimeRequest.nodeId) | |
| 1256 | nodeCancelled = event.node.status === "cancelled"; | |
| 1257 | if ( | |
| 1258 | event.type === "turn_item.updated" && | |
| 1259 | event.turnItem.nodeId === dialog.runtimeRequest.nodeId | |
| 1260 | ) | |
| 1261 | itemCancelled = event.turnItem.status === "cancelled"; | |
| 1262 | return ( | |
| 1263 | event.type === "provider_session.updated" && | |
| 1264 | event.providerSession.status === | |
| 1265 | (retirement === "transport death" ? "error" : "stopped") | |
| 1266 | ); | |
| 1267 | }); | |
| 1268 | assert.isTrue(requestCancelled, "Retired wake left a pending runtime request"); | |
| 1269 | assert.isTrue(nodeCancelled); | |
| 1270 | assert.isTrue(itemCancelled); | |
| 1271 | assert.isFalse(yield* runtime.hasPendingBackgroundWork!); | |
| 1272 | let dispatched = false; | |
| 1273 | yield* offer.dispatchIfCurrent!( | |
| 1274 | Effect.sync(() => { | |
| 1275 | dispatched = true; | |
| 1276 | }), | |
| 1277 | ); | |
| 1278 | assert.isFalse(dispatched); | |
| 1279 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1280 | ); | |
| 1281 | ||
| 1282 | it.effect("stops provider-initiated work that has no T3 turn owner", () => | |
| 1283 | Effect.gen(function* () { | |
| 1284 | const fake = yield* makeFakePi; | |
| 1285 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1286 | yield* runtime.ensureThread({ | |
| 1287 | threadId: THREAD_ID, | |
| 1288 | modelSelection: modelSelection("default"), | |
| 1289 | runtimePolicy, | |
| 1290 | }); | |
| 1291 | ||
| 1292 | yield* fake.emit({ type: "agent_start" }); | |
| 1293 | ||
| 1294 | const sessionError = yield* takeEvent( | |
| 1295 | (event) => | |
| 1296 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1297 | ); | |
| 1298 | assert.isTrue( | |
| 1299 | sessionError.type === "provider_session.updated" && | |
| 1300 | sessionError.providerSession.lastError?.includes("invisible tool execution") === true, | |
| 1301 | ); | |
| 1302 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1303 | ); | |
| 1304 | ||
| 1305 | it.effect("injects the T3 MCP extension and bearer when a session exists", () => | |
| 1306 | Effect.gen(function* () { | |
| 1307 | McpProviderSession.setMcpProviderSession({ | |
| 1308 | environmentId: EnvironmentId.make("environment-pi-mcp"), | |
| 1309 | threadId: THREAD_ID, | |
| 1310 | providerSessionId: "mcp-session-pi", | |
| 1311 | providerInstanceId: PI_INSTANCE_ID, | |
| 1312 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 1313 | authorizationHeader: "Bearer secret-pi-token", | |
| 1314 | browserToolsAvailable: true, | |
| 1315 | }); | |
| 1316 | const fake = yield* makeFakePi; | |
| 1317 | yield* openRuntime(fake); | |
| 1318 | const spawn = fake.lastSpawn(); | |
| 1319 | assert.isTrue(spawn.args.includes("--extension")); | |
| 1320 | const extensions = spawn.args.flatMap((arg, index) => | |
| 1321 | arg === "--extension" ? [spawn.args[index + 1]] : [], | |
| 1322 | ); | |
| 1323 | assert.isFalse(spawn.args.includes("--no-extensions")); | |
| 1324 | assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts"))); | |
| 1325 | assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp"); | |
| 1326 | assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token"); | |
| 1327 | assert.equal(spawn.env.T3_PI_RUNTIME_MODE, "full-access"); | |
| 1328 | }).pipe( | |
| 1329 | Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID))), | |
| 1330 | Effect.scoped, | |
| 1331 | Effect.provide(layerTest), | |
| 1332 | ), | |
| 1333 | ); | |
| 1334 | ||
| 1335 | it.effect("rejects a resume while a turn is active", () => | |
| 1336 | Effect.gen(function* () { | |
| 1337 | const fake = yield* makeFakePi; | |
| 1338 | const { runtime } = yield* openRuntime(fake); | |
| 1339 | const providerThread = yield* runtime.ensureThread({ | |
| 1340 | threadId: THREAD_ID, | |
| 1341 | modelSelection: modelSelection("default"), | |
| 1342 | runtimePolicy, | |
| 1343 | }); | |
| 1344 | yield* startTurn(runtime, providerThread); | |
| 1345 | yield* fake.takeRequest("prompt"); | |
| 1346 | const error = yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1347 | assert.equal(error._tag, "ProviderAdapterResumeThreadError"); | |
| 1348 | assert.match(String(error.cause), /while a turn is active/); | |
| 1349 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1350 | ); | |
| 1351 | ||
| 1352 | it.effect("waits for a slow Pi resume without starting a replacement", () => | |
| 1353 | Effect.gen(function* () { | |
| 1354 | const fake = yield* makeFakePi; | |
| 1355 | const { runtime } = yield* openRuntime(fake); | |
| 1356 | const providerThread = yield* runtime.ensureThread({ | |
| 1357 | threadId: THREAD_ID, | |
| 1358 | modelSelection: modelSelection("default"), | |
| 1359 | runtimePolicy, | |
| 1360 | }); | |
| 1361 | fake.deferNextLifecycle("switch_session"); | |
| 1362 | const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); | |
| 1363 | const request = yield* fake.takeRequest("switch_session"); | |
| 1364 | yield* TestClock.adjust(Duration.millis(16_820)); | |
| 1365 | yield* fake.emit({ | |
| 1366 | type: "response", | |
| 1367 | id: request.id, | |
| 1368 | command: "switch_session", | |
| 1369 | success: true, | |
| 1370 | data: { cancelled: false }, | |
| 1371 | }); | |
| 1372 | assert.equal((yield* Fiber.join(resumed)).nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1373 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1374 | yield* startTurn(runtime, providerThread, "default"); | |
| 1375 | yield* fake.takeRequest("prompt"); | |
| 1376 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1377 | ); | |
| 1378 | ||
| 1379 | it.effect("creates a distinct native session after a failed resume", () => | |
| 1380 | Effect.gen(function* () { | |
| 1381 | const fake = yield* makeFakePi; | |
| 1382 | const { runtime } = yield* openRuntime(fake); | |
| 1383 | const providerThread = yield* runtime.ensureThread({ | |
| 1384 | threadId: THREAD_ID, | |
| 1385 | modelSelection: modelSelection("default"), | |
| 1386 | runtimePolicy, | |
| 1387 | }); | |
| 1388 | fake.vetoNextSwitch(); | |
| 1389 | yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1390 | const replacement = yield* runtime.ensureThread({ | |
| 1391 | threadId: THREAD_ID, | |
| 1392 | modelSelection: modelSelection("default"), | |
| 1393 | runtimePolicy, | |
| 1394 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1395 | }); | |
| 1396 | assert.equal(replacement.id, providerThread.id); | |
| 1397 | assert.notEqual( | |
| 1398 | replacement.nativeThreadRef?.nativeId, | |
| 1399 | providerThread.nativeThreadRef?.nativeId, | |
| 1400 | ); | |
| 1401 | assert.equal( | |
| 1402 | fake.allRequests().filter((request) => request.type === "new_session").length, | |
| 1403 | 1, | |
| 1404 | ); | |
| 1405 | yield* startTurn(runtime, replacement, "default"); | |
| 1406 | yield* fake.takeRequest("prompt"); | |
| 1407 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1408 | ); | |
| 1409 | ||
| 1410 | it.effect.each(["veto", "same identity"] as const)( | |
| 1411 | "rejects a replacement with %s", | |
| 1412 | (invalidReplacement) => | |
| 1413 | Effect.gen(function* () { | |
| 1414 | const fake = yield* makeFakePi; | |
| 1415 | const { runtime } = yield* openRuntime(fake); | |
| 1416 | const providerThread = yield* runtime.ensureThread({ | |
| 1417 | threadId: THREAD_ID, | |
| 1418 | modelSelection: modelSelection("default"), | |
| 1419 | runtimePolicy, | |
| 1420 | }); | |
| 1421 | if (invalidReplacement === "veto") fake.vetoNextNewSession(); | |
| 1422 | else fake.queueState({ sessionFile: FAKE_SESSION_FILE }); | |
| 1423 | const error = yield* runtime | |
| 1424 | .ensureThread({ | |
| 1425 | threadId: THREAD_ID, | |
| 1426 | modelSelection: modelSelection("default"), | |
| 1427 | runtimePolicy, | |
| 1428 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1429 | }) | |
| 1430 | .pipe(Effect.flip); | |
| 1431 | assert.equal(error._tag, "ProviderAdapterEnsureThreadError"); | |
| 1432 | assert.match( | |
| 1433 | String(error.cause), | |
| 1434 | invalidReplacement === "veto" ? /cancelled/ : /distinct session/, | |
| 1435 | ); | |
| 1436 | yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip); | |
| 1437 | assert.isFalse(fake.allRequests().some((request) => request.type === "prompt")); | |
| 1438 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1439 | ); | |
| 1440 | ||
| 1441 | it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () => | |
| 1442 | Effect.gen(function* () { | |
| 1443 | const fake = yield* makeFakePi; | |
| 1444 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1445 | const providerThread = yield* runtime.ensureThread({ | |
| 1446 | threadId: THREAD_ID, | |
| 1447 | modelSelection: modelSelection("default"), | |
| 1448 | runtimePolicy, | |
| 1449 | }); | |
| 1450 | fake.deferNextLifecycle("switch_session"); | |
| 1451 | const resumed = yield* runtime | |
| 1452 | .resumeThread({ providerThread }) | |
| 1453 | .pipe(Effect.flip, Effect.forkChild); | |
| 1454 | const request = yield* fake.takeRequest("switch_session"); | |
| 1455 | yield* TestClock.adjust(Duration.seconds(60)); | |
| 1456 | const error = yield* Fiber.join(resumed); | |
| 1457 | assert.match(String(error.cause), /timed out after 60000ms/); | |
| 1458 | yield* takeEvent( | |
| 1459 | (event) => | |
| 1460 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1461 | ); | |
| 1462 | yield* fake.emit({ | |
| 1463 | type: "response", | |
| 1464 | id: request.id, | |
| 1465 | command: "switch_session", | |
| 1466 | success: true, | |
| 1467 | data: { cancelled: false }, | |
| 1468 | }); | |
| 1469 | yield* runtime | |
| 1470 | .ensureThread({ | |
| 1471 | threadId: THREAD_ID, | |
| 1472 | modelSelection: modelSelection("default"), | |
| 1473 | runtimePolicy, | |
| 1474 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1475 | }) | |
| 1476 | .pipe(Effect.flip); | |
| 1477 | assert.isFalse( | |
| 1478 | fake | |
| 1479 | .allRequests() | |
| 1480 | .some((request) => request.type === "new_session" || request.type === "prompt"), | |
| 1481 | ); | |
| 1482 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1483 | ); | |
| 1484 | ||
| 1485 | it.effect( | |
| 1486 | "uses the lifecycle deadline for fresh sessions and drops replaced native metadata", | |
| 1487 | () => | |
| 1488 | Effect.gen(function* () { | |
| 1489 | const fake = yield* makeFakePi; | |
| 1490 | const { runtime } = yield* openRuntime(fake); | |
| 1491 | const providerThread = yield* runtime.ensureThread({ | |
| 1492 | threadId: THREAD_ID, | |
| 1493 | modelSelection: modelSelection("default"), | |
| 1494 | runtimePolicy, | |
| 1495 | }); | |
| 1496 | fake.deferNextLifecycle("new_session"); | |
| 1497 | fake.queueState({ sessionFile: "/fake/fresh-after-delay.jsonl" }); | |
| 1498 | const replacing = yield* runtime | |
| 1499 | .ensureThread({ | |
| 1500 | threadId: THREAD_ID, | |
| 1501 | modelSelection: modelSelection("default"), | |
| 1502 | runtimePolicy, | |
| 1503 | existingProviderThread: { | |
| 1504 | ...providerThread, | |
| 1505 | nativeThreadRef: null, | |
| 1506 | nativeConversationHeadRef: { | |
| 1507 | driver: PI_PROVIDER, | |
| 1508 | nativeId: "old-leaf", | |
| 1509 | strength: "strong", | |
| 1510 | }, | |
| 1511 | contextUsage: { usedTokens: 314_551, maxTokens: 1_000_000 }, | |
| 1512 | }, | |
| 1513 | }) | |
| 1514 | .pipe(Effect.forkChild); | |
| 1515 | const request = yield* fake.takeRequest("new_session"); | |
| 1516 | yield* TestClock.adjust(Duration.millis(16_820)); | |
| 1517 | yield* fake.emit({ | |
| 1518 | type: "response", | |
| 1519 | id: request.id, | |
| 1520 | command: "new_session", | |
| 1521 | success: true, | |
| 1522 | data: { cancelled: false }, | |
| 1523 | }); | |
| 1524 | const replacement = yield* Fiber.join(replacing); | |
| 1525 | assert.equal(replacement.nativeThreadRef?.nativeId, "/fake/fresh-after-delay.jsonl"); | |
| 1526 | assert.isNull(replacement.contextUsage); | |
| 1527 | assert.isNull(replacement.nativeConversationHeadRef); | |
| 1528 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1529 | ); | |
| 1530 | ||
| 1531 | it.effect("replaces a native session when the first resume's state refresh fails", () => | |
| 1532 | Effect.gen(function* () { | |
| 1533 | const original = yield* makeFakePi; | |
| 1534 | const originalRuntime = yield* openRuntime(original); | |
| 1535 | const providerThread = yield* originalRuntime.runtime.ensureThread({ | |
| 1536 | threadId: THREAD_ID, | |
| 1537 | modelSelection: modelSelection("default"), | |
| 1538 | runtimePolicy, | |
| 1539 | }); | |
| 1540 | const fake = yield* makeFakePi; | |
| 1541 | const { runtime } = yield* openRuntime(fake); | |
| 1542 | fake.failNextState(); | |
| 1543 | yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); | |
| 1544 | const replacement = yield* runtime.ensureThread({ | |
| 1545 | threadId: THREAD_ID, | |
| 1546 | modelSelection: modelSelection("default"), | |
| 1547 | runtimePolicy, | |
| 1548 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1549 | }); | |
| 1550 | assert.notEqual( | |
| 1551 | replacement.nativeThreadRef?.nativeId, | |
| 1552 | providerThread.nativeThreadRef?.nativeId, | |
| 1553 | ); | |
| 1554 | yield* startTurn(runtime, replacement, "default"); | |
| 1555 | yield* fake.takeRequest("prompt"); | |
| 1556 | assert.isTrue(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1557 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1558 | ); | |
| 1559 | ||
| 1560 | it.effect("retires an interrupted switch before accepting further requests", () => | |
| 1561 | Effect.gen(function* () { | |
| 1562 | const fake = yield* makeFakePi; | |
| 1563 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1564 | const providerThread = yield* runtime.ensureThread({ | |
| 1565 | threadId: THREAD_ID, | |
| 1566 | modelSelection: modelSelection("default"), | |
| 1567 | runtimePolicy, | |
| 1568 | }); | |
| 1569 | fake.deferNextLifecycle("switch_session"); | |
| 1570 | const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); | |
| 1571 | yield* fake.takeRequest("switch_session"); | |
| 1572 | yield* Fiber.interrupt(resumed); | |
| 1573 | yield* takeEvent( | |
| 1574 | (event) => | |
| 1575 | event.type === "provider_session.updated" && event.providerSession.status === "error", | |
| 1576 | ); | |
| 1577 | yield* runtime | |
| 1578 | .ensureThread({ | |
| 1579 | threadId: THREAD_ID, | |
| 1580 | modelSelection: modelSelection("default"), | |
| 1581 | runtimePolicy, | |
| 1582 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1583 | }) | |
| 1584 | .pipe(Effect.flip); | |
| 1585 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1586 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1587 | ); | |
| 1588 | ||
| 1589 | it.effect("budgets legacy native history with Pi's selected model capacity", () => | |
| 1590 | Effect.gen(function* () { | |
| 1591 | const fake = yield* makeFakePi; | |
| 1592 | fake.queueState({ | |
| 1593 | sessionFile: FAKE_SESSION_FILE, | |
| 1594 | model: { provider: "anthropic", id: "large", contextWindow: 1_000_000 }, | |
| 1595 | }); | |
| 1596 | fake.queueModels([{ provider: "anthropic", id: "small", contextWindow: 32_000 }]); | |
| 1597 | const { runtime } = yield* openRuntime(fake); | |
| 1598 | const providerThread = yield* runtime.ensureThread({ | |
| 1599 | threadId: THREAD_ID, | |
| 1600 | modelSelection: modelSelection("default"), | |
| 1601 | runtimePolicy, | |
| 1602 | }); | |
| 1603 | const budget = (model: string) => | |
| 1604 | handoffBudget({ | |
| 1605 | tokenCap: 16_000, | |
| 1606 | userText: "$handoff", | |
| 1607 | attachments: [], | |
| 1608 | providerThread, | |
| 1609 | nativeContextEstimate: 307_543, | |
| 1610 | modelContextWindow: runtime.getModelContextWindow?.(modelSelection(model)), | |
| 1611 | }); | |
| 1612 | assert.equal(budget("default"), 16_000); | |
| 1613 | assert.equal(budget("anthropic/large"), 16_000); | |
| 1614 | assert.equal(runtime.getModelContextWindow?.(modelSelection("anthropic/small")), 32_000); | |
| 1615 | assert.equal(budget("anthropic/small"), 0); | |
| 1616 | assert.isUndefined(runtime.getModelContextWindow?.(modelSelection("anthropic/unknown"))); | |
| 1617 | assert.isUndefined( | |
| 1618 | runtime.getModelContextWindow?.({ | |
| 1619 | instanceId: ProviderInstanceId.make("other-pi"), | |
| 1620 | model: "anthropic/large", | |
| 1621 | }), | |
| 1622 | ); | |
| 1623 | // New native sessions have their own default, even within one process. | |
| 1624 | fake.queueState({ | |
| 1625 | sessionFile: "/fake/replacement.jsonl", | |
| 1626 | model: { provider: "anthropic", id: "small", contextWindow: 32_000 }, | |
| 1627 | }); | |
| 1628 | yield* runtime.ensureThread({ | |
| 1629 | threadId: THREAD_ID, | |
| 1630 | modelSelection: modelSelection("default"), | |
| 1631 | runtimePolicy, | |
| 1632 | existingProviderThread: { ...providerThread, nativeThreadRef: null }, | |
| 1633 | }); | |
| 1634 | assert.equal(runtime.getModelContextWindow?.(modelSelection("default")), 32_000); | |
| 1635 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1636 | ); | |
| 1637 | ||
| 1638 | it.effect("adopts the run's provider thread identity instead of minting a second row", () => | |
| 1639 | Effect.gen(function* () { | |
| 1640 | const fake = yield* makeFakePi; | |
| 1641 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1642 | const now = yield* DateTime.now; | |
| 1643 | // The placeholder row the orchestrator creates for a first run: no | |
| 1644 | // native identity yet. The adapter must bind the pi session to this | |
| 1645 | // row instead of registering a second session-file-keyed row, or the | |
| 1646 | // projection ends up with two live rows per app thread. | |
| 1647 | const placeholder: OrchestrationV2ProviderThread = { | |
| 1648 | id: ProviderThreadId.make("thread:provider:pi:native-thread:pending:run:thread-pi-test:1"), | |
| 1649 | driver: PI_PROVIDER, | |
| 1650 | providerInstanceId: PI_INSTANCE_ID, | |
| 1651 | providerSessionId: SESSION_ID, | |
| 1652 | appThreadId: THREAD_ID, | |
| 1653 | ownerNodeId: null, | |
| 1654 | nativeThreadRef: null, | |
| 1655 | nativeConversationHeadRef: null, | |
| 1656 | status: "not_loaded", | |
| 1657 | firstRunOrdinal: 1, | |
| 1658 | lastRunOrdinal: 1, | |
| 1659 | handoffIds: [], | |
| 1660 | forkedFrom: null, | |
| 1661 | createdAt: now, | |
| 1662 | updatedAt: now, | |
| 1663 | }; | |
| 1664 | const providerThread = yield* runtime.ensureThread({ | |
| 1665 | threadId: THREAD_ID, | |
| 1666 | modelSelection: modelSelection("default"), | |
| 1667 | runtimePolicy, | |
| 1668 | existingProviderThread: placeholder, | |
| 1669 | }); | |
| 1670 | assert.equal(providerThread.id, placeholder.id); | |
| 1671 | assert.equal(providerThread.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1672 | assert.isFalse(fake.allRequests().some((request) => request.type === "new_session")); | |
| 1673 | const updated = yield* takeEvent((event) => event.type === "provider_thread.updated"); | |
| 1674 | assert.isTrue( | |
| 1675 | updated.type === "provider_thread.updated" && updated.providerThread.id === placeholder.id, | |
| 1676 | ); | |
| 1677 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1678 | ); | |
| 1679 | ||
| 1680 | it.effect("resets applied thinking when returning to Pi default", () => | |
| 1681 | Effect.gen(function* () { | |
| 1682 | const fake = yield* makeFakePi; | |
| 1683 | fake.queueState({ | |
| 1684 | model: { provider: "xai", id: "grok-4.6" }, | |
| 1685 | thinkingLevel: "medium", | |
| 1686 | sessionFile: FAKE_SESSION_FILE, | |
| 1687 | sessionId: "abc", | |
| 1688 | }); | |
| 1689 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1690 | const providerThread = yield* runtime.ensureThread({ | |
| 1691 | threadId: THREAD_ID, | |
| 1692 | modelSelection: modelSelection("default"), | |
| 1693 | runtimePolicy, | |
| 1694 | }); | |
| 1695 | ||
| 1696 | // An explicit effort on a concrete model. | |
| 1697 | yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { | |
| 1698 | instanceId: PI_INSTANCE_ID, | |
| 1699 | model: "xai/grok-4.6", | |
| 1700 | options: [{ id: "thinking", value: "high" }], | |
| 1701 | }); | |
| 1702 | const modelRequest = yield* fake.takeRequest("set_model"); | |
| 1703 | assert.equal(modelRequest["provider"], "xai"); | |
| 1704 | assert.equal(modelRequest["modelId"], "grok-4.6"); | |
| 1705 | const levelRequest = yield* fake.takeRequest("set_thinking_level"); | |
| 1706 | assert.equal(levelRequest["level"], "high"); | |
| 1707 | yield* fake.emit({ type: "agent_start" }); | |
| 1708 | yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); | |
| 1709 | yield* fake.emit({ type: "agent_settled" }); | |
| 1710 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1711 | ||
| 1712 | // Back to Pi default with no explicit thinking choice of its own. | |
| 1713 | yield* startTurn( | |
| 1714 | runtime, | |
| 1715 | providerThread, | |
| 1716 | "default", | |
| 1717 | [], | |
| 1718 | "Hello pi", | |
| 1719 | { | |
| 1720 | instanceId: PI_INSTANCE_ID, | |
| 1721 | model: "default", | |
| 1722 | }, | |
| 1723 | 2, | |
| 1724 | ); | |
| 1725 | const replayModel = yield* fake.takeRequest("set_model"); | |
| 1726 | assert.equal(replayModel["provider"], "xai"); | |
| 1727 | assert.equal(replayModel["modelId"], "grok-4.6"); | |
| 1728 | const resetLevel = yield* fake.takeRequest("set_thinking_level"); | |
| 1729 | assert.equal(resetLevel["level"], "medium"); | |
| 1730 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1731 | ); | |
| 1732 | ||
| 1733 | it.effect("expands a selected $ skill through Pi's native skill command", () => | |
| 1734 | Effect.gen(function* () { | |
| 1735 | const fake = yield* makeFakePi; | |
| 1736 | fake.queueCommands({ | |
| 1737 | commands: [ | |
| 1738 | { | |
| 1739 | name: "skill:repo-review", | |
| 1740 | description: "Review this repository.", | |
| 1741 | source: "skill", | |
| 1742 | sourceInfo: { | |
| 1743 | path: "/workspace/.agents/skills/repo-review/SKILL.md", | |
| 1744 | scope: "project", | |
| 1745 | }, | |
| 1746 | }, | |
| 1747 | ], | |
| 1748 | }); | |
| 1749 | const { runtime } = yield* openRuntime(fake); | |
| 1750 | const providerThread = yield* runtime.ensureThread({ | |
| 1751 | threadId: THREAD_ID, | |
| 1752 | modelSelection: modelSelection("default"), | |
| 1753 | runtimePolicy, | |
| 1754 | }); | |
| 1755 | ||
| 1756 | yield* startTurn( | |
| 1757 | runtime, | |
| 1758 | providerThread, | |
| 1759 | "default", | |
| 1760 | [], | |
| 1761 | "Review this change please $repo-review", | |
| 1762 | ); | |
| 1763 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1764 | assert.equal(prompt["message"], "/skill:repo-review Review this change please"); | |
| 1765 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1766 | ); | |
| 1767 | ||
| 1768 | it.effect("expands every selected $ skill through Pi native skill commands", () => | |
| 1769 | Effect.gen(function* () { | |
| 1770 | const fake = yield* makeFakePi; | |
| 1771 | fake.queueCommands({ | |
| 1772 | commands: [ | |
| 1773 | { | |
| 1774 | name: "skill:repo-review", | |
| 1775 | source: "skill", | |
| 1776 | sourceInfo: { | |
| 1777 | path: "/workspace/.agents/skills/repo-review/SKILL.md", | |
| 1778 | scope: "project", | |
| 1779 | }, | |
| 1780 | }, | |
| 1781 | { | |
| 1782 | name: "skill:deploy", | |
| 1783 | source: "skill", | |
| 1784 | sourceInfo: { | |
| 1785 | path: "/workspace/.agents/skills/deploy/SKILL.md", | |
| 1786 | scope: "project", | |
| 1787 | }, | |
| 1788 | }, | |
| 1789 | ], | |
| 1790 | }); | |
| 1791 | const { runtime } = yield* openRuntime(fake); | |
| 1792 | const providerThread = yield* runtime.ensureThread({ | |
| 1793 | threadId: THREAD_ID, | |
| 1794 | modelSelection: modelSelection("default"), | |
| 1795 | runtimePolicy, | |
| 1796 | }); | |
| 1797 | ||
| 1798 | yield* startTurn(runtime, providerThread, "default", [], "use $repo-review and $deploy"); | |
| 1799 | const prompt = yield* fake.takeRequest("prompt"); | |
| 1800 | assert.equal(prompt["message"], "/skill:repo-review /skill:deploy use and"); | |
| 1801 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1802 | ); | |
| 1803 | ||
| 1804 | it.effect.each([ | |
| 1805 | { historical: false, label: "the latest turn" }, | |
| 1806 | { historical: true, label: "a historical turn" }, | |
| 1807 | ])("natively forks $label into an independent session", ({ historical }) => | |
| 1808 | Effect.gen(function* () { | |
| 1809 | const fake = yield* makeFakePi; | |
| 1810 | const forkFake = yield* makeFakePi; | |
| 1811 | const forkFile = "/fake/forked.jsonl"; | |
| 1812 | const { runtime, takeEvent } = yield* openRuntime( | |
| 1813 | fake, | |
| 1814 | "default", | |
| 1815 | THREAD_ID, | |
| 1816 | SESSION_ID, | |
| 1817 | forkFake, | |
| 1818 | ); | |
| 1819 | const source = yield* runtime.ensureThread({ | |
| 1820 | threadId: THREAD_ID, | |
| 1821 | modelSelection: modelSelection("default"), | |
| 1822 | runtimePolicy, | |
| 1823 | }); | |
| 1824 | const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ | |
| 1825 | id: ProviderTurnId.make(`turn-${ordinal}`), | |
| 1826 | providerThreadId: source.id, | |
| 1827 | nodeId: NodeId.make(`node-${ordinal}`), | |
| 1828 | runAttemptId: null, | |
| 1829 | nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, | |
| 1830 | ordinal, | |
| 1831 | status: "completed", | |
| 1832 | startedAt: null, | |
| 1833 | completedAt: null, | |
| 1834 | }); | |
| 1835 | forkFake.queueState({ sessionFile: forkFile }); | |
| 1836 | fake.queueState({ sessionFile: forkFile }); | |
| 1837 | const target = ThreadId.make("fork-target"); | |
| 1838 | const forked = yield* runtime.forkThread({ | |
| 1839 | sourceProviderThread: source, | |
| 1840 | sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], | |
| 1841 | providerTurnId: turn(1).id, | |
| 1842 | targetThreadId: target, | |
| 1843 | }); | |
| 1844 | assert.equal(forked.appThreadId, target); | |
| 1845 | assert.equal(forked.nativeThreadRef?.nativeId, forkFile); | |
| 1846 | assert.notEqual(forked.id, source.id); | |
| 1847 | assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); | |
| 1848 | const args = forkFake.lastSpawn().args; | |
| 1849 | assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); | |
| 1850 | assert.include(args, "--no-extensions"); | |
| 1851 | assert.include(args, "--no-tools"); | |
| 1852 | assert.notInclude(args, "--no-session"); | |
| 1853 | assert.deepEqual( | |
| 1854 | forkFake | |
| 1855 | .allRequests() | |
| 1856 | .filter((request) => request.type === "fork") | |
| 1857 | .map((request) => request.entryId), | |
| 1858 | historical ? ["u2"] : [], | |
| 1859 | ); | |
| 1860 | assert.isFalse( | |
| 1861 | fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), | |
| 1862 | ); | |
| 1863 | // ProviderTurnStartService adopts the fork into its pending row. | |
| 1864 | const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; | |
| 1865 | yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); | |
| 1866 | yield* fake.emit({ type: "agent_start" }); | |
| 1867 | yield* fake.emit({ type: "agent_settled" }); | |
| 1868 | const updated = yield* takeEvent( | |
| 1869 | (event) => | |
| 1870 | event.type === "provider_thread.updated" && event.providerThread.appThreadId === target, | |
| 1871 | ); | |
| 1872 | assert.isTrue( | |
| 1873 | updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, | |
| 1874 | ); | |
| 1875 | yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 1876 | yield* runtime.resumeThread({ providerThread: adopted }); | |
| 1877 | assert.equal( | |
| 1878 | fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath, | |
| 1879 | forkFile, | |
| 1880 | ); | |
| 1881 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1882 | ); | |
| 1883 | ||
| 1884 | it.effect("presents explicitly namespaced MCP extension tools", () => | |
| 1885 | Effect.gen(function* () { | |
| 1886 | const fake = yield* makeFakePi; | |
| 1887 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1888 | const providerThread = yield* runtime.ensureThread({ | |
| 1889 | threadId: THREAD_ID, | |
| 1890 | modelSelection: modelSelection("default"), | |
| 1891 | runtimePolicy, | |
| 1892 | }); | |
| 1893 | yield* startTurn(runtime, providerThread); | |
| 1894 | yield* fake.takeRequest("prompt"); | |
| 1895 | yield* fake.emit({ type: "agent_start" }); | |
| 1896 | for (const type of ["tool_execution_start", "tool_execution_end"]) { | |
| 1897 | yield* fake.emit({ | |
| 1898 | type, | |
| 1899 | toolCallId: "weather-call", | |
| 1900 | toolName: "mcp__weather__get_weather", | |
| 1901 | args: { city: "Berlin" }, | |
| 1902 | result: { content: [{ type: "text", text: "Sunny" }] }, | |
| 1903 | isError: false, | |
| 1904 | }); | |
| 1905 | const event = yield* takeEvent( | |
| 1906 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 1907 | ); | |
| 1908 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 1909 | return yield* Effect.die("Expected an MCP tool item"); | |
| 1910 | assert.equal(event.turnItem.title, "get weather"); | |
| 1911 | assert.equal( | |
| 1912 | event.turnItem.status, | |
| 1913 | type === "tool_execution_start" ? "running" : "completed", | |
| 1914 | ); | |
| 1915 | assert.deepEqual(event.turnItem.toolSource, { | |
| 1916 | key: "mcp:weather", | |
| 1917 | name: "weather", | |
| 1918 | kind: "integration", | |
| 1919 | }); | |
| 1920 | assert.deepEqual(event.turnItem.input, { city: "Berlin" }); | |
| 1921 | } | |
| 1922 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1923 | ); | |
| 1924 | ||
| 1925 | it.effect("observes official subagent results without inventing child threads", () => | |
| 1926 | Effect.gen(function* () { | |
| 1927 | const fake = yield* makeFakePi; | |
| 1928 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1929 | const providerThread = yield* runtime.ensureThread({ | |
| 1930 | threadId: THREAD_ID, | |
| 1931 | modelSelection: modelSelection("default"), | |
| 1932 | runtimePolicy, | |
| 1933 | }); | |
| 1934 | yield* startTurn(runtime, providerThread); | |
| 1935 | yield* fake.takeRequest("prompt"); | |
| 1936 | yield* fake.emit({ type: "agent_start" }); | |
| 1937 | yield* fake.emit({ | |
| 1938 | type: "tool_execution_update", | |
| 1939 | toolCallId: "call_sub", | |
| 1940 | toolName: "subagent", | |
| 1941 | partialResult: { | |
| 1942 | content: [{ type: "text", text: "(running...)" }], | |
| 1943 | details: { | |
| 1944 | mode: "single", | |
| 1945 | results: [ | |
| 1946 | { | |
| 1947 | agent: "scout", | |
| 1948 | task: "map the repo", | |
| 1949 | exitCode: 0, | |
| 1950 | stderr: "", | |
| 1951 | sessionFile: "/ignored/custom-extension-session.jsonl", | |
| 1952 | messages: [ | |
| 1953 | { role: "assistant", content: [{ type: "text", text: "scanning files" }] }, | |
| 1954 | ], | |
| 1955 | }, | |
| 1956 | ], | |
| 1957 | }, | |
| 1958 | }, | |
| 1959 | }); | |
| 1960 | const running = yield* takeEvent( | |
| 1961 | (event) => event.type === "subagent.updated" && event.subagent.status === "running", | |
| 1962 | ); | |
| 1963 | assert.isTrue( | |
| 1964 | running.type === "subagent.updated" && | |
| 1965 | running.subagent.title === "scout" && | |
| 1966 | running.subagent.prompt === "map the repo" && | |
| 1967 | running.subagent.progress === "scanning files" && | |
| 1968 | running.subagent.childThreadId === null, | |
| 1969 | ); | |
| 1970 | ||
| 1971 | yield* fake.emit({ | |
| 1972 | type: "tool_execution_end", | |
| 1973 | toolCallId: "call_sub", | |
| 1974 | toolName: "subagent", | |
| 1975 | isError: false, | |
| 1976 | result: { | |
| 1977 | content: [{ type: "text", text: "done" }], | |
| 1978 | details: { | |
| 1979 | mode: "single", | |
| 1980 | results: [ | |
| 1981 | { | |
| 1982 | agent: "scout", | |
| 1983 | task: "map the repo", | |
| 1984 | exitCode: 0, | |
| 1985 | stopReason: "stop", | |
| 1986 | stderr: "", | |
| 1987 | messages: [ | |
| 1988 | { role: "assistant", content: [{ type: "text", text: "repo has one file" }] }, | |
| 1989 | ], | |
| 1990 | }, | |
| 1991 | ], | |
| 1992 | }, | |
| 1993 | }, | |
| 1994 | }); | |
| 1995 | const doneCard = yield* takeEvent( | |
| 1996 | (event) => event.type === "subagent.updated" && event.subagent.status === "completed", | |
| 1997 | ); | |
| 1998 | assert.isTrue( | |
| 1999 | doneCard.type === "subagent.updated" && | |
| 2000 | doneCard.subagent.result === "repo has one file" && | |
| 2001 | doneCard.subagent.childThreadId === null, | |
| 2002 | ); | |
| 2003 | const subagentItem = yield* takeEvent( | |
| 2004 | (event) => | |
| 2005 | event.type === "turn_item.updated" && | |
| 2006 | event.turnItem.type === "subagent" && | |
| 2007 | event.turnItem.status === "completed", | |
| 2008 | ); | |
| 2009 | assert.isTrue( | |
| 2010 | subagentItem.type === "turn_item.updated" && | |
| 2011 | subagentItem.turnItem.type === "subagent" && | |
| 2012 | subagentItem.turnItem.childThreadId === null, | |
| 2013 | ); | |
| 2014 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2015 | ); | |
| 2016 | ||
| 2017 | it.effect("persists edit patches and write content on file change items", () => | |
| 2018 | Effect.gen(function* () { | |
| 2019 | const fake = yield* makeFakePi; | |
| 2020 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2021 | const providerThread = yield* runtime.ensureThread({ | |
| 2022 | threadId: THREAD_ID, | |
| 2023 | modelSelection: modelSelection("default"), | |
| 2024 | runtimePolicy, | |
| 2025 | }); | |
| 2026 | yield* startTurn(runtime, providerThread); | |
| 2027 | yield* fake.takeRequest("prompt"); | |
| 2028 | yield* fake.emit({ type: "agent_start" }); | |
| 2029 | const patch = "--- a.ts\n+++ a.ts\n@@ -1 +1 @@\n-old\n+new\n"; | |
| 2030 | yield* fake.emit({ | |
| 2031 | type: "tool_execution_start", | |
| 2032 | toolCallId: "call_edit", | |
| 2033 | toolName: "edit", | |
| 2034 | args: { path: "a.ts", edits: [{ oldText: "old", newText: "new" }] }, | |
| 2035 | }); | |
| 2036 | yield* fake.emit({ | |
| 2037 | type: "tool_execution_end", | |
| 2038 | toolCallId: "call_edit", | |
| 2039 | toolName: "edit", | |
| 2040 | isError: false, | |
| 2041 | result: { | |
| 2042 | content: [{ type: "text", text: "Successfully replaced 1 block(s) in a.ts." }], | |
| 2043 | details: { diff: "-1 old\n+1 new", patch, firstChangedLine: 1 }, | |
| 2044 | }, | |
| 2045 | }); | |
| 2046 | const edit = yield* takeEvent( | |
| 2047 | (event) => | |
| 2048 | event.type === "turn_item.updated" && | |
| 2049 | event.turnItem.type === "file_change" && | |
| 2050 | event.turnItem.status === "completed", | |
| 2051 | ); | |
| 2052 | assert.isTrue( | |
| 2053 | edit.type === "turn_item.updated" && | |
| 2054 | edit.turnItem.type === "file_change" && | |
| 2055 | edit.turnItem.fileName === "a.ts" && | |
| 2056 | edit.turnItem.diffStr === patch, | |
| 2057 | ); | |
| 2058 | ||
| 2059 | yield* fake.emit({ | |
| 2060 | type: "tool_execution_start", | |
| 2061 | toolCallId: "call_write", | |
| 2062 | toolName: "write", | |
| 2063 | args: { path: "b.ts", content: "export {};\n" }, | |
| 2064 | }); | |
| 2065 | yield* fake.emit({ | |
| 2066 | type: "tool_execution_end", | |
| 2067 | toolCallId: "call_write", | |
| 2068 | toolName: "write", | |
| 2069 | isError: false, | |
| 2070 | result: { content: [{ type: "text", text: "Successfully wrote to b.ts" }] }, | |
| 2071 | }); | |
| 2072 | const write = yield* takeEvent( | |
| 2073 | (event) => | |
| 2074 | event.type === "turn_item.updated" && | |
| 2075 | event.turnItem.type === "file_change" && | |
| 2076 | event.turnItem.status === "completed", | |
| 2077 | ); | |
| 2078 | assert.isTrue( | |
| 2079 | write.type === "turn_item.updated" && | |
| 2080 | write.turnItem.type === "file_change" && | |
| 2081 | write.turnItem.fileName === "b.ts" && | |
| 2082 | write.turnItem.newStr === "export {};\n", | |
| 2083 | ); | |
| 2084 | ||
| 2085 | // A failed edit has no patch, so it keeps the error to show when expanded. | |
| 2086 | yield* fake.emit({ | |
| 2087 | type: "tool_execution_start", | |
| 2088 | toolCallId: "call_edit_failed", | |
| 2089 | toolName: "edit", | |
| 2090 | args: { path: "c.ts", edits: [{ oldText: "missing", newText: "new" }] }, | |
| 2091 | }); | |
| 2092 | yield* fake.emit({ | |
| 2093 | type: "tool_execution_end", | |
| 2094 | toolCallId: "call_edit_failed", | |
| 2095 | toolName: "edit", | |
| 2096 | isError: true, | |
| 2097 | result: { content: [{ type: "text", text: "Could not find the text in c.ts." }] }, | |
| 2098 | }); | |
| 2099 | const failedEdit = yield* takeEvent( | |
| 2100 | (event) => | |
| 2101 | event.type === "turn_item.updated" && | |
| 2102 | event.turnItem.type === "file_change" && | |
| 2103 | event.turnItem.status === "failed", | |
| 2104 | ); | |
| 2105 | assert.isTrue( | |
| 2106 | failedEdit.type === "turn_item.updated" && | |
| 2107 | failedEdit.turnItem.type === "file_change" && | |
| 2108 | failedEdit.turnItem.diffStr === "Could not find the text in c.ts.", | |
| 2109 | ); | |
| 2110 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2111 | ); | |
| 2112 | ||
| 2113 | it.effect("settles a command-only prompt from its deferred ack and idle probe", () => | |
| 2114 | Effect.gen(function* () { | |
| 2115 | const fake = yield* makeFakePi; | |
| 2116 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2117 | const providerThread = yield* runtime.ensureThread({ | |
| 2118 | threadId: THREAD_ID, | |
| 2119 | modelSelection: modelSelection("default"), | |
| 2120 | runtimePolicy, | |
| 2121 | }); | |
| 2122 | yield* startTurn(runtime, providerThread, "default", [], "/command-only"); | |
| 2123 | yield* fake.takeRequest("prompt"); | |
| 2124 | // A pure extension command: dialog + notify, then the deferred ack — | |
| 2125 | // pi emits no agent_start/agent_settled at all. | |
| 2126 | yield* fake.emit({ | |
| 2127 | type: "extension_ui_request", | |
| 2128 | id: "ui-cmd", | |
| 2129 | method: "notify", | |
| 2130 | message: "done", | |
| 2131 | notifyType: "info", | |
| 2132 | }); | |
| 2133 | yield* fake.emit({ type: "response", command: "prompt", success: true }); | |
| 2134 | // The adapter probes get_state (auto-acked idle by the fake), then | |
| 2135 | // settles the turn as completed. | |
| 2136 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2137 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2138 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2139 | ); | |
| 2140 | ||
| 2141 | it.effect("leaves /compacted as an ordinary prompt", () => | |
| 2142 | Effect.gen(function* () { | |
| 2143 | const fake = yield* makeFakePi; | |
| 2144 | const { runtime } = yield* openRuntime(fake); | |
| 2145 | const providerThread = yield* runtime.ensureThread({ | |
| 2146 | threadId: THREAD_ID, | |
| 2147 | modelSelection: modelSelection("default"), | |
| 2148 | runtimePolicy, | |
| 2149 | }); | |
| 2150 | yield* startTurn(runtime, providerThread, "default", [], "/compacted please"); | |
| 2151 | const prompt = yield* fake.takeRequest("prompt"); | |
| 2152 | assert.equal(prompt["message"], "/compacted please"); | |
| 2153 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "compact")); | |
| 2154 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2155 | ); | |
| 2156 | ||
| 2157 | it.effect("fails a compact that never started", () => | |
| 2158 | Effect.gen(function* () { | |
| 2159 | const fake = yield* makeFakePi; | |
| 2160 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2161 | const providerThread = yield* runtime.ensureThread({ | |
| 2162 | threadId: THREAD_ID, | |
| 2163 | modelSelection: modelSelection("default"), | |
| 2164 | runtimePolicy, | |
| 2165 | }); | |
| 2166 | yield* startTurn(runtime, providerThread, "default", [], "/compact"); | |
| 2167 | yield* fake.takeRequest("compact"); | |
| 2168 | yield* fake.emit({ | |
| 2169 | type: "response", | |
| 2170 | command: "compact", | |
| 2171 | success: false, | |
| 2172 | error: "Nothing to compact (session too small)", | |
| 2173 | }); | |
| 2174 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2175 | assert.isTrue( | |
| 2176 | terminal.type === "turn.terminal" && | |
| 2177 | terminal.status === "failed" && | |
| 2178 | terminal.failure.message === "Nothing to compact (session too small)", | |
| 2179 | ); | |
| 2180 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2181 | ); | |
| 2182 | ||
| 2183 | it.effect("restarts Pi when Stop interrupts a user compact", () => | |
| 2184 | Effect.gen(function* () { | |
| 2185 | const fake = yield* makeFakePi; | |
| 2186 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2187 | const providerThread = yield* runtime.ensureThread({ | |
| 2188 | threadId: THREAD_ID, | |
| 2189 | modelSelection: modelSelection("default"), | |
| 2190 | runtimePolicy, | |
| 2191 | }); | |
| 2192 | yield* startTurn(runtime, providerThread, "default", [], "/compact"); | |
| 2193 | yield* fake.takeRequest("compact"); | |
| 2194 | const running = yield* takeEvent( | |
| 2195 | (event) => | |
| 2196 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 2197 | ); | |
| 2198 | const providerTurnId = | |
| 2199 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 2200 | assert.isDefined(providerTurnId); | |
| 2201 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2202 | yield* takeEvent( | |
| 2203 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2204 | ); | |
| 2205 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 2206 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); | |
| 2207 | yield* fake.closeStdout; | |
| 2208 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2209 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 2210 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2211 | ); | |
| 2212 | ||
| 2213 | it.effect("steers /compact as RPC compact instead of a prompt", () => | |
| 2214 | Effect.gen(function* () { | |
| 2215 | const fake = yield* makeFakePi; | |
| 2216 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2217 | const providerThread = yield* runtime.ensureThread({ | |
| 2218 | threadId: THREAD_ID, | |
| 2219 | modelSelection: modelSelection("default"), | |
| 2220 | runtimePolicy, | |
| 2221 | }); | |
| 2222 | yield* startTurn(runtime, providerThread); | |
| 2223 | yield* fake.takeRequest("prompt"); | |
| 2224 | const running = yield* takeEvent( | |
| 2225 | (event) => | |
| 2226 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 2227 | ); | |
| 2228 | const providerTurnId = | |
| 2229 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 2230 | yield* fake.emit({ type: "agent_start" }); | |
| 2231 | yield* runtime.steerTurn({ | |
| 2232 | threadId: THREAD_ID, | |
| 2233 | runId: RunId.make("run:thread-pi-test:1"), | |
| 2234 | providerThread, | |
| 2235 | providerTurnId: providerTurnId!, | |
| 2236 | message: { | |
| 2237 | messageId: "message:thread-pi-test:steer-compact" as never, | |
| 2238 | text: "/compact keep the tests", | |
| 2239 | attachments: [], | |
| 2240 | createdBy: "user", | |
| 2241 | creationSource: "web", | |
| 2242 | }, | |
| 2243 | }); | |
| 2244 | const compact = yield* fake.takeRequest("compact"); | |
| 2245 | assert.equal(compact["customInstructions"], "keep the tests"); | |
| 2246 | assert.isUndefined(compact["streamingBehavior"]); | |
| 2247 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2248 | yield* takeEvent( | |
| 2249 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2250 | ); | |
| 2251 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 2252 | yield* fake.emit({ | |
| 2253 | type: "compaction_end", | |
| 2254 | reason: "manual", | |
| 2255 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 2256 | aborted: false, | |
| 2257 | willRetry: false, | |
| 2258 | }); | |
| 2259 | yield* takeEvent( | |
| 2260 | (event) => | |
| 2261 | event.type === "turn_item.updated" && | |
| 2262 | event.turnItem.type === "compaction" && | |
| 2263 | event.turnItem.status === "completed", | |
| 2264 | ); | |
| 2265 | yield* fake.emit({ type: "response", command: "compact", success: true }); | |
| 2266 | yield* fake.emit({ type: "agent_settled" }); | |
| 2267 | yield* fake.takeRequest("get_state"); | |
| 2268 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2269 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2270 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2271 | ); | |
| 2272 | ||
| 2273 | it.effect("persists current xAI capacity text for the thread error banner", () => | |
| 2274 | expectModelFailure("The model is currently at capacity due to high demand."), | |
| 2275 | ); | |
| 2276 | ||
| 2277 | it.effect("persists extension-normalized xAI capacity text for the thread error banner", () => | |
| 2278 | expectModelFailure( | |
| 2279 | "Provider overloaded: The model is currently at capacity due to high demand.", | |
| 2280 | ), | |
| 2281 | ); | |
| 2282 | ||
| 2283 | it.effect("emits session-start dialogs before a turn exists", () => | |
| 2284 | Effect.gen(function* () { | |
| 2285 | const fake = yield* makeFakePi; | |
| 2286 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2287 | yield* runtime.ensureThread({ | |
| 2288 | threadId: THREAD_ID, | |
| 2289 | modelSelection: modelSelection("default"), | |
| 2290 | runtimePolicy, | |
| 2291 | }); | |
| 2292 | // Project-trust style prompt before any turn exists. | |
| 2293 | yield* fake.emit({ | |
| 2294 | type: "extension_ui_request", | |
| 2295 | id: "ui-trust", | |
| 2296 | method: "confirm", | |
| 2297 | title: "Run project extensions?", | |
| 2298 | message: "This project has .pi/extensions.", | |
| 2299 | }); | |
| 2300 | const pending = yield* takeEvent( | |
| 2301 | (event) => | |
| 2302 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2303 | ); | |
| 2304 | const requestId = | |
| 2305 | pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; | |
| 2306 | yield* runtime.respondToRuntimeRequest({ requestId: requestId!, decision: "accept" }); | |
| 2307 | const uiResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2308 | assert.equal(uiResponse["id"], "ui-trust"); | |
| 2309 | assert.equal(uiResponse["confirmed"], true); | |
| 2310 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2311 | ); | |
| 2312 | ||
| 2313 | it.effect("remembers session approvals only for identical confirmation content", () => | |
| 2314 | Effect.gen(function* () { | |
| 2315 | const fake = yield* makeFakePi; | |
| 2316 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2317 | yield* runtime.ensureThread({ | |
| 2318 | threadId: THREAD_ID, | |
| 2319 | modelSelection: modelSelection("default"), | |
| 2320 | runtimePolicy, | |
| 2321 | }); | |
| 2322 | // Project-trust style prompt before any turn exists. | |
| 2323 | yield* fake.emit({ | |
| 2324 | type: "extension_ui_request", | |
| 2325 | id: "ui-trust", | |
| 2326 | method: "confirm", | |
| 2327 | title: "Run project extensions?", | |
| 2328 | message: "This project has .pi/extensions.", | |
| 2329 | }); | |
| 2330 | const pending = yield* takeEvent( | |
| 2331 | (event) => | |
| 2332 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2333 | ); | |
| 2334 | const requestId = | |
| 2335 | pending.type === "runtime_request.updated" ? pending.runtimeRequest.id : undefined; | |
| 2336 | yield* runtime.respondToRuntimeRequest({ | |
| 2337 | requestId: requestId!, | |
| 2338 | decision: "acceptForSession", | |
| 2339 | }); | |
| 2340 | const uiResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2341 | assert.equal(uiResponse["id"], "ui-trust"); | |
| 2342 | assert.equal(uiResponse["confirmed"], true); | |
| 2343 | yield* fake.emit({ | |
| 2344 | type: "extension_ui_request", | |
| 2345 | id: "ui-trust-again", | |
| 2346 | method: "confirm", | |
| 2347 | title: "Run project extensions?", | |
| 2348 | message: "This project has .pi/extensions.", | |
| 2349 | }); | |
| 2350 | assert.equal((yield* fake.takeRequest("extension_ui_response"))["id"], "ui-trust-again"); | |
| 2351 | yield* fake.emit({ | |
| 2352 | type: "extension_ui_request", | |
| 2353 | id: "ui-other", | |
| 2354 | method: "confirm", | |
| 2355 | title: "Run project extensions?", | |
| 2356 | message: "A different project.", | |
| 2357 | }); | |
| 2358 | const other = yield* takeEvent( | |
| 2359 | (event) => | |
| 2360 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2361 | ); | |
| 2362 | assert.isTrue( | |
| 2363 | other.type === "runtime_request.updated" && | |
| 2364 | other.runtimeRequest.nativeRequestRef?.nativeId === "ui-other", | |
| 2365 | ); | |
| 2366 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2367 | ); | |
| 2368 | ||
| 2369 | it.effect("offers an explicit empty value for extension input dialogs", () => | |
| 2370 | Effect.gen(function* () { | |
| 2371 | const fake = yield* makeFakePi; | |
| 2372 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2373 | yield* runtime.ensureThread({ | |
| 2374 | threadId: THREAD_ID, | |
| 2375 | modelSelection: modelSelection("default"), | |
| 2376 | runtimePolicy, | |
| 2377 | }); | |
| 2378 | yield* fake.emit({ | |
| 2379 | type: "extension_ui_request", | |
| 2380 | id: "ui-input", | |
| 2381 | method: "input", | |
| 2382 | title: "Optional value", | |
| 2383 | }); | |
| 2384 | const event = yield* takeEvent( | |
| 2385 | (event) => | |
| 2386 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2387 | ); | |
| 2388 | assert.isTrue( | |
| 2389 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2390 | ); | |
| 2391 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") | |
| 2392 | return; | |
| 2393 | assert.equal(event.turnItem.questions[0]?.options[0]?.value, ""); | |
| 2394 | yield* runtime.respondToRuntimeRequest({ | |
| 2395 | requestId: event.turnItem.requestId, | |
| 2396 | answers: { "ui-input": "" }, | |
| 2397 | }); | |
| 2398 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 2399 | assert.equal(response["value"], ""); | |
| 2400 | assert.isUndefined(response["cancelled"]); | |
| 2401 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2402 | ); | |
| 2403 | ||
| 2404 | it.effect("keeps the complete editor prefill editable and returns exact edited text", () => | |
| 2405 | Effect.gen(function* () { | |
| 2406 | const fake = yield* makeFakePi; | |
| 2407 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2408 | yield* runtime.ensureThread({ | |
| 2409 | threadId: THREAD_ID, | |
| 2410 | modelSelection: modelSelection("default"), | |
| 2411 | runtimePolicy, | |
| 2412 | }); | |
| 2413 | const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `; | |
| 2414 | yield* fake.emit({ | |
| 2415 | type: "extension_ui_request", | |
| 2416 | id: "ui-editor", | |
| 2417 | method: "editor", | |
| 2418 | title: "Commit message", | |
| 2419 | prefill, | |
| 2420 | }); | |
| 2421 | const event = yield* takeEvent( | |
| 2422 | (event) => | |
| 2423 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | |
| 2424 | ); | |
| 2425 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") | |
| 2426 | return; | |
| 2427 | assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); | |
| 2428 | assert.deepEqual(event.turnItem.questions[0]?.options, []); | |
| 2429 | assert.equal(event.turnItem.questions[0]?.question, "Commit message"); | |
| 2430 | const edited = " fix(server): edited commit message\n\n"; | |
| 2431 | yield* runtime.respondToRuntimeRequest({ | |
| 2432 | requestId: event.turnItem.requestId, | |
| 2433 | answers: { "ui-editor": edited }, | |
| 2434 | }); | |
| 2435 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 2436 | assert.equal(response["value"], edited); | |
| 2437 | assert.isUndefined(response["cancelled"]); | |
| 2438 | yield* fake.emit({ | |
| 2439 | type: "extension_ui_request", | |
| 2440 | id: "ui-editor-cleared", | |
| 2441 | method: "editor", | |
| 2442 | title: "Commit message", | |
| 2443 | prefill, | |
| 2444 | }); | |
| 2445 | const cleared = yield* takeEvent( | |
| 2446 | (event) => | |
| 2447 | event.type === "turn_item.updated" && | |
| 2448 | event.turnItem.type === "user_input_request" && | |
| 2449 | event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"), | |
| 2450 | ); | |
| 2451 | if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request") | |
| 2452 | return; | |
| 2453 | yield* runtime.respondToRuntimeRequest({ | |
| 2454 | requestId: cleared.turnItem.requestId, | |
| 2455 | answers: { "ui-editor-cleared": "" }, | |
| 2456 | }); | |
| 2457 | const emptyResponse = yield* fake.takeRequest("extension_ui_response"); | |
| 2458 | assert.equal(emptyResponse["value"], ""); | |
| 2459 | assert.isUndefined(emptyResponse["cancelled"]); | |
| 2460 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2461 | ); | |
| 2462 | ||
| 2463 | it.effect("raises bridge edit confirmations as file-change approvals", () => | |
| 2464 | Effect.gen(function* () { | |
| 2465 | const fake = yield* makeFakePi; | |
| 2466 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2467 | const providerThread = yield* runtime.ensureThread({ | |
| 2468 | threadId: THREAD_ID, | |
| 2469 | modelSelection: modelSelection("default"), | |
| 2470 | runtimePolicy, | |
| 2471 | }); | |
| 2472 | yield* startTurn(runtime, providerThread); | |
| 2473 | yield* fake.takeRequest("prompt"); | |
| 2474 | for (const [id, title, requestKind] of [ | |
| 2475 | ["ui-edit", "Allow edit?", "file-change"], | |
| 2476 | ["ui-bash", "Allow bash?", "command"], | |
| 2477 | ["ui-ext", "Deploy to staging?", "command"], | |
| 2478 | ] as const) { | |
| 2479 | yield* fake.emit({ type: "extension_ui_request", id, method: "confirm", title }); | |
| 2480 | const item = yield* takeEvent( | |
| 2481 | (event) => | |
| 2482 | event.type === "turn_item.updated" && event.turnItem.type === "approval_request", | |
| 2483 | ); | |
| 2484 | assert.isTrue( | |
| 2485 | item.type === "turn_item.updated" && | |
| 2486 | item.turnItem.type === "approval_request" && | |
| 2487 | item.turnItem.requestKind === requestKind, | |
| 2488 | `${title} should be ${requestKind}`, | |
| 2489 | ); | |
| 2490 | } | |
| 2491 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2492 | ); | |
| 2493 | ||
| 2494 | it.effect("reads a thread snapshot from pi's active branch", () => | |
| 2495 | Effect.gen(function* () { | |
| 2496 | const fake = yield* makeFakePi; | |
| 2497 | const { runtime } = yield* openRuntime(fake); | |
| 2498 | const providerThread = yield* runtime.ensureThread({ | |
| 2499 | threadId: THREAD_ID, | |
| 2500 | modelSelection: modelSelection("default"), | |
| 2501 | runtimePolicy, | |
| 2502 | }); | |
| 2503 | fake.queueMessages({ | |
| 2504 | messages: [ | |
| 2505 | { | |
| 2506 | role: "user", | |
| 2507 | content: "hello pi", | |
| 2508 | timestamp: 1700000000000, | |
| 2509 | }, | |
| 2510 | { | |
| 2511 | role: "assistant", | |
| 2512 | content: [{ type: "text", text: "hello back" }], | |
| 2513 | timestamp: 1700000001000, | |
| 2514 | }, | |
| 2515 | { role: "toolResult", content: [] }, | |
| 2516 | ], | |
| 2517 | }); | |
| 2518 | const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); | |
| 2519 | assert.equal(snapshot.messages.length, 2); | |
| 2520 | assert.equal(snapshot.messages[0]!.role, "user"); | |
| 2521 | assert.equal(snapshot.messages[0]!.text, "hello pi"); | |
| 2522 | assert.equal(snapshot.messages[1]!.role, "assistant"); | |
| 2523 | assert.equal(snapshot.messages[1]!.text, "hello back"); | |
| 2524 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2525 | ); | |
| 2526 | ||
| 2527 | it.effect("keeps snapshot message identities distinct across native sessions", () => | |
| 2528 | Effect.gen(function* () { | |
| 2529 | const fake = yield* makeFakePi; | |
| 2530 | const { runtime } = yield* openRuntime(fake); | |
| 2531 | const first = yield* runtime.ensureThread({ | |
| 2532 | threadId: THREAD_ID, | |
| 2533 | modelSelection: modelSelection("default"), | |
| 2534 | runtimePolicy, | |
| 2535 | }); | |
| 2536 | const messages = { | |
| 2537 | messages: [{ role: "user", content: "same text", timestamp: 1700000000000 }], | |
| 2538 | }; | |
| 2539 | fake.queueMessages(messages); | |
| 2540 | const a = yield* runtime.readThreadSnapshot({ providerThread: first }); | |
| 2541 | fake.queueState({ sessionFile: "/fake/another-session.jsonl" }); | |
| 2542 | const second = yield* runtime.ensureThread({ | |
| 2543 | threadId: ThreadId.make("second-thread"), | |
| 2544 | modelSelection: modelSelection("default"), | |
| 2545 | runtimePolicy, | |
| 2546 | existingProviderThread: { | |
| 2547 | ...first, | |
| 2548 | nativeThreadRef: { | |
| 2549 | driver: PI_PROVIDER, | |
| 2550 | nativeId: "/fake/another-session.jsonl", | |
| 2551 | strength: "strong", | |
| 2552 | }, | |
| 2553 | }, | |
| 2554 | }); | |
| 2555 | fake.queueMessages(messages); | |
| 2556 | const b = yield* runtime.readThreadSnapshot({ providerThread: second }); | |
| 2557 | assert.notEqual(a.messages[0]!.id, b.messages[0]!.id); | |
| 2558 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2559 | ); | |
| 2560 | ||
| 2561 | it.effect("rejects a nonpersistent session UUID instead of treating it as a resumable path", () => | |
| 2562 | Effect.gen(function* () { | |
| 2563 | const fake = yield* makeFakePi; | |
| 2564 | const { runtime } = yield* openRuntime(fake); | |
| 2565 | // A --no-session Pi keeps its session in memory and reports no file. | |
| 2566 | fake.queueState({ sessionFile: undefined }); | |
| 2567 | const result = yield* runtime | |
| 2568 | .ensureThread({ | |
| 2569 | threadId: THREAD_ID, | |
| 2570 | modelSelection: modelSelection("default"), | |
| 2571 | runtimePolicy, | |
| 2572 | }) | |
| 2573 | .pipe(Effect.result); | |
| 2574 | assert.equal(result._tag, "Failure"); | |
| 2575 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2576 | ); | |
| 2577 | ||
| 2578 | it.effect("keeps a settled turn's late prompt rejection off the next turn", () => | |
| 2579 | Effect.gen(function* () { | |
| 2580 | const fake = yield* makeFakePi; | |
| 2581 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2582 | const providerThread = yield* runtime.ensureThread({ | |
| 2583 | threadId: THREAD_ID, | |
| 2584 | modelSelection: modelSelection("default"), | |
| 2585 | runtimePolicy, | |
| 2586 | }); | |
| 2587 | // An extension command can hold its prompt ack open past settlement. | |
| 2588 | yield* startTurn(runtime, providerThread, "default", [], "/my-command"); | |
| 2589 | yield* fake.takeRequest("prompt"); | |
| 2590 | yield* fake.emit({ type: "agent_start" }); | |
| 2591 | yield* fake.emit({ type: "agent_settled" }); | |
| 2592 | const firstTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2593 | assert.isTrue(firstTerminal.type === "turn.terminal" && firstTerminal.status === "completed"); | |
| 2594 | ||
| 2595 | yield* startTurn(runtime, providerThread, "default", [], "Second turn", undefined, 2); | |
| 2596 | yield* fake.takeRequest("prompt"); | |
| 2597 | // The rejection answers the first turn's prompt. It must not consume or | |
| 2598 | // fail the second turn's prompt acknowledgement. | |
| 2599 | yield* fake.emit({ | |
| 2600 | type: "response", | |
| 2601 | command: "prompt", | |
| 2602 | success: false, | |
| 2603 | error: "late command rejection", | |
| 2604 | }); | |
| 2605 | yield* fake.emit({ type: "agent_start" }); | |
| 2606 | yield* fake.emit({ type: "agent_settled" }); | |
| 2607 | const secondTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2608 | assert.isTrue( | |
| 2609 | secondTerminal.type === "turn.terminal" && secondTerminal.status === "completed", | |
| 2610 | ); | |
| 2611 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2612 | ); | |
| 2613 | ||
| 2614 | it.effect("shows compaction progress and completes the same activity row", () => | |
| 2615 | Effect.gen(function* () { | |
| 2616 | const fake = yield* makeFakePi; | |
| 2617 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2618 | const providerThread = yield* runtime.ensureThread({ | |
| 2619 | threadId: THREAD_ID, | |
| 2620 | modelSelection: modelSelection("default"), | |
| 2621 | runtimePolicy, | |
| 2622 | }); | |
| 2623 | yield* startTurn(runtime, providerThread); | |
| 2624 | yield* fake.takeRequest("prompt"); | |
| 2625 | yield* fake.emit({ type: "agent_start" }); | |
| 2626 | yield* fake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2627 | ||
| 2628 | const runningNode = yield* takeEvent( | |
| 2629 | (event) => event.type === "node.updated" && event.node.kind === "system", | |
| 2630 | ); | |
| 2631 | const runningItem = yield* takeEvent( | |
| 2632 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2633 | ); | |
| 2634 | assert.isTrue( | |
| 2635 | runningNode.type === "node.updated" && | |
| 2636 | runningNode.node.status === "running" && | |
| 2637 | runningItem.type === "turn_item.updated" && | |
| 2638 | runningItem.turnItem.type === "compaction" && | |
| 2639 | runningItem.turnItem.status === "running" && | |
| 2640 | runningItem.turnItem.title === "Compacting context...", | |
| 2641 | ); | |
| 2642 | ||
| 2643 | yield* fake.emit({ | |
| 2644 | type: "compaction_end", | |
| 2645 | reason: "threshold", | |
| 2646 | result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, | |
| 2647 | aborted: false, | |
| 2648 | willRetry: false, | |
| 2649 | }); | |
| 2650 | const completedNode = yield* takeEvent( | |
| 2651 | (event) => event.type === "node.updated" && event.node.kind === "system", | |
| 2652 | ); | |
| 2653 | const completedItem = yield* takeEvent( | |
| 2654 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2655 | ); | |
| 2656 | assert.isTrue( | |
| 2657 | runningNode.type === "node.updated" && | |
| 2658 | completedNode.type === "node.updated" && | |
| 2659 | runningItem.type === "turn_item.updated" && | |
| 2660 | runningItem.turnItem.type === "compaction" && | |
| 2661 | completedItem.type === "turn_item.updated" && | |
| 2662 | completedItem.turnItem.type === "compaction" && | |
| 2663 | completedNode.node.id === runningNode.node.id && | |
| 2664 | completedNode.node.status === "completed" && | |
| 2665 | completedItem.turnItem.id === runningItem.turnItem.id && | |
| 2666 | completedItem.turnItem.ordinal === runningItem.turnItem.ordinal && | |
| 2667 | completedItem.turnItem.startedAt === runningItem.turnItem.startedAt && | |
| 2668 | completedItem.turnItem.status === "completed" && | |
| 2669 | completedItem.turnItem.title === "Context compacted" && | |
| 2670 | completedItem.turnItem.beforeTokenCount === 200_000 && | |
| 2671 | completedItem.turnItem.afterTokenCount === 3_400, | |
| 2672 | ); | |
| 2673 | ||
| 2674 | yield* fake.emit({ type: "agent_settled" }); | |
| 2675 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2676 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2677 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2678 | ); | |
| 2679 | ||
| 2680 | it.effect("uses distinct compaction IDs for first turns in separate threads", () => | |
| 2681 | Effect.gen(function* () { | |
| 2682 | const firstFake = yield* makeFakePi; | |
| 2683 | const { runtime: firstRuntime, takeEvent: takeFirstEvent } = yield* openRuntime(firstFake); | |
| 2684 | const firstProviderThread = yield* firstRuntime.ensureThread({ | |
| 2685 | threadId: THREAD_ID, | |
| 2686 | modelSelection: modelSelection("default"), | |
| 2687 | runtimePolicy, | |
| 2688 | }); | |
| 2689 | yield* startTurn(firstRuntime, firstProviderThread); | |
| 2690 | yield* firstFake.takeRequest("prompt"); | |
| 2691 | yield* firstFake.emit({ type: "agent_start" }); | |
| 2692 | yield* firstFake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2693 | const first = yield* takeFirstEvent( | |
| 2694 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2695 | ); | |
| 2696 | ||
| 2697 | const secondThreadId = ThreadId.make("thread-pi-test-second"); | |
| 2698 | const secondFake = yield* makeFakePi; | |
| 2699 | const { runtime: secondRuntime, takeEvent: takeSecondEvent } = yield* openRuntime( | |
| 2700 | secondFake, | |
| 2701 | "default", | |
| 2702 | secondThreadId, | |
| 2703 | ProviderSessionId.make("provider-session-pi-test-second"), | |
| 2704 | ); | |
| 2705 | const secondProviderThread = yield* secondRuntime.ensureThread({ | |
| 2706 | threadId: secondThreadId, | |
| 2707 | modelSelection: modelSelection("default"), | |
| 2708 | runtimePolicy, | |
| 2709 | }); | |
| 2710 | yield* startTurn( | |
| 2711 | secondRuntime, | |
| 2712 | secondProviderThread, | |
| 2713 | "default", | |
| 2714 | [], | |
| 2715 | "Hello from another thread", | |
| 2716 | undefined, | |
| 2717 | 1, | |
| 2718 | secondThreadId, | |
| 2719 | ); | |
| 2720 | yield* secondFake.takeRequest("prompt"); | |
| 2721 | yield* secondFake.emit({ type: "agent_start" }); | |
| 2722 | yield* secondFake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2723 | const second = yield* takeSecondEvent( | |
| 2724 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2725 | ); | |
| 2726 | ||
| 2727 | assert.isTrue( | |
| 2728 | first.type === "turn_item.updated" && | |
| 2729 | first.turnItem.type === "compaction" && | |
| 2730 | second.type === "turn_item.updated" && | |
| 2731 | second.turnItem.type === "compaction" && | |
| 2732 | first.turnItem.ordinal === second.turnItem.ordinal && | |
| 2733 | first.turnItem.id !== second.turnItem.id, | |
| 2734 | ); | |
| 2735 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2736 | ); | |
| 2737 | ||
| 2738 | it.effect("shows aborted compactions as stopped", () => | |
| 2739 | Effect.gen(function* () { | |
| 2740 | const fake = yield* makeFakePi; | |
| 2741 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2742 | const providerThread = yield* runtime.ensureThread({ | |
| 2743 | threadId: THREAD_ID, | |
| 2744 | modelSelection: modelSelection("default"), | |
| 2745 | runtimePolicy, | |
| 2746 | }); | |
| 2747 | yield* startTurn(runtime, providerThread); | |
| 2748 | yield* fake.takeRequest("prompt"); | |
| 2749 | yield* fake.emit({ type: "agent_start" }); | |
| 2750 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 2751 | const running = yield* takeEvent( | |
| 2752 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2753 | ); | |
| 2754 | yield* fake.emit({ | |
| 2755 | type: "compaction_end", | |
| 2756 | reason: "manual", | |
| 2757 | aborted: true, | |
| 2758 | willRetry: false, | |
| 2759 | }); | |
| 2760 | const stopped = yield* takeEvent( | |
| 2761 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2762 | ); | |
| 2763 | assert.isTrue( | |
| 2764 | running.type === "turn_item.updated" && | |
| 2765 | running.turnItem.type === "compaction" && | |
| 2766 | stopped.type === "turn_item.updated" && | |
| 2767 | stopped.turnItem.type === "compaction" && | |
| 2768 | stopped.turnItem.id === running.turnItem.id && | |
| 2769 | stopped.turnItem.status === "cancelled" && | |
| 2770 | stopped.turnItem.title === "Context compaction stopped", | |
| 2771 | ); | |
| 2772 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2773 | ); | |
| 2774 | ||
| 2775 | it.effect("keeps the turn open and updates one retry row through final failure", () => | |
| 2776 | Effect.gen(function* () { | |
| 2777 | const fake = yield* makeFakePi; | |
| 2778 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2779 | const providerThread = yield* runtime.ensureThread({ | |
| 2780 | threadId: THREAD_ID, | |
| 2781 | modelSelection: modelSelection("default"), | |
| 2782 | runtimePolicy, | |
| 2783 | }); | |
| 2784 | yield* startTurn(runtime, providerThread); | |
| 2785 | yield* fake.takeRequest("prompt"); | |
| 2786 | ||
| 2787 | yield* fake.emit({ type: "agent_start" }); | |
| 2788 | yield* fake.emit({ type: "agent_end", messages: [], willRetry: true }); | |
| 2789 | yield* fake.emit({ | |
| 2790 | type: "auto_retry_start", | |
| 2791 | attempt: 1, | |
| 2792 | maxAttempts: 3, | |
| 2793 | delayMs: 3_000, | |
| 2794 | errorMessage: "529 overloaded", | |
| 2795 | }); | |
| 2796 | const firstRetry = yield* takeEvent( | |
| 2797 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2798 | ); | |
| 2799 | assert.isTrue( | |
| 2800 | firstRetry.type === "turn_item.updated" && | |
| 2801 | firstRetry.turnItem.type === "error" && | |
| 2802 | firstRetry.turnItem.status === "running" && | |
| 2803 | firstRetry.turnItem.title === "Provider retry" && | |
| 2804 | firstRetry.turnItem.failure.retryable === true && | |
| 2805 | firstRetry.turnItem.retry?.attempt === 1 && | |
| 2806 | firstRetry.turnItem.retry.maxAttempts === 3 && | |
| 2807 | firstRetry.turnItem.retry.retryDelayMs === 3_000, | |
| 2808 | ); | |
| 2809 | ||
| 2810 | yield* fake.emit({ | |
| 2811 | type: "auto_retry_start", | |
| 2812 | attempt: 3, | |
| 2813 | maxAttempts: 3, | |
| 2814 | delayMs: 12_000, | |
| 2815 | errorMessage: "529 still overloaded", | |
| 2816 | }); | |
| 2817 | const lastRetry = yield* takeEvent( | |
| 2818 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2819 | ); | |
| 2820 | assert.isTrue( | |
| 2821 | firstRetry.type === "turn_item.updated" && | |
| 2822 | firstRetry.turnItem.type === "error" && | |
| 2823 | lastRetry.type === "turn_item.updated" && | |
| 2824 | lastRetry.turnItem.type === "error" && | |
| 2825 | lastRetry.turnItem.id === firstRetry.turnItem.id && | |
| 2826 | lastRetry.turnItem.startedAt === firstRetry.turnItem.startedAt && | |
| 2827 | lastRetry.turnItem.retry?.attempt === 3, | |
| 2828 | ); | |
| 2829 | ||
| 2830 | yield* fake.emit({ | |
| 2831 | type: "auto_retry_end", | |
| 2832 | success: false, | |
| 2833 | attempt: 3, | |
| 2834 | finalError: "529 overloaded", | |
| 2835 | }); | |
| 2836 | const failedRetry = yield* takeEvent( | |
| 2837 | (event) => | |
| 2838 | event.type === "turn_item.updated" && | |
| 2839 | event.turnItem.type === "error" && | |
| 2840 | event.turnItem.status === "failed", | |
| 2841 | ); | |
| 2842 | assert.isTrue( | |
| 2843 | firstRetry.type === "turn_item.updated" && | |
| 2844 | firstRetry.turnItem.type === "error" && | |
| 2845 | failedRetry.type === "turn_item.updated" && | |
| 2846 | failedRetry.turnItem.type === "error" && | |
| 2847 | failedRetry.turnItem.id === firstRetry.turnItem.id && | |
| 2848 | failedRetry.turnItem.title === "Provider error" && | |
| 2849 | failedRetry.turnItem.failure.retryable === false && | |
| 2850 | failedRetry.turnItem.retry?.attempt === 3 && | |
| 2851 | failedRetry.turnItem.retry.maxAttempts === 3, | |
| 2852 | ); | |
| 2853 | yield* fake.emit({ type: "agent_settled" }); | |
| 2854 | ||
| 2855 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2856 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "failed"); | |
| 2857 | assert.isTrue( | |
| 2858 | firstRetry.type === "turn_item.updated" && | |
| 2859 | firstRetry.turnItem.type === "error" && | |
| 2860 | terminal.type === "turn.terminal" && | |
| 2861 | terminal.status === "failed" && | |
| 2862 | terminal.failure.message.includes("overloaded") && | |
| 2863 | terminal.retry?.attempt === 3 && | |
| 2864 | terminal.retry.maxAttempts === 3 && | |
| 2865 | terminal.retryStartedAt === firstRetry.turnItem.startedAt, | |
| 2866 | ); | |
| 2867 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2868 | ); | |
| 2869 | ||
| 2870 | it.effect("preserves exhausted retry failure through non-retrying compaction", () => | |
| 2871 | Effect.gen(function* () { | |
| 2872 | const fake = yield* makeFakePi; | |
| 2873 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2874 | const providerThread = yield* runtime.ensureThread({ | |
| 2875 | threadId: THREAD_ID, | |
| 2876 | modelSelection: modelSelection("default"), | |
| 2877 | runtimePolicy, | |
| 2878 | }); | |
| 2879 | yield* startTurn(runtime, providerThread); | |
| 2880 | yield* fake.takeRequest("prompt"); | |
| 2881 | yield* fake.emit({ type: "agent_start" }); | |
| 2882 | yield* fake.emit({ | |
| 2883 | type: "auto_retry_start", | |
| 2884 | attempt: 5, | |
| 2885 | maxAttempts: 5, | |
| 2886 | delayMs: 48_000, | |
| 2887 | errorMessage: "socket timed out", | |
| 2888 | }); | |
| 2889 | yield* takeEvent( | |
| 2890 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2891 | ); | |
| 2892 | yield* fake.emit({ | |
| 2893 | type: "auto_retry_end", | |
| 2894 | success: false, | |
| 2895 | attempt: 5, | |
| 2896 | finalError: "socket timed out", | |
| 2897 | }); | |
| 2898 | yield* takeEvent( | |
| 2899 | (event) => | |
| 2900 | event.type === "turn_item.updated" && | |
| 2901 | event.turnItem.type === "error" && | |
| 2902 | event.turnItem.status === "failed", | |
| 2903 | ); | |
| 2904 | yield* fake.emit({ type: "compaction_start", reason: "threshold" }); | |
| 2905 | yield* takeEvent( | |
| 2906 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 2907 | ); | |
| 2908 | yield* fake.emit({ | |
| 2909 | type: "compaction_end", | |
| 2910 | reason: "threshold", | |
| 2911 | result: { summary: "smaller", tokensBefore: 200_000, estimatedTokensAfter: 3_400 }, | |
| 2912 | aborted: false, | |
| 2913 | willRetry: false, | |
| 2914 | }); | |
| 2915 | yield* takeEvent( | |
| 2916 | (event) => | |
| 2917 | event.type === "turn_item.updated" && | |
| 2918 | event.turnItem.type === "compaction" && | |
| 2919 | event.turnItem.status === "completed", | |
| 2920 | ); | |
| 2921 | yield* fake.emit({ type: "agent_settled" }); | |
| 2922 | ||
| 2923 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2924 | assert.isTrue( | |
| 2925 | terminal.type === "turn.terminal" && | |
| 2926 | terminal.status === "failed" && | |
| 2927 | terminal.failure.message === "socket timed out" && | |
| 2928 | terminal.retry?.attempt === 5 && | |
| 2929 | terminal.retry.maxAttempts === 5, | |
| 2930 | ); | |
| 2931 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2932 | ); | |
| 2933 | ||
| 2934 | it.effect("marks retry progress recovered when Pi succeeds", () => | |
| 2935 | Effect.gen(function* () { | |
| 2936 | const fake = yield* makeFakePi; | |
| 2937 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2938 | const providerThread = yield* runtime.ensureThread({ | |
| 2939 | threadId: THREAD_ID, | |
| 2940 | modelSelection: modelSelection("default"), | |
| 2941 | runtimePolicy, | |
| 2942 | }); | |
| 2943 | yield* startTurn(runtime, providerThread); | |
| 2944 | yield* fake.takeRequest("prompt"); | |
| 2945 | yield* fake.emit({ type: "agent_start" }); | |
| 2946 | yield* fake.emit({ | |
| 2947 | type: "message_end", | |
| 2948 | message: { | |
| 2949 | role: "assistant", | |
| 2950 | content: [], | |
| 2951 | stopReason: "error", | |
| 2952 | errorMessage: "temporary network failure", | |
| 2953 | }, | |
| 2954 | }); | |
| 2955 | yield* fake.emit({ | |
| 2956 | type: "auto_retry_start", | |
| 2957 | attempt: 1, | |
| 2958 | maxAttempts: 5, | |
| 2959 | delayMs: 3_000, | |
| 2960 | errorMessage: "temporary network failure", | |
| 2961 | }); | |
| 2962 | const running = yield* takeEvent( | |
| 2963 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2964 | ); | |
| 2965 | yield* fake.emit({ type: "auto_retry_end", success: true, attempt: 1 }); | |
| 2966 | const recovered = yield* takeEvent( | |
| 2967 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 2968 | ); | |
| 2969 | assert.isTrue( | |
| 2970 | running.type === "turn_item.updated" && | |
| 2971 | running.turnItem.type === "error" && | |
| 2972 | recovered.type === "turn_item.updated" && | |
| 2973 | recovered.turnItem.type === "error" && | |
| 2974 | recovered.turnItem.id === running.turnItem.id && | |
| 2975 | recovered.turnItem.status === "completed" && | |
| 2976 | recovered.turnItem.title === "Provider recovered", | |
| 2977 | ); | |
| 2978 | ||
| 2979 | yield* fake.emit({ type: "agent_settled" }); | |
| 2980 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 2981 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 2982 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2983 | ); | |
| 2984 | ||
| 2985 | it.effect("stops active retry progress when the turn is interrupted", () => | |
| 2986 | Effect.gen(function* () { | |
| 2987 | const fake = yield* makeFakePi; | |
| 2988 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2989 | const providerThread = yield* runtime.ensureThread({ | |
| 2990 | threadId: THREAD_ID, | |
| 2991 | modelSelection: modelSelection("default"), | |
| 2992 | runtimePolicy, | |
| 2993 | }); | |
| 2994 | yield* startTurn(runtime, providerThread); | |
| 2995 | yield* fake.takeRequest("prompt"); | |
| 2996 | yield* fake.emit({ type: "agent_start" }); | |
| 2997 | const runningTurn = yield* takeEvent( | |
| 2998 | (event) => | |
| 2999 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3000 | ); | |
| 3001 | const providerTurnId = | |
| 3002 | runningTurn.type === "provider_turn.updated" ? runningTurn.providerTurn.id : undefined; | |
| 3003 | assert.isDefined(providerTurnId); | |
| 3004 | ||
| 3005 | yield* fake.emit({ | |
| 3006 | type: "auto_retry_start", | |
| 3007 | attempt: 2, | |
| 3008 | maxAttempts: 5, | |
| 3009 | delayMs: 6_000, | |
| 3010 | errorMessage: "temporary network failure", | |
| 3011 | }); | |
| 3012 | const retrying = yield* takeEvent( | |
| 3013 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 3014 | ); | |
| 3015 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 3016 | yield* fake.takeRequest("abort"); | |
| 3017 | yield* fake.emit({ type: "agent_settled" }); | |
| 3018 | ||
| 3019 | const stopped = yield* takeEvent( | |
| 3020 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", | |
| 3021 | ); | |
| 3022 | assert.isTrue( | |
| 3023 | retrying.type === "turn_item.updated" && | |
| 3024 | retrying.turnItem.type === "error" && | |
| 3025 | stopped.type === "turn_item.updated" && | |
| 3026 | stopped.turnItem.type === "error" && | |
| 3027 | stopped.turnItem.id === retrying.turnItem.id && | |
| 3028 | stopped.turnItem.status === "interrupted" && | |
| 3029 | stopped.turnItem.title === "Provider retry stopped", | |
| 3030 | ); | |
| 3031 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3032 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 3033 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3034 | ); | |
| 3035 | ||
| 3036 | it.effect("keeps extension-started compaction and recovery in the settled turn", () => | |
| 3037 | Effect.gen(function* () { | |
| 3038 | const fake = yield* makeFakePi; | |
| 3039 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3040 | const providerThread = yield* runtime.ensureThread({ | |
| 3041 | threadId: THREAD_ID, | |
| 3042 | modelSelection: modelSelection("default"), | |
| 3043 | runtimePolicy, | |
| 3044 | }); | |
| 3045 | yield* startTurn(runtime, providerThread); | |
| 3046 | yield* fake.takeRequest("prompt"); | |
| 3047 | yield* fake.emit({ type: "agent_start" }); | |
| 3048 | ||
| 3049 | // Extension ctx.compact() waits for this first settlement, then starts | |
| 3050 | // compaction in a detached continuation. | |
| 3051 | fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); | |
| 3052 | yield* fake.emit({ type: "agent_settled" }); | |
| 3053 | yield* fake.takeRequest("get_state"); | |
| 3054 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3055 | yield* takeEvent( | |
| 3056 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3057 | ); | |
| 3058 | ||
| 3059 | fake.queueState({ isStreaming: true, isCompacting: false, pendingMessageCount: 0 }); | |
| 3060 | yield* fake.emit({ | |
| 3061 | type: "compaction_end", | |
| 3062 | reason: "manual", | |
| 3063 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 3064 | aborted: false, | |
| 3065 | willRetry: false, | |
| 3066 | }); | |
| 3067 | yield* fake.emit({ type: "agent_start" }); | |
| 3068 | yield* takeEvent( | |
| 3069 | (event) => | |
| 3070 | event.type === "turn_item.updated" && | |
| 3071 | event.turnItem.type === "compaction" && | |
| 3072 | event.turnItem.status === "completed", | |
| 3073 | ); | |
| 3074 | yield* fake.takeRequest("get_state"); | |
| 3075 | ||
| 3076 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3077 | yield* fake.emit({ type: "agent_settled" }); | |
| 3078 | yield* fake.takeRequest("get_state"); | |
| 3079 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3080 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3081 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3082 | ); | |
| 3083 | ||
| 3084 | it.effect("keeps working after a settle probe fails before detached compaction", () => | |
| 3085 | Effect.gen(function* () { | |
| 3086 | const fake = yield* makeFakePi; | |
| 3087 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3088 | const providerThread = yield* runtime.ensureThread({ | |
| 3089 | threadId: THREAD_ID, | |
| 3090 | modelSelection: modelSelection("default"), | |
| 3091 | runtimePolicy, | |
| 3092 | }); | |
| 3093 | yield* startTurn(runtime, providerThread); | |
| 3094 | yield* fake.takeRequest("prompt"); | |
| 3095 | yield* fake.emit({ type: "agent_start" }); | |
| 3096 | ||
| 3097 | fake.failNextState(); | |
| 3098 | yield* fake.emit({ type: "agent_settled" }); | |
| 3099 | yield* fake.takeRequest("get_state"); | |
| 3100 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3101 | yield* takeEvent( | |
| 3102 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3103 | ); | |
| 3104 | ||
| 3105 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3106 | yield* fake.emit({ | |
| 3107 | type: "compaction_end", | |
| 3108 | reason: "manual", | |
| 3109 | result: { summary: "smaller", tokensBefore: 10_000, estimatedTokensAfter: 2_000 }, | |
| 3110 | aborted: false, | |
| 3111 | willRetry: false, | |
| 3112 | }); | |
| 3113 | yield* takeEvent( | |
| 3114 | (event) => | |
| 3115 | event.type === "turn_item.updated" && | |
| 3116 | event.turnItem.type === "compaction" && | |
| 3117 | event.turnItem.status === "completed", | |
| 3118 | ); | |
| 3119 | yield* fake.takeRequest("get_state"); | |
| 3120 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3121 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3122 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3123 | ); | |
| 3124 | ||
| 3125 | it.effect("restarts Pi when Stop interrupts detached compaction", () => | |
| 3126 | Effect.gen(function* () { | |
| 3127 | const fake = yield* makeFakePi; | |
| 3128 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3129 | const providerThread = yield* runtime.ensureThread({ | |
| 3130 | threadId: THREAD_ID, | |
| 3131 | modelSelection: modelSelection("default"), | |
| 3132 | runtimePolicy, | |
| 3133 | }); | |
| 3134 | yield* startTurn(runtime, providerThread); | |
| 3135 | yield* fake.takeRequest("prompt"); | |
| 3136 | const running = yield* takeEvent( | |
| 3137 | (event) => | |
| 3138 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3139 | ); | |
| 3140 | assert.equal(running.type, "provider_turn.updated"); | |
| 3141 | const providerTurnId = | |
| 3142 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 3143 | assert.isDefined(providerTurnId); | |
| 3144 | yield* fake.emit({ type: "agent_start" }); | |
| 3145 | ||
| 3146 | fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); | |
| 3147 | yield* fake.emit({ type: "agent_settled" }); | |
| 3148 | yield* fake.takeRequest("get_state"); | |
| 3149 | yield* fake.emit({ type: "compaction_start", reason: "manual" }); | |
| 3150 | yield* takeEvent( | |
| 3151 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", | |
| 3152 | ); | |
| 3153 | ||
| 3154 | yield* runtime.interruptTurn({ providerThread, providerTurnId: providerTurnId! }); | |
| 3155 | assert.isFalse(fake.allRequests().some((request) => request["type"] === "abort")); | |
| 3156 | yield* fake.closeStdout; | |
| 3157 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3158 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "interrupted"); | |
| 3159 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3160 | ); | |
| 3161 | ||
| 3162 | it.effect("ignores an idle snapshot made stale by a steer", () => | |
| 3163 | Effect.gen(function* () { | |
| 3164 | const fake = yield* makeFakePi; | |
| 3165 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 3166 | const providerThread = yield* runtime.ensureThread({ | |
| 3167 | threadId: THREAD_ID, | |
| 3168 | modelSelection: modelSelection("default"), | |
| 3169 | runtimePolicy, | |
| 3170 | }); | |
| 3171 | yield* startTurn(runtime, providerThread); | |
| 3172 | yield* fake.takeRequest("prompt"); | |
| 3173 | const running = yield* takeEvent( | |
| 3174 | (event) => | |
| 3175 | event.type === "provider_turn.updated" && event.providerTurn.status === "running", | |
| 3176 | ); | |
| 3177 | assert.equal(running.type, "provider_turn.updated"); | |
| 3178 | const providerTurnId = | |
| 3179 | running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; | |
| 3180 | assert.isDefined(providerTurnId); | |
| 3181 | yield* fake.emit({ type: "agent_start" }); | |
| 3182 | ||
| 3183 | fake.deferNextState(); | |
| 3184 | yield* fake.emit({ type: "agent_settled" }); | |
| 3185 | yield* fake.takeRequest("get_state"); | |
| 3186 | yield* runtime.steerTurn({ | |
| 3187 | threadId: THREAD_ID, | |
| 3188 | runId: RunId.make("run:thread-pi-test:1"), | |
| 3189 | providerThread, | |
| 3190 | providerTurnId: providerTurnId!, | |
| 3191 | message: { | |
| 3192 | messageId: "message:thread-pi-test:late-steer" as never, | |
| 3193 | text: "Continue after settlement", | |
| 3194 | attachments: [], | |
| 3195 | createdBy: "user", | |
| 3196 | creationSource: "web", | |
| 3197 | }, | |
| 3198 | }); | |
| 3199 | yield* fake.takeRequest("prompt"); | |
| 3200 | yield* fake.resolveDeferredState({ | |
| 3201 | isStreaming: false, | |
| 3202 | isCompacting: false, | |
| 3203 | pendingMessageCount: 0, | |
| 3204 | }); | |
| 3205 | ||
| 3206 | yield* fake.emit({ type: "agent_start" }); | |
| 3207 | yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); | |
| 3208 | yield* fake.emit({ | |
| 3209 | type: "message_update", | |
| 3210 | assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "Recovered" }, | |
| 3211 | }); | |
| 3212 | yield* fake.emit({ | |
| 3213 | type: "message_end", | |
| 3214 | message: { | |
| 3215 | role: "assistant", | |
| 3216 | content: [{ type: "text", text: "Recovered" }], | |
| 3217 | stopReason: "stop", | |
| 3218 | }, | |
| 3219 | }); | |
| 3220 | const assistantItem = yield* takeEvent( | |
| 3221 | (event) => | |
| 3222 | event.type === "turn_item.updated" && | |
| 3223 | event.turnItem.type === "assistant_message" && | |
| 3224 | event.turnItem.streaming === false, | |
| 3225 | ); | |
| 3226 | assert.isTrue( | |
| 3227 | assistantItem.type === "turn_item.updated" && | |
| 3228 | assistantItem.turnItem.type === "assistant_message" && | |
| 3229 | assistantItem.turnItem.text === "Recovered", | |
| 3230 | ); | |
| 3231 | ||
| 3232 | fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); | |
| 3233 | yield* fake.emit({ type: "agent_settled" }); | |
| 3234 | yield* fake.takeRequest("get_state"); | |
| 3235 | const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); | |
| 3236 | assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); | |
| 3237 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3238 | ); | |
| 3239 | }); | |
| 3240 | ||
| 3241 | describe("PiRpc framing", () => { | |
| 3242 | it.effect("reassembles records across chunk boundaries and strips CR", () => | |
| 3243 | Effect.gen(function* () { | |
| 3244 | const stdout = yield* Queue.unbounded<Uint8Array>(); | |
| 3245 | const spawner = ChildProcessSpawner.make(() => | |
| 3246 | Effect.succeed( | |
| 3247 | ChildProcessSpawner.makeHandle({ | |
| 3248 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3249 | exitCode: Effect.never, | |
| 3250 | isRunning: Effect.succeed(true), | |
| 3251 | kill: () => Effect.void, | |
| 3252 | unref: Effect.succeed(Effect.void), | |
| 3253 | stdin: Sink.drain, | |
| 3254 | stdout: Stream.fromQueue(stdout), | |
| 3255 | stderr: Stream.empty, | |
| 3256 | all: Stream.empty, | |
| 3257 | getInputFd: () => Sink.drain, | |
| 3258 | getOutputFd: () => Stream.empty, | |
| 3259 | }), | |
| 3260 | ), | |
| 3261 | ); | |
| 3262 | const connection = yield* makePiRpcConnection({ | |
| 3263 | command: "pi", | |
| 3264 | args: ["--mode", "rpc"], | |
| 3265 | cwd: undefined, | |
| 3266 | env: {}, | |
| 3267 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3268 | ||
| 3269 | const push = (text: string) => | |
| 3270 | Queue.offer(stdout, new TextEncoder().encode(text)).pipe(Effect.asVoid); | |
| 3271 | yield* push('{"type":"agent_'); | |
| 3272 | yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); | |
| 3273 | ||
| 3274 | yield* push("x".repeat(8 * 1024 * 1024)); | |
| 3275 | yield* push('x{"type":"must_not_emit"}\n{"type":"after_oversized"}\n'); | |
| 3276 | ||
| 3277 | const first = yield* Queue.take(connection.events); | |
| 3278 | assert.equal(first["type"], "agent_start"); | |
| 3279 | const second = yield* Queue.take(connection.events); | |
| 3280 | assert.equal(second["type"], "agent_settled"); | |
| 3281 | const third = yield* Queue.take(connection.events); | |
| 3282 | assert.equal(third["type"], "queue_update"); | |
| 3283 | assert.equal((yield* Queue.take(connection.events))["type"], "after_oversized"); | |
| 3284 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3285 | ); | |
| 3286 | }); | |
| 3287 | ||
| 3288 | // This fails before a provider transcript exists, so a replay fixture is not | |
| 3289 | // an honest fit. The boundary is the stdio transport seeing stdout end. | |
| 3290 | describe("PiRpc early process exit", () => { | |
| 3291 | const makeHandle = (options: { | |
| 3292 | readonly exitCode: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 3293 | readonly stderr: Stream.Stream<Uint8Array>; | |
| 3294 | }) => | |
| 3295 | ChildProcessSpawner.makeHandle({ | |
| 3296 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3297 | exitCode: options.exitCode, | |
| 3298 | isRunning: Effect.succeed(true), | |
| 3299 | kill: () => Effect.void, | |
| 3300 | unref: Effect.succeed(Effect.void), | |
| 3301 | stdin: Sink.drain, | |
| 3302 | stdout: Stream.empty, | |
| 3303 | stderr: options.stderr, | |
| 3304 | all: Stream.empty, | |
| 3305 | getInputFd: () => Sink.drain, | |
| 3306 | getOutputFd: () => Stream.empty, | |
| 3307 | }); | |
| 3308 | ||
| 3309 | it.effect("reports a nonzero exit code instead of an unexplained stdout close", () => | |
| 3310 | Effect.gen(function* () { | |
| 3311 | const secret = "API_KEY=super-secret\n"; | |
| 3312 | const spawner = ChildProcessSpawner.make(() => | |
| 3313 | Effect.succeed( | |
| 3314 | makeHandle({ | |
| 3315 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 3316 | stderr: Stream.fromIterable([new TextEncoder().encode(secret)]), | |
| 3317 | }), | |
| 3318 | ), | |
| 3319 | ); | |
| 3320 | const connection = yield* makePiRpcConnection({ | |
| 3321 | command: "pi", | |
| 3322 | args: ["--mode", "rpc"], | |
| 3323 | cwd: undefined, | |
| 3324 | env: {}, | |
| 3325 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3326 | ||
| 3327 | const error = yield* Queue.take(connection.events).pipe(Effect.flip); | |
| 3328 | assert.equal(error._tag, "PiRpcError"); | |
| 3329 | assert.equal(error.operation, "read"); | |
| 3330 | assert.equal(error.detail, "pi process exited with code 1"); | |
| 3331 | assert.isFalse((error.detail ?? "").includes("API_KEY")); | |
| 3332 | assert.isFalse((error.detail ?? "").includes("super-secret")); | |
| 3333 | assert.isFalse(error.message.includes("API_KEY")); | |
| 3334 | assert.isFalse(error.message.includes("super-secret")); | |
| 3335 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3336 | ); | |
| 3337 | ||
| 3338 | it.effect("keeps the unexplained stdout-close message when the process has not exited", () => | |
| 3339 | Effect.gen(function* () { | |
| 3340 | const spawner = ChildProcessSpawner.make(() => | |
| 3341 | Effect.succeed( | |
| 3342 | makeHandle({ | |
| 3343 | exitCode: Effect.never, | |
| 3344 | stderr: Stream.empty, | |
| 3345 | }), | |
| 3346 | ), | |
| 3347 | ); | |
| 3348 | const connection = yield* makePiRpcConnection({ | |
| 3349 | command: "pi", | |
| 3350 | args: ["--mode", "rpc"], | |
| 3351 | cwd: undefined, | |
| 3352 | env: {}, | |
| 3353 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3354 | ||
| 3355 | const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); | |
| 3356 | yield* TestClock.adjust(Duration.millis(300)); | |
| 3357 | const error = yield* Fiber.join(fiber); | |
| 3358 | assert.equal(error._tag, "PiRpcError"); | |
| 3359 | assert.equal(error.operation, "read"); | |
| 3360 | assert.equal(error.detail, "pi process closed stdout"); | |
| 3361 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3362 | ); | |
| 3363 | ||
| 3364 | it.effect("keeps the exit-code diagnosis when stdin breaks while exit is still pending", () => | |
| 3365 | Effect.gen(function* () { | |
| 3366 | const spawner = ChildProcessSpawner.make(() => | |
| 3367 | Effect.succeed( | |
| 3368 | ChildProcessSpawner.makeHandle({ | |
| 3369 | pid: ChildProcessSpawner.ProcessId(FAKE_PID), | |
| 3370 | exitCode: Effect.sleep(Duration.millis(50)).pipe( | |
| 3371 | Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(1))), | |
| 3372 | ), | |
| 3373 | isRunning: Effect.succeed(true), | |
| 3374 | kill: () => Effect.void, | |
| 3375 | unref: Effect.succeed(Effect.void), | |
| 3376 | stdin: Sink.fail( | |
| 3377 | PlatformError.systemError({ | |
| 3378 | _tag: "Unknown", | |
| 3379 | module: "ChildProcess", | |
| 3380 | method: "stdin", | |
| 3381 | description: "broken pipe", | |
| 3382 | }), | |
| 3383 | ), | |
| 3384 | stdout: Stream.empty, | |
| 3385 | stderr: Stream.empty, | |
| 3386 | all: Stream.empty, | |
| 3387 | getInputFd: () => Sink.drain, | |
| 3388 | getOutputFd: () => Stream.empty, | |
| 3389 | }), | |
| 3390 | ), | |
| 3391 | ); | |
| 3392 | const connection = yield* makePiRpcConnection({ | |
| 3393 | command: "pi", | |
| 3394 | args: ["--mode", "rpc"], | |
| 3395 | cwd: undefined, | |
| 3396 | env: {}, | |
| 3397 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 3398 | ||
| 3399 | const fiber = yield* Effect.forkChild(Queue.take(connection.events).pipe(Effect.flip)); | |
| 3400 | yield* TestClock.adjust(Duration.millis(300)); | |
| 3401 | const error = yield* Fiber.join(fiber); | |
| 3402 | assert.equal(error._tag, "PiRpcError"); | |
| 3403 | assert.equal(error.operation, "read"); | |
| 3404 | assert.equal(error.detail, "pi process exited with code 1"); | |
| 3405 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 3406 | ); | |
| 3407 | }); |
packages/provider-pi/src/server/adapter.ts created+3265| ... | ... | @@ -0,0 +1,3265 @@ |
| 1 | /** | |
| 2 | * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent | |
| 3 | * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. | |
| 4 | * | |
| 5 | * Design intent: honor the user's Pi customizations. The process is spawned | |
| 6 | * with no `--no-*` flags, so the user's extensions, skills, prompt templates, | |
| 7 | * AGENTS.md / SYSTEM.md context, settings.json, custom models, and auth all | |
| 8 | * load exactly as they do in the `pi` TUI. Sessions are stored by Pi itself | |
| 9 | * (default `~/.pi/agent/sessions/`), and the session file path is the durable | |
| 10 | * `nativeThreadRef`, so a thread started in T3 can be resumed from the TUI | |
| 11 | * and vice versa. | |
| 12 | * | |
| 13 | * Turn lifecycle: `agent_settled` is the only terminal signal. `agent_end` | |
| 14 | * merely closes one low-level run — compaction retries, auto-retries, and | |
| 15 | * queued continuations may still follow it, so the turn stays open until Pi | |
| 16 | * reports the session settled. An extension can start detached compaction as | |
| 17 | * that signal unwinds, so the adapter confirms Pi is idle before terminalizing. | |
| 18 | * | |
| 19 | * Extension UI: Pi extensions raise dialogs through `extension_ui_request`. | |
| 20 | * Dialog methods become v2 runtime requests (`confirm` → approval_request, | |
| 21 | * `select`/`input`/`editor` → user_input_request); answers travel back as | |
| 22 | * `extension_ui_response`. `notify` becomes a completed activity item. | |
| 23 | * Terminal-only decoration such as status, widget, title, and editor-text | |
| 24 | * updates has no matching T3 surface and is ignored. | |
| 25 | */ | |
| 26 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 27 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 28 | import { | |
| 29 | defaultInstanceIdForDriver, | |
| 30 | ProviderDriverKind, | |
| 31 | type ChatAttachment, | |
| 32 | type ModelSelection, | |
| 33 | type OrchestrationV2ExecutionNode, | |
| 34 | type OrchestrationV2ProviderCapabilities, | |
| 35 | type OrchestrationV2ProviderFailure, | |
| 36 | type OrchestrationV2ProviderRef, | |
| 37 | type OrchestrationV2ProviderRetry, | |
| 38 | type OrchestrationV2ProviderSession, | |
| 39 | type OrchestrationV2ProviderThread, | |
| 40 | type OrchestrationV2ProviderTurn, | |
| 41 | type OrchestrationV2RuntimeRequest, | |
| 42 | type OrchestrationV2TurnItem, | |
| 43 | type OrchestrationV2UserInputQuestion, | |
| 44 | type ProviderApprovalDecision, | |
| 45 | type ProviderInstanceId, | |
| 46 | type OrchestrationV2ProviderTurnTokenUsage, | |
| 47 | } from "@t3tools/contracts"; | |
| 48 | import { PiSettings } from "../settings.ts"; | |
| 49 | import * as Cause from "effect/Cause"; | |
| 50 | import * as DateTime from "effect/DateTime"; | |
| 51 | import * as Deferred from "effect/Deferred"; | |
| 52 | import * as Option from "effect/Option"; | |
| 53 | import * as Duration from "effect/Duration"; | |
| 54 | import * as Effect from "effect/Effect"; | |
| 55 | import * as FileSystem from "effect/FileSystem"; | |
| 56 | import * as Layer from "effect/Layer"; | |
| 57 | import * as Queue from "effect/Queue"; | |
| 58 | import * as Schema from "effect/Schema"; | |
| 59 | import * as Semaphore from "effect/Semaphore"; | |
| 60 | import * as Stream from "effect/Stream"; | |
| 61 | import { ChildProcessSpawner } from "effect/process"; | |
| 62 | ||
| 63 | import { ProviderHost, type ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; | |
| 64 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 65 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 66 | import { | |
| 67 | expandPiSkillReference, | |
| 68 | parsePiCompactCommand, | |
| 69 | parsePiDiscoveredCommands, | |
| 70 | type PiCompactCommand, | |
| 71 | } from "./commands.ts"; | |
| 72 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 73 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 74 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 75 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 76 | import { | |
| 77 | ProviderAdapterDriverCreateError, | |
| 78 | type ProviderAdapterDriver, | |
| 79 | type ProviderAdapterDriverCreateInput, | |
| 80 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 81 | import { | |
| 82 | makeProviderFailure, | |
| 83 | makeProviderRetryTurnItem, | |
| 84 | } from "@t3tools/provider-core/server/failure"; | |
| 85 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 86 | import { | |
| 87 | makePiRpcConnection, | |
| 88 | parsePiModelSlug, | |
| 89 | piRecordField as recordField, | |
| 90 | piRecordNumber as recordNumber, | |
| 91 | piRecordString as recordString, | |
| 92 | type PiRpcConnection, | |
| 93 | type PiRpcRecord, | |
| 94 | } from "./rpc.ts"; | |
| 95 | import { | |
| 96 | buildPiRpcLaunch, | |
| 97 | materializePiT3McpExtension, | |
| 98 | resolvePiLaunchArgs, | |
| 99 | } from "./mcpInjection.ts"; | |
| 100 | import { PI_FILE_CHANGE_TOOLS } from "./mcpExtensionSource.ts"; | |
| 101 | ||
| 102 | export const PI_PROVIDER = ProviderDriverKind.make("pi"); | |
| 103 | const PI_DRIVER_KIND = PI_PROVIDER; | |
| 104 | const PI_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(PI_DRIVER_KIND); | |
| 105 | const DEFAULT_PI_SETTINGS = Schema.decodeSync(PiSettings)({}); | |
| 106 | ||
| 107 | /** | |
| 108 | * Sentinel model slug meaning "do not call set_model": Pi resolves the model | |
| 109 | * from the user's own settings.json (`defaultProvider`/`defaultModel`). | |
| 110 | */ | |
| 111 | const PI_INHERIT_MODEL_SLUG = "default"; | |
| 112 | ||
| 113 | const STREAM_FLUSH_MS = 50; | |
| 114 | const PI_REQUEST_TIMEOUT_MS = 15_000; | |
| 115 | // Session lifecycle hooks reload extensions, MCP servers and language servers. | |
| 116 | const PI_SESSION_TIMEOUT_MS = 60_000; | |
| 117 | const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; | |
| 118 | const PI_UNSOLICITED_ACTIVITY_ERROR = | |
| 119 | "Pi started agent work outside an active T3 turn. The session was stopped to prevent invisible tool execution."; | |
| 120 | const SETTLE_PROBE_MAX_ATTEMPTS = 3; | |
| 121 | const SETTLE_PROBE_RETRY_DELAY = Duration.millis(100); | |
| 122 | ||
| 123 | const PiProviderCapabilitiesV2 = { | |
| 124 | runtimePolicy: { enforcement: "client-boundary" }, | |
| 125 | sessions: { | |
| 126 | supportsMultipleProviderThreadsPerSession: false, | |
| 127 | supportsModelSwitchInSession: true, | |
| 128 | supportsProviderSwitchingViaHandoff: true, | |
| 129 | // Mode changes restart this process so the injected permission hook gets | |
| 130 | // one immutable policy for its whole lifetime. | |
| 131 | supportsRuntimeModeSwitchInSession: false, | |
| 132 | pendingRequestsSurviveRestart: false, | |
| 133 | }, | |
| 134 | threads: { | |
| 135 | canCreateEmptyThread: true, | |
| 136 | canReadThreadSnapshot: true, | |
| 137 | canRollbackThread: true, | |
| 138 | canForkThread: true, | |
| 139 | canForkFromTurn: true, | |
| 140 | canForkFromSubagentThread: false, | |
| 141 | exposesNativeThreadId: true, | |
| 142 | }, | |
| 143 | turns: { | |
| 144 | exposesNativeTurnId: false, | |
| 145 | emitsTurnStarted: true, | |
| 146 | emitsTurnCompleted: true, | |
| 147 | supportsInterrupt: true, | |
| 148 | supportsActiveSteering: true, | |
| 149 | supportsSteeringByInterruptRestart: false, | |
| 150 | supportsQueuedMessages: true, | |
| 151 | terminalStatusQuality: "strong", | |
| 152 | }, | |
| 153 | streaming: { | |
| 154 | streamsAssistantText: true, | |
| 155 | streamsReasoning: true, | |
| 156 | streamsToolOutput: true, | |
| 157 | streamsPlanText: false, | |
| 158 | emitsMessageCompleted: true, | |
| 159 | }, | |
| 160 | tools: { | |
| 161 | exposesToolItemIds: true, | |
| 162 | emitsToolStarted: true, | |
| 163 | emitsToolCompleted: true, | |
| 164 | emitsToolOutput: true, | |
| 165 | supportsMcpTools: true, | |
| 166 | supportsDynamicToolCallbacks: false, | |
| 167 | }, | |
| 168 | approvals: { | |
| 169 | // Pi exposes a blocking tool_call extension hook. The T3 bridge uses it | |
| 170 | // for supervised and auto-accept modes and forwards its confirmations | |
| 171 | // through the same extension UI protocol as user-installed extensions. | |
| 172 | supportsCommandApproval: true, | |
| 173 | supportsFileReadApproval: false, | |
| 174 | supportsFileChangeApproval: true, | |
| 175 | supportsApplyPatchApproval: false, | |
| 176 | approvalsHaveNativeRequestIds: true, | |
| 177 | approvalCallbacksAreLiveOnly: true, | |
| 178 | approvalsCanOriginateFromSubagents: false, | |
| 179 | }, | |
| 180 | planning: { | |
| 181 | emitsPlanUpdated: false, | |
| 182 | emitsTodoList: false, | |
| 183 | emitsProposedPlan: false, | |
| 184 | supportsStructuredQuestions: true, | |
| 185 | planDeltasHaveItemIds: false, | |
| 186 | }, | |
| 187 | subagents: { | |
| 188 | // T3 delegation uses the shared MCP `delegate_task` path. Installed Pi | |
| 189 | // subagent extensions are observed best-effort, but their official tool | |
| 190 | // runs children with --no-session and exposes no resumable child id. | |
| 191 | supportsSubagents: true, | |
| 192 | exposesSubagentThreadIds: false, | |
| 193 | emitsSubagentLifecycle: true, | |
| 194 | canWaitForSubagents: false, | |
| 195 | canCloseSubagents: false, | |
| 196 | canForkSubagentThread: false, | |
| 197 | }, | |
| 198 | context: { | |
| 199 | acceptsSystemContext: false, | |
| 200 | acceptsDeveloperContext: false, | |
| 201 | acceptsSyntheticUserContext: true, | |
| 202 | canGenerateSummaries: false, | |
| 203 | canConsumeHandoffSummaries: true, | |
| 204 | // T3 delivers both full and delta handoffs through Pi's normal user-message | |
| 205 | // input, so neither strategy depends on a Pi-specific context hook. | |
| 206 | supportsDeltaHandoff: true, | |
| 207 | supportsFullThreadHandoff: true, | |
| 208 | maxRecommendedHandoffChars: null, | |
| 209 | }, | |
| 210 | checkpointing: { | |
| 211 | appCanCheckpointFilesystem: true, | |
| 212 | supportsNestedCheckpointScopes: false, | |
| 213 | providerCanRollbackConversation: true, | |
| 214 | // CommandPolicy.ensureRollback requires the snapshot whenever provider | |
| 215 | // rollback is enabled; rollbackThread returns the updated provider thread. | |
| 216 | providerRollbackReturnsSnapshot: true, | |
| 217 | providerCanReadConversationSnapshot: true, | |
| 218 | }, | |
| 219 | identity: { | |
| 220 | nativeThreadIds: "strong", | |
| 221 | nativeTurnIds: "weak", | |
| 222 | nativeItemIds: "strong", | |
| 223 | nativeRequestIds: "strong", | |
| 224 | }, | |
| 225 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 226 | ||
| 227 | export interface PiAdapterV2Options { | |
| 228 | readonly instanceId: ProviderInstanceId; | |
| 229 | readonly settings: PiSettings; | |
| 230 | readonly environment: NodeJS.ProcessEnv; | |
| 231 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 232 | readonly fileSystem: FileSystem.FileSystem; | |
| 233 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; | |
| 234 | readonly host: ProviderHostShape; | |
| 235 | readonly continuationRequests?: { | |
| 236 | readonly offer: ( | |
| 237 | request: ProviderContinuationRequests.ProviderContinuationRequest, | |
| 238 | ) => Effect.Effect<void>; | |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | /** Concatenate the `text` fields of a Pi content-block array. */ | |
| 243 | function contentText(content: unknown): string { | |
| 244 | if (!Array.isArray(content)) { | |
| 245 | return typeof content === "string" ? content : ""; | |
| 246 | } | |
| 247 | return content | |
| 248 | .map((block) => { | |
| 249 | if (recordField(block, "type") === "text") return recordString(block, "text") ?? ""; | |
| 250 | return ""; | |
| 251 | }) | |
| 252 | .join(""); | |
| 253 | } | |
| 254 | ||
| 255 | function providerRef( | |
| 256 | nativeId: string, | |
| 257 | strength: "strong" | "weak" = "strong", | |
| 258 | ): OrchestrationV2ProviderRef { | |
| 259 | return { driver: PI_PROVIDER, nativeId, strength }; | |
| 260 | } | |
| 261 | ||
| 262 | const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); | |
| 263 | ||
| 264 | // ── per-session state ───────────────────────────────────────── | |
| 265 | ||
| 266 | interface PiStreamItemState { | |
| 267 | readonly nativeItemId: string; | |
| 268 | readonly kind: "assistant_message" | "reasoning"; | |
| 269 | text: string; | |
| 270 | completed: boolean; | |
| 271 | flushScheduled: boolean; | |
| 272 | readonly startedAt: DateTime.Utc; | |
| 273 | } | |
| 274 | ||
| 275 | type PiCompactionStatus = "running" | "completed" | "failed" | "cancelled"; | |
| 276 | ||
| 277 | interface PiCompactionState { | |
| 278 | readonly nativeItemId: string; | |
| 279 | readonly startedAt: DateTime.Utc; | |
| 280 | } | |
| 281 | ||
| 282 | interface PiProviderRetryState { | |
| 283 | readonly retry: OrchestrationV2ProviderRetry; | |
| 284 | readonly failure: OrchestrationV2ProviderFailure; | |
| 285 | readonly startedAt: DateTime.Utc; | |
| 286 | readonly itemOrdinal: number; | |
| 287 | } | |
| 288 | ||
| 289 | function compactionTitle(status: PiCompactionStatus): string { | |
| 290 | switch (status) { | |
| 291 | case "running": | |
| 292 | return "Compacting context..."; | |
| 293 | case "completed": | |
| 294 | return "Context compacted"; | |
| 295 | case "failed": | |
| 296 | return "Context compaction failed"; | |
| 297 | case "cancelled": | |
| 298 | return "Context compaction stopped"; | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | interface ActivePiTurn { | |
| 303 | readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; | |
| 304 | readonly providerTurn: OrchestrationV2ProviderTurn; | |
| 305 | readonly startedAt: DateTime.Utc; | |
| 306 | readonly itemOrdinals: Map<string, number>; | |
| 307 | nextItemOrdinal: number; | |
| 308 | /** Increments on assistant `message_start` so content indexes stay unique. */ | |
| 309 | messageOrdinal: number; | |
| 310 | readonly streamItems: Map<string, PiStreamItemState>; | |
| 311 | readonly toolArgs: Map<string, unknown>; | |
| 312 | /** | |
| 313 | * First-seen time per `toolCallId`. Later update/end events reuse it so a | |
| 314 | * tool keeps one start timestamp and reports a real duration. | |
| 315 | */ | |
| 316 | readonly toolStartedAt: Map<string, DateTime.Utc>; | |
| 317 | interrupted: boolean; | |
| 318 | /** | |
| 319 | * Whether any agent run activity was observed. Command-only prompts (pure | |
| 320 | * extension slash commands) never start an agent run and never emit | |
| 321 | * `agent_settled`; their deferred prompt ack plus an idle probe settles | |
| 322 | * the turn instead. | |
| 323 | */ | |
| 324 | sawAgentActivity: boolean; | |
| 325 | /** A user prompt joined provider-native work whose settlement may already be queued. */ | |
| 326 | adoptedWake: boolean; | |
| 327 | /** Only slash-command prompts can complete without starting an agent run. */ | |
| 328 | readonly promptMayBeCommandOnly: boolean; | |
| 329 | /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ | |
| 330 | latestCompactionAfterTokens: number | null; | |
| 331 | /** Last streamed usage total already emitted on the running turn. */ | |
| 332 | lastLiveUsedTokens: number | null; | |
| 333 | /** Invalidates idle snapshots when new work starts after a settle probe. */ | |
| 334 | settleProbeGeneration: number; | |
| 335 | /** An extension may start compaction immediately after Pi emits agent_settled. */ | |
| 336 | settleWhenIdle: boolean; | |
| 337 | sawCompaction: boolean; | |
| 338 | /** RPC compact is in flight; Pi abort does not cancel it. */ | |
| 339 | manualCompactInFlight: boolean; | |
| 340 | activeCompaction: PiCompactionState | null; | |
| 341 | activeProviderRetry: PiProviderRetryState | null; | |
| 342 | failure: ReturnType<typeof makeProviderFailure> | null; | |
| 343 | /** Native retry can recover its work without accepting a rejected joining prompt. */ | |
| 344 | rejectedPromptFailure: ReturnType<typeof makeProviderFailure> | null; | |
| 345 | /** Session-tree refs read just before Stop terminates Pi, when no read is possible later. */ | |
| 346 | stopTreeRefs?: PiTurnTreeRefs | null; | |
| 347 | } | |
| 348 | ||
| 349 | interface PiTurnTreeRefs { | |
| 350 | readonly turnStartEntryId: string | null; | |
| 351 | readonly leafId: string | null; | |
| 352 | } | |
| 353 | ||
| 354 | interface PendingPiPrompt { | |
| 355 | readonly nativeRequestId: string; | |
| 356 | readonly method: "select" | "confirm" | "input" | "editor"; | |
| 357 | readonly questionId: string; | |
| 358 | readonly approvalKey: string; | |
| 359 | runtimeRequest: OrchestrationV2RuntimeRequest; | |
| 360 | readonly node: OrchestrationV2ExecutionNode; | |
| 361 | readonly turnItem: OrchestrationV2TurnItem; | |
| 362 | } | |
| 363 | ||
| 364 | /** | |
| 365 | * The T3 bridge confirms tool calls as `Allow <tool>?`. Edits surface as | |
| 366 | * file-change approvals so clients render them like other providers' edits; | |
| 367 | * every other confirmation, including ones from user extensions, is a command. | |
| 368 | */ | |
| 369 | function piApprovalRequestKind(title: string): "command" | "file-change" { | |
| 370 | const toolName = /^Allow (\S+)\?$/.exec(title)?.[1]; | |
| 371 | return toolName !== undefined && | |
| 372 | (PI_FILE_CHANGE_TOOLS as ReadonlyArray<string>).includes(toolName) | |
| 373 | ? "file-change" | |
| 374 | : "command"; | |
| 375 | } | |
| 376 | ||
| 377 | interface PiThreadState { | |
| 378 | providerThread: OrchestrationV2ProviderThread; | |
| 379 | activeTurn: ActivePiTurn | null; | |
| 380 | } | |
| 381 | ||
| 382 | interface PiWake { | |
| 383 | readonly state: PiThreadState; | |
| 384 | readonly events: Array<PiRpcRecord>; | |
| 385 | } | |
| 386 | ||
| 387 | // ── adapter ─────────────────────────────────────────────────── | |
| 388 | ||
| 389 | export function makePiAdapterV2( | |
| 390 | options: PiAdapterV2Options, | |
| 391 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 392 | const { idAllocator } = options; | |
| 393 | ||
| 394 | const protocolError = (detail: string, payload?: unknown) => | |
| 395 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 396 | driver: PI_PROVIDER, | |
| 397 | detail, | |
| 398 | ...(payload === undefined ? {} : { payload }), | |
| 399 | }); | |
| 400 | ||
| 401 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 402 | instanceId: options.instanceId, | |
| 403 | driver: PI_PROVIDER, | |
| 404 | getCapabilities: () => Effect.succeed(PiProviderCapabilitiesV2), | |
| 405 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 406 | openSession: Effect.fn("PiAdapterV2.openSession")(function* ( | |
| 407 | input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, | |
| 408 | ) { | |
| 409 | const scope = yield* Effect.scope; | |
| 410 | const cwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; | |
| 411 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 412 | const provideCacheFs = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => | |
| 413 | effect.pipe( | |
| 414 | Effect.provideService(FileSystem.FileSystem, options.fileSystem), | |
| 415 | Effect.mapError( | |
| 416 | (cause) => | |
| 417 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 418 | driver: PI_PROVIDER, | |
| 419 | providerSessionId: input.providerSessionId, | |
| 420 | cause, | |
| 421 | }), | |
| 422 | ), | |
| 423 | ); | |
| 424 | // The extension owns both the optional MCP bridge and Pi's permission | |
| 425 | // hook. Materialize it even when this session has no MCP credential so | |
| 426 | // Supervised never silently degrades to unrestricted tool execution. | |
| 427 | const extensionPath = yield* provideCacheFs( | |
| 428 | materializePiT3McpExtension(options.host.paths.providerStatusCacheDir), | |
| 429 | ); | |
| 430 | const resolvedLaunchArgs = resolvePiLaunchArgs(options.settings.launchArgs); | |
| 431 | if (!resolvedLaunchArgs.ok) { | |
| 432 | return yield* protocolError(resolvedLaunchArgs.message); | |
| 433 | } | |
| 434 | const launch = buildPiRpcLaunch({ | |
| 435 | launchArgs: resolvedLaunchArgs.args, | |
| 436 | environment: options.environment, | |
| 437 | mcpSession, | |
| 438 | extensionPath, | |
| 439 | runtimeMode: input.runtimePolicy.runtimeMode, | |
| 440 | }); | |
| 441 | const connection: PiRpcConnection = yield* makePiRpcConnection({ | |
| 442 | command: options.settings.binaryPath || "pi", | |
| 443 | args: launch.args, | |
| 444 | cwd, | |
| 445 | env: launch.env, | |
| 446 | }).pipe( | |
| 447 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), | |
| 448 | Effect.mapError( | |
| 449 | (cause) => | |
| 450 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 451 | driver: PI_PROVIDER, | |
| 452 | providerSessionId: input.providerSessionId, | |
| 453 | cause, | |
| 454 | }), | |
| 455 | ), | |
| 456 | ); | |
| 457 | const discoverSkillNames = connection | |
| 458 | .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) | |
| 459 | .pipe( | |
| 460 | Effect.map( | |
| 461 | (data) => new Set(parsePiDiscoveredCommands(data).skills.map((skill) => skill.name)), | |
| 462 | ), | |
| 463 | ); | |
| 464 | let skillNames: Set<string> | null = null; | |
| 465 | ||
| 466 | const now = yield* DateTime.now; | |
| 467 | let sessionEntity: OrchestrationV2ProviderSession = { | |
| 468 | id: input.providerSessionId, | |
| 469 | driver: PI_PROVIDER, | |
| 470 | providerInstanceId: options.instanceId, | |
| 471 | status: "ready", | |
| 472 | cwd, | |
| 473 | model: input.modelSelection.model, | |
| 474 | capabilities: PiProviderCapabilitiesV2, | |
| 475 | createdAt: now, | |
| 476 | updatedAt: now, | |
| 477 | lastError: null, | |
| 478 | }; | |
| 479 | const events = yield* Queue.unbounded< | |
| 480 | ProviderAdapter.ProviderAdapterV2Event, | |
| 481 | ProviderAdapter.ProviderAdapterV2Error | Cause.Done | |
| 482 | >(); | |
| 483 | const pendingPrompts = new Map<string, PendingPiPrompt>(); | |
| 484 | const sessionApprovals = new Set<string>(); | |
| 485 | // Answering a dialog and terminalizing a turn both publish lifecycle | |
| 486 | // events. Pi can settle immediately after `extension_ui_response`, so | |
| 487 | // serialize the two paths to stop `turn.terminal` from overtaking the | |
| 488 | // dialog's own resolution updates. | |
| 489 | const sessionEventPermit = yield* Semaphore.make(1); | |
| 490 | let threadState: PiThreadState | null = null; | |
| 491 | let registrationAttempted = false; | |
| 492 | let lastNativeThreadId: string | null = null; | |
| 493 | // User Stop intentionally tears down this RPC process after aborting. | |
| 494 | // Keep that intent beyond turn finalization so the later stdout close is | |
| 495 | // not mistaken for an unexpected transport failure. | |
| 496 | let stopRequested = false; | |
| 497 | // Native work is held until an orchestrator-owned continuation or user | |
| 498 | // turn attaches. The guard remains for sessions with no routable owner. | |
| 499 | let unsolicitedActivityDetected = false; | |
| 500 | let pendingWake: PiWake | null = null; | |
| 501 | let rollbackBarrier: Deferred.Deferred< | |
| 502 | void, | |
| 503 | ProviderAdapter.ProviderAdapterProtocolError | |
| 504 | > | null = null; | |
| 505 | let closed = false; | |
| 506 | yield* Effect.addFinalizer(() => | |
| 507 | Effect.gen(function* () { | |
| 508 | closed = true; | |
| 509 | pendingWake = null; | |
| 510 | if (rollbackBarrier !== null) | |
| 511 | yield* Deferred.fail( | |
| 512 | rollbackBarrier, | |
| 513 | protocolError("Pi session closed during rollback"), | |
| 514 | ); | |
| 515 | }), | |
| 516 | ); | |
| 517 | let appliedModel: string | null = null; | |
| 518 | let appliedThinking: string | null = null; | |
| 519 | /** Last thread title synced into pi's session name (`/resume` listing). */ | |
| 520 | let appliedSessionName: string | null = null; | |
| 521 | /** Extension failures raised during startup are attached to the next turn. */ | |
| 522 | const outOfTurnExtensionErrors: Array<PiRpcRecord> = []; | |
| 523 | /** | |
| 524 | * Leaf entry id of the pi session tree as of the last turn boundary. | |
| 525 | * Turn-start user entries are located relative to it, giving each | |
| 526 | * provider turn a durable native ref for session-tree rollback. | |
| 527 | */ | |
| 528 | let lastKnownLeaf: string | null = null; | |
| 529 | /** | |
| 530 | * Set when a `get_entries` capture failed. Pi may have advanced past | |
| 531 | * `lastKnownLeaf` since, so the cursor no longer bounds a single turn | |
| 532 | * and the next capture re-syncs it instead of trusting it. | |
| 533 | */ | |
| 534 | let leafCursorStale = false; | |
| 535 | // Pi's own configured defaults, captured from the first `get_state` so | |
| 536 | // that selecting the displayed "Pi default" again can restore them. Pi | |
| 537 | // has no "unset" commands, so the baselines have to be replayed | |
| 538 | // explicitly. | |
| 539 | let baselineModel: { provider: string; modelId: string } | null = null; | |
| 540 | let baselineThinking: string | null = null; | |
| 541 | /** Context window of the model Pi currently runs, from get_state and set_model. */ | |
| 542 | let contextWindow: number | null = null; | |
| 543 | const modelContextWindows = new Map<string, number>(); | |
| 544 | let modelsDiscovered = false; | |
| 545 | // Prompt responses carry no id. Keep their session-wide send order and | |
| 546 | // owner so a late ack from a settled turn cannot affect the next turn. | |
| 547 | const pendingPromptResponses: Array<{ | |
| 548 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 549 | readonly kind: "turn_start" | "steer"; | |
| 550 | }> = []; | |
| 551 | const pendingCompactResponses: Array<{ | |
| 552 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 553 | readonly kind: "turn_start" | "steer"; | |
| 554 | }> = []; | |
| 555 | ||
| 556 | const compactRpcRecord = (command: PiCompactCommand): PiRpcRecord => | |
| 557 | command.customInstructions === undefined | |
| 558 | ? { type: "compact" } | |
| 559 | : { type: "compact", customInstructions: command.customInstructions }; | |
| 560 | ||
| 561 | const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 562 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 563 | ||
| 564 | const updateProviderSession = ( | |
| 565 | status: OrchestrationV2ProviderSession["status"], | |
| 566 | lastError: string | null = sessionEntity.lastError, | |
| 567 | ) => | |
| 568 | Effect.gen(function* () { | |
| 569 | const updatedAt = yield* DateTime.now; | |
| 570 | sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; | |
| 571 | yield* emit({ | |
| 572 | type: "provider_session.updated", | |
| 573 | driver: PI_PROVIDER, | |
| 574 | providerSession: sessionEntity, | |
| 575 | }); | |
| 576 | }); | |
| 577 | ||
| 578 | const updateProviderThread = ( | |
| 579 | state: PiThreadState, | |
| 580 | patch: Partial<OrchestrationV2ProviderThread>, | |
| 581 | ) => | |
| 582 | Effect.gen(function* () { | |
| 583 | const updatedAt = yield* DateTime.now; | |
| 584 | state.providerThread = { ...state.providerThread, ...patch, updatedAt }; | |
| 585 | yield* emit({ | |
| 586 | type: "provider_thread.updated", | |
| 587 | driver: PI_PROVIDER, | |
| 588 | providerThread: state.providerThread, | |
| 589 | }); | |
| 590 | }); | |
| 591 | ||
| 592 | const itemOrdinal = (turn: ActivePiTurn, nativeItemId: string): number => { | |
| 593 | const existing = turn.itemOrdinals.get(nativeItemId); | |
| 594 | if (existing !== undefined) return existing; | |
| 595 | const ordinal = turn.nextItemOrdinal++; | |
| 596 | turn.itemOrdinals.set(nativeItemId, ordinal); | |
| 597 | return ordinal; | |
| 598 | }; | |
| 599 | ||
| 600 | const request = (record: PiRpcRecord, timeoutMs = PI_REQUEST_TIMEOUT_MS) => | |
| 601 | connection.request(record, timeoutMs); | |
| 602 | ||
| 603 | const nonNegativeInteger = (input: unknown, key: string): number | undefined => { | |
| 604 | const value = recordNumber(input, key); | |
| 605 | return value === undefined ? undefined : Math.max(0, Math.trunc(value)); | |
| 606 | }; | |
| 607 | ||
| 608 | const rememberModelContextWindow = (model: unknown): number | null => { | |
| 609 | const provider = recordString(model, "provider"); | |
| 610 | const id = recordString(model, "id"); | |
| 611 | const capacity = nonNegativeInteger(model, "contextWindow"); | |
| 612 | if (provider !== undefined && id !== undefined && capacity !== undefined && capacity > 0) { | |
| 613 | modelContextWindows.set(`${provider}/${id}`, capacity); | |
| 614 | return capacity; | |
| 615 | } | |
| 616 | return null; | |
| 617 | }; | |
| 618 | ||
| 619 | const lifecycleRequest = (record: PiRpcRecord) => | |
| 620 | request(record, PI_SESSION_TIMEOUT_MS).pipe( | |
| 621 | // A local timeout does not cancel Pi's lifecycle hook. Retire the | |
| 622 | // process before fallback can race its eventual switch/new-session. | |
| 623 | Effect.tapError((error) => | |
| 624 | Effect.logWarning("Pi session lifecycle request failed", { | |
| 625 | providerSessionId: input.providerSessionId, | |
| 626 | operation: record["type"], | |
| 627 | errorTag: error._tag, | |
| 628 | }), | |
| 629 | ), | |
| 630 | Effect.catchTags({ | |
| 631 | PiRpcTimeoutError: (error) => | |
| 632 | connection.terminate.pipe(Effect.andThen(Effect.fail(error))), | |
| 633 | }), | |
| 634 | Effect.onInterrupt(() => connection.terminate), | |
| 635 | ); | |
| 636 | ||
| 637 | const tokenUsageFromStats = ( | |
| 638 | stats: unknown, | |
| 639 | fallbackUsedTokens: number | null, | |
| 640 | updatedAt: DateTime.Utc, | |
| 641 | ): OrchestrationV2ProviderTurnTokenUsage | undefined => { | |
| 642 | const contextUsage = recordField(stats, "contextUsage"); | |
| 643 | const maxTokens = nonNegativeInteger(contextUsage, "contextWindow"); | |
| 644 | const usedTokens = | |
| 645 | nonNegativeInteger(contextUsage, "tokens") ?? fallbackUsedTokens ?? undefined; | |
| 646 | if (usedTokens === undefined || maxTokens === undefined || maxTokens === 0) | |
| 647 | return undefined; | |
| 648 | ||
| 649 | const totals = recordField(stats, "tokens"); | |
| 650 | const inputTokens = nonNegativeInteger(totals, "input"); | |
| 651 | const cachedInputTokens = nonNegativeInteger(totals, "cacheRead"); | |
| 652 | const outputTokens = nonNegativeInteger(totals, "output"); | |
| 653 | return { | |
| 654 | usedTokens, | |
| 655 | maxTokens, | |
| 656 | ...(inputTokens === undefined ? {} : { inputTokens }), | |
| 657 | ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), | |
| 658 | ...(outputTokens === undefined ? {} : { outputTokens }), | |
| 659 | updatedAt: DateTime.formatIso(updatedAt), | |
| 660 | }; | |
| 661 | }; | |
| 662 | ||
| 663 | /** | |
| 664 | * Pi only reports context usage through `get_session_stats`, so the | |
| 665 | * settled turn carries it on the base's per-turn `tokenUsage` (#8144). | |
| 666 | * Usage is secondary telemetry: the request is bounded and a provider | |
| 667 | * version without stats simply leaves the turn without a report, which | |
| 668 | * keeps the meter on the last turn that had one. | |
| 669 | */ | |
| 670 | const readTokenUsage = (fallbackUsedTokens: number | null, updatedAt: DateTime.Utc) => | |
| 671 | request({ type: "get_session_stats" }, 2_000).pipe( | |
| 672 | Effect.map((stats) => tokenUsageFromStats(stats, fallbackUsedTokens, updatedAt)), | |
| 673 | Effect.orElseSucceed(() => undefined), | |
| 674 | ); | |
| 675 | ||
| 676 | /** | |
| 677 | * Pi attaches the current message's cumulative usage to every streaming | |
| 678 | * update (0.84.2+). Emit it on the running turn only when the total | |
| 679 | * changes, so the meter moves live without a burst of no-op updates. | |
| 680 | */ | |
| 681 | const reportLiveUsage = (turn: ActivePiTurn, usage: unknown) => | |
| 682 | Effect.gen(function* () { | |
| 683 | const usedTokens = nonNegativeInteger(usage, "totalTokens"); | |
| 684 | if ( | |
| 685 | usedTokens === undefined || | |
| 686 | usedTokens === 0 || | |
| 687 | usedTokens === turn.lastLiveUsedTokens || | |
| 688 | contextWindow === null || | |
| 689 | contextWindow === 0 | |
| 690 | ) { | |
| 691 | return; | |
| 692 | } | |
| 693 | turn.lastLiveUsedTokens = usedTokens; | |
| 694 | const inputTokens = nonNegativeInteger(usage, "input"); | |
| 695 | const cachedInputTokens = nonNegativeInteger(usage, "cacheRead"); | |
| 696 | const outputTokens = nonNegativeInteger(usage, "output"); | |
| 697 | const updatedAt = yield* DateTime.now; | |
| 698 | yield* emit({ | |
| 699 | type: "provider_turn.updated", | |
| 700 | driver: PI_PROVIDER, | |
| 701 | threadId: turn.turnInput.threadId, | |
| 702 | providerTurn: { | |
| 703 | ...turn.providerTurn, | |
| 704 | tokenUsage: { | |
| 705 | usedTokens, | |
| 706 | maxTokens: contextWindow, | |
| 707 | ...(inputTokens === undefined ? {} : { inputTokens }), | |
| 708 | ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), | |
| 709 | ...(outputTokens === undefined ? {} : { outputTokens }), | |
| 710 | updatedAt: DateTime.formatIso(updatedAt), | |
| 711 | }, | |
| 712 | }, | |
| 713 | }); | |
| 714 | }); | |
| 715 | ||
| 716 | const baseItemFields = ( | |
| 717 | turn: ActivePiTurn, | |
| 718 | nativeItemId: string, | |
| 719 | startedAt: DateTime.Utc, | |
| 720 | updatedAt: DateTime.Utc, | |
| 721 | ) => ({ | |
| 722 | id: idAllocator.derive.turnItemFromProviderItem({ | |
| 723 | driver: PI_PROVIDER, | |
| 724 | nativeItemId, | |
| 725 | }), | |
| 726 | threadId: turn.turnInput.threadId, | |
| 727 | runId: turn.turnInput.runId, | |
| 728 | nodeId: idAllocator.derive.nodeFromProviderItem({ | |
| 729 | driver: PI_PROVIDER, | |
| 730 | nativeItemId, | |
| 731 | }), | |
| 732 | providerThreadId: turn.turnInput.providerThread.id, | |
| 733 | providerTurnId: turn.providerTurn.id, | |
| 734 | nativeItemRef: providerRef(nativeItemId), | |
| 735 | parentItemId: null, | |
| 736 | ordinal: itemOrdinal(turn, nativeItemId), | |
| 737 | startedAt, | |
| 738 | updatedAt, | |
| 739 | }); | |
| 740 | ||
| 741 | const emitItemNode = ( | |
| 742 | turn: ActivePiTurn, | |
| 743 | nativeItemId: string, | |
| 744 | kind: OrchestrationV2ExecutionNode["kind"], | |
| 745 | status: OrchestrationV2ExecutionNode["status"], | |
| 746 | startedAt: DateTime.Utc, | |
| 747 | completedAt: DateTime.Utc | null, | |
| 748 | ) => | |
| 749 | emit({ | |
| 750 | type: "node.updated", | |
| 751 | driver: PI_PROVIDER, | |
| 752 | node: { | |
| 753 | id: idAllocator.derive.nodeFromProviderItem({ | |
| 754 | driver: PI_PROVIDER, | |
| 755 | nativeItemId, | |
| 756 | }), | |
| 757 | threadId: turn.turnInput.threadId, | |
| 758 | runId: turn.turnInput.runId, | |
| 759 | parentNodeId: turn.turnInput.rootNodeId, | |
| 760 | rootNodeId: turn.turnInput.rootNodeId, | |
| 761 | kind, | |
| 762 | status, | |
| 763 | countsForRun: false, | |
| 764 | providerThreadId: turn.turnInput.providerThread.id, | |
| 765 | providerTurnId: turn.providerTurn.id, | |
| 766 | nativeItemRef: providerRef(nativeItemId), | |
| 767 | runtimeRequestId: null, | |
| 768 | checkpointScopeId: null, | |
| 769 | startedAt, | |
| 770 | completedAt, | |
| 771 | }, | |
| 772 | }); | |
| 773 | ||
| 774 | const emitProviderRetry = Effect.fnUntraced(function* ( | |
| 775 | turn: ActivePiTurn, | |
| 776 | providerRetry: PiProviderRetryState, | |
| 777 | status: "running" | "completed" | "failed" | "interrupted" | "cancelled", | |
| 778 | updatedAt: DateTime.Utc, | |
| 779 | ) { | |
| 780 | yield* emit({ | |
| 781 | type: "turn_item.updated", | |
| 782 | driver: PI_PROVIDER, | |
| 783 | turnItem: makeProviderRetryTurnItem({ | |
| 784 | idAllocator, | |
| 785 | driver: PI_PROVIDER, | |
| 786 | threadId: turn.turnInput.threadId, | |
| 787 | runId: turn.turnInput.runId, | |
| 788 | nodeId: turn.turnInput.rootNodeId, | |
| 789 | providerThreadId: turn.turnInput.providerThread.id, | |
| 790 | providerTurnId: turn.providerTurn.id, | |
| 791 | itemOrdinal: providerRetry.itemOrdinal, | |
| 792 | failure: providerRetry.failure, | |
| 793 | retry: providerRetry.retry, | |
| 794 | status, | |
| 795 | startedAt: providerRetry.startedAt, | |
| 796 | updatedAt, | |
| 797 | }), | |
| 798 | }); | |
| 799 | }); | |
| 800 | ||
| 801 | const compactionNativeItemId = (turn: ActivePiTurn): string => | |
| 802 | `compaction:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; | |
| 803 | ||
| 804 | const emitCompaction = Effect.fnUntraced(function* ( | |
| 805 | turn: ActivePiTurn, | |
| 806 | compaction: PiCompactionState, | |
| 807 | status: PiCompactionStatus, | |
| 808 | details: { | |
| 809 | readonly summary?: string; | |
| 810 | readonly beforeTokenCount?: number; | |
| 811 | readonly afterTokenCount?: number; | |
| 812 | } = {}, | |
| 813 | ) { | |
| 814 | const emittedAt = yield* DateTime.now; | |
| 815 | const completedAt = status === "running" ? null : emittedAt; | |
| 816 | yield* emitItemNode( | |
| 817 | turn, | |
| 818 | compaction.nativeItemId, | |
| 819 | "system", | |
| 820 | status, | |
| 821 | compaction.startedAt, | |
| 822 | completedAt, | |
| 823 | ); | |
| 824 | yield* emit({ | |
| 825 | type: "turn_item.updated", | |
| 826 | driver: PI_PROVIDER, | |
| 827 | turnItem: { | |
| 828 | ...baseItemFields(turn, compaction.nativeItemId, compaction.startedAt, emittedAt), | |
| 829 | status, | |
| 830 | title: compactionTitle(status), | |
| 831 | completedAt, | |
| 832 | type: "compaction", | |
| 833 | driver: PI_PROVIDER, | |
| 834 | ...details, | |
| 835 | }, | |
| 836 | }); | |
| 837 | }); | |
| 838 | ||
| 839 | // ── streaming text / reasoning ──────────────────────── | |
| 840 | ||
| 841 | const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => | |
| 842 | Effect.gen(function* () { | |
| 843 | const emittedAt = yield* DateTime.now; | |
| 844 | const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); | |
| 845 | yield* emitItemNode( | |
| 846 | turn, | |
| 847 | item.nativeItemId, | |
| 848 | item.kind, | |
| 849 | streaming ? "running" : "completed", | |
| 850 | item.startedAt, | |
| 851 | streaming ? null : emittedAt, | |
| 852 | ); | |
| 853 | if (item.kind === "assistant_message") { | |
| 854 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 855 | driver: PI_PROVIDER, | |
| 856 | nativeItemId: item.nativeItemId, | |
| 857 | }); | |
| 858 | yield* emit({ | |
| 859 | type: "turn_item.updated", | |
| 860 | driver: PI_PROVIDER, | |
| 861 | turnItem: { | |
| 862 | ...base, | |
| 863 | status: streaming ? "running" : "completed", | |
| 864 | title: null, | |
| 865 | completedAt: streaming ? null : emittedAt, | |
| 866 | type: "assistant_message", | |
| 867 | messageId, | |
| 868 | text: item.text, | |
| 869 | streaming, | |
| 870 | }, | |
| 871 | }); | |
| 872 | yield* emit({ | |
| 873 | type: "message.updated", | |
| 874 | driver: PI_PROVIDER, | |
| 875 | message: { | |
| 876 | id: messageId, | |
| 877 | threadId: turn.turnInput.threadId, | |
| 878 | runId: turn.turnInput.runId, | |
| 879 | nodeId: idAllocator.derive.nodeFromProviderItem({ | |
| 880 | driver: PI_PROVIDER, | |
| 881 | nativeItemId: item.nativeItemId, | |
| 882 | }), | |
| 883 | role: "assistant", | |
| 884 | text: item.text, | |
| 885 | attachments: [], | |
| 886 | streaming, | |
| 887 | createdBy: "agent", | |
| 888 | creationSource: "provider", | |
| 889 | createdAt: item.startedAt, | |
| 890 | updatedAt: emittedAt, | |
| 891 | }, | |
| 892 | }); | |
| 893 | return; | |
| 894 | } | |
| 895 | yield* emit({ | |
| 896 | type: "turn_item.updated", | |
| 897 | driver: PI_PROVIDER, | |
| 898 | turnItem: { | |
| 899 | ...base, | |
| 900 | status: streaming ? "running" : "completed", | |
| 901 | title: null, | |
| 902 | completedAt: streaming ? null : emittedAt, | |
| 903 | type: "reasoning", | |
| 904 | text: item.text, | |
| 905 | streaming, | |
| 906 | }, | |
| 907 | }); | |
| 908 | }); | |
| 909 | ||
| 910 | const scheduleStreamFlush = (turn: ActivePiTurn, item: PiStreamItemState) => | |
| 911 | Effect.gen(function* () { | |
| 912 | if (item.flushScheduled || item.completed) return; | |
| 913 | item.flushScheduled = true; | |
| 914 | yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( | |
| 915 | Effect.andThen( | |
| 916 | Effect.suspend(() => { | |
| 917 | item.flushScheduled = false; | |
| 918 | return item.completed ? Effect.void : emitStreamItem(turn, item, true); | |
| 919 | }), | |
| 920 | ), | |
| 921 | Effect.forkIn(scope), | |
| 922 | ); | |
| 923 | }); | |
| 924 | ||
| 925 | const streamItemFor = Effect.fnUntraced(function* ( | |
| 926 | turn: ActivePiTurn, | |
| 927 | kind: PiStreamItemState["kind"], | |
| 928 | contentIndex: number, | |
| 929 | ) { | |
| 930 | const nativeItemId = `${turn.providerTurn.id}:m${turn.messageOrdinal}:c${contentIndex}`; | |
| 931 | const existing = turn.streamItems.get(nativeItemId); | |
| 932 | if (existing !== undefined) return existing; | |
| 933 | const startedAt = yield* DateTime.now; | |
| 934 | const item: PiStreamItemState = { | |
| 935 | nativeItemId, | |
| 936 | kind, | |
| 937 | text: "", | |
| 938 | completed: false, | |
| 939 | flushScheduled: false, | |
| 940 | startedAt, | |
| 941 | }; | |
| 942 | turn.streamItems.set(nativeItemId, item); | |
| 943 | // Ordinal reserved on first delta so items appear in stream order. | |
| 944 | itemOrdinal(turn, nativeItemId); | |
| 945 | return item; | |
| 946 | }); | |
| 947 | ||
| 948 | const completeStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, text?: string) => | |
| 949 | Effect.suspend(() => { | |
| 950 | if (item.completed) return Effect.void; | |
| 951 | item.completed = true; | |
| 952 | if (text !== undefined && text.length > 0) item.text = text; | |
| 953 | return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); | |
| 954 | }); | |
| 955 | ||
| 956 | const completeOpenStreamItems = (turn: ActivePiTurn) => | |
| 957 | Effect.forEach( | |
| 958 | Array.from(turn.streamItems.values()).filter((item) => !item.completed), | |
| 959 | (item) => completeStreamItem(turn, item), | |
| 960 | { discard: true }, | |
| 961 | ); | |
| 962 | ||
| 963 | // ── tools ───────────────────────────────────────────── | |
| 964 | ||
| 965 | const emitToolItem = Effect.fnUntraced(function* ( | |
| 966 | turn: ActivePiTurn, | |
| 967 | event: PiRpcRecord, | |
| 968 | phase: "start" | "update" | "end", | |
| 969 | ) { | |
| 970 | const toolCallId = recordString(event, "toolCallId"); | |
| 971 | const toolName = recordString(event, "toolName") ?? "tool"; | |
| 972 | if (toolCallId === undefined) return; | |
| 973 | if (phase === "start") { | |
| 974 | turn.toolArgs.set(toolCallId, event["args"]); | |
| 975 | } | |
| 976 | const args = event["args"] ?? turn.toolArgs.get(toolCallId); | |
| 977 | const emittedAt = yield* DateTime.now; | |
| 978 | const startedAt = turn.toolStartedAt.get(toolCallId) ?? emittedAt; | |
| 979 | turn.toolStartedAt.set(toolCallId, startedAt); | |
| 980 | const completed = phase === "end"; | |
| 981 | const isError = event["isError"] === true; | |
| 982 | const resultRecord = completed ? event["result"] : event["partialResult"]; | |
| 983 | const outputText = contentText(recordField(resultRecord, "content")); | |
| 984 | // A Stop aborts in-flight tools, and pi reports those as error ends. | |
| 985 | // Present them as interrupted (matching the run) rather than failed. | |
| 986 | const status = completed | |
| 987 | ? isError | |
| 988 | ? turn.interrupted | |
| 989 | ? "interrupted" | |
| 990 | : "failed" | |
| 991 | : "completed" | |
| 992 | : "running"; | |
| 993 | const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); | |
| 994 | yield* emitItemNode( | |
| 995 | turn, | |
| 996 | toolCallId, | |
| 997 | "tool_call", | |
| 998 | status, | |
| 999 | startedAt, | |
| 1000 | completed ? emittedAt : null, | |
| 1001 | ); | |
| 1002 | const shared = { | |
| 1003 | ...base, | |
| 1004 | status, | |
| 1005 | completedAt: completed ? emittedAt : null, | |
| 1006 | } as const; | |
| 1007 | if (toolName === "bash") { | |
| 1008 | const exitCode = recordNumber(recordField(resultRecord, "details"), "exitCode"); | |
| 1009 | yield* emit({ | |
| 1010 | type: "turn_item.updated", | |
| 1011 | driver: PI_PROVIDER, | |
| 1012 | turnItem: { | |
| 1013 | ...shared, | |
| 1014 | title: toolName, | |
| 1015 | type: "command_execution", | |
| 1016 | input: recordString(args, "command") ?? "", | |
| 1017 | ...(outputText.length > 0 ? { output: outputText } : {}), | |
| 1018 | ...(exitCode === undefined ? {} : { exitCode }), | |
| 1019 | }, | |
| 1020 | }); | |
| 1021 | return; | |
| 1022 | } | |
| 1023 | if (toolName === "edit" || toolName === "write") { | |
| 1024 | const fileName = recordString(args, "path") ?? recordString(args, "file_path"); | |
| 1025 | if (fileName !== undefined) { | |
| 1026 | // edit reports a unified patch in its result details; write only | |
| 1027 | // carries the new content in its args. A failed call keeps its error. | |
| 1028 | const diffStr = | |
| 1029 | recordString(recordField(resultRecord, "details"), "patch") ?? | |
| 1030 | (isError && outputText.trim().length > 0 ? outputText : undefined); | |
| 1031 | const newStr = toolName === "write" ? recordString(args, "content") : undefined; | |
| 1032 | yield* emit({ | |
| 1033 | type: "turn_item.updated", | |
| 1034 | driver: PI_PROVIDER, | |
| 1035 | turnItem: { | |
| 1036 | ...shared, | |
| 1037 | title: toolName, | |
| 1038 | type: "file_change", | |
| 1039 | fileName, | |
| 1040 | ...(diffStr === undefined ? {} : { diffStr }), | |
| 1041 | ...(newStr === undefined ? {} : { newStr }), | |
| 1042 | }, | |
| 1043 | }); | |
| 1044 | return; | |
| 1045 | } | |
| 1046 | } | |
| 1047 | yield* emit({ | |
| 1048 | type: "turn_item.updated", | |
| 1049 | driver: PI_PROVIDER, | |
| 1050 | turnItem: { | |
| 1051 | ...shared, | |
| 1052 | title: toolName, | |
| 1053 | type: "dynamic_tool", | |
| 1054 | ...mcpToolPresentation({ toolName }), | |
| 1055 | toolName, | |
| 1056 | input: args ?? {}, | |
| 1057 | ...(outputText.length > 0 ? { output: outputText } : {}), | |
| 1058 | }, | |
| 1059 | }); | |
| 1060 | if (toolName === "subagent") { | |
| 1061 | yield* emitSubagentTasks(turn, toolCallId, resultRecord, completed); | |
| 1062 | } | |
| 1063 | }); | |
| 1064 | ||
| 1065 | /** | |
| 1066 | * Observe the result shape from Pi's official example subagent extension. | |
| 1067 | * The extension runs children with --no-session, so these entries are | |
| 1068 | * visible in T3's shared subagent UI without inventing a child thread. | |
| 1069 | * Unknown or changed result shapes stay ordinary dynamic tool output. | |
| 1070 | */ | |
| 1071 | const emitSubagentTasks = Effect.fnUntraced(function* ( | |
| 1072 | turn: ActivePiTurn, | |
| 1073 | toolCallId: string, | |
| 1074 | resultRecord: unknown, | |
| 1075 | completed: boolean, | |
| 1076 | ) { | |
| 1077 | const results = recordField(recordField(resultRecord, "details"), "results"); | |
| 1078 | if (!Array.isArray(results)) return; | |
| 1079 | const emittedAt = yield* DateTime.now; | |
| 1080 | const parentNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1081 | driver: PI_PROVIDER, | |
| 1082 | nativeItemId: toolCallId, | |
| 1083 | }); | |
| 1084 | for (const [index, result] of results.entries()) { | |
| 1085 | const agent = recordString(result, "agent"); | |
| 1086 | const task = recordString(result, "task"); | |
| 1087 | if (agent === undefined || task === undefined) continue; | |
| 1088 | const nativeTaskId = `${toolCallId}:subagent:${recordNumber(result, "step") ?? index}`; | |
| 1089 | const subagentId = idAllocator.derive.nodeFromProviderItem({ | |
| 1090 | driver: PI_PROVIDER, | |
| 1091 | nativeItemId: nativeTaskId, | |
| 1092 | }); | |
| 1093 | const startedAt = turn.toolStartedAt.get(nativeTaskId) ?? emittedAt; | |
| 1094 | turn.toolStartedAt.set(nativeTaskId, startedAt); | |
| 1095 | const finished = completed || recordField(result, "finished") === true; | |
| 1096 | const stopReason = recordString(result, "stopReason"); | |
| 1097 | const interrupted = finished && stopReason === "aborted"; | |
| 1098 | const failed = | |
| 1099 | finished && | |
| 1100 | !interrupted && | |
| 1101 | ((recordNumber(result, "exitCode") ?? 0) !== 0 || stopReason === "error"); | |
| 1102 | const status = interrupted | |
| 1103 | ? "interrupted" | |
| 1104 | : failed | |
| 1105 | ? "failed" | |
| 1106 | : finished | |
| 1107 | ? "completed" | |
| 1108 | : "running"; | |
| 1109 | const outputText = piSubagentOutput(result); | |
| 1110 | const progress = | |
| 1111 | !finished && outputText.length > 0 ? { progress: outputText.slice(0, 200) } : {}; | |
| 1112 | const resultText = finished && outputText.length > 0 ? outputText.slice(0, 10_000) : null; | |
| 1113 | yield* emit({ | |
| 1114 | type: "subagent.updated", | |
| 1115 | driver: PI_PROVIDER, | |
| 1116 | subagent: { | |
| 1117 | id: subagentId, | |
| 1118 | threadId: turn.turnInput.threadId, | |
| 1119 | runId: turn.turnInput.runId, | |
| 1120 | parentNodeId, | |
| 1121 | origin: "provider_native", | |
| 1122 | createdBy: "agent", | |
| 1123 | driver: PI_PROVIDER, | |
| 1124 | providerInstanceId: options.instanceId, | |
| 1125 | providerThreadId: turn.turnInput.providerThread.id, | |
| 1126 | childThreadId: null, | |
| 1127 | nativeTaskRef: providerRef(nativeTaskId), | |
| 1128 | prompt: task, | |
| 1129 | title: agent, | |
| 1130 | model: recordString(result, "model") ?? null, | |
| 1131 | status, | |
| 1132 | ...progress, | |
| 1133 | result: resultText, | |
| 1134 | startedAt, | |
| 1135 | completedAt: finished ? emittedAt : null, | |
| 1136 | updatedAt: emittedAt, | |
| 1137 | }, | |
| 1138 | }); | |
| 1139 | yield* emit({ | |
| 1140 | type: "turn_item.updated", | |
| 1141 | driver: PI_PROVIDER, | |
| 1142 | turnItem: { | |
| 1143 | ...baseItemFields(turn, nativeTaskId, startedAt, emittedAt), | |
| 1144 | status, | |
| 1145 | title: agent, | |
| 1146 | completedAt: finished ? emittedAt : null, | |
| 1147 | type: "subagent", | |
| 1148 | subagentId, | |
| 1149 | origin: "provider_native", | |
| 1150 | driver: PI_PROVIDER, | |
| 1151 | providerInstanceId: options.instanceId, | |
| 1152 | childThreadId: null, | |
| 1153 | prompt: task, | |
| 1154 | ...progress, | |
| 1155 | result: resultText, | |
| 1156 | }, | |
| 1157 | }); | |
| 1158 | } | |
| 1159 | }); | |
| 1160 | ||
| 1161 | // ── extension UI prompts ────────────────────────────── | |
| 1162 | ||
| 1163 | const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => | |
| 1164 | Effect.gen(function* () { | |
| 1165 | yield* connection | |
| 1166 | .send({ | |
| 1167 | type: "extension_ui_response", | |
| 1168 | id: pending.nativeRequestId, | |
| 1169 | cancelled: true, | |
| 1170 | }) | |
| 1171 | .pipe(Effect.ignore); | |
| 1172 | pending.runtimeRequest = { | |
| 1173 | ...pending.runtimeRequest, | |
| 1174 | status: "cancelled", | |
| 1175 | resolvedAt, | |
| 1176 | }; | |
| 1177 | yield* emit({ | |
| 1178 | type: "runtime_request.updated", | |
| 1179 | driver: PI_PROVIDER, | |
| 1180 | threadId: pending.node.threadId, | |
| 1181 | runtimeRequest: pending.runtimeRequest, | |
| 1182 | }); | |
| 1183 | yield* emit({ | |
| 1184 | type: "node.updated", | |
| 1185 | driver: PI_PROVIDER, | |
| 1186 | node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, | |
| 1187 | }); | |
| 1188 | yield* emit({ | |
| 1189 | type: "turn_item.updated", | |
| 1190 | driver: PI_PROVIDER, | |
| 1191 | turnItem: { | |
| 1192 | ...pending.turnItem, | |
| 1193 | status: "cancelled", | |
| 1194 | completedAt: resolvedAt, | |
| 1195 | updatedAt: resolvedAt, | |
| 1196 | }, | |
| 1197 | }); | |
| 1198 | }); | |
| 1199 | ||
| 1200 | const cancelPendingPrompts = (resolvedAt: DateTime.Utc) => | |
| 1201 | Effect.gen(function* () { | |
| 1202 | const pending = Array.from(pendingPrompts.values()); | |
| 1203 | pendingPrompts.clear(); | |
| 1204 | yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { | |
| 1205 | discard: true, | |
| 1206 | }); | |
| 1207 | }); | |
| 1208 | ||
| 1209 | const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1210 | const method = recordString(event, "method"); | |
| 1211 | const nativeRequestId = recordString(event, "id"); | |
| 1212 | if (method === undefined) return; | |
| 1213 | if (method === "notify") { | |
| 1214 | const state = threadState; | |
| 1215 | const turn = state?.activeTurn ?? null; | |
| 1216 | const message = recordString(event, "message") ?? ""; | |
| 1217 | if (turn === null || message.length === 0) return; | |
| 1218 | const emittedAt = yield* DateTime.now; | |
| 1219 | const nativeItemId = `notify:${turn.nextItemOrdinal}`; | |
| 1220 | yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); | |
| 1221 | yield* emit({ | |
| 1222 | type: "turn_item.updated", | |
| 1223 | driver: PI_PROVIDER, | |
| 1224 | turnItem: { | |
| 1225 | ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), | |
| 1226 | status: "completed", | |
| 1227 | completedAt: emittedAt, | |
| 1228 | title: "notify", | |
| 1229 | type: "dynamic_tool", | |
| 1230 | toolName: "notify", | |
| 1231 | input: { | |
| 1232 | message, | |
| 1233 | notifyType: recordString(event, "notifyType") ?? "info", | |
| 1234 | }, | |
| 1235 | }, | |
| 1236 | }); | |
| 1237 | return; | |
| 1238 | } | |
| 1239 | if ( | |
| 1240 | method !== "select" && | |
| 1241 | method !== "confirm" && | |
| 1242 | method !== "input" && | |
| 1243 | method !== "editor" | |
| 1244 | ) { | |
| 1245 | // Terminal decoration has no matching T3 surface. | |
| 1246 | yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); | |
| 1247 | return; | |
| 1248 | } | |
| 1249 | if (nativeRequestId === undefined) return; | |
| 1250 | const approvalTitle = recordString(event, "title") ?? ""; | |
| 1251 | const approvalKey = `${approvalTitle.length}:${approvalTitle}${recordString(event, "message") ?? ""}`; | |
| 1252 | if (method === "confirm" && sessionApprovals.has(approvalKey)) { | |
| 1253 | yield* connection.send({ | |
| 1254 | type: "extension_ui_response", | |
| 1255 | id: nativeRequestId, | |
| 1256 | confirmed: true, | |
| 1257 | }); | |
| 1258 | return; | |
| 1259 | } | |
| 1260 | const state = threadState; | |
| 1261 | const turn = state?.activeTurn ?? null; | |
| 1262 | const createdAt = yield* DateTime.now; | |
| 1263 | const requestId = yield* idAllocator.allocate.runtimeRequest({ | |
| 1264 | driver: PI_PROVIDER, | |
| 1265 | ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), | |
| 1266 | nativeRequestId, | |
| 1267 | }); | |
| 1268 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | |
| 1269 | const title = recordString(event, "title") ?? method; | |
| 1270 | const threadId = | |
| 1271 | turn?.turnInput.threadId ?? state?.providerThread.appThreadId ?? input.threadId; | |
| 1272 | const providerThreadId = state?.providerThread.id ?? null; | |
| 1273 | const providerTurnId = turn?.providerTurn.id ?? null; | |
| 1274 | const runtimeRequest: OrchestrationV2RuntimeRequest = { | |
| 1275 | id: requestId, | |
| 1276 | nodeId, | |
| 1277 | providerTurnId, | |
| 1278 | nativeRequestRef: providerRef(nativeRequestId), | |
| 1279 | kind: method === "confirm" ? "command" : "user_input", | |
| 1280 | status: "pending", | |
| 1281 | responseCapability: { type: "live", providerSessionId: input.providerSessionId }, | |
| 1282 | createdAt, | |
| 1283 | resolvedAt: null, | |
| 1284 | }; | |
| 1285 | const node: OrchestrationV2ExecutionNode = { | |
| 1286 | id: nodeId, | |
| 1287 | threadId, | |
| 1288 | runId: turn?.turnInput.runId ?? null, | |
| 1289 | parentNodeId: turn?.turnInput.rootNodeId ?? null, | |
| 1290 | rootNodeId: turn?.turnInput.rootNodeId ?? nodeId, | |
| 1291 | kind: method === "confirm" ? "approval_request" : "user_input_request", | |
| 1292 | status: "waiting", | |
| 1293 | countsForRun: false, | |
| 1294 | providerThreadId, | |
| 1295 | providerTurnId, | |
| 1296 | nativeItemRef: providerRef(nativeRequestId), | |
| 1297 | runtimeRequestId: requestId, | |
| 1298 | checkpointScopeId: null, | |
| 1299 | startedAt: createdAt, | |
| 1300 | completedAt: null, | |
| 1301 | }; | |
| 1302 | const itemBase = { | |
| 1303 | id: idAllocator.derive.approvalTurnItem({ requestId }), | |
| 1304 | threadId, | |
| 1305 | runId: turn?.turnInput.runId ?? null, | |
| 1306 | nodeId, | |
| 1307 | providerThreadId, | |
| 1308 | providerTurnId, | |
| 1309 | nativeItemRef: providerRef(nativeRequestId), | |
| 1310 | parentItemId: null, | |
| 1311 | // Runless startup/session-switch requests are normalized into the | |
| 1312 | // thread-level ordinal range by TurnItemPositionStore. | |
| 1313 | ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), | |
| 1314 | status: "waiting" as const, | |
| 1315 | title, | |
| 1316 | startedAt: createdAt, | |
| 1317 | completedAt: null, | |
| 1318 | updatedAt: createdAt, | |
| 1319 | }; | |
| 1320 | const turnItem: OrchestrationV2TurnItem = | |
| 1321 | method === "confirm" | |
| 1322 | ? { | |
| 1323 | ...itemBase, | |
| 1324 | type: "approval_request", | |
| 1325 | requestId, | |
| 1326 | requestKind: piApprovalRequestKind(title), | |
| 1327 | prompt: recordString(event, "message") ?? title, | |
| 1328 | } | |
| 1329 | : { | |
| 1330 | ...itemBase, | |
| 1331 | type: "user_input_request", | |
| 1332 | requestId, | |
| 1333 | questions: [piQuestion(nativeRequestId, method, title, event)], | |
| 1334 | }; | |
| 1335 | pendingPrompts.set(String(requestId), { | |
| 1336 | nativeRequestId, | |
| 1337 | method, | |
| 1338 | questionId: nativeRequestId, | |
| 1339 | approvalKey, | |
| 1340 | runtimeRequest, | |
| 1341 | node, | |
| 1342 | turnItem, | |
| 1343 | }); | |
| 1344 | yield* emit({ | |
| 1345 | type: "runtime_request.updated", | |
| 1346 | driver: PI_PROVIDER, | |
| 1347 | threadId, | |
| 1348 | runtimeRequest, | |
| 1349 | }); | |
| 1350 | yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); | |
| 1351 | yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); | |
| 1352 | }); | |
| 1353 | ||
| 1354 | const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1355 | const state = threadState; | |
| 1356 | const turn = state?.activeTurn ?? null; | |
| 1357 | if (turn === null) { | |
| 1358 | outOfTurnExtensionErrors.push(event); | |
| 1359 | return; | |
| 1360 | } | |
| 1361 | const emittedAt = yield* DateTime.now; | |
| 1362 | const nativeItemId = `extension-error:${turn.nextItemOrdinal}`; | |
| 1363 | const extensionName = piExtensionDisplayName(recordString(event, "extensionPath")); | |
| 1364 | const extensionEvent = recordString(event, "event"); | |
| 1365 | const detail = recordString(event, "error")?.trim(); | |
| 1366 | const message = [ | |
| 1367 | `${extensionName} failed${extensionEvent === undefined ? "" : ` during ${extensionEvent}`}.`, | |
| 1368 | detail === undefined || detail.length === 0 ? undefined : detail.slice(0, 2_000), | |
| 1369 | ] | |
| 1370 | .filter((part): part is string => part !== undefined) | |
| 1371 | .join("\n\n"); | |
| 1372 | const failure = makeProviderFailure({ | |
| 1373 | message, | |
| 1374 | class: "provider_error", | |
| 1375 | retryable: false, | |
| 1376 | }); | |
| 1377 | yield* emitItemNode(turn, nativeItemId, "system", "failed", emittedAt, emittedAt); | |
| 1378 | yield* emit({ | |
| 1379 | type: "turn_item.updated", | |
| 1380 | driver: PI_PROVIDER, | |
| 1381 | turnItem: { | |
| 1382 | ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), | |
| 1383 | status: "failed", | |
| 1384 | title: extensionName, | |
| 1385 | completedAt: emittedAt, | |
| 1386 | type: "error", | |
| 1387 | failure, | |
| 1388 | }, | |
| 1389 | }); | |
| 1390 | }); | |
| 1391 | ||
| 1392 | // ── turn lifecycle ──────────────────────────────────── | |
| 1393 | ||
| 1394 | /** | |
| 1395 | * Locate this turn's first user entry and the new leaf in pi's session | |
| 1396 | * tree. The user-entry id becomes the provider turn's native ref (the | |
| 1397 | * point `fork` rolls back to); the leaf becomes the conversation head. | |
| 1398 | * Pure bookkeeping: failures degrade to the synthetic refs. | |
| 1399 | */ | |
| 1400 | const captureTurnTreeRefs = Effect.fnUntraced(function* ( | |
| 1401 | timeoutMs = PI_REQUEST_TIMEOUT_MS, | |
| 1402 | ): Effect.fn.Return<PiTurnTreeRefs | null> { | |
| 1403 | const cursorWasStale = leafCursorStale; | |
| 1404 | const cursor = cursorWasStale ? null : lastKnownLeaf; | |
| 1405 | const data = yield* request( | |
| 1406 | { | |
| 1407 | type: "get_entries", | |
| 1408 | ...(cursor === null ? {} : { since: cursor }), | |
| 1409 | }, | |
| 1410 | timeoutMs, | |
| 1411 | ).pipe(Effect.orElseSucceed(() => undefined)); | |
| 1412 | if (data === undefined) { | |
| 1413 | // Pi may have advanced past `lastKnownLeaf` while this failed, so the | |
| 1414 | // cursor can no longer be trusted to bound a single turn. | |
| 1415 | leafCursorStale = true; | |
| 1416 | return null; | |
| 1417 | } | |
| 1418 | const entries = recordField(data, "entries"); | |
| 1419 | const leafId = recordString(data, "leafId"); | |
| 1420 | if (leafId !== undefined) lastKnownLeaf = leafId; | |
| 1421 | // Without a trustworthy cursor this window spans more than one turn, so | |
| 1422 | // its first user entry belongs to an earlier turn. Re-sync the cursor | |
| 1423 | // and skip the turn-start ref rather than pointing rollback too far | |
| 1424 | // back; the next turn gets an accurate ref again. | |
| 1425 | leafCursorStale = false; | |
| 1426 | const firstUserEntryId = cursorWasStale | |
| 1427 | ? undefined | |
| 1428 | : Array.isArray(entries) | |
| 1429 | ? entries | |
| 1430 | .filter( | |
| 1431 | (entry) => | |
| 1432 | recordField(entry, "type") === "message" && | |
| 1433 | recordString(recordField(entry, "message"), "role") === "user", | |
| 1434 | ) | |
| 1435 | .map((entry) => recordString(entry, "id")) | |
| 1436 | .find((id) => id !== undefined) | |
| 1437 | : undefined; | |
| 1438 | return { | |
| 1439 | turnStartEntryId: firstUserEntryId ?? null, | |
| 1440 | leafId: leafId ?? null, | |
| 1441 | }; | |
| 1442 | }); | |
| 1443 | ||
| 1444 | const finalizeTurn = Effect.fnUntraced(function* (state: PiThreadState, readUsage = true) { | |
| 1445 | const turn = state.activeTurn; | |
| 1446 | if (turn === null) return; | |
| 1447 | state.activeTurn = null; | |
| 1448 | const completedAt = yield* DateTime.now; | |
| 1449 | yield* completeOpenStreamItems(turn); | |
| 1450 | if (turn.activeCompaction !== null) { | |
| 1451 | const status = turn.interrupted | |
| 1452 | ? "cancelled" | |
| 1453 | : turn.failure === null | |
| 1454 | ? "completed" | |
| 1455 | : "failed"; | |
| 1456 | yield* emitCompaction(turn, turn.activeCompaction, status); | |
| 1457 | turn.activeCompaction = null; | |
| 1458 | } | |
| 1459 | if (turn.activeProviderRetry !== null) { | |
| 1460 | if (turn.interrupted) { | |
| 1461 | yield* emitProviderRetry(turn, turn.activeProviderRetry, "interrupted", completedAt); | |
| 1462 | turn.activeProviderRetry = null; | |
| 1463 | } else if (turn.failure === null) { | |
| 1464 | yield* emitProviderRetry(turn, turn.activeProviderRetry, "completed", completedAt); | |
| 1465 | turn.activeProviderRetry = null; | |
| 1466 | } | |
| 1467 | } | |
| 1468 | yield* cancelPendingPrompts(completedAt); | |
| 1469 | const treeRefs = | |
| 1470 | turn.stopTreeRefs !== undefined ? turn.stopTreeRefs : yield* captureTurnTreeRefs(); | |
| 1471 | const tokenUsage = readUsage | |
| 1472 | ? yield* readTokenUsage(turn.latestCompactionAfterTokens, completedAt) | |
| 1473 | : undefined; | |
| 1474 | const failure = turn.interrupted ? null : (turn.rejectedPromptFailure ?? turn.failure); | |
| 1475 | yield* emit({ | |
| 1476 | type: "provider_turn.updated", | |
| 1477 | driver: PI_PROVIDER, | |
| 1478 | threadId: turn.turnInput.threadId, | |
| 1479 | providerTurn: { | |
| 1480 | ...turn.providerTurn, | |
| 1481 | ...(treeRefs?.turnStartEntryId == null | |
| 1482 | ? {} | |
| 1483 | : { nativeTurnRef: providerRef(treeRefs.turnStartEntryId) }), | |
| 1484 | status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", | |
| 1485 | completedAt, | |
| 1486 | ...(tokenUsage === undefined ? {} : { tokenUsage }), | |
| 1487 | }, | |
| 1488 | }); | |
| 1489 | yield* updateProviderThread(state, { | |
| 1490 | status: "idle", | |
| 1491 | ...(treeRefs?.leafId == null | |
| 1492 | ? {} | |
| 1493 | : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), | |
| 1494 | }); | |
| 1495 | yield* updateProviderSession( | |
| 1496 | failure !== null ? "error" : "ready", | |
| 1497 | failure?.message ?? null, | |
| 1498 | ); | |
| 1499 | if (failure !== null) { | |
| 1500 | const failureItemId = `terminal-failure:${turn.providerTurn.id}`; | |
| 1501 | if (turn.activeProviderRetry !== null) { | |
| 1502 | yield* emitProviderRetry( | |
| 1503 | turn, | |
| 1504 | { ...turn.activeProviderRetry, failure }, | |
| 1505 | "failed", | |
| 1506 | completedAt, | |
| 1507 | ); | |
| 1508 | } else { | |
| 1509 | yield* emit({ | |
| 1510 | type: "turn_item.updated", | |
| 1511 | driver: PI_PROVIDER, | |
| 1512 | turnItem: { | |
| 1513 | ...baseItemFields(turn, failureItemId, completedAt, completedAt), | |
| 1514 | status: "failed", | |
| 1515 | title: null, | |
| 1516 | completedAt, | |
| 1517 | type: "error", | |
| 1518 | failure, | |
| 1519 | }, | |
| 1520 | }); | |
| 1521 | } | |
| 1522 | yield* emit({ | |
| 1523 | type: "turn.terminal", | |
| 1524 | driver: PI_PROVIDER, | |
| 1525 | providerThreadId: state.providerThread.id, | |
| 1526 | providerTurnId: turn.providerTurn.id, | |
| 1527 | runOrdinal: turn.turnInput.runOrdinal, | |
| 1528 | failureItemOrdinal: itemOrdinal(turn, failureItemId), | |
| 1529 | status: "failed", | |
| 1530 | failure, | |
| 1531 | ...(turn.activeProviderRetry === null | |
| 1532 | ? {} | |
| 1533 | : { | |
| 1534 | retry: turn.activeProviderRetry.retry, | |
| 1535 | retryStartedAt: turn.activeProviderRetry.startedAt, | |
| 1536 | }), | |
| 1537 | threadDisposition: "reusable", | |
| 1538 | }); | |
| 1539 | } else { | |
| 1540 | yield* emit({ | |
| 1541 | type: "turn.terminal", | |
| 1542 | driver: PI_PROVIDER, | |
| 1543 | providerThreadId: state.providerThread.id, | |
| 1544 | providerTurnId: turn.providerTurn.id, | |
| 1545 | runOrdinal: turn.turnInput.runOrdinal, | |
| 1546 | status: turn.interrupted ? "interrupted" : "completed", | |
| 1547 | failure: null, | |
| 1548 | threadDisposition: "reusable", | |
| 1549 | }); | |
| 1550 | } | |
| 1551 | }); | |
| 1552 | ||
| 1553 | // ── event pump ──────────────────────────────────────── | |
| 1554 | ||
| 1555 | const scheduleSettleProbe = ( | |
| 1556 | turn: ActivePiTurn, | |
| 1557 | settleAfterAgentActivity = false, | |
| 1558 | attempt = 1, | |
| 1559 | ) => { | |
| 1560 | const providerTurnId = turn.providerTurn.id; | |
| 1561 | const settleProbeGeneration = turn.settleProbeGeneration; | |
| 1562 | return request({ type: "get_state" }, 2_000).pipe( | |
| 1563 | Effect.matchEffect({ | |
| 1564 | onSuccess: (data) => | |
| 1565 | Queue.offer(connection.events, { | |
| 1566 | type: "t3.settle_probe", | |
| 1567 | providerTurnId, | |
| 1568 | settleAfterAgentActivity, | |
| 1569 | settleProbeGeneration, | |
| 1570 | attempt, | |
| 1571 | data, | |
| 1572 | }), | |
| 1573 | // A failed probe still has to reach the pump. Dropping it would | |
| 1574 | // leave a command-only turn active forever, because Pi never emits | |
| 1575 | // agent events for one. | |
| 1576 | onFailure: () => | |
| 1577 | Queue.offer(connection.events, { | |
| 1578 | type: "t3.settle_probe", | |
| 1579 | providerTurnId, | |
| 1580 | settleAfterAgentActivity, | |
| 1581 | settleProbeGeneration, | |
| 1582 | attempt, | |
| 1583 | probeFailed: true, | |
| 1584 | }), | |
| 1585 | }), | |
| 1586 | Effect.ignore, | |
| 1587 | Effect.forkIn(scope), | |
| 1588 | ); | |
| 1589 | }; | |
| 1590 | ||
| 1591 | const stopPendingWake = Effect.fnUntraced(function* (wake: PiWake) { | |
| 1592 | if (pendingWake !== wake || threadState !== wake.state || closed) return; | |
| 1593 | pendingWake = null; | |
| 1594 | stopRequested = true; | |
| 1595 | yield* cancelPendingPrompts(yield* DateTime.now); | |
| 1596 | yield* connection.terminate; | |
| 1597 | }); | |
| 1598 | ||
| 1599 | const handleSessionEvent = Effect.fnUntraced(function* (event: PiRpcRecord) { | |
| 1600 | // Model-selection hooks can await a dialog before startTurn can claim | |
| 1601 | // a wake. Keep interactive requests session-scoped until a turn owns | |
| 1602 | // them; buffer native output and notifications for that turn. | |
| 1603 | const interactiveUiRequest = | |
| 1604 | event["type"] === "extension_ui_request" && | |
| 1605 | ["select", "confirm", "input", "editor"].includes(String(event["method"])); | |
| 1606 | if ( | |
| 1607 | threadState?.activeTurn === null && | |
| 1608 | pendingWake !== null && | |
| 1609 | !interactiveUiRequest && | |
| 1610 | !String(event["type"]).startsWith("t3.") && | |
| 1611 | event["type"] !== "response" | |
| 1612 | ) { | |
| 1613 | pendingWake.events.push(event); | |
| 1614 | if (event["type"] === "agent_start") yield* updateProviderSession("running", null); | |
| 1615 | if (event["type"] === "agent_settled") yield* updateProviderSession("ready", null); | |
| 1616 | return; | |
| 1617 | } | |
| 1618 | const state = threadState; | |
| 1619 | const turn = state?.activeTurn ?? null; | |
| 1620 | switch (event["type"]) { | |
| 1621 | case "agent_start": { | |
| 1622 | if ( | |
| 1623 | turn === null && | |
| 1624 | state?.providerThread.appThreadId != null && | |
| 1625 | options.continuationRequests !== undefined && | |
| 1626 | !closed && | |
| 1627 | !stopRequested | |
| 1628 | ) { | |
| 1629 | const wake: PiWake = { state, events: [event] }; | |
| 1630 | pendingWake = wake; | |
| 1631 | if (rollbackBarrier !== null) { | |
| 1632 | yield* stopPendingWake(wake); | |
| 1633 | return; | |
| 1634 | } | |
| 1635 | yield* updateProviderSession("running", null); | |
| 1636 | yield* options.continuationRequests | |
| 1637 | .offer({ | |
| 1638 | threadId: state.providerThread.appThreadId, | |
| 1639 | providerThreadId: state.providerThread.id, | |
| 1640 | driver: PI_PROVIDER, | |
| 1641 | detail: null, | |
| 1642 | delivery: "adapter_buffered", | |
| 1643 | dispatchIfCurrent: (dispatch) => | |
| 1644 | sessionEventPermit.withPermits(1)( | |
| 1645 | Effect.suspend(() => | |
| 1646 | pendingWake === wake && threadState === state && !closed | |
| 1647 | ? dispatch.pipe( | |
| 1648 | Effect.map(Option.some), | |
| 1649 | Effect.tapError(() => stopPendingWake(wake)), | |
| 1650 | ) | |
| 1651 | : Effect.succeed(Option.none()), | |
| 1652 | ), | |
| 1653 | ), | |
| 1654 | clearIfCurrent: () => sessionEventPermit.withPermits(1)(stopPendingWake(wake)), | |
| 1655 | }) | |
| 1656 | .pipe(Effect.tapError(() => stopPendingWake(wake))); | |
| 1657 | return; | |
| 1658 | } | |
| 1659 | if (turn === null) { | |
| 1660 | unsolicitedActivityDetected = true; | |
| 1661 | yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); | |
| 1662 | yield* connection.terminate; | |
| 1663 | return; | |
| 1664 | } | |
| 1665 | turn.sawAgentActivity = true; | |
| 1666 | turn.settleProbeGeneration += 1; | |
| 1667 | return; | |
| 1668 | } | |
| 1669 | case "message_start": { | |
| 1670 | if (turn !== null && recordString(event["message"], "role") === "assistant") { | |
| 1671 | turn.sawAgentActivity = true; | |
| 1672 | turn.messageOrdinal += 1; | |
| 1673 | } | |
| 1674 | return; | |
| 1675 | } | |
| 1676 | case "message_update": { | |
| 1677 | if (turn === null) return; | |
| 1678 | turn.sawAgentActivity = true; | |
| 1679 | yield* reportLiveUsage(turn, event["usage"]); | |
| 1680 | const delta = event["assistantMessageEvent"]; | |
| 1681 | const deltaType = recordString(delta, "type"); | |
| 1682 | const contentIndex = recordNumber(delta, "contentIndex") ?? 0; | |
| 1683 | if (deltaType === "text_delta" || deltaType === "thinking_delta") { | |
| 1684 | const item = yield* streamItemFor( | |
| 1685 | turn, | |
| 1686 | deltaType === "text_delta" ? "assistant_message" : "reasoning", | |
| 1687 | contentIndex, | |
| 1688 | ); | |
| 1689 | item.text += recordString(delta, "delta") ?? ""; | |
| 1690 | yield* scheduleStreamFlush(turn, item); | |
| 1691 | return; | |
| 1692 | } | |
| 1693 | if (deltaType === "text_end" || deltaType === "thinking_end") { | |
| 1694 | const item = yield* streamItemFor( | |
| 1695 | turn, | |
| 1696 | deltaType === "text_end" ? "assistant_message" : "reasoning", | |
| 1697 | contentIndex, | |
| 1698 | ); | |
| 1699 | yield* completeStreamItem( | |
| 1700 | turn, | |
| 1701 | item, | |
| 1702 | recordString(delta, "content") ?? recordString(delta, "thinking"), | |
| 1703 | ); | |
| 1704 | return; | |
| 1705 | } | |
| 1706 | return; | |
| 1707 | } | |
| 1708 | case "message_end": { | |
| 1709 | if (turn === null) return; | |
| 1710 | const message = event["message"]; | |
| 1711 | if (recordString(message, "role") !== "assistant") return; | |
| 1712 | yield* completeOpenStreamItems(turn); | |
| 1713 | if (recordString(message, "stopReason") === "error" && turn.failure === null) { | |
| 1714 | turn.failure = makeProviderFailure({ | |
| 1715 | message: recordString(message, "errorMessage") ?? "Pi reported a model error.", | |
| 1716 | class: "provider_error", | |
| 1717 | }); | |
| 1718 | } | |
| 1719 | return; | |
| 1720 | } | |
| 1721 | case "tool_execution_start": | |
| 1722 | if (turn !== null) { | |
| 1723 | turn.sawAgentActivity = true; | |
| 1724 | yield* emitToolItem(turn, event, "start"); | |
| 1725 | } | |
| 1726 | return; | |
| 1727 | case "tool_execution_update": | |
| 1728 | if (turn !== null) yield* emitToolItem(turn, event, "update"); | |
| 1729 | return; | |
| 1730 | case "tool_execution_end": | |
| 1731 | if (turn !== null) yield* emitToolItem(turn, event, "end"); | |
| 1732 | return; | |
| 1733 | case "compaction_start": { | |
| 1734 | if (turn === null) return; | |
| 1735 | turn.settleProbeGeneration += 1; | |
| 1736 | turn.sawCompaction = true; | |
| 1737 | if (turn.activeCompaction !== null) { | |
| 1738 | yield* emitCompaction(turn, turn.activeCompaction, "cancelled"); | |
| 1739 | } | |
| 1740 | const startedAt = yield* DateTime.now; | |
| 1741 | const compaction = { | |
| 1742 | nativeItemId: compactionNativeItemId(turn), | |
| 1743 | startedAt, | |
| 1744 | } satisfies PiCompactionState; | |
| 1745 | turn.activeCompaction = compaction; | |
| 1746 | yield* emitCompaction(turn, compaction, "running"); | |
| 1747 | return; | |
| 1748 | } | |
| 1749 | case "compaction_end": { | |
| 1750 | if (turn === null) return; | |
| 1751 | const observedAt = yield* DateTime.now; | |
| 1752 | const compaction = turn.activeCompaction ?? { | |
| 1753 | nativeItemId: compactionNativeItemId(turn), | |
| 1754 | startedAt: observedAt, | |
| 1755 | }; | |
| 1756 | turn.activeCompaction = null; | |
| 1757 | const result = event["result"]; | |
| 1758 | if (result === null || result === undefined) { | |
| 1759 | if (event["aborted"] === true) { | |
| 1760 | yield* emitCompaction(turn, compaction, "cancelled"); | |
| 1761 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1762 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1763 | } | |
| 1764 | return; | |
| 1765 | } | |
| 1766 | const errorMessage = | |
| 1767 | recordString(event, "errorMessage") ?? "Pi context compaction failed."; | |
| 1768 | yield* emitCompaction(turn, compaction, "failed", { | |
| 1769 | summary: errorMessage.slice(0, 1_000), | |
| 1770 | }); | |
| 1771 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1772 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1773 | } | |
| 1774 | return; | |
| 1775 | } | |
| 1776 | // An overflow can surface as a model error (`message_end` with | |
| 1777 | // stopReason error) before Pi compacts and retries the turn. Clear | |
| 1778 | // that failure only when Pi confirms that compaction will retry; | |
| 1779 | // a successful non-retrying compaction must not erase an exhausted | |
| 1780 | // provider retry. | |
| 1781 | if (event["willRetry"] === true) turn.failure = null; | |
| 1782 | turn.latestCompactionAfterTokens = | |
| 1783 | nonNegativeInteger(result, "estimatedTokensAfter") ?? null; | |
| 1784 | const summary = recordString(result, "summary"); | |
| 1785 | const beforeTokenCount = nonNegativeInteger(result, "tokensBefore"); | |
| 1786 | const afterTokenCount = nonNegativeInteger(result, "estimatedTokensAfter"); | |
| 1787 | yield* emitCompaction(turn, compaction, "completed", { | |
| 1788 | ...(summary === undefined ? {} : { summary }), | |
| 1789 | ...(beforeTokenCount === undefined ? {} : { beforeTokenCount }), | |
| 1790 | ...(afterTokenCount === undefined ? {} : { afterTokenCount }), | |
| 1791 | }); | |
| 1792 | if (turn.settleWhenIdle || !turn.sawAgentActivity) { | |
| 1793 | yield* scheduleSettleProbe(turn, turn.settleWhenIdle); | |
| 1794 | } | |
| 1795 | return; | |
| 1796 | } | |
| 1797 | case "auto_retry_start": { | |
| 1798 | if (turn === null) return; | |
| 1799 | const emittedAt = yield* DateTime.now; | |
| 1800 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 1801 | const maxAttempts = Math.max( | |
| 1802 | attempt, | |
| 1803 | Math.trunc(recordNumber(event, "maxAttempts") ?? attempt), | |
| 1804 | ); | |
| 1805 | const retryDelayMs = Math.max(0, Math.trunc(recordNumber(event, "delayMs") ?? 0)); | |
| 1806 | const failure = makeProviderFailure({ | |
| 1807 | message: recordString(event, "errorMessage") ?? "Pi provider request failed.", | |
| 1808 | class: "provider_error", | |
| 1809 | retryable: true, | |
| 1810 | }); | |
| 1811 | const current = turn.activeProviderRetry; | |
| 1812 | const providerRetry = { | |
| 1813 | retry: { attempt, maxAttempts, retryDelayMs }, | |
| 1814 | failure, | |
| 1815 | startedAt: current?.startedAt ?? emittedAt, | |
| 1816 | itemOrdinal: | |
| 1817 | current?.itemOrdinal ?? | |
| 1818 | itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1819 | } satisfies PiProviderRetryState; | |
| 1820 | turn.activeProviderRetry = providerRetry; | |
| 1821 | yield* emitProviderRetry(turn, providerRetry, "running", emittedAt); | |
| 1822 | return; | |
| 1823 | } | |
| 1824 | case "auto_retry_end": { | |
| 1825 | if (turn === null) return; | |
| 1826 | const emittedAt = yield* DateTime.now; | |
| 1827 | if (event["success"] === true) { | |
| 1828 | // The retry recovered. Pi emits the erroring `message_end` | |
| 1829 | // before retrying, so leaving that failure in place would make | |
| 1830 | // `agent_settled` terminalize a successful turn as failed. | |
| 1831 | if (turn.activeProviderRetry !== null) { | |
| 1832 | const attempt = Math.max( | |
| 1833 | 1, | |
| 1834 | Math.trunc( | |
| 1835 | recordNumber(event, "attempt") ?? turn.activeProviderRetry.retry.attempt, | |
| 1836 | ), | |
| 1837 | ); | |
| 1838 | const recoveredRetry = { | |
| 1839 | ...turn.activeProviderRetry, | |
| 1840 | retry: { ...turn.activeProviderRetry.retry, attempt }, | |
| 1841 | }; | |
| 1842 | yield* emitProviderRetry(turn, recoveredRetry, "completed", emittedAt); | |
| 1843 | turn.activeProviderRetry = null; | |
| 1844 | } | |
| 1845 | turn.failure = null; | |
| 1846 | return; | |
| 1847 | } | |
| 1848 | const failure = makeProviderFailure({ | |
| 1849 | message: recordString(event, "finalError") ?? "Pi auto-retry failed.", | |
| 1850 | class: "provider_error", | |
| 1851 | retryable: false, | |
| 1852 | }); | |
| 1853 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 1854 | const current = turn.activeProviderRetry; | |
| 1855 | const providerRetry = { | |
| 1856 | retry: { | |
| 1857 | attempt, | |
| 1858 | maxAttempts: current?.retry.maxAttempts ?? attempt, | |
| 1859 | retryDelayMs: current?.retry.retryDelayMs ?? null, | |
| 1860 | }, | |
| 1861 | failure, | |
| 1862 | startedAt: current?.startedAt ?? emittedAt, | |
| 1863 | itemOrdinal: | |
| 1864 | current?.itemOrdinal ?? | |
| 1865 | itemOrdinal(turn, `terminal-failure:${turn.providerTurn.id}`), | |
| 1866 | } satisfies PiProviderRetryState; | |
| 1867 | turn.activeProviderRetry = providerRetry; | |
| 1868 | turn.failure = failure; | |
| 1869 | yield* emitProviderRetry(turn, providerRetry, "failed", emittedAt); | |
| 1870 | return; | |
| 1871 | } | |
| 1872 | case "extension_ui_request": | |
| 1873 | yield* handleExtensionUiRequest(event); | |
| 1874 | return; | |
| 1875 | case "extension_error": { | |
| 1876 | yield* emitExtensionError(event); | |
| 1877 | return; | |
| 1878 | } | |
| 1879 | case "agent_settled": { | |
| 1880 | if (turn?.interrupted === true) { | |
| 1881 | if (state !== null) yield* finalizeTurn(state); | |
| 1882 | return; | |
| 1883 | } | |
| 1884 | if (turn !== null) { | |
| 1885 | turn.settleWhenIdle = true; | |
| 1886 | turn.settleProbeGeneration += 1; | |
| 1887 | // A wake can settle while the newly joined user prompt is still | |
| 1888 | // expanding. Its acknowledgement opens the idle-probe barrier. | |
| 1889 | if ( | |
| 1890 | turn.adoptedWake && | |
| 1891 | pendingPromptResponses.some( | |
| 1892 | (pending) => | |
| 1893 | pending.providerTurnId === turn.providerTurn.id && | |
| 1894 | pending.kind === "turn_start", | |
| 1895 | ) | |
| 1896 | ) | |
| 1897 | return; | |
| 1898 | yield* scheduleSettleProbe(turn, true); | |
| 1899 | } | |
| 1900 | return; | |
| 1901 | } | |
| 1902 | case "response": { | |
| 1903 | // Correlated responses never reach the pump; an id-less response | |
| 1904 | // is the deferred ack of a fire-and-forget prompt/steer/compact. | |
| 1905 | const command = recordString(event, "command"); | |
| 1906 | if (command === "compact") { | |
| 1907 | const pendingCompact = pendingCompactResponses.shift(); | |
| 1908 | const compactTurn = | |
| 1909 | pendingCompact?.providerTurnId === turn?.providerTurn.id ? turn : null; | |
| 1910 | if (compactTurn !== null) compactTurn.manualCompactInFlight = false; | |
| 1911 | if (event["success"] === true) { | |
| 1912 | if ( | |
| 1913 | pendingCompact?.kind === "turn_start" && | |
| 1914 | compactTurn !== null && | |
| 1915 | compactTurn.promptMayBeCommandOnly && | |
| 1916 | !compactTurn.sawAgentActivity | |
| 1917 | ) { | |
| 1918 | yield* scheduleSettleProbe(compactTurn); | |
| 1919 | } | |
| 1920 | return; | |
| 1921 | } | |
| 1922 | if (event["success"] !== false) return; | |
| 1923 | if (compactTurn === null) return; | |
| 1924 | if (compactTurn.activeCompaction !== null) return; | |
| 1925 | if (pendingCompact?.kind === "steer") { | |
| 1926 | yield* Effect.logWarning("Pi rejected a compact steer.", { | |
| 1927 | errorLength: recordString(event, "error")?.length, | |
| 1928 | }); | |
| 1929 | return; | |
| 1930 | } | |
| 1931 | if (!compactTurn.sawCompaction) { | |
| 1932 | compactTurn.failure = makeProviderFailure({ | |
| 1933 | message: recordString(event, "error") ?? "Pi compact failed.", | |
| 1934 | class: "provider_error", | |
| 1935 | }); | |
| 1936 | if (state !== null) yield* finalizeTurn(state); | |
| 1937 | return; | |
| 1938 | } | |
| 1939 | if (!compactTurn.sawAgentActivity) { | |
| 1940 | yield* scheduleSettleProbe(compactTurn); | |
| 1941 | } | |
| 1942 | return; | |
| 1943 | } | |
| 1944 | const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; | |
| 1945 | const responseTurn = | |
| 1946 | pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; | |
| 1947 | if (event["success"] === true) { | |
| 1948 | // Deferred success ack. Command-only prompts (pure extension | |
| 1949 | // slash commands) never start an agent run and never emit | |
| 1950 | // `agent_settled`, so probe for idleness. The probe result is | |
| 1951 | // re-queued behind any events Pi emitted before answering | |
| 1952 | // get_state, which keeps the check stream-ordered. | |
| 1953 | if ( | |
| 1954 | pendingPrompt?.kind === "turn_start" && | |
| 1955 | responseTurn !== null && | |
| 1956 | ((responseTurn.promptMayBeCommandOnly && !responseTurn.sawAgentActivity) || | |
| 1957 | (responseTurn.adoptedWake && | |
| 1958 | (responseTurn.settleWhenIdle || responseTurn.promptMayBeCommandOnly))) | |
| 1959 | ) { | |
| 1960 | yield* scheduleSettleProbe(responseTurn, responseTurn.adoptedWake); | |
| 1961 | } | |
| 1962 | return; | |
| 1963 | } | |
| 1964 | if (event["success"] !== false) return; | |
| 1965 | if (command === "steer" || pendingPrompt?.kind === "steer") { | |
| 1966 | // A rejected steer only means that one message was refused. The | |
| 1967 | // turn it was aimed at is still running on Pi, so terminalizing | |
| 1968 | // here would report a failure while output keeps streaming. | |
| 1969 | yield* Effect.logWarning("Pi rejected a steer message.", { | |
| 1970 | errorLength: recordString(event, "error")?.length, | |
| 1971 | }); | |
| 1972 | return; | |
| 1973 | } | |
| 1974 | const failedTurn = | |
| 1975 | command === "prompt" && pendingPrompt?.kind === "turn_start" | |
| 1976 | ? responseTurn | |
| 1977 | : command === "parse" | |
| 1978 | ? turn | |
| 1979 | : null; | |
| 1980 | if (failedTurn !== null) { | |
| 1981 | const failure = makeProviderFailure({ | |
| 1982 | message: recordString(event, "error") ?? "Pi rejected the prompt.", | |
| 1983 | class: "provider_error", | |
| 1984 | }); | |
| 1985 | if (failedTurn.adoptedWake && command === "prompt") { | |
| 1986 | failedTurn.rejectedPromptFailure = failure; | |
| 1987 | failedTurn.settleWhenIdle = true; | |
| 1988 | yield* scheduleSettleProbe(failedTurn, true); | |
| 1989 | } else { | |
| 1990 | failedTurn.failure = failure; | |
| 1991 | if (state !== null) yield* finalizeTurn(state); | |
| 1992 | } | |
| 1993 | } | |
| 1994 | return; | |
| 1995 | } | |
| 1996 | case "t3.flush_extension_errors": { | |
| 1997 | // Startup extension failures are informational and do not block | |
| 1998 | // Pi, so attach them to the next real turn instead of creating a | |
| 1999 | // standalone failed run. | |
| 2000 | for (const extensionError of outOfTurnExtensionErrors.splice(0)) { | |
| 2001 | yield* emitExtensionError(extensionError); | |
| 2002 | } | |
| 2003 | return; | |
| 2004 | } | |
| 2005 | case "t3.rollback_barrier": { | |
| 2006 | if (rollbackBarrier !== null && event["barrier"] === rollbackBarrier) | |
| 2007 | yield* Deferred.succeed(rollbackBarrier, undefined); | |
| 2008 | return; | |
| 2009 | } | |
| 2010 | case "t3.settle_probe": { | |
| 2011 | // New work increments the generation before the pump can consume | |
| 2012 | // a stale idle snapshot, so only a current snapshot may settle. | |
| 2013 | const data = event["data"]; | |
| 2014 | const probeFailed = event["probeFailed"] === true; | |
| 2015 | const settleAfterAgentActivity = event["settleAfterAgentActivity"] === true; | |
| 2016 | const attempt = Math.max(1, Math.trunc(recordNumber(event, "attempt") ?? 1)); | |
| 2017 | if ( | |
| 2018 | turn === null || | |
| 2019 | turn.providerTurn.id !== event["providerTurnId"] || | |
| 2020 | turn.settleProbeGeneration !== event["settleProbeGeneration"] || | |
| 2021 | (!settleAfterAgentActivity && turn.sawAgentActivity) || | |
| 2022 | turn.activeCompaction !== null | |
| 2023 | ) { | |
| 2024 | return; | |
| 2025 | } | |
| 2026 | if (probeFailed) { | |
| 2027 | if (!settleAfterAgentActivity) { | |
| 2028 | if (state !== null) yield* finalizeTurn(state); | |
| 2029 | return; | |
| 2030 | } | |
| 2031 | if (attempt < SETTLE_PROBE_MAX_ATTEMPTS) { | |
| 2032 | yield* Effect.sleep(SETTLE_PROBE_RETRY_DELAY).pipe( | |
| 2033 | Effect.andThen(scheduleSettleProbe(turn, true, attempt + 1)), | |
| 2034 | Effect.forkIn(scope), | |
| 2035 | ); | |
| 2036 | return; | |
| 2037 | } | |
| 2038 | stopRequested = true; | |
| 2039 | yield* connection.terminate; | |
| 2040 | return; | |
| 2041 | } | |
| 2042 | if ( | |
| 2043 | recordField(data, "isStreaming") !== true && | |
| 2044 | recordField(data, "isCompacting") !== true && | |
| 2045 | (recordNumber(data, "pendingMessageCount") ?? 0) === 0 | |
| 2046 | ) { | |
| 2047 | turn.settleWhenIdle = false; | |
| 2048 | if (state !== null) yield* finalizeTurn(state); | |
| 2049 | } | |
| 2050 | return; | |
| 2051 | } | |
| 2052 | default: | |
| 2053 | return; | |
| 2054 | } | |
| 2055 | }); | |
| 2056 | ||
| 2057 | yield* Effect.gen(function* () { | |
| 2058 | while (true) { | |
| 2059 | const event = yield* Queue.take(connection.events); | |
| 2060 | yield* sessionEventPermit.withPermits(1)(handleSessionEvent(event)); | |
| 2061 | } | |
| 2062 | }).pipe( | |
| 2063 | Effect.catchCause((cause) => | |
| 2064 | sessionEventPermit.withPermits(1)( | |
| 2065 | Effect.gen(function* () { | |
| 2066 | // Transport death finalizes any live turn. Stop-with-restart | |
| 2067 | // closes the provider stream cleanly; only an unexpected death | |
| 2068 | // is surfaced as an event-stream failure. | |
| 2069 | const state = threadState; | |
| 2070 | if (rollbackBarrier !== null) | |
| 2071 | yield* Deferred.fail( | |
| 2072 | rollbackBarrier, | |
| 2073 | protocolError("Pi transport closed during rollback"), | |
| 2074 | ); | |
| 2075 | const hadPendingWake = pendingWake !== null; | |
| 2076 | pendingWake = null; | |
| 2077 | const interrupted = state?.activeTurn?.interrupted === true; | |
| 2078 | if (state?.activeTurn != null) { | |
| 2079 | state.activeTurn.failure = interrupted | |
| 2080 | ? null | |
| 2081 | : makeProviderFailure({ | |
| 2082 | cause, | |
| 2083 | message: "Pi process exited unexpectedly.", | |
| 2084 | class: "transport_error", | |
| 2085 | }); | |
| 2086 | yield* finalizeTurn(state, false); | |
| 2087 | } else if (hadPendingWake) { | |
| 2088 | yield* cancelPendingPrompts(yield* DateTime.now); | |
| 2089 | } | |
| 2090 | if (unsolicitedActivityDetected) { | |
| 2091 | yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); | |
| 2092 | yield* Queue.end(events); | |
| 2093 | } else if (stopRequested) { | |
| 2094 | yield* updateProviderSession("stopped", null); | |
| 2095 | yield* Queue.end(events); | |
| 2096 | } else { | |
| 2097 | yield* updateProviderSession( | |
| 2098 | "error", | |
| 2099 | interrupted ? "Pi process was stopped." : "Pi process exited unexpectedly.", | |
| 2100 | ); | |
| 2101 | yield* Queue.fail( | |
| 2102 | events, | |
| 2103 | new ProviderAdapter.ProviderAdapterEventStreamError({ | |
| 2104 | driver: PI_PROVIDER, | |
| 2105 | providerSessionId: input.providerSessionId, | |
| 2106 | cause, | |
| 2107 | }), | |
| 2108 | ); | |
| 2109 | } | |
| 2110 | }), | |
| 2111 | ), | |
| 2112 | ), | |
| 2113 | Effect.forkIn(scope), | |
| 2114 | ); | |
| 2115 | ||
| 2116 | // Discovery can invoke extension code and therefore raise a blocking | |
| 2117 | // UI request. Start it only after the event pump exists, and never hold | |
| 2118 | // session opening on it; startup requests are persisted at session | |
| 2119 | // scope and can be answered before a turn begins. | |
| 2120 | yield* discoverSkillNames.pipe( | |
| 2121 | Effect.tap((discovered) => Effect.sync(() => (skillNames = discovered))), | |
| 2122 | Effect.ignore, | |
| 2123 | Effect.forkIn(scope), | |
| 2124 | ); | |
| 2125 | ||
| 2126 | // ── session runtime ─────────────────────────────────── | |
| 2127 | ||
| 2128 | const registerThread = Effect.fnUntraced(function* ( | |
| 2129 | threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, | |
| 2130 | publish = true, | |
| 2131 | ) { | |
| 2132 | if (rollbackBarrier !== null) | |
| 2133 | return yield* protocolError("Cannot register a Pi thread during rollback"); | |
| 2134 | if (threadState !== null && threadState.activeTurn !== null) { | |
| 2135 | return yield* protocolError("Cannot register a Pi thread while a turn is active"); | |
| 2136 | } | |
| 2137 | if (pendingWake !== null) { | |
| 2138 | const state = threadState; | |
| 2139 | if ( | |
| 2140 | state !== null && | |
| 2141 | state === pendingWake.state && | |
| 2142 | state.providerThread.appThreadId === threadInput.threadId && | |
| 2143 | (threadInput.existingProviderThread === undefined || | |
| 2144 | threadInput.existingProviderThread.nativeThreadRef?.nativeId === | |
| 2145 | state.providerThread.nativeThreadRef?.nativeId) | |
| 2146 | ) | |
| 2147 | return state.providerThread; | |
| 2148 | return yield* protocolError( | |
| 2149 | "Cannot switch Pi threads while native work is awaiting a turn", | |
| 2150 | ); | |
| 2151 | } | |
| 2152 | const existing = threadInput.existingProviderThread; | |
| 2153 | const resumeId = existing?.nativeThreadRef?.nativeId; | |
| 2154 | const needsNewSession = resumeId == null && registrationAttempted; | |
| 2155 | registrationAttempted = true; | |
| 2156 | if (resumeId != null || needsNewSession) { | |
| 2157 | lastNativeThreadId = resumeId ?? lastNativeThreadId; | |
| 2158 | // Even a failed lifecycle operation can change Pi's native session. | |
| 2159 | // Never leave the old app binding or model defaults usable afterward. | |
| 2160 | threadState = null; | |
| 2161 | appliedModel = null; | |
| 2162 | appliedThinking = null; | |
| 2163 | appliedSessionName = null; | |
| 2164 | baselineModel = null; | |
| 2165 | baselineThinking = null; | |
| 2166 | contextWindow = null; | |
| 2167 | const result = yield* lifecycleRequest( | |
| 2168 | resumeId != null | |
| 2169 | ? { type: "switch_session", sessionPath: resumeId } | |
| 2170 | : { type: "new_session" }, | |
| 2171 | ); | |
| 2172 | if (recordField(result, "cancelled") === true) { | |
| 2173 | return yield* protocolError("A Pi extension cancelled the session switch"); | |
| 2174 | } | |
| 2175 | } | |
| 2176 | const stateData = yield* request({ type: "get_state" }); | |
| 2177 | if (!modelsDiscovered) { | |
| 2178 | const modelsData = yield* request({ type: "get_available_models" }).pipe( | |
| 2179 | Effect.orElseSucceed(() => undefined), | |
| 2180 | ); | |
| 2181 | const models = recordField(modelsData, "models"); | |
| 2182 | if (Array.isArray(models)) { | |
| 2183 | for (const model of models) rememberModelContextWindow(model); | |
| 2184 | modelsDiscovered = true; | |
| 2185 | } | |
| 2186 | } | |
| 2187 | contextWindow = rememberModelContextWindow(recordField(stateData, "model")); | |
| 2188 | // Each baseline is captured independently, and only while nothing has | |
| 2189 | // been applied yet, so a `get_state` that arrives after our own | |
| 2190 | // selection cannot record that selection as Pi's default. | |
| 2191 | if (baselineModel === null && appliedModel === null) { | |
| 2192 | const stateModel = recordField(stateData, "model"); | |
| 2193 | const provider = recordString(stateModel, "provider"); | |
| 2194 | const modelId = recordString(stateModel, "id"); | |
| 2195 | if (provider !== undefined && modelId !== undefined) { | |
| 2196 | baselineModel = { provider, modelId }; | |
| 2197 | } | |
| 2198 | } | |
| 2199 | if (baselineThinking === null && appliedThinking === null) { | |
| 2200 | baselineThinking = recordString(stateData, "thinkingLevel") ?? null; | |
| 2201 | } | |
| 2202 | // switch_session accepts a file path, not Pi's display session UUID. | |
| 2203 | const nativeId = recordString(stateData, "sessionFile"); | |
| 2204 | if (nativeId === undefined) { | |
| 2205 | return yield* protocolError("get_state returned no persisted sessionFile", stateData); | |
| 2206 | } | |
| 2207 | if (needsNewSession && nativeId === lastNativeThreadId) { | |
| 2208 | return yield* protocolError("Pi did not create a distinct session file"); | |
| 2209 | } | |
| 2210 | lastNativeThreadId = nativeId; | |
| 2211 | const createdAt = yield* DateTime.now; | |
| 2212 | const providerThread: OrchestrationV2ProviderThread = | |
| 2213 | existing !== undefined | |
| 2214 | ? { | |
| 2215 | ...existing, | |
| 2216 | providerSessionId: input.providerSessionId, | |
| 2217 | nativeThreadRef: providerRef(nativeId), | |
| 2218 | ...(needsNewSession ? { nativeConversationHeadRef: null, contextUsage: null } : {}), | |
| 2219 | status: "idle", | |
| 2220 | updatedAt: createdAt, | |
| 2221 | } | |
| 2222 | : { | |
| 2223 | id: idAllocator.derive.providerThread({ | |
| 2224 | driver: PI_PROVIDER, | |
| 2225 | nativeThreadId: nativeId, | |
| 2226 | }), | |
| 2227 | driver: PI_PROVIDER, | |
| 2228 | providerInstanceId: options.instanceId, | |
| 2229 | providerSessionId: input.providerSessionId, | |
| 2230 | appThreadId: threadInput.threadId, | |
| 2231 | ownerNodeId: null, | |
| 2232 | nativeThreadRef: providerRef(nativeId), | |
| 2233 | nativeConversationHeadRef: null, | |
| 2234 | status: "idle", | |
| 2235 | firstRunOrdinal: null, | |
| 2236 | lastRunOrdinal: null, | |
| 2237 | handoffIds: [], | |
| 2238 | forkedFrom: null, | |
| 2239 | pendingBackgroundTasks: [], | |
| 2240 | createdAt, | |
| 2241 | updatedAt: createdAt, | |
| 2242 | }; | |
| 2243 | threadState = { providerThread, activeTurn: null }; | |
| 2244 | // Baseline the session-tree leaf so the first turn's user entry can | |
| 2245 | // be located with a `since` cursor instead of a full entry scan. | |
| 2246 | const baselineEntries = yield* request({ type: "get_entries" }).pipe( | |
| 2247 | Effect.orElseSucceed(() => undefined), | |
| 2248 | ); | |
| 2249 | lastKnownLeaf = recordString(baselineEntries, "leafId") ?? null; | |
| 2250 | // A successful full baseline makes the cursor trustworthy again. Only | |
| 2251 | // a failed one leaves it stale, so a recovered session does not keep | |
| 2252 | // skipping turn refs. An empty tree is a success with no leafId. | |
| 2253 | leafCursorStale = baselineEntries === undefined; | |
| 2254 | if (publish) | |
| 2255 | yield* emit({ | |
| 2256 | type: "provider_thread.updated", | |
| 2257 | driver: PI_PROVIDER, | |
| 2258 | providerThread, | |
| 2259 | }); | |
| 2260 | return providerThread; | |
| 2261 | }); | |
| 2262 | ||
| 2263 | const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { | |
| 2264 | const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); | |
| 2265 | if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { | |
| 2266 | // Returning to "Pi default" after an explicit pick has to replay the | |
| 2267 | // captured baseline, otherwise Pi stays on the last model applied. | |
| 2268 | if (appliedModel !== null && baselineModel !== null) { | |
| 2269 | const restoredModel = yield* request({ type: "set_model", ...baselineModel }); | |
| 2270 | contextWindow = rememberModelContextWindow(restoredModel); | |
| 2271 | appliedModel = null; | |
| 2272 | const updatedAt = yield* DateTime.now; | |
| 2273 | sessionEntity = { ...sessionEntity, model: PI_INHERIT_MODEL_SLUG, updatedAt }; | |
| 2274 | yield* emit({ | |
| 2275 | type: "provider_session.updated", | |
| 2276 | driver: PI_PROVIDER, | |
| 2277 | providerSession: sessionEntity, | |
| 2278 | }); | |
| 2279 | } | |
| 2280 | // The "Pi default" model advertises no thinking choices of its own, | |
| 2281 | // so an unqualified return also restores Pi's configured level | |
| 2282 | // instead of silently keeping the effort a previous pick applied. | |
| 2283 | if ( | |
| 2284 | thinking === undefined && | |
| 2285 | appliedThinking !== null && | |
| 2286 | baselineThinking !== null && | |
| 2287 | appliedThinking !== baselineThinking | |
| 2288 | ) { | |
| 2289 | yield* request({ type: "set_thinking_level", level: baselineThinking }); | |
| 2290 | appliedThinking = null; | |
| 2291 | } | |
| 2292 | } else if (modelSelection.model !== appliedModel) { | |
| 2293 | const parsed = parsePiModelSlug(modelSelection.model); | |
| 2294 | if (parsed === null) { | |
| 2295 | return yield* protocolError( | |
| 2296 | `Pi model '${modelSelection.model}' must use provider/model format`, | |
| 2297 | ); | |
| 2298 | } | |
| 2299 | const selectedModel = yield* request({ | |
| 2300 | type: "set_model", | |
| 2301 | provider: parsed.provider, | |
| 2302 | modelId: parsed.modelId, | |
| 2303 | }); | |
| 2304 | contextWindow = rememberModelContextWindow(selectedModel); | |
| 2305 | appliedModel = modelSelection.model; | |
| 2306 | const updatedAt = yield* DateTime.now; | |
| 2307 | sessionEntity = { ...sessionEntity, model: modelSelection.model, updatedAt }; | |
| 2308 | yield* emit({ | |
| 2309 | type: "provider_session.updated", | |
| 2310 | driver: PI_PROVIDER, | |
| 2311 | providerSession: sessionEntity, | |
| 2312 | }); | |
| 2313 | } | |
| 2314 | if ( | |
| 2315 | thinking !== undefined && | |
| 2316 | thinking !== appliedThinking && | |
| 2317 | PI_THINKING_LEVELS.has(thinking) | |
| 2318 | ) { | |
| 2319 | yield* request({ type: "set_thinking_level", level: thinking }); | |
| 2320 | appliedThinking = thinking; | |
| 2321 | } | |
| 2322 | }); | |
| 2323 | ||
| 2324 | const resolvePromptPayload = Effect.fnUntraced(function* ( | |
| 2325 | text: string, | |
| 2326 | attachments: ReadonlyArray<ChatAttachment>, | |
| 2327 | ) { | |
| 2328 | // Provider discovery and the live session are separate Pi processes. | |
| 2329 | // Retry a failed session-local lookup once at first use so a transient | |
| 2330 | // startup failure cannot leave a visible $ skill inert for this session. | |
| 2331 | if (skillNames === null && text.includes("$")) { | |
| 2332 | skillNames = yield* discoverSkillNames.pipe( | |
| 2333 | Effect.orElseSucceed(() => new Set<string>()), | |
| 2334 | ); | |
| 2335 | } | |
| 2336 | const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); | |
| 2337 | const images: Array<{ type: "image"; data: string; mimeType: string }> = []; | |
| 2338 | const extraLines: Array<string> = []; | |
| 2339 | for (const attachment of attachments) { | |
| 2340 | const path = options.host.resolveAttachmentPath(attachment); | |
| 2341 | if (path === null) continue; | |
| 2342 | if (attachment.mimeType.startsWith("image/")) { | |
| 2343 | const bytes = yield* options.fileSystem.readFile(path); | |
| 2344 | images.push({ | |
| 2345 | type: "image", | |
| 2346 | data: Buffer.from(bytes).toString("base64"), | |
| 2347 | mimeType: attachment.mimeType, | |
| 2348 | }); | |
| 2349 | } else { | |
| 2350 | extraLines.push(`[Attachment saved at ${path}]`); | |
| 2351 | } | |
| 2352 | } | |
| 2353 | const message = | |
| 2354 | extraLines.length === 0 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; | |
| 2355 | return { message, images }; | |
| 2356 | }); | |
| 2357 | ||
| 2358 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 2359 | instanceId: options.instanceId, | |
| 2360 | driver: PI_PROVIDER, | |
| 2361 | providerSessionId: input.providerSessionId, | |
| 2362 | get providerSession() { | |
| 2363 | return sessionEntity; | |
| 2364 | }, | |
| 2365 | events: Stream.fromQueue(events), | |
| 2366 | hasPendingBackgroundWork: Effect.sync(() => pendingWake !== null), | |
| 2367 | hasPendingBackgroundWorkForThread: (providerThread) => | |
| 2368 | Effect.sync( | |
| 2369 | () => pendingWake !== null && pendingWake.state.providerThread.id === providerThread.id, | |
| 2370 | ), | |
| 2371 | getModelContextWindow: (selection) => { | |
| 2372 | if (selection.instanceId !== options.instanceId) return undefined; | |
| 2373 | const slug = | |
| 2374 | selection.model === PI_INHERIT_MODEL_SLUG | |
| 2375 | ? baselineModel === null | |
| 2376 | ? undefined | |
| 2377 | : `${baselineModel.provider}/${baselineModel.modelId}` | |
| 2378 | : selection.model; | |
| 2379 | return slug === undefined ? undefined : modelContextWindows.get(slug); | |
| 2380 | }, | |
| 2381 | ensureThread: (threadInput) => | |
| 2382 | registerThread(threadInput).pipe( | |
| 2383 | Effect.mapError( | |
| 2384 | (cause) => | |
| 2385 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 2386 | driver: PI_PROVIDER, | |
| 2387 | threadId: threadInput.threadId, | |
| 2388 | cause, | |
| 2389 | }), | |
| 2390 | ), | |
| 2391 | ), | |
| 2392 | resumeThread: (threadInput) => | |
| 2393 | registerThread({ | |
| 2394 | threadId: | |
| 2395 | threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, | |
| 2396 | modelSelection: threadInput.modelSelection ?? input.modelSelection, | |
| 2397 | runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, | |
| 2398 | existingProviderThread: threadInput.providerThread, | |
| 2399 | }).pipe( | |
| 2400 | Effect.mapError( | |
| 2401 | (cause) => | |
| 2402 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 2403 | driver: PI_PROVIDER, | |
| 2404 | providerSessionId: input.providerSessionId, | |
| 2405 | providerThreadId: threadInput.providerThread.id, | |
| 2406 | cause, | |
| 2407 | }), | |
| 2408 | ), | |
| 2409 | ), | |
| 2410 | // The run executor routes a bare `/compact` here instead of startTurn. | |
| 2411 | // Pi's start path already turns that text into the RPC compact call. | |
| 2412 | compactThread: (turnInput) => | |
| 2413 | runtime.startTurn({ | |
| 2414 | ...turnInput, | |
| 2415 | message: { ...turnInput.message, text: "/compact" }, | |
| 2416 | }), | |
| 2417 | startTurn: (turnInput) => | |
| 2418 | Effect.gen(function* () { | |
| 2419 | if (rollbackBarrier !== null) | |
| 2420 | return yield* protocolError("Cannot start a Pi turn during rollback"); | |
| 2421 | const state = threadState; | |
| 2422 | if (state === null) { | |
| 2423 | return yield* protocolError("Pi session has no registered thread"); | |
| 2424 | } | |
| 2425 | if (state.activeTurn !== null) { | |
| 2426 | return yield* protocolError( | |
| 2427 | `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, | |
| 2428 | ); | |
| 2429 | } | |
| 2430 | if ( | |
| 2431 | state.providerThread.nativeThreadRef?.nativeId !== | |
| 2432 | turnInput.providerThread.nativeThreadRef?.nativeId | |
| 2433 | ) { | |
| 2434 | return yield* protocolError("Pi turn requested for a different native session"); | |
| 2435 | } | |
| 2436 | // The orchestrator adopts a fork under its already-allocated row. | |
| 2437 | // Future session updates must retain that authoritative identity. | |
| 2438 | const continuation = | |
| 2439 | turnInput.message.createdBy === "agent" && | |
| 2440 | turnInput.message.creationSource === "provider"; | |
| 2441 | if (!continuation) yield* applySelection(turnInput.modelSelection); | |
| 2442 | // Mirror the thread title into pi's session name so the session | |
| 2443 | // stays identifiable in pi's own /resume listing. Best-effort: | |
| 2444 | // naming must never block a turn. | |
| 2445 | if (turnInput.appThread.title !== appliedSessionName) { | |
| 2446 | yield* request({ | |
| 2447 | type: "set_session_name", | |
| 2448 | name: turnInput.appThread.title, | |
| 2449 | }).pipe( | |
| 2450 | Effect.tap(() => | |
| 2451 | Effect.sync(() => (appliedSessionName = turnInput.appThread.title)), | |
| 2452 | ), | |
| 2453 | Effect.ignore, | |
| 2454 | ); | |
| 2455 | } | |
| 2456 | // Resolved before the turn is installed: a failure here (an | |
| 2457 | // unreadable attachment) must not leave `activeTurn` set, which | |
| 2458 | // would reject every later turn as already active. | |
| 2459 | // Orchestration instructions reach pi through the T3 MCP | |
| 2460 | // extension's before_agent_start system-prompt hook, never by | |
| 2461 | // wrapping the user text: a wrapped first message would no | |
| 2462 | // longer start with "/" and slash commands would stop expanding. | |
| 2463 | const compactCommand = continuation | |
| 2464 | ? null | |
| 2465 | : parsePiCompactCommand(turnInput.message.text); | |
| 2466 | const payload = | |
| 2467 | !continuation && compactCommand === null | |
| 2468 | ? yield* resolvePromptPayload(turnInput.message.text, turnInput.message.attachments) | |
| 2469 | : null; | |
| 2470 | const startedAt = yield* DateTime.now; | |
| 2471 | const syntheticNativeTurnId = `${state.providerThread.id}:attempt:${turnInput.attemptId}`; | |
| 2472 | const providerTurn: OrchestrationV2ProviderTurn = { | |
| 2473 | id: idAllocator.derive.providerTurn({ | |
| 2474 | driver: PI_PROVIDER, | |
| 2475 | nativeTurnId: syntheticNativeTurnId, | |
| 2476 | }), | |
| 2477 | providerThreadId: turnInput.providerThread.id, | |
| 2478 | nodeId: turnInput.rootNodeId, | |
| 2479 | runAttemptId: turnInput.attemptId, | |
| 2480 | nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"), | |
| 2481 | ordinal: turnInput.providerTurnOrdinal, | |
| 2482 | status: "running", | |
| 2483 | startedAt, | |
| 2484 | completedAt: null, | |
| 2485 | }; | |
| 2486 | const activeTurn: ActivePiTurn = { | |
| 2487 | turnInput, | |
| 2488 | providerTurn, | |
| 2489 | startedAt, | |
| 2490 | itemOrdinals: new Map(), | |
| 2491 | nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, | |
| 2492 | messageOrdinal: 0, | |
| 2493 | streamItems: new Map(), | |
| 2494 | toolArgs: new Map(), | |
| 2495 | toolStartedAt: new Map(), | |
| 2496 | interrupted: false, | |
| 2497 | sawAgentActivity: false, | |
| 2498 | adoptedWake: false, | |
| 2499 | promptMayBeCommandOnly: | |
| 2500 | compactCommand !== null || (payload?.message.trimStart().startsWith("/") ?? false), | |
| 2501 | latestCompactionAfterTokens: null, | |
| 2502 | lastLiveUsedTokens: null, | |
| 2503 | settleProbeGeneration: 0, | |
| 2504 | settleWhenIdle: false, | |
| 2505 | sawCompaction: false, | |
| 2506 | manualCompactInFlight: compactCommand !== null, | |
| 2507 | activeCompaction: null, | |
| 2508 | activeProviderRetry: null, | |
| 2509 | failure: null, | |
| 2510 | rejectedPromptFailure: null, | |
| 2511 | }; | |
| 2512 | // Only the install/send/start-event boundary excludes the event | |
| 2513 | // pump. Earlier correlated requests must leave the pump free so | |
| 2514 | // project trust, login, and session-switch dialogs can be shown | |
| 2515 | // and answered instead of deadlocking the caller. | |
| 2516 | yield* Effect.gen(function* () { | |
| 2517 | if ( | |
| 2518 | threadState !== state || | |
| 2519 | state.activeTurn !== null || | |
| 2520 | closed || | |
| 2521 | stopRequested || | |
| 2522 | rollbackBarrier !== null || | |
| 2523 | state.providerThread.nativeThreadRef?.nativeId !== | |
| 2524 | turnInput.providerThread.nativeThreadRef?.nativeId | |
| 2525 | ) { | |
| 2526 | return yield* protocolError("Pi turn ownership changed before it could start"); | |
| 2527 | } | |
| 2528 | const wake = pendingWake?.state === state ? pendingWake : null; | |
| 2529 | if (wake !== null && wake.state.providerThread.appThreadId !== turnInput.threadId) { | |
| 2530 | return yield* protocolError("Pi native wake belongs to a different app thread"); | |
| 2531 | } | |
| 2532 | pendingWake = null; | |
| 2533 | state.providerThread = turnInput.providerThread; | |
| 2534 | state.activeTurn = activeTurn; | |
| 2535 | activeTurn.adoptedWake = wake !== null && !continuation; | |
| 2536 | if (compactCommand !== null) { | |
| 2537 | yield* connection.send(compactRpcRecord(compactCommand)); | |
| 2538 | pendingCompactResponses.push({ | |
| 2539 | providerTurnId: providerTurn.id, | |
| 2540 | kind: "turn_start", | |
| 2541 | }); | |
| 2542 | } else if (payload !== null) { | |
| 2543 | yield* connection.send({ | |
| 2544 | type: "prompt", | |
| 2545 | message: payload.message, | |
| 2546 | ...(wake === null ? {} : { streamingBehavior: "steer" }), | |
| 2547 | ...(payload.images.length === 0 ? {} : { images: payload.images }), | |
| 2548 | }); | |
| 2549 | pendingPromptResponses.push({ | |
| 2550 | providerTurnId: providerTurn.id, | |
| 2551 | kind: "turn_start", | |
| 2552 | }); | |
| 2553 | } | |
| 2554 | yield* emit({ | |
| 2555 | type: "provider_turn.updated", | |
| 2556 | driver: PI_PROVIDER, | |
| 2557 | threadId: turnInput.threadId, | |
| 2558 | providerTurn, | |
| 2559 | }); | |
| 2560 | yield* updateProviderThread(state, { | |
| 2561 | status: "active", | |
| 2562 | firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, | |
| 2563 | lastRunOrdinal: turnInput.runOrdinal, | |
| 2564 | }); | |
| 2565 | yield* updateProviderSession("running", null); | |
| 2566 | if (wake !== null) { | |
| 2567 | for (const event of wake.events) { | |
| 2568 | if (!continuation && event["type"] === "agent_settled") { | |
| 2569 | activeTurn.settleWhenIdle = true; | |
| 2570 | continue; | |
| 2571 | } | |
| 2572 | yield* handleSessionEvent(event); | |
| 2573 | } | |
| 2574 | } else if (continuation) { | |
| 2575 | // A user turn, teardown, or newer binding already took the wake. | |
| 2576 | yield* finalizeTurn(state); | |
| 2577 | } | |
| 2578 | if (outOfTurnExtensionErrors.length > 0) { | |
| 2579 | yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); | |
| 2580 | } | |
| 2581 | }).pipe( | |
| 2582 | sessionEventPermit.withPermits(1), | |
| 2583 | Effect.tapError(() => | |
| 2584 | Effect.sync(() => { | |
| 2585 | if (state.activeTurn === activeTurn) state.activeTurn = null; | |
| 2586 | }), | |
| 2587 | ), | |
| 2588 | ); | |
| 2589 | // Pi acks `prompt` only after slash-command expansion completes, | |
| 2590 | // and extension commands may block on user dialogs indefinitely. | |
| 2591 | // Rejections therefore return later as id-less response records | |
| 2592 | // handled by the event pump. | |
| 2593 | }).pipe( | |
| 2594 | Effect.mapError( | |
| 2595 | (cause) => | |
| 2596 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 2597 | driver: PI_PROVIDER, | |
| 2598 | threadId: turnInput.threadId, | |
| 2599 | providerThreadId: turnInput.providerThread.id, | |
| 2600 | runId: turnInput.runId, | |
| 2601 | cause, | |
| 2602 | }), | |
| 2603 | ), | |
| 2604 | ), | |
| 2605 | steerTurn: (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) => | |
| 2606 | Effect.gen(function* () { | |
| 2607 | const turn = threadState?.activeTurn ?? null; | |
| 2608 | if (turn === null || turn.providerTurn.id !== steerInput.providerTurnId) { | |
| 2609 | return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); | |
| 2610 | } | |
| 2611 | const compactCommand = parsePiCompactCommand(steerInput.message.text); | |
| 2612 | const payload = | |
| 2613 | compactCommand === null | |
| 2614 | ? yield* resolvePromptPayload( | |
| 2615 | steerInput.message.text, | |
| 2616 | steerInput.message.attachments, | |
| 2617 | ) | |
| 2618 | : null; | |
| 2619 | // Prompt with streamingBehavior steer is atomic on Pi's side: it | |
| 2620 | // queues during an active run and starts a new run if settlement | |
| 2621 | // won the race. A direct `steer` sent after Pi became idle would | |
| 2622 | // remain queued forever. Send fire-and-forget under the session | |
| 2623 | // permit so a slash-command dialog cannot block the turn, and so | |
| 2624 | // settlement cannot overtake the active-turn check. | |
| 2625 | // /compact is not a prompt: Pi's compact RPC aborts the agent first. | |
| 2626 | yield* sessionEventPermit.withPermits(1)( | |
| 2627 | Effect.gen(function* () { | |
| 2628 | if (threadState?.activeTurn !== turn) { | |
| 2629 | return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); | |
| 2630 | } | |
| 2631 | if (compactCommand !== null) { | |
| 2632 | turn.manualCompactInFlight = true; | |
| 2633 | yield* connection.send(compactRpcRecord(compactCommand)); | |
| 2634 | pendingCompactResponses.push({ | |
| 2635 | providerTurnId: turn.providerTurn.id, | |
| 2636 | kind: "steer", | |
| 2637 | }); | |
| 2638 | } else if (payload !== null) { | |
| 2639 | yield* connection.send({ | |
| 2640 | type: "prompt", | |
| 2641 | message: payload.message, | |
| 2642 | streamingBehavior: "steer", | |
| 2643 | ...(payload.images.length === 0 ? {} : { images: payload.images }), | |
| 2644 | }); | |
| 2645 | pendingPromptResponses.push({ | |
| 2646 | providerTurnId: turn.providerTurn.id, | |
| 2647 | kind: "steer", | |
| 2648 | }); | |
| 2649 | } | |
| 2650 | turn.settleProbeGeneration += 1; | |
| 2651 | }), | |
| 2652 | ); | |
| 2653 | }).pipe( | |
| 2654 | Effect.mapError( | |
| 2655 | (cause) => | |
| 2656 | new ProviderAdapter.ProviderAdapterSteerRunError({ | |
| 2657 | driver: PI_PROVIDER, | |
| 2658 | providerThreadId: steerInput.providerThread.id, | |
| 2659 | providerTurnId: steerInput.providerTurnId, | |
| 2660 | cause, | |
| 2661 | }), | |
| 2662 | ), | |
| 2663 | ), | |
| 2664 | interruptTurn: (interruptInput) => | |
| 2665 | Effect.gen(function* () { | |
| 2666 | const turn = threadState?.activeTurn ?? null; | |
| 2667 | // Stop on a settled turn: Pi runs nothing between prompts, so | |
| 2668 | // nothing of that turn is left to stop. | |
| 2669 | if (turn === null && interruptInput.requestRuntimeRestart === true) { | |
| 2670 | if (pendingWake !== null) | |
| 2671 | yield* sessionEventPermit.withPermits(1)(stopPendingWake(pendingWake)); | |
| 2672 | return; | |
| 2673 | } | |
| 2674 | if (turn === null || turn.providerTurn.id !== interruptInput.providerTurnId) { | |
| 2675 | return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); | |
| 2676 | } | |
| 2677 | turn.interrupted = true; | |
| 2678 | if ( | |
| 2679 | interruptInput.requestRuntimeRestart === true || | |
| 2680 | turn.settleWhenIdle || | |
| 2681 | turn.activeCompaction !== null || | |
| 2682 | turn.manualCompactInFlight | |
| 2683 | ) { | |
| 2684 | // Pi's generic abort does not cancel manual compaction. Terminate | |
| 2685 | // so Stop covers user /compact as well as detached recovery compact. | |
| 2686 | stopRequested = true; | |
| 2687 | if (interruptInput.requestRuntimeRestart === true && !turn.settleWhenIdle) { | |
| 2688 | yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); | |
| 2689 | } | |
| 2690 | // Terminating fails every later request, so read the stopped | |
| 2691 | // turn's session-tree refs first: rolling back past this turn | |
| 2692 | // forks at its user entry. Holding the event permit also lets a | |
| 2693 | // finalize that is already reading them finish before the kill. | |
| 2694 | yield* sessionEventPermit.withPermits(1)( | |
| 2695 | Effect.gen(function* () { | |
| 2696 | if (threadState?.activeTurn === turn && turn.stopTreeRefs === undefined) { | |
| 2697 | turn.stopTreeRefs = yield* captureTurnTreeRefs(2_000); | |
| 2698 | } | |
| 2699 | yield* connection.terminate; | |
| 2700 | }), | |
| 2701 | ); | |
| 2702 | return; | |
| 2703 | } | |
| 2704 | yield* request({ type: "abort" }).pipe( | |
| 2705 | Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), | |
| 2706 | ); | |
| 2707 | }).pipe( | |
| 2708 | Effect.mapError( | |
| 2709 | (cause) => | |
| 2710 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 2711 | driver: PI_PROVIDER, | |
| 2712 | providerThreadId: interruptInput.providerThread.id, | |
| 2713 | providerTurnId: interruptInput.providerTurnId, | |
| 2714 | cause, | |
| 2715 | }), | |
| 2716 | ), | |
| 2717 | ), | |
| 2718 | respondToRuntimeRequest: (requestInput) => | |
| 2719 | Effect.gen(function* () { | |
| 2720 | const pending = pendingPrompts.get(String(requestInput.requestId)); | |
| 2721 | if (pending === undefined) { | |
| 2722 | return yield* protocolError( | |
| 2723 | `No pending Pi extension request ${requestInput.requestId}`, | |
| 2724 | ); | |
| 2725 | } | |
| 2726 | const response = piUiResponse(pending, requestInput.decision, requestInput.answers); | |
| 2727 | yield* connection.send({ | |
| 2728 | type: "extension_ui_response", | |
| 2729 | id: pending.nativeRequestId, | |
| 2730 | ...response, | |
| 2731 | }); | |
| 2732 | // Dropped only once Pi has the answer, so a failed send leaves the | |
| 2733 | // request retryable and still cancellable during teardown. | |
| 2734 | pendingPrompts.delete(String(requestInput.requestId)); | |
| 2735 | if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { | |
| 2736 | sessionApprovals.add(pending.approvalKey); | |
| 2737 | } | |
| 2738 | const resolvedAt = yield* DateTime.now; | |
| 2739 | pending.runtimeRequest = { | |
| 2740 | ...pending.runtimeRequest, | |
| 2741 | status: "resolved", | |
| 2742 | resolvedAt, | |
| 2743 | }; | |
| 2744 | yield* emit({ | |
| 2745 | type: "runtime_request.updated", | |
| 2746 | driver: PI_PROVIDER, | |
| 2747 | threadId: pending.node.threadId, | |
| 2748 | runtimeRequest: pending.runtimeRequest, | |
| 2749 | }); | |
| 2750 | yield* emit({ | |
| 2751 | type: "node.updated", | |
| 2752 | driver: PI_PROVIDER, | |
| 2753 | node: { ...pending.node, status: "completed", completedAt: resolvedAt }, | |
| 2754 | }); | |
| 2755 | yield* emit({ | |
| 2756 | type: "turn_item.updated", | |
| 2757 | driver: PI_PROVIDER, | |
| 2758 | turnItem: { | |
| 2759 | ...pending.turnItem, | |
| 2760 | status: "completed", | |
| 2761 | completedAt: resolvedAt, | |
| 2762 | updatedAt: resolvedAt, | |
| 2763 | }, | |
| 2764 | }); | |
| 2765 | }).pipe( | |
| 2766 | sessionEventPermit.withPermits(1), | |
| 2767 | Effect.mapError( | |
| 2768 | (cause) => | |
| 2769 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 2770 | driver: PI_PROVIDER, | |
| 2771 | requestId: requestInput.requestId, | |
| 2772 | cause, | |
| 2773 | }), | |
| 2774 | ), | |
| 2775 | ), | |
| 2776 | readThreadSnapshot: (snapshotInput) => | |
| 2777 | Effect.gen(function* () { | |
| 2778 | const state = threadState; | |
| 2779 | const boundNativeId = state?.providerThread.nativeThreadRef?.nativeId; | |
| 2780 | const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; | |
| 2781 | if (state === null || wantedNativeId == null || boundNativeId !== wantedNativeId) { | |
| 2782 | return yield* protocolError( | |
| 2783 | "Pi snapshot requested for a thread this session does not host", | |
| 2784 | ); | |
| 2785 | } | |
| 2786 | // get_messages is Pi's active-branch view. get_entries returns the | |
| 2787 | // whole session tree, including abandoned branches after /tree or | |
| 2788 | // fork, which would leak discarded conversation into handoffs. | |
| 2789 | const messagesData = yield* request({ type: "get_messages" }); | |
| 2790 | const activeMessages = recordField(messagesData, "messages"); | |
| 2791 | const threadId = state.providerThread.appThreadId ?? input.threadId; | |
| 2792 | const messages = (Array.isArray(activeMessages) ? activeMessages : []).flatMap( | |
| 2793 | (message, index) => { | |
| 2794 | const role = recordString(message, "role"); | |
| 2795 | if (role !== "user" && role !== "assistant") return []; | |
| 2796 | const text = contentText(recordField(message, "content")); | |
| 2797 | if (text.length === 0) return []; | |
| 2798 | const timestamp = recordNumber(message, "timestamp"); | |
| 2799 | const at = Option.getOrElse( | |
| 2800 | DateTime.make(timestamp ?? Number.NaN), | |
| 2801 | () => state.providerThread.createdAt, | |
| 2802 | ); | |
| 2803 | return [ | |
| 2804 | { | |
| 2805 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2806 | driver: PI_PROVIDER, | |
| 2807 | // RPC messages do not expose session-tree entry ids. The | |
| 2808 | // active-branch index is stable for the lifetime of this | |
| 2809 | // snapshot and keeps abandoned branch ids out of it. | |
| 2810 | nativeItemId: `${wantedNativeId}:snapshot-message:${index}`, | |
| 2811 | }), | |
| 2812 | threadId, | |
| 2813 | runId: null, | |
| 2814 | nodeId: null, | |
| 2815 | role: role as "user" | "assistant", | |
| 2816 | text, | |
| 2817 | attachments: [], | |
| 2818 | streaming: false, | |
| 2819 | createdBy: role === "user" ? ("user" as const) : ("agent" as const), | |
| 2820 | creationSource: "provider" as const, | |
| 2821 | createdAt: at, | |
| 2822 | updatedAt: at, | |
| 2823 | }, | |
| 2824 | ]; | |
| 2825 | }, | |
| 2826 | ); | |
| 2827 | return { | |
| 2828 | providerThread: state.providerThread, | |
| 2829 | providerTurns: [], | |
| 2830 | messages, | |
| 2831 | runtimeRequests: [], | |
| 2832 | }; | |
| 2833 | }).pipe( | |
| 2834 | Effect.mapError( | |
| 2835 | (cause) => | |
| 2836 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 2837 | driver: PI_PROVIDER, | |
| 2838 | providerThreadId: snapshotInput.providerThread.id, | |
| 2839 | cause, | |
| 2840 | }), | |
| 2841 | ), | |
| 2842 | ), | |
| 2843 | rollbackThread: (rollbackInput) => | |
| 2844 | Effect.gen(function* () { | |
| 2845 | const state = threadState; | |
| 2846 | if (state === null) { | |
| 2847 | return yield* protocolError("Pi session has no registered thread"); | |
| 2848 | } | |
| 2849 | if (state.providerThread.id !== rollbackInput.providerThread.id) { | |
| 2850 | return yield* protocolError( | |
| 2851 | "Pi rollback requested for a thread this session does not host", | |
| 2852 | ); | |
| 2853 | } | |
| 2854 | if (state.activeTurn !== null) { | |
| 2855 | return yield* protocolError("Cannot roll back while a Pi turn is active"); | |
| 2856 | } | |
| 2857 | if (pendingWake !== null) { | |
| 2858 | return yield* protocolError("Cannot roll back while native Pi work awaits a turn"); | |
| 2859 | } | |
| 2860 | // `fork(entryId)` re-roots the active branch before that user | |
| 2861 | // message, so the rollback boundary is the first user entry of | |
| 2862 | // the earliest turn being discarded. | |
| 2863 | const forkEntryId = piRollbackForkEntry(rollbackInput); | |
| 2864 | if (forkEntryId === null) { | |
| 2865 | // Nothing after the target: the conversation is already there. | |
| 2866 | return piThreadSnapshot(state.providerThread); | |
| 2867 | } | |
| 2868 | if (forkEntryId === undefined) { | |
| 2869 | return yield* protocolError("Pi rollback target has no captured session-tree entry"); | |
| 2870 | } | |
| 2871 | return yield* Effect.acquireUseRelease( | |
| 2872 | sessionEventPermit.withPermits(1)( | |
| 2873 | Effect.gen(function* () { | |
| 2874 | if ( | |
| 2875 | rollbackBarrier !== null || | |
| 2876 | threadState !== state || | |
| 2877 | state.activeTurn !== null || | |
| 2878 | pendingWake !== null || | |
| 2879 | closed || | |
| 2880 | stopRequested | |
| 2881 | ) | |
| 2882 | return yield* protocolError( | |
| 2883 | "Pi rollback ownership changed before its fork could start", | |
| 2884 | ); | |
| 2885 | const barrier = yield* Deferred.make< | |
| 2886 | void, | |
| 2887 | ProviderAdapter.ProviderAdapterProtocolError | |
| 2888 | >(); | |
| 2889 | rollbackBarrier = barrier; | |
| 2890 | return barrier; | |
| 2891 | }), | |
| 2892 | ), | |
| 2893 | (barrier) => | |
| 2894 | Effect.gen(function* () { | |
| 2895 | const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); | |
| 2896 | if (recordField(forkData, "cancelled") === true) | |
| 2897 | return yield* protocolError("A Pi extension cancelled the session fork"); | |
| 2898 | const forkState = yield* request({ type: "get_state" }).pipe( | |
| 2899 | Effect.onError(() => | |
| 2900 | Effect.sync(() => { | |
| 2901 | threadState = null; | |
| 2902 | }), | |
| 2903 | ), | |
| 2904 | ); | |
| 2905 | const forkSessionFile = recordString(forkState, "sessionFile"); | |
| 2906 | if (forkSessionFile === undefined) { | |
| 2907 | threadState = null; | |
| 2908 | return yield* protocolError("Pi fork did not return a persisted session file"); | |
| 2909 | } | |
| 2910 | const entriesData = yield* request({ type: "get_entries" }).pipe( | |
| 2911 | Effect.orElseSucceed(() => undefined), | |
| 2912 | ); | |
| 2913 | const leafId = recordString(entriesData, "leafId") ?? null; | |
| 2914 | // RPC responses bypass the pump. Drain preceding native events | |
| 2915 | // before deciding whether this fork can replace their binding. | |
| 2916 | const queued = yield* Queue.offer(connection.events, { | |
| 2917 | type: "t3.rollback_barrier", | |
| 2918 | barrier, | |
| 2919 | }); | |
| 2920 | if (!queued) return yield* protocolError("Pi transport closed during rollback"); | |
| 2921 | yield* Deferred.await(barrier); | |
| 2922 | return yield* sessionEventPermit.withPermits(1)( | |
| 2923 | Effect.gen(function* () { | |
| 2924 | if (pendingWake !== null) { | |
| 2925 | yield* stopPendingWake(pendingWake); | |
| 2926 | return yield* protocolError( | |
| 2927 | "Native Pi work started while rollback was changing the session", | |
| 2928 | ); | |
| 2929 | } | |
| 2930 | if ( | |
| 2931 | threadState !== state || | |
| 2932 | state.activeTurn !== null || | |
| 2933 | closed || | |
| 2934 | stopRequested | |
| 2935 | ) | |
| 2936 | return yield* protocolError( | |
| 2937 | "Pi rollback ownership changed during its fork", | |
| 2938 | ); | |
| 2939 | lastNativeThreadId = forkSessionFile; | |
| 2940 | appliedModel = null; | |
| 2941 | appliedThinking = null; | |
| 2942 | appliedSessionName = null; | |
| 2943 | lastKnownLeaf = leafId; | |
| 2944 | leafCursorStale = entriesData === undefined; | |
| 2945 | yield* updateProviderThread(state, { | |
| 2946 | nativeThreadRef: providerRef(forkSessionFile), | |
| 2947 | nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), | |
| 2948 | }); | |
| 2949 | rollbackBarrier = null; | |
| 2950 | return piThreadSnapshot(state.providerThread); | |
| 2951 | }), | |
| 2952 | ); | |
| 2953 | }).pipe(Effect.onInterrupt(() => connection.terminate)), | |
| 2954 | (barrier) => | |
| 2955 | Effect.sync(() => { | |
| 2956 | if (rollbackBarrier === barrier) rollbackBarrier = null; | |
| 2957 | }), | |
| 2958 | ); | |
| 2959 | }).pipe( | |
| 2960 | Effect.mapError( | |
| 2961 | (cause) => | |
| 2962 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 2963 | driver: PI_PROVIDER, | |
| 2964 | providerThreadId: rollbackInput.providerThread.id, | |
| 2965 | checkpointId: rollbackInput.target.checkpointId, | |
| 2966 | cause, | |
| 2967 | }), | |
| 2968 | ), | |
| 2969 | ), | |
| 2970 | forkThread: (forkInput) => | |
| 2971 | Effect.gen(function* () { | |
| 2972 | if (threadState?.activeTurn != null) { | |
| 2973 | return yield* protocolError("Cannot fork while a Pi turn is active"); | |
| 2974 | } | |
| 2975 | const source = forkInput.sourceProviderThread; | |
| 2976 | const sourceFile = source.nativeThreadRef?.nativeId; | |
| 2977 | if (sourceFile == null) | |
| 2978 | return yield* protocolError("Pi fork source has no session file"); | |
| 2979 | const sourceTurns = (forkInput.sourceProviderTurns ?? []).filter( | |
| 2980 | (turn) => turn.providerThreadId === source.id, | |
| 2981 | ); | |
| 2982 | const target = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId); | |
| 2983 | if (forkInput.providerTurnId !== undefined && target === undefined) { | |
| 2984 | return yield* protocolError("Pi fork target turn is missing"); | |
| 2985 | } | |
| 2986 | const beforeEntry = | |
| 2987 | target === undefined | |
| 2988 | ? null | |
| 2989 | : piRollbackForkEntry({ | |
| 2990 | target: { type: "provider_turn", providerTurn: target }, | |
| 2991 | providerThreadTurns: sourceTurns, | |
| 2992 | }); | |
| 2993 | if (beforeEntry === undefined) { | |
| 2994 | return yield* protocolError("Pi fork boundary has no captured session-tree entry"); | |
| 2995 | } | |
| 2996 | // CLI --fork uses Pi's own session format and sets the destination | |
| 2997 | // cwd. RPC switch_session/clone alone would retain the source cwd. | |
| 2998 | // This short-lived process never prompts or runs user extensions. | |
| 2999 | const forkLaunch = buildPiRpcLaunch({ | |
| 3000 | launchArgs: resolvedLaunchArgs.args, | |
| 3001 | environment: options.environment, | |
| 3002 | mcpSession: undefined, | |
| 3003 | extensionPath: undefined, | |
| 3004 | disableExtensions: true, | |
| 3005 | disableTools: true, | |
| 3006 | }); | |
| 3007 | const nativeId = yield* Effect.scoped( | |
| 3008 | Effect.gen(function* () { | |
| 3009 | const forkConnection = yield* makePiRpcConnection({ | |
| 3010 | command: options.settings.binaryPath || "pi", | |
| 3011 | args: [...forkLaunch.args, "--fork", sourceFile], | |
| 3012 | cwd, | |
| 3013 | env: forkLaunch.env, | |
| 3014 | }); | |
| 3015 | yield* Stream.fromQueue(forkConnection.events).pipe( | |
| 3016 | Stream.runDrain, | |
| 3017 | Effect.ignore, | |
| 3018 | Effect.forkScoped, | |
| 3019 | ); | |
| 3020 | if (beforeEntry !== null) { | |
| 3021 | const result = yield* forkConnection.request({ | |
| 3022 | type: "fork", | |
| 3023 | entryId: beforeEntry, | |
| 3024 | }); | |
| 3025 | if (recordField(result, "cancelled") === true) { | |
| 3026 | return yield* protocolError("A Pi extension cancelled the session fork"); | |
| 3027 | } | |
| 3028 | } | |
| 3029 | const state = yield* forkConnection.request({ type: "get_state" }); | |
| 3030 | const file = recordString(state, "sessionFile"); | |
| 3031 | if (file === undefined || file === sourceFile) { | |
| 3032 | return yield* protocolError("Pi fork did not create a distinct session file"); | |
| 3033 | } | |
| 3034 | return file; | |
| 3035 | }), | |
| 3036 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner)); | |
| 3037 | const now = yield* DateTime.now; | |
| 3038 | return yield* registerThread( | |
| 3039 | { | |
| 3040 | threadId: forkInput.targetThreadId, | |
| 3041 | modelSelection: forkInput.modelSelection ?? input.modelSelection, | |
| 3042 | runtimePolicy: forkInput.runtimePolicy ?? input.runtimePolicy, | |
| 3043 | existingProviderThread: { | |
| 3044 | ...source, | |
| 3045 | id: idAllocator.derive.providerThread({ | |
| 3046 | driver: PI_PROVIDER, | |
| 3047 | nativeThreadId: nativeId, | |
| 3048 | }), | |
| 3049 | appThreadId: forkInput.targetThreadId, | |
| 3050 | providerSessionId: input.providerSessionId, | |
| 3051 | providerInstanceId: options.instanceId, | |
| 3052 | ownerNodeId: forkInput.ownerNodeId ?? null, | |
| 3053 | nativeThreadRef: providerRef(nativeId), | |
| 3054 | nativeConversationHeadRef: null, | |
| 3055 | firstRunOrdinal: null, | |
| 3056 | lastRunOrdinal: null, | |
| 3057 | handoffIds: [], | |
| 3058 | pendingBackgroundTasks: [], | |
| 3059 | forkedFrom: { | |
| 3060 | providerThreadId: source.id, | |
| 3061 | ...(forkInput.providerTurnId === undefined | |
| 3062 | ? {} | |
| 3063 | : { providerTurnId: forkInput.providerTurnId }), | |
| 3064 | }, | |
| 3065 | createdAt: now, | |
| 3066 | updatedAt: now, | |
| 3067 | }, | |
| 3068 | }, | |
| 3069 | false, | |
| 3070 | ); | |
| 3071 | }).pipe( | |
| 3072 | Effect.mapError( | |
| 3073 | (cause) => | |
| 3074 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 3075 | driver: PI_PROVIDER, | |
| 3076 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 3077 | cause, | |
| 3078 | }), | |
| 3079 | ), | |
| 3080 | ), | |
| 3081 | }; | |
| 3082 | return runtime; | |
| 3083 | }), | |
| 3084 | }); | |
| 3085 | } | |
| 3086 | ||
| 3087 | /** | |
| 3088 | * Resolve the pi session-tree entry `fork` should re-root at for a rollback. | |
| 3089 | * Returns `null` when no turns follow the target (nothing to discard) and | |
| 3090 | * `undefined` when the boundary turn has no captured entry ref (only | |
| 3091 | * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). | |
| 3092 | */ | |
| 3093 | function piRollbackForkEntry(input: { | |
| 3094 | readonly target: | |
| 3095 | | { readonly type: "thread_start" } | |
| 3096 | | { readonly type: "provider_turn"; readonly providerTurn: OrchestrationV2ProviderTurn }; | |
| 3097 | readonly providerThreadTurns: ReadonlyArray<OrchestrationV2ProviderTurn>; | |
| 3098 | }): string | null | undefined { | |
| 3099 | const boundaryOrdinal = | |
| 3100 | input.target.type === "thread_start" ? 0 : input.target.providerTurn.ordinal; | |
| 3101 | const discarded = input.providerThreadTurns | |
| 3102 | .filter((turn) => turn.ordinal > boundaryOrdinal) | |
| 3103 | .sort((a, b) => a.ordinal - b.ordinal); | |
| 3104 | const boundary = discarded[0]; | |
| 3105 | if (boundary === undefined) return null; | |
| 3106 | const ref = boundary.nativeTurnRef; | |
| 3107 | if (ref === null || ref.strength !== "strong" || ref.nativeId === null) return undefined; | |
| 3108 | return ref.nativeId; | |
| 3109 | } | |
| 3110 | ||
| 3111 | /** | |
| 3112 | * Human-readable output for one subagent-extension task result: the last | |
| 3113 | * assistant text from its transcript, or the error/stderr when it failed. | |
| 3114 | */ | |
| 3115 | function piSubagentOutput(result: unknown): string { | |
| 3116 | const stopReason = recordString(result, "stopReason"); | |
| 3117 | const failed = | |
| 3118 | (recordNumber(result, "exitCode") ?? 0) !== 0 || | |
| 3119 | stopReason === "error" || | |
| 3120 | stopReason === "aborted"; | |
| 3121 | if (failed) { | |
| 3122 | // Falsy fallback, not `??`: an empty `errorMessage` must not suppress a | |
| 3123 | // non-empty `stderr`, which is often the only description of the failure. | |
| 3124 | const failure = recordString(result, "errorMessage") || recordString(result, "stderr"); | |
| 3125 | if (failure !== undefined && failure.length > 0) return failure; | |
| 3126 | } | |
| 3127 | const messages = recordField(result, "messages"); | |
| 3128 | if (!Array.isArray(messages)) return ""; | |
| 3129 | for (let index = messages.length - 1; index >= 0; index -= 1) { | |
| 3130 | const message = messages[index]; | |
| 3131 | if (recordString(message, "role") !== "assistant") continue; | |
| 3132 | const text = contentText(recordField(message, "content")); | |
| 3133 | if (text.length > 0) return text; | |
| 3134 | } | |
| 3135 | return ""; | |
| 3136 | } | |
| 3137 | ||
| 3138 | function piExtensionDisplayName(extensionPath: string | undefined): string { | |
| 3139 | if (extensionPath === undefined) return "Pi extension"; | |
| 3140 | const normalized = extensionPath.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 3141 | const name = normalized.slice(normalized.lastIndexOf("/") + 1).replace(/\.[^.]+$/, ""); | |
| 3142 | return name.length === 0 ? "Pi extension" : name; | |
| 3143 | } | |
| 3144 | ||
| 3145 | function piThreadSnapshot( | |
| 3146 | providerThread: OrchestrationV2ProviderThread, | |
| 3147 | ): ProviderAdapter.ProviderAdapterV2ThreadSnapshot { | |
| 3148 | return { providerThread, providerTurns: [], messages: [], runtimeRequests: [] }; | |
| 3149 | } | |
| 3150 | ||
| 3151 | function piQuestion( | |
| 3152 | questionId: string, | |
| 3153 | method: "select" | "input" | "editor", | |
| 3154 | title: string, | |
| 3155 | event: PiRpcRecord, | |
| 3156 | ): OrchestrationV2UserInputQuestion { | |
| 3157 | const options = | |
| 3158 | method === "select" && Array.isArray(event["options"]) | |
| 3159 | ? event["options"] | |
| 3160 | .filter((option): option is string => typeof option === "string") | |
| 3161 | .map((option) => ({ label: option || "Empty value", description: option, value: option })) | |
| 3162 | : method === "editor" | |
| 3163 | ? [] | |
| 3164 | : [ | |
| 3165 | { | |
| 3166 | label: "Submit empty value", | |
| 3167 | description: "Send an empty string to the extension.", | |
| 3168 | value: "", | |
| 3169 | }, | |
| 3170 | ]; | |
| 3171 | const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; | |
| 3172 | return { | |
| 3173 | id: questionId, | |
| 3174 | header: title, | |
| 3175 | question, | |
| 3176 | ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), | |
| 3177 | options, | |
| 3178 | }; | |
| 3179 | } | |
| 3180 | ||
| 3181 | function piUiResponse( | |
| 3182 | pending: PendingPiPrompt, | |
| 3183 | decision: ProviderApprovalDecision | undefined, | |
| 3184 | answers: Record<string, unknown> | undefined, | |
| 3185 | ): PiRpcRecord { | |
| 3186 | if (pending.method === "confirm") { | |
| 3187 | if (decision === "accept" || decision === "acceptForSession") return { confirmed: true }; | |
| 3188 | if (decision === "decline") return { confirmed: false }; | |
| 3189 | return { cancelled: true }; | |
| 3190 | } | |
| 3191 | const answer = answers?.[pending.questionId]; | |
| 3192 | // An empty string is a valid dialog value per the RPC spec (the extension | |
| 3193 | // receives ""), distinct from cancelling (the extension receives undefined). | |
| 3194 | if (typeof answer === "string") return { value: answer }; | |
| 3195 | return { cancelled: true }; | |
| 3196 | } | |
| 3197 | ||
| 3198 | // ── driver ──────────────────────────────────────────────────── | |
| 3199 | ||
| 3200 | export type PiAdapterV2DriverEnv = | |
| 3201 | | ChildProcessSpawner.ChildProcessSpawner | |
| 3202 | | FileSystem.FileSystem | |
| 3203 | | IdAllocator.IdAllocatorV2 | |
| 3204 | | ProviderHost; | |
| 3205 | ||
| 3206 | export const PiAdapterV2Driver: ProviderAdapterDriver<PiSettings, PiAdapterV2DriverEnv> = { | |
| 3207 | driverKind: PI_DRIVER_KIND, | |
| 3208 | configSchema: PiSettings, | |
| 3209 | defaultConfig: (): PiSettings => DEFAULT_PI_SETTINGS, | |
| 3210 | create: Effect.fn("PiAdapterV2Driver.create")( | |
| 3211 | function* (input: ProviderAdapterDriverCreateInput<PiSettings>) { | |
| 3212 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3213 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 3215 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3216 | const host = yield* ProviderHost; | |
| 3217 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 3218 | return makePiAdapterV2({ | |
| 3219 | instanceId: input.instanceId, | |
| 3220 | settings: { ...input.config, enabled: input.enabled }, | |
| 3221 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 3222 | spawner, | |
| 3223 | fileSystem, | |
| 3224 | idAllocator, | |
| 3225 | host, | |
| 3226 | continuationRequests, | |
| 3227 | }); | |
| 3228 | }, | |
| 3229 | (effect, input) => | |
| 3230 | effect.pipe( | |
| 3231 | Effect.mapError( | |
| 3232 | (cause) => | |
| 3233 | new ProviderAdapterDriverCreateError({ | |
| 3234 | driver: PI_DRIVER_KIND, | |
| 3235 | instanceId: input.instanceId, | |
| 3236 | detail: "Failed to create Pi adapter.", | |
| 3237 | cause, | |
| 3238 | }), | |
| 3239 | ), | |
| 3240 | ), | |
| 3241 | ), | |
| 3242 | }; | |
| 3243 | ||
| 3244 | const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, PiAdapterV2DriverEnv> = | |
| 3245 | Layer.effect( | |
| 3246 | ProviderAdapter.ProviderAdapterV2, | |
| 3247 | Effect.gen(function* () { | |
| 3248 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 3249 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3250 | const fileSystem = yield* FileSystem.FileSystem; | |
| 3251 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 3252 | const host = yield* ProviderHost; | |
| 3253 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | |
| 3254 | return makePiAdapterV2({ | |
| 3255 | instanceId: PI_DEFAULT_INSTANCE_ID, | |
| 3256 | settings: DEFAULT_PI_SETTINGS, | |
| 3257 | environment: hostEnvironment, | |
| 3258 | spawner, | |
| 3259 | fileSystem, | |
| 3260 | idAllocator, | |
| 3261 | host, | |
| 3262 | continuationRequests, | |
| 3263 | }); | |
| 3264 | }), | |
| 3265 | ); |
packages/provider-pi/src/server/commands.test.ts created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | expandPiSkillReference, | |
| 5 | parsePiCompactCommand, | |
| 6 | parsePiDiscoveredCommands, | |
| 7 | PI_COMPACT_SLASH_COMMAND, | |
| 8 | withPiBuiltinSlashCommands, | |
| 9 | } from "./commands.ts"; | |
| 10 | ||
| 11 | it("maps current Pi skill metadata to T3's user and project skill scopes", () => { | |
| 12 | expect( | |
| 13 | parsePiDiscoveredCommands({ | |
| 14 | commands: [ | |
| 15 | { | |
| 16 | name: "skill:global-review", | |
| 17 | description: "Review changes.", | |
| 18 | source: "skill", | |
| 19 | sourceInfo: { | |
| 20 | path: "/home/test/.agents/skills/global-review/SKILL.md", | |
| 21 | scope: "user", | |
| 22 | }, | |
| 23 | }, | |
| 24 | { | |
| 25 | name: "skill:project-deploy", | |
| 26 | description: "Deploy this project.", | |
| 27 | source: "skill", | |
| 28 | sourceInfo: { | |
| 29 | path: "/workspace/.agents/skills/project-deploy/SKILL.md", | |
| 30 | scope: "project", | |
| 31 | }, | |
| 32 | }, | |
| 33 | { name: "hello", description: "Say hello.", source: "extension" }, | |
| 34 | ], | |
| 35 | }), | |
| 36 | ).toEqual({ | |
| 37 | skills: [ | |
| 38 | { | |
| 39 | name: "global-review", | |
| 40 | description: "Review changes.", | |
| 41 | path: "/home/test/.agents/skills/global-review/SKILL.md", | |
| 42 | scope: "user", | |
| 43 | enabled: true, | |
| 44 | }, | |
| 45 | { | |
| 46 | name: "project-deploy", | |
| 47 | description: "Deploy this project.", | |
| 48 | path: "/workspace/.agents/skills/project-deploy/SKILL.md", | |
| 49 | scope: "project", | |
| 50 | enabled: true, | |
| 51 | }, | |
| 52 | ], | |
| 53 | slashCommands: [{ name: "hello", description: "Say hello." }], | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("maps Pi global location and interface labels onto T3 skill fields", () => { | |
| 58 | expect( | |
| 59 | parsePiDiscoveredCommands({ | |
| 60 | commands: [ | |
| 61 | { | |
| 62 | name: "skill:global-review", | |
| 63 | description: "Review changes.", | |
| 64 | source: "skill", | |
| 65 | location: "global", | |
| 66 | interface: { | |
| 67 | displayName: "Global Review", | |
| 68 | shortDescription: "Review diffs.", | |
| 69 | }, | |
| 70 | }, | |
| 71 | ], | |
| 72 | }), | |
| 73 | ).toEqual({ | |
| 74 | skills: [ | |
| 75 | { | |
| 76 | name: "global-review", | |
| 77 | description: "Review changes.", | |
| 78 | path: "pi:skill:global-review", | |
| 79 | scope: "user", | |
| 80 | enabled: true, | |
| 81 | displayName: "Global Review", | |
| 82 | shortDescription: "Review diffs.", | |
| 83 | }, | |
| 84 | ], | |
| 85 | slashCommands: [], | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | it("parses a standalone /compact command and optional instructions", () => { | |
| 90 | expect(parsePiCompactCommand("/compact")).toEqual({}); | |
| 91 | expect(parsePiCompactCommand(" /compact ")).toEqual({}); | |
| 92 | expect(parsePiCompactCommand("/compact keep the auth rewrite")).toEqual({ | |
| 93 | customInstructions: "keep the auth rewrite", | |
| 94 | }); | |
| 95 | expect(parsePiCompactCommand("/compacted")).toBeNull(); | |
| 96 | expect(parsePiCompactCommand("/compact-now")).toBeNull(); | |
| 97 | expect(parsePiCompactCommand("please /compact")).toBeNull(); | |
| 98 | }); | |
| 99 | ||
| 100 | it("prepends the builtin compact command without duplicating a discovered one", () => { | |
| 101 | expect(withPiBuiltinSlashCommands([{ name: "hello", description: "Say hello." }])).toEqual([ | |
| 102 | PI_COMPACT_SLASH_COMMAND, | |
| 103 | { name: "hello", description: "Say hello." }, | |
| 104 | ]); | |
| 105 | expect( | |
| 106 | withPiBuiltinSlashCommands([ | |
| 107 | { name: "compact", description: "Extension compact." }, | |
| 108 | { name: "hello" }, | |
| 109 | ]), | |
| 110 | ).toEqual([PI_COMPACT_SLASH_COMMAND, { name: "hello" }]); | |
| 111 | }); | |
| 112 | ||
| 113 | it("leaves unrelated dollar-prefixed text unchanged", () => { | |
| 114 | expect(expandPiSkillReference("Explain $HOME", new Set(["global-review"]))).toBe("Explain $HOME"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("hoists every known $ skill and keeps the rest of the prompt", () => { | |
| 118 | expect(expandPiSkillReference("use $alpha then $beta please", new Set(["alpha", "beta"]))).toBe( | |
| 119 | "/skill:alpha /skill:beta use then please", | |
| 120 | ); | |
| 121 | }); | |
| 122 | ||
| 123 | it("preserves code indentation and line breaks when expanding a skill", () => { | |
| 124 | expect(expandPiSkillReference("$review\n```ts\n const x = 1;\n```", new Set(["review"]))).toBe( | |
| 125 | "/skill:review ```ts\n const x = 1;\n```", | |
| 126 | ); | |
| 127 | }); |
packages/provider-pi/src/server/commands.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import { type ServerProviderSkill, type ServerProviderSlashCommand } from "@t3tools/contracts"; | |
| 2 | import * as Predicate from "effect/Predicate"; | |
| 3 | ||
| 4 | // Pi RPC get_commands omits TUI builtins. Advertise /compact so T3 can map it to RPC compact. | |
| 5 | export const PI_COMPACT_SLASH_COMMAND: ServerProviderSlashCommand = { | |
| 6 | name: "compact", | |
| 7 | description: "Summarize the conversation and reduce context usage", | |
| 8 | input: { hint: "Optional instructions" }, | |
| 9 | }; | |
| 10 | ||
| 11 | export interface PiCompactCommand { | |
| 12 | readonly customInstructions?: string; | |
| 13 | } | |
| 14 | ||
| 15 | export function parsePiCompactCommand(text: string): PiCompactCommand | null { | |
| 16 | const trimmed = text.trim(); | |
| 17 | if (trimmed === "/compact") return {}; | |
| 18 | if (!trimmed.startsWith("/compact")) return null; | |
| 19 | const rest = trimmed.slice("/compact".length); | |
| 20 | if (rest.length === 0) return {}; | |
| 21 | if (!/^\s/.test(rest)) return null; | |
| 22 | const customInstructions = rest.trim(); | |
| 23 | return customInstructions.length === 0 ? {} : { customInstructions }; | |
| 24 | } | |
| 25 | ||
| 26 | export function withPiBuiltinSlashCommands( | |
| 27 | commands: ReadonlyArray<ServerProviderSlashCommand>, | |
| 28 | ): ReadonlyArray<ServerProviderSlashCommand> { | |
| 29 | return [PI_COMPACT_SLASH_COMMAND, ...commands.filter((command) => command.name !== "compact")]; | |
| 30 | } | |
| 31 | ||
| 32 | export interface PiDiscoveredCommands { | |
| 33 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 34 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 35 | } | |
| 36 | ||
| 37 | function normalizePiSkillScope(scope: string | undefined): string | undefined { | |
| 38 | if (scope === undefined) return undefined; | |
| 39 | const normalized = scope.trim().toLowerCase(); | |
| 40 | if (normalized === "global" || normalized === "personal") return "user"; | |
| 41 | if (normalized === "workspace" || normalized === "local") return "project"; | |
| 42 | return scope; | |
| 43 | } | |
| 44 | ||
| 45 | /** Maps Pi's `get_commands` payload to T3's shared command and skill surfaces. */ | |
| 46 | export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { | |
| 47 | const commands = recordField(data, "commands"); | |
| 48 | if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; | |
| 49 | const slashCommands: Array<ServerProviderSlashCommand> = []; | |
| 50 | const skills: Array<ServerProviderSkill> = []; | |
| 51 | for (const command of commands) { | |
| 52 | const commandName = recordString(command, "name"); | |
| 53 | if (commandName === undefined || commandName.length === 0) continue; | |
| 54 | const description = recordString(command, "description"); | |
| 55 | if (recordString(command, "source") === "skill") { | |
| 56 | const name = commandName.startsWith("skill:") | |
| 57 | ? commandName.slice("skill:".length) | |
| 58 | : commandName; | |
| 59 | if (name.length === 0) continue; | |
| 60 | const sourceInfo = recordField(command, "sourceInfo"); | |
| 61 | const commandInterface = recordField(command, "interface"); | |
| 62 | const path = | |
| 63 | recordString(sourceInfo, "path") ?? recordString(command, "path") ?? `pi:skill:${name}`; | |
| 64 | const scope = normalizePiSkillScope( | |
| 65 | recordString(sourceInfo, "scope") ?? recordString(command, "location"), | |
| 66 | ); | |
| 67 | const displayName = | |
| 68 | recordString(command, "displayName") ?? | |
| 69 | recordString(sourceInfo, "displayName") ?? | |
| 70 | recordString(commandInterface, "displayName"); | |
| 71 | const shortDescription = | |
| 72 | recordString(command, "shortDescription") ?? | |
| 73 | recordString(sourceInfo, "shortDescription") ?? | |
| 74 | recordString(commandInterface, "shortDescription"); | |
| 75 | skills.push({ | |
| 76 | name, | |
| 77 | path, | |
| 78 | enabled: true, | |
| 79 | ...(description === undefined ? {} : { description }), | |
| 80 | ...(scope === undefined ? {} : { scope }), | |
| 81 | ...(displayName === undefined ? {} : { displayName }), | |
| 82 | ...(shortDescription === undefined ? {} : { shortDescription }), | |
| 83 | }); | |
| 84 | continue; | |
| 85 | } | |
| 86 | slashCommands.push({ | |
| 87 | name: commandName, | |
| 88 | ...(description === undefined ? {} : { description }), | |
| 89 | }); | |
| 90 | } | |
| 91 | return { slashCommands, skills }; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Pi expands skills only through leading `/skill:name` commands. T3 stores | |
| 96 | * skill chips as `$name`, so hoist every known `$skill` to that native | |
| 97 | * command position while preserving the rest of the user's prompt. | |
| 98 | */ | |
| 99 | export function expandPiSkillReference(text: string, skillNames: ReadonlySet<string>): string { | |
| 100 | const references = /(^|\s)\$([^\s]+)(?=\s|$)/g; | |
| 101 | const found: Array<{ name: string; start: number; end: number }> = []; | |
| 102 | for (const match of text.matchAll(references)) { | |
| 103 | const name = match[2]; | |
| 104 | if (name === undefined || !skillNames.has(name) || match.index === undefined) continue; | |
| 105 | const tokenStart = match.index + (match[1]?.length ?? 0); | |
| 106 | found.push({ name, start: tokenStart, end: tokenStart + name.length + 1 }); | |
| 107 | } | |
| 108 | if (found.length === 0) return text; | |
| 109 | ||
| 110 | const orderedNames: string[] = []; | |
| 111 | const seen = new Set<string>(); | |
| 112 | for (const token of found) { | |
| 113 | if (seen.has(token.name)) continue; | |
| 114 | seen.add(token.name); | |
| 115 | orderedNames.push(token.name); | |
| 116 | } | |
| 117 | ||
| 118 | let body = text; | |
| 119 | for (let index = found.length - 1; index >= 0; index -= 1) { | |
| 120 | const token = found[index]; | |
| 121 | if (token === undefined) continue; | |
| 122 | body = `${body.slice(0, token.start)}${body.slice(token.end)}`; | |
| 123 | } | |
| 124 | body = body.trim(); | |
| 125 | const prefix = orderedNames.map((name) => `/skill:${name}`).join(" "); | |
| 126 | return body.length === 0 ? prefix : `${prefix} ${body}`; | |
| 127 | } | |
| 128 | ||
| 129 | function recordField(input: unknown, key: string): unknown { | |
| 130 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 131 | } | |
| 132 | ||
| 133 | function recordString(input: unknown, key: string): string | undefined { | |
| 134 | const value = recordField(input, key); | |
| 135 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | |
| 136 | } |
packages/provider-pi/src/server/driver.test.ts created+218| ... | ... | @@ -0,0 +1,218 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Queue from "effect/Queue"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Sink from "effect/Sink"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | import { HttpClient } from "effect/http"; | |
| 14 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 15 | ||
| 16 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 17 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 18 | import type { PiSettings } from "../settings.ts"; | |
| 19 | import { PiDriver } from "./driver.ts"; | |
| 20 | ||
| 21 | const layerTest = Layer.mergeAll( | |
| 22 | layerTestProviderHost({ | |
| 23 | cwd: "/machine", | |
| 24 | settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false }, | |
| 25 | runBackgroundWork: false, | |
| 26 | }), | |
| 27 | IdAllocator.layer, | |
| 28 | Layer.succeed( | |
| 29 | HttpClient.HttpClient, | |
| 30 | HttpClient.make(() => Effect.die("Unexpected HTTP")), | |
| 31 | ), | |
| 32 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 33 | ||
| 34 | const decodeRequest = Schema.decodeSync( | |
| 35 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | |
| 36 | ); | |
| 37 | const encoder = new TextEncoder(); | |
| 38 | const personalSkill = { | |
| 39 | name: "skill:personal", | |
| 40 | source: "skill", | |
| 41 | sourceInfo: { scope: "user", path: "/home/.pi/agent/skills/personal/SKILL.md" }, | |
| 42 | }; | |
| 43 | ||
| 44 | // Respond through the real stdio transport, with a distinct command catalog for each cwd. | |
| 45 | const makePiSpawner = Effect.gen(function* () { | |
| 46 | const pendingCommand = yield* Deferred.make<void>(); | |
| 47 | const launches: Array<ChildProcess.StandardCommand> = []; | |
| 48 | const spawner = ChildProcessSpawner.make((command) => | |
| 49 | Effect.gen(function* () { | |
| 50 | assert.isTrue(ChildProcess.isStandardCommand(command)); | |
| 51 | if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("Unexpected pipeline"); | |
| 52 | launches.push(command); | |
| 53 | const version = command.args.includes("--version"); | |
| 54 | const stdout = yield* Queue.unbounded<Uint8Array>(); | |
| 55 | const cwd = command.options.cwd; | |
| 56 | return ChildProcessSpawner.makeHandle({ | |
| 57 | // Outside the valid PID range, so transport cleanup cannot signal a real process. | |
| 58 | pid: ChildProcessSpawner.ProcessId(999_999_999), | |
| 59 | exitCode: version ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) : Effect.never, | |
| 60 | isRunning: Effect.succeed(!version), | |
| 61 | kill: () => Effect.void, | |
| 62 | unref: Effect.succeed(Effect.void), | |
| 63 | stdin: Sink.forEach((chunk: Uint8Array) => { | |
| 64 | const request = decodeRequest(new TextDecoder().decode(chunk).trim()); | |
| 65 | if (cwd === "/pending" && request.type === "get_commands") { | |
| 66 | return Deferred.succeed(pendingCommand, undefined).pipe(Effect.asVoid); | |
| 67 | } | |
| 68 | const failed = cwd === "/failed" && request.type === "get_commands"; | |
| 69 | const data = | |
| 70 | request.type === "get_commands" | |
| 71 | ? { | |
| 72 | commands: [ | |
| 73 | personalSkill, | |
| 74 | ...(cwd === "/machine" | |
| 75 | ? [] | |
| 76 | : [ | |
| 77 | { | |
| 78 | name: `skill:${cwd?.slice(1)}`, | |
| 79 | source: "skill", | |
| 80 | sourceInfo: { | |
| 81 | scope: "project", | |
| 82 | path: `${cwd}/.agents/skills/SKILL.md`, | |
| 83 | }, | |
| 84 | }, | |
| 85 | { name: `prompt-${cwd?.slice(1)}`, source: "prompt" }, | |
| 86 | ]), | |
| 87 | ], | |
| 88 | } | |
| 89 | : request.type === "get_available_models" | |
| 90 | ? { models: [{ provider: "test", id: "model" }] } | |
| 91 | : {}; | |
| 92 | return Queue.offer( | |
| 93 | stdout, | |
| 94 | encoder.encode( | |
| 95 | `${JSON.stringify({ type: "response", id: request.id, success: !failed, data, ...(failed ? { error: "commands unavailable" } : {}) })}\n`, | |
| 96 | ), | |
| 97 | ).pipe(Effect.asVoid); | |
| 98 | }), | |
| 99 | stdout: version ? Stream.succeed(encoder.encode("pi 1.0.2\n")) : Stream.fromQueue(stdout), | |
| 100 | stderr: Stream.empty, | |
| 101 | all: Stream.empty, | |
| 102 | getInputFd: () => Sink.drain, | |
| 103 | getOutputFd: () => Stream.empty, | |
| 104 | }); | |
| 105 | }), | |
| 106 | ); | |
| 107 | return { spawner, launches, pendingCommand }; | |
| 108 | }); | |
| 109 | ||
| 110 | const create = (config: Partial<PiSettings> = {}, enabled = true) => | |
| 111 | PiDriver.create({ | |
| 112 | instanceId: ProviderInstanceId.make("pi-workspace-test"), | |
| 113 | displayName: "My Pi", | |
| 114 | accentColor: "#abcdef", | |
| 115 | environment: [{ name: "PI_CODING_AGENT_DIR", value: "/isolated-pi", sensitive: false }], | |
| 116 | enabled, | |
| 117 | config: { ...PiDriver.defaultConfig(), binaryPath: "custom-pi", ...config }, | |
| 118 | }); | |
| 119 | ||
| 120 | it.layer(layerTest)("PiDriver workspace discovery", (it) => { | |
| 121 | it.effect("keeps each workspace's skills and commands separate from the machine catalog", () => | |
| 122 | Effect.gen(function* () { | |
| 123 | const { spawner, launches } = yield* makePiSpawner; | |
| 124 | const instance = yield* create({ launchArgs: '--approve --skill "extra skill"' }).pipe( | |
| 125 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 126 | ); | |
| 127 | yield* instance.snapshot.refresh; | |
| 128 | assert.isDefined(instance.snapshotForCwd); | |
| 129 | const [first, second] = yield* Effect.all( | |
| 130 | [instance.snapshotForCwd!("/first"), instance.snapshotForCwd!("/second")], | |
| 131 | { concurrency: "unbounded" }, | |
| 132 | ); | |
| 133 | assert.deepEqual( | |
| 134 | first.skills.map((skill) => skill.name), | |
| 135 | ["personal", "first"], | |
| 136 | ); | |
| 137 | assert.deepEqual( | |
| 138 | second.skills.map((skill) => skill.name), | |
| 139 | ["personal", "second"], | |
| 140 | ); | |
| 141 | assert.deepEqual( | |
| 142 | first.slashCommands.map((command) => command.name), | |
| 143 | ["compact", "prompt-first"], | |
| 144 | ); | |
| 145 | const machine = yield* instance.snapshot.getSnapshot; | |
| 146 | assert.deepEqual( | |
| 147 | machine.skills.map((skill) => skill.name), | |
| 148 | ["personal"], | |
| 149 | ); | |
| 150 | assert.deepEqual( | |
| 151 | machine.slashCommands.map((command) => command.name), | |
| 152 | ["compact"], | |
| 153 | ); | |
| 154 | assert.equal(first.instanceId, instance.instanceId); | |
| 155 | assert.equal(first.displayName, "My Pi"); | |
| 156 | assert.equal(first.accentColor, "#abcdef"); | |
| 157 | assert.deepEqual(first.models, machine.models); | |
| 158 | const workspaceLaunch = launches.find((launch) => launch.options.cwd === "/first"); | |
| 159 | assert.isDefined(workspaceLaunch); | |
| 160 | assert.equal(workspaceLaunch!.command, "custom-pi"); | |
| 161 | assert.includeMembers( | |
| 162 | [...workspaceLaunch!.args], | |
| 163 | ["--approve", "--skill", "extra skill", "--no-session"], | |
| 164 | ); | |
| 165 | assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi"); | |
| 166 | }).pipe(Effect.scoped), | |
| 167 | ); | |
| 168 | ||
| 169 | it.effect("does not run a disabled provider's workspace probe", () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const instance = yield* create({}, false).pipe( | |
| 172 | Effect.provideService( | |
| 173 | ChildProcessSpawner.ChildProcessSpawner, | |
| 174 | ChildProcessSpawner.make(() => Effect.die("Disabled Pi must not spawn")), | |
| 175 | ), | |
| 176 | ); | |
| 177 | assert.isDefined(instance.snapshotForCwd); | |
| 178 | const workspace = yield* instance.snapshotForCwd!("/first"); | |
| 179 | assert.isFalse(workspace.enabled); | |
| 180 | assert.deepEqual(workspace.skills, []); | |
| 181 | }).pipe(Effect.scoped), | |
| 182 | ); | |
| 183 | ||
| 184 | it.effect( | |
| 185 | "fails command discovery instead of returning an empty successful workspace catalog", | |
| 186 | () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const { spawner } = yield* makePiSpawner; | |
| 189 | const instance = yield* create().pipe( | |
| 190 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 191 | ); | |
| 192 | yield* instance.snapshot.refresh; | |
| 193 | assert.isDefined(instance.snapshotForCwd); | |
| 194 | const error = yield* Effect.flip(instance.snapshotForCwd!("/failed")); | |
| 195 | assert.equal(error._tag, "ProviderDriverError"); | |
| 196 | assert.equal(error.instanceId, instance.instanceId); | |
| 197 | assert.deepEqual( | |
| 198 | (yield* instance.snapshot.getSnapshot).skills.map((skill) => skill.name), | |
| 199 | ["personal"], | |
| 200 | ); | |
| 201 | }).pipe(Effect.scoped), | |
| 202 | ); | |
| 203 | ||
| 204 | it.effect("times out workspace discovery that needs interactive input", () => | |
| 205 | Effect.gen(function* () { | |
| 206 | const { spawner, pendingCommand } = yield* makePiSpawner; | |
| 207 | const instance = yield* create().pipe( | |
| 208 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 209 | ); | |
| 210 | const probe = yield* instance.snapshotForCwd!("/pending").pipe(Effect.flip, Effect.forkChild); | |
| 211 | yield* Deferred.await(pendingCommand); | |
| 212 | yield* TestClock.adjust("15 seconds"); | |
| 213 | const error = yield* Fiber.join(probe); | |
| 214 | assert.equal(error._tag, "ProviderDriverError"); | |
| 215 | assert.include(error.detail, "workspace commands"); | |
| 216 | }).pipe(Effect.scoped), | |
| 217 | ); | |
| 218 | }); |
packages/provider-pi/src/server/driver.ts created+210| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | /** | |
| 2 | * PiDriver — v1 `ProviderDriver` for the Pi coding agent, composing the | |
| 3 | * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer | |
| 4 | * (`PiProvider`), and Pi-backed text generation. | |
| 5 | * | |
| 6 | * Pi state (sessions, settings, extensions, auth) lives in the user's own | |
| 7 | * `~/.pi/agent`, so continuation identity uses the default instance grouping. | |
| 8 | */ | |
| 9 | import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | |
| 10 | import { PiSettings } from "../settings.ts"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { HttpClient } from "effect/http"; | |
| 16 | import { ChildProcessSpawner } from "effect/process"; | |
| 17 | ||
| 18 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 19 | import { makePiTextGeneration } from "./textGeneration.ts"; | |
| 20 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; | |
| 21 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | |
| 22 | import { | |
| 23 | buildInitialPiProviderSnapshot, | |
| 24 | checkPiProviderStatus, | |
| 25 | discoverPiCommandsForCwd, | |
| 26 | enrichPiSnapshot, | |
| 27 | } from "./status.ts"; | |
| 28 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 29 | import { | |
| 30 | defaultProviderContinuationIdentity, | |
| 31 | type ProviderDriver, | |
| 32 | type ProviderInstance, | |
| 33 | } from "@t3tools/provider-core/server/driver"; | |
| 34 | import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 35 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 36 | import { | |
| 37 | makeCachedProviderMaintenanceResolution, | |
| 38 | makePackageManagedProviderMaintenanceResolver, | |
| 39 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 40 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 41 | import { | |
| 42 | haveProviderSnapshotSettingsChanged, | |
| 43 | makeProviderSnapshotSettingsSource, | |
| 44 | type ProviderSnapshotSettings, | |
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 46 | ||
| 47 | const decodePiSettings = Schema.decodeSync(PiSettings); | |
| 48 | ||
| 49 | const DRIVER_KIND = ProviderDriverKind.make("pi"); | |
| 50 | const UPDATE = makePackageManagedProviderMaintenanceResolver({ | |
| 51 | provider: DRIVER_KIND, | |
| 52 | npmPackageName: "@earendil-works/pi-coding-agent", | |
| 53 | // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals. | |
| 54 | nativeUpdate: { args: ["update", "--self"] }, | |
| 55 | }); | |
| 56 | ||
| 57 | export type PiDriverEnv = | |
| 58 | | PiAdapterV2DriverEnv | |
| 59 | | ProviderHost | |
| 60 | | ChildProcessSpawner.ChildProcessSpawner | |
| 61 | | FileSystem.FileSystem | |
| 62 | | HttpClient.HttpClient | |
| 63 | | Path.Path; | |
| 64 | ||
| 65 | const withInstanceIdentity = | |
| 66 | (input: { | |
| 67 | readonly instanceId: ProviderInstance["instanceId"]; | |
| 68 | readonly displayName: string | undefined; | |
| 69 | readonly accentColor: string | undefined; | |
| 70 | readonly continuationGroupKey: string; | |
| 71 | }) => | |
| 72 | (snapshot: ServerProviderDraft): ServerProvider => ({ | |
| 73 | ...snapshot, | |
| 74 | instanceId: input.instanceId, | |
| 75 | driver: DRIVER_KIND, | |
| 76 | ...(input.displayName ? { displayName: input.displayName } : {}), | |
| 77 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 78 | continuation: { groupKey: input.continuationGroupKey }, | |
| 79 | }); | |
| 80 | ||
| 81 | export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = { | |
| 82 | driverKind: DRIVER_KIND, | |
| 83 | metadata: { | |
| 84 | displayName: "Pi", | |
| 85 | supportsMultipleInstances: true, | |
| 86 | }, | |
| 87 | configSchema: PiSettings, | |
| 88 | defaultConfig: (): PiSettings => decodePiSettings({}), | |
| 89 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 90 | Effect.gen(function* () { | |
| 91 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 92 | const fileSystem = yield* FileSystem.FileSystem; | |
| 93 | const pathService = yield* Path.Path; | |
| 94 | const httpClient = yield* HttpClient.HttpClient; | |
| 95 | const host = yield* ProviderHost; | |
| 96 | const { cwd } = host.paths; | |
| 97 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 98 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 99 | driverKind: DRIVER_KIND, | |
| 100 | instanceId, | |
| 101 | }); | |
| 102 | const stampIdentity = withInstanceIdentity({ | |
| 103 | instanceId, | |
| 104 | displayName, | |
| 105 | accentColor, | |
| 106 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 107 | }); | |
| 108 | const effectiveConfig = { ...config, enabled } satisfies PiSettings; | |
| 109 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( | |
| 110 | resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 111 | binaryPath: effectiveConfig.binaryPath, | |
| 112 | env: processEnv, | |
| 113 | }).pipe( | |
| 114 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 115 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 116 | Effect.provideService(Path.Path, pathService), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | const orchestrationAdapter = yield* PiAdapterV2Driver.create({ | |
| 121 | instanceId, | |
| 122 | displayName, | |
| 123 | accentColor, | |
| 124 | environment, | |
| 125 | enabled, | |
| 126 | config, | |
| 127 | }).pipe( | |
| 128 | Effect.mapError( | |
| 129 | (cause) => | |
| 130 | new ProviderDriverError({ | |
| 131 | driver: DRIVER_KIND, | |
| 132 | instanceId, | |
| 133 | detail: "Failed to build Pi orchestration adapter.", | |
| 134 | cause, | |
| 135 | }), | |
| 136 | ), | |
| 137 | ); | |
| 138 | const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); | |
| 139 | ||
| 140 | const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( | |
| 141 | Effect.map(stampIdentity), | |
| 142 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 143 | ); | |
| 144 | ||
| 145 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 146 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<PiSettings>>({ | |
| 147 | resolveMaintenance, | |
| 148 | getSettings: snapshotSettings.getSettings, | |
| 149 | streamSettings: snapshotSettings.streamSettings, | |
| 150 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 151 | initialSnapshot: (settings) => | |
| 152 | buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), | |
| 153 | checkProvider, | |
| 154 | enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => | |
| 155 | resolveMaintenance().pipe( | |
| 156 | Effect.flatMap((maintenanceCapabilities) => | |
| 157 | enrichPiSnapshot({ | |
| 158 | snapshot: currentSnapshot, | |
| 159 | maintenanceCapabilities, | |
| 160 | enableProviderUpdateChecks: settings.enableProviderUpdateChecks, | |
| 161 | publishSnapshot, | |
| 162 | httpClient, | |
| 163 | }), | |
| 164 | ), | |
| 165 | ), | |
| 166 | }).pipe( | |
| 167 | Effect.mapError( | |
| 168 | (cause) => | |
| 169 | new ProviderDriverError({ | |
| 170 | driver: DRIVER_KIND, | |
| 171 | instanceId, | |
| 172 | detail: "Failed to build Pi snapshot.", | |
| 173 | cause, | |
| 174 | }), | |
| 175 | ), | |
| 176 | ); | |
| 177 | ||
| 178 | return { | |
| 179 | instanceId, | |
| 180 | driverKind: DRIVER_KIND, | |
| 181 | continuationIdentity, | |
| 182 | displayName, | |
| 183 | accentColor, | |
| 184 | enabled, | |
| 185 | snapshot, | |
| 186 | snapshotForCwd: (workspaceCwd) => | |
| 187 | !effectiveConfig.enabled | |
| 188 | ? snapshot.getSnapshot | |
| 189 | : Effect.all([ | |
| 190 | snapshot.getSnapshot, | |
| 191 | discoverPiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe( | |
| 192 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 193 | Effect.mapError( | |
| 194 | (cause) => | |
| 195 | new ProviderDriverError({ | |
| 196 | driver: DRIVER_KIND, | |
| 197 | instanceId, | |
| 198 | detail: "Failed to discover Pi workspace commands.", | |
| 199 | cause, | |
| 200 | }), | |
| 201 | ), | |
| 202 | ), | |
| 203 | ]).pipe( | |
| 204 | Effect.map(([machineSnapshot, commands]) => ({ ...machineSnapshot, ...commands })), | |
| 205 | ), | |
| 206 | orchestrationAdapter, | |
| 207 | textGeneration, | |
| 208 | } satisfies ProviderInstance; | |
| 209 | }), | |
| 210 | }; |
packages/provider-pi/src/server/mcpExtensionSource.test.ts created+361| ... | ... | @@ -0,0 +1,361 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | import * as NodeVM from "node:vm"; | |
| 3 | import { assert, describe, it } from "@effect/vitest"; | |
| 4 | ||
| 5 | import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; | |
| 6 | ||
| 7 | type RequestHook = ( | |
| 8 | event: { payload: unknown }, | |
| 9 | ctx: { model: { provider: string } }, | |
| 10 | ) => Record<string, unknown> | undefined; | |
| 11 | ||
| 12 | interface RegisteredTool { | |
| 13 | readonly name: string; | |
| 14 | readonly description: string; | |
| 15 | readonly parameters: unknown; | |
| 16 | readonly exposure?: string; | |
| 17 | readonly promptSnippet?: string; | |
| 18 | readonly promptGuidelines?: ReadonlyArray<string>; | |
| 19 | readonly execute: ( | |
| 20 | id: string, | |
| 21 | args: Record<string, unknown>, | |
| 22 | signal?: AbortSignal, | |
| 23 | ) => Promise<{ | |
| 24 | readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; | |
| 25 | }>; | |
| 26 | } | |
| 27 | ||
| 28 | type AgentStartHook = ( | |
| 29 | event: { systemPrompt: string }, | |
| 30 | ctx: { ui: { notify: (message: string, severity: string) => void } }, | |
| 31 | ) => Promise<{ systemPrompt: string }>; | |
| 32 | ||
| 33 | async function loadMcpBridge( | |
| 34 | options: { | |
| 35 | readonly modern?: boolean; | |
| 36 | readonly toolSearchAvailable?: boolean; | |
| 37 | readonly toolSearchDisabled?: boolean; | |
| 38 | readonly allowsTool?: (name: string) => boolean; | |
| 39 | } = {}, | |
| 40 | ) { | |
| 41 | const handlers = new Map<string, AgentStartHook>(); | |
| 42 | const tools: RegisteredTool[] = []; | |
| 43 | const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; | |
| 44 | let activeTools = ["read"]; | |
| 45 | const transports: Array<{ | |
| 46 | readonly url: string; | |
| 47 | readonly authorization: string; | |
| 48 | readonly signal: AbortSignal | undefined; | |
| 49 | }> = []; | |
| 50 | const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = []; | |
| 51 | const catalog = [ | |
| 52 | { name: "orchestrator_capabilities", description: "Discover available providers and models." }, | |
| 53 | { name: "delegate_task", description: "Delegate work to another agent." }, | |
| 54 | { name: "task_status", description: "Check delegated work." }, | |
| 55 | { name: "preview_snapshot", description: "Inspect the collaborative browser." }, | |
| 56 | ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); | |
| 57 | const source = NodeModule.stripTypeScriptTypes( | |
| 58 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 59 | "export default async function", | |
| 60 | "async function", | |
| 61 | ), | |
| 62 | ); | |
| 63 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 64 | process: { | |
| 65 | env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, | |
| 66 | }, | |
| 67 | AbortSignal, | |
| 68 | Type: { Unsafe: (schema: unknown) => schema }, | |
| 69 | fetch: async ( | |
| 70 | url: string, | |
| 71 | options: { body: string; headers: Record<string, string>; signal?: AbortSignal }, | |
| 72 | ) => { | |
| 73 | transports.push({ | |
| 74 | url, | |
| 75 | authorization: options.headers.authorization!, | |
| 76 | signal: options.signal, | |
| 77 | }); | |
| 78 | const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; | |
| 79 | requests.push(request); | |
| 80 | const result = | |
| 81 | request.method === "tools/list" | |
| 82 | ? { tools: catalog } | |
| 83 | : request.method === "tools/call" | |
| 84 | ? { content: [{ type: "text", text: "browser snapshot" }] } | |
| 85 | : {}; | |
| 86 | return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { | |
| 87 | headers: { "content-type": "application/json" }, | |
| 88 | }); | |
| 89 | }, | |
| 90 | pi: { | |
| 91 | on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), | |
| 92 | registerTool: (tool: RegisteredTool) => { | |
| 93 | if (options.allowsTool && !options.allowsTool(tool.name)) return; | |
| 94 | const index = tools.findIndex((current) => current.name === tool.name); | |
| 95 | if (index === -1) tools.push(tool); | |
| 96 | else tools[index] = tool; | |
| 97 | }, | |
| 98 | getActiveTools: () => activeTools, | |
| 99 | setActiveTools: (names: string[]) => { | |
| 100 | activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); | |
| 101 | }, | |
| 102 | getAllTools: () => | |
| 103 | options.toolSearchAvailable && | |
| 104 | !options.toolSearchDisabled && | |
| 105 | (options.allowsTool?.("tool_search") ?? true) | |
| 106 | ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] | |
| 107 | : [], | |
| 108 | ...(options.modern | |
| 109 | ? { | |
| 110 | registerMcpServer: (name: string, config: Record<string, unknown>) => | |
| 111 | servers.push({ name, config }), | |
| 112 | unregisterMcpServer: () => servers.splice(0), | |
| 113 | } | |
| 114 | : {}), | |
| 115 | }, | |
| 116 | }); | |
| 117 | return { | |
| 118 | handlers, | |
| 119 | tools, | |
| 120 | requests, | |
| 121 | servers, | |
| 122 | transports, | |
| 123 | getActiveTools: () => activeTools, | |
| 124 | restoreActiveTools: (names: string[]) => { | |
| 125 | activeTools = names; | |
| 126 | }, | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | describe("Pi MCP tool exposure", () => { | |
| 131 | it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { | |
| 132 | const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); | |
| 133 | await bridge.handlers.get("session_start")!( | |
| 134 | { systemPrompt: "" }, | |
| 135 | { ui: { notify: () => undefined } }, | |
| 136 | ); | |
| 137 | const start = bridge.handlers.get("before_agent_start"); | |
| 138 | assert.isDefined(start); | |
| 139 | const prompt = await start!( | |
| 140 | { systemPrompt: "Pi system prompt" }, | |
| 141 | { ui: { notify: () => undefined } }, | |
| 142 | ); | |
| 143 | assert.include(prompt.systemPrompt, "orchestrator_capabilities"); | |
| 144 | assert.equal(bridge.servers.length, 0); | |
| 145 | assert.equal(bridge.tools.length, 8); | |
| 146 | assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]); | |
| 147 | assert.deepEqual( | |
| 148 | bridge.tools | |
| 149 | .filter((tool) => tool.exposure !== "hidden") | |
| 150 | .map((tool) => [tool.name, tool.exposure]), | |
| 151 | [ | |
| 152 | ["mcp__t3-code__orchestrator_capabilities", "direct"], | |
| 153 | ["mcp__t3-code__delegate_task", "direct"], | |
| 154 | ["mcp__t3-code__task_status", "direct"], | |
| 155 | ["mcp__t3-code__preview_snapshot", "deferred"], | |
| 156 | ], | |
| 157 | ); | |
| 158 | assert.deepEqual( | |
| 159 | bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name), | |
| 160 | [ | |
| 161 | "mcp__t3_code__orchestrator_capabilities", | |
| 162 | "mcp__t3_code__delegate_task", | |
| 163 | "mcp__t3_code__task_status", | |
| 164 | "mcp__t3_code__preview_snapshot", | |
| 165 | ], | |
| 166 | ); | |
| 167 | const result = await bridge.tools | |
| 168 | .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! | |
| 169 | .execute("call-1", { depth: 2 }); | |
| 170 | assert.equal(result.content[0]?.text, "browser snapshot"); | |
| 171 | assert.equal(bridge.requests.at(-1)?.method, "tools/call"); | |
| 172 | }); | |
| 173 | ||
| 174 | it.each(["legacy Pi", "disabled tool search"])( | |
| 175 | "keeps tool execution available with %s", | |
| 176 | async (mode) => { | |
| 177 | const bridge = await loadMcpBridge({ | |
| 178 | modern: mode !== "legacy Pi", | |
| 179 | toolSearchAvailable: mode === "disabled tool search", | |
| 180 | toolSearchDisabled: mode === "disabled tool search", | |
| 181 | }); | |
| 182 | if (mode !== "legacy Pi") { | |
| 183 | const start = bridge.handlers.get("session_start"); | |
| 184 | await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } }); | |
| 185 | } | |
| 186 | assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); | |
| 187 | assert.isTrue( | |
| 188 | bridge.tools | |
| 189 | .filter((tool) => tool.exposure !== "hidden") | |
| 190 | .every((tool) => tool.exposure === undefined || tool.exposure === "direct"), | |
| 191 | ); | |
| 192 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); | |
| 193 | assert.isDefined(tool); | |
| 194 | const controller = new AbortController(); | |
| 195 | const result = await tool!.execute("call-1", { depth: 2 }, controller.signal); | |
| 196 | assert.equal(result.content[0]?.text, "browser snapshot"); | |
| 197 | assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal); | |
| 198 | assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp"); | |
| 199 | assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token"); | |
| 200 | assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), { | |
| 201 | jsonrpc: "2.0", | |
| 202 | id: 3, | |
| 203 | method: "tools/call", | |
| 204 | params: { name: "preview_snapshot", arguments: { depth: 2 } }, | |
| 205 | }); | |
| 206 | assert.isUndefined(tool?.promptSnippet); | |
| 207 | assert.isUndefined(tool?.promptGuidelines); | |
| 208 | }, | |
| 209 | ); | |
| 210 | ||
| 211 | it("preserves legacy wildcard tool selection", async () => { | |
| 212 | const bridge = await loadMcpBridge({ | |
| 213 | modern: true, | |
| 214 | toolSearchAvailable: true, | |
| 215 | allowsTool: (name) => | |
| 216 | name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"), | |
| 217 | }); | |
| 218 | await bridge.handlers.get("session_start")!( | |
| 219 | { systemPrompt: "" }, | |
| 220 | { ui: { notify: () => undefined } }, | |
| 221 | ); | |
| 222 | assert.equal(bridge.tools.length, 4); | |
| 223 | assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3); | |
| 224 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); | |
| 225 | assert.isDefined(tool); | |
| 226 | assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot"); | |
| 227 | }); | |
| 228 | ||
| 229 | it.each([false, true])( | |
| 230 | "reconciles tree loadouts while honoring search exclusion: %s", | |
| 231 | async (excludeSearch) => { | |
| 232 | const bridge = await loadMcpBridge({ | |
| 233 | modern: true, | |
| 234 | toolSearchAvailable: true, | |
| 235 | allowsTool: (name) => | |
| 236 | name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"), | |
| 237 | }); | |
| 238 | await bridge.handlers.get("session_start")!( | |
| 239 | { systemPrompt: "" }, | |
| 240 | { ui: { notify: () => undefined } }, | |
| 241 | ); | |
| 242 | bridge.restoreActiveTools([ | |
| 243 | "read", | |
| 244 | "mcp__t3-code__task_status", | |
| 245 | "mcp__t3-code__preview_snapshot", | |
| 246 | ]); | |
| 247 | const tree = bridge.handlers.get("session_tree"); | |
| 248 | assert.isDefined(tree); | |
| 249 | await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } }); | |
| 250 | assert.deepEqual(bridge.getActiveTools(), [ | |
| 251 | "read", | |
| 252 | "mcp__t3-code__task_status", | |
| 253 | "mcp__t3-code__preview_snapshot", | |
| 254 | ...(!excludeSearch ? ["tool_search"] : []), | |
| 255 | ]); | |
| 256 | assert.isFalse( | |
| 257 | bridge.tools.some( | |
| 258 | (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"), | |
| 259 | ), | |
| 260 | ); | |
| 261 | }, | |
| 262 | ); | |
| 263 | }); | |
| 264 | ||
| 265 | describe("Pi tool discovery permissions", () => { | |
| 266 | it("allows discovery without confirmation and still gates the discovered tool", async () => { | |
| 267 | type ToolCallHook = ( | |
| 268 | event: { toolName: string; input: unknown }, | |
| 269 | ctx: { ui: { confirm: (title: string, detail: string) => Promise<boolean> } }, | |
| 270 | ) => Promise<{ block: true; reason: string } | undefined>; | |
| 271 | let toolCall: ToolCallHook | undefined; | |
| 272 | let searchPath = "builtin:tool-search"; | |
| 273 | const source = NodeModule.stripTypeScriptTypes( | |
| 274 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 275 | "export default async function", | |
| 276 | "async function", | |
| 277 | ), | |
| 278 | ); | |
| 279 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 280 | process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } }, | |
| 281 | pi: { | |
| 282 | on: (name: string, handler: ToolCallHook) => { | |
| 283 | if (name === "tool_call") toolCall = handler; | |
| 284 | }, | |
| 285 | getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }], | |
| 286 | }, | |
| 287 | }); | |
| 288 | assert.isDefined(toolCall); | |
| 289 | const confirmations: string[] = []; | |
| 290 | const ctx = { | |
| 291 | ui: { | |
| 292 | confirm: async (title: string) => { | |
| 293 | confirmations.push(title); | |
| 294 | return false; | |
| 295 | }, | |
| 296 | }, | |
| 297 | }; | |
| 298 | assert.isUndefined( | |
| 299 | await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx), | |
| 300 | ); | |
| 301 | assert.equal(confirmations.length, 0); | |
| 302 | const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx); | |
| 303 | assert.equal(result?.block, true); | |
| 304 | assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]); | |
| 305 | ||
| 306 | // An extension that replaces the search builtin is not known to be read-only. | |
| 307 | searchPath = "/extensions/custom-search.ts"; | |
| 308 | const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx); | |
| 309 | assert.equal(replaced?.block, true); | |
| 310 | assert.equal(confirmations.at(-1), "Allow tool_search?"); | |
| 311 | }); | |
| 312 | }); | |
| 313 | ||
| 314 | async function loadRequestHook(): Promise<RequestHook> { | |
| 315 | const handlers = new Map<string, RequestHook>(); | |
| 316 | // Execute the shipped extension with MCP disabled; this path needs no Typebox. | |
| 317 | const source = NodeModule.stripTypeScriptTypes( | |
| 318 | PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( | |
| 319 | "export default async function", | |
| 320 | "async function", | |
| 321 | ), | |
| 322 | ); | |
| 323 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 324 | process: { env: {} }, | |
| 325 | pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) }, | |
| 326 | }); | |
| 327 | const hook = handlers.get("before_provider_request"); | |
| 328 | assert.isDefined(hook); | |
| 329 | return hook!; | |
| 330 | } | |
| 331 | ||
| 332 | describe("Pi upstream output-budget workaround", () => { | |
| 333 | it.each(["max_tokens", "max_completion_tokens"])( | |
| 334 | "caps %s without changing the conversation or tools", | |
| 335 | async (key) => { | |
| 336 | const hook = await loadRequestHook(); | |
| 337 | const payload = { | |
| 338 | model: "moonshotai/kimi-k2.6", | |
| 339 | messages: [{ role: "user", content: "hello" }], | |
| 340 | tools: [{ type: "function", function: { name: "read" } }], | |
| 341 | [key]: 231_969, | |
| 342 | }; | |
| 343 | const result = hook({ payload }, { model: { provider: "openrouter" } }); | |
| 344 | assert.equal(result?.[key], 32_768); | |
| 345 | assert.strictEqual(result?.messages, payload.messages); | |
| 346 | assert.strictEqual(result?.tools, payload.tools); | |
| 347 | assert.equal(result?.model, payload.model); | |
| 348 | assert.equal(payload[key], 231_969); | |
| 349 | }, | |
| 350 | ); | |
| 351 | ||
| 352 | it("preserves smaller budgets and other providers' payloads", async () => { | |
| 353 | const hook = await loadRequestHook(); | |
| 354 | for (const payload of [{ max_tokens: 8192 }, { max_completion_tokens: 32_768 }, {}, null]) { | |
| 355 | assert.isUndefined(hook({ payload }, { model: { provider: "openrouter" } })); | |
| 356 | } | |
| 357 | assert.isUndefined( | |
| 358 | hook({ payload: { max_tokens: 231_969 } }, { model: { provider: "anthropic" } }), | |
| 359 | ); | |
| 360 | }); | |
| 361 | }); |
packages/provider-pi/src/server/mcpExtensionSource.ts created+368| ... | ... | @@ -0,0 +1,368 @@ |
| 1 | /** | |
| 2 | * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. | |
| 3 | * | |
| 4 | * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions | |
| 5 | * keep them directly available. Pi loads this TypeScript via `--extension`; the | |
| 6 | * server writes it to a cache so packaged builds need no sibling .ts file. | |
| 7 | * | |
| 8 | * Do not import t3code modules from the string body. The Pi process resolves | |
| 9 | * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. | |
| 10 | */ | |
| 11 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 12 | ||
| 13 | export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; | |
| 14 | ||
| 15 | export const T3_MCP_URL_ENV = "T3_MCP_URL"; | |
| 16 | export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; | |
| 17 | export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; | |
| 18 | ||
| 19 | /** | |
| 20 | * Pi tools whose confirmations the bridge raises as file-change approvals. | |
| 21 | * Auto-accept edits skips them; the adapter keys the approval kind off them. | |
| 22 | */ | |
| 23 | export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; | |
| 24 | ||
| 25 | export const PI_T3_MCP_EXTENSION_SOURCE = `\ | |
| 26 | import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; | |
| 27 | import { Type } from "typebox"; | |
| 28 | ||
| 29 | const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; | |
| 30 | const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; | |
| 31 | const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; | |
| 32 | const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; | |
| 33 | const PROTOCOL = "2025-06-18"; | |
| 34 | const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); | |
| 35 | const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); | |
| 36 | ||
| 37 | type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; | |
| 38 | ||
| 39 | type JsonRpcResponse = { | |
| 40 | readonly id?: number | string; | |
| 41 | readonly result?: unknown; | |
| 42 | readonly error?: { readonly message?: string }; | |
| 43 | }; | |
| 44 | ||
| 45 | type McpTool = { | |
| 46 | readonly name: string; | |
| 47 | readonly description?: string; | |
| 48 | readonly inputSchema?: Record<string, unknown>; | |
| 49 | }; | |
| 50 | ||
| 51 | function env(name: string): string | undefined { | |
| 52 | const value = process.env[name]; | |
| 53 | return value && value.length > 0 ? value : undefined; | |
| 54 | } | |
| 55 | ||
| 56 | function runtimeMode(): RuntimeMode { | |
| 57 | const value = env(RUNTIME_MODE_ENV); | |
| 58 | return value === "approval-required" || | |
| 59 | value === "auto-accept-edits" || | |
| 60 | value === "auto" || | |
| 61 | value === "full-access" | |
| 62 | ? value | |
| 63 | : "full-access"; | |
| 64 | } | |
| 65 | ||
| 66 | function toolInputSummary(input: unknown): string { | |
| 67 | try { | |
| 68 | return JSON.stringify(input, null, 2).slice(0, 4_000); | |
| 69 | } catch { | |
| 70 | return String(input).slice(0, 4_000); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | function parseSseOrJson(body: string, contentType: string): JsonRpcResponse { | |
| 75 | if (contentType.includes("text/event-stream")) { | |
| 76 | for (const line of body.split("\\n")) { | |
| 77 | const trimmed = line.startsWith("data:") ? line.slice(5).trim() : ""; | |
| 78 | if (trimmed.length === 0) continue; | |
| 79 | const parsed = JSON.parse(trimmed) as JsonRpcResponse; | |
| 80 | if (parsed.id !== undefined || parsed.result !== undefined || parsed.error !== undefined) { | |
| 81 | return parsed; | |
| 82 | } | |
| 83 | } | |
| 84 | throw new Error("MCP SSE response had no JSON-RPC payload."); | |
| 85 | } | |
| 86 | return JSON.parse(body) as JsonRpcResponse; | |
| 87 | } | |
| 88 | ||
| 89 | function jsonSchemaToTypebox(schema: Record<string, unknown> | undefined) { | |
| 90 | const unsafe = (Type as { Unsafe?: (value: unknown) => unknown }).Unsafe; | |
| 91 | if (typeof unsafe === "function" && schema !== undefined) { | |
| 92 | return unsafe(schema); | |
| 93 | } | |
| 94 | return Type.Object({}, { additionalProperties: true }); | |
| 95 | } | |
| 96 | ||
| 97 | function formatMcpContent(result: unknown): string { | |
| 98 | if (result === null || result === undefined) return ""; | |
| 99 | if (typeof result !== "object") return String(result); | |
| 100 | const record = result as { | |
| 101 | readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; | |
| 102 | readonly structuredContent?: unknown; | |
| 103 | readonly isError?: boolean; | |
| 104 | }; | |
| 105 | const texts: string[] = []; | |
| 106 | if (Array.isArray(record.content)) { | |
| 107 | for (const part of record.content) { | |
| 108 | if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); | |
| 109 | } | |
| 110 | } | |
| 111 | // Most T3 tools mirror structuredContent in a text block. Repeating it would | |
| 112 | // leave T3's own output parsing two JSON documents instead of one. | |
| 113 | if (record.structuredContent !== undefined) { | |
| 114 | const structured = JSON.stringify(record.structuredContent); | |
| 115 | if (!texts.includes(structured)) texts.push(structured); | |
| 116 | } | |
| 117 | if (texts.length > 0) return texts.join("\\n"); | |
| 118 | return JSON.stringify(result); | |
| 119 | } | |
| 120 | ||
| 121 | function isMcpToolError(result: unknown): boolean { | |
| 122 | return ( | |
| 123 | typeof result === "object" && | |
| 124 | result !== null && | |
| 125 | "isError" in result && | |
| 126 | result.isError === true | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | function createMcpClient(endpoint: string, token: string) { | |
| 131 | let nextId = 1; | |
| 132 | let sessionId: string | undefined; | |
| 133 | ||
| 134 | const headers = (): Record<string, string> => { | |
| 135 | const next: Record<string, string> = { | |
| 136 | accept: "application/json, text/event-stream", | |
| 137 | authorization: token.startsWith("Bearer ") ? token : \`Bearer \${token}\`, | |
| 138 | "content-type": "application/json", | |
| 139 | // Effect's HTTP MCP rejects post-initialize requests without this | |
| 140 | // (400). The worktree client in McpHttpServer tests sends the same | |
| 141 | // header; initialize itself does not require it. | |
| 142 | "mcp-protocol-version": PROTOCOL, | |
| 143 | }; | |
| 144 | if (sessionId !== undefined) next["mcp-session-id"] = sessionId; | |
| 145 | return next; | |
| 146 | }; | |
| 147 | ||
| 148 | const request = async (method: string, params?: unknown, signal?: AbortSignal) => { | |
| 149 | const id = nextId++; | |
| 150 | const response = await fetch(endpoint, { | |
| 151 | method: "POST", | |
| 152 | headers: headers(), | |
| 153 | body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), | |
| 154 | signal, | |
| 155 | }); | |
| 156 | const nextSession = response.headers.get("mcp-session-id"); | |
| 157 | if (nextSession) sessionId = nextSession; | |
| 158 | const body = await response.text(); | |
| 159 | if (!response.ok) { | |
| 160 | throw new Error(\`MCP \${method} failed (\${response.status}): \${body.slice(0, 400)}\`); | |
| 161 | } | |
| 162 | if (body.length === 0) return undefined; | |
| 163 | const parsed = parseSseOrJson(body, response.headers.get("content-type") ?? ""); | |
| 164 | if (parsed.error) { | |
| 165 | throw new Error(parsed.error.message ?? \`MCP \${method} returned an error\`); | |
| 166 | } | |
| 167 | return parsed.result; | |
| 168 | }; | |
| 169 | ||
| 170 | const notify = async (method: string, params?: unknown, signal?: AbortSignal) => { | |
| 171 | await fetch(endpoint, { | |
| 172 | method: "POST", | |
| 173 | headers: headers(), | |
| 174 | body: JSON.stringify({ jsonrpc: "2.0", method, params }), | |
| 175 | signal, | |
| 176 | }); | |
| 177 | }; | |
| 178 | ||
| 179 | return { | |
| 180 | async connect(signal?: AbortSignal) { | |
| 181 | await request( | |
| 182 | "initialize", | |
| 183 | { | |
| 184 | protocolVersion: PROTOCOL, | |
| 185 | capabilities: {}, | |
| 186 | clientInfo: { name: "t3-pi-mcp", version: "1.0.0" }, | |
| 187 | }, | |
| 188 | signal, | |
| 189 | ); | |
| 190 | await notify("notifications/initialized", {}, signal).catch(() => undefined); | |
| 191 | }, | |
| 192 | async listTools(signal?: AbortSignal) { | |
| 193 | const tools: McpTool[] = []; | |
| 194 | let cursor: string | undefined; | |
| 195 | do { | |
| 196 | const result = (await request( | |
| 197 | "tools/list", | |
| 198 | cursor === undefined ? {} : { cursor }, | |
| 199 | signal, | |
| 200 | )) as { tools?: McpTool[]; nextCursor?: string } | undefined; | |
| 201 | tools.push(...(result?.tools ?? [])); | |
| 202 | cursor = result?.nextCursor; | |
| 203 | } while (cursor); | |
| 204 | return tools; | |
| 205 | }, | |
| 206 | async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal) { | |
| 207 | return request("tools/call", { name, arguments: args }, signal); | |
| 208 | }, | |
| 209 | }; | |
| 210 | } | |
| 211 | ||
| 212 | export default async function t3McpExtension(pi: ExtensionAPI) { | |
| 213 | // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the | |
| 214 | // previous response's usage even when a fork's instructions/tools differ, | |
| 215 | // then reserves almost all remaining context for output. OpenRouter can | |
| 216 | // reject even a short conversation. Remove this cap when Pi accounts for | |
| 217 | // the current request prefix reliably (api/simple-options + utils/estimate). | |
| 218 | pi.on("before_provider_request", (event, ctx) => { | |
| 219 | if (ctx.model?.provider !== "openrouter") return; | |
| 220 | const payload = event.payload; | |
| 221 | if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return; | |
| 222 | const replacement = { ...payload } as Record<string, unknown>; | |
| 223 | let changed = false; | |
| 224 | for (const key of ["max_tokens", "max_completion_tokens"]) { | |
| 225 | const limit = replacement[key]; | |
| 226 | if (typeof limit === "number" && Number.isFinite(limit) && limit > 32_768) { | |
| 227 | replacement[key] = 32_768; | |
| 228 | changed = true; | |
| 229 | } | |
| 230 | } | |
| 231 | if (changed) return replacement; | |
| 232 | }); | |
| 233 | ||
| 234 | // Pi deliberately leaves permission policy to extensions. T3's injected | |
| 235 | // bridge uses Pi's public blocking tool hook so the shared runtime modes | |
| 236 | // keep their normal meaning without replacing or shadowing Pi's runtime. | |
| 237 | // Only Pi's own search is known to be read-only. An extension that replaces | |
| 238 | // it keeps the name, and cannot discover deferred tools either. | |
| 239 | const hasBuiltinToolSearch = () => | |
| 240 | typeof pi.getAllTools === "function" && | |
| 241 | pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search"); | |
| 242 | ||
| 243 | pi.on("tool_call", async (event, ctx) => { | |
| 244 | const mode = runtimeMode(); | |
| 245 | if (mode === "full-access") return; | |
| 246 | if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) { | |
| 247 | return; | |
| 248 | } | |
| 249 | if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) { | |
| 250 | return; | |
| 251 | } | |
| 252 | const approved = await ctx.ui.confirm( | |
| 253 | \`Allow \${event.toolName}?\`, | |
| 254 | toolInputSummary(event.input), | |
| 255 | ); | |
| 256 | if (!approved) { | |
| 257 | return { block: true, reason: \`\${event.toolName} was declined in T3 Code.\` }; | |
| 258 | } | |
| 259 | }); | |
| 260 | ||
| 261 | const endpoint = env(URL_ENV); | |
| 262 | const token = env(TOKEN_ENV); | |
| 263 | if (endpoint === undefined || token === undefined) { | |
| 264 | pi.on("session_start", async (_event, ctx) => { | |
| 265 | ctx.ui.notify( | |
| 266 | "t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.", | |
| 267 | "warning", | |
| 268 | ); | |
| 269 | }); | |
| 270 | return; | |
| 271 | } | |
| 272 | ||
| 273 | // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP | |
| 274 | // bridge as the credential owner: mcp.json overrides native registrations. | |
| 275 | const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function"; | |
| 276 | const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]); | |
| 277 | let deferOptionalTools = supportsExposure; | |
| 278 | let catalog: ReadonlyArray<McpTool> = []; | |
| 279 | ||
| 280 | const client = createMcpClient(endpoint, token); | |
| 281 | let started: Promise<void> | undefined; | |
| 282 | ||
| 283 | const registerTools = () => { | |
| 284 | // Preserve public names for saved loadouts and tool selectors. Hidden | |
| 285 | // canonical names reserve ownership against Pi's configured MCP servers. | |
| 286 | const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"]; | |
| 287 | for (const tool of catalog) { | |
| 288 | const name = tool.name; | |
| 289 | for (const prefix of prefixes) { | |
| 290 | const exposure = prefix === "mcp__t3_code__" ? "hidden" : | |
| 291 | deferOptionalTools && !directTools.has(name) ? "deferred" : "direct"; | |
| 292 | pi.registerTool({ | |
| 293 | name: \`\${prefix}\${name}\`, | |
| 294 | label: name, | |
| 295 | description: tool.description ?? name, | |
| 296 | parameters: jsonSchemaToTypebox(tool.inputSchema), | |
| 297 | ...(supportsExposure ? { exposure } : {}), | |
| 298 | async execute(_toolCallId, params, signal) { | |
| 299 | const result = await client.callTool( | |
| 300 | name, | |
| 301 | (params ?? {}) as Record<string, unknown>, | |
| 302 | signal, | |
| 303 | ); | |
| 304 | const text = formatMcpContent(result); | |
| 305 | return { | |
| 306 | content: [{ type: "text", text }], | |
| 307 | details: { server: "t3-code", tool: name }, | |
| 308 | ...(isMcpToolError(result) ? { isError: true } : {}), | |
| 309 | }; | |
| 310 | }, | |
| 311 | }); | |
| 312 | } | |
| 313 | } | |
| 314 | }; | |
| 315 | ||
| 316 | const ensureStarted = () => { | |
| 317 | if (started !== undefined) return started; | |
| 318 | const attempt = (async () => { | |
| 319 | const signal = AbortSignal.timeout(10_000); | |
| 320 | await client.connect(signal); | |
| 321 | catalog = await client.listTools(signal); | |
| 322 | registerTools(); | |
| 323 | })(); | |
| 324 | started = attempt; | |
| 325 | void attempt.catch(() => { | |
| 326 | if (started === attempt) started = undefined; | |
| 327 | }); | |
| 328 | return attempt; | |
| 329 | }; | |
| 330 | ||
| 331 | // CLI extensions load before builtins, and Pi keeps the first registration | |
| 332 | // of a tool name. Register now so the bridge owns the T3 namespace even when | |
| 333 | // mcp.json configures it; retry a failed connection at session_start. | |
| 334 | await ensureStarted().catch(() => undefined); | |
| 335 | ||
| 336 | const reconcileDiscovery = () => { | |
| 337 | if (!supportsExposure) return; | |
| 338 | // A disabled or replaced search builtin cannot discover deferred tools. | |
| 339 | const hasToolSearch = hasBuiltinToolSearch(); | |
| 340 | const exposureChanged = deferOptionalTools !== hasToolSearch; | |
| 341 | deferOptionalTools = hasToolSearch; | |
| 342 | if (exposureChanged) registerTools(); | |
| 343 | if (hasToolSearch) { | |
| 344 | const active = pi.getActiveTools(); | |
| 345 | if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]); | |
| 346 | } | |
| 347 | }; | |
| 348 | ||
| 349 | // Tree navigation restores its saved loadout after session_start. | |
| 350 | pi.on("session_tree", reconcileDiscovery); | |
| 351 | pi.on("session_start", async (_event, ctx) => { | |
| 352 | reconcileDiscovery(); | |
| 353 | try { | |
| 354 | await ensureStarted(); | |
| 355 | } catch (error) { | |
| 356 | const message = error instanceof Error ? error.message : String(error); | |
| 357 | ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning"); | |
| 358 | } | |
| 359 | }); | |
| 360 | ||
| 361 | // Deliver orchestration guidance through pi's real system-prompt channel. | |
| 362 | // Wrapping the first user message instead would stop it from starting | |
| 363 | // with "/" and silently break slash-command expansion. | |
| 364 | pi.on("before_agent_start", (event) => ({ | |
| 365 | systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS, | |
| 366 | })); | |
| 367 | } | |
| 368 | `; |
packages/provider-pi/src/server/mcpInjection.test.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | ||
| 7 | import { | |
| 8 | PI_T3_MCP_EXTENSION_FILENAME, | |
| 9 | T3_MCP_BEARER_ENV, | |
| 10 | T3_MCP_URL_ENV, | |
| 11 | T3_PI_RUNTIME_MODE_ENV, | |
| 12 | } from "./mcpExtensionSource.ts"; | |
| 13 | import { | |
| 14 | buildPiRpcLaunch, | |
| 15 | materializePiT3McpExtension, | |
| 16 | resolvePiLaunchArgs, | |
| 17 | } from "./mcpInjection.ts"; | |
| 18 | ||
| 19 | const threadId = ThreadId.make("thread-pi-t3-mcp"); | |
| 20 | ||
| 21 | const mcpSession = { | |
| 22 | environmentId: EnvironmentId.make("environment-pi-t3-mcp"), | |
| 23 | threadId, | |
| 24 | providerSessionId: "mcp-session-pi", | |
| 25 | providerInstanceId: ProviderInstanceId.make("pi"), | |
| 26 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 27 | authorizationHeader: "Bearer secret-pi-token", | |
| 28 | browserToolsAvailable: true, | |
| 29 | }; | |
| 30 | ||
| 31 | describe("pi T3 MCP injection", () => { | |
| 32 | it("always adds the permission bridge and configures MCP when available", () => { | |
| 33 | const resolvedArgs = resolvePiLaunchArgs( | |
| 34 | "--extension=/home/user/.pi/agent/extensions/demo.ts --session-dir=/tmp/pi-sessions --provider=anthropic --model=claude-sonnet --tools='' --name=-review --extension-flag=kept", | |
| 35 | ); | |
| 36 | assert.isTrue(resolvedArgs.ok); | |
| 37 | if (!resolvedArgs.ok) return; | |
| 38 | const launch = buildPiRpcLaunch({ | |
| 39 | launchArgs: resolvedArgs.args, | |
| 40 | environment: { PATH: "/usr/bin" }, | |
| 41 | mcpSession, | |
| 42 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 43 | runtimeMode: "approval-required", | |
| 44 | }); | |
| 45 | assert.deepEqual(launch.args, [ | |
| 46 | "--mode", | |
| 47 | "rpc", | |
| 48 | "--extension", | |
| 49 | "/home/user/.pi/agent/extensions/demo.ts", | |
| 50 | "--session-dir", | |
| 51 | "/tmp/pi-sessions", | |
| 52 | "--provider", | |
| 53 | "anthropic", | |
| 54 | "--model", | |
| 55 | "claude-sonnet", | |
| 56 | "--tools", | |
| 57 | "", | |
| 58 | "--name", | |
| 59 | "-review", | |
| 60 | "--extension-flag=kept", | |
| 61 | "--extension", | |
| 62 | "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 63 | ]); | |
| 64 | assert.notInclude(launch.args, "--no-extensions"); | |
| 65 | assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); | |
| 66 | assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); | |
| 67 | assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); | |
| 68 | ||
| 69 | const permissionOnly = buildPiRpcLaunch({ | |
| 70 | launchArgs: [], | |
| 71 | environment: { | |
| 72 | [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale", | |
| 73 | [T3_MCP_BEARER_ENV]: "stale-token", | |
| 74 | }, | |
| 75 | mcpSession: undefined, | |
| 76 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 77 | runtimeMode: "auto-accept-edits", | |
| 78 | }); | |
| 79 | assert.deepEqual(permissionOnly.args, [ | |
| 80 | "--mode", | |
| 81 | "rpc", | |
| 82 | "--extension", | |
| 83 | "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 84 | ]); | |
| 85 | assert.isFalse(permissionOnly.hasT3Mcp); | |
| 86 | assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]); | |
| 87 | assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]); | |
| 88 | assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits"); | |
| 89 | }); | |
| 90 | ||
| 91 | it("falls back to Pi's first supported mode for legacy auto threads", () => { | |
| 92 | const launch = buildPiRpcLaunch({ | |
| 93 | launchArgs: [], | |
| 94 | environment: {}, | |
| 95 | mcpSession: undefined, | |
| 96 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 97 | runtimeMode: "auto", | |
| 98 | }); | |
| 99 | ||
| 100 | assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); | |
| 101 | }); | |
| 102 | ||
| 103 | it("forces tools and user extensions off for unattended text generation", () => { | |
| 104 | const launch = buildPiRpcLaunch({ | |
| 105 | launchArgs: [ | |
| 106 | "--tools", | |
| 107 | "read,write", | |
| 108 | "--extension", | |
| 109 | "/home/user/.pi/agent/extensions/demo.ts", | |
| 110 | "--extension=./second.ts", | |
| 111 | "--provider", | |
| 112 | "anthropic", | |
| 113 | ], | |
| 114 | environment: {}, | |
| 115 | mcpSession, | |
| 116 | extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts", | |
| 117 | ephemeral: true, | |
| 118 | disableExtensions: true, | |
| 119 | disableTools: true, | |
| 120 | }); | |
| 121 | assert.deepEqual(launch.args, [ | |
| 122 | "--mode", | |
| 123 | "rpc", | |
| 124 | "--no-session", | |
| 125 | "--provider", | |
| 126 | "anthropic", | |
| 127 | "--no-extensions", | |
| 128 | "--no-tools", | |
| 129 | ]); | |
| 130 | assert.isFalse(launch.hasT3Mcp); | |
| 131 | assert.deepInclude(resolvePiLaunchArgs("--mode text"), { | |
| 132 | ok: false, | |
| 133 | message: "Pi launch argument '--mode' is controlled by T3 Code and cannot be overridden.", | |
| 134 | }); | |
| 135 | assert.deepInclude(resolvePiLaunchArgs("--session old.jsonl"), { ok: false }); | |
| 136 | assert.deepInclude(resolvePiLaunchArgs("prompt pi immediately"), { ok: false }); | |
| 137 | assert.deepInclude(resolvePiLaunchArgs("--plan @instructions.md"), { ok: false }); | |
| 138 | }); | |
| 139 | ||
| 140 | it("rejects --provider without --model, which Pi 1.0 refuses at startup", () => { | |
| 141 | const rejection = { | |
| 142 | ok: false, | |
| 143 | message: "Pi launch argument '--provider' requires '--model'.", | |
| 144 | }; | |
| 145 | assert.deepInclude(resolvePiLaunchArgs("--provider openrouter"), rejection); | |
| 146 | assert.deepInclude(resolvePiLaunchArgs("--provider=openrouter --models gpt-6"), rejection); | |
| 147 | assert.isTrue(resolvePiLaunchArgs("--provider openrouter --model=deepseek/v4").ok); | |
| 148 | assert.isTrue(resolvePiLaunchArgs("--model deepseek/v4").ok); | |
| 149 | }); | |
| 150 | ||
| 151 | it.effect("materializes the MCP bridge with namespaced tool registration", () => | |
| 152 | Effect.gen(function* () { | |
| 153 | const fs = yield* FileSystem.FileSystem; | |
| 154 | const cacheDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-extensions-" }); | |
| 155 | const mcpDest = yield* materializePiT3McpExtension(cacheDir); | |
| 156 | assert.isTrue(mcpDest.endsWith(PI_T3_MCP_EXTENSION_FILENAME)); | |
| 157 | const mcpSource = yield* fs.readFileString(mcpDest); | |
| 158 | assert.include(mcpSource, "export default async function t3McpExtension"); | |
| 159 | assert.include(mcpSource, "before_agent_start"); | |
| 160 | assert.include(mcpSource, 'pi.on("tool_call"'); | |
| 161 | assert.include(mcpSource, "Allow ${event.toolName}?"); | |
| 162 | assert.include(mcpSource, '"mcp-protocol-version"'); | |
| 163 | assert.include(mcpSource, '"tools/call"'); | |
| 164 | assert.include(mcpSource, "mcp__t3-code__"); | |
| 165 | assert.include(mcpSource, "mcp__t3_code__"); | |
| 166 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 167 | ); | |
| 168 | }); |
packages/provider-pi/src/server/mcpInjection.ts created+316| ... | ... | @@ -0,0 +1,316 @@ |
| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | ||
| 5 | import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; | |
| 6 | import { | |
| 7 | PI_T3_MCP_EXTENSION_FILENAME, | |
| 8 | PI_T3_MCP_EXTENSION_SOURCE, | |
| 9 | T3_MCP_BEARER_ENV, | |
| 10 | T3_MCP_URL_ENV, | |
| 11 | T3_PI_RUNTIME_MODE_ENV, | |
| 12 | } from "./mcpExtensionSource.ts"; | |
| 13 | ||
| 14 | const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ | |
| 15 | "--continue", | |
| 16 | "-c", | |
| 17 | "--export", | |
| 18 | "--fork", | |
| 19 | "--help", | |
| 20 | "-h", | |
| 21 | "--list-models", | |
| 22 | "--mode", | |
| 23 | "--no-session", | |
| 24 | "--print", | |
| 25 | "-p", | |
| 26 | "--resume", | |
| 27 | "-r", | |
| 28 | "--session", | |
| 29 | "--session-id", | |
| 30 | "--version", | |
| 31 | "-v", | |
| 32 | ]); | |
| 33 | ||
| 34 | const PI_ARGUMENTS_WITH_VALUES = new Set([ | |
| 35 | "--api-key", | |
| 36 | "--append-system-prompt", | |
| 37 | "--exclude-tools", | |
| 38 | "-xt", | |
| 39 | "--extension", | |
| 40 | "-e", | |
| 41 | "--model", | |
| 42 | "--models", | |
| 43 | "--name", | |
| 44 | "-n", | |
| 45 | "--prompt-template", | |
| 46 | "--provider", | |
| 47 | "--session-dir", | |
| 48 | "--skill", | |
| 49 | "--system-prompt", | |
| 50 | "--theme", | |
| 51 | "--thinking", | |
| 52 | "--tools", | |
| 53 | "-t", | |
| 54 | "--tui-mode", | |
| 55 | "--use-theme", | |
| 56 | ]); | |
| 57 | ||
| 58 | const PI_ARGUMENTS_WITHOUT_VALUES = new Set([ | |
| 59 | "--approve", | |
| 60 | "-a", | |
| 61 | "--no-approve", | |
| 62 | "-na", | |
| 63 | "--no-builtin-tools", | |
| 64 | "-nbt", | |
| 65 | "--no-context-files", | |
| 66 | "-nc", | |
| 67 | "--no-extensions", | |
| 68 | "-ne", | |
| 69 | "--no-prompt-templates", | |
| 70 | "-np", | |
| 71 | "--no-skills", | |
| 72 | "-ns", | |
| 73 | "--no-themes", | |
| 74 | "--no-tools", | |
| 75 | "-nt", | |
| 76 | "--offline", | |
| 77 | "--verbose", | |
| 78 | ]); | |
| 79 | ||
| 80 | export type PiLaunchArgsResolution = | |
| 81 | | { readonly ok: true; readonly args: ReadonlyArray<string> } | |
| 82 | | { readonly ok: false; readonly message: string }; | |
| 83 | ||
| 84 | function reservedPiArgument(arg: string): string | undefined { | |
| 85 | for (const reserved of RESERVED_PI_LAUNCH_ARGUMENTS) { | |
| 86 | if (arg === reserved || (reserved.startsWith("--") && arg.startsWith(`${reserved}=`))) { | |
| 87 | return reserved; | |
| 88 | } | |
| 89 | } | |
| 90 | return undefined; | |
| 91 | } | |
| 92 | ||
| 93 | function normalizePiBuiltInEqualsArguments(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 94 | return args.flatMap((arg) => { | |
| 95 | const equalsIndex = arg.indexOf("="); | |
| 96 | if (equalsIndex <= 0) return [arg]; | |
| 97 | const option = arg.slice(0, equalsIndex); | |
| 98 | return PI_ARGUMENTS_WITH_VALUES.has(option) ? [option, arg.slice(equalsIndex + 1)] : [arg]; | |
| 99 | }); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Pi launch arguments may configure resources, models, tools, trust, and | |
| 104 | * storage. T3 owns RPC mode and session identity, so arguments that select a | |
| 105 | * different execution mode or native session are rejected before spawn. | |
| 106 | */ | |
| 107 | export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution { | |
| 108 | // Pi parses equals-form tokens only as extension flags, even when their name | |
| 109 | // matches a built-in option. Split known built-ins while leaving arbitrary | |
| 110 | // extension flags in their native form. | |
| 111 | const args = normalizePiBuiltInEqualsArguments(tokenizeCliArgs(launchArgs)); | |
| 112 | let hasProvider = false; | |
| 113 | let hasModel = false; | |
| 114 | for (let index = 0; index < args.length; index += 1) { | |
| 115 | const arg = args[index]; | |
| 116 | if (arg === undefined) continue; | |
| 117 | const reserved = reservedPiArgument(arg); | |
| 118 | if (reserved !== undefined) { | |
| 119 | return { | |
| 120 | ok: false, | |
| 121 | message: `Pi launch argument '${reserved}' is controlled by T3 Code and cannot be overridden.`, | |
| 122 | }; | |
| 123 | } | |
| 124 | if (arg === "--") { | |
| 125 | return { | |
| 126 | ok: false, | |
| 127 | message: "Pi launch arguments cannot include positional prompts.", | |
| 128 | }; | |
| 129 | } | |
| 130 | if (PI_ARGUMENTS_WITH_VALUES.has(arg)) { | |
| 131 | const value = args[index + 1]; | |
| 132 | if (value === undefined) { | |
| 133 | return { ok: false, message: `Pi launch argument '${arg}' requires a value.` }; | |
| 134 | } | |
| 135 | if (arg === "--provider") hasProvider = true; | |
| 136 | if (arg === "--model") hasModel = true; | |
| 137 | index += 1; | |
| 138 | continue; | |
| 139 | } | |
| 140 | if (PI_ARGUMENTS_WITHOUT_VALUES.has(arg) || (arg.startsWith("--") && arg.includes("="))) { | |
| 141 | continue; | |
| 142 | } | |
| 143 | if (arg.startsWith("--")) { | |
| 144 | // Pi extensions may register arbitrary long flags. Treat one following | |
| 145 | // non-flag token as that extension flag's value. | |
| 146 | if ( | |
| 147 | args[index + 1] !== undefined && | |
| 148 | !args[index + 1]!.startsWith("-") && | |
| 149 | !args[index + 1]!.startsWith("@") | |
| 150 | ) { | |
| 151 | index += 1; | |
| 152 | } | |
| 153 | continue; | |
| 154 | } | |
| 155 | if (arg.startsWith("-")) { | |
| 156 | return { ok: false, message: `Pi launch argument '${arg}' is not supported by T3 Code.` }; | |
| 157 | } | |
| 158 | return { | |
| 159 | ok: false, | |
| 160 | message: `Pi launch arguments cannot include positional prompt '${arg}'.`, | |
| 161 | }; | |
| 162 | } | |
| 163 | // Pi 1.0 exits at startup on `--provider` without `--model`; older versions | |
| 164 | // silently ran another provider's default model. Rejecting it here makes | |
| 165 | // every spawn site report the reason instead of a bare exit code. | |
| 166 | if (hasProvider && !hasModel) { | |
| 167 | return { ok: false, message: "Pi launch argument '--provider' requires '--model'." }; | |
| 168 | } | |
| 169 | return { ok: true, args }; | |
| 170 | } | |
| 171 | ||
| 172 | function bearerTokenFromAuthorizationHeader(header: string): string { | |
| 173 | return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header; | |
| 174 | } | |
| 175 | ||
| 176 | function normalizedPiPath(value: string): string { | |
| 177 | return value.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 178 | } | |
| 179 | ||
| 180 | function hasExplicitExtension(args: ReadonlyArray<string>, extensionPath: string): boolean { | |
| 181 | const wanted = normalizedPiPath(extensionPath); | |
| 182 | for (let index = 0; index < args.length; index += 1) { | |
| 183 | const arg = args[index]; | |
| 184 | if (arg !== "--extension" && arg !== "-e") continue; | |
| 185 | const configured = args[index + 1]; | |
| 186 | if (configured !== undefined && normalizedPiPath(configured) === wanted) return true; | |
| 187 | index += 1; | |
| 188 | } | |
| 189 | return false; | |
| 190 | } | |
| 191 | ||
| 192 | function withoutExplicitExtensions(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 193 | const filtered: string[] = []; | |
| 194 | for (let index = 0; index < args.length; index += 1) { | |
| 195 | const arg = args[index]; | |
| 196 | if (arg === undefined) continue; | |
| 197 | if (arg === "--extension" || arg === "-e") { | |
| 198 | index += 1; | |
| 199 | continue; | |
| 200 | } | |
| 201 | if (arg.startsWith("--extension=") || arg.startsWith("-e=")) continue; | |
| 202 | filtered.push(arg); | |
| 203 | } | |
| 204 | return filtered; | |
| 205 | } | |
| 206 | ||
| 207 | function withoutToolSelectionArgs(args: ReadonlyArray<string>): ReadonlyArray<string> { | |
| 208 | const filtered: string[] = []; | |
| 209 | for (let index = 0; index < args.length; index += 1) { | |
| 210 | const arg = args[index]; | |
| 211 | if (arg === undefined) continue; | |
| 212 | if (arg === "--tools" || arg === "-t" || arg === "--exclude-tools" || arg === "-xt") { | |
| 213 | index += 1; | |
| 214 | continue; | |
| 215 | } | |
| 216 | if ( | |
| 217 | arg === "--no-tools" || | |
| 218 | arg === "-nt" || | |
| 219 | arg === "--no-builtin-tools" || | |
| 220 | arg === "-nbt" || | |
| 221 | arg.startsWith("--tools=") || | |
| 222 | arg.startsWith("-t=") || | |
| 223 | arg.startsWith("--exclude-tools=") || | |
| 224 | arg.startsWith("-xt=") | |
| 225 | ) { | |
| 226 | continue; | |
| 227 | } | |
| 228 | filtered.push(arg); | |
| 229 | } | |
| 230 | return filtered; | |
| 231 | } | |
| 232 | ||
| 233 | function piT3McpExtensionDestPath(cacheDir: string): string { | |
| 234 | return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`; | |
| 235 | } | |
| 236 | ||
| 237 | export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* ( | |
| 238 | cacheDir: string, | |
| 239 | ) { | |
| 240 | const fs = yield* FileSystem.FileSystem; | |
| 241 | yield* fs.makeDirectory(cacheDir, { recursive: true }); | |
| 242 | const dest = piT3McpExtensionDestPath(cacheDir); | |
| 243 | const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => "")); | |
| 244 | if (existing !== PI_T3_MCP_EXTENSION_SOURCE) { | |
| 245 | yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE); | |
| 246 | } | |
| 247 | return dest; | |
| 248 | }); | |
| 249 | ||
| 250 | export function buildPiRpcLaunch(input: { | |
| 251 | readonly launchArgs: ReadonlyArray<string>; | |
| 252 | readonly environment: NodeJS.ProcessEnv; | |
| 253 | readonly mcpSession: McpProviderSessionConfig | undefined; | |
| 254 | readonly extensionPath: string | undefined; | |
| 255 | readonly ephemeral?: boolean; | |
| 256 | readonly disableExtensions?: boolean; | |
| 257 | readonly disableTools?: boolean; | |
| 258 | readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; | |
| 259 | }): { | |
| 260 | readonly args: ReadonlyArray<string>; | |
| 261 | readonly env: NodeJS.ProcessEnv; | |
| 262 | readonly hasT3Mcp: boolean; | |
| 263 | } { | |
| 264 | const hasT3Extension = input.disableExtensions !== true && input.extensionPath !== undefined; | |
| 265 | const hasT3Mcp = hasT3Extension && input.mcpSession !== undefined; | |
| 266 | const extensionSafeArgs = | |
| 267 | input.disableExtensions === true | |
| 268 | ? withoutExplicitExtensions(input.launchArgs) | |
| 269 | : input.launchArgs; | |
| 270 | const launchArgs = | |
| 271 | input.disableTools === true ? withoutToolSelectionArgs(extensionSafeArgs) : extensionSafeArgs; | |
| 272 | const args = [ | |
| 273 | "--mode", | |
| 274 | "rpc", | |
| 275 | ...(input.ephemeral === true ? ["--no-session"] : []), | |
| 276 | ...launchArgs, | |
| 277 | // Restrictions follow user launch args so a configured --tools or | |
| 278 | // --extension cannot silently re-enable unattended text-generation code. | |
| 279 | ...(input.disableExtensions === true ? ["--no-extensions"] : []), | |
| 280 | ...(input.disableTools === true ? ["--no-tools"] : []), | |
| 281 | ]; | |
| 282 | if ( | |
| 283 | hasT3Extension && | |
| 284 | input.extensionPath !== undefined && | |
| 285 | !hasExplicitExtension(args, input.extensionPath) | |
| 286 | ) { | |
| 287 | args.push("--extension", input.extensionPath); | |
| 288 | } | |
| 289 | const environment = { ...input.environment }; | |
| 290 | // These values belong to the current T3 session. Never let a Pi child reuse | |
| 291 | // credentials inherited from the server or a parent provider process. | |
| 292 | delete environment[T3_MCP_URL_ENV]; | |
| 293 | delete environment[T3_MCP_BEARER_ENV]; | |
| 294 | ||
| 295 | return { | |
| 296 | args, | |
| 297 | env: { | |
| 298 | ...environment, | |
| 299 | ...(hasT3Extension && input.runtimeMode !== undefined | |
| 300 | ? { | |
| 301 | [T3_PI_RUNTIME_MODE_ENV]: | |
| 302 | input.runtimeMode === "auto" ? "approval-required" : input.runtimeMode, | |
| 303 | } | |
| 304 | : {}), | |
| 305 | ...(hasT3Mcp && input.mcpSession !== undefined | |
| 306 | ? { | |
| 307 | [T3_MCP_URL_ENV]: input.mcpSession.endpoint, | |
| 308 | [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( | |
| 309 | input.mcpSession.authorizationHeader, | |
| 310 | ), | |
| 311 | } | |
| 312 | : {}), | |
| 313 | }, | |
| 314 | hasT3Mcp, | |
| 315 | }; | |
| 316 | } |
packages/provider-pi/src/server/rpc.ts created+484| ... | ... | @@ -0,0 +1,484 @@ |
| 1 | /** | |
| 2 | * PiRpc — stdio JSONL transport for the Pi coding agent's RPC mode. | |
| 3 | * | |
| 4 | * Spawns `pi --mode rpc` and speaks Pi's line-delimited JSON protocol: | |
| 5 | * requests go to stdin as `{"type": "...", "id": "..."}` records, responses | |
| 6 | * come back as `{"type": "response", "id": ..., "success": ...}` and are | |
| 7 | * correlated by `id`; every other stdout record is a session event and is | |
| 8 | * surfaced on the `events` queue in arrival order. | |
| 9 | * | |
| 10 | * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` | |
| 11 | * stripped. Lines are split manually (never with `readline`, which also | |
| 12 | * splits on U+2028/U+2029 and would corrupt frames). Records that fail to | |
| 13 | * parse as JSON are dropped with a debug log rather than failing the | |
| 14 | * transport, so a chatty extension cannot take the session down. | |
| 15 | * | |
| 16 | * Used by `PiAdapterV2` for sessions and by `PiTextGeneration` / | |
| 17 | * `PiProvider` for ephemeral one-shot processes. | |
| 18 | */ | |
| 19 | import * as Clock from "effect/Clock"; | |
| 20 | import * as Deferred from "effect/Deferred"; | |
| 21 | import * as Duration from "effect/Duration"; | |
| 22 | import * as Effect from "effect/Effect"; | |
| 23 | import * as Option from "effect/Option"; | |
| 24 | import * as Predicate from "effect/Predicate"; | |
| 25 | import * as Queue from "effect/Queue"; | |
| 26 | import * as Scope from "effect/Scope"; | |
| 27 | import * as Schema from "effect/Schema"; | |
| 28 | import * as Stream from "effect/Stream"; | |
| 29 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 30 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 31 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 32 | ||
| 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; | |
| 34 | ||
| 35 | export class PiRpcError extends Schema.TaggedError<PiRpcError>()("PiRpcError", { | |
| 36 | operation: Schema.String, | |
| 37 | detail: Schema.optional(Schema.String), | |
| 38 | cause: Schema.optional(Schema.Defect()), | |
| 39 | }) { | |
| 40 | override get message(): string { | |
| 41 | return `Pi RPC ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export class PiRpcTimeoutError extends Schema.TaggedError<PiRpcTimeoutError>()( | |
| 46 | "PiRpcTimeoutError", | |
| 47 | { | |
| 48 | operation: Schema.String, | |
| 49 | timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), | |
| 50 | }, | |
| 51 | ) { | |
| 52 | override get message(): string { | |
| 53 | return `Pi RPC ${this.operation} failed: timed out after ${this.timeoutMs}ms.`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | export type PiRpcRecord = Record<string, unknown>; | |
| 58 | ||
| 59 | export function piRecordField(input: unknown, key: string): unknown { | |
| 60 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 61 | } | |
| 62 | ||
| 63 | export function piRecordString(input: unknown, key: string): string | undefined { | |
| 64 | const value = piRecordField(input, key); | |
| 65 | return Predicate.isString(value) ? value : undefined; | |
| 66 | } | |
| 67 | ||
| 68 | export function piRecordNumber(input: unknown, key: string): number | undefined { | |
| 69 | const value = piRecordField(input, key); | |
| 70 | return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Splits a `provider/model` slug into the two fields `set_model` expects. | |
| 75 | * Returns null for slugs without a usable separator so callers can reject the | |
| 76 | * selection instead of silently leaving Pi on its configured default. | |
| 77 | */ | |
| 78 | export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { | |
| 79 | const separator = slug.indexOf("/"); | |
| 80 | if (separator <= 0 || separator === slug.length - 1) return null; | |
| 81 | return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; | |
| 82 | } | |
| 83 | ||
| 84 | export interface PiRpcSpawnOptions { | |
| 85 | readonly command: string; | |
| 86 | readonly args: ReadonlyArray<string>; | |
| 87 | readonly cwd: string | undefined; | |
| 88 | readonly env: NodeJS.ProcessEnv; | |
| 89 | } | |
| 90 | ||
| 91 | export interface PiRpcConnection { | |
| 92 | /** Fire-and-forget write (used for `extension_ui_response`). */ | |
| 93 | readonly send: (record: PiRpcRecord) => Effect.Effect<void, PiRpcError>; | |
| 94 | /** | |
| 95 | * Correlated request: assigns an `id`, waits for the matching response | |
| 96 | * record, and returns its `data` (undefined when the command carries none). | |
| 97 | * Fails on `success: false`, transport death, or timeout. | |
| 98 | */ | |
| 99 | readonly request: ( | |
| 100 | record: PiRpcRecord, | |
| 101 | timeoutMs?: number, | |
| 102 | ) => Effect.Effect<unknown, PiRpcError | PiRpcTimeoutError>; | |
| 103 | /** | |
| 104 | * Session events (every non-response stdout record) in arrival order. The | |
| 105 | * full queue is exposed so consumers can append order-preserving synthetic | |
| 106 | * records of their own (see PiAdapterV2's settle probe). | |
| 107 | */ | |
| 108 | readonly events: Queue.Queue<PiRpcRecord, PiRpcError>; | |
| 109 | /** Resolves when the process has exited, with its exit code. */ | |
| 110 | readonly exited: Effect.Effect<number, PiRpcError>; | |
| 111 | /** | |
| 112 | * Kill the pi process group immediately (SIGTERM, grace, SIGKILL). Used by | |
| 113 | * Stop-with-restart when the process may be wedged and `abort` cannot be | |
| 114 | * trusted to land. The transport fails and the session manager respawns a | |
| 115 | * fresh process on the next turn. | |
| 116 | */ | |
| 117 | readonly terminate: Effect.Effect<void>; | |
| 118 | } | |
| 119 | ||
| 120 | const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; | |
| 121 | const TERMINATION_GRACE = Duration.seconds(1); | |
| 122 | ||
| 123 | interface PendingPiRequest { | |
| 124 | readonly deferred: Deferred.Deferred<unknown, PiRpcError>; | |
| 125 | } | |
| 126 | ||
| 127 | const MAX_PI_RECORD_CHARS = 8 * 1024 * 1024; | |
| 128 | ||
| 129 | function makeJsonlFramer() { | |
| 130 | let buffer = ""; | |
| 131 | let dropping = false; | |
| 132 | return (chunk: string): ReadonlyArray<string> => { | |
| 133 | const lines: string[] = []; | |
| 134 | let start = 0; | |
| 135 | while (start < chunk.length) { | |
| 136 | const newline = chunk.indexOf("\n", start); | |
| 137 | const end = newline < 0 ? chunk.length : newline; | |
| 138 | if (!dropping) { | |
| 139 | if (buffer.length + end - start > MAX_PI_RECORD_CHARS) { | |
| 140 | buffer = ""; | |
| 141 | dropping = true; | |
| 142 | } else { | |
| 143 | buffer += chunk.slice(start, end); | |
| 144 | } | |
| 145 | } | |
| 146 | if (newline < 0) break; | |
| 147 | if (!dropping && buffer.length > 0) { | |
| 148 | lines.push(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer); | |
| 149 | } | |
| 150 | buffer = ""; | |
| 151 | dropping = false; | |
| 152 | start = newline + 1; | |
| 153 | } | |
| 154 | return lines; | |
| 155 | }; | |
| 156 | } | |
| 157 | ||
| 158 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | |
| 159 | const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); | |
| 160 | const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); | |
| 161 | ||
| 162 | const PI_ERROR_DETAIL_MAX_CHARS = 200; | |
| 163 | ||
| 164 | /** | |
| 165 | * Bounded, human-readable summary of a failed response's `error` payload. | |
| 166 | * The untruncated value stays on the error's `cause`, so `message` never | |
| 167 | * carries unbounded remote text while logs keep something diagnostic. | |
| 168 | */ | |
| 169 | function summarizePiError(error: unknown): string { | |
| 170 | const text = typeof error === "string" ? error : JSON.stringify(error); | |
| 171 | if (text === undefined) return "unknown error"; | |
| 172 | return text.length > PI_ERROR_DETAIL_MAX_CHARS | |
| 173 | ? `${text.slice(0, PI_ERROR_DETAIL_MAX_CHARS)}…` | |
| 174 | : text; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Stdout closure is not enough to diagnose an early crash: Pi 0.84+ can exit | |
| 179 | * 1 on import before writing any protocol line. The numeric exit code is | |
| 180 | * sanitized; stderr stays out of public details because it can carry | |
| 181 | * credentials or prompt text. | |
| 182 | */ | |
| 183 | function describePiStdoutClosure(exitCode: number | undefined): string { | |
| 184 | if (exitCode === undefined || !Number.isFinite(exitCode)) { | |
| 185 | return "pi process closed stdout"; | |
| 186 | } | |
| 187 | return `pi process exited with code ${exitCode}`; | |
| 188 | } | |
| 189 | ||
| 190 | function parsePiRecord(line: string): PiRpcRecord | undefined { | |
| 191 | try { | |
| 192 | const parsed: unknown = decodeJsonLine(line); | |
| 193 | return Predicate.isObject(parsed) ? parsed : undefined; | |
| 194 | } catch { | |
| 195 | return undefined; | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Kill the pi process group: SIGTERM, short grace, then SIGKILL. | |
| 201 | * | |
| 202 | * `hasExited` is consulted before each signal. Once the original child is | |
| 203 | * gone its pid/pgid can be recycled by the OS, so escalating blindly could | |
| 204 | * deliver SIGKILL to an unrelated process. | |
| 205 | */ | |
| 206 | const terminatePiProcess = (kill: (signal: NodeJS.Signals) => boolean, hasExited: () => boolean) => | |
| 207 | Effect.gen(function* () { | |
| 208 | if (hasExited()) return; | |
| 209 | if (!kill("SIGTERM")) return; | |
| 210 | yield* Effect.sleep(TERMINATION_GRACE); | |
| 211 | if (hasExited()) return; | |
| 212 | kill("SIGKILL"); | |
| 213 | }); | |
| 214 | ||
| 215 | export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSpawnOptions) { | |
| 216 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 217 | const platform = yield* HostProcessPlatform; | |
| 218 | const scope = yield* Effect.scope; | |
| 219 | ||
| 220 | const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { | |
| 221 | env: options.env, | |
| 222 | }).pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); | |
| 223 | const child = yield* spawner | |
| 224 | .spawn( | |
| 225 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 226 | ...(options.cwd === undefined ? {} : { cwd: options.cwd }), | |
| 227 | env: options.env, | |
| 228 | extendEnv: false, | |
| 229 | shell: spawnCommand.shell, | |
| 230 | detached: platform !== "win32", | |
| 231 | }), | |
| 232 | ) | |
| 233 | .pipe(Effect.mapError((cause) => new PiRpcError({ operation: "spawn", cause }))); | |
| 234 | ||
| 235 | let childExited = false; | |
| 236 | let diagnosingStdoutClose = false; | |
| 237 | ||
| 238 | const killProcessGroup = (signal: NodeJS.Signals): boolean => { | |
| 239 | try { | |
| 240 | if (platform === "win32") { | |
| 241 | process.kill(Number(child.pid), signal); | |
| 242 | } else { | |
| 243 | signalProcessGroup(Number(child.pid), signal); | |
| 244 | } | |
| 245 | return true; | |
| 246 | } catch { | |
| 247 | return false; | |
| 248 | } | |
| 249 | }; | |
| 250 | ||
| 251 | /** Signal 0 probes liveness without delivering anything. */ | |
| 252 | const hasExited = (): boolean => { | |
| 253 | if (childExited) return true; | |
| 254 | try { | |
| 255 | if (platform === "win32") process.kill(Number(child.pid), 0); | |
| 256 | else signalProcessGroup(Number(child.pid), 0); | |
| 257 | return false; | |
| 258 | } catch { | |
| 259 | return true; | |
| 260 | } | |
| 261 | }; | |
| 262 | ||
| 263 | /** | |
| 264 | * Windows has no process groups, so `process.kill` reaches only pi itself | |
| 265 | * and leaves extension subprocesses running with inherited stdio handles. | |
| 266 | * `taskkill /T` reaps the whole tree. | |
| 267 | */ | |
| 268 | const terminateWindowsTree = Effect.gen(function* () { | |
| 269 | if (hasExited()) return; | |
| 270 | const taskkill = yield* spawner.spawn( | |
| 271 | ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), | |
| 272 | ); | |
| 273 | yield* taskkill.exitCode; | |
| 274 | }).pipe(Effect.scoped, Effect.ignore); | |
| 275 | ||
| 276 | const terminateProcess = | |
| 277 | platform === "win32" ? terminateWindowsTree : terminatePiProcess(killProcessGroup, hasExited); | |
| 278 | ||
| 279 | // Registered before any further setup: an interrupt or failure between the | |
| 280 | // spawn and the rest of this constructor would otherwise leak a detached | |
| 281 | // pi process with no finalizer to reap it. | |
| 282 | yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); | |
| 283 | ||
| 284 | const pendingRequests = new Map<string, PendingPiRequest>(); | |
| 285 | const events = yield* Queue.unbounded<PiRpcRecord, PiRpcError>(); | |
| 286 | const outgoing = yield* Queue.unbounded<Uint8Array, PiRpcError>(); | |
| 287 | const transportDown = yield* Deferred.make<never, PiRpcError>(); | |
| 288 | const exitDeferred = yield* Deferred.make<number, PiRpcError>(); | |
| 289 | let nextRequestId = 0; | |
| 290 | ||
| 291 | const failTransport = (error: PiRpcError) => | |
| 292 | Effect.gen(function* () { | |
| 293 | const claimed = yield* Deferred.fail(transportDown, error); | |
| 294 | if (!claimed) return; | |
| 295 | for (const [key, pending] of pendingRequests) { | |
| 296 | pendingRequests.delete(key); | |
| 297 | yield* Deferred.fail(pending.deferred, error); | |
| 298 | } | |
| 299 | // Closing `outgoing` is what makes `send` non-racy: once the writer is | |
| 300 | // gone every later offer is refused rather than silently buffered. | |
| 301 | yield* Queue.fail(outgoing, error); | |
| 302 | yield* Queue.fail(events, error); | |
| 303 | }); | |
| 304 | ||
| 305 | const routeRecord = (record: PiRpcRecord) => | |
| 306 | Effect.gen(function* () { | |
| 307 | if (record["type"] === "response" && typeof record["id"] === "string") { | |
| 308 | const pending = pendingRequests.get(record["id"]); | |
| 309 | if (pending !== undefined) { | |
| 310 | pendingRequests.delete(record["id"]); | |
| 311 | if (record["success"] === true) { | |
| 312 | yield* Deferred.succeed(pending.deferred, record["data"]); | |
| 313 | } else { | |
| 314 | yield* Deferred.fail( | |
| 315 | pending.deferred, | |
| 316 | new PiRpcError({ | |
| 317 | operation: String(record["command"] ?? "request"), | |
| 318 | detail: summarizePiError(record["error"]), | |
| 319 | ...(record["error"] === undefined ? {} : { cause: record["error"] }), | |
| 320 | }), | |
| 321 | ); | |
| 322 | } | |
| 323 | return; | |
| 324 | } | |
| 325 | } | |
| 326 | yield* Queue.offer(events, record); | |
| 327 | }); | |
| 328 | ||
| 329 | // Watch exit before the reader so an immediate crash can populate | |
| 330 | // `exitDeferred` before stdout-close diagnosis runs. | |
| 331 | yield* child.exitCode.pipe( | |
| 332 | Effect.matchEffect({ | |
| 333 | onFailure: (cause) => | |
| 334 | Deferred.fail(exitDeferred, new PiRpcError({ operation: "exit", cause })), | |
| 335 | onSuccess: (code) => | |
| 336 | Effect.suspend(() => { | |
| 337 | childExited = true; | |
| 338 | return Deferred.succeed(exitDeferred, Number(code)); | |
| 339 | }), | |
| 340 | }), | |
| 341 | Effect.forkIn(scope), | |
| 342 | ); | |
| 343 | ||
| 344 | // Reader: decode stdout into LF-delimited JSON records. | |
| 345 | yield* Effect.gen(function* () { | |
| 346 | const frame = makeJsonlFramer(); | |
| 347 | yield* child.stdout.pipe( | |
| 348 | Stream.decodeText(), | |
| 349 | Stream.runForEach((chunk) => | |
| 350 | Effect.gen(function* () { | |
| 351 | const lines = frame(chunk); | |
| 352 | for (const line of lines) { | |
| 353 | const record = parsePiRecord(line); | |
| 354 | if (record === undefined) { | |
| 355 | yield* Effect.logDebug("Dropping non-JSON pi stdout line.", { | |
| 356 | lineLength: line.length, | |
| 357 | }); | |
| 358 | continue; | |
| 359 | } | |
| 360 | yield* routeRecord(record); | |
| 361 | } | |
| 362 | }), | |
| 363 | ), | |
| 364 | ); | |
| 365 | for (const line of frame("\n")) { | |
| 366 | const trailing = parsePiRecord(line); | |
| 367 | if (trailing !== undefined) yield* routeRecord(trailing); | |
| 368 | } | |
| 369 | }).pipe( | |
| 370 | Effect.matchCauseEffect({ | |
| 371 | onFailure: (cause) => failTransport(new PiRpcError({ operation: "read", cause })), | |
| 372 | onSuccess: () => | |
| 373 | Effect.gen(function* () { | |
| 374 | // Claim the stdout-close path before waiting so a broken stdin | |
| 375 | // writer cannot replace the exit diagnosis with a write error. | |
| 376 | diagnosingStdoutClose = true; | |
| 377 | const polled = yield* Deferred.poll(exitDeferred); | |
| 378 | const maybeExitCode = Option.isSome(polled) | |
| 379 | ? yield* polled.value.pipe( | |
| 380 | Effect.map(Option.some), | |
| 381 | Effect.orElseSucceed(() => Option.none<number>()), | |
| 382 | ) | |
| 383 | : yield* Deferred.await(exitDeferred).pipe( | |
| 384 | Effect.timeoutOption(Duration.millis(250)), | |
| 385 | // Adapter tests run under TestClock. A planned stdout close | |
| 386 | // without an exit, for example Stop-with-restart, must not wait | |
| 387 | // on that clock or the transport never fails. | |
| 388 | Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), | |
| 389 | Effect.orElseSucceed(() => Option.none<number>()), | |
| 390 | ); | |
| 391 | return yield* failTransport( | |
| 392 | new PiRpcError({ | |
| 393 | operation: "read", | |
| 394 | detail: describePiStdoutClosure( | |
| 395 | Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, | |
| 396 | ), | |
| 397 | }), | |
| 398 | ); | |
| 399 | }), | |
| 400 | }), | |
| 401 | Effect.forkIn(scope), | |
| 402 | ); | |
| 403 | ||
| 404 | // Surface stderr as debug logs; pi reserves stdout for the protocol. | |
| 405 | yield* child.stderr.pipe( | |
| 406 | Stream.decodeText(), | |
| 407 | Stream.runForEach((chunk) => | |
| 408 | chunk.trim().length === 0 | |
| 409 | ? Effect.void | |
| 410 | : // Length only: pi's stderr is unbounded remote output and can carry | |
| 411 | // credentials or prompt text, so it never enters a log annotation. | |
| 412 | Effect.logDebug("pi stderr", { stderrLength: chunk.length }), | |
| 413 | ), | |
| 414 | Effect.ignore, | |
| 415 | Effect.forkIn(scope), | |
| 416 | ); | |
| 417 | ||
| 418 | // Writer starts after the reader so an already-closed stdout can mark | |
| 419 | // diagnosis before a broken-pipe stdin claims the transport. | |
| 420 | yield* Stream.fromQueue(outgoing).pipe( | |
| 421 | Stream.run(child.stdin), | |
| 422 | Effect.catchCause((cause) => | |
| 423 | diagnosingStdoutClose | |
| 424 | ? Effect.void | |
| 425 | : failTransport(new PiRpcError({ operation: "write", cause })), | |
| 426 | ), | |
| 427 | Effect.forkIn(scope), | |
| 428 | ); | |
| 429 | ||
| 430 | const send = (record: PiRpcRecord): Effect.Effect<void, PiRpcError> => | |
| 431 | Effect.gen(function* () { | |
| 432 | const accepted = yield* Queue.offer( | |
| 433 | outgoing, | |
| 434 | new TextEncoder().encode(`${encodeJsonLine(record)}\n`), | |
| 435 | ); | |
| 436 | // A refused offer means `failTransport` already closed the queue, so the | |
| 437 | // write can never land; surface the transport error instead of | |
| 438 | // reporting a success the caller cannot rely on. | |
| 439 | if (!accepted) { | |
| 440 | return yield* Deferred.await(transportDown); | |
| 441 | } | |
| 442 | }); | |
| 443 | ||
| 444 | const request = ( | |
| 445 | record: PiRpcRecord, | |
| 446 | timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, | |
| 447 | ): Effect.Effect<unknown, PiRpcError | PiRpcTimeoutError> => | |
| 448 | Effect.gen(function* () { | |
| 449 | const id = `t3-${nextRequestId++}`; | |
| 450 | const deferred = yield* Deferred.make<unknown, PiRpcError>(); | |
| 451 | pendingRequests.set(id, { deferred }); | |
| 452 | yield* send({ ...record, id }).pipe( | |
| 453 | Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 454 | ); | |
| 455 | // Raced against the transport: a death that lands after this request was | |
| 456 | // registered (or between `send`'s check and its enqueue) would otherwise | |
| 457 | // leave the caller waiting out the full timeout for a reply that is | |
| 458 | // never coming. | |
| 459 | return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( | |
| 460 | Effect.timeoutOrElse({ | |
| 461 | duration: Duration.millis(timeoutMs), | |
| 462 | orElse: () => | |
| 463 | Effect.fail( | |
| 464 | new PiRpcTimeoutError({ | |
| 465 | operation: String(record["type"] ?? "request"), | |
| 466 | timeoutMs, | |
| 467 | }), | |
| 468 | ), | |
| 469 | }), | |
| 470 | Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 471 | Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), | |
| 472 | ); | |
| 473 | }); | |
| 474 | ||
| 475 | return { | |
| 476 | send, | |
| 477 | request, | |
| 478 | events, | |
| 479 | exited: Deferred.await(exitDeferred), | |
| 480 | terminate: failTransport( | |
| 481 | new PiRpcError({ operation: "terminate", detail: "pi process was stopped" }), | |
| 482 | ).pipe(Effect.andThen(terminateProcess), Effect.ignore, Effect.uninterruptible), | |
| 483 | } satisfies PiRpcConnection; | |
| 484 | }); |
packages/provider-pi/src/server/status.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Sink from "effect/Sink"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 7 | ||
| 8 | import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./status.ts"; | |
| 9 | ||
| 10 | const encoder = new TextEncoder(); | |
| 11 | ||
| 12 | function processHandle(input: { | |
| 13 | readonly stdout?: string; | |
| 14 | readonly stderr?: string; | |
| 15 | readonly exitCode?: number; | |
| 16 | }) { | |
| 17 | const bytes = (value: string | undefined) => | |
| 18 | value === undefined || value.length === 0 | |
| 19 | ? Stream.empty | |
| 20 | : Stream.succeed(encoder.encode(value)); | |
| 21 | return ChildProcessSpawner.makeHandle({ | |
| 22 | pid: ChildProcessSpawner.ProcessId(900_000_001), | |
| 23 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | |
| 24 | isRunning: Effect.succeed(false), | |
| 25 | kill: () => Effect.void, | |
| 26 | unref: Effect.succeed(Effect.void), | |
| 27 | stdin: Sink.drain, | |
| 28 | stdout: bytes(input.stdout), | |
| 29 | stderr: bytes(input.stderr), | |
| 30 | all: Stream.empty, | |
| 31 | getInputFd: () => Sink.drain, | |
| 32 | getOutputFd: () => Stream.empty, | |
| 33 | }); | |
| 34 | } | |
| 35 | ||
| 36 | function piProbeSpawner(version: string) { | |
| 37 | return ChildProcessSpawner.make((command) => { | |
| 38 | const args = ChildProcess.isStandardCommand(command) ? command.args : []; | |
| 39 | return Effect.succeed( | |
| 40 | args.includes("--version") | |
| 41 | ? processHandle({ stdout: `pi ${version}\n` }) | |
| 42 | : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), | |
| 43 | ); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | const settings = { | |
| 48 | enabled: true, | |
| 49 | binaryPath: "pi", | |
| 50 | launchArgs: "", | |
| 51 | customModels: [], | |
| 52 | } as const; | |
| 53 | ||
| 54 | describe("PiProvider", () => { | |
| 55 | it.effect("requires the first published Pi version with entries and settlement hooks", () => | |
| 56 | Effect.gen(function* () { | |
| 57 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | |
| 58 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), | |
| 59 | ); | |
| 60 | assert.equal(snapshot.status, "error"); | |
| 61 | assert.equal(snapshot.version, "0.80.3"); | |
| 62 | assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); | |
| 63 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 64 | ); | |
| 65 | ||
| 66 | it.effect("keeps compatible Pi selectable when optional discovery fails", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | |
| 69 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), | |
| 70 | ); | |
| 71 | assert.equal(snapshot.status, "ready"); | |
| 72 | assert.equal(snapshot.auth.status, "unknown"); | |
| 73 | assert.deepEqual( | |
| 74 | snapshot.models.map((model) => model.slug), | |
| 75 | ["default"], | |
| 76 | ); | |
| 77 | assert.include(snapshot.message ?? "", "could not refresh its models and commands"); | |
| 78 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 79 | ); | |
| 80 | }); |
packages/provider-pi/src/server/status.ts created+479| ... | ... | @@ -0,0 +1,479 @@ |
| 1 | /** | |
| 2 | * PiProvider — snapshot/probe layer for the Pi coding agent. | |
| 3 | * | |
| 4 | * Health is probed with `pi --version`. Models, the user's default model, and | |
| 5 | * the user's commands (extension slash commands, prompt templates, skills) | |
| 6 | * are discovered through a short-lived ephemeral RPC session | |
| 7 | * (`pi --mode rpc --no-session`), so everything the user configured in | |
| 8 | * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — | |
| 9 | * shows up in T3 without any hardcoded catalog. | |
| 10 | */ | |
| 11 | import { | |
| 12 | type CustomModelSetting, | |
| 13 | type ServerProvider, | |
| 14 | type ServerProviderModel, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import type { PiSettings } from "../settings.ts"; | |
| 17 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 19 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 20 | import * as DateTime from "effect/DateTime"; | |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | import * as Stream from "effect/Stream"; | |
| 23 | import * as Exit from "effect/Exit"; | |
| 24 | import * as Option from "effect/Option"; | |
| 25 | import * as Result from "effect/Result"; | |
| 26 | import { HttpClient } from "effect/http"; | |
| 27 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 28 | ||
| 29 | import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; | |
| 30 | import { | |
| 31 | makePiRpcConnection, | |
| 32 | PiRpcError, | |
| 33 | PiRpcTimeoutError, | |
| 34 | piRecordField as recordField, | |
| 35 | piRecordString as recordString, | |
| 36 | } from "./rpc.ts"; | |
| 37 | import { | |
| 38 | buildServerProvider, | |
| 39 | isCommandMissingCause, | |
| 40 | parseGenericCliVersion, | |
| 41 | providerModelsFromSettings, | |
| 42 | spawnAndCollect, | |
| 43 | type ServerProviderDraft, | |
| 44 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 45 | import { | |
| 46 | enrichProviderSnapshotWithVersionAdvisory, | |
| 47 | type ProviderMaintenanceCapabilities, | |
| 48 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 49 | import { | |
| 50 | EMPTY_PI_MODEL_CAPABILITIES, | |
| 51 | thinkingCapabilitiesForPiModel, | |
| 52 | } from "./thinkingCapabilities.ts"; | |
| 53 | import { | |
| 54 | parsePiDiscoveredCommands, | |
| 55 | withPiBuiltinSlashCommands, | |
| 56 | type PiDiscoveredCommands, | |
| 57 | } from "./commands.ts"; | |
| 58 | ||
| 59 | const PI_PRESENTATION = { | |
| 60 | displayName: "Pi", | |
| 61 | showInteractionModeToggle: false, | |
| 62 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], | |
| 63 | // The adapter reports context usage from Pi's streaming usage while a | |
| 64 | // turn runs, so clients can reserve the meter before the first settle. | |
| 65 | reportsContextWindow: true, | |
| 66 | requiresNewThreadForModelChange: false, | |
| 67 | } as const; | |
| 68 | ||
| 69 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | |
| 70 | const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; | |
| 71 | /** | |
| 72 | * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. | |
| 73 | * Version 0.80.5 was the first published package containing both hooks. T3 | |
| 74 | * needs them for rollback boundaries and reliable turn terminalization. | |
| 75 | */ | |
| 76 | export const MINIMUM_PI_VERSION = "0.80.5"; | |
| 77 | ||
| 78 | /** Deferring to the user's own settings.json default model. */ | |
| 79 | const PI_DEFAULT_MODEL: ServerProviderModel = { | |
| 80 | slug: "default", | |
| 81 | name: "Pi default", | |
| 82 | isCustom: false, | |
| 83 | capabilities: EMPTY_PI_MODEL_CAPABILITIES, | |
| 84 | }; | |
| 85 | ||
| 86 | interface PiDiscovery extends PiDiscoveredCommands { | |
| 87 | readonly models: ReadonlyArray<ServerProviderModel>; | |
| 88 | readonly authenticated: boolean; | |
| 89 | } | |
| 90 | ||
| 91 | function piModelsFromSettings( | |
| 92 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | |
| 93 | discovered: ReadonlyArray<ServerProviderModel> = [], | |
| 94 | ): ReadonlyArray<ServerProviderModel> { | |
| 95 | return providerModelsFromSettings( | |
| 96 | [PI_DEFAULT_MODEL, ...discovered], | |
| 97 | customModels ?? [], | |
| 98 | EMPTY_PI_MODEL_CAPABILITIES, | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 102 | function parseDiscoveredModels( | |
| 103 | data: unknown, | |
| 104 | defaultThinkingLevel: unknown, | |
| 105 | ): ReadonlyArray<ServerProviderModel> { | |
| 106 | const models = recordField(data, "models"); | |
| 107 | if (!Array.isArray(models)) return []; | |
| 108 | const seen = new Set<string>(); | |
| 109 | const parsed: Array<ServerProviderModel> = []; | |
| 110 | for (const model of models) { | |
| 111 | const provider = recordString(model, "provider"); | |
| 112 | const id = recordString(model, "id"); | |
| 113 | if (provider === undefined || id === undefined) continue; | |
| 114 | const slug = `${provider}/${id}`; | |
| 115 | if (seen.has(slug)) continue; | |
| 116 | seen.add(slug); | |
| 117 | parsed.push({ | |
| 118 | slug, | |
| 119 | name: recordString(model, "name") ?? slug, | |
| 120 | subProvider: provider, | |
| 121 | isCustom: false, | |
| 122 | capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), | |
| 123 | }); | |
| 124 | } | |
| 125 | return parsed; | |
| 126 | } | |
| 127 | ||
| 128 | const makePiDiscoveryConnection = Effect.fnUntraced(function* ( | |
| 129 | piSettings: PiSettings, | |
| 130 | environment: NodeJS.ProcessEnv, | |
| 131 | launchArgs: ReadonlyArray<string>, | |
| 132 | cwd?: string, | |
| 133 | ) { | |
| 134 | const launch = buildPiRpcLaunch({ | |
| 135 | launchArgs, | |
| 136 | environment, | |
| 137 | mcpSession: undefined, | |
| 138 | extensionPath: undefined, | |
| 139 | ephemeral: true, | |
| 140 | }); | |
| 141 | const connection = yield* makePiRpcConnection({ | |
| 142 | command: piSettings.binaryPath || "pi", | |
| 143 | args: launch.args, | |
| 144 | cwd, | |
| 145 | env: launch.env, | |
| 146 | }); | |
| 147 | yield* Stream.fromQueue(connection.events).pipe( | |
| 148 | Stream.runDrain, | |
| 149 | Effect.ignore, | |
| 150 | Effect.forkScoped, | |
| 151 | ); | |
| 152 | return connection; | |
| 153 | }); | |
| 154 | ||
| 155 | const discoverPiViaRpc = ( | |
| 156 | piSettings: PiSettings, | |
| 157 | environment: NodeJS.ProcessEnv, | |
| 158 | launchArgs: ReadonlyArray<string>, | |
| 159 | cwd?: string, | |
| 160 | ) => | |
| 161 | Effect.gen(function* () { | |
| 162 | const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); | |
| 163 | const stateData = yield* connection.request({ type: "get_state" }); | |
| 164 | const modelsData = yield* connection.request({ type: "get_available_models" }); | |
| 165 | const commandsData = yield* connection | |
| 166 | .request({ type: "get_commands" }) | |
| 167 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 168 | const discoveredModels = parseDiscoveredModels( | |
| 169 | modelsData, | |
| 170 | recordString(stateData, "thinkingLevel"), | |
| 171 | ); | |
| 172 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | |
| 173 | return { | |
| 174 | models: discoveredModels, | |
| 175 | slashCommands: withPiBuiltinSlashCommands(slashCommands), | |
| 176 | skills, | |
| 177 | authenticated: discoveredModels.length > 0, | |
| 178 | } satisfies PiDiscovery; | |
| 179 | }).pipe(Effect.scoped); | |
| 180 | ||
| 181 | /** Probe the full command catalog Pi exposes in a workspace without changing machine health. */ | |
| 182 | export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( | |
| 183 | function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { | |
| 184 | const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 185 | if (!launchArgs.ok) { | |
| 186 | return yield* new PiRpcError({ operation: "launch", detail: launchArgs.message }); | |
| 187 | } | |
| 188 | const connection = yield* makePiDiscoveryConnection( | |
| 189 | piSettings, | |
| 190 | environment, | |
| 191 | launchArgs.args, | |
| 192 | cwd, | |
| 193 | ); | |
| 194 | // A failed read must not replace a previously usable workspace catalog with an empty one. | |
| 195 | const commandsData = yield* connection.request({ type: "get_commands" }); | |
| 196 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | |
| 197 | return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; | |
| 198 | }, | |
| 199 | Effect.scoped, | |
| 200 | Effect.timeoutOrElse({ | |
| 201 | duration: PI_RPC_DISCOVERY_TIMEOUT_MS, | |
| 202 | orElse: () => | |
| 203 | Effect.fail( | |
| 204 | new PiRpcTimeoutError({ operation: "discovery", timeoutMs: PI_RPC_DISCOVERY_TIMEOUT_MS }), | |
| 205 | ), | |
| 206 | }), | |
| 207 | ); | |
| 208 | ||
| 209 | const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => | |
| 210 | Effect.gen(function* () { | |
| 211 | const command = piSettings.binaryPath || "pi"; | |
| 212 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { | |
| 213 | env: environment, | |
| 214 | }); | |
| 215 | return yield* spawnAndCollect( | |
| 216 | command, | |
| 217 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 218 | env: environment, | |
| 219 | shell: spawnCommand.shell, | |
| 220 | }), | |
| 221 | ); | |
| 222 | }); | |
| 223 | ||
| 224 | export function buildInitialPiProviderSnapshot( | |
| 225 | piSettings: PiSettings, | |
| 226 | ): Effect.Effect<ServerProviderDraft> { | |
| 227 | return Effect.gen(function* () { | |
| 228 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 229 | const models = piModelsFromSettings(piSettings.customModels); | |
| 230 | if (!piSettings.enabled) { | |
| 231 | return buildServerProvider({ | |
| 232 | presentation: PI_PRESENTATION, | |
| 233 | enabled: false, | |
| 234 | checkedAt, | |
| 235 | models, | |
| 236 | probe: { | |
| 237 | installed: false, | |
| 238 | version: null, | |
| 239 | status: "warning", | |
| 240 | auth: { status: "unknown" }, | |
| 241 | message: "Pi is disabled in T3 Code settings.", | |
| 242 | }, | |
| 243 | }); | |
| 244 | } | |
| 245 | return buildServerProvider({ | |
| 246 | presentation: PI_PRESENTATION, | |
| 247 | enabled: true, | |
| 248 | checkedAt, | |
| 249 | models, | |
| 250 | probe: { | |
| 251 | installed: true, | |
| 252 | version: null, | |
| 253 | status: "warning", | |
| 254 | auth: { status: "unknown" }, | |
| 255 | message: "Checking Pi CLI availability...", | |
| 256 | }, | |
| 257 | }); | |
| 258 | }); | |
| 259 | } | |
| 260 | ||
| 261 | export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( | |
| 262 | piSettings: PiSettings, | |
| 263 | environment: NodeJS.ProcessEnv = process.env, | |
| 264 | cwd?: string, | |
| 265 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 266 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 267 | const fallbackModels = piModelsFromSettings(piSettings.customModels); | |
| 268 | ||
| 269 | if (!piSettings.enabled) { | |
| 270 | return buildServerProvider({ | |
| 271 | presentation: PI_PRESENTATION, | |
| 272 | enabled: false, | |
| 273 | checkedAt, | |
| 274 | models: fallbackModels, | |
| 275 | probe: { | |
| 276 | installed: false, | |
| 277 | version: null, | |
| 278 | status: "warning", | |
| 279 | auth: { status: "unknown" }, | |
| 280 | message: "Pi is disabled in T3 Code settings.", | |
| 281 | }, | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( | |
| 286 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | |
| 287 | Effect.result, | |
| 288 | ); | |
| 289 | ||
| 290 | if (Result.isFailure(versionResult)) { | |
| 291 | const error = versionResult.failure; | |
| 292 | yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); | |
| 293 | return buildServerProvider({ | |
| 294 | presentation: PI_PRESENTATION, | |
| 295 | enabled: piSettings.enabled, | |
| 296 | checkedAt, | |
| 297 | models: fallbackModels, | |
| 298 | probe: { | |
| 299 | installed: !isCommandMissingCause(error), | |
| 300 | version: null, | |
| 301 | status: "error", | |
| 302 | auth: { status: "unknown" }, | |
| 303 | message: isCommandMissingCause(error) | |
| 304 | ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." | |
| 305 | : "Failed to execute Pi CLI health check.", | |
| 306 | }, | |
| 307 | }); | |
| 308 | } | |
| 309 | ||
| 310 | if (Option.isNone(versionResult.success)) { | |
| 311 | return buildServerProvider({ | |
| 312 | presentation: PI_PRESENTATION, | |
| 313 | enabled: piSettings.enabled, | |
| 314 | checkedAt, | |
| 315 | models: fallbackModels, | |
| 316 | probe: { | |
| 317 | installed: true, | |
| 318 | version: null, | |
| 319 | status: "error", | |
| 320 | auth: { status: "unknown" }, | |
| 321 | message: "Pi CLI is installed but timed out while running `pi --version`.", | |
| 322 | }, | |
| 323 | }); | |
| 324 | } | |
| 325 | ||
| 326 | const versionOutput = versionResult.success.value; | |
| 327 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | |
| 328 | if (versionOutput.code !== 0) { | |
| 329 | return buildServerProvider({ | |
| 330 | presentation: PI_PRESENTATION, | |
| 331 | enabled: piSettings.enabled, | |
| 332 | checkedAt, | |
| 333 | models: fallbackModels, | |
| 334 | probe: { | |
| 335 | installed: true, | |
| 336 | version, | |
| 337 | status: "error", | |
| 338 | auth: { status: "unknown" }, | |
| 339 | message: "Pi CLI is installed but failed to run.", | |
| 340 | }, | |
| 341 | }); | |
| 342 | } | |
| 343 | ||
| 344 | if (version === null) { | |
| 345 | return buildServerProvider({ | |
| 346 | presentation: PI_PRESENTATION, | |
| 347 | enabled: piSettings.enabled, | |
| 348 | checkedAt, | |
| 349 | models: fallbackModels, | |
| 350 | probe: { | |
| 351 | installed: true, | |
| 352 | version: null, | |
| 353 | status: "error", | |
| 354 | auth: { status: "unknown" }, | |
| 355 | message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, | |
| 356 | }, | |
| 357 | }); | |
| 358 | } | |
| 359 | ||
| 360 | if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { | |
| 361 | return buildServerProvider({ | |
| 362 | presentation: PI_PRESENTATION, | |
| 363 | enabled: piSettings.enabled, | |
| 364 | checkedAt, | |
| 365 | models: fallbackModels, | |
| 366 | probe: { | |
| 367 | installed: true, | |
| 368 | version, | |
| 369 | status: "error", | |
| 370 | auth: { status: "unknown" }, | |
| 371 | message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, | |
| 372 | }, | |
| 373 | }); | |
| 374 | } | |
| 375 | ||
| 376 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 377 | if (!resolvedLaunchArgs.ok) { | |
| 378 | return buildServerProvider({ | |
| 379 | presentation: PI_PRESENTATION, | |
| 380 | enabled: piSettings.enabled, | |
| 381 | checkedAt, | |
| 382 | models: fallbackModels, | |
| 383 | probe: { | |
| 384 | installed: true, | |
| 385 | version, | |
| 386 | status: "error", | |
| 387 | auth: { status: "unknown" }, | |
| 388 | message: resolvedLaunchArgs.message, | |
| 389 | }, | |
| 390 | }); | |
| 391 | } | |
| 392 | ||
| 393 | const discoveryExit = yield* discoverPiViaRpc( | |
| 394 | piSettings, | |
| 395 | environment, | |
| 396 | resolvedLaunchArgs.args, | |
| 397 | cwd, | |
| 398 | ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); | |
| 399 | if (Exit.isFailure(discoveryExit)) { | |
| 400 | yield* Effect.logWarning("Pi RPC discovery failed.", { | |
| 401 | errorTag: causeErrorTag(discoveryExit.cause), | |
| 402 | }); | |
| 403 | return buildServerProvider({ | |
| 404 | presentation: PI_PRESENTATION, | |
| 405 | enabled: piSettings.enabled, | |
| 406 | checkedAt, | |
| 407 | models: fallbackModels, | |
| 408 | probe: { | |
| 409 | installed: true, | |
| 410 | version, | |
| 411 | status: "ready", | |
| 412 | auth: { status: "unknown" }, | |
| 413 | message: | |
| 414 | "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", | |
| 415 | }, | |
| 416 | }); | |
| 417 | } | |
| 418 | if (Option.isNone(discoveryExit.value)) { | |
| 419 | return buildServerProvider({ | |
| 420 | presentation: PI_PRESENTATION, | |
| 421 | enabled: piSettings.enabled, | |
| 422 | checkedAt, | |
| 423 | models: fallbackModels, | |
| 424 | probe: { | |
| 425 | installed: true, | |
| 426 | version, | |
| 427 | status: "ready", | |
| 428 | auth: { status: "unknown" }, | |
| 429 | message: | |
| 430 | "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", | |
| 431 | }, | |
| 432 | }); | |
| 433 | } | |
| 434 | ||
| 435 | const discovery = discoveryExit.value.value; | |
| 436 | const models = piModelsFromSettings(piSettings.customModels, discovery.models); | |
| 437 | return buildServerProvider({ | |
| 438 | presentation: PI_PRESENTATION, | |
| 439 | enabled: piSettings.enabled, | |
| 440 | checkedAt, | |
| 441 | models, | |
| 442 | slashCommands: discovery.slashCommands, | |
| 443 | skills: discovery.skills, | |
| 444 | probe: { | |
| 445 | installed: true, | |
| 446 | version, | |
| 447 | status: discovery.authenticated ? "ready" : "warning", | |
| 448 | auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, | |
| 449 | ...(discovery.authenticated | |
| 450 | ? {} | |
| 451 | : { | |
| 452 | message: | |
| 453 | "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", | |
| 454 | }), | |
| 455 | }, | |
| 456 | }); | |
| 457 | }); | |
| 458 | ||
| 459 | export const enrichPiSnapshot = (input: { | |
| 460 | readonly snapshot: ServerProvider; | |
| 461 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | |
| 462 | readonly enableProviderUpdateChecks?: boolean; | |
| 463 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | |
| 464 | readonly httpClient: HttpClient.HttpClient; | |
| 465 | }): Effect.Effect<void> => { | |
| 466 | const { snapshot, publishSnapshot } = input; | |
| 467 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | |
| 468 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | |
| 469 | }).pipe( | |
| 470 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | |
| 471 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 472 | Effect.catchCause((cause) => | |
| 473 | Effect.logWarning("Pi version advisory enrichment failed", { | |
| 474 | errorTag: causeErrorTag(cause), | |
| 475 | }), | |
| 476 | ), | |
| 477 | Effect.asVoid, | |
| 478 | ); | |
| 479 | }; |
packages/provider-pi/src/server/textGeneration.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Queue from "effect/Queue"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import * as Sink from "effect/Sink"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | ||
| 12 | import { makePiTextGeneration } from "./textGeneration.ts"; | |
| 13 | ||
| 14 | const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 15 | const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 16 | ||
| 17 | /** | |
| 18 | * In-process `pi --mode rpc` that answers one prompt with `reply`. Every | |
| 19 | * record written to its stdin lands in `received`. | |
| 20 | */ | |
| 21 | const makeFakePi = (reply: string) => | |
| 22 | Effect.gen(function* () { | |
| 23 | const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>(); | |
| 24 | const received: Array<Record<string, unknown>> = []; | |
| 25 | const emit = (record: Record<string, unknown>) => | |
| 26 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); | |
| 27 | let buffered = ""; | |
| 28 | const onStdin = (chunk: Uint8Array) => | |
| 29 | Effect.gen(function* () { | |
| 30 | buffered += new TextDecoder().decode(chunk); | |
| 31 | const lines = buffered.split("\n"); | |
| 32 | buffered = lines.pop() ?? ""; | |
| 33 | for (const line of lines.filter((candidate) => candidate.length > 0)) { | |
| 34 | const record = decodeJsonLine(line) as Record<string, unknown>; | |
| 35 | received.push(record); | |
| 36 | const response = { type: "response", id: record["id"], command: record["type"] }; | |
| 37 | if (record["type"] === "prompt") { | |
| 38 | yield* emit({ ...response, success: true }); | |
| 39 | yield* emit({ type: "agent_settled" }); | |
| 40 | } else if (record["type"] === "get_last_assistant_text") { | |
| 41 | yield* emit({ ...response, success: true, data: { text: reply } }); | |
| 42 | } else { | |
| 43 | yield* emit({ ...response, success: true }); | |
| 44 | } | |
| 45 | } | |
| 46 | }); | |
| 47 | const spawner = ChildProcessSpawner.make(() => | |
| 48 | Effect.succeed( | |
| 49 | ChildProcessSpawner.makeHandle({ | |
| 50 | // Outside the valid pid range, so PiRpc's process-group kill never lands. | |
| 51 | pid: ChildProcessSpawner.ProcessId(999_999_999), | |
| 52 | exitCode: Effect.never, | |
| 53 | isRunning: Effect.succeed(true), | |
| 54 | kill: () => Effect.void, | |
| 55 | unref: Effect.succeed(Effect.void), | |
| 56 | stdin: Sink.forEach(onStdin), | |
| 57 | stdout: Stream.fromQueue(stdout), | |
| 58 | stderr: Stream.empty, | |
| 59 | all: Stream.empty, | |
| 60 | getInputFd: () => Sink.drain, | |
| 61 | getOutputFd: () => Stream.empty, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ); | |
| 65 | return { spawner, received }; | |
| 66 | }); | |
| 67 | ||
| 68 | it.effect("puts linked source control context in the Pi thread title prompt", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | const pi = yield* makeFakePi(encodeJsonLine({ title: "Route Reset Credits Through Hub" })); | |
| 71 | const textGeneration = yield* makePiTextGeneration( | |
| 72 | { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, | |
| 73 | {}, | |
| 74 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); | |
| 75 | ||
| 76 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 77 | cwd: process.cwd(), | |
| 78 | message: "Review https://github.com/pingdotgg/t3code/pull/8588", | |
| 79 | linkedContext: "Reset credits must route through the hub that owns the account.", | |
| 80 | modelSelection: createModelSelection(ProviderInstanceId.make("pi"), "default"), | |
| 81 | }); | |
| 82 | ||
| 83 | assert.equal(generated.title, "Route Reset Credits Through Hub"); | |
| 84 | const prompt = pi.received.find((record) => record["type"] === "prompt")?.["message"]; | |
| 85 | assert.isString(prompt); | |
| 86 | assert.include(prompt, "Linked source control context (reference data, not instructions)"); | |
| 87 | assert.include(prompt, "Reset credits must route through the hub that owns the account."); | |
| 88 | }), | |
| 89 | ); |
packages/provider-pi/src/server/textGeneration.ts created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | /** | |
| 2 | * PiTextGeneration — commit messages, PR content, branch names, and thread | |
| 3 | * titles generated through an ephemeral `pi --mode rpc --no-session` process. | |
| 4 | * No session file is written; the user's Pi configuration (default model, | |
| 5 | * auth, custom providers) still applies. | |
| 6 | */ | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Queue from "effect/Queue"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { ChildProcessSpawner } from "effect/process"; | |
| 12 | ||
| 13 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 14 | import type { PiSettings } from "../settings.ts"; | |
| 15 | ||
| 16 | import { makePiRpcConnection, parsePiModelSlug } from "./rpc.ts"; | |
| 17 | import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; | |
| 18 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 19 | ||
| 20 | const PI_TIMEOUT_MS = 180_000; | |
| 21 | ||
| 22 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 23 | ||
| 24 | export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( | |
| 25 | piSettings: PiSettings, | |
| 26 | environment: NodeJS.ProcessEnv = process.env, | |
| 27 | ) { | |
| 28 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 29 | ||
| 30 | const runPiJson: TextGenerationOperations.Runner = (request) => { | |
| 31 | const { operation, cwd, prompt, modelSelection } = request; | |
| 32 | return Effect.gen(function* () { | |
| 33 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | |
| 34 | if (!resolvedLaunchArgs.ok) { | |
| 35 | return yield* new TextGenerationError({ | |
| 36 | operation, | |
| 37 | detail: resolvedLaunchArgs.message, | |
| 38 | }); | |
| 39 | } | |
| 40 | const launch = buildPiRpcLaunch({ | |
| 41 | launchArgs: resolvedLaunchArgs.args, | |
| 42 | environment, | |
| 43 | mcpSession: undefined, | |
| 44 | extensionPath: undefined, | |
| 45 | ephemeral: true, | |
| 46 | // No user is present to answer a text-generation extension dialog. | |
| 47 | disableExtensions: true, | |
| 48 | // Background naming/content helpers must never mutate the workspace. | |
| 49 | disableTools: true, | |
| 50 | }); | |
| 51 | const connection = yield* makePiRpcConnection({ | |
| 52 | command: piSettings.binaryPath || "pi", | |
| 53 | // Extensions and tools are disabled because no user is present to | |
| 54 | // answer a dialog and background text generation is read-only. User | |
| 55 | // model config and auth still apply. | |
| 56 | args: launch.args, | |
| 57 | cwd, | |
| 58 | env: launch.env, | |
| 59 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 60 | ||
| 61 | if (modelSelection.model !== "default") { | |
| 62 | // `customModels` accepts arbitrary strings, so an unusable slug is | |
| 63 | // rejected rather than skipped: running Pi's default model here would | |
| 64 | // report success for a model the caller never asked for. | |
| 65 | const parsed = parsePiModelSlug(modelSelection.model); | |
| 66 | if (parsed === null) { | |
| 67 | return yield* new TextGenerationError({ | |
| 68 | operation, | |
| 69 | detail: `Pi model '${modelSelection.model}' must use provider/model format.`, | |
| 70 | }); | |
| 71 | } | |
| 72 | yield* connection.request({ | |
| 73 | type: "set_model", | |
| 74 | provider: parsed.provider, | |
| 75 | modelId: parsed.modelId, | |
| 76 | }); | |
| 77 | } | |
| 78 | ||
| 79 | yield* connection.request({ type: "prompt", message: prompt }); | |
| 80 | yield* Effect.gen(function* () { | |
| 81 | while (true) { | |
| 82 | const event = yield* Queue.take(connection.events); | |
| 83 | if (event["type"] === "agent_settled") return; | |
| 84 | } | |
| 85 | }); | |
| 86 | const data = yield* connection.request({ type: "get_last_assistant_text" }); | |
| 87 | const text = | |
| 88 | typeof data === "object" && | |
| 89 | data !== null && | |
| 90 | typeof (data as { text?: unknown }).text === "string" | |
| 91 | ? (data as { text: string }).text.trim() | |
| 92 | : ""; | |
| 93 | if (!text) { | |
| 94 | return yield* new TextGenerationError({ | |
| 95 | operation, | |
| 96 | detail: "Pi returned empty output.", | |
| 97 | }); | |
| 98 | } | |
| 99 | return yield* TextGenerationOperations.decodeJsonReply(request, "Pi", text); | |
| 100 | }).pipe( | |
| 101 | Effect.timeoutOption(PI_TIMEOUT_MS), | |
| 102 | Effect.flatMap( | |
| 103 | Option.match({ | |
| 104 | onNone: () => | |
| 105 | Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), | |
| 106 | onSome: (value) => Effect.succeed(value), | |
| 107 | }), | |
| 108 | ), | |
| 109 | Effect.mapError((cause) => | |
| 110 | isTextGenerationError(cause) | |
| 111 | ? cause | |
| 112 | : new TextGenerationError({ | |
| 113 | operation, | |
| 114 | detail: "Pi text generation failed.", | |
| 115 | cause, | |
| 116 | }), | |
| 117 | ), | |
| 118 | Effect.scoped, | |
| 119 | ); | |
| 120 | }; | |
| 121 | ||
| 122 | return TextGenerationOperations.fromRunner("PiTextGeneration", runPiJson); | |
| 123 | }); |
packages/provider-pi/src/server/thinkingCapabilities.test.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { thinkingCapabilitiesForPiModel } from "./thinkingCapabilities.ts"; | |
| 4 | ||
| 5 | describe("thinkingCapabilitiesForPiModel", () => { | |
| 6 | it("gives every choice its real level id and tags Pi's default", () => { | |
| 7 | const capabilities = thinkingCapabilitiesForPiModel( | |
| 8 | { | |
| 9 | reasoning: true, | |
| 10 | thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, | |
| 11 | }, | |
| 12 | "xhigh", | |
| 13 | ); | |
| 14 | const descriptors = capabilities.optionDescriptors ?? []; | |
| 15 | const thinking = descriptors[0]; | |
| 16 | assert.equal(thinking?.id, "thinking"); | |
| 17 | assert.equal(thinking?.type, "select"); | |
| 18 | if (thinking?.type !== "select") return; | |
| 19 | assert.deepEqual( | |
| 20 | thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), | |
| 21 | [ | |
| 22 | ["minimal", "Minimal", false], | |
| 23 | ["low", "Low", false], | |
| 24 | ["medium", "Medium", false], | |
| 25 | ["high", "High", false], | |
| 26 | ["xhigh", "Extra High", true], | |
| 27 | ], | |
| 28 | ); | |
| 29 | }); | |
| 30 | ||
| 31 | it("clamps Pi's default to each model's supported levels", () => { | |
| 32 | const capabilities = thinkingCapabilitiesForPiModel( | |
| 33 | { | |
| 34 | reasoning: true, | |
| 35 | thinkingLevelMap: { xhigh: "extra_high", max: null }, | |
| 36 | }, | |
| 37 | "max", | |
| 38 | ); | |
| 39 | const thinking = capabilities.optionDescriptors?.[0]; | |
| 40 | assert.equal(thinking?.type, "select"); | |
| 41 | if (thinking?.type !== "select") return; | |
| 42 | assert.deepInclude(thinking.options, { | |
| 43 | id: "xhigh", | |
| 44 | label: "Extra High", | |
| 45 | isDefault: true, | |
| 46 | }); | |
| 47 | assert.notInclude( | |
| 48 | thinking.options.map((option) => option.id), | |
| 49 | "max", | |
| 50 | ); | |
| 51 | }); | |
| 52 | }); |
packages/provider-pi/src/server/thinkingCapabilities.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; | |
| 2 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | import * as Predicate from "effect/Predicate"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per | |
| 7 | * model via `thinkingLevelMap`; advertising them globally makes | |
| 8 | * `set_thinking_level` fail on models that lack them. | |
| 9 | */ | |
| 10 | const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; | |
| 11 | ||
| 12 | export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; | |
| 13 | ||
| 14 | const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = { | |
| 15 | off: "Off", | |
| 16 | minimal: "Minimal", | |
| 17 | low: "Low", | |
| 18 | medium: "Medium", | |
| 19 | high: "High", | |
| 20 | xhigh: "Extra High", | |
| 21 | max: "Max", | |
| 22 | }; | |
| 23 | ||
| 24 | export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 25 | optionDescriptors: [], | |
| 26 | }); | |
| 27 | ||
| 28 | export function thinkingCapabilitiesForPiModel( | |
| 29 | model: unknown, | |
| 30 | defaultThinkingLevel: unknown, | |
| 31 | ): ModelCapabilities { | |
| 32 | const levels = supportedPiThinkingLevelsFromModel(model); | |
| 33 | if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; | |
| 34 | const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); | |
| 35 | return createModelCapabilities({ | |
| 36 | optionDescriptors: [ | |
| 37 | { | |
| 38 | id: "thinking", | |
| 39 | label: "Thinking", | |
| 40 | type: "select", | |
| 41 | options: levels.map((level): ProviderOptionChoice => ({ | |
| 42 | id: level, | |
| 43 | label: PI_THINKING_LEVEL_LABELS[level], | |
| 44 | ...(level === defaultLevel ? { isDefault: true } : {}), | |
| 45 | })), | |
| 46 | }, | |
| 47 | ], | |
| 48 | }); | |
| 49 | } | |
| 50 | ||
| 51 | /** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ | |
| 52 | function clampPiThinkingLevel( | |
| 53 | input: unknown, | |
| 54 | availableLevels: ReadonlyArray<PiThinkingLevel>, | |
| 55 | ): PiThinkingLevel | undefined { | |
| 56 | if (typeof input !== "string") return undefined; | |
| 57 | const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); | |
| 58 | if (requestedIndex === -1) return undefined; | |
| 59 | const exact = availableLevels.find((level) => level === input); | |
| 60 | if (exact !== undefined) return exact; | |
| 61 | for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { | |
| 62 | const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | |
| 63 | if (higher !== undefined) return higher; | |
| 64 | } | |
| 65 | for (let index = requestedIndex - 1; index >= 0; index -= 1) { | |
| 66 | const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | |
| 67 | if (lower !== undefined) return lower; | |
| 68 | } | |
| 69 | return availableLevels[0]; | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. | |
| 74 | * | |
| 75 | * A reasoning model always exposes off through high unless a map entry is | |
| 76 | * `null`. Extra High and Max appear only when the map has a non-null entry. | |
| 77 | */ | |
| 78 | function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> { | |
| 79 | if (recordField(model, "reasoning") !== true) return []; | |
| 80 | const thinkingLevelMap = thinkingLevelMapFromModel(model); | |
| 81 | return PI_THINKING_LEVELS.filter((level) => { | |
| 82 | const mapped = thinkingLevelMap?.[level]; | |
| 83 | if (mapped === null) return false; | |
| 84 | if (level === "xhigh" || level === "max") return mapped !== undefined; | |
| 85 | return true; | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined { | |
| 90 | const value = recordField(model, "thinkingLevelMap"); | |
| 91 | if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; | |
| 92 | return value as Record<string, unknown>; | |
| 93 | } | |
| 94 | ||
| 95 | function recordField(input: unknown, key: string): unknown { | |
| 96 | return Predicate.isObject(input) ? input[key] : undefined; | |
| 97 | } |
packages/provider-pi/src/settings.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | /** | |
| 2 | * Pi instance settings. Shared by the server driver and the client settings | |
| 3 | * form, so it holds only browser-safe schema code. | |
| 4 | * | |
| 5 | * @module provider-pi/settings | |
| 6 | */ | |
| 7 | import { | |
| 8 | CustomModelSetting, | |
| 9 | makeBinaryPathSetting, | |
| 10 | makeProviderSettingsSchema, | |
| 11 | TrimmedString, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | ||
| 16 | export const PiSettings = makeProviderSettingsSchema( | |
| 17 | { | |
| 18 | // Off by default like Cursor and Grok. Users opt in from Settings. | |
| 19 | enabled: Schema.Boolean.pipe( | |
| 20 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 21 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 22 | ), | |
| 23 | binaryPath: makeBinaryPathSetting("pi").pipe( | |
| 24 | Schema.annotateKey({ | |
| 25 | title: "Binary path", | |
| 26 | description: "Path to the Pi coding agent binary.", | |
| 27 | providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, | |
| 28 | }), | |
| 29 | ), | |
| 30 | launchArgs: TrimmedString.pipe( | |
| 31 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 32 | Schema.annotateKey({ | |
| 33 | title: "Launch arguments", | |
| 34 | description: "Additional CLI arguments passed to pi --mode rpc on session start.", | |
| 35 | providerSettingsForm: { clearWhenEmpty: "omit" }, | |
| 36 | }), | |
| 37 | ), | |
| 38 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 39 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 40 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 41 | ), | |
| 42 | }, | |
| 43 | { | |
| 44 | order: ["binaryPath", "launchArgs"], | |
| 45 | }, | |
| 46 | ); | |
| 47 | export type PiSettings = typeof PiSettings.Type; |
packages/provider-pi/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
pnpm-lock.yaml+43| ... | ... | @@ -353,6 +353,12 @@ importers: |
| 353 | 353 | '@t3tools/mobile-terminal-native': |
| 354 | 354 | specifier: file:./modules/t3-terminal |
| 355 | 355 | version: file:apps/mobile/modules/t3-terminal |
| 356 | '@t3tools/provider-core': | |
| 357 | specifier: workspace:* | |
| 358 | version: link:../../packages/provider-core | |
| 359 | '@t3tools/provider-pi': | |
| 360 | specifier: workspace:* | |
| 361 | version: link:../../packages/provider-pi | |
| 356 | 362 | '@t3tools/shared': |
| 357 | 363 | specifier: workspace:* |
| 358 | 364 | version: link:../../packages/shared |
| ... | ... | @@ -597,6 +603,9 @@ importers: |
| 597 | 603 | '@t3tools/provider-core': |
| 598 | 604 | specifier: workspace:* |
| 599 | 605 | version: link:../../packages/provider-core |
| 606 | '@t3tools/provider-pi': | |
| 607 | specifier: workspace:* | |
| 608 | version: link:../../packages/provider-pi | |
| 600 | 609 | diff: |
| 601 | 610 | specifier: 8.0.3 |
| 602 | 611 | version: 8.0.3 |
| ... | ... | @@ -724,6 +733,9 @@ importers: |
| 724 | 733 | '@t3tools/provider-core': |
| 725 | 734 | specifier: workspace:* |
| 726 | 735 | version: link:../../packages/provider-core |
| 736 | '@t3tools/provider-pi': | |
| 737 | specifier: workspace:* | |
| 738 | version: link:../../packages/provider-pi | |
| 727 | 739 | '@t3tools/shared': |
| 728 | 740 | specifier: workspace:* |
| 729 | 741 | version: link:../../packages/shared |
| ... | ... | @@ -1087,6 +1099,37 @@ importers: |
| 1087 | 1099 | specifier: 1.0.0 |
| 1088 | 1100 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1089 | 1101 | |
| 1102 | packages/provider-pi: | |
| 1103 | dependencies: | |
| 1104 | '@t3tools/contracts': | |
| 1105 | specifier: workspace:* | |
| 1106 | version: link:../contracts | |
| 1107 | '@t3tools/provider-core': | |
| 1108 | specifier: workspace:* | |
| 1109 | version: link:../provider-core | |
| 1110 | '@t3tools/shared': | |
| 1111 | specifier: workspace:* | |
| 1112 | version: link:../shared | |
| 1113 | effect: | |
| 1114 | specifier: 4.0.1 | |
| 1115 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) | |
| 1116 | devDependencies: | |
| 1117 | '@effect/platform-node': | |
| 1118 | specifier: 4.0.1 | |
| 1119 | version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) | |
| 1120 | '@effect/vitest': | |
| 1121 | specifier: 4.0.1 | |
| 1122 | version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1123 | '@t3tools/provider-testing': | |
| 1124 | specifier: workspace:* | |
| 1125 | version: link:../provider-testing | |
| 1126 | '@types/node': | |
| 1127 | specifier: 24.12.4 | |
| 1128 | version: 24.12.4 | |
| 1129 | vite-plus: | |
| 1130 | specifier: 1.0.0 | |
| 1131 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1132 | ||
| 1090 | 1133 | packages/provider-testing: |
| 1091 | 1134 | dependencies: |
| 1092 | 1135 | '@effect/platform-node': |
scripts/release-smoke.ts+1| ... | ... | @@ -30,6 +30,7 @@ const workspaceFiles = [ |
| 30 | 30 | "packages/tailscale/package.json", |
| 31 | 31 | "packages/provider-core/package.json", |
| 32 | 32 | "packages/provider-testing/package.json", |
| 33 | "packages/provider-pi/package.json", | |
| 33 | 34 | "packages/effect-acp/package.json", |
| 34 | 35 | "packages/effect-codex-app-server/package.json", |
| 35 | 36 | "scripts/package.json", |