@yeaft/webchat-agent 1.0.508 → 1.0.509

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.
@@ -27,7 +27,7 @@ export const DEFAULT_RUNTIME_CACHE_TURN_CAP = 25;
27
27
  export const DEFAULT_RUNTIME_CACHE_TOKEN_BUDGET = 32768;
28
28
  export const DEFAULT_RUNTIME_CACHE_MESSAGE_CAP = 256;
29
29
 
30
- const MINIMUM_RECENT_PROVIDER_TURNS = 20;
30
+ const MINIMUM_RECENT_PROVIDER_TURNS = 5;
31
31
  const IMAGE_PART_TOKEN_COST = 1024;
32
32
  const DOCUMENT_PART_TOKEN_COST = 2048;
33
33
  const CONTENT_PART_FRAME_TOKENS = 2;
@@ -900,9 +900,10 @@ function describeBucket(turns, messages = turns.flatMap(turn => turn.text)) {
900
900
  * configured recent window fits; tools are optional enrichment, newest first.
901
901
  *
902
902
  * The active turn is outside both buckets and consumes the global budget first.
903
- * Its opening user row is protected. Oversized recent text is fitted only in the
904
- * disposable provider copy so one large turn cannot evict the rest of the recent
905
- * window. Related recall remains optional and complete. External recall must have
903
+ * Its opening user row is protected. Recent text stays complete: reduce the
904
+ * oldest end of the suffix down to five turns (or all available for a new
905
+ * Session); fail closed if that floor cannot fit. Related recall only uses
906
+ * remaining budget and stays optional and complete. External recall must have
906
907
  * comparable userSeq/source identities to establish
907
908
  * that it predates recent/current history; unknown chronology fails closed.
908
909
  *
@@ -918,7 +919,7 @@ export function buildHistoryBuckets(snapshot, options = {}) {
918
919
  const tokenBudget = bucketCap(options.messageTokenBudget, DEFAULT_MESSAGE_TOKEN_BUDGET);
919
920
  const messageCap = bucketCap(options.maxMessageCount, DEFAULT_RUNTIME_CACHE_MESSAGE_CAP);
920
921
  const recentCap = bucketCap(options.recentTurnCap, 20);
921
- const relatedCap = bucketCap(options.relatedTurnCap, 8, 10);
922
+ const relatedCap = bucketCap(options.relatedTurnCap, 5, 5);
922
923
  const keepToolTurns = bucketCap(options.keepToolTurns, DEFAULT_KEEP_TOOL_TURNS);
923
924
  const allTurns = splitBucketTurns(source);
924
925
  const currentStart = Number.isInteger(options.currentTurnStartIndex)
@@ -1022,65 +1023,26 @@ export function buildHistoryBuckets(snapshot, options = {}) {
1022
1023
  // past-turn boundary is still a safe fence; never guess from text/time.
1023
1024
  || (turn.index == null && currentIdentity.userSeq == null && past.length > 0
1024
1025
  && bucketBefore(turn, past.at(-1)))));
1025
- // A candidate inside the initial recent cap can become related after budget
1026
- // eviction. Reserve before picking the final suffix so it can re-enter.
1027
- const reservable = relatedCap > 0 ? eligible.filter(turn => turn.tokens <= availableTokens
1028
- && turn.text.length <= availableRows
1029
- && (recentCap === 0 || !past.length || bucketBefore(turn, past.at(-1)))) : [];
1030
- const reserve = reservable.length ? Math.floor(availableTokens * 0.25) : 0;
1031
- const reservedRows = reservable.length
1032
- ? Math.max(Math.floor(availableRows * 0.25), Math.min(...reservable.map(turn => turn.text.length))) : 0;
1033
- function fitRecentTurn(turn, tokenLimit) {
1034
- if (turn.tokens <= tokenLimit) return turn.text;
1035
- const fitted = [];
1036
- let tokens = tokenLimit;
1037
- for (let index = 0; index < turn.text.length; index += 1) {
1038
- const remainingRows = turn.text.length - index;
1039
- const allowance = Math.max(2, Math.floor(tokens / remainingRows));
1040
- const message = shrinkMessageToBudget(turn.text[index], allowance);
1041
- const rows = dropEmptyAssistantRows([message]);
1042
- fitted.push(...rows);
1043
- tokens -= estimateMessagesTokens(rows);
1044
- }
1045
- return fitted;
1026
+ // Recent text has first claim on history budget. Drop whole oldest turns,
1027
+ // never reserve space for recall or truncate text to manufacture turn counts.
1028
+ const recent = [];
1029
+ let recentTokens = 0;
1030
+ let recentRows = 0;
1031
+ const recentCandidates = recentCap > 0 ? past.slice(-recentCap) : [];
1032
+ for (let index = recentCandidates.length - 1; index >= 0; index -= 1) {
1033
+ const turn = recentCandidates[index];
1034
+ if (recentTokens + turn.tokens > availableTokens
1035
+ || recentRows + turn.text.length > availableRows) break;
1036
+ recent.unshift(turn);
1037
+ recentTokens += turn.tokens;
1038
+ recentRows += turn.text.length;
1046
1039
  }
1047
- function selectRecent(limit, rowLimit = availableRows) {
1048
- const candidates = past.slice(-recentCap);
1049
- if (!candidates.length || limit <= 0 || rowLimit <= 0) return [];
1050
- const preserveBoundaries = candidates.length >= MINIMUM_RECENT_PROVIDER_TURNS
1051
- && rowLimit >= candidates.reduce((sum, turn) => sum + turn.text.length, 0)
1052
- && limit >= candidates.length * 2;
1053
- if (!preserveBoundaries) {
1054
- const selected = [];
1055
- let tokens = 0;
1056
- let rows = 0;
1057
- for (let index = candidates.length - 1; index >= 0; index -= 1) {
1058
- const turn = candidates[index];
1059
- if (tokens + turn.tokens > limit || rows + turn.text.length > rowLimit) break;
1060
- selected.unshift(turn);
1061
- tokens += turn.tokens;
1062
- rows += turn.text.length;
1063
- }
1064
- return selected;
1065
- }
1066
-
1067
- const selected = [];
1068
- let tokens = Math.floor(limit);
1069
- let rows = Math.floor(rowLimit);
1070
- for (const turn of candidates) {
1071
- const remainingTurns = candidates.length - selected.length;
1072
- if (rows < remainingTurns) break;
1073
- const allowance = Math.max(2, Math.floor(tokens / remainingTurns));
1074
- const fitted = fitRecentTurn(turn, allowance);
1075
- if (!fitted.length || fitted.length > rows - (remainingTurns - 1)) continue;
1076
- const used = estimateMessagesTokens(fitted);
1077
- selected.push({ ...turn, text: fitted, tokens: used });
1078
- tokens -= used;
1079
- rows -= fitted.length;
1080
- }
1081
- return selected;
1040
+ const minimumRecent = Math.min(MINIMUM_RECENT_PROVIDER_TURNS, recentCandidates.length);
1041
+ if (recent.length < minimumRecent) {
1042
+ const error = new Error(`Context budget cannot retain ${minimumRecent} complete recent history turns`);
1043
+ error.code = 'HISTORY_RECENT_BUDGET_EXCEEDED';
1044
+ throw error;
1082
1045
  }
1083
- let recent = selectRecent(availableTokens - reserve, availableRows - reservedRows);
1084
1046
  let remainingTokens = availableTokens - recent.reduce((total, turn) => total + turn.tokens, 0);
1085
1047
  let remainingRows = availableRows - recent.reduce((total, turn) => total + turn.text.length, 0);
1086
1048
  const related = [];
@@ -1098,34 +1060,15 @@ export function buildHistoryBuckets(snapshot, options = {}) {
1098
1060
  remainingTokens -= turn.tokens;
1099
1061
  remainingRows -= turn.text.length;
1100
1062
  }
1101
- if (!related.length) recent = selectRecent(availableTokens);
1102
- else {
1103
- // Pay the actual related cost, not the provisional reserve. Expand the
1104
- // recent suffix only while every related turn remains strictly older.
1105
- const expanded = selectRecent(
1106
- availableTokens - related.reduce((sum, turn) => sum + turn.tokens, 0),
1107
- availableRows - related.reduce((sum, turn) => sum + turn.text.length, 0),
1108
- );
1109
- while (expanded.length > recent.length && related.some(turn => (
1110
- !bucketBefore(turn, expanded[0]) || bucketOverlap(turn, expanded[0])
1111
- ))) expanded.shift();
1112
- if (expanded.length > recent.length) recent = expanded;
1113
- }
1114
1063
  related.sort((a, b) => bucketBefore(a, b) ? -1 : bucketBefore(b, a) ? 1 : 0);
1115
1064
 
1116
1065
  const relatedMessages = related.flatMap(turn => turn.text);
1117
- const recentText = recent.flatMap(turn => turn.messages)
1118
- .filter(isVisibleConversationRow);
1119
- const recentWasFitted = recent.some(turn => turn.text !== turn.messages
1120
- && (turn.text.length !== bucketTextMessages(turn.messages).length
1121
- || turn.tokens !== estimateMessagesTokens(bucketTextMessages(turn.messages))));
1122
- const recentBaseline = recent.flatMap(turn => recentWasFitted ? turn.text : bucketTextMessages(turn.messages));
1066
+ const recentBaseline = recent.flatMap(turn => turn.text);
1123
1067
  // Enrich only after both complete-text buckets and the active turn are paid.
1124
1068
  // Tool protocol is useful only for immediate continuity; unlike visible text,
1125
1069
  // it never reaches farther back than the configured recent tool window.
1126
- const toolCutIndex = indexOfNthTurnFromEnd(recentText, keepToolTurns);
1127
1070
  const recentToolSource = keepToolTurns > 0
1128
- ? recentText.slice(toolCutIndex < 0 ? 0 : toolCutIndex)
1071
+ ? recent.slice(-keepToolTurns).flatMap(turn => turn.messages).filter(isVisibleConversationRow)
1129
1072
  : [];
1130
1073
  const recentMessages = withoutHistorySourceIndexes(addOptionalRecentToolPairs(
1131
1074
  recentBaseline,
@@ -1154,7 +1097,8 @@ export function buildHistoryBuckets(snapshot, options = {}) {
1154
1097
  budget: {
1155
1098
  messageTokenBudget: tokenBudget, maxMessageCount: messageCap,
1156
1099
  recentTurnCap: recentCap, relatedTurnCap: relatedCap,
1157
- relatedReservedTokens: reserve, availableHistoryTokens: availableTokens,
1100
+ minimumRecentTurns: minimumRecent,
1101
+ relatedReservedTokens: 0, availableHistoryTokens: availableTokens,
1158
1102
  usedTokens: estimateMessagesTokens(messages), usedMessages: messages.length,
1159
1103
  },
1160
1104
  dropped: {
@@ -24,6 +24,7 @@ import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
24
24
  import { Engine } from './engine.js';
25
25
  import { loadSession } from './session.js';
26
26
  import { loadAgentMCPConfig, loadConfig, loadMCPConfig } from './config.js';
27
+ import { resolveMaxOutputTokens } from './models.js';
27
28
  import {
28
29
  createManagedProjectSkill,
29
30
  createManagedSkill,
@@ -160,7 +161,8 @@ const SKILL_RELOAD_INTERVAL_MS = 2_000;
160
161
  * loads the Session.
161
162
  * @type {Map<string, {
162
163
  * resolve:Function,
163
- * reject?:Function,
164
+ * reject:Function,
165
+ * conversationId:string|null,
164
166
  * sessionId:string,
165
167
  * vpId:string,
166
168
  * threadId:string,
@@ -176,6 +178,9 @@ const SKILL_RELOAD_INTERVAL_MS = 2_000;
176
178
  * onAbort?:Function,
177
179
  * }>} */
178
180
  const pendingUserPrompts = new Map();
181
+ // Instance-local retry receipts, never persisted with Session runtime data.
182
+ const terminalUserPrompts = new Map();
183
+ const MAX_TERMINAL_USER_PROMPTS = 256;
179
184
 
180
185
  /** @type {import('./session.js').Session | null} */
181
186
  let session = null;
@@ -2238,7 +2243,9 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2238
2243
  });
2239
2244
  }
2240
2245
 
2241
- if (related) {
2246
+ // An append shares the running query's request config. A quick send owns
2247
+ // a separate query, so retain its envelope in this thread's normal inbox.
2248
+ if (related && !envelope?._turnConfig) {
2242
2249
  const content = promptParts || prompt;
2243
2250
  const injectedBy = envelope?.msg?.meta?.injectedBy;
2244
2251
  const isInternalAppend = injectedBy === 'route_forward' || injectedBy === 'task_result';
@@ -3036,10 +3043,10 @@ function mergedStatusForProjectRuntime(runtime, ownerSession = session) {
3036
3043
  }
3037
3044
 
3038
3045
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
3039
- function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, requestId, requestClientId, perfTraceId } = {}) {
3046
+ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, requestId, requestClientId, perfTraceId, conversationId = yeaftConversationId } = {}) {
3040
3047
  sendToServer({
3041
3048
  type: 'yeaft_output',
3042
- conversationId: yeaftConversationId,
3049
+ conversationId,
3043
3050
  ...(perfTraceId ? { perfTraceId } : {}),
3044
3051
  ...(requestId ? { requestId } : {}),
3045
3052
  ...(requestClientId ? { _requestClientId: requestClientId } : {}),
@@ -3085,26 +3092,52 @@ function replayPendingUserPrompts(sessionId) {
3085
3092
  }
3086
3093
  }
3087
3094
 
3088
- function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false } = {}) {
3095
+ function registerPendingUserPrompt(requestId, pending) {
3096
+ pending.onAbort = () => settlePendingUserPrompt(requestId, pending, { aborted: true });
3097
+ pending.timer = setTimeout(() => {
3098
+ settlePendingUserPrompt(requestId, pending, { timedOut: true });
3099
+ }, Math.max(0, pending.expiresAt - Date.now()));
3100
+ pending.timer.unref?.();
3101
+ pendingUserPrompts.set(requestId, pending);
3102
+ if (pending.signal?.aborted) pending.onAbort();
3103
+ else pending.signal?.addEventListener('abort', pending.onAbort, { once: true });
3104
+ }
3105
+
3106
+ function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false, aborted = false } = {}) {
3089
3107
  if (pendingUserPrompts.get(requestId) !== pending) return false;
3090
3108
  pendingUserPrompts.delete(requestId);
3091
3109
  if (pending.timer) clearTimeout(pending.timer);
3092
3110
  if (pending.signal && pending.onAbort) {
3093
3111
  try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
3094
3112
  }
3095
- try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
3096
- sendSessionEvent({
3097
- type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
3113
+ if (!aborted) {
3114
+ try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
3115
+ }
3116
+ const expired = timedOut || aborted;
3117
+ const event = {
3118
+ type: expired ? 'ask_user_expired' : 'ask_user_answered',
3098
3119
  requestId,
3099
3120
  toolCallId: pending.toolCallId || null,
3100
- ...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
3101
- }, {
3121
+ ...(expired ? { expiredAt: Date.now() } : { answers: answers || {} }),
3122
+ };
3123
+ const identity = {
3124
+ requestId,
3125
+ toolCallId: pending.toolCallId,
3126
+ conversationId: pending.conversationId,
3102
3127
  sessionId: pending.sessionId,
3103
3128
  vpId: pending.vpId,
3104
3129
  threadId: pending.threadId,
3105
3130
  turnId: pending.turnId,
3106
- });
3107
- pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
3131
+ };
3132
+ terminalUserPrompts.set(requestId, { identity, event });
3133
+ while (terminalUserPrompts.size > MAX_TERMINAL_USER_PROMPTS) {
3134
+ terminalUserPrompts.delete(terminalUserPrompts.keys().next().value);
3135
+ }
3136
+ // Like prompt replay, live settlement belongs to the current projection,
3137
+ // not the conversation generation captured when the question was created.
3138
+ sendSessionEvent(event, { ...identity, conversationId: yeaftConversationId || pending.conversationId });
3139
+ if (aborted) pending.reject(new Error('aborted'));
3140
+ else pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
3108
3141
  return true;
3109
3142
  }
3110
3143
 
@@ -4744,6 +4777,27 @@ function handleEngineEvent(event, hctx) {
4744
4777
  * - No legacy "no-session" fallback paths — they were the source of the
4745
4778
  * router_unavailable bug fixed in v0.1.671.
4746
4779
  */
4780
+ /** Validate a one-message override against the loaded Agent catalog, never Session config. */
4781
+ export function validateQuickSend(value, config) {
4782
+ if (value === undefined) return null;
4783
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
4784
+ throw new Error('quickSend must be an object');
4785
+ }
4786
+ const { model, effort, maxOutputTokens } = value;
4787
+ const entry = typeof model === 'string' && model.includes('/')
4788
+ ? config?.availableModels?.find(candidate => candidate.ref === model)
4789
+ : null;
4790
+ if (!entry) throw new Error('quickSend.model must name an available provider/model');
4791
+ if (effort !== null && (typeof effort !== 'string' || !entry.effortOptions?.includes(effort))) {
4792
+ throw new Error('quickSend.effort is not supported by the selected model');
4793
+ }
4794
+ if (maxOutputTokens !== null && (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1
4795
+ || maxOutputTokens > resolveMaxOutputTokens(entry.id, { modelInfo: entry }))) {
4796
+ throw new Error('quickSend.maxOutputTokens must be null or a positive integer within the model output limit');
4797
+ }
4798
+ return { model, effort, maxOutputTokens };
4799
+ }
4800
+
4747
4801
  async function runYeaftSessionSend(msg) {
4748
4802
  if (!msg || typeof msg !== 'object') return;
4749
4803
  const { text } = msg;
@@ -4800,6 +4854,20 @@ async function runYeaftSessionSend(msg) {
4800
4854
  return;
4801
4855
  }
4802
4856
 
4857
+ // Reject malformed overrides before boot, roster changes, attachment writes,
4858
+ // or coordinator persistence. The error includes the client id for retry.
4859
+ let turnConfig = null;
4860
+ try {
4861
+ if (msg.quickSend !== undefined) {
4862
+ turnConfig = validateQuickSend(msg.quickSend, loadConfig({ dir: yeaftDir }));
4863
+ }
4864
+ } catch (err) {
4865
+ sendSessionOutputFrame({ type: 'error', code: 'invalid_quick_send',
4866
+ message: err.message, clientMessageId: msg.id || null }, { sessionId });
4867
+ sendSessionOutputFrame({ type: 'result', result_text: '', is_error: true }, { sessionId });
4868
+ return;
4869
+ }
4870
+
4803
4871
  // Open the user-level session metadata first so project runtime loading can
4804
4872
  // use the stored workDir without blocking the user-message hot path. If
4805
4873
  // metadata is missing we still boot the agent runtime for a useful error
@@ -4981,6 +5049,7 @@ async function runYeaftSessionSend(msg) {
4981
5049
  // Live form — adapters need the base64 image blocks; runVpTurn
4982
5050
  // reads `_promptParts` off the envelope rather than going
4983
5051
  // back to disk on every fan-out target. NOT persisted.
5052
+ _turnConfig: turnConfig,
4984
5053
  _promptParts: attachmentBundle.promptParts,
4985
5054
  _promptSuffix: attachmentBundle.promptSuffix,
4986
5055
  _perfTraceId: perfTraceId,
@@ -5166,6 +5235,12 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
5166
5235
  // envelope inside router.forward.
5167
5236
  if (envelope && typeof envelope === 'object') {
5168
5237
  out.inboundEnvelope = envelope;
5238
+ // Only direct delivery of this user message inherits quick-send settings.
5239
+ // RouteForward and background reentry keep the recipient's own defaults.
5240
+ if (envelope._turnConfig && !envelope.msg?.meta?.injectedBy
5241
+ && (envelope.msg?.role === 'user' || envelope.msg?.from === 'user')) {
5242
+ out.turnConfig = { ...envelope._turnConfig };
5243
+ }
5169
5244
  }
5170
5245
  // TodoWrite per-thread isolation. Bind closures that read/write a slot
5171
5246
  // keyed by `${sessionId}::${vpId}::${threadId}` so concurrent threads for
@@ -5720,6 +5795,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5720
5795
  const pending = {
5721
5796
  resolve,
5722
5797
  reject,
5798
+ conversationId: yeaftConversationId,
5723
5799
  sessionId,
5724
5800
  vpId,
5725
5801
  threadId,
@@ -5734,20 +5810,8 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5734
5810
  signal,
5735
5811
  onAbort: null,
5736
5812
  };
5737
- const onAbort = () => {
5738
- if (pendingUserPrompts.get(requestId) !== pending) return;
5739
- pendingUserPrompts.delete(requestId);
5740
- if (pending.timer) clearTimeout(pending.timer);
5741
- reject(new Error('aborted'));
5742
- };
5743
- pending.onAbort = onAbort;
5744
- pending.timer = setTimeout(() => {
5745
- settlePendingUserPrompt(requestId, pending, { timedOut: true });
5746
- }, ASK_USER_TIMEOUT_MS);
5747
- if (typeof pending.timer.unref === 'function') pending.timer.unref();
5748
- pendingUserPrompts.set(requestId, pending);
5749
- signal.addEventListener('abort', onAbort, { once: true });
5750
- sendPendingUserPrompt(requestId, pending);
5813
+ registerPendingUserPrompt(requestId, pending);
5814
+ if (pendingUserPrompts.has(requestId)) sendPendingUserPrompt(requestId, pending);
5751
5815
  }),
5752
5816
  threadId,
5753
5817
  vpTurnId: turnId,
@@ -6829,16 +6893,42 @@ export async function handleYeaftFetchDebugHistory(msg = {}) {
6829
6893
  }
6830
6894
 
6831
6895
  /** Resolve a pending Yeaft AskUser prompt from the web UI. */
6832
- export function handleYeaftAskUserAnswer(msg) {
6896
+ export function handleYeaftAskUserAnswer(msg = {}) {
6833
6897
  const requestId = typeof msg?.requestId === 'string' ? msg.requestId : '';
6898
+ const rejectAnswer = reason => {
6899
+ // Echo only submitted identity: never expose the actual pending scope.
6900
+ sendSessionEvent({
6901
+ type: 'ask_user_answer_rejected', requestId,
6902
+ toolCallId: msg?.toolCallId || null, reason,
6903
+ }, {
6904
+ requestId, requestClientId: msg?._requestClientId,
6905
+ conversationId: msg?.conversationId ?? null,
6906
+ sessionId: msg?.sessionId, vpId: msg?.vpId,
6907
+ turnId: msg?.turnId, threadId: msg?.threadId,
6908
+ });
6909
+ return false;
6910
+ };
6834
6911
  const pending = pendingUserPrompts.get(requestId);
6835
- if (!pending) return false;
6836
- if (msg.sessionId && msg.sessionId !== pending.sessionId) return false;
6837
- if (msg.vpId && msg.vpId !== pending.vpId) return false;
6838
- if (msg.turnId && msg.turnId !== pending.turnId) return false;
6839
- if (msg.threadId && msg.threadId !== pending.threadId) return false;
6840
- if (msg.toolCallId && msg.toolCallId !== pending.toolCallId) return false;
6841
-
6912
+ const terminal = terminalUserPrompts.get(requestId);
6913
+ const identity = pending || terminal?.identity;
6914
+ if (!identity) return rejectAnswer('unavailable');
6915
+ // Retain legacy optional identity matching. conversationId is a projection
6916
+ // generation, not Session ownership; it can change after a reconnect.
6917
+ for (const field of ['sessionId', 'vpId', 'turnId', 'threadId', 'toolCallId']) {
6918
+ if (msg[field] && msg[field] !== identity[field]) return rejectAnswer('identity_mismatch');
6919
+ }
6920
+ if (!pending) {
6921
+ sendSessionEvent(terminal.event, {
6922
+ ...terminal.identity,
6923
+ conversationId: msg.conversationId ?? terminal.identity.conversationId,
6924
+ requestClientId: msg._requestClientId,
6925
+ });
6926
+ return terminal.event.type === 'ask_user_answered' ? true : rejectAnswer('unavailable');
6927
+ }
6928
+ if (pending.expiresAt <= Date.now()) {
6929
+ settlePendingUserPrompt(requestId, pending, { timedOut: true });
6930
+ return rejectAnswer('unavailable');
6931
+ }
6842
6932
  return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
6843
6933
  }
6844
6934
 
@@ -8211,11 +8301,10 @@ export const __testHooks = {
8211
8301
  vpAborts.clear();
8212
8302
  vpInboxes.clear();
8213
8303
  },
8214
- seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', toolCallId = 'call-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
8215
- let resolved;
8216
- const promise = new Promise(resolve => { resolved = resolve; });
8217
- pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, toolCallId, question, options, createdAt, expiresAt, timer: null });
8218
- return promise;
8304
+ seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', toolCallId = 'call-test', conversationId = yeaftConversationId, question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS, signal = null } = {}) {
8305
+ return new Promise((resolve, reject) => {
8306
+ registerPendingUserPrompt(requestId, { resolve, reject, conversationId, sessionId, vpId, threadId, turnId, toolCallId, question, options, createdAt, expiresAt, signal });
8307
+ });
8219
8308
  },
8220
8309
  replayPendingUserPrompts,
8221
8310
  settlePendingUserPromptForTest(requestId, opts = {}) {
@@ -8225,8 +8314,10 @@ export const __testHooks = {
8225
8314
  resetPendingUserPrompts() {
8226
8315
  for (const pending of pendingUserPrompts.values()) {
8227
8316
  if (pending.timer) clearTimeout(pending.timer);
8317
+ if (pending.onAbort) pending.signal?.removeEventListener('abort', pending.onAbort);
8228
8318
  }
8229
8319
  pendingUserPrompts.clear();
8320
+ terminalUserPrompts.clear();
8230
8321
  },
8231
8322
  resetVpStatusBroker() {
8232
8323
  if (vpStatusBroker) vpStatusBroker.reset();