| author | |
| committer | |
| log | 6e9c5a851c92c52e2f4a8a80c655e60db133f9ac |
| tree | 704f8a986c17fc684e4e061da490ff3bd8205a5c |
| parent | 62da1cd48bb43def11a79cd6aa033dbc478e4dfd |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 172 insertions(+), 6 deletions(-)
packages/provider-pi/src/server/adapter.test.ts+104| ... | ... | @@ -2912,6 +2912,110 @@ describe("PiAdapterV2", () => { |
| 2912 | 2912 | }).pipe(Effect.scoped, Effect.provide(layerTest)), |
| 2913 | 2913 | ); |
| 2914 | 2914 | |
| 2915 | it.effect("expires native dialogs and rejects stale answers without caching approval", () => | |
| 2916 | Effect.gen(function* () { | |
| 2917 | const fake = yield* makeFakePi; | |
| 2918 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2919 | yield* runtime.ensureThread({ | |
| 2920 | threadId: THREAD_ID, | |
| 2921 | modelSelection: modelSelection("default"), | |
| 2922 | runtimePolicy, | |
| 2923 | }); | |
| 2924 | const dialog = { | |
| 2925 | type: "extension_ui_request", | |
| 2926 | method: "confirm", | |
| 2927 | title: "Allow extension?", | |
| 2928 | message: "Approve this operation", | |
| 2929 | }; | |
| 2930 | yield* fake.emit({ ...dialog, id: "timed-dialog", timeout: 1000 }); | |
| 2931 | const pending = yield* takeEvent( | |
| 2932 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "approval_request", | |
| 2933 | ); | |
| 2934 | assert.isTrue( | |
| 2935 | pending.type === "turn_item.updated" && pending.turnItem.type === "approval_request", | |
| 2936 | ); | |
| 2937 | if (pending.type !== "turn_item.updated" || pending.turnItem.type !== "approval_request") | |
| 2938 | return; | |
| 2939 | const requestId = pending.turnItem.requestId; | |
| 2940 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 2941 | yield* takeEvent( | |
| 2942 | (event) => | |
| 2943 | event.type === "runtime_request.updated" && | |
| 2944 | event.runtimeRequest.id === requestId && | |
| 2945 | event.runtimeRequest.status === "cancelled", | |
| 2946 | ); | |
| 2947 | assert.equal((yield* fake.takeRequest("extension_ui_response"))["cancelled"], true); | |
| 2948 | const late = yield* Effect.result( | |
| 2949 | runtime.respondToRuntimeRequest({ | |
| 2950 | requestId: pending.turnItem.requestId, | |
| 2951 | decision: "acceptForSession", | |
| 2952 | }), | |
| 2953 | ); | |
| 2954 | assert.equal(late._tag, "Failure"); | |
| 2955 | yield* fake.emit({ ...dialog, id: "fresh-dialog", timeout: 1000 }); | |
| 2956 | const fresh = yield* takeEvent( | |
| 2957 | (event) => | |
| 2958 | event.type === "runtime_request.updated" && | |
| 2959 | event.runtimeRequest.status === "pending" && | |
| 2960 | event.runtimeRequest.nativeRequestRef?.nativeId === "fresh-dialog", | |
| 2961 | ); | |
| 2962 | if (fresh.type !== "runtime_request.updated") return; | |
| 2963 | yield* runtime.respondToRuntimeRequest({ | |
| 2964 | requestId: fresh.runtimeRequest.id, | |
| 2965 | decision: "accept", | |
| 2966 | }); | |
| 2967 | assert.equal((yield* fake.takeRequest("extension_ui_response"))["id"], "fresh-dialog"); | |
| 2968 | // The old expiry must not cancel an already answered request, and zero disables expiry. | |
| 2969 | yield* fake.emit({ ...dialog, id: "zero-timeout", timeout: 0 }); | |
| 2970 | const zero = yield* takeEvent( | |
| 2971 | (event) => | |
| 2972 | event.type === "runtime_request.updated" && | |
| 2973 | event.runtimeRequest.status === "pending" && | |
| 2974 | event.runtimeRequest.nativeRequestRef?.nativeId === "zero-timeout", | |
| 2975 | ); | |
| 2976 | yield* TestClock.adjust(Duration.seconds(60)); | |
| 2977 | if (zero.type !== "runtime_request.updated") return; | |
| 2978 | yield* runtime.respondToRuntimeRequest({ | |
| 2979 | requestId: zero.runtimeRequest.id, | |
| 2980 | decision: "decline", | |
| 2981 | }); | |
| 2982 | const response = yield* fake.takeRequest("extension_ui_response"); | |
| 2983 | assert.equal(response["id"], "zero-timeout"); | |
| 2984 | assert.equal(response["confirmed"], false); | |
| 2985 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2986 | ); | |
| 2987 | ||
| 2988 | it.effect("cancels startup dialogs when Pi exits before a turn exists", () => | |
| 2989 | Effect.gen(function* () { | |
| 2990 | const fake = yield* makeFakePi; | |
| 2991 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2992 | yield* runtime.ensureThread({ | |
| 2993 | threadId: THREAD_ID, | |
| 2994 | modelSelection: modelSelection("default"), | |
| 2995 | runtimePolicy, | |
| 2996 | }); | |
| 2997 | yield* fake.emit({ | |
| 2998 | type: "extension_ui_request", | |
| 2999 | id: "startup", | |
| 3000 | method: "confirm", | |
| 3001 | title: "Trust project?", | |
| 3002 | }); | |
| 3003 | const pending = yield* takeEvent( | |
| 3004 | (event) => | |
| 3005 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 3006 | ); | |
| 3007 | if (pending.type !== "runtime_request.updated") return; | |
| 3008 | yield* fake.closeStdout; | |
| 3009 | const cancelled = yield* takeEvent( | |
| 3010 | (event) => | |
| 3011 | event.type === "runtime_request.updated" && | |
| 3012 | event.runtimeRequest.id === pending.runtimeRequest.id && | |
| 3013 | event.runtimeRequest.status === "cancelled", | |
| 3014 | ); | |
| 3015 | assert.isTrue(cancelled.type === "runtime_request.updated"); | |
| 3016 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 3017 | ); | |
| 3018 | ||
| 2915 | 3019 | it.effect("remembers session approvals only for identical confirmation content", () => |
| 2916 | 3020 | Effect.gen(function* () { |
| 2917 | 3021 | const fake = yield* makeFakePi; |
packages/provider-pi/src/server/adapter.ts+53-4| ... | ... | @@ -56,6 +56,7 @@ import * as Option from "effect/Option"; |
| 56 | 56 | import * as Duration from "effect/Duration"; |
| 57 | 57 | import * as Effect from "effect/Effect"; |
| 58 | 58 | import * as FileSystem from "effect/FileSystem"; |
| 59 | import * as Fiber from "effect/Fiber"; | |
| 59 | 60 | import * as Layer from "effect/Layer"; |
| 60 | 61 | import * as Queue from "effect/Queue"; |
| 61 | 62 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -456,6 +457,8 @@ interface PendingPiPrompt { |
| 456 | 457 | readonly method: "select" | "confirm" | "input" | "editor"; |
| 457 | 458 | readonly questionId: string; |
| 458 | 459 | readonly approvalKey: string; |
| 460 | readonly expiresAt: number | null; | |
| 461 | expirationFiber: Fiber.Fiber<void> | null; | |
| 459 | 462 | runtimeRequest: OrchestrationV2RuntimeRequest; |
| 460 | 463 | readonly node: OrchestrationV2ExecutionNode; |
| 461 | 464 | readonly turnItem: OrchestrationV2TurnItem; |
| ... | ... | @@ -1270,6 +1273,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1270 | 1273 | |
| 1271 | 1274 | const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => |
| 1272 | 1275 | Effect.gen(function* () { |
| 1276 | if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); | |
| 1273 | 1277 | yield* connection |
| 1274 | 1278 | .send({ |
| 1275 | 1279 | type: "extension_ui_response", |
| ... | ... | @@ -1368,6 +1372,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1368 | 1372 | const state = threadState; |
| 1369 | 1373 | const turn = state?.activeTurn ?? null; |
| 1370 | 1374 | const createdAt = yield* DateTime.now; |
| 1375 | const timeout = recordNumber(event, "timeout"); | |
| 1376 | // Pi treats zero as no timeout and negative durations as immediate expiry. | |
| 1377 | const expiresAt = | |
| 1378 | timeout === undefined || timeout === 0 | |
| 1379 | ? null | |
| 1380 | : DateTime.toEpochMillis(createdAt) + Math.max(0, timeout); | |
| 1371 | 1381 | const requestId = yield* idAllocator.allocate.runtimeRequest({ |
| 1372 | 1382 | driver: PI_PROVIDER, |
| 1373 | 1383 | ...(turn === null ? {} : { providerTurnId: turn.providerTurn.id }), |
| ... | ... | @@ -1440,15 +1450,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1440 | 1450 | requestId, |
| 1441 | 1451 | questions: [piQuestion(nativeRequestId, method, title, event)], |
| 1442 | 1452 | }; |
| 1443 | pendingPrompts.set(String(requestId), { | |
| 1453 | const pending: PendingPiPrompt = { | |
| 1444 | 1454 | nativeRequestId, |
| 1445 | 1455 | method, |
| 1446 | 1456 | questionId: nativeRequestId, |
| 1447 | 1457 | approvalKey, |
| 1458 | expiresAt, | |
| 1459 | expirationFiber: null, | |
| 1448 | 1460 | runtimeRequest, |
| 1449 | 1461 | node, |
| 1450 | 1462 | turnItem, |
| 1451 | }); | |
| 1463 | }; | |
| 1464 | pendingPrompts.set(String(requestId), pending); | |
| 1452 | 1465 | yield* emit({ |
| 1453 | 1466 | type: "runtime_request.updated", |
| 1454 | 1467 | driver: PI_PROVIDER, |
| ... | ... | @@ -1457,6 +1470,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1457 | 1470 | }); |
| 1458 | 1471 | yield* emit({ type: "node.updated", driver: PI_PROVIDER, node }); |
| 1459 | 1472 | yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, turnItem }); |
| 1473 | if (expiresAt !== null) { | |
| 1474 | const now = yield* DateTime.now; | |
| 1475 | pending.expirationFiber = yield* Effect.sleep( | |
| 1476 | Duration.millis(Math.max(0, expiresAt - DateTime.toEpochMillis(now))), | |
| 1477 | ).pipe( | |
| 1478 | Effect.andThen( | |
| 1479 | Queue.offer(connection.events, { | |
| 1480 | type: "t3.ui_request_expired", | |
| 1481 | requestId: String(requestId), | |
| 1482 | pending, | |
| 1483 | }), | |
| 1484 | ), | |
| 1485 | Effect.asVoid, | |
| 1486 | Effect.forkIn(scope), | |
| 1487 | ); | |
| 1488 | } | |
| 1460 | 1489 | }); |
| 1461 | 1490 | |
| 1462 | 1491 | const emitExtensionError = Effect.fnUntraced(function* (event: PiRpcRecord) { |
| ... | ... | @@ -2132,6 +2161,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2132 | 2161 | } |
| 2133 | 2162 | return; |
| 2134 | 2163 | } |
| 2164 | case "t3.ui_request_expired": { | |
| 2165 | const requestId = recordString(event, "requestId"); | |
| 2166 | if (requestId === undefined) return; | |
| 2167 | const pending = pendingPrompts.get(requestId); | |
| 2168 | if (pending === undefined || pending !== event["pending"]) return; | |
| 2169 | pendingPrompts.delete(requestId); | |
| 2170 | yield* cancelPrompt(pending, yield* DateTime.now); | |
| 2171 | return; | |
| 2172 | } | |
| 2135 | 2173 | case "t3.flush_extension_errors": { |
| 2136 | 2174 | // Startup extension failures are informational and do not block |
| 2137 | 2175 | // Pi, so attach them to the next real turn instead of creating a |
| ... | ... | @@ -2211,7 +2249,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2211 | 2249 | rollbackBarrier, |
| 2212 | 2250 | protocolError("Pi transport closed during rollback"), |
| 2213 | 2251 | ); |
| 2214 | const hadPendingWake = pendingWake !== null; | |
| 2215 | 2252 | pendingWake = null; |
| 2216 | 2253 | const interrupted = state?.activeTurn?.interrupted === true; |
| 2217 | 2254 | if (state?.activeTurn != null) { |
| ... | ... | @@ -2223,7 +2260,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2223 | 2260 | class: "transport_error", |
| 2224 | 2261 | }); |
| 2225 | 2262 | yield* finalizeTurn(state, false); |
| 2226 | } else if (hadPendingWake) { | |
| 2263 | } else { | |
| 2227 | 2264 | yield* cancelPendingPrompts(yield* DateTime.now); |
| 2228 | 2265 | } |
| 2229 | 2266 | if (unsolicitedActivityDetected) { |
| ... | ... | @@ -2844,6 +2881,17 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2844 | 2881 | `No pending Pi extension request ${requestInput.requestId}`, |
| 2845 | 2882 | ); |
| 2846 | 2883 | } |
| 2884 | const answeredAt = yield* DateTime.now; | |
| 2885 | if ( | |
| 2886 | pending.expiresAt !== null && | |
| 2887 | DateTime.toEpochMillis(answeredAt) >= pending.expiresAt | |
| 2888 | ) { | |
| 2889 | pendingPrompts.delete(String(requestInput.requestId)); | |
| 2890 | yield* cancelPrompt(pending, answeredAt); | |
| 2891 | return yield* protocolError( | |
| 2892 | `Pi extension request ${requestInput.requestId} has expired`, | |
| 2893 | ); | |
| 2894 | } | |
| 2847 | 2895 | const response = piUiResponse(pending, requestInput.decision, requestInput.answers); |
| 2848 | 2896 | yield* connection.send({ |
| 2849 | 2897 | type: "extension_ui_response", |
| ... | ... | @@ -2853,6 +2901,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 2853 | 2901 | // Dropped only once Pi has the answer, so a failed send leaves the |
| 2854 | 2902 | // request retryable and still cancellable during teardown. |
| 2855 | 2903 | pendingPrompts.delete(String(requestInput.requestId)); |
| 2904 | if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); | |
| 2856 | 2905 | if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { |
| 2857 | 2906 | sessionApprovals.add(pending.approvalKey); |
| 2858 | 2907 | } |
packages/provider-pi/src/server/mcpExtensionSource.test.ts+14-2| ... | ... | @@ -249,7 +249,16 @@ describe("Pi tool discovery permissions", () => { |
| 249 | 249 | it("allows discovery without confirmation and still gates the discovered tool", async () => { |
| 250 | 250 | type ToolCallHook = ( |
| 251 | 251 | event: { toolName: string; input: unknown }, |
| 252 | ctx: { ui: { confirm: (title: string, detail: string) => Promise<boolean> } }, | |
| 252 | ctx: { | |
| 253 | signal: AbortSignal; | |
| 254 | ui: { | |
| 255 | confirm: ( | |
| 256 | title: string, | |
| 257 | detail: string, | |
| 258 | options: { signal: AbortSignal }, | |
| 259 | ) => Promise<boolean>; | |
| 260 | }; | |
| 261 | }, | |
| 253 | 262 | ) => Promise<{ block: true; reason: string } | undefined>; |
| 254 | 263 | let toolCall: ToolCallHook | undefined; |
| 255 | 264 | let searchPath = "builtin:tool-search"; |
| ... | ... | @@ -270,9 +279,12 @@ describe("Pi tool discovery permissions", () => { |
| 270 | 279 | }); |
| 271 | 280 | assert.isDefined(toolCall); |
| 272 | 281 | const confirmations: string[] = []; |
| 282 | const controller = new AbortController(); | |
| 273 | 283 | const ctx = { |
| 284 | signal: controller.signal, | |
| 274 | 285 | ui: { |
| 275 | confirm: async (title: string) => { | |
| 286 | confirm: async (title: string, _detail: string, options: { signal: AbortSignal }) => { | |
| 287 | assert.strictEqual(options.signal, controller.signal); | |
| 276 | 288 | confirmations.push(title); |
| 277 | 289 | return false; |
| 278 | 290 | }, |
packages/provider-pi/src/server/mcpExtensionSource.ts+1| ... | ... | @@ -318,6 +318,7 @@ export default async function t3McpExtension(pi: ExtensionAPI) { |
| 318 | 318 | const approved = await ctx.ui.confirm( |
| 319 | 319 | \`Allow \${event.toolName}?\`, |
| 320 | 320 | toolInputSummary(event.input), |
| 321 | { signal: ctx.signal }, | |
| 321 | 322 | ); |
| 322 | 323 | if (!approved) { |
| 323 | 324 | return { block: true, reason: \`\${event.toolName} was declined in T3 Code.\` }; |