@ai-sdk/harness-pi 1.0.72 → 1.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pi-session.ts CHANGED
@@ -249,6 +249,30 @@ interface PendingToolApproval {
249
249
  interface ActivePiTurn {
250
250
  readonly token: object;
251
251
  readonly done: Promise<void>;
252
+ readonly abort: (reason?: unknown) => Promise<void>;
253
+ }
254
+
255
+ /**
256
+ * A host tool call recorded in the restored journal without a matching tool
257
+ * result — it was awaiting host input (typically a tool approval) when the
258
+ * process that owned the live turn went away.
259
+ */
260
+ interface DanglingHostToolCall {
261
+ readonly toolCallId: string;
262
+ readonly toolName: string;
263
+ }
264
+
265
+ /**
266
+ * Barrier that holds a cross-process rerun until the framework has
267
+ * re-delivered the results for every journal-pending host tool call.
268
+ */
269
+ interface DeferredRerunBarrier {
270
+ /** toolCallId -> toolName still awaiting a submitted result. */
271
+ readonly awaiting: Map<string, string>;
272
+ readonly startRerun: () => void;
273
+ /** Settle the barrier without running: resolves `done` cleanly when no
274
+ * reason is given, rejects it otherwise. No-op once the rerun started. */
275
+ readonly cancel: (reason?: unknown) => void;
252
276
  }
253
277
 
254
278
  export async function createPiSession(
@@ -509,6 +533,20 @@ export async function createPiSession(
509
533
  let suspending = false;
510
534
  const pendingToolResults = new Map<string, PendingToolResult>();
511
535
  const pendingToolApprovals = new Map<string, PendingToolApproval>();
536
+ /*
537
+ * Results the framework submitted for journal-pending (dangling) host tool
538
+ * calls while no live turn held a promise for them — the cross-process
539
+ * continuation path. They are written into the restored journal before the
540
+ * rerun (or on suspend/stop, so a later resume still sees them).
541
+ */
542
+ const deliveredDanglingResults = new Map<
543
+ string,
544
+ { toolName: string; output: unknown; isError: boolean }
545
+ >();
546
+ let restoredSessionManager:
547
+ | ReturnType<typeof SessionManager.open>
548
+ | undefined;
549
+ let deferredRerun: DeferredRerunBarrier | undefined;
512
550
 
513
551
  // Emit channel set at the start of every doPromptTurn and cleared on end.
514
552
  let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
@@ -565,17 +603,258 @@ export async function createPiSession(
565
603
  });
566
604
  }
567
605
 
606
+ function getRestoredSessionManager():
607
+ | ReturnType<typeof SessionManager.open>
608
+ | undefined {
609
+ if (resumeSessionFilePath == null) return undefined;
610
+ restoredSessionManager ??= SessionManager.open(
611
+ resumeSessionFilePath,
612
+ hostSessionDir,
613
+ sessionWorkDir,
614
+ );
615
+ return restoredSessionManager;
616
+ }
617
+
618
+ /*
619
+ * Host tool calls in the restored journal that never received a result on
620
+ * the active branch. These are the calls that were blocked on host input
621
+ * (typically a tool approval) when the process owning the live turn exited;
622
+ * the framework re-delivers their results via `submitToolResult` right after
623
+ * `doContinueTurn` returns. Only meaningful before the first rebuild of a
624
+ * resumed session — once a Pi session is live, pending host input is held as
625
+ * in-process promises instead.
626
+ */
627
+ function findDanglingHostToolCalls(
628
+ userTools: ReadonlyArray<HarnessV1ToolSpec>,
629
+ ): DanglingHostToolCall[] {
630
+ if (piSession != null || resumeSessionFilePath == null) return [];
631
+ const hostToolNames = new Set(userTools.map(tool => tool.name));
632
+ if (hostToolNames.size === 0) return [];
633
+ const journal = getRestoredSessionManager();
634
+ if (journal == null) return [];
635
+ const messages = journal.buildSessionContext().messages;
636
+ /*
637
+ * Results already delivered by a previous continuation of this session
638
+ * count as resolved even though they are not in the journal yet — the
639
+ * framework has marked them settled and will never re-deliver them, so a
640
+ * new barrier must not wait on them (it would deadlock the turn). They
641
+ * are injected into the journal before the rerun.
642
+ */
643
+ const resolvedToolCallIds = new Set<string>(
644
+ deliveredDanglingResults.keys(),
645
+ );
646
+ for (const message of messages) {
647
+ if (message.role === 'toolResult') {
648
+ resolvedToolCallIds.add(message.toolCallId);
649
+ }
650
+ }
651
+ const dangling: DanglingHostToolCall[] = [];
652
+ for (const message of messages) {
653
+ if (message.role !== 'assistant') continue;
654
+ /*
655
+ * Pi's message transform drops errored/aborted assistant messages from
656
+ * the LLM context entirely, so their tool calls are not awaiting
657
+ * results — the model retries from the last valid state instead.
658
+ */
659
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
660
+ continue;
661
+ }
662
+ for (const block of message.content) {
663
+ if (
664
+ block.type === 'toolCall' &&
665
+ hostToolNames.has(block.name) &&
666
+ !resolvedToolCallIds.has(block.id)
667
+ ) {
668
+ dangling.push({ toolCallId: block.id, toolName: block.name });
669
+ }
670
+ }
671
+ }
672
+ return dangling;
673
+ }
674
+
675
+ /*
676
+ * A result submitted while no live turn holds a pending promise for its
677
+ * toolCallId. On the cross-process continuation path this is the framework
678
+ * re-delivering the caller's tool-approval/tool-result continuation for a
679
+ * journal-pending call; stash it for injection and release the rerun once
680
+ * every dangling call has its result. Results for ids that are neither live
681
+ * nor journal-pending have nowhere to go and are dropped, as before.
682
+ */
683
+ function acceptDanglingHostToolResult(args: {
684
+ toolCallId: string;
685
+ output: unknown;
686
+ isError?: boolean;
687
+ }): void {
688
+ const barrier = deferredRerun;
689
+ const toolName = barrier?.awaiting.get(args.toolCallId);
690
+ if (barrier == null || toolName == null) return;
691
+ barrier.awaiting.delete(args.toolCallId);
692
+ deliveredDanglingResults.set(args.toolCallId, {
693
+ toolName,
694
+ output: args.output,
695
+ isError: args.isError ?? false,
696
+ });
697
+ if (barrier.awaiting.size === 0) {
698
+ barrier.startRerun();
699
+ }
700
+ }
701
+
702
+ /*
703
+ * Write delivered dangling-call results into the restored journal so the
704
+ * rerun's context carries the real outputs — without this, Pi's message
705
+ * transform synthesizes an error result ("No result provided") for each
706
+ * dangling call and the model continues as if the tool never answered. The
707
+ * serialized text matches what a live turn would have produced
708
+ * (`asPiToolResult(serializeToolOutput(...))`), so the model sees the same
709
+ * bytes either way.
710
+ */
711
+ function appendDeliveredHostToolResults(): boolean {
712
+ if (deliveredDanglingResults.size === 0 || resumeSessionFilePath == null) {
713
+ return false;
714
+ }
715
+ const journal = getRestoredSessionManager();
716
+ if (journal == null) return false;
717
+ for (const [toolCallId, delivered] of deliveredDanglingResults) {
718
+ journal.appendMessage({
719
+ role: 'toolResult',
720
+ toolCallId,
721
+ toolName: delivered.toolName,
722
+ content: [
723
+ { type: 'text', text: serializeToolOutput(delivered.output) },
724
+ ],
725
+ isError: delivered.isError,
726
+ timestamp: Date.now(),
727
+ });
728
+ }
729
+ deliveredDanglingResults.clear();
730
+ /*
731
+ * The journal on disk now differs from the copy in the sandbox. Make sure
732
+ * the lifecycle persistence knows which file to push back even when no
733
+ * turn ever rebuilt the Pi session in this process (e.g. a suspend that
734
+ * lands while the rerun is still held back).
735
+ */
736
+ if (!sessionFileName) {
737
+ sessionFileName = safePiSessionFileName(
738
+ path.basename(resumeSessionFilePath),
739
+ );
740
+ }
741
+ return true;
742
+ }
743
+
744
+ /*
745
+ * Cross-process continuation of a turn that paused on host input: the
746
+ * restored journal ends with host tool calls that have no results, and the
747
+ * framework re-delivers those results through `control.submitToolResult`
748
+ * (with the original tool-call ids) immediately after this call returns.
749
+ * Starting the rerun right away would race that delivery — the rerun's
750
+ * context would resolve the dangling calls as synthetic empty results and
751
+ * the submitted outputs would be dropped. Hold the rerun until every
752
+ * dangling call's result has arrived, write the results into the journal,
753
+ * and only then re-drive the turn.
754
+ *
755
+ * If the caller resumes without supplying all continuations, the turn stays
756
+ * parked awaiting the remaining host input — the same behaviour as the
757
+ * in-process path, where the live turn stays blocked on its tool promises.
758
+ */
759
+ function deferRerunUntilHostToolResults(
760
+ danglingCalls: ReadonlyArray<DanglingHostToolCall>,
761
+ continueOpts: HarnessV1ContinueTurnOptions,
762
+ ): HarnessV1PromptControl {
763
+ /*
764
+ * A previous continuation may have ended while its rerun was still held
765
+ * back (e.g. it paused again awaiting a tool-result continuation). Close
766
+ * that turn's control cleanly before installing the new barrier.
767
+ */
768
+ deferredRerun?.cancel();
769
+
770
+ let resolveDone!: () => void;
771
+ let rejectDone!: (error: unknown) => void;
772
+ const done = new Promise<void>((resolve, reject) => {
773
+ resolveDone = resolve;
774
+ rejectDone = reject;
775
+ });
776
+ let settled = false;
777
+
778
+ const startRerun = () => {
779
+ if (settled) return;
780
+ settled = true;
781
+ deferredRerun = undefined;
782
+ void (async () => {
783
+ try {
784
+ // `runTurn` injects the delivered results into the journal before
785
+ // rebuilding the Pi session from it.
786
+ const control = await runTurn({
787
+ text: '',
788
+ tools: continueOpts.tools ?? [],
789
+ instructions: continueOpts.instructions,
790
+ emit: continueOpts.emit,
791
+ abortSignal: continueOpts.abortSignal,
792
+ });
793
+ await control.done;
794
+ resolveDone();
795
+ } catch (error) {
796
+ rejectDone(error);
797
+ }
798
+ })();
799
+ };
800
+
801
+ const cancel = (reason?: unknown) => {
802
+ if (settled) return;
803
+ settled = true;
804
+ deferredRerun = undefined;
805
+ if (reason == null) {
806
+ resolveDone();
807
+ } else {
808
+ rejectDone(reason);
809
+ }
810
+ };
811
+
812
+ deferredRerun = {
813
+ awaiting: new Map(
814
+ danglingCalls.map(call => [call.toolCallId, call.toolName]),
815
+ ),
816
+ startRerun,
817
+ cancel,
818
+ };
819
+
820
+ const abortBarrier = () => {
821
+ cancel(
822
+ continueOpts.abortSignal?.reason ??
823
+ new Error(
824
+ 'Pi turn was aborted before its host tool results were delivered.',
825
+ ),
826
+ );
827
+ };
828
+ if (continueOpts.abortSignal?.aborted) {
829
+ abortBarrier();
830
+ } else {
831
+ continueOpts.abortSignal?.addEventListener('abort', abortBarrier, {
832
+ once: true,
833
+ });
834
+ }
835
+
836
+ return createPromptControl({
837
+ done,
838
+ abortSignal: continueOpts.abortSignal,
839
+ });
840
+ }
841
+
568
842
  function createPromptControl(input: {
569
843
  done: Promise<void>;
570
844
  abortSignal?: AbortSignal;
845
+ abort?: (reason?: unknown) => Promise<void>;
571
846
  }): HarnessV1PromptControl {
572
847
  const abortHandler = () => {
573
- piSession?.abort().catch(() => {});
848
+ void input.abort?.(input.abortSignal?.reason);
574
849
  };
575
850
  if (input.abortSignal) {
576
- input.abortSignal.addEventListener('abort', abortHandler, {
577
- once: true,
578
- });
851
+ if (input.abortSignal.aborted) {
852
+ abortHandler();
853
+ } else {
854
+ input.abortSignal.addEventListener('abort', abortHandler, {
855
+ once: true,
856
+ });
857
+ }
579
858
  void input.done.then(
580
859
  () => {
581
860
  input.abortSignal?.removeEventListener('abort', abortHandler);
@@ -589,7 +868,10 @@ export async function createPiSession(
589
868
  return {
590
869
  async submitToolResult(args) {
591
870
  const pending = pendingToolResults.get(args.toolCallId);
592
- if (!pending) return;
871
+ if (!pending) {
872
+ acceptDanglingHostToolResult(args);
873
+ return;
874
+ }
593
875
  pendingToolResults.delete(args.toolCallId);
594
876
  /*
595
877
  * Preserve the original output so the result projection can surface it
@@ -713,11 +995,7 @@ export async function createPiSession(
713
995
  // session; create fresh otherwise.
714
996
  const sessionManager =
715
997
  isFirstBuild && resumeSessionFilePath
716
- ? SessionManager.open(
717
- resumeSessionFilePath,
718
- hostSessionDir,
719
- sessionWorkDir,
720
- )
998
+ ? getRestoredSessionManager()!
721
999
  : SessionManager.create(sessionWorkDir, hostSessionDir);
722
1000
 
723
1001
  const { session } = await createAgentSession({
@@ -781,6 +1059,7 @@ export async function createPiSession(
781
1059
  async function runTurn(turnOpts: {
782
1060
  text: string;
783
1061
  tools: ReadonlyArray<HarnessV1ToolSpec>;
1062
+ instructions?: string;
784
1063
  emit: (part: HarnessV1StreamPart) => void;
785
1064
  abortSignal?: AbortSignal;
786
1065
  }): Promise<HarnessV1PromptControl> {
@@ -789,101 +1068,164 @@ export async function createPiSession(
789
1068
  }
790
1069
 
791
1070
  const userTools = turnOpts.tools;
792
- const signature = JSON.stringify(userTools.map(t => t.name).sort());
793
- const needsRebuild = piSession == null || signature !== lastToolsSignature;
794
- let resourcesReloaded = false;
795
- if (needsRebuild) {
796
- resourcesReloaded = await rebuildPiSession(userTools, piSession == null);
797
- lastToolsSignature = signature;
798
- }
799
-
800
- if (!resourcesReloaded) {
801
- await reloadResourcesOnly();
802
- }
803
- await syncHostWorkspaceFromSandbox({
804
- sandbox,
805
- sandboxWorkDir: input.sessionWorkDir,
806
- hostWorkDir,
807
- });
808
-
809
1071
  currentEmit = turnOpts.emit;
810
- // Fresh translator state for the new turn — keep the tool sets the
811
- // session was built with.
812
- translatorState = createPiTranslatorState({
813
- builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
814
- hostToolNames: userTools.map(tool => tool.name),
815
- nativeToCommon: NATIVE_TO_COMMON,
816
- });
817
-
818
- turnOpts.emit({ type: 'stream-start' });
1072
+ const turnAbortController = new AbortController();
1073
+ const abort = async (reason?: unknown): Promise<void> => {
1074
+ if (turnAbortController.signal.aborted) return;
1075
+ if (reason === undefined) {
1076
+ turnAbortController.abort();
1077
+ } else {
1078
+ turnAbortController.abort(reason);
1079
+ }
1080
+ await Promise.resolve(piSession?.abort()).catch(() => {});
1081
+ };
819
1082
 
820
1083
  const turnPromise = (async () => {
821
- let terminalError: string | undefined;
822
- const session = piSession!;
823
-
824
- // We subscribed in rebuild, but the translator may need to detect
825
- // terminal errors too — wrap a second listener that records them.
826
- const unsubErr = session.subscribe(raw => {
827
- const ev = parseNativeEvent(raw);
828
- if (!ev) return;
829
- const err = getPiTerminalError(ev);
830
- if (err && !terminalError) {
831
- terminalError = err;
1084
+ try {
1085
+ await applySessionInstructions(turnOpts.instructions);
1086
+ turnAbortController.signal.throwIfAborted();
1087
+
1088
+ /*
1089
+ * Any host tool results delivered while no turn was live must land in the
1090
+ * journal before the session (re)builds from it, whichever turn entry
1091
+ * point runs next. No-op when nothing was delivered.
1092
+ */
1093
+ const didAppendDeliveredHostToolResults =
1094
+ appendDeliveredHostToolResults();
1095
+
1096
+ const signature = JSON.stringify(userTools.map(t => t.name).sort());
1097
+ const needsRebuild =
1098
+ piSession == null || signature !== lastToolsSignature;
1099
+ let resourcesReloaded = false;
1100
+ if (needsRebuild) {
1101
+ resourcesReloaded = await rebuildPiSession(
1102
+ userTools,
1103
+ piSession == null,
1104
+ );
1105
+ turnAbortController.signal.throwIfAborted();
1106
+ lastToolsSignature = signature;
832
1107
  }
833
- });
834
1108
 
835
- try {
836
- await session.prompt(turnOpts.text);
837
-
838
- if (terminalError) {
839
- /*
840
- * A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
841
- * that abort as a *resolved* prompt with a recorded terminal error
842
- * ("This operation was aborted") rather than a thrown exception, so the
843
- * `catch` guard below never sees it. Swallow it here too — but only if
844
- * it's actually the abort: the stream then closes cleanly (no spurious
845
- * `error` chunk) and the next slice rerun-continues from the journal.
846
- * Any other terminal error mid-suspend is unanticipated and must
847
- * surface.
848
- */
849
- if (suspending && isAbortError(terminalError)) return;
850
- currentEmit?.({ type: 'error', error: new Error(terminalError) });
851
- return;
1109
+ if (!resourcesReloaded) {
1110
+ await reloadResourcesOnly();
1111
+ turnAbortController.signal.throwIfAborted();
852
1112
  }
1113
+ await syncHostWorkspaceFromSandbox({
1114
+ sandbox,
1115
+ sandboxWorkDir: input.sessionWorkDir,
1116
+ hostWorkDir,
1117
+ });
1118
+ turnAbortController.signal.throwIfAborted();
1119
+
1120
+ // Fresh translator state for the new turn — keep the tool sets the
1121
+ // session was built with.
1122
+ translatorState = createPiTranslatorState({
1123
+ builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
1124
+ hostToolNames: userTools.map(tool => tool.name),
1125
+ nativeToCommon: NATIVE_TO_COMMON,
1126
+ });
853
1127
 
854
- const stats = session.getSessionStats();
855
- const finishReason = {
856
- unified: 'stop' as const,
857
- raw: undefined,
858
- };
859
- const usage = {
860
- inputTokens: {
861
- total: stats.tokens.input,
862
- noCache: undefined,
863
- cacheRead: stats.tokens.cacheRead,
864
- cacheWrite: stats.tokens.cacheWrite,
865
- },
866
- outputTokens: {
867
- total: stats.tokens.output,
868
- text: undefined,
869
- reasoning: undefined,
870
- },
871
- };
872
- currentEmit?.({
873
- type: 'finish',
874
- finishReason,
875
- totalUsage: usage,
1128
+ currentEmit?.({ type: 'stream-start' });
1129
+
1130
+ /*
1131
+ * A live continuation reports the completed tool execution before the
1132
+ * next assistant message, which closes the resumed tool-call step. A
1133
+ * journal rerun starts after that result has already been persisted,
1134
+ * so Pi has no live tool event to emit. Recreate only the missing step
1135
+ * boundary; otherwise the continuation layer mistakes the next
1136
+ * assistant response for the resumed step and discards it.
1137
+ */
1138
+ if (didAppendDeliveredHostToolResults) {
1139
+ currentEmit?.({
1140
+ type: 'finish-step',
1141
+ finishReason: { unified: 'tool-calls', raw: undefined },
1142
+ usage: {
1143
+ inputTokens: {
1144
+ total: 0,
1145
+ noCache: 0,
1146
+ cacheRead: 0,
1147
+ cacheWrite: 0,
1148
+ },
1149
+ outputTokens: {
1150
+ total: 0,
1151
+ text: 0,
1152
+ reasoning: 0,
1153
+ },
1154
+ },
1155
+ harnessMetadata: { pi: { inferredStep: true } },
1156
+ });
1157
+ }
1158
+
1159
+ let terminalError: string | undefined;
1160
+ const session = piSession!;
1161
+
1162
+ // We subscribed in rebuild, but the translator may need to detect
1163
+ // terminal errors too — wrap a second listener that records them.
1164
+ const unsubErr = session.subscribe(raw => {
1165
+ const ev = parseNativeEvent(raw);
1166
+ if (!ev) return;
1167
+ const err = getPiTerminalError(ev);
1168
+ if (err && !terminalError) {
1169
+ terminalError = err;
1170
+ }
876
1171
  });
1172
+
1173
+ try {
1174
+ await session.prompt(turnOpts.text);
1175
+
1176
+ if (terminalError) {
1177
+ /*
1178
+ * A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
1179
+ * that abort as a *resolved* prompt with a recorded terminal error
1180
+ * ("This operation was aborted") rather than a thrown exception, so the
1181
+ * `catch` guard below never sees it. Swallow it here too — but only if
1182
+ * it's actually the abort: the stream then closes cleanly (no spurious
1183
+ * `error` chunk) and the next slice rerun-continues from the journal.
1184
+ * Any other terminal error mid-suspend is unanticipated and must
1185
+ * surface.
1186
+ */
1187
+ if (suspending && isAbortError(terminalError)) return;
1188
+ currentEmit?.({ type: 'error', error: new Error(terminalError) });
1189
+ return;
1190
+ }
1191
+
1192
+ const stats = session.getSessionStats();
1193
+ const finishReason = {
1194
+ unified: 'stop' as const,
1195
+ raw: undefined,
1196
+ };
1197
+ const usage = {
1198
+ inputTokens: {
1199
+ total: stats.tokens.input,
1200
+ noCache: undefined,
1201
+ cacheRead: stats.tokens.cacheRead,
1202
+ cacheWrite: stats.tokens.cacheWrite,
1203
+ },
1204
+ outputTokens: {
1205
+ total: stats.tokens.output,
1206
+ text: undefined,
1207
+ reasoning: undefined,
1208
+ },
1209
+ };
1210
+ currentEmit?.({
1211
+ type: 'finish',
1212
+ finishReason,
1213
+ totalUsage: usage,
1214
+ });
1215
+ } catch (err) {
1216
+ // A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
1217
+ // so the stream closes cleanly without a spurious `error` chunk; the
1218
+ // next slice rerun-continues from the persisted journal.
1219
+ // Same rule as the resolved-with-terminalError path: only swallow the
1220
+ // abort our own suspend caused; surface anything unanticipated.
1221
+ if (suspending && isAbortError(err)) return;
1222
+ currentEmit?.({ type: 'error', error: err });
1223
+ } finally {
1224
+ unsubErr();
1225
+ }
877
1226
  } catch (err) {
878
- // A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
879
- // so the stream closes cleanly without a spurious `error` chunk; the
880
- // next slice rerun-continues from the persisted journal.
881
- // Same rule as the resolved-with-terminalError path: only swallow the
882
- // abort our own suspend caused; surface anything unanticipated.
883
1227
  if (suspending && isAbortError(err)) return;
884
- currentEmit?.({ type: 'error', error: err });
885
- } finally {
886
- unsubErr();
1228
+ throw err;
887
1229
  }
888
1230
  })();
889
1231
 
@@ -891,17 +1233,19 @@ export async function createPiSession(
891
1233
  const done = turnPromise.finally(() => {
892
1234
  if (activeTurn?.token === activeTurnToken) {
893
1235
  activeTurn = undefined;
1236
+ currentEmit = undefined;
894
1237
  }
895
- currentEmit = undefined;
896
1238
  });
897
1239
  activeTurn = {
898
1240
  token: activeTurnToken,
899
1241
  done,
1242
+ abort,
900
1243
  };
901
1244
 
902
1245
  return createPromptControl({
903
1246
  done,
904
1247
  abortSignal: turnOpts.abortSignal,
1248
+ abort,
905
1249
  });
906
1250
  }
907
1251
 
@@ -911,8 +1255,24 @@ export async function createPiSession(
911
1255
  }
912
1256
  stopped = true;
913
1257
  parkedPiSessions.delete(input.sessionId);
1258
+ deferredRerun?.cancel();
1259
+ const turnToStop = activeTurn;
1260
+ const abortingTurn = turnToStop?.abort();
914
1261
  settlePendingToolResults('Pi session stopped');
915
1262
  settlePendingToolApprovals('Pi session stopped');
1263
+ await abortingTurn;
1264
+ await turnToStop?.done.catch(() => {});
1265
+
1266
+ /*
1267
+ * Results the framework already delivered for journal-pending calls must
1268
+ * reach the journal before it is persisted — the framework has marked
1269
+ * them settled and will not re-deliver them on a later resume.
1270
+ */
1271
+ try {
1272
+ appendDeliveredHostToolResults();
1273
+ } catch {
1274
+ // Best-effort: an unwritable journal falls back to the pre-delivery copy.
1275
+ }
916
1276
 
917
1277
  // Persist the Pi session file into the sandbox so a future process
918
1278
  // can pick it up after `provider.resumeSession({ sessionId })` reattaches.
@@ -951,11 +1311,10 @@ export async function createPiSession(
951
1311
  doPromptTurn: async (
952
1312
  promptOpts: HarnessV1PromptTurnOptions,
953
1313
  ): Promise<HarnessV1PromptControl> => {
954
- await applySessionInstructions(promptOpts.instructions);
955
-
956
1314
  return runTurn({
957
1315
  text: extractUserText(promptOpts.prompt),
958
1316
  tools: promptOpts.tools ?? [],
1317
+ instructions: promptOpts.instructions,
959
1318
  emit: promptOpts.emit,
960
1319
  abortSignal: promptOpts.abortSignal,
961
1320
  });
@@ -969,9 +1328,31 @@ export async function createPiSession(
969
1328
  return createPromptControl({
970
1329
  done: activeTurn.done,
971
1330
  abortSignal: continueOpts.abortSignal,
1331
+ abort: activeTurn.abort,
972
1332
  });
973
1333
  }
974
1334
 
1335
+ if (stopped) {
1336
+ throw new Error('Pi session has been stopped.');
1337
+ }
1338
+
1339
+ /*
1340
+ * The restored journal ends with host tool calls that never got their
1341
+ * results — the turn was paused on host input (e.g. a tool approval)
1342
+ * when the previous process exited. The framework re-delivers those
1343
+ * results via `submitToolResult` right after this call returns; hold
1344
+ * the rerun until they have all arrived so they reach the model.
1345
+ */
1346
+ const danglingHostToolCalls = findDanglingHostToolCalls(
1347
+ continueOpts.tools ?? [],
1348
+ );
1349
+ if (danglingHostToolCalls.length > 0) {
1350
+ return deferRerunUntilHostToolResults(
1351
+ danglingHostToolCalls,
1352
+ continueOpts,
1353
+ );
1354
+ }
1355
+
975
1356
  /*
976
1357
  * Pi runs the model on the host, so there is no live turn in the sandbox
977
1358
  * to attach to — the previous slice's turn died with its process.
@@ -980,10 +1361,10 @@ export async function createPiSession(
980
1361
  * flight at the slice boundary is recomputed because a host-resident
981
1362
  * runtime cannot do a lossless attach.
982
1363
  */
983
- await applySessionInstructions(continueOpts.instructions);
984
1364
  return runTurn({
985
1365
  text: '',
986
1366
  tools: continueOpts.tools ?? [],
1367
+ instructions: continueOpts.instructions,
987
1368
  emit: continueOpts.emit,
988
1369
  abortSignal: continueOpts.abortSignal,
989
1370
  });
@@ -1006,8 +1387,13 @@ export async function createPiSession(
1006
1387
  if (stopped) return;
1007
1388
  stopped = true;
1008
1389
  parkedPiSessions.delete(input.sessionId);
1390
+ deferredRerun?.cancel();
1391
+ const turnToDestroy = activeTurn;
1392
+ const abortingTurn = turnToDestroy?.abort();
1009
1393
  settlePendingToolResults('Pi session stopped');
1010
1394
  settlePendingToolApprovals('Pi session stopped');
1395
+ await abortingTurn;
1396
+ await turnToDestroy?.done.catch(() => {});
1011
1397
  await disposePiSession();
1012
1398
  workspaceVfs.unmount();
1013
1399
  await rm(hostRoot, { recursive: true, force: true });
@@ -1077,7 +1463,22 @@ export async function createPiSession(
1077
1463
  * turn the way a bridge adapter can.
1078
1464
  */
1079
1465
  suspending = true;
1080
- await Promise.resolve(piSession?.abort()).catch(() => {});
1466
+ const turnToSuspend = activeTurn;
1467
+ await turnToSuspend?.abort();
1468
+ deferredRerun?.cancel();
1469
+ await turnToSuspend?.done.catch(() => {});
1470
+
1471
+ /*
1472
+ * A suspend can land while the rerun is still held back waiting for
1473
+ * host tool results. Whatever the framework already delivered must land
1474
+ * in the journal now — it will not be re-delivered — while calls still
1475
+ * awaiting results stay dangling for the next continuation to collect.
1476
+ */
1477
+ try {
1478
+ appendDeliveredHostToolResults();
1479
+ } catch {
1480
+ // Best-effort: an unwritable journal falls back to the pre-delivery copy.
1481
+ }
1081
1482
 
1082
1483
  if (sessionFileName) {
1083
1484
  try {