pi-long-task 0.4.0 → 0.6.0

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.
@@ -8,14 +8,25 @@ import {
8
8
  parseCompleteTaskResult,
9
9
  parseReportedStatus,
10
10
  } from "./result_writer.ts";
11
+ import type { NetworkRecoveryConfig } from "./network_recovery_config.ts";
11
12
  import type { Task } from "./todo_parser.ts";
12
13
 
14
+ export interface WorkerNetworkRecoveryContext {
15
+ /** One-based coordinator network retry count; this is not a task attempt. */
16
+ retryCount: number;
17
+ durableEvidencePath: string;
18
+ priorSessionId?: string;
19
+ failure: string;
20
+ }
21
+
13
22
  export interface WorkerTaskPromptOptions {
14
23
  todoPath: string;
15
24
  task: Pick<Task, "taskId" | "title" | "section">;
16
25
  attempt: number;
17
26
  commitRequested: boolean;
18
27
  previousAttempts?: string;
28
+ /** Continuity supplied only when a failed transport session is replaced. */
29
+ networkRecoveryContext?: WorkerNetworkRecoveryContext;
19
30
  globalInstructions?: string;
20
31
  goal?: string;
21
32
  maxBashTimeoutSeconds: number;
@@ -43,6 +54,18 @@ Previous attempts for this same assigned task are below. Use them only as contin
43
54
  \`\`\`text
44
55
  ${previousAttempts}
45
56
  \`\`\`
57
+ `
58
+ : "";
59
+
60
+ const recovery = options.networkRecoveryContext;
61
+ const recoveryText = recovery
62
+ ? `
63
+ Network recovery continuation (network retry ${recovery.retryCount}, still ordinary task attempt ${options.attempt}):
64
+ - The prior worker session ended only after Pi's bounded provider-request retries were exhausted: ${recovery.failure}
65
+ - Durable interruption evidence was recorded in \`${recovery.durableEvidencePath}\`${recovery.priorSessionId ? ` for session \`${recovery.priorSessionId}\`` : ""}.
66
+ - The prior session may already have completed tool calls and changed the working tree. Inspect the durable evidence and current files before acting.
67
+ - Continue this same TODO from its current state. Never blindly replay prior edits, commands, commits, external writes, or other side effects.
68
+ - Report one final TASK_RESULT for the assignment only after verifying what remains.
46
69
  `
47
70
  : "";
48
71
 
@@ -93,7 +116,7 @@ Rules:
93
116
  - Do not run bash commands with timeout greater than ${options.maxBashTimeoutSeconds.toFixed(0)} seconds. For long full-suite checks, run once with a bounded timeout and report any timeout/failure in TASK_RESULT instead of continuing indefinitely.
94
117
  - If TODO-file global instructions restrict scope, obey them strictly. If the task appears to require out-of-scope code changes, stop and report \`status: blocked\` instead of changing those files.
95
118
 
96
- ${globalText}Assigned task content only:
119
+ ${globalText}${recoveryText}Assigned task content only:
97
120
 
98
121
  \`\`\`markdown
99
122
  ${options.task.section.trimEnd()}
@@ -116,6 +139,18 @@ Only use \`status: done\` if the assigned task is fully complete and verified as
116
139
 
117
140
  export const buildAssignedTaskPrompt = buildTaskPrompt;
118
141
 
142
+ export function buildReusedAssignmentPrompt(
143
+ options: WorkerTaskPromptOptions,
144
+ previousTask: Pick<Task, "taskId" | "title">,
145
+ ): string {
146
+ return `Pi Long Task assignment boundary:
147
+ The prior assignment ${taskLabel(previousTask)} has ended.
148
+ A new, independent assignment has begun: ${taskLabel(options.task)}.
149
+ Treat every instruction and TASK_RESULT below as belonging only to this new assignment. Do not repeat or reuse the prior assignment's TASK_RESULT.
150
+
151
+ ${buildTaskPrompt(options)}`;
152
+ }
153
+
119
154
  export function buildTimeLimitMessage(seconds: number): string {
120
155
  return `Pi Long Task notice: this worker session has reached its ${seconds.toFixed(0)}s time budget.
121
156
  Stop after the current safe point. Do not start more implementation work.
@@ -244,6 +279,18 @@ export interface WorkerSessionFactoryResult {
244
279
  diagnostics?: string[];
245
280
  }
246
281
 
282
+ /** Coordinator-owned session allocation. Disposal is idempotent and owned by disposeWorkerSessionResource(). */
283
+ export interface WorkerSessionResource extends WorkerSessionFactoryResult {
284
+ disposed: boolean;
285
+ /** Last readable cumulative statistics, retained only for task-boundary delta accounting. */
286
+ accountingBaseline?: WorkerSessionStatsSnapshot;
287
+ completedAssignments?: number;
288
+ }
289
+
290
+ export interface ReusedWorkerAssignment {
291
+ previousTask: Pick<Task, "taskId" | "title">;
292
+ }
293
+
247
294
  export interface CreateWorkerSessionOptions {
248
295
  cwd: string;
249
296
  agentDir?: string;
@@ -265,9 +312,12 @@ export type WorkerSessionFactory = (options: CreateWorkerSessionOptions) => Prom
265
312
  export interface RunWorkerTaskOptions extends WorkerTaskPromptOptions, CreateWorkerSessionOptions {
266
313
  taskTimeoutSeconds?: number;
267
314
  gracefulShutdownSeconds?: number;
315
+ /** Normalized coordinator policy for resuming this operation after transient transport failure. */
316
+ networkRecovery?: Readonly<NetworkRecoveryConfig>;
268
317
  abortSignal?: AbortSignal;
269
318
  sessionFactory?: WorkerSessionFactory;
270
319
  onEvent?: (event: CapturedWorkerEvent) => void;
320
+ onSessionDiagnostic?: (diagnostic: WorkerSessionDiagnostic) => void;
271
321
  now?: () => Date;
272
322
  }
273
323
 
@@ -282,6 +332,29 @@ export interface CapturedWorkerEvent {
282
332
  usageCostKey?: string;
283
333
  }
284
334
 
335
+ export interface WorkerUsageTotals {
336
+ input: number;
337
+ output: number;
338
+ cacheRead: number;
339
+ cacheWrite: number;
340
+ total: number;
341
+ }
342
+
343
+ export interface WorkerSessionStatsSnapshot {
344
+ cost?: number;
345
+ tokens?: WorkerUsageTotals;
346
+ }
347
+
348
+ export type WorkerSessionLifecycleEvent = "session_started" | "session_reused" | "session_rotated" | "session_retained";
349
+
350
+ export interface WorkerSessionDiagnostic {
351
+ event: WorkerSessionLifecycleEvent;
352
+ reasonCode: string;
353
+ contextUsagePercent?: number;
354
+ contextThresholdPercent?: number;
355
+ previousTaskId?: string;
356
+ }
357
+
285
358
  export interface SessionOutcome {
286
359
  task: Pick<Task, "taskId" | "title" | "section">;
287
360
  attempt: number;
@@ -297,10 +370,16 @@ export interface SessionOutcome {
297
370
  events: CapturedWorkerEvent[];
298
371
  workerCostTotal: number;
299
372
  workerCostSource?: string;
373
+ /** Task/attempt-scoped token deltas when cumulative session statistics are available. */
374
+ workerUsage?: WorkerUsageTotals;
375
+ /** Additive lifecycle evidence; omitted by legacy/custom worker runners. */
376
+ sessionDiagnostics?: WorkerSessionDiagnostic[];
300
377
  shutdownRequested: boolean;
301
378
  timedOut: boolean;
302
379
  aborted: boolean;
303
380
  error?: string;
381
+ /** Original provider/transport failure retained for coordinator classification. */
382
+ failure?: unknown;
304
383
  }
305
384
 
306
385
  export function buildMissingTaskResultMessage(): string {
@@ -424,7 +503,29 @@ export async function createIsolatedWorkerSession(
424
503
  };
425
504
  }
426
505
 
427
- export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<SessionOutcome> {
506
+ export async function createWorkerSessionResource(
507
+ options: CreateWorkerSessionOptions,
508
+ sessionFactory: WorkerSessionFactory = createIsolatedWorkerSession,
509
+ ): Promise<WorkerSessionResource> {
510
+ const result = await sessionFactory(options);
511
+ return { ...result, disposed: false, completedAssignments: 0 };
512
+ }
513
+
514
+ /** Dispose an allocated worker session at most once, regardless of competing ownership paths. */
515
+ export async function disposeWorkerSessionResource(resource: WorkerSessionResource): Promise<void> {
516
+ if (resource.disposed) {
517
+ return;
518
+ }
519
+ resource.disposed = true;
520
+ await Promise.resolve(resource.session.dispose?.());
521
+ }
522
+
523
+ /** Execute exactly one assignment in an already-created session without disposing that session. */
524
+ export async function runWorkerTaskAssignment(
525
+ options: RunWorkerTaskOptions,
526
+ resource: WorkerSessionResource,
527
+ reusedAssignment?: ReusedWorkerAssignment,
528
+ ): Promise<SessionOutcome> {
428
529
  const now = options.now ?? (() => new Date());
429
530
  const startedAt = now().toISOString();
430
531
  const contextObservations: string[] = [];
@@ -438,18 +539,24 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
438
539
  let timedOut = false;
439
540
  let aborted = false;
440
541
  let error: string | undefined;
542
+ let failure: unknown;
441
543
  let finished = false;
442
544
  let turnCount = 0;
443
545
  let messageUsageCostTotal = 0;
444
546
  let hasMessageUsageCost = false;
445
- let sessionStatsCostTotal: number | undefined;
547
+ let sessionStatsStart: WorkerSessionStatsSnapshot | undefined;
548
+ let sessionStatsEnd: WorkerSessionStatsSnapshot | undefined;
549
+ let accountingBaseline: WorkerSessionStatsSnapshot | undefined;
550
+ const wasFirstAssignment = (resource.completedAssignments ?? 0) === 0;
446
551
  let resolvePromptWait: (() => void) | undefined;
447
552
 
448
- const prompt = buildTaskPrompt(options);
553
+ const prompt = reusedAssignment
554
+ ? buildReusedAssignmentPrompt(options, reusedAssignment.previousTask)
555
+ : buildTaskPrompt(options);
449
556
  const taskTimeoutSeconds = options.taskTimeoutSeconds ?? DEFAULT_TASK_TIMEOUT_SECONDS;
450
557
  const gracefulShutdownSeconds = options.gracefulShutdownSeconds ?? DEFAULT_GRACEFUL_SHUTDOWN_SECONDS;
451
- const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
452
- let session: WorkerSessionLike | undefined;
558
+ const session = resource.session;
559
+ const invocationMessageStart = Array.isArray(session.messages) ? session.messages.length : 0;
453
560
  let unsubscribe: (() => void) | undefined;
454
561
  const timers = new Set<ReturnType<typeof setTimeout>>();
455
562
 
@@ -558,6 +665,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
558
665
  },
559
666
  (exc: unknown) => {
560
667
  settled = true;
668
+ failure ??= exc;
561
669
  error = error ?? errorMessage(exc);
562
670
  resolvePromptWait?.();
563
671
  resolvePromptWait = undefined;
@@ -578,14 +686,14 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
578
686
  throw new Error("worker session aborted before start");
579
687
  }
580
688
 
581
- const factoryResult = await sessionFactory(options);
582
- session = factoryResult.session;
689
+ sessionStatsStart = await workerSessionStatsSnapshot(session);
690
+ accountingBaseline = sessionStatsStart ?? resource.accountingBaseline;
583
691
  sessionFile = session.sessionFile;
584
692
  sessionId = session.sessionId;
585
- if (factoryResult.modelFallbackMessage) {
586
- contextObservations.push(`model fallback: ${factoryResult.modelFallbackMessage}`);
693
+ if (resource.modelFallbackMessage) {
694
+ contextObservations.push(`model fallback: ${resource.modelFallbackMessage}`);
587
695
  }
588
- for (const diagnostic of factoryResult.diagnostics ?? []) {
696
+ for (const diagnostic of resource.diagnostics ?? []) {
589
697
  contextObservations.push(diagnostic);
590
698
  }
591
699
 
@@ -684,33 +792,33 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
684
792
  }
685
793
 
686
794
  await waitForPrompt(prompt);
687
- assistantText = latestAssistantText(session, assistantText);
795
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
688
796
 
689
797
  if (!hasCompleteTaskResult(assistantText) && !error && !aborted && !timedOut && !options.abortSignal?.aborted) {
690
798
  contextObservations.push(
691
799
  "missing TASK_RESULT status after initial prompt, or required fields were incomplete; requested required block once",
692
800
  );
693
801
  await waitForPrompt(buildMissingTaskResultMessage());
694
- assistantText = latestAssistantText(session, assistantText);
802
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
695
803
  }
696
804
  } catch (exc) {
805
+ failure ??= exc;
697
806
  error = error ?? errorMessage(exc);
698
807
  } finally {
699
808
  finished = true;
700
809
  clearTimers();
701
810
  options.abortSignal?.removeEventListener("abort", abortListener);
702
811
  unsubscribe?.();
703
- if (session) {
704
- assistantText = latestAssistantText(session, assistantText);
705
- sessionFile = session.sessionFile ?? sessionFile;
706
- sessionId = session.sessionId ?? sessionId;
707
- sessionStatsCostTotal = await workerUsageCostFromSessionStats(session);
708
- try {
709
- await Promise.resolve(session.dispose?.());
710
- } catch (exc) {
711
- compactionEvents.push(`session dispose failed: ${errorMessage(exc)}`);
712
- }
812
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
813
+ sessionFile = session.sessionFile ?? sessionFile;
814
+ sessionId = session.sessionId ?? sessionId;
815
+ sessionStatsEnd = await workerSessionStatsSnapshot(session);
816
+ if (sessionStatsEnd) {
817
+ resource.accountingBaseline = sessionStatsEnd;
818
+ } else if (sessionStatsStart) {
819
+ resource.accountingBaseline = sessionStatsStart;
713
820
  }
821
+ resource.completedAssignments = (resource.completedAssignments ?? 0) + 1;
714
822
  }
715
823
 
716
824
  if ((error || aborted || timedOut) && !hasTaskResult(assistantText)) {
@@ -719,9 +827,11 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
719
827
 
720
828
  const parsedResult = parseCompleteTaskResult(assistantText);
721
829
  const reportedStatus = parsedResult?.status ?? parseReportedStatus(assistantText);
830
+ const cancelled = Boolean(options.abortSignal?.aborted);
831
+ const statsDelta = workerSessionStatsDelta(accountingBaseline, sessionStatsEnd, wasFirstAssignment);
722
832
  const capturedWorkerCost = selectWorkerCostTotal({
723
833
  messageCostTotal: hasMessageUsageCost ? messageUsageCostTotal : undefined,
724
- statsCostTotal: sessionStatsCostTotal,
834
+ statsCostTotal: statsDelta?.cost,
725
835
  });
726
836
  return {
727
837
  task: options.task,
@@ -729,7 +839,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
729
839
  startedAt,
730
840
  endedAt: now().toISOString(),
731
841
  reportedStatus,
732
- done: Boolean(parsedResult && isDoneStatus(reportedStatus) && !error && !aborted && !timedOut),
842
+ done: Boolean(parsedResult && isDoneStatus(reportedStatus) && !error && !aborted && !timedOut && !cancelled),
733
843
  assistantText,
734
844
  sessionFile,
735
845
  sessionId,
@@ -738,10 +848,62 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
738
848
  events,
739
849
  workerCostTotal: capturedWorkerCost.total,
740
850
  workerCostSource: capturedWorkerCost.source,
851
+ workerUsage: statsDelta?.tokens,
741
852
  shutdownRequested,
742
853
  timedOut,
743
- aborted: aborted || Boolean(options.abortSignal?.aborted),
854
+ aborted: aborted || cancelled,
744
855
  error,
856
+ ...(failure === undefined ? {} : { failure }),
857
+ };
858
+ }
859
+
860
+ /** Backward-compatible isolated lifecycle: create, execute one assignment, and dispose. */
861
+ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<SessionOutcome> {
862
+ let resource: WorkerSessionResource | undefined;
863
+ try {
864
+ resource = await createWorkerSessionResource(options, options.sessionFactory ?? createIsolatedWorkerSession);
865
+ return await runWorkerTaskAssignment(options, resource);
866
+ } catch (error) {
867
+ if (resource) {
868
+ throw error;
869
+ }
870
+ return buildWorkerSessionCreationFailureOutcome(options, error);
871
+ } finally {
872
+ if (resource) {
873
+ try {
874
+ await disposeWorkerSessionResource(resource);
875
+ } catch {
876
+ // Preserve the historical best-effort worker disposal behavior.
877
+ }
878
+ }
879
+ }
880
+ }
881
+
882
+ export function buildWorkerSessionCreationFailureOutcome(
883
+ options: RunWorkerTaskOptions,
884
+ error: unknown,
885
+ ): SessionOutcome {
886
+ const now = options.now ?? (() => new Date());
887
+ const startedAt = now().toISOString();
888
+ const message = errorMessage(error);
889
+ const assistantText = buildLongTaskFailureTaskResult(message);
890
+ return {
891
+ task: options.task,
892
+ attempt: options.attempt,
893
+ startedAt,
894
+ endedAt: now().toISOString(),
895
+ reportedStatus: parseReportedStatus(assistantText),
896
+ done: false,
897
+ assistantText,
898
+ contextObservations: [],
899
+ compactionEvents: [],
900
+ events: [],
901
+ workerCostTotal: 0,
902
+ shutdownRequested: false,
903
+ timedOut: false,
904
+ aborted: Boolean(options.abortSignal?.aborted),
905
+ error: message,
906
+ failure: error,
745
907
  };
746
908
  }
747
909
 
@@ -1015,18 +1177,92 @@ export function workerUsageCostFromStats(stats: unknown): number | undefined {
1015
1177
  return usageCostTotal(stats.usage) ?? usageCostTotal(stats);
1016
1178
  }
1017
1179
 
1018
- async function workerUsageCostFromSessionStats(session: WorkerSessionLike): Promise<number | undefined> {
1180
+ async function workerSessionStatsSnapshot(session: WorkerSessionLike): Promise<WorkerSessionStatsSnapshot | undefined> {
1019
1181
  if (!session.getSessionStats) {
1020
1182
  return undefined;
1021
1183
  }
1022
1184
 
1023
1185
  try {
1024
- return workerUsageCostFromStats(await session.getSessionStats());
1186
+ const stats = await session.getSessionStats();
1187
+ const cost = workerUsageCostFromStats(stats);
1188
+ const tokens = workerUsageTokensFromStats(stats);
1189
+ return cost === undefined && !tokens ? undefined : { cost, tokens };
1025
1190
  } catch {
1026
1191
  return undefined;
1027
1192
  }
1028
1193
  }
1029
1194
 
1195
+ /**
1196
+ * Convert cumulative session counters into one assignment's nonnegative delta.
1197
+ * A lower ending counter means the SDK reset that counter, so the ending value
1198
+ * is the entire post-reset contribution. Without any baseline, cumulative
1199
+ * values are safe only for the resource's first assignment.
1200
+ */
1201
+ export function workerSessionStatsDelta(
1202
+ baseline: WorkerSessionStatsSnapshot | undefined,
1203
+ ending: WorkerSessionStatsSnapshot | undefined,
1204
+ firstAssignment: boolean,
1205
+ ): WorkerSessionStatsSnapshot | undefined {
1206
+ if (!ending) {
1207
+ return undefined;
1208
+ }
1209
+ if (!baseline && !firstAssignment) {
1210
+ return undefined;
1211
+ }
1212
+
1213
+ const cost = cumulativeCounterDelta(baseline?.cost, ending.cost, firstAssignment);
1214
+ const tokens = ending.tokens
1215
+ ? {
1216
+ input: cumulativeCounterDelta(baseline?.tokens?.input, ending.tokens.input, firstAssignment) ?? 0,
1217
+ output: cumulativeCounterDelta(baseline?.tokens?.output, ending.tokens.output, firstAssignment) ?? 0,
1218
+ cacheRead: cumulativeCounterDelta(baseline?.tokens?.cacheRead, ending.tokens.cacheRead, firstAssignment) ?? 0,
1219
+ cacheWrite:
1220
+ cumulativeCounterDelta(baseline?.tokens?.cacheWrite, ending.tokens.cacheWrite, firstAssignment) ?? 0,
1221
+ total: cumulativeCounterDelta(baseline?.tokens?.total, ending.tokens.total, firstAssignment) ?? 0,
1222
+ }
1223
+ : undefined;
1224
+ return cost === undefined && !tokens ? undefined : { cost, tokens };
1225
+ }
1226
+
1227
+ function workerUsageTokensFromStats(stats: unknown): WorkerUsageTotals | undefined {
1228
+ if (!isRecord(stats)) {
1229
+ return undefined;
1230
+ }
1231
+ const tokens = isRecord(stats.tokens) ? stats.tokens : isRecord(stats.usage) ? stats.usage : undefined;
1232
+ if (!tokens) {
1233
+ return undefined;
1234
+ }
1235
+ const input = finiteNonNegativeNumber(tokens.input);
1236
+ const output = finiteNonNegativeNumber(tokens.output);
1237
+ const cacheRead = finiteNonNegativeNumber(tokens.cacheRead ?? tokens.cache_read);
1238
+ const cacheWrite = finiteNonNegativeNumber(tokens.cacheWrite ?? tokens.cache_write);
1239
+ const total = finiteNonNegativeNumber(tokens.total);
1240
+ if ([input, output, cacheRead, cacheWrite, total].every((value) => value === undefined)) {
1241
+ return undefined;
1242
+ }
1243
+ return {
1244
+ input: input ?? 0,
1245
+ output: output ?? 0,
1246
+ cacheRead: cacheRead ?? 0,
1247
+ cacheWrite: cacheWrite ?? 0,
1248
+ total: total ?? (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0),
1249
+ };
1250
+ }
1251
+
1252
+ function cumulativeCounterDelta(
1253
+ baseline: number | undefined,
1254
+ ending: number | undefined,
1255
+ allowUnbased: boolean,
1256
+ ): number | undefined {
1257
+ if (ending === undefined) {
1258
+ return undefined;
1259
+ }
1260
+ if (baseline === undefined) {
1261
+ return allowUnbased ? ending : undefined;
1262
+ }
1263
+ return ending >= baseline ? ending - baseline : ending;
1264
+ }
1265
+
1030
1266
  function usageCostTotal(usage: unknown): number | undefined {
1031
1267
  if (!isRecord(usage)) {
1032
1268
  return undefined;
@@ -1074,6 +1310,19 @@ function contextUsageFromStats(stats: unknown): unknown {
1074
1310
  return isRecord(stats) ? stats.contextUsage : undefined;
1075
1311
  }
1076
1312
 
1313
+ export async function workerSessionContextUsagePercent(session: WorkerSessionLike): Promise<number | undefined> {
1314
+ try {
1315
+ const direct = session.getContextUsage?.();
1316
+ if (direct !== undefined) {
1317
+ return contextPercent(direct);
1318
+ }
1319
+ const stats = session.getSessionStats ? await session.getSessionStats() : undefined;
1320
+ return contextPercent(contextUsageFromStats(stats));
1321
+ } catch {
1322
+ return undefined;
1323
+ }
1324
+ }
1325
+
1077
1326
  function contextPercent(usage: unknown): number | undefined {
1078
1327
  if (!isRecord(usage)) {
1079
1328
  return undefined;
@@ -1128,13 +1377,24 @@ function formatCompactionEndEvent(event: Record<string, unknown>): string {
1128
1377
  return `compaction_end reason=${reason} aborted=${aborted} error=${String(event.errorMessage ?? "unknown")}`;
1129
1378
  }
1130
1379
 
1131
- function latestAssistantText(session: WorkerSessionLike, fallback: string): string {
1132
- const direct = session.getLastAssistantText?.();
1133
- if (direct) {
1134
- return direct;
1380
+ function latestInvocationAssistantText(
1381
+ session: WorkerSessionLike,
1382
+ fallback: string,
1383
+ messageStart: number,
1384
+ allowDirectFallback: boolean,
1385
+ ): string {
1386
+ const invocationMessages = Array.isArray(session.messages) ? session.messages.slice(messageStart) : undefined;
1387
+ const fromInvocation = lastAssistantTextFromMessages(invocationMessages);
1388
+ if (fromInvocation) {
1389
+ return fromInvocation;
1390
+ }
1391
+ if (allowDirectFallback) {
1392
+ const direct = session.getLastAssistantText?.();
1393
+ if (direct) {
1394
+ return direct;
1395
+ }
1135
1396
  }
1136
- const fromMessages = lastAssistantTextFromMessages(session.messages);
1137
- return fromMessages || fallback;
1397
+ return fallback;
1138
1398
  }
1139
1399
 
1140
1400
  function buildLongTaskFailureTaskResult(reason: string): string {