@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.
@@ -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
 
package/ts/interfaces.ts CHANGED
@@ -600,6 +600,8 @@ export interface IFlexPromptQueueEntry {
600
600
  /** The prompt's `promptId`, when it named one. */
601
601
  promptId?: string;
602
602
  scheduleKey?: string;
603
+ /** This entry answers existing context without adding a prompt message. */
604
+ continuation?: true;
603
605
  /** The entry is a cache-warm prompt; see {@link IFlexMessage.cacheWarm}. */
604
606
  cacheWarm?: true;
605
607
  startedAt?: string;
@@ -608,8 +610,9 @@ export interface IFlexPromptQueueEntry {
608
610
  /**
609
611
  * On a finished entry: whether its prompt, the steers its run took in and what its model
610
612
  * produced stay in the model context of later turns. True for a completed run and a run
611
- * stopped after its model produced output; false for a failed run, a run stopped before any
612
- * model output and a prompt that never ran. Send the prompt again to have it answered.
613
+ * stopped or failed after its model produced output; false for an unsuccessful run without
614
+ * model output and a prompt that never ran. For a continuation, this describes only the new
615
+ * run's history; it does not change the retention of the existing input.
613
616
  */
614
617
  keptInContext?: boolean;
615
618
  /**
@@ -620,10 +623,10 @@ export interface IFlexPromptQueueEntry {
620
623
  unappliedSteerIds?: string[];
621
624
  }
622
625
 
623
- export interface IFlexPromptResult {
626
+ /** The result of answering existing context, without adding a prompt message. */
627
+ export interface IFlexContinuePromptResult {
624
628
  runId: string;
625
629
  sessionId: string;
626
- userMessage: IFlexMessage;
627
630
  /** The run's last assistant message; a steered run completed earlier ones as it took steers in. */
628
631
  assistantMessage: IFlexMessage;
629
632
  model: IFlexModelIdentity;
@@ -634,6 +637,10 @@ export interface IFlexPromptResult {
634
637
  unappliedSteerIds: string[];
635
638
  }
636
639
 
640
+ export interface IFlexPromptResult extends IFlexContinuePromptResult {
641
+ userMessage: IFlexMessage;
642
+ }
643
+
637
644
  /** A message for the running run of a session, taken in at the run's next step boundary. */
638
645
  export interface IFlexSteerInput {
639
646
  /** Caller-chosen identity, unique among the steers of the run. */
@@ -647,12 +654,13 @@ export interface IFlexSteerAdmission {
647
654
  runId: string;
648
655
  }
649
656
 
650
- export interface IFlexPromptQueueAdmission {
657
+ export interface IFlexPromptQueueAdmission<TResult extends IFlexContinuePromptResult = IFlexPromptResult> {
651
658
  queueId: string;
652
- completion: Promise<IFlexPromptResult>;
659
+ completion: Promise<TResult>;
653
660
  }
654
661
 
655
- export interface IFlexPromptAdmission extends IFlexPromptQueueAdmission {
662
+ export interface IFlexPromptAdmission<TResult extends IFlexContinuePromptResult = IFlexPromptResult>
663
+ extends IFlexPromptQueueAdmission<TResult> {
656
664
  runId: string;
657
665
  }
658
666
 
@@ -673,6 +681,13 @@ export interface IFlexEnqueuePromptOptions extends IFlexPromptOptions {
673
681
  steers?: readonly IFlexSteerInput[];
674
682
  }
675
683
 
684
+ /** Options for continuation through the same FIFO, optionally with scheduled execution. */
685
+ export interface IFlexContinuePromptOptions extends Omit<IFlexEnqueuePromptOptions, 'promptId'> {
686
+ scheduleKey?: string;
687
+ /** Requires scheduleKey; defaults to 50 ms when scheduled. */
688
+ debounceMs?: number;
689
+ }
690
+
676
691
  export interface IFlexScheduledPromptAdmission extends IFlexPromptAdmission {
677
692
  scheduleKey: string;
678
693
  }
@@ -1260,7 +1275,8 @@ export interface IFlexScopeSnapshot {
1260
1275
  export interface IFlexTerminalProjection {
1261
1276
  runId: string;
1262
1277
  status: 'completed' | 'failed' | 'cancelled';
1263
- userMessage: IFlexMessage;
1278
+ /** Absent for a continuation, which creates no prompt message. */
1279
+ userMessage?: IFlexMessage;
1264
1280
  assistantMessage: IFlexMessage;
1265
1281
  model?: IFlexModelIdentity;
1266
1282
  /** The usage the provider reported for the run's model calls, whatever the status. */
@@ -1288,7 +1304,8 @@ export interface IFlexAffectedWorkspace {
1288
1304
 
1289
1305
  interface IFlexReversionSegmentV2 {
1290
1306
  runId: string;
1291
- userMessageId: string;
1307
+ /** Absent for an assistant-only continuation segment. */
1308
+ userMessageId?: string;
1292
1309
  status: TFlexReversionSegmentStatus;
1293
1310
  contextAvailable: boolean;
1294
1311
  eventIds: string[];
package/ts/plugins.ts CHANGED
@@ -159,6 +159,7 @@ export type {
159
159
  IAgentRetryEvent,
160
160
  IAgentRunResult,
161
161
  IAgentSession,
162
+ IAgentContinuationSession,
162
163
  IAgentSessionOptions,
163
164
  IAgentToolCallUpdateEvent,
164
165
  IAgentToolExecutionOptions,
package/ts/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`;
package/ts/utils.json.ts CHANGED
@@ -762,6 +762,7 @@ interface IValidatedMessageIdentity {
762
762
  runId: string;
763
763
  role: 'user' | 'assistant';
764
764
  status: 'streaming' | 'completed' | 'failed' | 'cancelled';
765
+ steerId?: string;
765
766
  }
766
767
 
767
768
  interface IValidatedSessionIdentity {
@@ -932,6 +933,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
932
933
  runId,
933
934
  role: message.role,
934
935
  status: message.status as IValidatedMessageIdentity['status'],
936
+ ...(message.steerId === undefined ? {} : { steerId: message.steerId as string }),
935
937
  };
936
938
  }
937
939
 
@@ -1226,7 +1228,7 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
1226
1228
 
1227
1229
  interface IValidatedProjectionBody {
1228
1230
  messages: IValidatedMessageIdentity[];
1229
- stagedUsers: IValidatedMessageIdentity[];
1231
+ stagedMessages: IValidatedMessageIdentity[];
1230
1232
  }
1231
1233
 
1232
1234
  function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedProjectionBody {
@@ -1240,7 +1242,7 @@ function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedPr
1240
1242
  }
1241
1243
  const runIds = new Set<string>();
1242
1244
  const stagedMessageIds = new Set<string>();
1243
- const stagedUsers: IValidatedMessageIdentity[] = [];
1245
+ const stagedMessages: IValidatedMessageIdentity[] = [];
1244
1246
  for (let index = 0; index < snapshot.stagedTerminals.length; index++) {
1245
1247
  const path = `$snapshot.stagedTerminals[${index}]`;
1246
1248
  const terminal = requireRecord(snapshot.stagedTerminals[index], path);
@@ -1266,26 +1268,33 @@ function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedPr
1266
1268
  if (!['completed', 'failed', 'cancelled'].includes(String(terminal.status))) {
1267
1269
  throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
1268
1270
  }
1269
- const user = validateMessage(terminal.userMessage, `${path}.userMessage`);
1271
+ const user = terminal.userMessage === undefined
1272
+ ? undefined
1273
+ : validateMessage(terminal.userMessage, `${path}.userMessage`);
1270
1274
  const assistant = validateMessage(terminal.assistantMessage, `${path}.assistantMessage`);
1271
- if (user.role !== 'user' || assistant.role !== 'assistant') {
1275
+ if ((user && user.role !== 'user') || assistant.role !== 'assistant') {
1272
1276
  throw new FlexHarnessStoreFormatError(`${path} has invalid terminal message roles.`);
1273
1277
  }
1274
- if (user.runId !== runId || assistant.runId !== runId || user.sessionId !== assistant.sessionId) {
1278
+ if ((user && (user.runId !== runId || user.sessionId !== assistant.sessionId)) || assistant.runId !== runId) {
1275
1279
  throw new FlexHarnessStoreFormatError(`${path} has uncorrelated terminal messages.`);
1276
1280
  }
1277
1281
  if (assistant.status !== terminal.status) {
1278
1282
  throw new FlexHarnessStoreFormatError(`${path}.status does not match its assistant message.`);
1279
1283
  }
1280
- if (user.status !== terminal.status) {
1284
+ if (!user && visibleMessages.some((message) =>
1285
+ message.runId === runId && message.role === 'user' && message.steerId === undefined)) {
1286
+ throw new FlexHarnessStoreFormatError(`${path} omits its projected prompt message.`);
1287
+ }
1288
+ if (user && user.status !== terminal.status) {
1281
1289
  throw new FlexHarnessStoreFormatError(`${path}.status does not match its user message.`);
1282
1290
  }
1283
- stagedUsers.push(user);
1284
- projectionSessionId ??= user.sessionId;
1285
- if (user.sessionId !== projectionSessionId) {
1291
+ if (user) stagedMessages.push(user);
1292
+ stagedMessages.push(assistant);
1293
+ projectionSessionId ??= assistant.sessionId;
1294
+ if (assistant.sessionId !== projectionSessionId) {
1286
1295
  throw new FlexHarnessStoreFormatError(`${path} belongs to another session.`);
1287
1296
  }
1288
- for (const messageId of [user.messageId, assistant.messageId]) {
1297
+ for (const messageId of [...(user ? [user.messageId] : []), assistant.messageId]) {
1289
1298
  if (stagedMessageIds.has(messageId)) {
1290
1299
  throw new FlexHarnessStoreFormatError(
1291
1300
  `Snapshot staged terminals contain duplicate message "${messageId}".`,
@@ -1298,7 +1307,7 @@ function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedPr
1298
1307
  requireOptionalString(terminal.finishReason, `${path}.finishReason`);
1299
1308
  if (terminal.steps !== undefined) requireNonNegativeInteger(terminal.steps, `${path}.steps`);
1300
1309
  }
1301
- return { messages: visibleMessages, stagedUsers };
1310
+ return { messages: visibleMessages, stagedMessages };
1302
1311
  }
1303
1312
 
1304
1313
  export function assertFlexProjectionSnapshotV1(
@@ -1358,7 +1367,7 @@ export function assertFlexProjectionSnapshotV2(
1358
1367
  'workspaceReference',
1359
1368
  ], path);
1360
1369
  const runId = requireString(segment.runId, `${path}.runId`);
1361
- const userMessageId = requireString(segment.userMessageId, `${path}.userMessageId`);
1370
+ const userMessageId = requireOptionalString(segment.userMessageId, `${path}.userMessageId`);
1362
1371
  if (segmentRunIds.has(runId)) {
1363
1372
  throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate reversion run "${runId}".`);
1364
1373
  }
@@ -1414,12 +1423,19 @@ export function assertFlexProjectionSnapshotV2(
1414
1423
  throw new FlexHarnessStoreFormatError(`${path}.workspaceReference exceeds its byte limit.`);
1415
1424
  }
1416
1425
  }
1417
- const correlatedUsers = new Set([...projectionBody.messages, ...projectionBody.stagedUsers]
1426
+ const projectedMessages = [...projectionBody.messages, ...projectionBody.stagedMessages];
1427
+ if (userMessageId === undefined && projectedMessages.some((message) =>
1428
+ message.runId === runId && message.role === 'user' && message.steerId === undefined)) {
1429
+ throw new FlexHarnessStoreFormatError(`${path} omits its projected prompt identity.`);
1430
+ }
1431
+ const correlatedMessages = new Set(projectedMessages
1418
1432
  .filter((message) =>
1419
- message.role === 'user' && message.runId === runId && message.messageId === userMessageId)
1433
+ message.runId === runId && (userMessageId === undefined
1434
+ ? message.role === 'assistant'
1435
+ : message.role === 'user' && message.messageId === userMessageId))
1420
1436
  .map((message) => `${message.runId}:${message.messageId}`));
1421
- if (correlatedUsers.size !== 1) {
1422
- throw new FlexHarnessStoreFormatError(`${path} does not match exactly one projected user message.`);
1437
+ if (userMessageId === undefined ? correlatedMessages.size === 0 : correlatedMessages.size !== 1) {
1438
+ throw new FlexHarnessStoreFormatError(`${path} does not match its projected turn messages.`);
1423
1439
  }
1424
1440
  }
1425
1441
  requireNonNegativeInteger(snapshot.revertCursor, '$snapshot.revertCursor');
@@ -1843,7 +1859,7 @@ export function assertFlexProjectionSnapshotV3(
1843
1859
  const segment = entry as Record<string, unknown>;
1844
1860
  return {
1845
1861
  runId: segment.runId,
1846
- userMessageId: segment.userMessageId,
1862
+ ...(segment.userMessageId === undefined ? {} : { userMessageId: segment.userMessageId }),
1847
1863
  status: segment.status,
1848
1864
  contextAvailable: segment.contextAvailable,
1849
1865
  eventIds: segment.eventIds,
@@ -1901,7 +1917,7 @@ function assertProjectionSession(
1901
1917
  }
1902
1918
  for (const terminal of snapshot.stagedTerminals) {
1903
1919
  if (
1904
- terminal.userMessage.sessionId !== sessionId
1920
+ (terminal.userMessage !== undefined && terminal.userMessage.sessionId !== sessionId)
1905
1921
  || terminal.assistantMessage.sessionId !== sessionId
1906
1922
  ) {
1907
1923
  throw new FlexHarnessStoreFormatError(