@agentick/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +270 -64
  2. package/dist/.tsbuildinfo.build +1 -1
  3. package/dist/agentick-instance.d.ts.map +1 -1
  4. package/dist/agentick-instance.js +125 -119
  5. package/dist/agentick-instance.js.map +1 -1
  6. package/dist/app/session-store.d.ts +1 -1
  7. package/dist/app/session-store.js +1 -1
  8. package/dist/app/session.d.ts +26 -17
  9. package/dist/app/session.d.ts.map +1 -1
  10. package/dist/app/session.js +222 -204
  11. package/dist/app/session.js.map +1 -1
  12. package/dist/app/types.d.ts +230 -149
  13. package/dist/app/types.d.ts.map +1 -1
  14. package/dist/com/object-model.d.ts +7 -4
  15. package/dist/com/object-model.d.ts.map +1 -1
  16. package/dist/com/object-model.js +13 -4
  17. package/dist/com/object-model.js.map +1 -1
  18. package/dist/compiler/collector.d.ts +1 -1
  19. package/dist/compiler/collector.js +1 -1
  20. package/dist/compiler/fiber-compiler.d.ts +16 -30
  21. package/dist/compiler/fiber-compiler.d.ts.map +1 -1
  22. package/dist/compiler/fiber-compiler.js +32 -72
  23. package/dist/compiler/fiber-compiler.js.map +1 -1
  24. package/dist/compiler/index.d.ts +1 -1
  25. package/dist/compiler/index.js +1 -1
  26. package/dist/compiler/scheduler.d.ts +3 -3
  27. package/dist/compiler/scheduler.js +4 -4
  28. package/dist/compiler/scheduler.js.map +1 -1
  29. package/dist/component/component.d.ts +6 -6
  30. package/dist/component/component.d.ts.map +1 -1
  31. package/dist/hooks/com-state.d.ts +18 -4
  32. package/dist/hooks/com-state.d.ts.map +1 -1
  33. package/dist/hooks/com-state.js +44 -15
  34. package/dist/hooks/com-state.js.map +1 -1
  35. package/dist/hooks/context-info.d.ts +2 -35
  36. package/dist/hooks/context-info.d.ts.map +1 -1
  37. package/dist/hooks/context-info.js +8 -0
  38. package/dist/hooks/context-info.js.map +1 -1
  39. package/dist/hooks/context.d.ts +2 -3
  40. package/dist/hooks/context.d.ts.map +1 -1
  41. package/dist/hooks/context.js +2 -3
  42. package/dist/hooks/context.js.map +1 -1
  43. package/dist/hooks/data.d.ts +19 -2
  44. package/dist/hooks/data.d.ts.map +1 -1
  45. package/dist/hooks/data.js +14 -3
  46. package/dist/hooks/data.js.map +1 -1
  47. package/dist/hooks/formatter-context.d.ts +1 -2
  48. package/dist/hooks/formatter-context.d.ts.map +1 -1
  49. package/dist/hooks/formatter-context.js +1 -2
  50. package/dist/hooks/formatter-context.js.map +1 -1
  51. package/dist/hooks/index.d.ts +6 -4
  52. package/dist/hooks/index.d.ts.map +1 -1
  53. package/dist/hooks/index.js +6 -2
  54. package/dist/hooks/index.js.map +1 -1
  55. package/dist/hooks/message-context.d.ts +1 -1
  56. package/dist/hooks/message-context.js +1 -1
  57. package/dist/hooks/resolved.d.ts +2 -0
  58. package/dist/hooks/resolved.d.ts.map +1 -0
  59. package/dist/hooks/resolved.js +6 -0
  60. package/dist/hooks/resolved.js.map +1 -0
  61. package/dist/hooks/runtime-context.d.ts +46 -1
  62. package/dist/hooks/runtime-context.d.ts.map +1 -1
  63. package/dist/hooks/runtime-context.js +36 -1
  64. package/dist/hooks/runtime-context.js.map +1 -1
  65. package/dist/hooks/timeline.d.ts +10 -0
  66. package/dist/hooks/timeline.d.ts.map +1 -0
  67. package/dist/hooks/timeline.js +13 -0
  68. package/dist/hooks/timeline.js.map +1 -0
  69. package/dist/hooks/types.d.ts +1 -1
  70. package/dist/hooks/types.js +1 -1
  71. package/dist/index.d.ts +2 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +8 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/jsx/components/timeline.d.ts.map +1 -1
  76. package/dist/jsx/components/timeline.js +11 -11
  77. package/dist/jsx/components/timeline.js.map +1 -1
  78. package/dist/jsx/jsx-runtime.d.ts +1 -3
  79. package/dist/jsx/jsx-runtime.d.ts.map +1 -1
  80. package/dist/local-transport.d.ts +31 -0
  81. package/dist/local-transport.d.ts.map +1 -0
  82. package/dist/local-transport.js +119 -0
  83. package/dist/local-transport.js.map +1 -0
  84. package/dist/model/model.d.ts +0 -2
  85. package/dist/model/model.d.ts.map +1 -1
  86. package/dist/model/model.js.map +1 -1
  87. package/dist/procedure/index.d.ts.map +1 -1
  88. package/dist/reconciler/host-config.d.ts +6 -5
  89. package/dist/reconciler/host-config.d.ts.map +1 -1
  90. package/dist/reconciler/host-config.js +56 -27
  91. package/dist/reconciler/host-config.js.map +1 -1
  92. package/dist/reconciler/index.d.ts +1 -1
  93. package/dist/reconciler/index.js +1 -1
  94. package/dist/reconciler/reconciler.d.ts +12 -11
  95. package/dist/reconciler/reconciler.d.ts.map +1 -1
  96. package/dist/reconciler/reconciler.js +23 -22
  97. package/dist/reconciler/reconciler.js.map +1 -1
  98. package/dist/reconciler/types.d.ts +2 -8
  99. package/dist/reconciler/types.d.ts.map +1 -1
  100. package/dist/reconciler/types.js +2 -2
  101. package/dist/reconciler/types.js.map +1 -1
  102. package/dist/renderers/types.d.ts +1 -1
  103. package/dist/renderers/types.js +1 -1
  104. package/dist/testing/act.d.ts.map +1 -1
  105. package/dist/testing/act.js +2 -3
  106. package/dist/testing/act.js.map +1 -1
  107. package/dist/testing/index.d.ts +2 -0
  108. package/dist/testing/index.d.ts.map +1 -1
  109. package/dist/testing/index.js +2 -0
  110. package/dist/testing/index.js.map +1 -1
  111. package/dist/testing/mock-app.d.ts.map +1 -1
  112. package/dist/testing/mock-app.js +5 -15
  113. package/dist/testing/mock-app.js.map +1 -1
  114. package/dist/testing/mocks.d.ts +2 -3
  115. package/dist/testing/mocks.d.ts.map +1 -1
  116. package/dist/testing/mocks.js +2 -3
  117. package/dist/testing/mocks.js.map +1 -1
  118. package/dist/testing/render-agent.d.ts +1 -1
  119. package/dist/testing/render-agent.d.ts.map +1 -1
  120. package/dist/testing/render-agent.js +5 -5
  121. package/dist/testing/render-agent.js.map +1 -1
  122. package/dist/testing/test-environment.d.ts +122 -0
  123. package/dist/testing/test-environment.d.ts.map +1 -0
  124. package/dist/testing/test-environment.js +126 -0
  125. package/dist/testing/test-environment.js.map +1 -0
  126. package/package.json +15 -15
  127. package/dist/hibernation/index.d.ts +0 -126
  128. package/dist/hibernation/index.d.ts.map +0 -1
  129. package/dist/hibernation/index.js +0 -127
  130. package/dist/hibernation/index.js.map +0 -1
@@ -58,18 +58,17 @@ export class SessionImpl extends EventEmitter {
58
58
  _tick = 1;
59
59
  _isAborted = false;
60
60
  _currentExecutionId = null;
61
- // Hydration state (pending fiber tree data to restore)
62
- _pendingHydrationData = null;
63
61
  // Compilation infrastructure (no intermediate layer)
64
62
  compiler = null;
65
63
  ctx = null;
66
64
  structureRenderer = null;
67
65
  scheduler = null;
68
- // State that persists across ticks
69
- _previousOutput = null;
66
+ // Last completed tick's compiled output. Used only by inspect().lastOutput.
67
+ _lastCompleteOutput = null;
70
68
  _currentOutput = null;
71
- // Track timeline sent to model (for combining with response in complete())
72
- _lastSentTimeline = [];
69
+ // Session-owned timeline (source of truth, append-only)
70
+ _timeline = [];
71
+ _maxTimelineEntries;
73
72
  // Estimated context tokens from last compilation (pre-model-call)
74
73
  _estimatedContextTokens;
75
74
  // Track last published timeline length for delta publishing
@@ -113,8 +112,12 @@ export class SessionImpl extends EventEmitter {
113
112
  _currentHandle = null;
114
113
  _currentResultResolve = null;
115
114
  _currentResultReject = null;
116
- // Hibernate callback (set by App when registering session)
117
- _hibernateCallback = null;
115
+ // Auto-persist callback (set by App when store is configured)
116
+ _persistCallback = null;
117
+ // Snapshot for resolve (set when restoring from store)
118
+ _snapshotForResolve = null;
119
+ // Execution environment initialization tracking
120
+ _environmentInitialized = false;
118
121
  // Spawn hierarchy
119
122
  _parent = null;
120
123
  _children = [];
@@ -153,21 +156,10 @@ export class SessionImpl extends EventEmitter {
153
156
  });
154
157
  }
155
158
  }
156
- // Hydrate from snapshot if provided
157
- if (sessionOptions.snapshot) {
158
- this.hydrate(sessionOptions.snapshot);
159
- }
160
- // Seed initial timeline if provided
161
- if (sessionOptions.initialTimeline?.length) {
162
- this._previousOutput = {
163
- timeline: sessionOptions.initialTimeline,
164
- system: [],
165
- ephemeral: [],
166
- sections: {},
167
- tools: [],
168
- metadata: {},
169
- };
170
- }
159
+ // Read maxTimelineEntries from appOptions
160
+ this._maxTimelineEntries = appOptions.maxTimelineEntries;
161
+ // Note: snapshot/initialTimeline hydration now handled via _snapshotForResolve
162
+ // set by App.createSessionFromSnapshot() — applied in ensureCompilationInfrastructure()
171
163
  // Start recording if enabled via options
172
164
  if (sessionOptions.recording) {
173
165
  this.startRecording(sessionOptions.recording);
@@ -181,6 +173,14 @@ export class SessionImpl extends EventEmitter {
181
173
  get status() {
182
174
  return this._status;
183
175
  }
176
+ /** Whether the session is in a terminal state (closed). */
177
+ get isTerminal() {
178
+ return this._status === "closed";
179
+ }
180
+ /** Error message for operations attempted on a terminal session. */
181
+ get terminalError() {
182
+ return "Session is closed";
183
+ }
184
184
  get currentTick() {
185
185
  return this._tick;
186
186
  }
@@ -233,8 +233,8 @@ export class SessionImpl extends EventEmitter {
233
233
  handleFactory: false,
234
234
  executionBoundary: false,
235
235
  }, async (message) => {
236
- if (this._status === "closed") {
237
- throw new Error("Session is closed");
236
+ if (this.isTerminal) {
237
+ throw new Error(this.terminalError);
238
238
  }
239
239
  const messageWithId = ensureMessageId(message);
240
240
  this._queuedMessages.push(messageWithId);
@@ -254,6 +254,7 @@ export class SessionImpl extends EventEmitter {
254
254
  };
255
255
  const tickState = {
256
256
  tick: this._tick,
257
+ timeline: this._timeline,
257
258
  stop: () => { },
258
259
  queuedMessages: [],
259
260
  };
@@ -267,8 +268,8 @@ export class SessionImpl extends EventEmitter {
267
268
  handleFactory: false,
268
269
  executionBoundary: false,
269
270
  }, async (input) => {
270
- if (this._status === "closed") {
271
- throw new Error("Session is closed");
271
+ if (this.isTerminal) {
272
+ throw new Error(this.terminalError);
272
273
  }
273
274
  const { messages = [], props, metadata, maxTicks, signal } = input;
274
275
  // Apply metadata to messages
@@ -296,6 +297,7 @@ export class SessionImpl extends EventEmitter {
296
297
  };
297
298
  const tickState = {
298
299
  tick: this._tick,
300
+ timeline: this._timeline,
299
301
  stop: () => { },
300
302
  queuedMessages: [],
301
303
  };
@@ -330,8 +332,8 @@ export class SessionImpl extends EventEmitter {
330
332
  handleFactory: false,
331
333
  executionBoundary: false,
332
334
  }, (props, options) => {
333
- if (this._status === "closed") {
334
- throw new Error("Session is closed");
335
+ if (this.isTerminal) {
336
+ throw new Error(this.terminalError);
335
337
  }
336
338
  // Props is explicitly provided (even if empty object) - always run tick
337
339
  // Only skip if props is undefined/null AND no queued messages
@@ -357,25 +359,32 @@ export class SessionImpl extends EventEmitter {
357
359
  metadata: { operation: "spawn" },
358
360
  handleFactory: false,
359
361
  executionBoundary: false,
360
- }, async (component, input = {}) => {
361
- if (this._status === "closed") {
362
- throw new Error("Session is closed");
362
+ }, async (component, input, spawnOptions) => {
363
+ if (this.isTerminal) {
364
+ throw new Error(this.terminalError);
363
365
  }
364
366
  if (this._spawnDepth >= SessionImpl.MAX_SPAWN_DEPTH) {
365
367
  throw new Error(`Maximum spawn depth (${SessionImpl.MAX_SPAWN_DEPTH}) exceeded`);
366
368
  }
367
369
  // 1. Resolve to ComponentFunction
368
- const { Component, mergedProps } = this.resolveSpawnTarget(component, input);
370
+ const resolvedInput = input ?? {};
371
+ const { Component, mergedProps } = this.resolveSpawnTarget(component, resolvedInput);
369
372
  // 2. Create child SessionImpl (ephemeral — NOT registered in App's registry)
370
373
  // Whitelist structural fields only — lifecycle callbacks, session management,
371
374
  // signal, and devTools are intentionally excluded. New AppOptions fields
372
375
  // must be explicitly added here if children should inherit them.
376
+ //
377
+ // NOTE: `environment` IS inherited by default. A REPL environment, sandbox,
378
+ // or human-in-the-loop gateway should apply to sub-agents — the execution
379
+ // model is structural, not observational (unlike lifecycle callbacks).
380
+ // Use SpawnOptions to override for specific children.
373
381
  const childAppOptions = {
374
- model: this.appOptions.model,
382
+ model: spawnOptions?.model ?? this.appOptions.model,
375
383
  tools: this.appOptions.tools,
376
384
  mcpServers: this.appOptions.mcpServers,
377
- maxTicks: this.appOptions.maxTicks,
385
+ maxTicks: spawnOptions?.maxTicks ?? this.appOptions.maxTicks,
378
386
  inheritDefaults: this.appOptions.inheritDefaults,
387
+ environment: spawnOptions?.environment ?? this.appOptions.environment,
379
388
  };
380
389
  const childOptions = {
381
390
  signal: this.executionAbortController?.signal,
@@ -387,14 +396,14 @@ export class SessionImpl extends EventEmitter {
387
396
  this._children.push(child);
388
397
  // 3. Delegate to child.send()
389
398
  const handle = await child.send({
390
- ...input,
399
+ ...resolvedInput,
391
400
  props: mergedProps,
392
401
  });
393
402
  // 4. Cleanup on completion
394
403
  handle.result
395
- .finally(() => {
404
+ .finally(async () => {
396
405
  this._children = this._children.filter((c) => c !== child);
397
- child.close();
406
+ await child.close();
398
407
  })
399
408
  .catch(() => { });
400
409
  return handle;
@@ -648,8 +657,8 @@ export class SessionImpl extends EventEmitter {
648
657
  // Interrupt & Abort
649
658
  // ════════════════════════════════════════════════════════════════════════
650
659
  interrupt(message, reason) {
651
- if (this._status === "closed") {
652
- throw new Error("Session is closed");
660
+ if (this.isTerminal) {
661
+ throw new Error(this.terminalError);
653
662
  }
654
663
  if (message) {
655
664
  this._queuedMessages.push(message);
@@ -788,37 +797,29 @@ export class SessionImpl extends EventEmitter {
788
797
  version: "1.0",
789
798
  sessionId: this.id,
790
799
  tick: this._tick,
791
- timeline: this._previousOutput?.timeline ?? null,
792
- componentState: this.serializeFiberTree(), // Serialized fiber tree with hook states
800
+ timeline: [...this._timeline],
801
+ comState: this.compiler
802
+ ? this.compiler.getSerializableComState(this.ctx?.getStateAll() ?? {})
803
+ : {},
804
+ dataCache: this.compiler?.getSerializableDataCache() ?? {},
793
805
  usage: { ...this._totalUsage },
794
806
  timestamp: Date.now(),
795
807
  };
796
808
  }
797
809
  /**
798
- * Set the hibernate callback.
799
- * Called by the App when registering this session.
810
+ * Set the auto-persist callback.
811
+ * Called by the App when a store is configured.
800
812
  * @internal
801
813
  */
802
- setHibernateCallback(callback) {
803
- this._hibernateCallback = callback;
814
+ setPersistCallback(callback) {
815
+ this._persistCallback = callback;
804
816
  }
805
- async hibernate() {
806
- if (this._status === "closed") {
807
- return null;
808
- }
809
- if (this._status === "running") {
810
- // Cannot hibernate while running - abort first
811
- this.interrupt(undefined, "Hibernation requested");
812
- }
813
- // Delegate to the App's hibernate callback if available
814
- if (this._hibernateCallback) {
815
- return this._hibernateCallback();
816
- }
817
- // No callback - just return the snapshot without persisting
818
- // This allows sessions created outside of App management to still hibernate
819
- const snapshot = this.snapshot();
820
- this.close();
821
- return snapshot;
817
+ /**
818
+ * Set a snapshot to be applied/resolved when compilation infrastructure is created.
819
+ * @internal
820
+ */
821
+ setSnapshotForResolve(snapshot) {
822
+ this._snapshotForResolve = snapshot;
822
823
  }
823
824
  inspect() {
824
825
  // Get fiber summary for component/hook stats
@@ -834,7 +835,7 @@ export class SessionImpl extends EventEmitter {
834
835
  queuedMessages: [...this._queuedMessages],
835
836
  currentPhase: this._status === "running" ? "model" : undefined, // Approximate
836
837
  isAborted: this._isAborted,
837
- lastOutput: this._previousOutput,
838
+ lastOutput: this._lastCompleteOutput,
838
839
  lastModelOutput: this._lastModelOutput,
839
840
  lastToolCalls: lastSnapshot?.tools.calls ?? [],
840
841
  lastToolResults: lastSnapshot?.tools.results.map((r) => ({
@@ -1200,16 +1201,23 @@ export class SessionImpl extends EventEmitter {
1200
1201
  }
1201
1202
  }
1202
1203
  // ════════════════════════════════════════════════════════════════════════
1203
- // Close
1204
+ // Close & Teardown
1204
1205
  // ════════════════════════════════════════════════════════════════════════
1205
- close() {
1206
- if (this._status === "closed")
1206
+ async close() {
1207
+ if (this.isTerminal)
1207
1208
  return;
1208
1209
  this._status = "closed";
1209
- // Close all child sessions
1210
- for (const child of [...this._children]) {
1211
- child.close();
1210
+ // Notify execution environment of destroy
1211
+ if (this._environmentInitialized && this.appOptions.environment?.onDestroy) {
1212
+ try {
1213
+ await this.appOptions.environment.onDestroy(this);
1214
+ }
1215
+ catch (err) {
1216
+ this.log.warn({ error: err }, "Environment onDestroy failed");
1217
+ }
1212
1218
  }
1219
+ // Close all child sessions
1220
+ await Promise.all(this._children.map((child) => child.close()));
1213
1221
  this._children = [];
1214
1222
  this.sessionAbortController.abort("Session closed");
1215
1223
  this.executionAbortController?.abort("Session closed");
@@ -1226,7 +1234,12 @@ export class SessionImpl extends EventEmitter {
1226
1234
  }
1227
1235
  // Unmount compiler if it exists
1228
1236
  if (this.compiler) {
1229
- this.compiler.unmount().catch(() => { });
1237
+ try {
1238
+ await this.compiler.unmount();
1239
+ }
1240
+ catch {
1241
+ // Unmount errors during close are non-fatal
1242
+ }
1230
1243
  this.compiler = null;
1231
1244
  }
1232
1245
  this.ctx = null;
@@ -1268,29 +1281,6 @@ export class SessionImpl extends EventEmitter {
1268
1281
  const merged = { ...baseContext, ...(current ?? {}), ...sessionContext };
1269
1282
  return Context.run(merged, fn);
1270
1283
  }
1271
- hydrate(snapshot) {
1272
- this._tick = snapshot.tick;
1273
- // Hydrate timeline (conversation history)
1274
- if (snapshot.timeline) {
1275
- this._previousOutput = {
1276
- timeline: snapshot.timeline,
1277
- system: [],
1278
- ephemeral: [],
1279
- sections: {},
1280
- tools: [],
1281
- metadata: {},
1282
- };
1283
- }
1284
- // Hydrate usage stats
1285
- if (snapshot.usage) {
1286
- this._totalUsage = { ...snapshot.usage };
1287
- }
1288
- // Hydrate fiber tree (component state)
1289
- // This will be applied when the compiler is first created
1290
- if (snapshot.componentState) {
1291
- this._pendingHydrationData = snapshot.componentState;
1292
- }
1293
- }
1294
1284
  /**
1295
1285
  * The core tick execution loop.
1296
1286
  *
@@ -1300,8 +1290,8 @@ export class SessionImpl extends EventEmitter {
1300
1290
  * 3. Return result
1301
1291
  */
1302
1292
  async executeTick(props, options) {
1303
- if (this._status === "closed") {
1304
- throw new Error("Session is closed");
1293
+ if (this.isTerminal) {
1294
+ throw new Error(this.terminalError);
1305
1295
  }
1306
1296
  const signal = this.executionAbortController?.signal ?? this.sessionAbortController.signal;
1307
1297
  if (signal.aborted) {
@@ -1345,7 +1335,7 @@ export class SessionImpl extends EventEmitter {
1345
1335
  let stopReason;
1346
1336
  let output;
1347
1337
  const outputs = {};
1348
- const responseChunks = [];
1338
+ let responseText = "";
1349
1339
  const toolExecutor = new ToolExecutor();
1350
1340
  this.emitEvent({
1351
1341
  type: "execution_start",
@@ -1429,7 +1419,11 @@ export class SessionImpl extends EventEmitter {
1429
1419
  if (signal.aborted) {
1430
1420
  throw new AbortError("Execution aborted", signal.reason);
1431
1421
  }
1432
- const modelInput = compiled.modelInput ?? compiled.formatted;
1422
+ let modelInput = compiled.modelInput ?? compiled.formatted;
1423
+ // Apply execution environment transformation
1424
+ if (this.appOptions.environment?.prepareModelInput) {
1425
+ modelInput = await this.appOptions.environment.prepareModelInput(modelInput, (compiled.tools ?? []));
1426
+ }
1433
1427
  const modelStartTime = Date.now();
1434
1428
  // Emit model request to DevTools
1435
1429
  // modelInput is the Agentick ModelInput format (after fromEngineState transformation)
@@ -1458,17 +1452,12 @@ export class SessionImpl extends EventEmitter {
1458
1452
  // Stream model output if supported
1459
1453
  let modelOutput;
1460
1454
  if (model.stream) {
1461
- const streamEvents = [];
1462
1455
  const streamIterable = await model.stream(modelInput);
1463
1456
  for await (const event of streamIterable) {
1464
1457
  if (signal.aborted) {
1465
1458
  throw new AbortError("Execution aborted", signal.reason);
1466
1459
  }
1467
- streamEvents.push(event);
1468
1460
  this.emitEvent(event);
1469
- if (event.type === "content_delta" && "delta" in event) {
1470
- responseChunks.push(event.delta);
1471
- }
1472
1461
  if (event.type === "message" && "message" in event) {
1473
1462
  const messageEvent = event;
1474
1463
  modelOutput = {
@@ -1480,27 +1469,8 @@ export class SessionImpl extends EventEmitter {
1480
1469
  };
1481
1470
  }
1482
1471
  }
1483
- // Aggregate stream events into final output
1484
1472
  if (!modelOutput) {
1485
- if (model.processStream) {
1486
- // Use model's processStream if available
1487
- modelOutput = await model.processStream(streamEvents);
1488
- }
1489
- else if (responseChunks.length > 0) {
1490
- // Construct from collected chunks
1491
- const text = responseChunks.join("");
1492
- modelOutput = {
1493
- message: {
1494
- role: "assistant",
1495
- content: [{ type: "text", text }],
1496
- },
1497
- stopReason: "stop",
1498
- raw: { streamed: true },
1499
- };
1500
- }
1501
- else {
1502
- throw new Error("Streaming completed but no response was received");
1503
- }
1473
+ throw new Error("Streaming completed but no model output was received");
1504
1474
  }
1505
1475
  }
1506
1476
  else {
@@ -1516,8 +1486,7 @@ export class SessionImpl extends EventEmitter {
1516
1486
  if (modelOutput?.message) {
1517
1487
  const textContent = modelOutput.message.content?.find((b) => b.type === "text");
1518
1488
  if (textContent && "text" in textContent) {
1519
- responseChunks.length = 0;
1520
- responseChunks.push(textContent.text);
1489
+ responseText = textContent.text;
1521
1490
  }
1522
1491
  }
1523
1492
  // Update usage
@@ -1625,9 +1594,9 @@ export class SessionImpl extends EventEmitter {
1625
1594
  // These can call tickResult.stop() or tickResult.continue() to influence continuation
1626
1595
  const tickEndState = {
1627
1596
  tick: currentTick,
1628
- previous: this._previousOutput ?? undefined,
1629
1597
  current: this._currentOutput,
1630
1598
  queuedMessages: [],
1599
+ timeline: this._timeline,
1631
1600
  stop: () => { }, // No-op at tick end - use tickResult.stop() instead
1632
1601
  };
1633
1602
  await this.compiler?.notifyTickEnd(tickEndState, tickResult);
@@ -1666,24 +1635,18 @@ export class SessionImpl extends EventEmitter {
1666
1635
  shouldContinue,
1667
1636
  stopReason,
1668
1637
  });
1669
- // Update _previousOutput after each tick so the next tick has access to the timeline
1670
- // This is critical for <Timeline> to render conversation history on subsequent ticks
1671
1638
  output = await this.complete();
1672
- this._previousOutput = output;
1673
- this.log.debug({ timelineLength: output.timeline?.length ?? 0 }, "Updated _previousOutput after tick");
1639
+ this._lastCompleteOutput = output;
1674
1640
  this._tick++;
1675
1641
  usage.ticks = (usage.ticks ?? 0) + 1;
1676
1642
  }
1677
- // Final complete (may be redundant but ensures consistency)
1678
- output = await this.complete();
1679
- this._previousOutput = output;
1680
1643
  // Accumulate usage
1681
1644
  this._totalUsage.inputTokens += usage.inputTokens;
1682
1645
  this._totalUsage.outputTokens += usage.outputTokens;
1683
1646
  this._totalUsage.totalTokens += usage.totalTokens;
1684
1647
  this._totalUsage.ticks = (this._totalUsage.ticks ?? 0) + (usage.ticks ?? 0);
1685
1648
  const resultPayload = {
1686
- response: responseChunks.join(""),
1649
+ response: responseText,
1687
1650
  outputs,
1688
1651
  usage,
1689
1652
  stopReason,
@@ -1708,6 +1671,16 @@ export class SessionImpl extends EventEmitter {
1708
1671
  output: output ?? null,
1709
1672
  timestamp: timestamp(),
1710
1673
  });
1674
+ // Auto-persist snapshot after successful execution (fire-and-forget, skip on abort)
1675
+ if (this._persistCallback && !this._isAborted) {
1676
+ let snap = this.snapshot();
1677
+ if (this.appOptions.environment?.onPersist) {
1678
+ snap = await this.appOptions.environment.onPersist(this, snap);
1679
+ }
1680
+ this._persistCallback(snap).catch((err) => {
1681
+ this.log.warn({ error: err }, "Auto-persist failed");
1682
+ });
1683
+ }
1711
1684
  // Publish timeline delta to channel for real-time sync across clients
1712
1685
  // Only send NEW messages since last publish - O(delta) not O(n)
1713
1686
  if (output?.timeline) {
@@ -1738,7 +1711,7 @@ export class SessionImpl extends EventEmitter {
1738
1711
  this._status = "idle";
1739
1712
  }
1740
1713
  return {
1741
- response: responseChunks.join(""),
1714
+ response: responseText,
1742
1715
  outputs,
1743
1716
  usage,
1744
1717
  stopReason,
@@ -1759,12 +1732,13 @@ export class SessionImpl extends EventEmitter {
1759
1732
  this.ctx = new COM({
1760
1733
  metadata: {},
1761
1734
  });
1762
- this.compiler = new FiberCompiler(this.ctx, undefined, {});
1763
- // Apply pending hydration data if available
1764
- if (this._pendingHydrationData) {
1765
- this.compiler.setHydrationData(this._pendingHydrationData);
1766
- this._pendingHydrationData = null; // Clear after applying
1767
- }
1735
+ this.compiler = new FiberCompiler(this.ctx);
1736
+ // Wire timeline accessors to RuntimeStore
1737
+ const runtimeStore = this.compiler.getRuntimeStore();
1738
+ runtimeStore.getSessionTimeline = () => this._timeline;
1739
+ runtimeStore.setSessionTimeline = (entries) => {
1740
+ this._timeline = [...entries];
1741
+ };
1768
1742
  // Create scheduler and wire it to the compiler
1769
1743
  // This enables the reactive model: state changes between ticks trigger reconciliation
1770
1744
  this.scheduler = new ReconciliationScheduler(this.compiler, {
@@ -1793,6 +1767,75 @@ export class SessionImpl extends EventEmitter {
1793
1767
  }
1794
1768
  // Notify compiler that compilation is starting
1795
1769
  await this.compiler.notifyStart();
1770
+ // Apply snapshot-for-resolve if set (restore from store)
1771
+ if (this._snapshotForResolve) {
1772
+ try {
1773
+ const resolveConfig = this.appOptions.resolve;
1774
+ if (resolveConfig) {
1775
+ // Layer 2: resolve controls reconstruction
1776
+ const ctx = { sessionId: this.id, snapshot: this._snapshotForResolve };
1777
+ const resolved = await this.executeResolve(resolveConfig, ctx);
1778
+ runtimeStore.resolvedData = resolved;
1779
+ }
1780
+ else {
1781
+ // Layer 1: auto-apply snapshot
1782
+ const snap = this._snapshotForResolve;
1783
+ this._timeline = [...(snap.timeline ?? [])];
1784
+ this._tick = snap.tick;
1785
+ if (snap.usage)
1786
+ this._totalUsage = { ...snap.usage };
1787
+ if (snap.comState && Object.keys(snap.comState).length > 0) {
1788
+ this.ctx.setStatePartial(snap.comState);
1789
+ }
1790
+ if (snap.dataCache && Object.keys(snap.dataCache).length > 0) {
1791
+ this.compiler.setDataCache(snap.dataCache);
1792
+ }
1793
+ }
1794
+ // Notify execution environment of restore
1795
+ if (this.appOptions.environment?.onRestore) {
1796
+ await this.appOptions.environment.onRestore(this, this._snapshotForResolve);
1797
+ }
1798
+ }
1799
+ finally {
1800
+ this._snapshotForResolve = null;
1801
+ }
1802
+ }
1803
+ // Initialize execution environment (once per session lifecycle)
1804
+ if (!this._environmentInitialized) {
1805
+ if (this.appOptions.environment?.onSessionInit) {
1806
+ await this.appOptions.environment.onSessionInit(this);
1807
+ }
1808
+ this._environmentInitialized = true;
1809
+ }
1810
+ }
1811
+ /**
1812
+ * Execute resolve configuration and return results.
1813
+ */
1814
+ async executeResolve(config, ctx) {
1815
+ if (typeof config === "function") {
1816
+ try {
1817
+ return await config(ctx);
1818
+ }
1819
+ catch (err) {
1820
+ throw new Error(`resolve function failed: ${err instanceof Error ? err.message : String(err)}`);
1821
+ }
1822
+ }
1823
+ // Object form: resolve each entry
1824
+ const results = {};
1825
+ for (const [key, value] of Object.entries(config)) {
1826
+ if (typeof value === "function") {
1827
+ try {
1828
+ results[key] = await value(ctx);
1829
+ }
1830
+ catch (err) {
1831
+ throw new Error(`resolve["${key}"] failed: ${err instanceof Error ? err.message : String(err)}`);
1832
+ }
1833
+ }
1834
+ else {
1835
+ results[key] = value;
1836
+ }
1837
+ }
1838
+ return results;
1796
1839
  }
1797
1840
  /**
1798
1841
  * Compile a single tick.
@@ -1820,9 +1863,9 @@ export class SessionImpl extends EventEmitter {
1820
1863
  // Prepare tick state
1821
1864
  const tickState = {
1822
1865
  tick: this._tick,
1823
- previous: this._previousOutput ?? undefined,
1824
1866
  current: this._currentOutput,
1825
1867
  queuedMessages: queuedMessages,
1868
+ timeline: [...this._timeline],
1826
1869
  stop: (reason) => {
1827
1870
  tickState.stopReason = reason;
1828
1871
  },
@@ -1847,21 +1890,16 @@ export class SessionImpl extends EventEmitter {
1847
1890
  // Enter tick mode - scheduler will defer reconciliations until exitTick
1848
1891
  this.scheduler?.enterTick();
1849
1892
  let compiled;
1850
- const wasHydrating = this.compiler.isHydratingNow();
1851
1893
  try {
1852
1894
  // Compile until stable
1853
1895
  // Note: tickControl and getChannel are available for future use
1854
- // but not currently used by the v2 FiberCompiler
1896
+ // but not currently used by FiberCompiler
1855
1897
  void tickControl;
1856
1898
  void getChannel;
1857
1899
  const result = await this.compiler.compileUntilStable(rootElement, tickState, {
1858
1900
  maxIterations: 50,
1859
1901
  });
1860
1902
  compiled = result.compiled;
1861
- // Complete hydration after first successful compile
1862
- if (wasHydrating) {
1863
- this.compiler.completeHydration();
1864
- }
1865
1903
  }
1866
1904
  finally {
1867
1905
  // Exit tick mode - any pending reconciliations will now flush
@@ -1872,8 +1910,6 @@ export class SessionImpl extends EventEmitter {
1872
1910
  // Format input - compiled structure IS the complete projection
1873
1911
  // JSX components render history as <Message>, so compiled.timelineEntries is complete
1874
1912
  const formatted = await this.structureRenderer.formatInput(this.ctx.toInput());
1875
- // Track what we're sending to the model (for combining with response in complete())
1876
- this._lastSentTimeline = formatted.timeline ?? [];
1877
1913
  // Track estimated context tokens for contextInfo
1878
1914
  this._estimatedContextTokens = formatted.totalTokens;
1879
1915
  // Get model from COM if not in options
@@ -1922,17 +1958,28 @@ export class SessionImpl extends EventEmitter {
1922
1958
  });
1923
1959
  continue;
1924
1960
  }
1925
- // Execute tool
1961
+ // Execute tool (optionally wrapped by execution environment)
1926
1962
  try {
1927
- const result = await executor.processToolWithConfirmation(call, this.ctx, executableTools);
1928
- results.push(result.result);
1963
+ const env = this.appOptions.environment;
1964
+ let toolResult;
1965
+ if (env?.executeToolCall) {
1966
+ toolResult = await env.executeToolCall(call, tool, async () => {
1967
+ const r = await executor.processToolWithConfirmation(call, this.ctx, executableTools);
1968
+ return r.result;
1969
+ });
1970
+ }
1971
+ else {
1972
+ const r = await executor.processToolWithConfirmation(call, this.ctx, executableTools);
1973
+ toolResult = r.result;
1974
+ }
1975
+ results.push(toolResult);
1929
1976
  const completedAt = timestamp();
1930
1977
  this.emitEvent({
1931
1978
  type: "tool_result",
1932
- callId: result.result.toolUseId,
1933
- name: result.result.name,
1934
- result: result.result,
1935
- isError: !result.result.success,
1979
+ callId: toolResult.toolUseId,
1980
+ name: toolResult.name,
1981
+ result: toolResult,
1982
+ isError: !toolResult.success,
1936
1983
  executedBy: "engine",
1937
1984
  startedAt,
1938
1985
  completedAt,
@@ -1987,9 +2034,11 @@ export class SessionImpl extends EventEmitter {
1987
2034
  const userEntries = [];
1988
2035
  const newUserEntries = [];
1989
2036
  for (const queued of queuedMessages) {
1990
- if (queued.type !== "user" || !queued.content)
2037
+ if (!queued.content)
1991
2038
  continue;
1992
2039
  const message = queued.content;
2040
+ if (message.role !== "user")
2041
+ continue;
1993
2042
  const existing = existingByMessage.get(message);
1994
2043
  if (existing) {
1995
2044
  userEntries.push(existing);
@@ -2029,17 +2078,19 @@ export class SessionImpl extends EventEmitter {
2029
2078
  toolCalls: response.toolCalls,
2030
2079
  toolResults,
2031
2080
  };
2032
- // Add entries to COM - user entries first, then assistant response
2081
+ // Add entries to COM and session timeline - user entries first, then assistant response
2033
2082
  for (const entry of newUserEntries) {
2034
2083
  this.ctx.addTimelineEntry(entry);
2084
+ this._timeline.push(entry);
2035
2085
  }
2036
2086
  if (response.newTimelineEntries) {
2037
2087
  for (const entry of response.newTimelineEntries) {
2038
2088
  this.ctx.addTimelineEntry(entry);
2089
+ this._timeline.push(entry);
2039
2090
  }
2040
2091
  }
2041
2092
  if (toolResults.length > 0) {
2042
- this.ctx.addTimelineEntry({
2093
+ const toolResultEntry = {
2043
2094
  kind: "message",
2044
2095
  message: {
2045
2096
  role: "tool",
@@ -2052,7 +2103,15 @@ export class SessionImpl extends EventEmitter {
2052
2103
  })),
2053
2104
  },
2054
2105
  tags: ["tool_output"],
2055
- });
2106
+ };
2107
+ this.ctx.addTimelineEntry(toolResultEntry);
2108
+ this._timeline.push(toolResultEntry);
2109
+ }
2110
+ // Apply maxTimelineEntries trim
2111
+ if (this._maxTimelineEntries && this._timeline.length > this._maxTimelineEntries) {
2112
+ const removed = this._timeline.length - this._maxTimelineEntries;
2113
+ this._timeline = this._timeline.slice(-this._maxTimelineEntries);
2114
+ this.log.debug({ removed, remaining: this._timeline.length }, "Timeline trimmed (maxTimelineEntries)");
2056
2115
  }
2057
2116
  this._currentOutput = current;
2058
2117
  // Resolve tick control
@@ -2067,58 +2126,17 @@ export class SessionImpl extends EventEmitter {
2067
2126
  /**
2068
2127
  * Complete execution and return final state.
2069
2128
  *
2070
- * Returns the complete timeline for this execution:
2071
- * - What was sent to the model (from compiled JSX via _lastSentTimeline)
2072
- * - What the model responded (from _currentOutput.timeline - only NEW entries)
2073
- *
2074
- * This ensures the full conversation history is preserved across executions.
2129
+ * Session._timeline is the source of truth. No merge/dedup logic needed.
2075
2130
  */
2076
2131
  async complete() {
2077
2132
  if (!this.ctx || !this.structureRenderer || !this.compiler) {
2078
2133
  throw new Error("Compilation infrastructure not initialized");
2079
2134
  }
2080
- // Build complete timeline:
2081
- // 1. _lastSentTimeline = what was sent to model (includes history rendered by JSX)
2082
- // 2. _currentOutput.timeline = new entries from this tick (user input + model response + tool results)
2083
- //
2084
- // Only USER messages might overlap (rendered via JSX AND in queuedMessages).
2085
- // Model responses (assistant/tool) are ALWAYS new and should NEVER be deduplicated.
2086
- //
2087
- // NOTE: We use content-based comparison for user messages because <Message {...entry.message}>
2088
- // creates NEW message objects when rendering history, so reference equality fails.
2089
- const sentTimeline = this._lastSentTimeline ?? [];
2090
- const currentTimeline = this._currentOutput?.timeline ?? [];
2091
- // Create a signature for user messages only
2092
- const userMessageSignature = (entry) => {
2093
- if (!entry.message || entry.message.role !== "user")
2094
- return null;
2095
- const m = entry.message;
2096
- if (m.id)
2097
- return `id:${m.id}`;
2098
- const contentStr = JSON.stringify(m.content);
2099
- return `user:${contentStr}`;
2100
- };
2101
- // Only track signatures of USER messages that were sent
2102
- const sentUserSignatures = new Set(sentTimeline.map(userMessageSignature).filter((s) => s !== null));
2103
- // Filter currentTimeline:
2104
- // - USER messages: dedupe against sentTimeline (might be rendered via JSX)
2105
- // - Other roles (assistant, tool): always include (they're new from this tick)
2106
- const newEntries = currentTimeline.filter((entry) => {
2107
- if (!entry.message)
2108
- return true; // Non-message entries always included
2109
- if (entry.message.role !== "user")
2110
- return true; // Assistant/tool always included
2111
- // User message: check for duplicates
2112
- const sig = userMessageSignature(entry);
2113
- return sig === null || !sentUserSignatures.has(sig);
2114
- });
2115
- const timeline = [...sentTimeline, ...newEntries];
2116
2135
  const comOutput = this.ctx.toInput();
2117
2136
  const finalOutput = {
2118
2137
  ...comOutput,
2119
- timeline,
2138
+ timeline: [...this._timeline],
2120
2139
  };
2121
- // Notify compiler of completion
2122
2140
  try {
2123
2141
  await this.compiler.notifyComplete(finalOutput);
2124
2142
  }