@modelprofile.com/flexharness 15.0.0 → 16.0.1

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.
@@ -13,6 +13,8 @@ import {
13
13
  } from './errors.js';
14
14
  import type {
15
15
  IFlexAgentContextInvocation,
16
+ IFlexContinuePromptOptions,
17
+ IFlexContinuePromptResult,
16
18
  IFlexEnqueuePromptOptions,
17
19
  IFlexSteerAdmission,
18
20
  IFlexSteerInput,
@@ -24,6 +26,7 @@ import type {
24
26
  IFlexScheduledPromptAdmission,
25
27
  IFlexSchedulePromptOptions,
26
28
  TFlexPrompt,
29
+ TFlexAgentPrompt,
27
30
  } from './interfaces.js';
28
31
  import { cloneSerializable } from './utils.json.js';
29
32
  import { normalizeFlexPrompt, type INormalizedFlexPrompt } from './utils.prompt.js';
@@ -71,13 +74,15 @@ export class FlexHarnessPromptQueue<TScope> {
71
74
  constructor(private readonly core: FlexHarnessCore<TScope>) {}
72
75
 
73
76
  public promptAdmissionByteSize(
74
- prompt: TFlexPrompt,
77
+ prompt: TFlexPrompt | undefined,
75
78
  options: IFlexPromptOptions,
76
79
  scheduleKey?: string,
77
80
  debounceMs?: number,
78
81
  ): number {
79
82
  return jsonBytes({
80
- prompt: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols),
83
+ ...(prompt === undefined
84
+ ? {}
85
+ : { prompt: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols) }),
81
86
  options: cloneSerializable(options),
82
87
  scheduleKey,
83
88
  debounceMs,
@@ -100,12 +105,52 @@ export class FlexHarnessPromptQueue<TScope> {
100
105
  return admission.completion;
101
106
  }
102
107
 
108
+ public async continuePrompt(
109
+ scopeId: string,
110
+ sessionId: string,
111
+ options: IFlexContinuePromptOptions = {},
112
+ ): Promise<IFlexPromptQueueAdmission<IFlexContinuePromptResult>> {
113
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
114
+ throw new FlexHarnessValidationError('Continuation options must be a plain object.');
115
+ }
116
+ const { scheduleKey, debounceMs, ...enqueueOptions } = options;
117
+ if ('promptId' in enqueueOptions) {
118
+ throw new FlexHarnessValidationError('A continuation has no new prompt to identify.');
119
+ }
120
+ const admitted = this.splitEnqueueOptions(enqueueOptions);
121
+ if (scheduleKey === undefined && debounceMs !== undefined) {
122
+ throw new FlexHarnessValidationError('debounceMs requires scheduleKey.');
123
+ }
124
+ if (scheduleKey !== undefined) {
125
+ validateIdentifier(scheduleKey, 'scheduleKey');
126
+ requireTransferIdentifier(scheduleKey, 'scheduleKey');
127
+ this.validateScheduleDebounce(debounceMs ?? 50);
128
+ }
129
+ const queued = await this.enqueuePromptInternal(
130
+ scopeId, sessionId, undefined, admitted.options,
131
+ scheduleKey, scheduleKey === undefined ? undefined : debounceMs ?? 50,
132
+ undefined, undefined, false, false, admitted.steers,
133
+ );
134
+ return queued.admission;
135
+ }
136
+
137
+ private validateScheduleDebounce(debounceMs: number): void {
138
+ if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
139
+ throw new FlexHarnessValidationError(
140
+ `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
141
+ );
142
+ }
143
+ }
144
+
103
145
  public async startPrompt(
104
146
  scopeId: string,
105
147
  sessionId: string,
106
148
  prompt: TFlexPrompt,
107
149
  options: IFlexEnqueuePromptOptions = {},
108
150
  ): Promise<IFlexPromptAdmission> {
151
+ if (prompt === undefined) {
152
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
153
+ }
109
154
  const admitted = this.splitEnqueueOptions(options);
110
155
  const queued = await this.enqueuePromptInternal(
111
156
  scopeId, sessionId, prompt, admitted.options,
@@ -120,6 +165,9 @@ export class FlexHarnessPromptQueue<TScope> {
120
165
  prompt: TFlexPrompt,
121
166
  options: IFlexEnqueuePromptOptions = {},
122
167
  ): Promise<IFlexPromptQueueAdmission> {
168
+ if (prompt === undefined) {
169
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
170
+ }
123
171
  const admitted = this.splitEnqueueOptions(options);
124
172
  const queued = await this.enqueuePromptInternal(
125
173
  scopeId, sessionId, prompt, admitted.options,
@@ -166,15 +214,14 @@ export class FlexHarnessPromptQueue<TScope> {
166
214
  prompt: TFlexPrompt,
167
215
  options: IFlexSchedulePromptOptions = {},
168
216
  ): Promise<IFlexScheduledPromptAdmission> {
217
+ if (prompt === undefined) {
218
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
219
+ }
169
220
  validateIdentifier(scheduleKey, 'scheduleKey');
170
221
  requireTransferIdentifier(scheduleKey, 'scheduleKey');
171
222
  validatePromptOptions(options, true);
172
223
  const debounceMs = options.debounceMs ?? 50;
173
- if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
174
- throw new FlexHarnessValidationError(
175
- `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
176
- );
177
- }
224
+ this.validateScheduleDebounce(debounceMs);
178
225
  const queued = await this.enqueuePromptInternal(
179
226
  scopeId,
180
227
  sessionId,
@@ -331,9 +378,7 @@ export class FlexHarnessPromptQueue<TScope> {
331
378
  try {
332
379
  steering.input.add({
333
380
  inputId: steer.steerId,
334
- content: cloneSerializable(prompt.modelMessage.content) as Parameters<
335
- plugins.IAgentSession['beginGeneration']
336
- >[0],
381
+ content: cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt,
337
382
  });
338
383
  } catch (error) {
339
384
  if (error instanceof plugins.AgentPendingInputRejectedError) {
@@ -355,10 +400,10 @@ export class FlexHarnessPromptQueue<TScope> {
355
400
  return { steerId: steer.steerId, queueId: run.queueId, runId: run.runId };
356
401
  }
357
402
 
358
- public async enqueuePromptInternal(
403
+ public async enqueuePromptInternal<TPrompt extends TFlexPrompt | undefined>(
359
404
  scopeId: string,
360
405
  sessionId: string,
361
- prompt: TFlexPrompt,
406
+ prompt: TPrompt,
362
407
  options: IFlexPromptOptions,
363
408
  scheduleKey?: string,
364
409
  debounceMs?: number,
@@ -368,13 +413,15 @@ export class FlexHarnessPromptQueue<TScope> {
368
413
  cacheWarm = false,
369
414
  steers: readonly IAdmittedSteer[] = [],
370
415
  ): Promise<{
371
- admission: IFlexPromptQueueAdmission;
372
- started: Promise<IFlexPromptAdmission>;
416
+ admission: IFlexPromptQueueAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>;
417
+ started: Promise<IFlexPromptAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>>;
373
418
  queued?: IQueuedPrompt;
374
419
  }> {
375
420
  this.core.assertOpen();
376
421
  if (options.promptId !== undefined) requireTransferIdentifier(options.promptId, 'promptId');
377
- const normalizedPrompt = normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols);
422
+ const normalizedPrompt = prompt === undefined
423
+ ? undefined
424
+ : normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols);
378
425
  const normalizedOptions = cloneSerializable(options);
379
426
  const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
380
427
  const steerBytes = steers.reduce((sum, steer) => sum + steer.byteSize, 0);
@@ -467,16 +514,16 @@ export class FlexHarnessPromptQueue<TScope> {
467
514
  ) {
468
515
  throw new FlexHarnessDuplicatePromptError(options.promptId);
469
516
  }
470
- let resolveCompletion!: (result: IFlexPromptResult) => void;
517
+ let resolveCompletion!: (result: IFlexContinuePromptResult) => void;
471
518
  let rejectCompletion!: (error: unknown) => void;
472
- const completion = new Promise<IFlexPromptResult>((resolve, reject) => {
519
+ const completion = new Promise<IFlexContinuePromptResult>((resolve, reject) => {
473
520
  resolveCompletion = resolve;
474
521
  rejectCompletion = reject;
475
522
  });
476
523
  void completion.catch(() => undefined);
477
- let resolveStarted!: (admission: IFlexPromptAdmission) => void;
524
+ let resolveStarted!: (admission: IFlexPromptAdmission<IFlexContinuePromptResult>) => void;
478
525
  let rejectStarted!: (error: unknown) => void;
479
- const started = new Promise<IFlexPromptAdmission>((resolve, reject) => {
526
+ const started = new Promise<IFlexPromptAdmission<IFlexContinuePromptResult>>((resolve, reject) => {
480
527
  resolveStarted = resolve;
481
528
  rejectStarted = reject;
482
529
  });
@@ -494,7 +541,7 @@ export class FlexHarnessPromptQueue<TScope> {
494
541
  ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
495
542
  ...(cacheWarm ? { cacheWarm: true as const } : {}),
496
543
  ...(options.promptId === undefined ? {} : { promptId: options.promptId }),
497
- prompt: normalizedPrompt,
544
+ ...(normalizedPrompt === undefined ? { continuation: true as const } : { prompt: normalizedPrompt }),
498
545
  options: normalizedOptions,
499
546
  ...(delegationSeed === undefined ? {} : { delegationSeed }),
500
547
  byteSize,
@@ -517,8 +564,13 @@ export class FlexHarnessPromptQueue<TScope> {
517
564
  this.emitPromptQueueEvent(queued, 'prompt.queued');
518
565
  this.schedulePromptQueueDrain(state, stored);
519
566
  return {
520
- admission: Object.freeze({ queueId: queued.queueId, completion }),
521
- started,
567
+ // The input determines whether the completed run has a user message.
568
+ admission: Object.freeze({ queueId: queued.queueId, completion }) as IFlexPromptQueueAdmission<
569
+ TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult
570
+ >,
571
+ started: started as Promise<IFlexPromptAdmission<
572
+ TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult
573
+ >>,
522
574
  queued,
523
575
  };
524
576
  } finally {
@@ -625,8 +677,28 @@ export class FlexHarnessPromptQueue<TScope> {
625
677
  }
626
678
  }
627
679
 
680
+ /** Check the actual model context at the FIFO boundary, before any durable run claim. */
681
+ private assertContinuableContext(stored: IStoredSessionState): void {
682
+ const visibleEvents = this.core.reversions.filterReversionContextEvents(stored, stored.agentSession.getEvents());
683
+ const retainedRunIds = new Set(plugins.filterModelVisibleAgentEvents(visibleEvents)
684
+ .map((event) => event.generationId));
685
+ const lastTurnMessage = [...this.core.visibleMessages(stored)].reverse().find((message) =>
686
+ !message.cacheWarm && (message.status === 'completed'
687
+ || message.keptInContext === true || retainedRunIds.has(message.runId)));
688
+ // A summary or runtime update can look like a user message even after a completed answer.
689
+ const answered = !lastTurnMessage || (lastTurnMessage.role === 'assistant'
690
+ && lastTurnMessage.status === 'completed');
691
+ const last = answered ? undefined : stored.agentSession.getModelMessages().at(-1);
692
+ if (last?.role === 'user' || last?.role === 'tool') return;
693
+ if (last?.role === 'assistant' && Array.isArray(last.content)
694
+ && last.content.some((part) => part.type === 'tool-call')) return;
695
+ throw this.core.trustInternalError(new FlexHarnessValidationError(
696
+ 'The conversation has no unanswered turn to continue.',
697
+ ));
698
+ }
699
+
628
700
  private async promoteQueuedPrompt(queued: IQueuedPrompt, run: IActiveRun): Promise<void> {
629
- const prompt = queued.prompt!;
701
+ const prompt = queued.prompt;
630
702
  const options = queued.options!;
631
703
  let projectionReserved = false;
632
704
  // Open from the reservation until its messages are announced or rolled back.
@@ -636,18 +708,18 @@ export class FlexHarnessPromptQueue<TScope> {
636
708
  this.assertPromptPromotion(queued, run);
637
709
  await this.core.admission.acquireDelegatedRunAdmission(queued, run);
638
710
  this.assertPromptPromotion(queued, run);
711
+ if (queued.continuation) this.assertContinuableContext(run.stored);
639
712
  await this.core.reversions.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
640
713
  this.assertPromptPromotion(queued, run);
641
- const generationPrompt = cloneSerializable(prompt.modelMessage.content) as Parameters<
642
- plugins.IAgentSession['beginGeneration']
643
- >[0];
714
+ const generationPrompt = prompt === undefined
715
+ ? undefined
716
+ : cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt;
644
717
  this.assertPromptPromotion(queued, run);
645
718
  run.transaction = await this.core.compaction.withRunCompactorContext(
646
719
  run,
647
- () => run.stored.agentSession.beginGeneration(
648
- generationPrompt,
649
- { generationId: run.runId },
650
- ),
720
+ () => generationPrompt === undefined
721
+ ? run.stored.agentSession.beginContinuation({ generationId: run.runId })
722
+ : run.stored.agentSession.beginGeneration(generationPrompt, { generationId: run.runId }),
651
723
  );
652
724
  this.assertPromptPromotion(queued, run);
653
725
  const reservation = this.core.runs.createReservation(run, prompt, queued.promptId);
@@ -658,7 +730,8 @@ export class FlexHarnessPromptQueue<TScope> {
658
730
  throw new Error('Session metrics already owns a generation.');
659
731
  }
660
732
  run.stored.metrics.pendingGenerationId = run.runId;
661
- run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
733
+ if (reservation.userMessage) run.stored.messages.push(reservation.userMessage);
734
+ run.stored.messages.push(reservation.assistantMessage);
662
735
  // A cache-warm turn is no turn of the user's: it gets no reversion segment of its own.
663
736
  if (run.stored.session.agent === undefined && !run.cacheWarm) {
664
737
  this.core.reversions.pruneReversionState(run.stored, true);
@@ -668,7 +741,7 @@ export class FlexHarnessPromptQueue<TScope> {
668
741
  const protocolVersion = this.core.reversions.reversionProviderProtocolVersion() ?? 1;
669
742
  run.stored.reversionSegments.push({
670
743
  runId: run.runId,
671
- userMessageId: reservation.userMessage.messageId,
744
+ ...(reservation.userMessage ? { userMessageId: reservation.userMessage.messageId } : {}),
672
745
  status: 'capturing',
673
746
  contextAvailable: true,
674
747
  eventIds: [],
@@ -690,7 +763,7 @@ export class FlexHarnessPromptQueue<TScope> {
690
763
  }
691
764
  });
692
765
  projectionReserved = true;
693
- run.reservedUserMessage = publicSnapshot(reservation.userMessage);
766
+ if (reservation.userMessage) run.reservedUserMessage = publicSnapshot(reservation.userMessage);
694
767
  run.reservedAssistantMessage = publicSnapshot(reservation.assistantMessage);
695
768
  this.assertPromptPromotion(queued, run);
696
769
  await this.core.mutateScope(run.state, () => {
@@ -707,9 +780,9 @@ export class FlexHarnessPromptQueue<TScope> {
707
780
  run.stored.session.activity = {
708
781
  runId: run.runId,
709
782
  status: run.scheduleKey ? 'scheduled' : 'running',
710
- startedAt: reservation.userMessage.createdAt,
783
+ startedAt: reservation.assistantMessage.createdAt,
711
784
  };
712
- run.stored.session.updatedAt = reservation.userMessage.createdAt;
785
+ run.stored.session.updatedAt = reservation.assistantMessage.createdAt;
713
786
  const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
714
787
  if (preview !== undefined) run.stored.session.preview = preview;
715
788
  });
@@ -751,11 +824,12 @@ export class FlexHarnessPromptQueue<TScope> {
751
824
  this.core.emitEvent(run.scopeId, run.stored.session, {
752
825
  type: runType,
753
826
  runId: run.runId,
754
- messageId: run.userMessageId,
827
+ messageId: run.userMessageId ?? run.assistantMessageId,
755
828
  session: publicSnapshot(run.stored.session),
756
829
  ...(run.cacheWarm ? { cacheWarm: true } : {}),
757
830
  });
758
- for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
831
+ for (const message of [run.reservedUserMessage, run.reservedAssistantMessage!]) {
832
+ if (!message) continue;
759
833
  this.core.emitEvent(run.scopeId, run.stored.session, {
760
834
  type: 'message.created',
761
835
  runId: run.runId,
@@ -828,6 +902,7 @@ export class FlexHarnessPromptQueue<TScope> {
828
902
  queuedAt: queued.queuedAt,
829
903
  ...(queued.runId ? { runId: queued.runId } : {}),
830
904
  ...(queued.promptId === undefined ? {} : { promptId: queued.promptId }),
905
+ ...(queued.continuation ? { continuation: true as const } : {}),
831
906
  ...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
832
907
  ...(queued.cacheWarm ? { cacheWarm: true } : {}),
833
908
  ...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
@@ -861,7 +936,7 @@ export class FlexHarnessPromptQueue<TScope> {
861
936
  queued: IQueuedPrompt,
862
937
  status: 'completed' | 'failed' | 'cancelled',
863
938
  error?: unknown,
864
- result?: IFlexPromptResult,
939
+ result?: IFlexContinuePromptResult,
865
940
  ): void {
866
941
  const stored = queued.stored;
867
942
  if (stored.outstandingPromptsById.get(queued.queueId) !== queued) return;
@@ -16,7 +16,8 @@ import {
16
16
  type IFlexMessage,
17
17
  type IFlexPermissionRequestInput,
18
18
  type IFlexPromptOptions,
19
- type IFlexPromptResult,
19
+ type IFlexContinuePromptResult,
20
+ type TFlexAgentPrompt,
20
21
  type IFlexResolvedModel,
21
22
  type IFlexRunFinishedEvent,
22
23
  type IFlexSession,
@@ -94,9 +95,7 @@ export class FlexHarnessRuns<TScope> {
94
95
  for (const steer of steers) {
95
96
  steering.input.add({
96
97
  inputId: steer.steerId,
97
- content: cloneSerializable(steer.prompt.modelMessage.content) as Parameters<
98
- plugins.IAgentSession['beginGeneration']
99
- >[0],
98
+ content: cloneSerializable(steer.prompt.modelMessage.content) as TFlexAgentPrompt,
100
99
  });
101
100
  steering.accepted.set(steer.steerId, { prompt: steer.prompt, byteSize: steer.byteSize });
102
101
  }
@@ -123,7 +122,7 @@ export class FlexHarnessRuns<TScope> {
123
122
  sessionId: queued.sessionId,
124
123
  queueId: queued.queueId,
125
124
  runId: queued.runId!,
126
- userMessageId: plugins.crypto.randomUUID(),
125
+ ...(queued.continuation ? { continuation: true as const } : { userMessageId: plugins.crypto.randomUUID() }),
127
126
  assistantMessageId: plugins.crypto.randomUUID(),
128
127
  controller: new AbortController(),
129
128
  ...(queued.scheduleKey === undefined
@@ -162,7 +161,7 @@ export class FlexHarnessRuns<TScope> {
162
161
  public async executeRun(
163
162
  run: IActiveRun,
164
163
  generation: Promise<plugins.IAgentGenerateResult>,
165
- ): Promise<IFlexPromptResult> {
164
+ ): Promise<IFlexContinuePromptResult> {
166
165
  let generated = false;
167
166
  try {
168
167
  // The transactional generation settles only after its model calls are reported, so
@@ -236,7 +235,7 @@ export class FlexHarnessRuns<TScope> {
236
235
  return {
237
236
  runId: run.runId,
238
237
  sessionId: run.sessionId,
239
- userMessage: publicSnapshot(terminal.userMessage),
238
+ ...(terminal.userMessage ? { userMessage: publicSnapshot(terminal.userMessage) } : {}),
240
239
  assistantMessage: publicSnapshot(terminal.assistantMessage),
241
240
  model: publicSnapshot(terminal.model!),
242
241
  usage: publicSnapshot(terminal.usage!),
@@ -729,12 +728,12 @@ export class FlexHarnessRuns<TScope> {
729
728
 
730
729
  public createReservation(
731
730
  run: IActiveRun,
732
- prompt: INormalizedFlexPrompt,
731
+ prompt: INormalizedFlexPrompt | undefined,
733
732
  promptId?: string,
734
- ): { userMessage: IFlexMessage; assistantMessage: IFlexMessage } {
733
+ ): { userMessage?: IFlexMessage; assistantMessage: IFlexMessage } {
735
734
  const timestamp = new Date().toISOString();
736
- const userMessage: IFlexMessage = {
737
- messageId: run.userMessageId,
735
+ const userMessage: IFlexMessage | undefined = prompt === undefined ? undefined : {
736
+ messageId: run.userMessageId!,
738
737
  sessionId: run.sessionId,
739
738
  runId: run.runId,
740
739
  role: 'user',
@@ -754,7 +753,7 @@ export class FlexHarnessRuns<TScope> {
754
753
  parts: [],
755
754
  ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
756
755
  };
757
- return { userMessage, assistantMessage };
756
+ return { ...(userMessage ? { userMessage } : {}), assistantMessage };
758
757
  }
759
758
 
760
759
  private userMessageParts(prompt: INormalizedFlexPrompt): TFlexMessagePart[] {
@@ -915,11 +914,11 @@ export class FlexHarnessRuns<TScope> {
915
914
  error?: Error,
916
915
  ): IFlexTerminalProjection {
917
916
  const timestamp = new Date().toISOString();
918
- const userMessage: IFlexMessage = cloneSerializable(
917
+ const userMessage = run.continuation ? undefined : cloneSerializable(
919
918
  run.reservedUserMessage
920
919
  ?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
921
920
  ?? {
922
- messageId: run.userMessageId,
921
+ messageId: run.userMessageId!,
923
922
  sessionId: run.sessionId,
924
923
  runId: run.runId,
925
924
  role: 'user',
@@ -957,11 +956,13 @@ export class FlexHarnessRuns<TScope> {
957
956
  }
958
957
  }
959
958
  if (run.cacheWarm) {
960
- userMessage.cacheWarm = true;
959
+ if (userMessage) userMessage.cacheWarm = true;
961
960
  assistantMessage.cacheWarm = true;
962
961
  }
963
- userMessage.status = status;
964
- userMessage.completedAt = timestamp;
962
+ if (userMessage) {
963
+ userMessage.status = status;
964
+ userMessage.completedAt = timestamp;
965
+ }
965
966
  assistantMessage.status = status;
966
967
  assistantMessage.completedAt = timestamp;
967
968
  if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
@@ -984,6 +985,7 @@ export class FlexHarnessRuns<TScope> {
984
985
  // A failed turn fails its prompt too. A stopped turn is no fault of its prompt, so the prompt
985
986
  // stays an ordinary user message and only the answer it stopped carries the stop.
986
987
  for (const failedMessage of status === 'cancelled' ? [assistantMessage] : [userMessage, assistantMessage]) {
988
+ if (!failedMessage) continue;
987
989
  failedMessage.error = message;
988
990
  if (limit) failedMessage.limit = cloneSerializable(limit);
989
991
  if (rejectedPermissionId) failedMessage.rejectedPermissionId = rejectedPermissionId;
@@ -992,7 +994,7 @@ export class FlexHarnessRuns<TScope> {
992
994
  return {
993
995
  runId: run.runId,
994
996
  status,
995
- userMessage,
997
+ ...(userMessage ? { userMessage } : {}),
996
998
  assistantMessage,
997
999
  ...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
998
1000
  ...(status === 'completed' && result
@@ -1060,7 +1062,9 @@ export class FlexHarnessRuns<TScope> {
1060
1062
  * the steers it took in in the model context. `replaceVisibleRun()` writes it onto the steers.
1061
1063
  */
1062
1064
  public withKeptInContext(terminal: IFlexTerminalProjection, keptInContext: boolean): IFlexTerminalProjection {
1063
- return { ...terminal, userMessage: { ...terminal.userMessage, keptInContext } };
1065
+ return terminal.userMessage
1066
+ ? { ...terminal, userMessage: { ...terminal.userMessage, keptInContext } }
1067
+ : terminal;
1064
1068
  }
1065
1069
 
1066
1070
  private replaceStagedTerminal(stored: IStoredSessionState, terminal: IFlexTerminalProjection): void {
@@ -1076,11 +1080,13 @@ export class FlexHarnessRuns<TScope> {
1076
1080
  */
1077
1081
  public replaceVisibleRun(stored: IStoredSessionState, terminal: IFlexTerminalProjection): void {
1078
1082
  const replacements = new Map([
1079
- [terminal.userMessage.messageId, terminal.userMessage],
1083
+ ...(terminal.userMessage ? [[terminal.userMessage.messageId, terminal.userMessage] as const] : []),
1080
1084
  [terminal.assistantMessage.messageId, terminal.assistantMessage],
1081
1085
  ]);
1082
1086
  const placed = new Set<string>();
1083
- const keptInContext = terminal.userMessage.keptInContext;
1087
+ const keptInContext = terminal.userMessage?.keptInContext
1088
+ ?? (terminal.status === 'completed'
1089
+ || plugins.isGenerationInModelContext(stored.agentSession.getEvents(), terminal.runId));
1084
1090
  stored.messages = stored.messages.map((message) => {
1085
1091
  const replacement = message.runId === terminal.runId
1086
1092
  ? replacements.get(message.messageId)
@@ -1096,7 +1102,7 @@ export class FlexHarnessRuns<TScope> {
1096
1102
  return cloneSerializable(replacement);
1097
1103
  });
1098
1104
  const firstIndex = stored.messages.findIndex((message) => message.runId === terminal.runId);
1099
- if (!placed.has(terminal.userMessage.messageId)) {
1105
+ if (terminal.userMessage && !placed.has(terminal.userMessage.messageId)) {
1100
1106
  stored.messages.splice(
1101
1107
  firstIndex < 0 ? stored.messages.length : firstIndex,
1102
1108
  0,
@@ -1130,10 +1136,14 @@ export class FlexHarnessRuns<TScope> {
1130
1136
  this.core.emitPartEvent(run, 'part.completed', part);
1131
1137
  }
1132
1138
  }
1133
- const steerMessages = terminal.userMessage.keptInContext === undefined
1134
- ? []
1135
- : run.stored.messages.filter((message) => message.runId === run.runId && message.steerId !== undefined);
1139
+ const retentionKnown = terminal.userMessage
1140
+ ? terminal.userMessage.keptInContext !== undefined
1141
+ : run.steering.keptInContext !== undefined;
1142
+ const steerMessages = retentionKnown
1143
+ ? run.stored.messages.filter((message) => message.runId === run.runId && message.steerId !== undefined)
1144
+ : [];
1136
1145
  for (const message of [terminal.userMessage, ...steerMessages, terminal.assistantMessage]) {
1146
+ if (!message) continue;
1137
1147
  this.core.emitEvent(run.scopeId, run.stored.session, {
1138
1148
  type: 'message.updated',
1139
1149
  runId: run.runId,
@@ -13,6 +13,7 @@ import type {
13
13
  IFlexSteerAdmission,
14
14
  IFlexSteerInput,
15
15
  IFlexPromptAdmission,
16
+ IFlexContinuePromptResult,
16
17
  IFlexPromptOptions,
17
18
  IFlexSession,
18
19
  IFlexSubagentDefinition,
@@ -378,7 +379,7 @@ export class FlexHarnessSubagents<TScope> {
378
379
  let child: IStoredSessionState | undefined;
379
380
  let childCreated = false;
380
381
  let queued: Awaited<ReturnType<typeof this.core.prompts.enqueuePromptInternal>> | undefined;
381
- let admission: IFlexPromptAdmission | undefined;
382
+ let admission: IFlexPromptAdmission<IFlexContinuePromptResult> | undefined;
382
383
  const abortChild = () => {
383
384
  const reason = delegatedCancellation(run.controller.signal.reason)
384
385
  ?? this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
@@ -4,6 +4,8 @@ import type {
4
4
  IFlexCacheWarmInput,
5
5
  IFlexCacheWarmState,
6
6
  IFlexCompactSessionOptions,
7
+ IFlexContinuePromptOptions,
8
+ IFlexContinuePromptResult,
7
9
  IFlexCreateProjectTaskInput,
8
10
  IFlexCreateSessionOptions,
9
11
  IFlexDeleteSessionGenerationCohortInput,
@@ -320,6 +322,14 @@ export class FlexHarness<TScope = unknown> {
320
322
  return this.core.prompts.prompt(scopeId, sessionId, prompt, options);
321
323
  }
322
324
 
325
+ public continuePrompt(
326
+ scopeId: string,
327
+ sessionId: string,
328
+ options: IFlexContinuePromptOptions = {},
329
+ ): Promise<IFlexPromptQueueAdmission<IFlexContinuePromptResult>> {
330
+ return this.core.prompts.continuePrompt(scopeId, sessionId, options);
331
+ }
332
+
323
333
  public startPrompt(
324
334
  scopeId: string,
325
335
  sessionId: string,
@@ -15,7 +15,7 @@ import type {
15
15
  IFlexPromptAdmission,
16
16
  IFlexPromptQueueEntry,
17
17
  IFlexPromptOptions,
18
- IFlexPromptResult,
18
+ IFlexContinuePromptResult,
19
19
  IFlexReversionSegment,
20
20
  IFlexPendingReversionRelease,
21
21
  IFlexResolvedModel,
@@ -88,7 +88,7 @@ export interface IStoredSessionState {
88
88
  permissionRevision: number;
89
89
  permissionQueue: Promise<void>;
90
90
  eventStore: plugins.IAgentEventStore;
91
- agentSession: plugins.IAgentSession;
91
+ agentSession: plugins.IAgentContinuationSession;
92
92
  agentSessionAbortCompleted: boolean;
93
93
  agentSessionCloseCompleted: boolean;
94
94
  agentEventStoreReleased: boolean;
@@ -226,7 +226,8 @@ export interface IActiveRun {
226
226
  sessionId: string;
227
227
  queueId: string;
228
228
  runId: string;
229
- userMessageId: string;
229
+ userMessageId?: string;
230
+ continuation?: true;
230
231
  assistantMessageId: string;
231
232
  controller: AbortController;
232
233
  transaction?: plugins.IAgentGenerationHandle;
@@ -292,7 +293,7 @@ export interface IActiveRun {
292
293
  permissionPolicy?: IFlexResolvedSessionConfiguration<unknown>['permissionPolicy'];
293
294
  delegatedRunAdmissionOwner?: IDelegatedRunAdmissionOwner;
294
295
  delegatedRunAdmissionClose?: Promise<void>;
295
- completion: Promise<IFlexPromptResult>;
296
+ completion: Promise<IFlexContinuePromptResult>;
296
297
  steering: IRunSteering;
297
298
  /** Stops the run's generation for its owner, so what the model produced stays in context. */
298
299
  stop: AbortController;
@@ -415,6 +416,7 @@ export interface IQueuedPrompt {
415
416
  /** The prompt's `promptId`; kept after its payload is released. */
416
417
  promptId?: string;
417
418
  startedAt?: string;
419
+ continuation?: true;
418
420
  prompt?: INormalizedFlexPrompt;
419
421
  options?: IFlexPromptOptions;
420
422
  delegationSeed?: IDelegatedRunAdmissionSeed;
@@ -426,11 +428,11 @@ export interface IQueuedPrompt {
426
428
  steering?: IRunSteering;
427
429
  /** The steers admitted with the prompt, in their given order. */
428
430
  admittedSteerIds?: readonly string[];
429
- completion: Promise<IFlexPromptResult>;
430
- resolveCompletion: (result: IFlexPromptResult) => void;
431
+ completion: Promise<IFlexContinuePromptResult>;
432
+ resolveCompletion: (result: IFlexContinuePromptResult) => void;
431
433
  rejectCompletion: (error: unknown) => void;
432
- started: Promise<IFlexPromptAdmission>;
433
- resolveStarted: (admission: IFlexPromptAdmission) => void;
434
+ started: Promise<IFlexPromptAdmission<IFlexContinuePromptResult>>;
435
+ resolveStarted: (admission: IFlexPromptAdmission<IFlexContinuePromptResult>) => void;
434
436
  rejectStarted: (error: unknown) => void;
435
437
  }
436
438