@adminforth/agent 2.2.5 → 2.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.
@@ -32,6 +32,7 @@ export type LlmStreamInput = {
32
32
  };
33
33
  context: AgentTurnContext;
34
34
  observability: AgentTurnObservability;
35
+ branchFromCheckpointId?: string;
35
36
  };
36
37
  /**
37
38
  * The boundary between the application layer and the concrete LLM runtime
@@ -63,4 +64,21 @@ export interface LlmPort {
63
64
  completionAdapter: AgentModeCompletionAdapter;
64
65
  sessionId: string;
65
66
  }): Promise<PendingInterrupt[]>;
67
+ /**
68
+ * Return the session thread's latest (tip) checkpoint id, or null when the thread
69
+ * has no persisted state. Recorded on each turn so message editing can fork from
70
+ * the previous turn's checkpoint.
71
+ */
72
+ getLatestCheckpointId(input: {
73
+ completionAdapter: AgentModeCompletionAdapter;
74
+ sessionId: string;
75
+ }): Promise<string | null>;
76
+ /**
77
+ * Drop the entire LangGraph checkpoint thread for a session. Used when editing the
78
+ * first message, where there is no earlier checkpoint to fork from.
79
+ */
80
+ resetThreadCheckpoints(input: {
81
+ completionAdapter: AgentModeCompletionAdapter;
82
+ sessionId: string;
83
+ }): Promise<void>;
66
84
  }
@@ -3,7 +3,7 @@ import type { AgentSessionStore } from "../persistence/sessionStore.js";
3
3
  import type { SteerBuffer } from "../domain/steerBuffer.js";
4
4
  import type { PluginOptions } from "../types.js";
5
5
  import type { LlmPort } from "./ports.js";
6
- import type { DebugSink, HandleTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
6
+ import type { DebugSink, HandleTurnInput, HandleEditTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
7
7
  /**
8
8
  * Build the provider-agnostic resume payload for a human-in-the-loop turn.
9
9
  * Exported for unit testing; the LLM port wraps it into a LangGraph Command.
@@ -41,6 +41,13 @@ export type RunTurnUseCaseDeps = {
41
41
  getAgentSystemPrompt: () => Promise<string>;
42
42
  /** True when a persistent checkpointer is configured (not the in-memory MemorySaver). */
43
43
  hasPersistentCheckpointer: boolean;
44
+ /**
45
+ * True when `turnResource.checkpointIdField` is configured — the tip checkpoint id
46
+ * is then recorded per turn, enabling message editing / branching.
47
+ */
48
+ turnCheckpointsEnabled: boolean;
49
+ /** Resource config used for the edit ownership check + turn lookups. */
50
+ sessionResource: PluginOptions["sessionResource"];
44
51
  /** Factory for the per-turn debug sink; the concrete impl lives in the llm layer. */
45
52
  createDebugSink: () => DebugSink;
46
53
  };
@@ -62,10 +69,19 @@ export declare class RunTurnUseCase {
62
69
  * MemorySaver the in-process cache is authoritative (single process, cannot be stale).
63
70
  */
64
71
  private resolvePendingInterrupts;
72
+ /** Verify the session exists and belongs to the requesting admin user. */
73
+ private assertSessionOwnership;
74
+ /**
75
+ * Prepare an edit/branch turn: validate ownership, resolve the fork checkpoint from
76
+ * the previous turn, truncate the conversation after the edited turn, and reuse the
77
+ * edited turn's id for the regenerated response.
78
+ */
79
+ private prepareEditTurn;
65
80
  private prepareTurn;
66
81
  private buildMessages;
67
82
  private handleInterrupt;
68
83
  private runAgentTurn;
69
84
  runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput): Promise<RunAndPersistAgentResponseResult>;
70
85
  handleTurn(input: HandleTurnInput): Promise<RunAndPersistAgentResponseResult>;
86
+ handleEditTurn(input: HandleEditTurnInput): Promise<RunAndPersistAgentResponseResult>;
71
87
  }
@@ -14,7 +14,7 @@ var __asyncValues = (this && this.__asyncValues) || function (o) {
14
14
  function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
15
15
  function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
16
16
  };
17
- import { logger } from "adminforth";
17
+ import { logger, Filters } from "adminforth";
18
18
  import { randomUUID } from "crypto";
19
19
  import { VegaLiteStreamBuffer } from "../domain/vegaLiteStreamBuffer.js";
20
20
  import { buildAgentTurnSystemPrompt } from "../domain/systemPrompt.js";
@@ -121,12 +121,88 @@ export class RunTurnUseCase {
121
121
  return (_a = this.pendingInterrupts.get(sessionId)) !== null && _a !== void 0 ? _a : [];
122
122
  });
123
123
  }
124
+ /** Verify the session exists and belongs to the requesting admin user. */
125
+ assertSessionOwnership(sessionId, adminUser) {
126
+ return __awaiter(this, void 0, void 0, function* () {
127
+ const s = this.deps.sessionResource;
128
+ const session = yield this.deps.getAdminforth().resource(s.resourceId).get([Filters.EQ(s.idField, sessionId)]);
129
+ if (!session) {
130
+ throw new Error(`Session "${sessionId}" not found.`);
131
+ }
132
+ if (session[s.askerIdField] !== adminUser.pk) {
133
+ throw new Error(`Session "${sessionId}" does not belong to the current user.`);
134
+ }
135
+ });
136
+ }
137
+ /**
138
+ * Prepare an edit/branch turn: validate ownership, resolve the fork checkpoint from
139
+ * the previous turn, truncate the conversation after the edited turn, and reuse the
140
+ * edited turn's id for the regenerated response.
141
+ */
142
+ prepareEditTurn(input) {
143
+ return __awaiter(this, void 0, void 0, function* () {
144
+ const sequenceDebugSink = this.deps.createDebugSink();
145
+ const mode = this.resolveMode(input.modeName);
146
+ const editTurnId = input.editTurnId;
147
+ if (!this.deps.turnCheckpointsEnabled) {
148
+ throw new Error("Edit/fork requires checkpointIdField to be configured on turnResource.");
149
+ }
150
+ yield this.assertSessionOwnership(input.sessionId, input.adminUser);
151
+ const agentTurns = yield this.deps.sessions.getAgentTurns(input.sessionId);
152
+ const targetIndex = agentTurns.findIndex((turn) => turn.id === editTurnId);
153
+ if (targetIndex === -1) {
154
+ throw new Error(`Turn "${editTurnId}" not found in session "${input.sessionId}".`);
155
+ }
156
+ const previous = agentTurns[targetIndex - 1];
157
+ let branchFromCheckpointId;
158
+ if (previous) {
159
+ if (!previous.checkpointId) {
160
+ throw new Error(`Cannot edit this message: the previous turn has no stored checkpoint to branch from.`);
161
+ }
162
+ branchFromCheckpointId = previous.checkpointId;
163
+ }
164
+ else {
165
+ // No earlier turn to fork from — drop the whole thread and start fresh.
166
+ yield this.deps.llm.resetThreadCheckpoints({
167
+ completionAdapter: mode.completionAdapter,
168
+ sessionId: input.sessionId,
169
+ });
170
+ }
171
+ // Language-detection context: the kept user messages just before the edited turn.
172
+ const previousUserMessages = agentTurns
173
+ .slice(Math.max(0, targetIndex - 2), targetIndex)
174
+ .map((turn) => ({ text: turn.prompt }));
175
+ // Editing supersedes any in-flight turn state for this session.
176
+ this.deps.steerBuffer.clear(input.sessionId);
177
+ this.pendingInterrupts.delete(input.sessionId);
178
+ // Apply the edit + truncate (deletes later turns first, then rewrites the edited turn).
179
+ yield this.deps.sessions.editTurnAndTruncateAfter({
180
+ sessionId: input.sessionId,
181
+ turnId: editTurnId,
182
+ newPrompt: input.prompt,
183
+ });
184
+ yield this.deps.sessions.touchSession(input.sessionId);
185
+ return {
186
+ prompt: input.prompt,
187
+ sessionId: input.sessionId,
188
+ turnId: editTurnId,
189
+ previousUserMessages,
190
+ mode,
191
+ context: this.buildContext(input, editTurnId),
192
+ observability: { emit: input.emit, sequenceDebugSink },
193
+ branchFromCheckpointId,
194
+ };
195
+ });
196
+ }
124
197
  prepareTurn(input) {
125
198
  return __awaiter(this, void 0, void 0, function* () {
126
199
  const sequenceDebugSink = this.deps.createDebugSink();
127
200
  const mode = this.resolveMode(input.modeName);
128
201
  const approvalDecision = getApprovalDecision(input);
129
202
  const shouldResume = Boolean(approvalDecision);
203
+ if (input.editTurnId && !shouldResume) {
204
+ return this.prepareEditTurn(input);
205
+ }
130
206
  let turnId;
131
207
  let previousUserMessages = [];
132
208
  let initialResponse;
@@ -228,6 +304,7 @@ export class RunTurnUseCase {
228
304
  input: streamInput,
229
305
  context: prepared.context,
230
306
  observability: prepared.observability,
307
+ branchFromCheckpointId: prepared.branchFromCheckpointId,
231
308
  });
232
309
  const { emit } = prepared.observability;
233
310
  const abortSignal = prepared.context.abortSignal;
@@ -285,9 +362,14 @@ export class RunTurnUseCase {
285
362
  }
286
363
  runAndPersistAgentResponse(input) {
287
364
  return __awaiter(this, void 0, void 0, function* () {
288
- var _a, _b;
365
+ var _a, _b, _c, _d;
289
366
  const prepared = yield this.prepareTurn(input);
290
- let fullResponse = (_a = prepared.initialResponse) !== null && _a !== void 0 ? _a : "";
367
+ yield ((_b = (_a = prepared.observability).emit) === null || _b === void 0 ? void 0 : _b.call(_a, {
368
+ type: "turn-persisted",
369
+ sessionId: prepared.sessionId,
370
+ turnId: prepared.turnId,
371
+ }));
372
+ let fullResponse = (_c = prepared.initialResponse) !== null && _c !== void 0 ? _c : "";
291
373
  let aborted = false;
292
374
  let failed = false;
293
375
  try {
@@ -295,7 +377,7 @@ export class RunTurnUseCase {
295
377
  fullResponse += agentResponse.text;
296
378
  }
297
379
  catch (error) {
298
- if (((_b = input.abortSignal) === null || _b === void 0 ? void 0 : _b.aborted) || isAbortError(error)) {
380
+ if (((_d = input.abortSignal) === null || _d === void 0 ? void 0 : _d.aborted) || isAbortError(error)) {
299
381
  aborted = true;
300
382
  logger.info(input.abortLogMessage);
301
383
  }
@@ -305,11 +387,28 @@ export class RunTurnUseCase {
305
387
  logger.error(`${input.failureLogMessage}:\n${fullResponse}`);
306
388
  }
307
389
  }
390
+ // Record the turn's tip checkpoint id ONLY on success, so message editing can
391
+ // fork from it. On abort/failure leave the previous value untouched (undefined =
392
+ // don't overwrite). Never let a checkpoint read break response persistence.
393
+ let checkpointId;
394
+ if (!aborted && !failed && this.deps.turnCheckpointsEnabled) {
395
+ try {
396
+ checkpointId = yield this.deps.llm.getLatestCheckpointId({
397
+ completionAdapter: prepared.mode.completionAdapter,
398
+ sessionId: prepared.sessionId,
399
+ });
400
+ }
401
+ catch (error) {
402
+ logger.warn(`Failed to read latest checkpoint id for turn "${prepared.turnId}": ${getErrorMessage(error)}`);
403
+ checkpointId = undefined;
404
+ }
405
+ }
308
406
  prepared.observability.sequenceDebugSink.flush();
309
407
  yield this.deps.sessions.saveTurnResponse({
310
408
  turnId: prepared.turnId,
311
409
  responseText: fullResponse,
312
410
  debugHistory: prepared.observability.sequenceDebugSink.getHistory(),
411
+ checkpointId,
313
412
  });
314
413
  return {
315
414
  text: fullResponse,
@@ -361,4 +460,46 @@ export class RunTurnUseCase {
361
460
  return agentResponse;
362
461
  });
363
462
  }
463
+ handleEditTurn(input) {
464
+ return __awaiter(this, void 0, void 0, function* () {
465
+ var _a, _b;
466
+ yield input.emit({
467
+ type: "turn-started",
468
+ messageId: randomUUID(),
469
+ });
470
+ const agentResponse = yield this.runAndPersistAgentResponse({
471
+ prompt: input.prompt,
472
+ sessionId: input.sessionId,
473
+ modeName: input.modeName,
474
+ userTimeZone: input.userTimeZone,
475
+ currentPage: input.currentPage,
476
+ chatSurface: input.chatSurface,
477
+ adminPublicOrigin: input.adminPublicOrigin,
478
+ editTurnId: input.turnId,
479
+ abortSignal: input.abortSignal,
480
+ adminUser: input.adminUser,
481
+ emit: input.emit,
482
+ failureLogMessage: (_a = input.failureLogMessage) !== null && _a !== void 0 ? _a : "Agent message edit failed",
483
+ abortLogMessage: (_b = input.abortLogMessage) !== null && _b !== void 0 ? _b : "Agent message edit aborted",
484
+ });
485
+ if (agentResponse.failed) {
486
+ yield input.emit({
487
+ type: "error",
488
+ error: agentResponse.text,
489
+ });
490
+ }
491
+ else if (!agentResponse.aborted) {
492
+ yield input.emit({
493
+ type: "response",
494
+ text: agentResponse.text,
495
+ sessionId: input.sessionId,
496
+ turnId: agentResponse.turnId,
497
+ });
498
+ }
499
+ yield input.emit({
500
+ type: "finish",
501
+ });
502
+ return agentResponse;
503
+ });
504
+ }
364
505
  }
@@ -59,7 +59,7 @@
59
59
  >
60
60
  <div
61
61
  v-if="isModeMenuOpen"
62
- class="absolute bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
62
+ class="absolute z-20 bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
63
63
  >
64
64
  <button
65
65
  v-for="mode in agentStore.availableModes"
@@ -75,29 +75,52 @@
75
75
  </Transition>
76
76
  </div>
77
77
  <MicrophoneButton
78
- v-if="props.meta.hasAudioAdapter"
78
+ v-if="props.meta.hasAudioAdapter"
79
+ v-show="!agentStore.isEditingMessage"
79
80
  />
80
81
  <template v-if="!agentStore.isAudioChatMode">
81
- <Button
82
- v-if="!agentStore.isResponseInProgress"
83
- class=" !p-0 h-7 w-7 transition-opacity duration-200"
84
- @click="sendMessage"
85
- :disabled="!agentStore.trimmedUserMessage"
86
- >
87
- <IconArrowUpOutline
88
- class="w-6 h-6
89
- text-white"
90
- />
91
- </Button>
92
- <Button
93
- v-else
94
- class="right-4 bottom-2 !p-0 h-7 w-7"
95
- @click="stopCurrentRequest"
96
- >
97
- <div
98
- class="w-2.5 h-2.5 bg-white rounded-sm"
99
- />
100
- </Button>
82
+ <template v-if="agentStore.isEditingMessage">
83
+ <div class="flex items-center gap-3">
84
+ <Button
85
+ variant="secondary"
86
+ class="!p-0 h-7 w-7 mr-1 bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800"
87
+ title="Cancel edit"
88
+ @click="cancelEdit"
89
+ >
90
+ <IconCloseOutline class="w-5 h-5" />
91
+ </Button>
92
+ <Button
93
+ class="!p-0 h-7 w-7"
94
+ title="Send edited message"
95
+ @click="sendMessage"
96
+ :disabled="!agentStore.trimmedUserMessage"
97
+ >
98
+ <IconCheckOutline class="w-6 h-6 text-white" />
99
+ </Button>
100
+ </div>
101
+ </template>
102
+ <template v-else>
103
+ <Button
104
+ v-if="!agentStore.isResponseInProgress"
105
+ class=" !p-0 h-7 w-7 transition-opacity duration-200"
106
+ @click="sendMessage"
107
+ :disabled="!agentStore.trimmedUserMessage"
108
+ >
109
+ <IconArrowUpOutline
110
+ class="w-6 h-6
111
+ text-white"
112
+ />
113
+ </Button>
114
+ <Button
115
+ v-else
116
+ class="right-4 bottom-2 !p-0 h-7 w-7"
117
+ @click="stopCurrentRequest"
118
+ >
119
+ <div
120
+ class="w-2.5 h-2.5 bg-white rounded-sm"
121
+ />
122
+ </Button>
123
+ </template>
101
124
  </template>
102
125
  </div>
103
126
  </div>
@@ -105,7 +128,7 @@
105
128
  </template>
106
129
 
107
130
  <script setup lang="ts">
108
- import { IconArrowUpOutline, IconAngleDownOutline } from '@iconify-prerendered/vue-flowbite';
131
+ import { IconArrowUpOutline, IconAngleDownOutline, IconCloseOutline, IconCheckOutline } from '@iconify-prerendered/vue-flowbite';
109
132
  import { useTemplateRef, onMounted, ref, onUnmounted, watch, computed } from 'vue';
110
133
  import { onClickOutside } from '@vueuse/core'
111
134
  import { useAgentStore } from './composables/useAgentStore';
@@ -126,6 +149,7 @@ const props = defineProps<{
126
149
  defaultModeName: string | null;
127
150
  stickByDefault: boolean;
128
151
  hasAudioAdapter: boolean;
152
+ editingEnabled: boolean;
129
153
  }
130
154
  adminUser: any
131
155
  conversationAreaRef: any
@@ -150,6 +174,7 @@ onClickOutside(modeMenu as any, () => { isModeMenuOpen.value = false; });
150
174
  onMounted(async () => {
151
175
  agentStore.setAvailableModes(props.meta.modes, props.meta.defaultModeName);
152
176
  agentStore.setCurrentGenerationModeFromLocalStorage();
177
+ agentStore.setEditingEnabled(props.meta.editingEnabled);
153
178
  agentStore.regisrerTextInput(textInput.value);
154
179
  textInput.value?.focus();
155
180
  autoResize();
@@ -187,6 +212,14 @@ function selectMode(modeName: string) {
187
212
  async function sendMessage() {
188
213
  if (agentStore.isAudioChatMode) return;
189
214
  isModeMenuOpen.value = false;
215
+ // While editing a message, the same send action commits the edit (regenerating
216
+ // from that turn) instead of starting/steering a turn.
217
+ if (agentStore.isEditingMessage) {
218
+ await agentStore.submitEditMessage();
219
+ autoResize();
220
+ props.conversationAreaRef?.handleSendMessage();
221
+ return;
222
+ }
190
223
  // Routes to a steer (folded into the running turn) while generating, or a normal
191
224
  // new-turn send when idle.
192
225
  await agentStore.submitUserMessage();
@@ -194,6 +227,11 @@ async function sendMessage() {
194
227
  props.conversationAreaRef?.handleSendMessage();
195
228
  }
196
229
 
230
+ function cancelEdit() {
231
+ agentStore.cancelEditMessage();
232
+ autoResize();
233
+ }
234
+
197
235
  function stopCurrentRequest() {
198
236
  agentStore.abortCurrentChatRequestAndAddSystemMessage();
199
237
  }
@@ -78,6 +78,7 @@ const props = defineProps<{
78
78
  defaultModeName: string | null;
79
79
  stickByDefault: boolean;
80
80
  hasAudioAdapter: boolean;
81
+ editingEnabled: boolean;
81
82
  }
82
83
  adminUser: any
83
84
  }>();
@@ -1,5 +1,5 @@
1
1
  import { DefaultChatTransport } from 'ai';
2
- import { shallowRef, type Ref } from 'vue';
2
+ import { ref, shallowRef, watch, type Ref } from 'vue';
3
3
  import { Chat } from '../../chat';
4
4
  import { getCurrentPageContext } from './pageContext';
5
5
  // const { DefaultChatTransport } = await import('ai');
@@ -25,6 +25,7 @@ export function createAgentChatManager({
25
25
  }: CreateAgentChatManagerOptions) {
26
26
  const chats = new Map<string, Chat<any>>();
27
27
  const currentChat = shallowRef<Chat<any> | null>();
28
+ const editingTurnId = ref<string | null>(null);
28
29
  const agentApiBase = `${(import.meta as AgentImportMeta).env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/agent`;
29
30
 
30
31
  function replaceLastMessage(message: any) {
@@ -89,6 +90,26 @@ export function createAgentChatManager({
89
90
  replaceLastMessage(assistantMessage);
90
91
  }
91
92
 
93
+ // Stamp the just-finished turn's id onto its prompt message so it becomes editable
94
+ // without a session reload. The prompt is the last user message that isn't a
95
+ // mid-turn steer.
96
+ function tagCurrentTurnPrompt(turnId: string) {
97
+ const messages = currentChat.value?.messages;
98
+ if (!messages) {
99
+ return;
100
+ }
101
+ for (let i = messages.length - 1; i >= 0; i--) {
102
+ const message = messages[i];
103
+ if (message.role === 'user' && !message.metadata?.steer) {
104
+ messages.splice(i, 1, {
105
+ ...message,
106
+ metadata: { ...(message.metadata ?? {}), turnId },
107
+ });
108
+ return;
109
+ }
110
+ }
111
+ }
112
+
92
113
  function handleRealtimeChatData(dataPart: any) {
93
114
  if (dataPart?.type === 'data-open-page' && typeof dataPart.data?.targetPath === 'string') {
94
115
  onOpenPage(dataPart.data.targetPath);
@@ -97,6 +118,16 @@ export function createAgentChatManager({
97
118
 
98
119
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
99
120
  onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
121
+ return;
122
+ }
123
+
124
+ // `data-turn-persisted` arrives at turn start, `data-response` at turn end — either
125
+ // way, stamp the prompt with its turn id so it can be edited.
126
+ if (
127
+ (dataPart?.type === 'data-turn-persisted' || dataPart?.type === 'data-response')
128
+ && typeof dataPart.data?.turnId === 'string'
129
+ ) {
130
+ tagCurrentTurnPrompt(dataPart.data.turnId);
100
131
  }
101
132
  }
102
133
 
@@ -128,6 +159,14 @@ export function createAgentChatManager({
128
159
 
129
160
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
130
161
  onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
162
+ return;
163
+ }
164
+
165
+ if (
166
+ (dataPart?.type === 'data-turn-persisted' || dataPart?.type === 'data-response')
167
+ && typeof dataPart.data?.turnId === 'string'
168
+ ) {
169
+ tagCurrentTurnPrompt(dataPart.data.turnId);
131
170
  }
132
171
  }
133
172
 
@@ -178,7 +217,11 @@ export function createAgentChatManager({
178
217
  credentials: 'include',
179
218
  prepareSendMessagesRequest({ messages }: any) {
180
219
  const message = lastMessage.value;
181
- const body = {
220
+ const headers = {
221
+ Accept: 'text/event-stream',
222
+ 'x-vercel-ai-ui-message-stream': 'v1',
223
+ };
224
+ const body: Record<string, any> = {
182
225
  message,
183
226
  sessionId,
184
227
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -186,12 +229,20 @@ export function createAgentChatManager({
186
229
  currentPage: getCurrentPageContext(),
187
230
  };
188
231
 
232
+ // Editing an existing message branches from its turn: hit /edit, which
233
+ // truncates every turn after `turnId` and regenerates from that point.
234
+ if (editingTurnId.value) {
235
+ body.turnId = editingTurnId.value;
236
+ return {
237
+ api: `${agentApiBase}/edit`,
238
+ headers,
239
+ body,
240
+ };
241
+ }
242
+
189
243
  return {
190
- headers: {
191
- Accept: 'text/event-stream',
192
- 'x-vercel-ai-ui-message-stream': 'v1',
193
- },
194
- body
244
+ headers,
245
+ body,
195
246
  };
196
247
  }
197
248
  }),
@@ -210,6 +261,69 @@ export function createAgentChatManager({
210
261
  currentChat.value?.stop();
211
262
  }
212
263
 
264
+ function isChatBusy() {
265
+ const status = (currentChat.value as any)?.status;
266
+ return status === 'streaming' || status === 'submitted';
267
+ }
268
+
269
+ async function stopActiveTurnAndWaitForIdle() {
270
+ const chat = currentChat.value;
271
+ if (!chat || !isChatBusy()) {
272
+ return;
273
+ }
274
+ await chat.stop();
275
+ if (!isChatBusy()) {
276
+ return;
277
+ }
278
+ await new Promise<void>((resolve) => {
279
+ let settled = false;
280
+ let stopWatch = () => {};
281
+ const finish = () => {
282
+ if (settled) {
283
+ return;
284
+ }
285
+ settled = true;
286
+ clearTimeout(timer);
287
+ stopWatch();
288
+ resolve();
289
+ };
290
+ // Safety valve so a stuck status can never hang the edit indefinitely.
291
+ const timer = setTimeout(finish, 3000);
292
+ stopWatch = watch(
293
+ () => (currentChat.value as any)?.status,
294
+ () => {
295
+ if (!isChatBusy()) {
296
+ finish();
297
+ }
298
+ },
299
+ );
300
+ });
301
+ }
302
+
303
+ // Replace the target user message in place and regenerate from it. The SDK's
304
+ // `sendMessage({ messageId })` truncates every message after the replaced one
305
+ // (mirroring the server-side truncate), and `editingTurnId` routes the request
306
+ // to /edit for the matching turn. If a turn is still generating it is stopped
307
+ // first so the edit supersedes it.
308
+ async function sendEditMessage({ messageId, turnId, text }: { messageId: string; turnId: string; text: string; }) {
309
+ const chat = currentChat.value;
310
+ if (!chat) {
311
+ return;
312
+ }
313
+ const currentMessage = chat.messages.find((m: any) => m.id === messageId);
314
+ if (!currentMessage || currentMessage.parts[0].text.trim() === text.trim()) {
315
+ return;
316
+ }
317
+ await stopActiveTurnAndWaitForIdle();
318
+ editingTurnId.value = turnId;
319
+ lastMessage.value = text;
320
+ try {
321
+ await chat.sendMessage({ text, messageId, metadata: { turnId } });
322
+ } finally {
323
+ editingTurnId.value = null;
324
+ }
325
+ }
326
+
213
327
  async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
214
328
  const response = await fetch(`${agentApiBase}/approval`, {
215
329
  method: 'POST',
@@ -237,5 +351,6 @@ export function createAgentChatManager({
237
351
  setCurrentChat,
238
352
  abortCurrentChatRequest,
239
353
  submitToolApproval,
354
+ sendEditMessage,
240
355
  };
241
356
  }
@@ -52,32 +52,35 @@ export function createAgentSessionManager({
52
52
  if (currentSession.value) {
53
53
  currentSession.value.messages = currentChat.value?.messages.map((m: any) => {
54
54
  const text = m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join('');
55
+ const turnId = m.metadata?.turnId;
55
56
  if (m.role === 'user' && m.metadata?.steer) {
56
- return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}` };
57
+ return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}`, turnId };
57
58
  }
58
- return { role: m.role, text };
59
+ return { role: m.role, text, turnId };
59
60
  }) || [];
60
61
  sessions.value[currentSession.value.sessionId] = currentSession.value;
61
62
  }
62
63
  }
63
64
 
64
65
  function mapStoredMessage(m: any): any[] {
66
+ const turnId = m.turnId;
65
67
  if (m.role === 'user' && typeof m.text === 'string' && m.text.includes(STEER_PERSIST_PREFIX)) {
66
68
  const [prompt, ...steers] = m.text.split(STEER_PERSIST_PREFIX);
67
69
  const mapped: any[] = [];
68
70
  if (prompt) {
69
- mapped.push({ role: 'user', parts: [{ type: 'text', text: prompt, state: 'done' }] });
71
+ mapped.push({ id: crypto.randomUUID(), role: 'user', metadata: { turnId }, parts: [{ type: 'text', text: prompt, state: 'done' }] });
70
72
  }
71
73
  for (const steer of steers) {
72
74
  mapped.push({
75
+ id: crypto.randomUUID(),
73
76
  role: 'user',
74
- metadata: { steer: true },
77
+ metadata: { turnId, steer: true },
75
78
  parts: [{ type: 'text', text: steer, state: 'done' }],
76
79
  });
77
80
  }
78
81
  return mapped;
79
82
  }
80
- return [{ role: m.role, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
83
+ return [{ id: crypto.randomUUID(), role: m.role, metadata: { turnId }, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
81
84
  }
82
85
 
83
86
  async function fetchSession(sessionId: string) {
@@ -97,7 +97,6 @@ export function createAgentSteerQueue({
97
97
  state: part?.state === 'streaming' ? 'done' : part?.state,
98
98
  }));
99
99
  messages[lastIndex] = { ...streamingMessage, parts: frozenParts };
100
- console.log('Frozen parts:', frozenParts);
101
100
  // 2) Redirect the SDK's in-flight reasoning/text parts into a fresh continuation
102
101
  // message so subsequent deltas (resolved by id reference) land after the steer.
103
102
  const continuation: any = { id: crypto.randomUUID(), role: 'assistant', parts: [] };
@@ -175,11 +174,16 @@ export function createAgentSteerQueue({
175
174
  void sendMessage(next.text);
176
175
  }
177
176
 
177
+ function clear() {
178
+ queue.value = [];
179
+ }
180
+
178
181
  return {
179
182
  queue,
180
183
  enqueue,
181
184
  removeQueuedMessage,
182
185
  steerQueuedMessage,
183
186
  flushNext,
187
+ clear,
184
188
  };
185
189
  }