@modelprofile.com/flexharness 9.4.0 → 9.5.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.
@@ -4,6 +4,7 @@ import {
4
4
  FlexHarnessCallbackOverflowError,
5
5
  FlexHarnessClosedError,
6
6
  FlexHarnessExternalError,
7
+ FlexHarnessImportedSessionError,
7
8
  FlexHarnessNotFoundError,
8
9
  FlexHarnessPermissionRejectedError,
9
10
  FlexHarnessPermissionStateError,
@@ -25,6 +26,7 @@ import {
25
26
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
26
27
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
27
28
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
29
+ FLEX_SESSION_IMPORT_MAX_DEPTH,
28
30
  } from './interfaces.js';
29
31
  import type {
30
32
  IFlexAgentContextCompactionOptions,
@@ -32,6 +34,7 @@ import type {
32
34
  IFlexAgentSessionPolicy,
33
35
  IFlexAffectedWorkspace,
34
36
  IFlexBackgroundExecution,
37
+ IFlexBeginSessionImportOptions,
35
38
  IFlexCallbackLimits,
36
39
  IFlexCompactSessionOptions,
37
40
  IFlexCreateProjectTaskInput,
@@ -45,6 +48,7 @@ import type {
45
48
  IFlexExecutionContextHandle,
46
49
  IFlexHarnessOptions,
47
50
  IFlexHarnessStores,
51
+ IFlexImportedRun,
48
52
  IFlexMessage,
49
53
  IFlexMessagePage,
50
54
  IFlexMessagePageOptions,
@@ -85,6 +89,10 @@ import type {
85
89
  IFlexSessionActivity,
86
90
  IFlexSessionGeneration,
87
91
  IFlexSessionGenerationCohortEntry,
92
+ IFlexSessionImportHandle,
93
+ IFlexSessionImportLimits,
94
+ IFlexSessionImportProgress,
95
+ IFlexSessionOrigin,
88
96
  IFlexSessionReversionGroup,
89
97
  IFlexSessionReversionInfo,
90
98
  IFlexSessionTombstone,
@@ -106,6 +114,7 @@ import type {
106
114
  TFlexAgentToolCallUpdateEvent,
107
115
  TFlexAgentToolCallFinishEvent,
108
116
  TFlexAgentToolSet,
117
+ TFlexBeginSessionImportResult,
109
118
  TFlexExternalErrorProjector,
110
119
  TFlexExternalErrorSource,
111
120
  TFlexHarnessEvent,
@@ -184,6 +193,14 @@ import {
184
193
  truncateUtf8,
185
194
  type IRunResultProjection,
186
195
  } from './utils.transfer.js';
196
+ import {
197
+ normalizeBeginSessionImportOptions,
198
+ normalizeImportedRunPage,
199
+ resolveSessionImportLimits,
200
+ sessionImportIdentity,
201
+ validateSessionImportHandle,
202
+ type INormalizedSessionImportOptions,
203
+ } from './utils.sessionimport.js';
187
204
  import { wrapToolSet } from './utils.tooloutput.js';
188
205
  import {
189
206
  validateCompactSessionOptions,
@@ -299,6 +316,25 @@ interface IOrphanedExecutionContextOwner {
299
316
  sessionId: string;
300
317
  }
301
318
 
319
+ /** A staged session import: its runs stay in memory until the import is committed. */
320
+ interface IStagedSessionImport {
321
+ importId: string;
322
+ scopeId: string;
323
+ state: IStorageState;
324
+ scope: unknown;
325
+ sessionId: string;
326
+ options: INormalizedSessionImportOptions;
327
+ identity: string;
328
+ runs: IFlexImportedRun[];
329
+ runIds: Set<string>;
330
+ messageIds: Set<string>;
331
+ messageCount: number;
332
+ bytes: number;
333
+ phase: 'staging' | 'committing';
334
+ /** Discards the import when it stays idle; cleared once the import commits or is released. */
335
+ idleTimer?: ReturnType<typeof setTimeout>;
336
+ }
337
+
302
338
  interface IStorageState {
303
339
  storageKey: string;
304
340
  compactorLifecycleController: AbortController;
@@ -310,6 +346,8 @@ interface IStorageState {
310
346
  sessionDeletions: Map<string, Promise<void>>;
311
347
  initializingSessions: Set<string>;
312
348
  sessionInitializations: Map<string, Promise<void>>;
349
+ /** Staged session imports by session id; their ids are also in `initializingSessions`. */
350
+ sessionImports: Map<string, IStagedSessionImport>;
313
351
  tombstones: Map<string, IFlexSessionTombstone>;
314
352
  tombstoneCleanups: Map<string, Promise<void>>;
315
353
  activeRuns: Map<string, IActiveRun>;
@@ -575,6 +613,7 @@ const resourceToolStemLength = 16;
575
613
  const repairCancellationMessage = 'The process stopped before this run completed.';
576
614
  const scopeRetirementMessage = 'The scope is being retired.';
577
615
  const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
616
+ const importedSessionUnavailableReason = 'Session is an imported conversation and is read-only.';
578
617
  const externalErrorFallback: IFlexErrorInfo = Object.freeze({
579
618
  name: 'FlexHarnessExternalError',
580
619
  message: 'The model operation failed.',
@@ -714,6 +753,7 @@ export class FlexHarness<TScope = unknown> {
714
753
  private readonly callbackLimits: Required<IFlexCallbackLimits>;
715
754
  private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
716
755
  private readonly reversionLimits: Required<IFlexReversionLimits>;
756
+ private readonly sessionImportLimits: Required<IFlexSessionImportLimits>;
717
757
  private readonly reversionMaintenanceTimeoutMs: number;
718
758
  private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
719
759
  private readonly reversionPolicy: TFlexReversionPolicy;
@@ -743,6 +783,7 @@ export class FlexHarness<TScope = unknown> {
743
783
  private readonly projectManagementQueues = new Map<string, Promise<void>>();
744
784
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
745
785
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
786
+ private readonly stagedSessionImports = new Map<string, IStagedSessionImport>();
746
787
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
747
788
  ICompactorInvocation<TScope>
748
789
  >();
@@ -779,6 +820,7 @@ export class FlexHarness<TScope = unknown> {
779
820
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
780
821
  this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
781
822
  this.reversionLimits = resolveReversionLimits(options.reversionLimits);
823
+ this.sessionImportLimits = resolveSessionImportLimits(options.sessionImportLimits);
782
824
  this.reversionMaintenanceTimeoutMs = resolveReversionMaintenanceTimeoutMs(options);
783
825
  this.turnReversionProvider = options.turnReversionProvider;
784
826
  this.reversionPolicy = options.reversionPolicy ?? 'transcript-optional';
@@ -996,6 +1038,271 @@ export class FlexHarness<TScope = unknown> {
996
1038
  return result;
997
1039
  }
998
1040
 
1041
+ /**
1042
+ * Starts a staged import of a finished conversation into a new read-only session. The session
1043
+ * stays hidden until `commitSessionImport()`. A committed session with the same source, source
1044
+ * id and part is reported instead of being imported twice.
1045
+ */
1046
+ public async beginSessionImport(
1047
+ scopeId: string,
1048
+ options: IFlexBeginSessionImportOptions,
1049
+ ): Promise<TFlexBeginSessionImportResult> {
1050
+ this.assertOpen();
1051
+ const normalized = normalizeBeginSessionImportOptions(options);
1052
+ const { scope, state } = await this.resolveState(scopeId);
1053
+ await state.scopeQueue;
1054
+ this.assertOpen();
1055
+ this.assertStateAcceptingWork(state);
1056
+ const { sessionId } = normalized;
1057
+ const identity = sessionImportIdentity(normalized.origin);
1058
+ for (const staged of state.sessionImports.values()) {
1059
+ if (staged.sessionId === sessionId || staged.identity === identity) {
1060
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is already being imported');
1061
+ }
1062
+ }
1063
+ for (const stored of state.sessions.values()) {
1064
+ const origin = stored.session.origin;
1065
+ if (origin === undefined || sessionImportIdentity(origin) !== identity) continue;
1066
+ const existing: TFlexBeginSessionImportResult = {
1067
+ status: origin.digest === normalized.origin.digest ? 'already_imported' : 'digest_mismatch',
1068
+ session: stored.session,
1069
+ };
1070
+ return publicSnapshot(existing);
1071
+ }
1072
+ if (
1073
+ state.sessions.has(sessionId)
1074
+ || state.tombstones.has(sessionId)
1075
+ || state.initializingSessions.has(sessionId)
1076
+ ) {
1077
+ throw new FlexHarnessValidationError(`Session "${sessionId}" already exists or is being deleted.`);
1078
+ }
1079
+ if (normalized.parent) this.requireImportParent(state, sessionId, normalized.parent);
1080
+ if (this.stagedSessionImports.size >= this.sessionImportLimits.maxOpenImports) {
1081
+ throw new FlexHarnessQueueFullError('FlexHarness has reached its open session import limit.');
1082
+ }
1083
+ const staged: IStagedSessionImport = {
1084
+ importId: `import_${plugins.crypto.randomUUID()}`,
1085
+ scopeId,
1086
+ state,
1087
+ scope: scope.scope,
1088
+ sessionId,
1089
+ options: normalized,
1090
+ identity,
1091
+ runs: [],
1092
+ runIds: new Set(),
1093
+ messageIds: new Set(),
1094
+ messageCount: 0,
1095
+ bytes: 0,
1096
+ phase: 'staging',
1097
+ };
1098
+ this.stagedSessionImports.set(staged.importId, staged);
1099
+ this.armStagedImportIdleTimer(staged);
1100
+ state.sessionImports.set(sessionId, staged);
1101
+ state.initializingSessions.add(sessionId);
1102
+ const started: TFlexBeginSessionImportResult = {
1103
+ status: 'started',
1104
+ handle: { importId: staged.importId, scopeId, sessionId },
1105
+ };
1106
+ return publicSnapshot(started);
1107
+ }
1108
+
1109
+ /** Stages one page of runs, in order, for a started session import. A rejected page stages nothing. */
1110
+ public async appendImportedRuns(
1111
+ handle: IFlexSessionImportHandle,
1112
+ runs: readonly IFlexImportedRun[],
1113
+ ): Promise<IFlexSessionImportProgress> {
1114
+ const staged = this.requireStagedSessionImport(handle);
1115
+ this.assertOpen();
1116
+ this.assertStateAcceptingWork(staged.state);
1117
+ const page = normalizeImportedRunPage(runs, staged.sessionId, this.sessionImportLimits.maxPageBytes);
1118
+ const runIds = new Set<string>();
1119
+ const messageIds = new Set<string>();
1120
+ for (const run of page.runs) {
1121
+ if (staged.runIds.has(run.user.runId) || runIds.has(run.user.runId)) {
1122
+ throw new FlexHarnessValidationError(`Imported run "${run.user.runId}" is not unique.`);
1123
+ }
1124
+ runIds.add(run.user.runId);
1125
+ for (const message of [run.user, run.assistant]) {
1126
+ if (staged.messageIds.has(message.messageId) || messageIds.has(message.messageId)) {
1127
+ throw new FlexHarnessValidationError(`Imported message "${message.messageId}" is not unique.`);
1128
+ }
1129
+ messageIds.add(message.messageId);
1130
+ }
1131
+ }
1132
+ const messageCount = staged.messageCount + page.runs.length * 2;
1133
+ if (messageCount > this.sessionImportLimits.maxMessagesPerSession) {
1134
+ throw new FlexHarnessValidationError(
1135
+ `An imported session holds at most ${this.sessionImportLimits.maxMessagesPerSession} messages.`,
1136
+ );
1137
+ }
1138
+ const bytes = staged.bytes + page.bytes;
1139
+ if (bytes > this.sessionImportLimits.maxSessionBytes) {
1140
+ throw new FlexHarnessValidationError(
1141
+ `An imported session holds at most ${this.sessionImportLimits.maxSessionBytes} message bytes.`,
1142
+ );
1143
+ }
1144
+ staged.runs.push(...page.runs);
1145
+ for (const runId of runIds) staged.runIds.add(runId);
1146
+ for (const messageId of messageIds) staged.messageIds.add(messageId);
1147
+ staged.messageCount = messageCount;
1148
+ staged.bytes = bytes;
1149
+ this.armStagedImportIdleTimer(staged);
1150
+ return publicSnapshot({ runs: staged.runs.length, messages: messageCount, bytes });
1151
+ }
1152
+
1153
+ /**
1154
+ * Persists the staged runs and publishes the session: archived, read-only and marked with its
1155
+ * `origin`. A failed commit removes everything it persisted before it rejects.
1156
+ */
1157
+ public async commitSessionImport(handle: IFlexSessionImportHandle): Promise<IFlexSession> {
1158
+ const staged = this.requireStagedSessionImport(handle);
1159
+ this.assertOpen();
1160
+ const { state, scopeId, sessionId, options } = staged;
1161
+ this.assertStateAcceptingWork(state);
1162
+ if (staged.runs.length === 0) {
1163
+ throw new FlexHarnessValidationError('A session import needs at least one run before it is committed.');
1164
+ }
1165
+ staged.phase = 'committing';
1166
+ this.clearStagedImportIdleTimer(staged);
1167
+ const scope = staged.scope as TScope;
1168
+ let resolveInitialization!: () => void;
1169
+ const initialization = new Promise<void>((resolve) => {
1170
+ resolveInitialization = resolve;
1171
+ });
1172
+ let initializationCompleted = false;
1173
+ const completeInitialization = () => {
1174
+ if (initializationCompleted) return;
1175
+ initializationCompleted = true;
1176
+ if (state.sessionInitializations.get(sessionId) === initialization) {
1177
+ state.sessionInitializations.delete(sessionId);
1178
+ }
1179
+ resolveInitialization();
1180
+ };
1181
+ state.sessionInitializations.set(sessionId, initialization);
1182
+ const importedAt = new Date().toISOString();
1183
+ const origin: IFlexSessionOrigin = {
1184
+ kind: 'import',
1185
+ ...cloneSerializable(options.origin),
1186
+ importedAt,
1187
+ };
1188
+ let metadata!: IFlexSession;
1189
+ let reserved = false;
1190
+ let loaded: IStoredSessionState | undefined;
1191
+ try {
1192
+ // A tombstone reserves the id while the projection is written, so a crash before the session
1193
+ // is published leaves only data that the next load or deletion cleans up.
1194
+ await this.mutateScope(state, () => {
1195
+ if (state.sessions.has(sessionId) || state.tombstones.has(sessionId)) {
1196
+ throw this.trustInternalError(new FlexHarnessValidationError(
1197
+ `Session "${sessionId}" already exists or is being deleted.`,
1198
+ ));
1199
+ }
1200
+ const parent = options.parent
1201
+ ? this.requireImportParent(state, sessionId, options.parent)
1202
+ : undefined;
1203
+ metadata = {
1204
+ scopeId,
1205
+ sessionId,
1206
+ sessionGenerationId: createSessionGenerationId(),
1207
+ sessionGenerationSequence: state.revision + 1,
1208
+ ...(options.title === undefined ? {} : { title: options.title }),
1209
+ createdAt: options.createdAt,
1210
+ updatedAt: options.updatedAt,
1211
+ archivedAt: importedAt,
1212
+ status: 'idle',
1213
+ activity: { status: 'idle' },
1214
+ ...(options.parent === undefined
1215
+ ? {}
1216
+ : {
1217
+ parentSessionId: options.parent.sessionId,
1218
+ parentRunId: options.parent.runId,
1219
+ parentToolCallId: options.parent.toolCallId,
1220
+ agent: options.parent.agent,
1221
+ }),
1222
+ depth: parent === undefined ? 0 : (parent.session.depth ?? 0) + 1,
1223
+ origin,
1224
+ };
1225
+ state.tombstones.set(sessionId, {
1226
+ sessionId,
1227
+ ...requireSessionGeneration(metadata),
1228
+ deletedAt: importedAt,
1229
+ rootSessionId: sessionId,
1230
+ depth: 0,
1231
+ });
1232
+ });
1233
+ reserved = true;
1234
+ await this.stores.projections.save(state.storageKey, sessionId, {
1235
+ schemaVersion: 3,
1236
+ revision: 1,
1237
+ messages: staged.runs.flatMap((run) => [run.user, run.assistant]),
1238
+ stagedTerminals: [],
1239
+ reversionSegments: [],
1240
+ revertCursor: 0,
1241
+ excludedRunIds: [],
1242
+ pendingReversionReleases: [],
1243
+ }, 0);
1244
+ const runtime = await this.loadSessionRuntime(state, metadata, scopeId, scope);
1245
+ loaded = runtime;
1246
+ await this.mutateScope(state, () => {
1247
+ if (
1248
+ state.sessions.has(sessionId)
1249
+ || state.tombstones.get(sessionId)?.sessionGenerationId !== metadata.sessionGenerationId
1250
+ ) {
1251
+ throw this.trustInternalError(new FlexHarnessAbortError(
1252
+ 'The session import lost its reservation before it was committed.',
1253
+ ));
1254
+ }
1255
+ if (options.parent) this.requireImportParent(state, sessionId, options.parent);
1256
+ state.tombstones.delete(sessionId);
1257
+ state.sessions.set(sessionId, runtime);
1258
+ });
1259
+ } catch (error) {
1260
+ const projected = this.projectOperationError(error, 'persistence', scopeId, sessionId, 'session-import');
1261
+ const errors: unknown[] = [projected];
1262
+ if (loaded && state.sessions.get(sessionId) !== loaded) {
1263
+ try {
1264
+ await this.closeStoredSession(loaded);
1265
+ } catch (closeError) {
1266
+ this.orphanedStoredSessions.add(loaded);
1267
+ errors.push(closeError);
1268
+ }
1269
+ }
1270
+ // A fenced namespace cannot tell whether its last save was persisted; its next load resolves
1271
+ // the reservation instead: a surviving tombstone is cleaned up, a published session is kept.
1272
+ if (reserved && state.tombstones.has(sessionId) && state.lifecycle !== 'fenced') {
1273
+ completeInitialization();
1274
+ try {
1275
+ await this.finishTombstoneCleanup(state, sessionId, scopeId, { scopeId, scope });
1276
+ } catch (cleanupError) {
1277
+ errors.push(this.projectOperationError(
1278
+ cleanupError,
1279
+ 'persistence',
1280
+ scopeId,
1281
+ sessionId,
1282
+ 'session-import-cleanup',
1283
+ ));
1284
+ }
1285
+ }
1286
+ throw combineErrors(errors);
1287
+ } finally {
1288
+ this.releaseStagedSessionImport(staged);
1289
+ completeInitialization();
1290
+ }
1291
+ const result = publicSnapshot(metadata);
1292
+ this.emitEvent(scopeId, metadata, { type: 'session.created', session: result });
1293
+ return result;
1294
+ }
1295
+
1296
+ /** Discards a staged import. Nothing of it was persisted; an unknown or finished handle is ignored. */
1297
+ public async abortSessionImport(handle: IFlexSessionImportHandle): Promise<void> {
1298
+ const staged = this.findStagedSessionImport(handle);
1299
+ if (!staged) return;
1300
+ if (staged.phase === 'committing') {
1301
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
1302
+ }
1303
+ this.releaseStagedSessionImport(staged);
1304
+ }
1305
+
999
1306
  public async getSession(scopeId: string, sessionId: string): Promise<IFlexSession> {
1000
1307
  const { state } = await this.resolveState(scopeId);
1001
1308
  await state.scopeQueue;
@@ -1068,7 +1375,7 @@ export class FlexHarness<TScope = unknown> {
1068
1375
  scopeId: string,
1069
1376
  sessionId: string,
1070
1377
  ): Promise<IFlexProjectStateResult> {
1071
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1378
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1072
1379
  const snapshot = await this.readProjectManagementState(state, stored);
1073
1380
  return this.projectStateResult(snapshot);
1074
1381
  }
@@ -1077,7 +1384,7 @@ export class FlexHarness<TScope = unknown> {
1077
1384
  scopeId: string,
1078
1385
  sessionId: string,
1079
1386
  ): Promise<IFlexProjectTasksResult> {
1080
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1387
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1081
1388
  const snapshot = await this.readProjectManagementState(state, stored);
1082
1389
  return this.projectTasksResult(snapshot, snapshot.tasks);
1083
1390
  }
@@ -1088,7 +1395,7 @@ export class FlexHarness<TScope = unknown> {
1088
1395
  input: IFlexCreateProjectTaskInput,
1089
1396
  ): Promise<IFlexProjectTaskResult> {
1090
1397
  validateCreateProjectTaskInput(input);
1091
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1398
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1092
1399
  return this.createProjectTaskInState(
1093
1400
  state,
1094
1401
  stored,
@@ -1103,7 +1410,7 @@ export class FlexHarness<TScope = unknown> {
1103
1410
  input: IFlexUpdateProjectTaskInput,
1104
1411
  ): Promise<IFlexProjectTaskResult> {
1105
1412
  validateUpdateProjectTaskInput(input);
1106
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1413
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1107
1414
  return this.updateProjectTaskInState(
1108
1415
  state,
1109
1416
  stored,
@@ -1118,7 +1425,7 @@ export class FlexHarness<TScope = unknown> {
1118
1425
  taskId: string,
1119
1426
  ): Promise<IFlexProjectTaskResult> {
1120
1427
  validateProjectTaskId(taskId);
1121
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1428
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1122
1429
  return this.deleteProjectTaskInState(
1123
1430
  state,
1124
1431
  stored,
@@ -1131,7 +1438,7 @@ export class FlexHarness<TScope = unknown> {
1131
1438
  scopeId: string,
1132
1439
  sessionId: string,
1133
1440
  ): Promise<IFlexProjectTasksResult> {
1134
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1441
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1135
1442
  return this.clearProjectTasksInState(
1136
1443
  state,
1137
1444
  stored,
@@ -1143,7 +1450,7 @@ export class FlexHarness<TScope = unknown> {
1143
1450
  scopeId: string,
1144
1451
  sessionId: string,
1145
1452
  ): Promise<IFlexProjectGoalResult> {
1146
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1453
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1147
1454
  const snapshot = await this.readProjectManagementState(state, stored);
1148
1455
  return this.projectGoalResult(snapshot);
1149
1456
  }
@@ -1159,7 +1466,7 @@ export class FlexHarness<TScope = unknown> {
1159
1466
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
1160
1467
  true,
1161
1468
  );
1162
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1469
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1163
1470
  return this.setProjectGoalInState(
1164
1471
  state,
1165
1472
  stored,
@@ -1172,7 +1479,7 @@ export class FlexHarness<TScope = unknown> {
1172
1479
  scopeId: string,
1173
1480
  sessionId: string,
1174
1481
  ): Promise<IFlexProjectGoalResult> {
1175
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1482
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1176
1483
  return this.clearProjectGoalInState(
1177
1484
  state,
1178
1485
  stored,
@@ -1184,7 +1491,7 @@ export class FlexHarness<TScope = unknown> {
1184
1491
  scopeId: string,
1185
1492
  sessionId: string,
1186
1493
  ): Promise<IFlexProjectScratchpadResult> {
1187
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1494
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'read');
1188
1495
  const snapshot = await this.readProjectManagementState(state, stored);
1189
1496
  return this.projectScratchpadResult(snapshot);
1190
1497
  }
@@ -1199,7 +1506,7 @@ export class FlexHarness<TScope = unknown> {
1199
1506
  'project scratchpad',
1200
1507
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1201
1508
  );
1202
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1509
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1203
1510
  return this.setProjectScratchpadInState(
1204
1511
  state,
1205
1512
  stored,
@@ -1218,7 +1525,7 @@ export class FlexHarness<TScope = unknown> {
1218
1525
  'project scratchpad append',
1219
1526
  FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1220
1527
  );
1221
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1528
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1222
1529
  return this.appendProjectScratchpadInState(
1223
1530
  state,
1224
1531
  stored,
@@ -1231,7 +1538,7 @@ export class FlexHarness<TScope = unknown> {
1231
1538
  scopeId: string,
1232
1539
  sessionId: string,
1233
1540
  ): Promise<IFlexProjectScratchpadResult> {
1234
- const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1541
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId, 'write');
1235
1542
  return this.clearProjectScratchpadInState(
1236
1543
  state,
1237
1544
  stored,
@@ -1295,6 +1602,7 @@ export class FlexHarness<TScope = unknown> {
1295
1602
  if (!tombstone || !matchesSessionGeneration(tombstone, validated.root)) {
1296
1603
  return { matched: false };
1297
1604
  }
1605
+ this.assertNoCommittingSessionImport(state, validated.root.sessionId);
1298
1606
  const rootSessionId = tombstone.rootSessionId ?? tombstone.sessionId;
1299
1607
  const descendantRoots = this.descendantTombstoneRoots(state, rootSessionId);
1300
1608
  this.assertAuthorizedSessionGenerationCohort(
@@ -1323,6 +1631,7 @@ export class FlexHarness<TScope = unknown> {
1323
1631
  this.assertNoAmbientSlashCommandTeardown({ storageKey: state.storageKey });
1324
1632
  validateIdentifier(sessionId, 'sessionId');
1325
1633
  await state.scopeQueue;
1634
+ this.assertNoCommittingSessionImport(state, sessionId);
1326
1635
  return this.startSessionDeletion(state, scopeId, scope.scope, sessionId);
1327
1636
  }
1328
1637
 
@@ -1644,12 +1953,18 @@ export class FlexHarness<TScope = unknown> {
1644
1953
  signal,
1645
1954
  );
1646
1955
  signal.throwIfAborted();
1647
- return this.createSlashCommandDescriptors(
1956
+ const descriptors = this.createSlashCommandDescriptors(
1648
1957
  state,
1649
1958
  stored,
1650
1959
  sessionAvailable,
1651
1960
  reversionIdleReason,
1652
1961
  );
1962
+ if (stored.session.origin === undefined) return descriptors;
1963
+ return publicSnapshot(descriptors.map((descriptor): IFlexSlashCommandDescriptor => ({
1964
+ ...descriptor,
1965
+ available: false,
1966
+ unavailableReason: importedSessionUnavailableReason,
1967
+ })));
1653
1968
  });
1654
1969
  }
1655
1970
 
@@ -1788,6 +2103,7 @@ export class FlexHarness<TScope = unknown> {
1788
2103
  this.assertOpen();
1789
2104
  this.assertStateAcceptingWork(state);
1790
2105
  const stored = this.requireSession(state, sessionId);
2106
+ this.assertSessionWritable(stored);
1791
2107
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
1792
2108
  const invocationOwner = this.slashCommandInvocationContext.getStore();
1793
2109
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -2383,6 +2699,7 @@ export class FlexHarness<TScope = unknown> {
2383
2699
  }
2384
2700
  const { scope, state } = await this.resolveState(scopeId);
2385
2701
  const stored = this.requireSession(state, sessionId);
2702
+ this.assertSessionWritable(stored);
2386
2703
  this.assertStateAcceptingWork(state);
2387
2704
  let canonical: plugins.TAgentToolExecutionReconciliationOptions;
2388
2705
  if (reconciliation.resolution === 'executed') {
@@ -2422,6 +2739,7 @@ export class FlexHarness<TScope = unknown> {
2422
2739
  this.validateRuntimeEvent(event);
2423
2740
  const { scope, state } = await this.resolveState(scopeId);
2424
2741
  const stored = this.requireSession(state, sessionId);
2742
+ this.assertSessionWritable(stored);
2425
2743
  this.assertStateAcceptingWork(state);
2426
2744
  try {
2427
2745
  await this.withCompactorContext(
@@ -2441,6 +2759,7 @@ export class FlexHarness<TScope = unknown> {
2441
2759
  const { modelHint } = validateCompactSessionOptions(options);
2442
2760
  const { scope, state } = await this.resolveState(scopeId);
2443
2761
  const stored = this.requireSession(state, sessionId);
2762
+ this.assertSessionWritable(stored);
2444
2763
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2445
2764
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
2446
2765
  throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
@@ -2688,6 +3007,7 @@ export class FlexHarness<TScope = unknown> {
2688
3007
  signal?: AbortSignal,
2689
3008
  ): Promise<string> {
2690
3009
  const stored = this.requireSession(state, sessionId);
3010
+ this.assertSessionWritable(stored);
2691
3011
  this.assertStateAcceptingWork(state);
2692
3012
  const invocationOwner = this.slashCommandInvocationContext.getStore();
2693
3013
  if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
@@ -3040,6 +3360,7 @@ export class FlexHarness<TScope = unknown> {
3040
3360
  }
3041
3361
  const { scope, state } = await this.resolveState(scopeId);
3042
3362
  const stored = this.requireSession(state, sessionId);
3363
+ this.assertSessionWritable(stored);
3043
3364
  this.assertStateAcceptingWork(state);
3044
3365
  this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
3045
3366
  const compaction = compactionEventId === undefined
@@ -3239,6 +3560,7 @@ export class FlexHarness<TScope = unknown> {
3239
3560
  }
3240
3561
  this.assertStateAcceptingWork(state);
3241
3562
  const stored = this.requireSession(state, sessionId);
3563
+ this.assertSessionWritable(stored);
3242
3564
  const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
3243
3565
  const invocationOwner = this.slashCommandInvocationContext.getStore();
3244
3566
  if (invocationOwner?.sessionKey === sessionKey) {
@@ -4209,13 +4531,16 @@ export class FlexHarness<TScope = unknown> {
4209
4531
  private async resolveProjectManagementSession(
4210
4532
  scopeId: string,
4211
4533
  sessionId: string,
4534
+ access: 'read' | 'write',
4212
4535
  ): Promise<{ state: IStorageState; stored: IStoredSessionState }> {
4213
4536
  this.requireProjectManagementTools();
4214
4537
  const { state } = await this.resolveState(scopeId);
4215
4538
  await state.scopeQueue;
4216
4539
  this.assertOpen();
4217
4540
  this.assertStateAcceptingWork(state);
4218
- return { state, stored: this.requireSession(state, sessionId) };
4541
+ const stored = this.requireSession(state, sessionId);
4542
+ if (access === 'write') this.assertSessionWritable(stored);
4543
+ return { state, stored };
4219
4544
  }
4220
4545
 
4221
4546
  private assertProjectManagementSession(
@@ -7984,6 +8309,7 @@ export class FlexHarness<TScope = unknown> {
7984
8309
  sessionDeletions: new Map(),
7985
8310
  initializingSessions: new Set(),
7986
8311
  sessionInitializations: new Map(),
8312
+ sessionImports: new Map(),
7987
8313
  tombstones: new Map(snapshot.tombstones.map((entry) => [entry.sessionId, entry])),
7988
8314
  tombstoneCleanups: new Map(),
7989
8315
  activeRuns: new Map(),
@@ -8397,6 +8723,10 @@ export class FlexHarness<TScope = unknown> {
8397
8723
  scopeId?: string,
8398
8724
  scope?: TScope,
8399
8725
  ): Promise<boolean> {
8726
+ if (stored.session.origin !== undefined) {
8727
+ this.assertImportedSessionState(stored);
8728
+ return false;
8729
+ }
8400
8730
  const beforeProjection = JSON.stringify({
8401
8731
  messages: stored.messages,
8402
8732
  stagedTerminals: stored.stagedTerminals,
@@ -9916,6 +10246,9 @@ export class FlexHarness<TScope = unknown> {
9916
10246
  return;
9917
10247
  }
9918
10248
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
10249
+ for (const staged of [...state.sessionImports.values()]) {
10250
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10251
+ }
9919
10252
  const errors: unknown[] = [];
9920
10253
  const listingSettlement = this.abortSlashCommandListings(storageKey, reason);
9921
10254
  if (listingSettlement) await listingSettlement;
@@ -10048,6 +10381,7 @@ export class FlexHarness<TScope = unknown> {
10048
10381
  state.pendingPermissions.clear();
10049
10382
  state.initializingSessions.clear();
10050
10383
  state.sessionInitializations.clear();
10384
+ state.sessionImports.clear();
10051
10385
  }
10052
10386
 
10053
10387
  private async disposeInternal(): Promise<void> {
@@ -10388,6 +10722,138 @@ export class FlexHarness<TScope = unknown> {
10388
10722
  return stored;
10389
10723
  }
10390
10724
 
10725
+ /** Refuses an operation that would change an imported session's history or model context. */
10726
+ private assertSessionWritable(stored: IStoredSessionState): void {
10727
+ if (stored.session.origin !== undefined) {
10728
+ throw this.trustInternalError(new FlexHarnessImportedSessionError(stored.session.sessionId));
10729
+ }
10730
+ }
10731
+
10732
+ /** An imported session never ran here, so it has no hidden stage and no reversion state. */
10733
+ private assertImportedSessionState(stored: IStoredSessionState): void {
10734
+ if (
10735
+ stored.stagedTerminals.length > 0
10736
+ || stored.reversionSegments.length > 0
10737
+ || stored.excludedRunIds.length > 0
10738
+ || stored.pendingReversion !== undefined
10739
+ || stored.pendingReversionReleases.length > 0
10740
+ || stored.messages.some((message) => message.status === 'streaming')
10741
+ ) {
10742
+ throw new FlexHarnessValidationError(
10743
+ `Imported session "${stored.session.sessionId}" has run state it cannot have.`,
10744
+ );
10745
+ }
10746
+ }
10747
+
10748
+ private assertNoCommittingSessionImport(state: IStorageState, sessionId: string): void {
10749
+ if (state.sessionImports.get(sessionId)?.phase === 'committing') {
10750
+ throw new FlexHarnessSessionBusyError(sessionId, 'is being imported');
10751
+ }
10752
+ }
10753
+
10754
+ private findStagedSessionImport(
10755
+ handle: IFlexSessionImportHandle,
10756
+ ): IStagedSessionImport | undefined {
10757
+ const validated = validateSessionImportHandle(handle);
10758
+ const staged = this.stagedSessionImports.get(validated.importId);
10759
+ if (
10760
+ staged
10761
+ && (staged.scopeId !== validated.scopeId || staged.sessionId !== validated.sessionId)
10762
+ ) {
10763
+ throw new FlexHarnessValidationError('The session import handle does not match its import.');
10764
+ }
10765
+ return staged;
10766
+ }
10767
+
10768
+ private requireStagedSessionImport(handle: IFlexSessionImportHandle): IStagedSessionImport {
10769
+ const staged = this.findStagedSessionImport(handle);
10770
+ if (!staged) throw new FlexHarnessNotFoundError('Session import', handle.importId);
10771
+ if (staged.phase !== 'staging') {
10772
+ throw new FlexHarnessSessionBusyError(staged.sessionId, 'is being committed');
10773
+ }
10774
+ return staged;
10775
+ }
10776
+
10777
+ private armStagedImportIdleTimer(staged: IStagedSessionImport): void {
10778
+ this.clearStagedImportIdleTimer(staged);
10779
+ const timer = setTimeout(() => {
10780
+ if (staged.idleTimer !== timer) return;
10781
+ delete staged.idleTimer;
10782
+ if (staged.phase === 'staging') this.releaseStagedSessionImport(staged);
10783
+ }, this.sessionImportLimits.stagedImportIdleTimeoutMs);
10784
+ timer.unref();
10785
+ staged.idleTimer = timer;
10786
+ }
10787
+
10788
+ private clearStagedImportIdleTimer(staged: IStagedSessionImport): void {
10789
+ if (staged.idleTimer === undefined) return;
10790
+ clearTimeout(staged.idleTimer);
10791
+ delete staged.idleTimer;
10792
+ }
10793
+
10794
+ private releaseStagedSessionImport(staged: IStagedSessionImport): void {
10795
+ this.clearStagedImportIdleTimer(staged);
10796
+ if (this.stagedSessionImports.get(staged.importId) === staged) {
10797
+ this.stagedSessionImports.delete(staged.importId);
10798
+ }
10799
+ if (staged.state.sessionImports.get(staged.sessionId) === staged) {
10800
+ staged.state.sessionImports.delete(staged.sessionId);
10801
+ staged.state.initializingSessions.delete(staged.sessionId);
10802
+ }
10803
+ staged.runs = [];
10804
+ staged.runIds.clear();
10805
+ staged.messageIds.clear();
10806
+ }
10807
+
10808
+ /**
10809
+ * An imported child session belongs to a committed imported parent whose run `parent.runId` holds
10810
+ * the tool call `parent.toolCallId` naming the child, and no other session claims that tool call.
10811
+ */
10812
+ private requireImportParent(
10813
+ state: IStorageState,
10814
+ sessionId: string,
10815
+ parent: NonNullable<INormalizedSessionImportOptions['parent']>,
10816
+ ): IStoredSessionState {
10817
+ const parentStored = state.initializingSessions.has(parent.sessionId)
10818
+ || state.tombstones.has(parent.sessionId)
10819
+ ? undefined
10820
+ : state.sessions.get(parent.sessionId);
10821
+ if (!parentStored?.session.origin) {
10822
+ throw this.trustInternalError(new FlexHarnessValidationError(
10823
+ `The parent of imported session "${sessionId}" must be a committed imported session.`,
10824
+ ));
10825
+ }
10826
+ if ((parentStored.session.depth ?? 0) + 1 > FLEX_SESSION_IMPORT_MAX_DEPTH) {
10827
+ throw this.trustInternalError(new FlexHarnessValidationError(
10828
+ `Imported sessions nest at most ${FLEX_SESSION_IMPORT_MAX_DEPTH} levels deep.`,
10829
+ ));
10830
+ }
10831
+ const referenced = parentStored.messages.some((message) =>
10832
+ message.role === 'assistant'
10833
+ && message.runId === parent.runId
10834
+ && message.parts.some((part) =>
10835
+ part.type === 'tool'
10836
+ && part.toolCallId === parent.toolCallId
10837
+ && part.childSessionId === sessionId));
10838
+ if (!referenced) {
10839
+ throw this.trustInternalError(new FlexHarnessValidationError(
10840
+ `Parent run "${parent.runId}" has no tool call "${parent.toolCallId}" whose childSessionId is "${sessionId}".`,
10841
+ ));
10842
+ }
10843
+ for (const stored of state.sessions.values()) {
10844
+ if (
10845
+ stored.session.parentSessionId === parent.sessionId
10846
+ && stored.session.parentRunId === parent.runId
10847
+ && stored.session.parentToolCallId === parent.toolCallId
10848
+ ) {
10849
+ throw this.trustInternalError(new FlexHarnessValidationError(
10850
+ `Session "${stored.session.sessionId}" already belongs to tool call "${parent.toolCallId}".`,
10851
+ ));
10852
+ }
10853
+ }
10854
+ return parentStored;
10855
+ }
10856
+
10391
10857
  private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
10392
10858
  const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
10393
10859
  if (!message) throw new FlexHarnessNotFoundError('Message', messageId);