@junejs/server 1.0.0-dev.13 → 1.0.0-dev.14

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.
@@ -39,6 +39,7 @@ declare class DoSessionStore implements SessionStore {
39
39
  getSessionKey(): string | undefined;
40
40
  setSessionKey(key: string): void;
41
41
  tx<T>(fn: () => T): T;
42
+ reset(): number;
42
43
  unwrap<H = unknown>(): H;
43
44
  }
44
45
  type DoAgentDef = {
@@ -88,6 +89,12 @@ declare class AgentDurableObject {
88
89
  }): Promise<{
89
90
  turnId: string;
90
91
  }>;
92
+ reset(opts?: {
93
+ session?: string;
94
+ }): Promise<{
95
+ previousSession: string;
96
+ generation: number;
97
+ }>;
91
98
  transcript(): import("@junejs/core/agent-runtime").Turn[];
92
99
  fetch(req: Request): Promise<Response>;
93
100
  }
@@ -52,6 +52,22 @@ var DoSessionStore = class {
52
52
  tx(fn) {
53
53
  return this.storage.transactionSync(fn);
54
54
  }
55
+ reset() {
56
+ return this.storage.transactionSync(() => {
57
+ const sql = this.sql;
58
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
59
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
60
+ const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
61
+ const generation = rows.length ? Number(rows[0].v) : 0;
62
+ sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
63
+ sql.exec("DELETE FROM agent_messages");
64
+ sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
65
+ sql.exec("DELETE FROM agent_steps");
66
+ this.setStatus("new");
67
+ sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
68
+ return generation;
69
+ });
70
+ }
55
71
  unwrap() {
56
72
  return this.sql;
57
73
  }
@@ -160,6 +176,9 @@ var AgentDurableObject = class {
160
176
  services: this.services
161
177
  }, () => session.start(input));
162
178
  }
179
+ reset(opts) {
180
+ return this.resolveSession(opts?.session).reset();
181
+ }
163
182
  transcript() {
164
183
  if (this.session) return this.session.transcript();
165
184
  if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
@@ -171,6 +190,7 @@ var AgentDurableObject = class {
171
190
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
172
191
  const { userText, turnId, event, trigger } = await req.json();
173
192
  await ensureScope();
193
+ const replace = url.searchParams.get("replace") === "1";
174
194
  const wantsDeliver = url.searchParams.get("deliver") === "1";
175
195
  let deliverChannel;
176
196
  if (wantsDeliver) {
@@ -189,7 +209,8 @@ var AgentDurableObject = class {
189
209
  userText,
190
210
  turnId,
191
211
  event,
192
- trigger
212
+ trigger,
213
+ replace
193
214
  }));
194
215
  } catch (err) {
195
216
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
@@ -258,6 +279,19 @@ var AgentDurableObject = class {
258
279
  }
259
280
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
260
281
  }
282
+ if (req.method === "POST" && url.pathname.endsWith("/reset")) {
283
+ let session;
284
+ try {
285
+ session = this.resolveSession(key);
286
+ } catch (err) {
287
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
288
+ }
289
+ try {
290
+ return Response.json(await session.reset());
291
+ } catch (err) {
292
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
293
+ }
294
+ }
261
295
  if (url.pathname.endsWith("/transcript")) {
262
296
  if (key !== void 0) try {
263
297
  return Response.json({ transcript: this.resolveSession(key).transcript() });
@@ -266,7 +300,7 @@ var AgentDurableObject = class {
266
300
  }
267
301
  return Response.json({ transcript: this.transcript() });
268
302
  }
269
- return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
303
+ return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
270
304
  }
271
305
  };
272
306
  const SSE_HEADERS = {
@@ -292,7 +326,7 @@ function sseTurnStream(session, turnId) {
292
326
  }, 2e4);
293
327
  unsub = session.observe((e) => {
294
328
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
295
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") {
329
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") {
296
330
  stop();
297
331
  controller.close();
298
332
  }
@@ -309,7 +343,7 @@ function observeTurnEvents(session, turnId) {
309
343
  let notify;
310
344
  const unsub = session.observe((e) => {
311
345
  queue.push(e);
312
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
346
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
313
347
  notify?.();
314
348
  }, { turnId });
315
349
  return { [Symbol.asyncIterator]() {
@@ -366,6 +400,7 @@ async function sseTurnFinalText(res) {
366
400
  const e = JSON.parse(line.slice(5).trim());
367
401
  if (e.type === "turn.completed") return e.text;
368
402
  if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error });
403
+ if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
369
404
  }
370
405
  if (done) break;
371
406
  }
@@ -451,7 +486,7 @@ function durableChannelSurface(getNamespace, opts) {
451
486
  run: async (message, o) => {
452
487
  const namespace = getNamespace();
453
488
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
454
- return sseTurnFinalText(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
489
+ return sseTurnFinalText(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, {
455
490
  method: "POST",
456
491
  body: serializeTurn(message, o)
457
492
  })));
@@ -459,7 +494,7 @@ function durableChannelSurface(getNamespace, opts) {
459
494
  runDetached: async (message, o) => {
460
495
  const namespace = getNamespace();
461
496
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
462
- const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
497
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, {
463
498
  method: "POST",
464
499
  body: serializeTurn(message, o)
465
500
  }));
@@ -472,7 +507,7 @@ function durableChannelSurface(getNamespace, opts) {
472
507
  runDelivered: async (message, o) => {
473
508
  const namespace = getNamespace();
474
509
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
475
- const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?deliver=1", {
510
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, {
476
511
  method: "POST",
477
512
  body: serializeTurn(message, o)
478
513
  }));
@@ -486,7 +521,7 @@ function durableChannelSurface(getNamespace, opts) {
486
521
  runStream: async function* (message, o) {
487
522
  const namespace = getNamespace();
488
523
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
489
- yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
524
+ yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, {
490
525
  method: "POST",
491
526
  body: serializeTurn(message, o)
492
527
  })));
@@ -512,6 +547,16 @@ function durableChannelSurface(getNamespace, opts) {
512
547
  throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
513
548
  }
514
549
  return await res.json();
550
+ },
551
+ resetSession: async (o) => {
552
+ const namespace = getNamespace();
553
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
554
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/reset", { method: "POST" }));
555
+ if (!res.ok) {
556
+ const detail = (await res.text()).slice(0, 200);
557
+ throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
558
+ }
559
+ return await res.json();
515
560
  }
516
561
  });
517
562
  }
@@ -51,6 +51,21 @@ var SqliteSessionStore = class {
51
51
  throw e;
52
52
  }
53
53
  }
54
+ reset() {
55
+ return this.tx(() => {
56
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
57
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
58
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
59
+ const generation = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid)?.generation ?? 0;
60
+ this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
61
+ this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
62
+ this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
63
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
64
+ this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
65
+ this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
66
+ return generation;
67
+ });
68
+ }
54
69
  unwrap() {
55
70
  return this.db;
56
71
  }
@@ -99,6 +114,8 @@ var MemorySessionStore = class {
99
114
  msgs = [];
100
115
  steps = /* @__PURE__ */ new Map();
101
116
  status = "new";
117
+ generation = 0;
118
+ archives = [];
102
119
  appendMessage(m) {
103
120
  this.msgs.push(m);
104
121
  }
@@ -126,6 +143,18 @@ var MemorySessionStore = class {
126
143
  tx(fn) {
127
144
  return fn();
128
145
  }
146
+ reset() {
147
+ const generation = this.generation++;
148
+ this.archives.push({
149
+ generation,
150
+ msgs: this.msgs,
151
+ steps: this.steps
152
+ });
153
+ this.msgs = [];
154
+ this.steps = /* @__PURE__ */ new Map();
155
+ this.status = "new";
156
+ return generation;
157
+ }
129
158
  unwrap() {}
130
159
  };
131
160
  var MemoryRuntime = class {
@@ -166,14 +195,17 @@ function mountAgent(agent, runtime, opts = {}) {
166
195
  turnId: o?.turnId,
167
196
  userText: message,
168
197
  event: o?.event,
169
- trigger: o?.trigger
198
+ trigger: o?.trigger,
199
+ replace: o?.replace
170
200
  }),
171
201
  runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
172
202
  turnId: o?.turnId,
173
203
  userText: message,
174
204
  event: o?.event,
175
- trigger: o?.trigger
176
- })
205
+ trigger: o?.trigger,
206
+ replace: o?.replace
207
+ }),
208
+ resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset()
177
209
  };
178
210
  const channels = channelFetch(agent, ctx);
179
211
  const surface = async (req) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.13",
3
+ "version": "1.0.0-dev.14",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -132,7 +132,7 @@
132
132
  "rolldown": "^1.1.0"
133
133
  },
134
134
  "peerDependencies": {
135
- "@junejs/core": ">=0.2.0-dev.30",
135
+ "@junejs/core": ">=0.2.0-dev.32",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -137,6 +137,28 @@ export class DoSessionStore implements SessionStore {
137
137
  tx<T>(fn: () => T): T {
138
138
  return this.storage.transactionSync(fn);
139
139
  }
140
+ // Session reset (#129): ARCHIVE messages/steps under the current generation (the audit
141
+ // trail — never deleted), clear the live tables, status → "new". The session key in
142
+ // agent_meta is deliberately untouched: the DO's identity survives its history. Archive
143
+ // tables + the generation counter are created lazily, so existing objects stay
144
+ // untouched until their first reset. seq keeps counting across generations (DELETE
145
+ // doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
146
+ reset(): number {
147
+ return this.storage.transactionSync(() => {
148
+ const sql = this.sql;
149
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
150
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
151
+ const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
152
+ const generation = rows.length ? Number(rows[0]!.v) : 0;
153
+ sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
154
+ sql.exec("DELETE FROM agent_messages");
155
+ sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
156
+ sql.exec("DELETE FROM agent_steps");
157
+ this.setStatus("new");
158
+ sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
159
+ return generation;
160
+ });
161
+ }
140
162
  unwrap<H = unknown>(): H {
141
163
  return this.sql as unknown as H;
142
164
  }
@@ -358,6 +380,11 @@ export class AgentDurableObject {
358
380
  const session = this.resolveSession(input.session);
359
381
  return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
360
382
  }
383
+ // Session reset (#129) for custom shells — the direct sibling of the /reset route. No
384
+ // scope needed: reset touches only the store (no tools run).
385
+ reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
386
+ return this.resolveSession(opts?.session).reset();
387
+ }
361
388
  // Read-only: folds the durable log. When an identity exists (live, or persisted from a
362
389
  // prior life) the session resolves and caches like any other path. Only a read on a
363
390
  // NEVER-keyed object stays non-committal — caching there would bake "self" in as the
@@ -378,6 +405,11 @@ export class AgentDurableObject {
378
405
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
379
406
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
380
407
  await ensureScope();
408
+ // CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
409
+ // queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
410
+ // string and composes with both. A suspended session still 409s below — replace
411
+ // never cancels a parked approval.
412
+ const replace = url.searchParams.get("replace") === "1";
381
413
  // DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
382
414
  // dropped: this DO renders the turn's event stream through the source channel's own
383
415
  // deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
@@ -405,7 +437,7 @@ export class AgentDurableObject {
405
437
  let started: { turnId: string };
406
438
  try {
407
439
  session = this.resolveSession(key);
408
- started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
440
+ started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
409
441
  } catch (err) {
410
442
  // e.g. the session is suspended awaiting input, or the key mis-matches this
411
443
  // object's identity — a client-resolvable conflict, not a crash
@@ -516,6 +548,25 @@ export class AgentDurableObject {
516
548
  }
517
549
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
518
550
  }
551
+ // SESSION RESET (#129): terminally retire this session's history — supersede unfinished
552
+ // turns, archive messages/steps under the current generation (the audit trail), and
553
+ // answer with the audit handle. The response waits for in-flight turns to unwind at
554
+ // their next checkpoint boundary (the reset op runs on the turn chain), which is fine:
555
+ // a DO stays alive while it has pending work.
556
+ if (req.method === "POST" && url.pathname.endsWith("/reset")) {
557
+ let session: AgentSession;
558
+ try {
559
+ session = this.resolveSession(key);
560
+ } catch (err) {
561
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
562
+ }
563
+ try {
564
+ return Response.json(await session.reset());
565
+ } catch (err) {
566
+ // e.g. a store without reset() — a caller-resolvable refusal, not a crash
567
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
568
+ }
569
+ }
519
570
  if (url.pathname.endsWith("/transcript")) {
520
571
  // A keyed read still conflict-checks (and may commit the key — it is authentic,
521
572
  // idFromName routed it here); a key-less read stays non-committal via transcript().
@@ -528,7 +579,7 @@ export class AgentDurableObject {
528
579
  }
529
580
  return Response.json({ transcript: this.transcript() });
530
581
  }
531
- return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
582
+ return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
532
583
  }
533
584
  }
534
585
 
@@ -556,9 +607,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
556
607
  }, 20_000);
557
608
  unsub = session.observe((e) => {
558
609
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
559
- // terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
560
- // parked; the stream ends and a later /resume opens a fresh continuation stream).
561
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
610
+ // terminal for the STREAM: completed, failed, cancelled, OR suspended (input.requested
611
+ // → the turn parked; the stream ends and a later /resume opens a fresh continuation stream).
612
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") { stop(); controller.close(); }
562
613
  }, { turnId });
563
614
  },
564
615
  cancel() { stop(); },
@@ -578,7 +629,7 @@ function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable
578
629
  let notify: (() => void) | undefined;
579
630
  const unsub = session.observe((e) => {
580
631
  queue.push(e);
581
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
632
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
582
633
  notify?.();
583
634
  }, { turnId });
584
635
  return {
@@ -627,6 +678,7 @@ export async function sseTurnFinalText(res: Response): Promise<string> {
627
678
  const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
628
679
  if (e.type === "turn.completed") return e.text;
629
680
  if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
681
+ if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
630
682
  }
631
683
  if (done) break;
632
684
  }
@@ -827,7 +879,7 @@ export function durableChannelSurface(
827
879
  namespace,
828
880
  opts.agentName,
829
881
  o?.session ?? "default",
830
- new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
882
+ new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
831
883
  );
832
884
  // /turn streams SSE; the simple path collapses it to the final text.
833
885
  return sseTurnFinalText(res);
@@ -843,7 +895,7 @@ export function durableChannelSurface(
843
895
  namespace,
844
896
  opts.agentName,
845
897
  o?.session ?? "default",
846
- new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
898
+ new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
847
899
  );
848
900
  if (res.status !== 202) {
849
901
  const detail = (await res.text()).slice(0, 200);
@@ -864,7 +916,7 @@ export function durableChannelSurface(
864
916
  namespace,
865
917
  opts.agentName,
866
918
  o?.session ?? "default",
867
- new Request("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
919
+ new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
868
920
  );
869
921
  if (res.status === 501) {
870
922
  throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
@@ -883,7 +935,7 @@ export function durableChannelSurface(
883
935
  namespace,
884
936
  opts.agentName,
885
937
  o?.session ?? "default",
886
- new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
938
+ new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
887
939
  );
888
940
  yield* sseTurnEvents(res);
889
941
  },
@@ -924,6 +976,24 @@ export function durableChannelSurface(
924
976
  }
925
977
  return (await res.json()) as { turnId: string };
926
978
  },
979
+ // SESSION RESET (#129): retire the session's history on its DO. The response carries
980
+ // the audit handle ({ previousSession, generation }); a 409 (store without reset(),
981
+ // key mismatch) surfaces as an ordinary error — there is no fallback to invent.
982
+ resetSession: async (o) => {
983
+ const namespace = getNamespace();
984
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
985
+ const res = await durableFetch(
986
+ namespace,
987
+ opts.agentName,
988
+ o?.session ?? "default",
989
+ new Request("https://do/reset", { method: "POST" }),
990
+ );
991
+ if (!res.ok) {
992
+ const detail = (await res.text()).slice(0, 200);
993
+ throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
994
+ }
995
+ return (await res.json()) as { previousSession: string; generation: number };
996
+ },
927
997
  };
928
998
  return channelDispatch(resolved, ctx);
929
999
  }
@@ -79,6 +79,26 @@ class SqliteSessionStore implements SessionStore {
79
79
  throw e;
80
80
  }
81
81
  }
82
+ // Session reset (#129): ARCHIVE this session's messages/steps under the current
83
+ // generation (audit trail — never deleted), clear the live rows, status → "new".
84
+ // Archive tables + the generation counter are created lazily here, so existing
85
+ // databases stay untouched until the first reset.
86
+ reset(): number {
87
+ return this.tx(() => {
88
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
89
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
90
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
91
+ const r = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid) as { generation: number } | undefined;
92
+ const generation = r?.generation ?? 0;
93
+ this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
94
+ this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
95
+ this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
96
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
97
+ this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
98
+ this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
99
+ return generation;
100
+ });
101
+ }
82
102
  unwrap<H = unknown>(): H { return this.db as unknown as H; }
83
103
  }
84
104
 
@@ -139,6 +159,10 @@ class MemorySessionStore implements SessionStore {
139
159
  private msgs: Msg[] = [];
140
160
  private steps = new Map<string, unknown>();
141
161
  private status = "new";
162
+ private generation = 0;
163
+ // Retired generations (#129) — kept for the audit contract even in memory, so the
164
+ // backend behaves like the durable tiers within one process lifetime.
165
+ private readonly archives: { generation: number; msgs: Msg[]; steps: Map<string, unknown> }[] = [];
142
166
  appendMessage(m: Msg) { this.msgs.push(m); }
143
167
  messages(): Msg[] { return this.msgs.slice(); }
144
168
  hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
@@ -148,6 +172,14 @@ class MemorySessionStore implements SessionStore {
148
172
  getStatus(): string { return this.status; }
149
173
  setStatus(s: string) { this.status = s; }
150
174
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
175
+ reset(): number {
176
+ const generation = this.generation++;
177
+ this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
178
+ this.msgs = [];
179
+ this.steps = new Map();
180
+ this.status = "new";
181
+ return generation;
182
+ }
151
183
  unwrap<H = unknown>(): H { return undefined as unknown as H; }
152
184
  }
153
185
 
@@ -216,12 +248,16 @@ export function mountAgent(
216
248
  agent,
217
249
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
218
250
  run: (message, o) =>
219
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
251
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
220
252
  // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
221
253
  // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
222
254
  // written against ctx.runDetached behaves identically on both targets.
223
255
  runDetached: async (message, o) =>
224
- runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
256
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
257
+ // SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
258
+ // stores implement archival, so a channel written against ctx.resetSession behaves
259
+ // identically on both targets.
260
+ resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset(),
225
261
  };
226
262
  const channels = channelFetch(agent, ctx);
227
263
  const surface = async (req: Request): Promise<Response | null> => {