@dreb/coding-agent 2.60.1 → 2.61.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.
@@ -3062,21 +3062,71 @@ export class AgentSession {
3062
3062
  this._emit({ type: "session_name_changed", name: this.sessionName ?? "" });
3063
3063
  }
3064
3064
  /**
3065
- * Create a fork from a specific entry.
3065
+ * Create a fork from a specific entry. The fork point may be any user or
3066
+ * assistant message in the transcript; branch semantics depend on the role:
3067
+ *
3068
+ * - **Assistant message** -> the new branch *includes* the selected response
3069
+ * (and everything before it); no editor pre-fill. "Continue from this answer."
3070
+ * Forking at the last assistant message keeps the entire current state.
3071
+ * - **User message** -> rewind to *before* the selected message (branch from its
3072
+ * parent, dropping the message and everything after it) and offer its text as
3073
+ * editor pre-fill. "Edit / re-ask this question."
3074
+ *
3066
3075
  * Emits before_fork/fork session events to extensions.
3067
3076
  *
3068
- * @param entryId ID of the entry to fork from
3077
+ * @param entryId ID of the message entry to fork from
3069
3078
  * @returns Object with:
3070
- * - selectedText: The text of the selected user message (for editor pre-fill)
3079
+ * - selectedText: The selected user message text for editor pre-fill (empty
3080
+ * when forking at an assistant message).
3071
3081
  * - cancelled: True if an extension cancelled the fork
3072
3082
  */
3073
3083
  async fork(entryId) {
3074
- const previousSessionFile = this.sessionFile;
3075
3084
  const selectedEntry = this.sessionManager.getEntry(entryId);
3076
- if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
3085
+ if (!selectedEntry ||
3086
+ selectedEntry.type !== "message" ||
3087
+ (selectedEntry.message.role !== "user" && selectedEntry.message.role !== "assistant")) {
3077
3088
  throw new Error("Invalid entry ID for forking");
3078
3089
  }
3079
- const selectedText = this._extractUserMessageText(selectedEntry.message.content);
3090
+ if (selectedEntry.message.role === "assistant") {
3091
+ // Continue-from-answer: branch from the assistant entry itself so it (and
3092
+ // everything before it) is retained. No editor pre-fill.
3093
+ //
3094
+ // Reject turns that can't be safely branched from (interrupted, or waiting
3095
+ // on tool results) — branching there would silently produce a branch that
3096
+ // doesn't match the selected turn. See _isForkableAssistant.
3097
+ if (!this._isForkableAssistant(selectedEntry.message)) {
3098
+ throw new Error("Cannot fork at this assistant turn: it was interrupted or is still waiting on tool results");
3099
+ }
3100
+ const { cancelled } = await this._performFork(entryId, () => {
3101
+ this.sessionManager.createBranchedSession(entryId);
3102
+ });
3103
+ return { selectedText: "", cancelled };
3104
+ }
3105
+ const selectedText = this._extractMessageText(selectedEntry.message.content);
3106
+ // Rewind to *before* the selected user message by branching from its parent,
3107
+ // so the selected message (and everything after it) is dropped and its text is
3108
+ // offered as editor pre-fill.
3109
+ const { cancelled } = await this._performFork(entryId, (previousSessionFile) => {
3110
+ if (!selectedEntry.parentId) {
3111
+ this.sessionManager.newSession({ parentSession: previousSessionFile });
3112
+ }
3113
+ else {
3114
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
3115
+ }
3116
+ });
3117
+ return { selectedText, cancelled };
3118
+ }
3119
+ /**
3120
+ * Shared fork machinery: emit the cancellable session_before_fork event,
3121
+ * clear pending state, create the branch via the supplied strategy, reload
3122
+ * the conversation, and emit session_fork.
3123
+ *
3124
+ * @param entryId Entry the fork is anchored to (reported to extensions).
3125
+ * @param branch Strategy that creates the branched/new session. Receives the
3126
+ * previous session file so callers can set it as the parent when needed.
3127
+ */
3128
+ async _performFork(entryId, branch) {
3129
+ const previousSessionFile = this.sessionFile;
3080
3130
  let skipConversationRestore = false;
3081
3131
  // Emit session_before_fork event (can be cancelled)
3082
3132
  if (this._extensionRunner?.hasHandlers("session_before_fork")) {
@@ -3085,18 +3135,13 @@ export class AgentSession {
3085
3135
  entryId,
3086
3136
  }));
3087
3137
  if (result?.cancel) {
3088
- return { selectedText, cancelled: true };
3138
+ return { cancelled: true };
3089
3139
  }
3090
3140
  skipConversationRestore = result?.skipConversationRestore ?? false;
3091
3141
  }
3092
3142
  // Clear pending messages (bound to old session state)
3093
3143
  this._pendingNextTurnMessages = [];
3094
- if (!selectedEntry.parentId) {
3095
- this.sessionManager.newSession({ parentSession: previousSessionFile });
3096
- }
3097
- else {
3098
- this.sessionManager.createBranchedSession(selectedEntry.parentId);
3099
- }
3144
+ branch(previousSessionFile);
3100
3145
  this.agent.sessionId = this.sessionManager.getSessionId();
3101
3146
  // Reload messages from entries (works for both file and in-memory mode)
3102
3147
  const sessionContext = this.sessionManager.buildSessionContext();
@@ -3111,7 +3156,7 @@ export class AgentSession {
3111
3156
  if (!skipConversationRestore) {
3112
3157
  this.agent.replaceMessages(sessionContext.messages);
3113
3158
  }
3114
- return { selectedText, cancelled: false };
3159
+ return { cancelled: false };
3115
3160
  }
3116
3161
  // =========================================================================
3117
3162
  // Tree Navigation
@@ -3245,7 +3290,7 @@ export class AgentSession {
3245
3290
  if (targetEntry.type === "message" && targetEntry.message.role === "user") {
3246
3291
  // User message: leaf = parent (null if root), text goes to editor
3247
3292
  newLeafId = targetEntry.parentId;
3248
- editorText = this._extractUserMessageText(targetEntry.message.content);
3293
+ editorText = this._extractMessageText(targetEntry.message.content);
3249
3294
  }
3250
3295
  else if (targetEntry.type === "custom_message") {
3251
3296
  // Custom message: leaf = parent (null if root), text goes to editor
@@ -3307,24 +3352,68 @@ export class AgentSession {
3307
3352
  }
3308
3353
  }
3309
3354
  /**
3310
- * Get all user messages from session for fork selector.
3355
+ * Get all forkable messages (user *and* assistant) for the fork selector.
3356
+ *
3357
+ * Each entry carries its role so callers can label it and choose the right
3358
+ * fork semantics (assistant = continue-from-answer, user = rewind + re-ask).
3359
+ * A forkable assistant turn with no renderable text (e.g. a thinking-only
3360
+ * turn) still appears as a fork point, with a generic label.
3361
+ *
3362
+ * Assistant turns that cannot be safely branched from (interrupted turns, or
3363
+ * turns containing a tool call whose result lives in a descendant entry) are
3364
+ * excluded — see _isForkableAssistant.
3311
3365
  */
3312
- getUserMessagesForForking() {
3366
+ getForkableMessages() {
3313
3367
  const entries = this.sessionManager.getEntries();
3314
3368
  const result = [];
3315
3369
  for (const entry of entries) {
3316
3370
  if (entry.type !== "message")
3317
3371
  continue;
3318
- if (entry.message.role !== "user")
3372
+ const role = entry.message.role;
3373
+ if (role !== "user" && role !== "assistant")
3319
3374
  continue;
3320
- const text = this._extractUserMessageText(entry.message.content);
3321
- if (text) {
3322
- result.push({ entryId: entry.id, text });
3375
+ const text = this._extractMessageText(entry.message.content);
3376
+ if (role === "user") {
3377
+ // Preserve existing behavior: skip empty user messages.
3378
+ if (text)
3379
+ result.push({ entryId: entry.id, text, role });
3380
+ }
3381
+ else {
3382
+ // Only offer assistant turns that can be safely branched from.
3383
+ if (!this._isForkableAssistant(entry.message))
3384
+ continue;
3385
+ result.push({ entryId: entry.id, text: text || "(assistant response)", role });
3323
3386
  }
3324
3387
  }
3325
3388
  return result;
3326
3389
  }
3327
- _extractUserMessageText(content) {
3390
+ /**
3391
+ * Whether an assistant turn can be safely used as a fork point.
3392
+ *
3393
+ * Forking anchors on the entry's ancestors only (SessionManager.getBranch
3394
+ * walks parentId upward), and errored/aborted turns are dropped by
3395
+ * transformMessages() before every request. Two kinds of assistant turn
3396
+ * therefore produce a branch that silently does NOT match what was selected:
3397
+ *
3398
+ * - stopReason "error"/"aborted": transformMessages() skips the turn, so the
3399
+ * reply vanishes from context on the next request (defeating "continue from
3400
+ * this answer", and risking back-to-back user messages on strict providers).
3401
+ * - turns containing tool calls: their tool results are *descendant* entries a
3402
+ * branch cannot include, so transformMessages() substitutes a fabricated
3403
+ * "No result provided" (isError) result — telling the model a successful
3404
+ * tool call failed.
3405
+ *
3406
+ * A completed answer (the intended "continue from here" target) has a terminal
3407
+ * stopReason and no unresolved tool calls, so it passes.
3408
+ */
3409
+ _isForkableAssistant(message) {
3410
+ if (message.stopReason === "error" || message.stopReason === "aborted")
3411
+ return false;
3412
+ if (Array.isArray(message.content) && message.content.some((c) => c.type === "toolCall"))
3413
+ return false;
3414
+ return true;
3415
+ }
3416
+ _extractMessageText(content) {
3328
3417
  if (typeof content === "string")
3329
3418
  return content;
3330
3419
  if (Array.isArray(content)) {