@modelprofile.com/flexharness 15.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.
package/readme.md CHANGED
@@ -498,7 +498,7 @@ Sending requires the exact active parent run, its currently delegated direct chi
498
498
 
499
499
  Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and separately bounded harness-owned agent/task metadata. Its metadata includes `childSessionId`, the exact ID reserved for this call: FlexHarness derives it deterministically when `taskId` is omitted and copies the supplied candidate when `taskId` is present. A resume also retains that candidate as `taskId`. This block is not truncated by `toolOutputLimits`. The reserved ID binds permission handling before child creation but does not prove that the child exists or is owned: applications must treat the later delegated admission context as authoritative. The controller answers through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
500
500
 
501
- Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` accepts `sessionId`, `sessionGenerationId`, `configurationRef`, and `title`. A child inherits its parent's `configurationRef` and cannot resume under a different reference. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
501
+ Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` accepts `sessionId`, `sessionGenerationId`, `configurationRef`, and `title`. A child inherits its parent's `configurationRef` and cannot resume under a different reference. Child sessions reject direct `continuePrompt()`, `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
502
502
 
503
503
  The parent tool part receives `childSessionId` as soon as the child is acquired, so a host can offer an authorized live transcript. A foreground call also adds the resolved child model to its part and returns bounded JSON:
504
504
 
@@ -717,6 +717,55 @@ The reservation save is the admission point. A save failure produces no start ev
717
717
 
718
718
  `abort()` returns `true` only while cancellation is still accepted. Terminal persistence is the run's commit point; once it starts, `abort()` returns `false` and the already-fixed terminal outcome completes while the session remains busy.
719
719
 
720
+ ### Continuing Without New Input
721
+
722
+ Use `continuePrompt()` after a failed or stopped run whose input remains in context
723
+ (`keptInContext: true`), when the conversation still needs an answer:
724
+
725
+ ```typescript
726
+ const continuation = await harness.continuePrompt(scopeId, sessionId, {
727
+ modelHint: 'available-model',
728
+ });
729
+ console.log(continuation.queueId);
730
+ const result = await continuation.completion;
731
+ console.log(result.assistantMessage.parts);
732
+ ```
733
+
734
+ The signature is `continuePrompt(scopeId: string, sessionId: string,
735
+ options?: IFlexContinuePromptOptions): Promise<IFlexPromptQueueAdmission<IFlexContinuePromptResult>>`.
736
+ It accepts `modelHint`, `system`, `maxSteps` and initial `steers`, plus optional
737
+ `scheduleKey` and `debounceMs` for the same scheduling behavior as `schedulePrompt()`.
738
+ `debounceMs` requires a schedule key and defaults to 50 ms when scheduled. There is
739
+ no `prompt` or `promptId`: continuation creates a new run and an assistant message
740
+ without appending or modifying a user message. Its result has the prompt result's
741
+ fields except `userMessage`. Explicit steers still become user messages normally.
742
+
743
+ Admission returns `{ queueId, completion }` once queued, like `enqueuePrompt()`.
744
+ The entry and its `prompt.*` events carry `continuation: true`. Inspect its `runId`
745
+ after `prompt.started`, or read it through `getPromptQueueEntry()`. Use the same
746
+ `cancelPrompt()`, `cancelScheduledPrompt()`, `abort()` and `steerPrompt()` methods.
747
+ Queue bounds and FIFO order, permissions, resource cleanup, compaction, usage
748
+ accounting and provider limits follow the ordinary run path.
749
+
750
+ At the front of the FIFO, the harness validates the visible transcript and model
751
+ context with undone and excluded runs filtered out, before committing an undone
752
+ branch or reserving a generation or audit message. A refusal preserves redo history.
753
+ Empty context, an already answered turn, and an unsuccessful input that did not stay
754
+ in context refuse with `FlexHarnessValidationError` through `completion`. A retained
755
+ failure whose model context ends in partial assistant text with no tool call is also
756
+ refused; `keptInContext: true` alone does not guarantee it can be continued. An earlier
757
+ queued answer can therefore invalidate a waiting continuation. Cancellation before
758
+ execution leaves the retained input available for another continuation. Continuing never replays
759
+ previous tool calls; unknown external effects still require reconciliation.
760
+
761
+ Existing projections and Agent event stores remain readable without migration.
762
+ Assistant-only runs require two optional fields in the existing schema-3 projection:
763
+ a staged terminal omits `userMessage`, and its reversion segment omits
764
+ `userMessageId`. Requiring either would invent a user message. Generation claims
765
+ use existing events without a new `user-message` event. Older strict projection
766
+ readers cannot open a session containing these continuation records; do not mix
767
+ versions or downgrade those sessions.
768
+
720
769
  ### Steering a Running Turn
721
770
 
722
771
  `steerPrompt()` hands a message to the running run of a prompt instead of queueing it behind that run:
@@ -1010,7 +1059,7 @@ if (started.status === 'started') {
1010
1059
 
1011
1060
  **Read-only.** An imported session never runs, and nothing writes model context for it: it has no Agent events and no tool jobs. Every operation that would change its history or model context throws `FlexHarnessImportedSessionError` (code `FLEX_SESSION_READ_ONLY`):
1012
1061
 
1013
- - `prompt`, `startPrompt`, `enqueuePrompt` and `schedulePrompt`;
1062
+ - `prompt`, `startPrompt`, `enqueuePrompt`, `schedulePrompt` and `continuePrompt`;
1014
1063
  - `executeSlashCommand` and `compactSession`;
1015
1064
  - `undoSession` and `redoSession`;
1016
1065
  - `pushRuntimeEvent`, `reconcileToolExecution` and `archiveSessionEvents`;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '15.0.0',
6
+ version: '16.0.0',
7
7
  description: 'The FlexHarness toolbox source repository and shared dependency catalog.'
8
8
  }
@@ -8,7 +8,7 @@ import {
8
8
  import type {
9
9
  IFlexCacheWarmInput,
10
10
  IFlexCacheWarmState,
11
- IFlexPromptResult,
11
+ IFlexContinuePromptResult,
12
12
  IFlexSession,
13
13
  } from './interfaces.js';
14
14
  import type {
@@ -236,7 +236,7 @@ export class FlexHarnessCacheWarm<TScope> {
236
236
  queued: IQueuedPrompt,
237
237
  status: 'completed' | 'failed' | 'cancelled',
238
238
  error?: unknown,
239
- result?: IFlexPromptResult,
239
+ result?: IFlexContinuePromptResult,
240
240
  ): void {
241
241
  const entry = this.entries.get(this.key(queued.state.storageKey, queued.sessionId));
242
242
  if (queued.cacheWarm && status === 'completed' && result) {
@@ -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,
@@ -979,7 +979,7 @@ export class FlexHarnessCore<TScope> {
979
979
  const stage = stages.get(runId);
980
980
  if (outcome === 'accepted' && stage?.status === 'completed') {
981
981
  this.runs.replaceVisibleRun(stored, this.runs.withKeptInContext(stage, true));
982
- if (!stage.userMessage.cacheWarm) latestTerminal = stage;
982
+ if (!stage.assistantMessage.cacheWarm) latestTerminal = stage;
983
983
  } else if (outcome !== 'accepted') {
984
984
  const visible = stored.messages.filter((message) => message.runId === runId);
985
985
  // A run that failed or was stopped keeps its terminal and its error: the staged one, or the
@@ -994,7 +994,7 @@ export class FlexHarnessCore<TScope> {
994
994
  plugins.isGenerationInModelContext(agentEvents, runId),
995
995
  ));
996
996
  }
997
- if (!ended.userMessage.cacheWarm) latestTerminal = ended;
997
+ if (!ended.assistantMessage.cacheWarm) latestTerminal = ended;
998
998
  } else if (visible.length > 0 || stage) {
999
999
  const status = outcome === 'rejected' ? 'failed' : 'cancelled';
1000
1000
  const terminal = this.runs.withKeptInContext(
@@ -1002,7 +1002,7 @@ export class FlexHarnessCore<TScope> {
1002
1002
  plugins.isGenerationInModelContext(agentEvents, runId),
1003
1003
  );
1004
1004
  this.runs.replaceVisibleRun(stored, terminal);
1005
- if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
1005
+ if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
1006
1006
  }
1007
1007
  } else {
1008
1008
  const visibleSuccess = stored.messages.some((message) =>
@@ -1015,7 +1015,7 @@ export class FlexHarnessCore<TScope> {
1015
1015
  undefined,
1016
1016
  'completed',
1017
1017
  );
1018
- if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
1018
+ if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
1019
1019
  }
1020
1020
  }
1021
1021
  }
@@ -1120,13 +1120,13 @@ export class FlexHarnessCore<TScope> {
1120
1120
  if (visible.some((message) => message.status === 'streaming')) return undefined;
1121
1121
  const userMessage = visible.find((message) => message.role === 'user' && message.steerId === undefined);
1122
1122
  const assistantMessage = [...visible].reverse().find((message) => message.role === 'assistant');
1123
- if (!userMessage || !assistantMessage) return undefined;
1123
+ if (!assistantMessage) return undefined;
1124
1124
  const status = assistantMessage.status;
1125
1125
  if (status !== 'failed' && status !== 'cancelled') return undefined;
1126
1126
  return {
1127
1127
  runId,
1128
1128
  status,
1129
- userMessage,
1129
+ ...(userMessage ? { userMessage } : {}),
1130
1130
  assistantMessage,
1131
1131
  ...(assistantMessage.model ? { model: assistantMessage.model } : {}),
1132
1132
  ...(assistantMessage.usage ? { usage: assistantMessage.usage } : {}),
@@ -1147,15 +1147,7 @@ export class FlexHarnessCore<TScope> {
1147
1147
  ?? visible.find((message) => message.role === 'user' && message.steerId === undefined);
1148
1148
  const sourceAssistant = stage?.assistantMessage
1149
1149
  ?? [...visible].reverse().find((message) => message.role === 'assistant');
1150
- const userMessage: IFlexMessage = cloneSerializable(sourceUser ?? {
1151
- messageId: plugins.crypto.randomUUID(),
1152
- sessionId,
1153
- runId,
1154
- role: 'user',
1155
- status,
1156
- createdAt: timestamp,
1157
- parts: [],
1158
- });
1150
+ const userMessage = sourceUser ? cloneSerializable(sourceUser) : undefined;
1159
1151
  const assistantMessage: IFlexMessage = cloneSerializable(sourceAssistant ?? {
1160
1152
  messageId: plugins.crypto.randomUUID(),
1161
1153
  sessionId,
@@ -1165,9 +1157,9 @@ export class FlexHarnessCore<TScope> {
1165
1157
  createdAt: timestamp,
1166
1158
  parts: [],
1167
1159
  });
1168
- userMessage.status = status;
1160
+ if (userMessage) userMessage.status = status;
1169
1161
  assistantMessage.status = status;
1170
- userMessage.completedAt ??= timestamp;
1162
+ if (userMessage) userMessage.completedAt ??= timestamp;
1171
1163
  assistantMessage.completedAt ??= timestamp;
1172
1164
  for (const part of assistantMessage.parts) {
1173
1165
  if (part.type === 'reasoning' && part.status === 'running') {
@@ -1183,7 +1175,7 @@ export class FlexHarnessCore<TScope> {
1183
1175
  if (status !== 'completed') {
1184
1176
  const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
1185
1177
  // As for a run that ends live: a failed run marks its prompt too, a cancelled one only its answer.
1186
- if (status === 'failed') userMessage.error = message;
1178
+ if (status === 'failed' && userMessage) userMessage.error = message;
1187
1179
  assistantMessage.error = message;
1188
1180
  if (stage?.usage) {
1189
1181
  assistantMessage.usage = cloneSerializable(stage.usage);
@@ -1194,7 +1186,7 @@ export class FlexHarnessCore<TScope> {
1194
1186
  return {
1195
1187
  runId,
1196
1188
  status,
1197
- userMessage,
1189
+ ...(userMessage ? { userMessage } : {}),
1198
1190
  assistantMessage,
1199
1191
  ...(status === 'completed' && stage?.model ? { model: stage.model } : {}),
1200
1192
  ...(stage?.usage ? { usage: stage.usage } : {}),
@@ -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;