@modelprofile.com/flexharness 14.0.0 → 16.0.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.
Files changed (51) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.cachewarm.d.ts +2 -2
  3. package/dist_ts/classes.flexharness.cachewarm.js +1 -1
  4. package/dist_ts/classes.flexharness.core.d.ts +8 -1
  5. package/dist_ts/classes.flexharness.core.js +44 -31
  6. package/dist_ts/classes.flexharness.d.ts +5 -4
  7. package/dist_ts/classes.flexharness.js +10 -7
  8. package/dist_ts/classes.flexharness.prompts.d.ts +9 -5
  9. package/dist_ts/classes.flexharness.prompts.js +93 -18
  10. package/dist_ts/classes.flexharness.runs.d.ts +9 -4
  11. package/dist_ts/classes.flexharness.runs.js +70 -18
  12. package/dist_ts/classes.flexharness.sessions.d.ts +4 -5
  13. package/dist_ts/classes.flexharness.sessions.js +45 -12
  14. package/dist_ts/classes.flexharness.subagents.js +1 -1
  15. package/dist_ts/errors.d.ts +9 -0
  16. package/dist_ts/errors.js +14 -1
  17. package/dist_ts/index.d.ts +2 -0
  18. package/dist_ts/index.js +2 -1
  19. package/dist_ts/interfaces.d.ts +119 -11
  20. package/dist_ts/interfaces.flexharness.d.ts +12 -8
  21. package/dist_ts/interfaces.js +1 -1
  22. package/dist_ts/plugins.d.ts +3 -3
  23. package/dist_ts/plugins.js +3 -3
  24. package/dist_ts/utils.harnessoptions.js +6 -3
  25. package/dist_ts/utils.json.js +74 -18
  26. package/dist_ts/utils.sessionmetrics.d.ts +12 -1
  27. package/dist_ts/utils.sessionmetrics.js +18 -1
  28. package/dist_ts/utils.transfer.js +14 -1
  29. package/dist_ts/utils.validation.d.ts +7 -2
  30. package/dist_ts/utils.validation.js +28 -7
  31. package/package.json +3 -3
  32. package/readme.md +75 -6
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.flexharness.cachewarm.ts +2 -2
  35. package/ts/classes.flexharness.core.ts +49 -30
  36. package/ts/classes.flexharness.prompts.ts +124 -39
  37. package/ts/classes.flexharness.runs.ts +78 -24
  38. package/ts/classes.flexharness.sessions.ts +54 -9
  39. package/ts/classes.flexharness.subagents.ts +2 -1
  40. package/ts/classes.flexharness.ts +25 -5
  41. package/ts/errors.ts +15 -0
  42. package/ts/index.ts +6 -0
  43. package/ts/interfaces.flexharness.ts +12 -8
  44. package/ts/interfaces.ts +125 -11
  45. package/ts/plugins.ts +13 -0
  46. package/ts/readme.md +75 -6
  47. package/ts/utils.harnessoptions.ts +9 -2
  48. package/ts/utils.json.ts +75 -18
  49. package/ts/utils.sessionmetrics.ts +23 -0
  50. package/ts/utils.transfer.ts +13 -0
  51. package/ts/utils.validation.ts +31 -5
@@ -542,7 +542,7 @@ export class FlexHarnessCore<TScope> {
542
542
  storageKey: string,
543
543
  compactorLifecycleController = new AbortController(),
544
544
  ): IStoredSessionState {
545
- const unavailable = new Proxy({} as plugins.IAgentSession, {
545
+ const unavailable = new Proxy({} as plugins.IAgentContinuationSession, {
546
546
  get() {
547
547
  throw new Error('AgentSession is not initialized.');
548
548
  },
@@ -699,7 +699,7 @@ export class FlexHarnessCore<TScope> {
699
699
  permissionRevision: permission?.revision ?? 0,
700
700
  permissionQueue: Promise.resolve(),
701
701
  eventStore,
702
- agentSession: undefined as unknown as plugins.IAgentSession,
702
+ agentSession: undefined as unknown as plugins.IAgentContinuationSession,
703
703
  agentSessionAbortCompleted: false,
704
704
  agentSessionCloseCompleted: false,
705
705
  agentEventStoreReleased: this.stores.agentEvents.releaseSession === undefined,
@@ -728,8 +728,10 @@ export class FlexHarnessCore<TScope> {
728
728
  delete stored.metrics.pendingCompactionId;
729
729
  });
730
730
  }
731
+ // The compaction setting reaches each generation on its lease, with its model's window.
731
732
  const {
732
733
  contextCompactor,
734
+ contextCompaction: _contextCompaction,
733
735
  ...agentSessionPolicy
734
736
  } = this.agentSessionPolicy;
735
737
  const executionContext = executionContextHandle?.context;
@@ -959,7 +961,8 @@ export class FlexHarnessCore<TScope> {
959
961
  await this.reversions.drainReversionReleases(state, stored, scopeId, scope, false);
960
962
  this.assertStateAcceptingWork(state);
961
963
  }
962
- const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
964
+ const agentEvents = stored.agentSession.getEvents();
965
+ const outcomes = this.canonicalOutcomes(agentEvents);
963
966
  const firstHiddenCandidate = this.reversions.completedReversionCandidates(stored)[stored.revertCursor];
964
967
  const reversionVisibilityBoundary = firstHiddenCandidate
965
968
  ? stored.reversionSegments.findIndex((segment) => segment.runId === firstHiddenCandidate.runId)
@@ -975,8 +978,8 @@ export class FlexHarnessCore<TScope> {
975
978
  const outcome = outcomes.get(runId);
976
979
  const stage = stages.get(runId);
977
980
  if (outcome === 'accepted' && stage?.status === 'completed') {
978
- this.runs.replaceVisibleRun(stored, stage);
979
- if (!stage.userMessage.cacheWarm) latestTerminal = stage;
981
+ this.runs.replaceVisibleRun(stored, this.runs.withKeptInContext(stage, true));
982
+ if (!stage.assistantMessage.cacheWarm) latestTerminal = stage;
980
983
  } else if (outcome !== 'accepted') {
981
984
  const visible = stored.messages.filter((message) => message.runId === runId);
982
985
  // A run that failed or was stopped keeps its terminal and its error: the staged one, or the
@@ -985,13 +988,21 @@ export class FlexHarnessCore<TScope> {
985
988
  ? stage
986
989
  : stage ? undefined : this.publishedTerminal(runId, visible);
987
990
  if (ended) {
988
- if (ended === stage) this.runs.replaceVisibleRun(stored, stage);
989
- if (!ended.userMessage.cacheWarm) latestTerminal = ended;
991
+ if (ended === stage) {
992
+ this.runs.replaceVisibleRun(stored, this.runs.withKeptInContext(
993
+ stage,
994
+ plugins.isGenerationInModelContext(agentEvents, runId),
995
+ ));
996
+ }
997
+ if (!ended.assistantMessage.cacheWarm) latestTerminal = ended;
990
998
  } else if (visible.length > 0 || stage) {
991
999
  const status = outcome === 'rejected' ? 'failed' : 'cancelled';
992
- const terminal = this.repairTerminal(stored.session.sessionId, runId, visible, stage, status);
1000
+ const terminal = this.runs.withKeptInContext(
1001
+ this.repairTerminal(stored.session.sessionId, runId, visible, stage, status),
1002
+ plugins.isGenerationInModelContext(agentEvents, runId),
1003
+ );
993
1004
  this.runs.replaceVisibleRun(stored, terminal);
994
- if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
1005
+ if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
995
1006
  }
996
1007
  } else {
997
1008
  const visibleSuccess = stored.messages.some((message) =>
@@ -1004,7 +1015,7 @@ export class FlexHarnessCore<TScope> {
1004
1015
  undefined,
1005
1016
  'completed',
1006
1017
  );
1007
- if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
1018
+ if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
1008
1019
  }
1009
1020
  }
1010
1021
  }
@@ -1109,13 +1120,13 @@ export class FlexHarnessCore<TScope> {
1109
1120
  if (visible.some((message) => message.status === 'streaming')) return undefined;
1110
1121
  const userMessage = visible.find((message) => message.role === 'user' && message.steerId === undefined);
1111
1122
  const assistantMessage = [...visible].reverse().find((message) => message.role === 'assistant');
1112
- if (!userMessage || !assistantMessage) return undefined;
1123
+ if (!assistantMessage) return undefined;
1113
1124
  const status = assistantMessage.status;
1114
1125
  if (status !== 'failed' && status !== 'cancelled') return undefined;
1115
1126
  return {
1116
1127
  runId,
1117
1128
  status,
1118
- userMessage,
1129
+ ...(userMessage ? { userMessage } : {}),
1119
1130
  assistantMessage,
1120
1131
  ...(assistantMessage.model ? { model: assistantMessage.model } : {}),
1121
1132
  ...(assistantMessage.usage ? { usage: assistantMessage.usage } : {}),
@@ -1136,15 +1147,7 @@ export class FlexHarnessCore<TScope> {
1136
1147
  ?? visible.find((message) => message.role === 'user' && message.steerId === undefined);
1137
1148
  const sourceAssistant = stage?.assistantMessage
1138
1149
  ?? [...visible].reverse().find((message) => message.role === 'assistant');
1139
- const userMessage: IFlexMessage = cloneSerializable(sourceUser ?? {
1140
- messageId: plugins.crypto.randomUUID(),
1141
- sessionId,
1142
- runId,
1143
- role: 'user',
1144
- status,
1145
- createdAt: timestamp,
1146
- parts: [],
1147
- });
1150
+ const userMessage = sourceUser ? cloneSerializable(sourceUser) : undefined;
1148
1151
  const assistantMessage: IFlexMessage = cloneSerializable(sourceAssistant ?? {
1149
1152
  messageId: plugins.crypto.randomUUID(),
1150
1153
  sessionId,
@@ -1154,9 +1157,9 @@ export class FlexHarnessCore<TScope> {
1154
1157
  createdAt: timestamp,
1155
1158
  parts: [],
1156
1159
  });
1157
- userMessage.status = status;
1160
+ if (userMessage) userMessage.status = status;
1158
1161
  assistantMessage.status = status;
1159
- userMessage.completedAt ??= timestamp;
1162
+ if (userMessage) userMessage.completedAt ??= timestamp;
1160
1163
  assistantMessage.completedAt ??= timestamp;
1161
1164
  for (const part of assistantMessage.parts) {
1162
1165
  if (part.type === 'reasoning' && part.status === 'running') {
@@ -1172,7 +1175,7 @@ export class FlexHarnessCore<TScope> {
1172
1175
  if (status !== 'completed') {
1173
1176
  const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
1174
1177
  // As for a run that ends live: a failed run marks its prompt too, a cancelled one only its answer.
1175
- if (status === 'failed') userMessage.error = message;
1178
+ if (status === 'failed' && userMessage) userMessage.error = message;
1176
1179
  assistantMessage.error = message;
1177
1180
  if (stage?.usage) {
1178
1181
  assistantMessage.usage = cloneSerializable(stage.usage);
@@ -1183,7 +1186,7 @@ export class FlexHarnessCore<TScope> {
1183
1186
  return {
1184
1187
  runId,
1185
1188
  status,
1186
- userMessage,
1189
+ ...(userMessage ? { userMessage } : {}),
1187
1190
  assistantMessage,
1188
1191
  ...(status === 'completed' && stage?.model ? { model: stage.model } : {}),
1189
1192
  ...(stage?.usage ? { usage: stage.usage } : {}),
@@ -1747,12 +1750,28 @@ export class FlexHarnessCore<TScope> {
1747
1750
  }
1748
1751
 
1749
1752
  public visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
1750
- const firstHiddenSegment = this.reversions.reversionGroups(stored)[stored.revertCursor]?.segments[0];
1751
- if (!firstHiddenSegment) return stored.messages;
1752
- const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
1753
+ const boundary = this.undoneBoundary(stored);
1753
1754
  return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);
1754
1755
  }
1755
1756
 
1757
+ /**
1758
+ * The transcript: the visible messages, and with `includeUndone` the messages of undone turns
1759
+ * after them, each marked `undone: true`.
1760
+ */
1761
+ public transcriptMessages(stored: IStoredSessionState, includeUndone: boolean): IFlexMessage[] {
1762
+ if (!includeUndone) return this.visibleMessages(stored);
1763
+ const boundary = this.undoneBoundary(stored);
1764
+ if (boundary < 0) return stored.messages;
1765
+ return stored.messages.map((message, index) => index < boundary ? message : { ...message, undone: true });
1766
+ }
1767
+
1768
+ /** The index of the first message of an undone turn, or -1 when no turn is undone. */
1769
+ private undoneBoundary(stored: IStoredSessionState): number {
1770
+ const firstHiddenSegment = this.reversions.reversionGroups(stored)[stored.revertCursor]?.segments[0];
1771
+ if (!firstHiddenSegment) return -1;
1772
+ return stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
1773
+ }
1774
+
1756
1775
  public requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
1757
1776
  const stored = this.requireSession(state, sessionId);
1758
1777
  const pending = [...state.pendingPermissions.values()].some((entry) =>
@@ -1789,8 +1808,8 @@ export class FlexHarnessCore<TScope> {
1789
1808
  }
1790
1809
  }
1791
1810
 
1792
- public requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
1793
- const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
1811
+ public requireMessage(stored: IStoredSessionState, messageId: string, includeUndone = false): IFlexMessage {
1812
+ const message = this.transcriptMessages(stored, includeUndone).find((entry) => entry.messageId === messageId);
1794
1813
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
1795
1814
  return message;
1796
1815
  }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import {
3
3
  FlexHarnessAbortError,
4
+ FlexHarnessDuplicatePromptError,
4
5
  FlexHarnessNotFoundError,
5
6
  FlexHarnessQueueFullError,
6
7
  FlexHarnessSessionBusyError,
@@ -12,6 +13,8 @@ import {
12
13
  } from './errors.js';
13
14
  import type {
14
15
  IFlexAgentContextInvocation,
16
+ IFlexContinuePromptOptions,
17
+ IFlexContinuePromptResult,
15
18
  IFlexEnqueuePromptOptions,
16
19
  IFlexSteerAdmission,
17
20
  IFlexSteerInput,
@@ -23,6 +26,7 @@ import type {
23
26
  IFlexScheduledPromptAdmission,
24
27
  IFlexSchedulePromptOptions,
25
28
  TFlexPrompt,
29
+ TFlexAgentPrompt,
26
30
  } from './interfaces.js';
27
31
  import { cloneSerializable } from './utils.json.js';
28
32
  import { normalizeFlexPrompt, type INormalizedFlexPrompt } from './utils.prompt.js';
@@ -70,13 +74,15 @@ export class FlexHarnessPromptQueue<TScope> {
70
74
  constructor(private readonly core: FlexHarnessCore<TScope>) {}
71
75
 
72
76
  public promptAdmissionByteSize(
73
- prompt: TFlexPrompt,
77
+ prompt: TFlexPrompt | undefined,
74
78
  options: IFlexPromptOptions,
75
79
  scheduleKey?: string,
76
80
  debounceMs?: number,
77
81
  ): number {
78
82
  return jsonBytes({
79
- prompt: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols),
83
+ ...(prompt === undefined
84
+ ? {}
85
+ : { prompt: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols) }),
80
86
  options: cloneSerializable(options),
81
87
  scheduleKey,
82
88
  debounceMs,
@@ -99,12 +105,52 @@ export class FlexHarnessPromptQueue<TScope> {
99
105
  return admission.completion;
100
106
  }
101
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
+
102
145
  public async startPrompt(
103
146
  scopeId: string,
104
147
  sessionId: string,
105
148
  prompt: TFlexPrompt,
106
149
  options: IFlexEnqueuePromptOptions = {},
107
150
  ): Promise<IFlexPromptAdmission> {
151
+ if (prompt === undefined) {
152
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
153
+ }
108
154
  const admitted = this.splitEnqueueOptions(options);
109
155
  const queued = await this.enqueuePromptInternal(
110
156
  scopeId, sessionId, prompt, admitted.options,
@@ -119,6 +165,9 @@ export class FlexHarnessPromptQueue<TScope> {
119
165
  prompt: TFlexPrompt,
120
166
  options: IFlexEnqueuePromptOptions = {},
121
167
  ): Promise<IFlexPromptQueueAdmission> {
168
+ if (prompt === undefined) {
169
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
170
+ }
122
171
  const admitted = this.splitEnqueueOptions(options);
123
172
  const queued = await this.enqueuePromptInternal(
124
173
  scopeId, sessionId, prompt, admitted.options,
@@ -165,15 +214,14 @@ export class FlexHarnessPromptQueue<TScope> {
165
214
  prompt: TFlexPrompt,
166
215
  options: IFlexSchedulePromptOptions = {},
167
216
  ): Promise<IFlexScheduledPromptAdmission> {
217
+ if (prompt === undefined) {
218
+ throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
219
+ }
168
220
  validateIdentifier(scheduleKey, 'scheduleKey');
169
221
  requireTransferIdentifier(scheduleKey, 'scheduleKey');
170
222
  validatePromptOptions(options, true);
171
223
  const debounceMs = options.debounceMs ?? 50;
172
- if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
173
- throw new FlexHarnessValidationError(
174
- `debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
175
- );
176
- }
224
+ this.validateScheduleDebounce(debounceMs);
177
225
  const queued = await this.enqueuePromptInternal(
178
226
  scopeId,
179
227
  sessionId,
@@ -330,9 +378,7 @@ export class FlexHarnessPromptQueue<TScope> {
330
378
  try {
331
379
  steering.input.add({
332
380
  inputId: steer.steerId,
333
- content: cloneSerializable(prompt.modelMessage.content) as Parameters<
334
- plugins.IAgentSession['beginGeneration']
335
- >[0],
381
+ content: cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt,
336
382
  });
337
383
  } catch (error) {
338
384
  if (error instanceof plugins.AgentPendingInputRejectedError) {
@@ -354,10 +400,10 @@ export class FlexHarnessPromptQueue<TScope> {
354
400
  return { steerId: steer.steerId, queueId: run.queueId, runId: run.runId };
355
401
  }
356
402
 
357
- public async enqueuePromptInternal(
403
+ public async enqueuePromptInternal<TPrompt extends TFlexPrompt | undefined>(
358
404
  scopeId: string,
359
405
  sessionId: string,
360
- prompt: TFlexPrompt,
406
+ prompt: TPrompt,
361
407
  options: IFlexPromptOptions,
362
408
  scheduleKey?: string,
363
409
  debounceMs?: number,
@@ -367,12 +413,15 @@ export class FlexHarnessPromptQueue<TScope> {
367
413
  cacheWarm = false,
368
414
  steers: readonly IAdmittedSteer[] = [],
369
415
  ): Promise<{
370
- admission: IFlexPromptQueueAdmission;
371
- started: Promise<IFlexPromptAdmission>;
416
+ admission: IFlexPromptQueueAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>;
417
+ started: Promise<IFlexPromptAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>>;
372
418
  queued?: IQueuedPrompt;
373
419
  }> {
374
420
  this.core.assertOpen();
375
- const normalizedPrompt = normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols);
421
+ if (options.promptId !== undefined) requireTransferIdentifier(options.promptId, 'promptId');
422
+ const normalizedPrompt = prompt === undefined
423
+ ? undefined
424
+ : normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols);
376
425
  const normalizedOptions = cloneSerializable(options);
377
426
  const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
378
427
  const steerBytes = steers.reduce((sum, steer) => sum + steer.byteSize, 0);
@@ -459,16 +508,22 @@ export class FlexHarnessPromptQueue<TScope> {
459
508
  ) {
460
509
  throw new FlexHarnessSessionBusyError(sessionId, `already has scheduled key "${scheduleKey}"`);
461
510
  }
462
- let resolveCompletion!: (result: IFlexPromptResult) => void;
511
+ if (
512
+ options.promptId !== undefined
513
+ && [...stored.outstandingPromptsById.values()].some((entry) => entry.promptId === options.promptId)
514
+ ) {
515
+ throw new FlexHarnessDuplicatePromptError(options.promptId);
516
+ }
517
+ let resolveCompletion!: (result: IFlexContinuePromptResult) => void;
463
518
  let rejectCompletion!: (error: unknown) => void;
464
- const completion = new Promise<IFlexPromptResult>((resolve, reject) => {
519
+ const completion = new Promise<IFlexContinuePromptResult>((resolve, reject) => {
465
520
  resolveCompletion = resolve;
466
521
  rejectCompletion = reject;
467
522
  });
468
523
  void completion.catch(() => undefined);
469
- let resolveStarted!: (admission: IFlexPromptAdmission) => void;
524
+ let resolveStarted!: (admission: IFlexPromptAdmission<IFlexContinuePromptResult>) => void;
470
525
  let rejectStarted!: (error: unknown) => void;
471
- const started = new Promise<IFlexPromptAdmission>((resolve, reject) => {
526
+ const started = new Promise<IFlexPromptAdmission<IFlexContinuePromptResult>>((resolve, reject) => {
472
527
  resolveStarted = resolve;
473
528
  rejectStarted = reject;
474
529
  });
@@ -485,7 +540,8 @@ export class FlexHarnessPromptQueue<TScope> {
485
540
  queuedAt: new Date().toISOString(),
486
541
  ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
487
542
  ...(cacheWarm ? { cacheWarm: true as const } : {}),
488
- prompt: normalizedPrompt,
543
+ ...(options.promptId === undefined ? {} : { promptId: options.promptId }),
544
+ ...(normalizedPrompt === undefined ? { continuation: true as const } : { prompt: normalizedPrompt }),
489
545
  options: normalizedOptions,
490
546
  ...(delegationSeed === undefined ? {} : { delegationSeed }),
491
547
  byteSize,
@@ -508,8 +564,13 @@ export class FlexHarnessPromptQueue<TScope> {
508
564
  this.emitPromptQueueEvent(queued, 'prompt.queued');
509
565
  this.schedulePromptQueueDrain(state, stored);
510
566
  return {
511
- admission: Object.freeze({ queueId: queued.queueId, completion }),
512
- 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
+ >>,
513
574
  queued,
514
575
  };
515
576
  } finally {
@@ -616,8 +677,28 @@ export class FlexHarnessPromptQueue<TScope> {
616
677
  }
617
678
  }
618
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
+
619
700
  private async promoteQueuedPrompt(queued: IQueuedPrompt, run: IActiveRun): Promise<void> {
620
- const prompt = queued.prompt!;
701
+ const prompt = queued.prompt;
621
702
  const options = queued.options!;
622
703
  let projectionReserved = false;
623
704
  // Open from the reservation until its messages are announced or rolled back.
@@ -627,21 +708,21 @@ export class FlexHarnessPromptQueue<TScope> {
627
708
  this.assertPromptPromotion(queued, run);
628
709
  await this.core.admission.acquireDelegatedRunAdmission(queued, run);
629
710
  this.assertPromptPromotion(queued, run);
711
+ if (queued.continuation) this.assertContinuableContext(run.stored);
630
712
  await this.core.reversions.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
631
713
  this.assertPromptPromotion(queued, run);
632
- const generationPrompt = cloneSerializable(prompt.modelMessage.content) as Parameters<
633
- plugins.IAgentSession['beginGeneration']
634
- >[0];
714
+ const generationPrompt = prompt === undefined
715
+ ? undefined
716
+ : cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt;
635
717
  this.assertPromptPromotion(queued, run);
636
718
  run.transaction = await this.core.compaction.withRunCompactorContext(
637
719
  run,
638
- () => run.stored.agentSession.beginGeneration(
639
- generationPrompt,
640
- { generationId: run.runId },
641
- ),
720
+ () => generationPrompt === undefined
721
+ ? run.stored.agentSession.beginContinuation({ generationId: run.runId })
722
+ : run.stored.agentSession.beginGeneration(generationPrompt, { generationId: run.runId }),
642
723
  );
643
724
  this.assertPromptPromotion(queued, run);
644
- const reservation = this.core.runs.createReservation(run, prompt);
725
+ const reservation = this.core.runs.createReservation(run, prompt, queued.promptId);
645
726
  closeReservationPublication = this.core.openPublication(run.state, run.sessionId);
646
727
  run.metricsOperation = createSessionMetricsOperation(run.runId, 'generation');
647
728
  await this.core.mutateProjection(run.state, run.stored, () => {
@@ -649,7 +730,8 @@ export class FlexHarnessPromptQueue<TScope> {
649
730
  throw new Error('Session metrics already owns a generation.');
650
731
  }
651
732
  run.stored.metrics.pendingGenerationId = run.runId;
652
- 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);
653
735
  // A cache-warm turn is no turn of the user's: it gets no reversion segment of its own.
654
736
  if (run.stored.session.agent === undefined && !run.cacheWarm) {
655
737
  this.core.reversions.pruneReversionState(run.stored, true);
@@ -659,7 +741,7 @@ export class FlexHarnessPromptQueue<TScope> {
659
741
  const protocolVersion = this.core.reversions.reversionProviderProtocolVersion() ?? 1;
660
742
  run.stored.reversionSegments.push({
661
743
  runId: run.runId,
662
- userMessageId: reservation.userMessage.messageId,
744
+ ...(reservation.userMessage ? { userMessageId: reservation.userMessage.messageId } : {}),
663
745
  status: 'capturing',
664
746
  contextAvailable: true,
665
747
  eventIds: [],
@@ -681,7 +763,7 @@ export class FlexHarnessPromptQueue<TScope> {
681
763
  }
682
764
  });
683
765
  projectionReserved = true;
684
- run.reservedUserMessage = publicSnapshot(reservation.userMessage);
766
+ if (reservation.userMessage) run.reservedUserMessage = publicSnapshot(reservation.userMessage);
685
767
  run.reservedAssistantMessage = publicSnapshot(reservation.assistantMessage);
686
768
  this.assertPromptPromotion(queued, run);
687
769
  await this.core.mutateScope(run.state, () => {
@@ -698,9 +780,9 @@ export class FlexHarnessPromptQueue<TScope> {
698
780
  run.stored.session.activity = {
699
781
  runId: run.runId,
700
782
  status: run.scheduleKey ? 'scheduled' : 'running',
701
- startedAt: reservation.userMessage.createdAt,
783
+ startedAt: reservation.assistantMessage.createdAt,
702
784
  };
703
- run.stored.session.updatedAt = reservation.userMessage.createdAt;
785
+ run.stored.session.updatedAt = reservation.assistantMessage.createdAt;
704
786
  const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
705
787
  if (preview !== undefined) run.stored.session.preview = preview;
706
788
  });
@@ -742,11 +824,12 @@ export class FlexHarnessPromptQueue<TScope> {
742
824
  this.core.emitEvent(run.scopeId, run.stored.session, {
743
825
  type: runType,
744
826
  runId: run.runId,
745
- messageId: run.userMessageId,
827
+ messageId: run.userMessageId ?? run.assistantMessageId,
746
828
  session: publicSnapshot(run.stored.session),
747
829
  ...(run.cacheWarm ? { cacheWarm: true } : {}),
748
830
  });
749
- for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
831
+ for (const message of [run.reservedUserMessage, run.reservedAssistantMessage!]) {
832
+ if (!message) continue;
750
833
  this.core.emitEvent(run.scopeId, run.stored.session, {
751
834
  type: 'message.created',
752
835
  runId: run.runId,
@@ -818,6 +901,8 @@ export class FlexHarnessPromptQueue<TScope> {
818
901
  status: queued.status,
819
902
  queuedAt: queued.queuedAt,
820
903
  ...(queued.runId ? { runId: queued.runId } : {}),
904
+ ...(queued.promptId === undefined ? {} : { promptId: queued.promptId }),
905
+ ...(queued.continuation ? { continuation: true as const } : {}),
821
906
  ...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
822
907
  ...(queued.cacheWarm ? { cacheWarm: true } : {}),
823
908
  ...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
@@ -851,7 +936,7 @@ export class FlexHarnessPromptQueue<TScope> {
851
936
  queued: IQueuedPrompt,
852
937
  status: 'completed' | 'failed' | 'cancelled',
853
938
  error?: unknown,
854
- result?: IFlexPromptResult,
939
+ result?: IFlexContinuePromptResult,
855
940
  ): void {
856
941
  const stored = queued.stored;
857
942
  if (stored.outstandingPromptsById.get(queued.queueId) !== queued) return;