From 08874c15ac4ee56c286c549821b241121de6470b Mon Sep 17 00:00:00 2001 From: alice <81575558+aliceisjustplaying@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:10:25 +0100 Subject: [PATCH] fix(server): deliver Pi extension notices between turns --- .../Adapters/PiAdapterV2.test.ts | 27 +++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 33 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index df2bade7fc06..1f5c237bac2b 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -461,6 +461,33 @@ const expectModelFailure = (errorMessage: string) => }).pipe(Effect.scoped, Effect.provide(testLayer)); describe("PiAdapterV2", () => { + it.effect("delivers extension notices between turns without starting a run", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ + type: "extension_ui_request", + method: "notify", + id: "idle-note", + message: "A background task finished.", + }); + const event = yield* takeEvent((e) => e.type === "turn_item.updated"); + assert.isTrue(event.type === "turn_item.updated"); + if (event.type !== "turn_item.updated") return; + assert.equal(event.turnItem.providerThreadId, providerThread.id); + assert.isNull(event.turnItem.runId); + assert.isNull(event.turnItem.providerTurnId); + assert.equal(event.turnItem.title, "A background task finished."); + assert.isFalse(fake.allRequests().some((r) => r["type"] === "prompt")); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("stops provider-initiated work that has no T3 turn owner", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 56f3d7b28796..ac92dd435cdc 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -469,6 +469,7 @@ export function makePiAdapterV2( // dialog's own resolution updates. const sessionEventPermit = yield* Semaphore.make(1); let threadState: PiThreadState | null = null; + let noticeOrdinal = 0; let registrationAttempted = false; let lastNativeThreadId: string | null = null; // User Stop intentionally tears down this RPC process after aborting. @@ -1178,7 +1179,37 @@ export function makePiAdapterV2( const state = threadState; const turn = state?.activeTurn ?? null; const message = recordString(event, "message") ?? ""; - if (turn === null || message.length === 0) return; + if (state === null || message.length === 0) return; + if (turn === null) { + const now = yield* DateTime.now; + const nativeItemId = `notify:${input.providerSessionId}:idle:${nativeRequestId ?? DateTime.toEpochMillis(now)}:${noticeOrdinal++}`; + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId, + }), + threadId: input.threadId, + runId: null, + nodeId: null, + providerThreadId: state.providerThread.id, + providerTurnId: null, + nativeItemRef: providerRef(nativeItemId), + parentItemId: null, + ordinal: noticeOrdinal, + startedAt: now, + updatedAt: now, + completedAt: now, + status: "completed", + title: message, + type: "system_notice", + message, + }, + }); + return; + } const emittedAt = yield* DateTime.now; const nativeItemId = `notify:${turn.nextItemOrdinal}`; yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt);