dominds 1.16.7 → 1.16.8

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.
@@ -51,6 +51,7 @@ const evt_registry_1 = require("./evt-registry");
51
51
  const log_1 = require("./log");
52
52
  const persistence_1 = require("./persistence");
53
53
  const persistence_errors_1 = require("./persistence-errors");
54
+ const interjection_pause_stop_1 = require("./runtime/interjection-pause-stop");
54
55
  const log = (0, log_1.createLogger)('dialog-display-state');
55
56
  let broadcastToClients;
56
57
  const activeRunsByDialogKey = new Map();
@@ -128,11 +129,21 @@ async function getRunControlCountsSnapshot() {
128
129
  }
129
130
  else if (latest?.executionMarker?.kind === 'interrupted' &&
130
131
  isStoppedReasonResumable(latest.executionMarker.reason)) {
131
- const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialogId, 'running');
132
- const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialogId, 'running');
133
- if (q4h.length === 0 && pendingSubdialogs.length === 0) {
132
+ // Keep run-control counts aligned with actual Continue affordance:
133
+ // - ordinary interrupted dialogs count as resumable only when no blocker remains
134
+ // - interjection-paused dialogs still count as resumable even if blocker facts remain,
135
+ // because the intended UX is that Continue exits the temporary paused projection
136
+ // and re-evaluates the original task from fresh facts
137
+ if ((0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
134
138
  resumable++;
135
139
  }
140
+ else {
141
+ const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialogId, 'running');
142
+ const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialogId, 'running');
143
+ if (q4h.length === 0 && pendingSubdialogs.length === 0) {
144
+ resumable++;
145
+ }
146
+ }
136
147
  }
137
148
  }
138
149
  catch (error) {
@@ -470,6 +481,27 @@ async function refreshRunControlProjectionFromPersistenceFacts(dialogId, trigger
470
481
  latest.executionMarker.kind === 'dead') {
471
482
  return { kind: 'dead', reason: latest.executionMarker.reason };
472
483
  }
484
+ if (latest.executionMarker?.kind === 'interrupted' &&
485
+ (0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
486
+ // WARNING:
487
+ // This is the one place where the projection intentionally preserves the paused-interjection
488
+ // stopped state ahead of the current blocker facts. That is not a bug: after a user
489
+ // interjection we want the UI to keep showing "original task paused; click Continue" even if
490
+ // the underlying dialog is still waiting on Q4H/subdialogs.
491
+ //
492
+ // The true source-of-truth decision about what Continue should do next lives in
493
+ // `flow.ts`'s resume path, which performs a fresh fact scan at resume time and then either:
494
+ // - restores `blocked`, or
495
+ // - keeps driving immediately.
496
+ //
497
+ // Do not "heal" this branch away by prioritizing blocker facts here; that would collapse the
498
+ // temporary interjection UX and make repeated interjection turns revert too early.
499
+ return {
500
+ kind: 'stopped',
501
+ reason: latest.executionMarker.reason,
502
+ continueEnabled: isStoppedReasonResumable(latest.executionMarker.reason),
503
+ };
504
+ }
473
505
  const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialogId, 'running');
474
506
  const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialogId, 'running');
475
507
  const hasQ4H = q4h.length > 0;
@@ -155,6 +155,51 @@ Dialog state is persisted to storage at key points:
155
155
 
156
156
  This ensures crash recovery and enables the backend to resume from any persisted state without depending on frontend state.
157
157
 
158
+ ### User Interjection Pause And Continue Semantics
159
+
160
+ When a dialog still carries an inter-dialog reply obligation, but the user temporarily interjects and asks it to handle a local question first, the system must distinguish between the **UI projection** and the **true driving source state**.
161
+
162
+ **Normative semantics**:
163
+
164
+ 1. Every user interjection message is driven as a complete normal round.
165
+ 2. If that round needs tools, the system MUST finish the full tool round and any post-tool follow-up before pausing.
166
+ 3. The system only projects the original task as resumable `stopped` when this interjection has actually parked an original task that still needs explicit restoration.
167
+ 4. If there is no parked original task to resume afterwards (for example, no inter-dialog reply obligation needs reassertion), the interjection round should simply finish and return to the true underlying state without showing this special `stopped` panel.
168
+ 5. As long as the user keeps sending new messages, the dialog stays in temporary interjection-chat handling, and that paused projection remains in place only if it was established in the first place.
169
+ 6. Only an explicit UI `Continue` attempts to restore the original task.
170
+
171
+ **Key point**: this `stopped` state is only a temporary run-control / UI projection. It is not the same as an ordinary system-stop failure, and it is not the final business source of truth. It also does not apply to every interjection; it exists only when there really is a parked original task to resume.
172
+
173
+ After the user clicks `Continue`, the backend MUST re-evaluate fresh persistence facts and decide which true-source case now applies. It must not infer the result purely from the visible `displayState`:
174
+
175
+ - **Case 1: the dialog no longer has a reply obligation**
176
+ If there is also no blocker, the dialog should simply continue driving. If it has already become ordinary idle-waiting-user, then `resume_dialog` is no longer actually resumable.
177
+ - **Case 2: the dialog still has a reply obligation and is still suspended**
178
+ Typical examples are pending Q4H or pending subdialogs. In this case, `Continue` should exit the interjection-paused projection and restore the true `blocked` state.
179
+ - **Case 3: the dialog still has a reply obligation but is no longer suspended and is eligible to proceed**
180
+ For example, the blocker has disappeared, or a queued prompt provides a valid continuation path. In this case, `Continue` must not first fall back to an intermediate placeholder `blocked/idle` state; it should keep driving immediately.
181
+
182
+ **This leads to two implementation constraints**:
183
+
184
+ - `refreshRunControlProjectionFromPersistenceFacts()` MUST preserve the special "interjection handled; original task paused" `stopped` projection until the user explicitly clicks `Continue`; otherwise the UI collapses back to ordinary `blocked` too early and breaks multi-turn interjection UX. Conversely, when there is no parked original task, this paused projection should not be created at all.
185
+ - The actual outcome of `Continue` MUST be decided in the resume drive path by re-reading fresh persistence facts. "Continue is clickable" does not mean "the dialog will definitely enter proceeding immediately".
186
+ - The run-control toolbar's `resumable` count should align with "manual Continue attempt is meaningful". Therefore an interjection-paused `stopped` dialog still counts as resumable even when underlying blocker facts remain, because the business meaning of `Continue` there is "exit the temporary paused projection and re-evaluate from source-of-truth facts".
187
+
188
+ **Mental-model warning**:
189
+
190
+ - Do not reason about this flow from `displayState.kind === 'stopped'` alone.
191
+ - Do not reason about it from blocker facts alone and then wonder why the UI still shows `stopped`.
192
+ - Do not reason about it from `resume_dialog` eligibility alone and assume resumption always means immediate running.
193
+
194
+ You need all of the following together to understand the behavior correctly:
195
+
196
+ - reply-guidance suppression / deferred reassertion for interjection turns
197
+ - flow logic for "pause after local interjection reply" plus "fresh-fact second decision after Continue"
198
+ - dialog-display-state projection preservation
199
+ - websocket resume entry semantics distinguishing "allowed to attempt Continue" from "actually re-entered driving"
200
+
201
+ This is an intentionally cross-module semantic contract. Do not locally "simplify" one piece based only on its surface meaning.
202
+
158
203
  ---
159
204
 
160
205
  ## 3-Type Teammate Tellask Taxonomy
@@ -154,6 +154,51 @@
154
154
 
155
155
  这确保了崩溃恢复,并使后端能够从任何持久化状态恢复,而不依赖于前端状态。
156
156
 
157
+ ### 用户插话暂停与 Continue 语义
158
+
159
+ 当某个对话仍带有跨对话回复义务,但用户临时插话要求它先处理本地问题时,系统必须区分**UI 投影**与**真实驱动源状态**。
160
+
161
+ **规范语义**:
162
+
163
+ 1. 每条用户插话消息都按正常驱动轮完整执行。
164
+ 2. 若该轮需要工具,则必须先完整跑完该工具轮及其 post-tool follow-up。
165
+ 3. 只有当这条插话确实打断了一个仍待恢复的“原任务”时,系统才把该原任务投影为可 `Continue` 的 `stopped`,让用户先看到最后一条回复。
166
+ 4. 若当前并不存在待恢复的原任务(例如没有待重申的跨对话回复义务),则插话轮结束后应直接回到真实 underlying state,而不显示这个特殊 `stopped` 面板。
167
+ 5. 只要用户继续发送新消息,就继续作为插话临时对话处理;这个 paused projection 仅在它已被建立时持续保持。
168
+ 6. 只有用户显式点击 UI `Continue`,系统才尝试恢复原任务。
169
+
170
+ **关键点**:这里的 `stopped` 只是一个临时 run-control / UI 投影,不等于普通 system-stop 失败,也不是最终的业务真源;并且它不是所有插话都会出现,只在“确有一个待恢复的原任务被临时停靠”时出现。
171
+
172
+ 点击 `Continue` 后,后端必须重新从 persistence 真源判定当前对话属于哪一种情况,而不能只根据表面的 `displayState` 做静态推断:
173
+
174
+ - **情况 1:当前对话没有回复义务**
175
+ 这时若也没有其他 blocker,就应直接继续 drive;若已回到普通待用户输入态,则 `resume_dialog` 不应再被视为可继续。
176
+ - **情况 2:当前对话仍有回复义务,但处于 suspend 状态**
177
+ 常见于仍在等待 Q4H / pending subdialogs。此时 `Continue` 应退出插话 paused projection,并恢复成真实的 `blocked`。
178
+ - **情况 3:当前对话仍有回复义务,但已不再 suspend,具备继续推进条件**
179
+ 例如 blocker 已消失,或存在允许继续的 queued prompt。此时 `Continue` 不应先落一个中间 `blocked/idle` 占位态,而应直接继续 drive。
180
+
181
+ **因此有两个实现约束**:
182
+
183
+ - `refreshRunControlProjectionFromPersistenceFacts()` 在用户尚未点击 `Continue` 前,必须保留这层“插话已处理;原任务已暂停”的 `stopped` 投影;否则 UI 会过早塌回普通 `blocked`,破坏多轮插话体验。反过来,如果当前其实没有待恢复原任务,则根本不应建立这层 paused projection。
184
+ - 真正决定 `Continue` 结果的逻辑,必须在恢复驱动路径中重新读取 fresh persistence facts;不能把“可点 Continue”误解为“必然立即 proceeding”。
185
+ - run-control 工具栏中的 `resumable` 计数,应与“是否允许手动 Continue 尝试”保持一致。因此,处于 interjection-paused `stopped` 的对话即便底层仍有 blocker,也应计入 `resumable`;因为 `Continue` 的业务语义正是“退出这层临时 paused projection,并从真源重判下一步”。
186
+
187
+ **心智模型提醒**:
188
+
189
+ - 不能只看 `displayState.kind === 'stopped'` 就理解这条链路。
190
+ - 不能只看 blocker facts 就理解为什么 UI 仍显示 `stopped`。
191
+ - 也不能只看 `resume_dialog` eligibility 就推断恢复后一定马上运行。
192
+
193
+ 必须把以下几块一起看,才能形成完整且精确的理解:
194
+
195
+ - reply-guidance 中对插话轮的回复义务 suppression / deferred reassertion
196
+ - flow 中“插话回复后停车”与“Continue 后 fresh fact 二次判定”
197
+ - dialog-display-state 中 paused projection 的保留策略
198
+ - websocket resume 入口对“可尝试 Continue”与“实际已恢复 drive”的区分
199
+
200
+ 这是一条跨模块协同语义,不允许在单点上做“表面看起来更简单”的局部简化。
201
+
157
202
  ---
158
203
 
159
204
  ## 三类队友诉请分类
@@ -10,6 +10,7 @@ const log_1 = require("../../log");
10
10
  const load_1 = require("../../minds/load");
11
11
  const persistence_1 = require("../../persistence");
12
12
  const driver_messages_1 = require("../../runtime/driver-messages");
13
+ const interjection_pause_stop_1 = require("../../runtime/interjection-pause-stop");
13
14
  const reply_prompt_copy_1 = require("../../runtime/reply-prompt-copy");
14
15
  const work_language_1 = require("../../runtime/work-language");
15
16
  const client_1 = require("../client");
@@ -40,6 +41,30 @@ async function buildReplyToolReminderPrompt(args) {
40
41
  }),
41
42
  });
42
43
  }
44
+ async function loadFreshSuspensionStatusFromPersistence(dialog) {
45
+ const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialog.id, dialog.status);
46
+ const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialog.id, dialog.status);
47
+ const hasQ4H = q4h.length > 0;
48
+ const hasSubdialogs = pendingSubdialogs.length > 0;
49
+ return {
50
+ q4h: hasQ4H,
51
+ subdialogs: hasSubdialogs,
52
+ blockingSubdialogs: hasSubdialogs,
53
+ canDrive: !hasQ4H && !hasSubdialogs,
54
+ };
55
+ }
56
+ function buildDisplayStateFromSuspensionStatus(args) {
57
+ if (args.q4h && args.subdialogs) {
58
+ return { kind: 'blocked', reason: { kind: 'needs_human_input_and_subdialogs' } };
59
+ }
60
+ if (args.q4h) {
61
+ return { kind: 'blocked', reason: { kind: 'needs_human_input' } };
62
+ }
63
+ if (args.subdialogs) {
64
+ return { kind: 'blocked', reason: { kind: 'waiting_for_subdialogs' } };
65
+ }
66
+ return { kind: 'idle_waiting_user' };
67
+ }
43
68
  async function loadPendingDiagnosticsSnapshot(args) {
44
69
  const ownerDialogIdObj = new dialog_1.DialogID(args.ownerDialogId, args.rootId);
45
70
  try {
@@ -304,6 +329,8 @@ async function executeDriveRound(args) {
304
329
  let subdialogReplyTarget;
305
330
  let activeTellaskReplyDirective;
306
331
  let activePromptWasReplyToolReminder = false;
332
+ let shouldPauseAfterLocalUserInterjection = false;
333
+ let resumeFromInterjectionPause = false;
307
334
  const allowResumeFromInterrupted = driveOptions?.allowResumeFromInterrupted === true || humanPrompt?.origin === 'user';
308
335
  const driveSource = resolveDriveRequestSource(humanPrompt, driveOptions);
309
336
  try {
@@ -340,6 +367,11 @@ async function executeDriveRound(args) {
340
367
  });
341
368
  return;
342
369
  }
370
+ resumeFromInterjectionPause =
371
+ humanPrompt === undefined &&
372
+ allowResumeFromInterrupted &&
373
+ latest?.executionMarker?.kind === 'interrupted' &&
374
+ (0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason);
343
375
  }
344
376
  catch (err) {
345
377
  log_1.log.warn('kernel-driver failed to check execution facts before drive; proceeding best-effort', err, {
@@ -382,10 +414,39 @@ async function executeDriveRound(args) {
382
414
  return;
383
415
  }
384
416
  }
385
- const suspension = await dialog.getSuspensionStatus();
417
+ // WARNING:
418
+ // `allowResumeFromInterrupted` covers multiple stop reasons, but the interjection-pause case
419
+ // is semantically special. Clicking Continue here does NOT mean "blindly clear stopped and
420
+ // drive". We must re-read the fresh persistence facts first because there are three distinct
421
+ // true-source cases behind the same visible stopped panel:
422
+ // - no active reply obligation / not suspended anymore -> continue real driving now
423
+ // - active reply obligation + suspended -> restore true blocked state
424
+ // - active reply obligation + still proceeding entitlement (for example queued upNext) ->
425
+ // continue real driving now
426
+ //
427
+ // Do not refactor this branch using only `displayState` or only the previous interrupted
428
+ // marker. The correct behavior emerges from combining fresh blocker facts, queued prompt
429
+ // state, and the deferred reply reassertion logic elsewhere.
430
+ const suspension = resumeFromInterjectionPause
431
+ ? await loadFreshSuspensionStatusFromPersistence(dialog)
432
+ : await dialog.getSuspensionStatus();
386
433
  const queuedPrompt = dialog.peekUpNext();
387
434
  const queuedSubdialogPromptCanResume = dialog instanceof dialog_1.SubDialog && queuedPrompt !== undefined;
388
435
  if (!suspension.canDrive && !queuedSubdialogPromptCanResume) {
436
+ if (resumeFromInterjectionPause) {
437
+ const restoredState = buildDisplayStateFromSuspensionStatus({
438
+ q4h: suspension.q4h,
439
+ subdialogs: suspension.subdialogs,
440
+ });
441
+ await (0, dialog_display_state_1.setDialogDisplayState)(dialog.id, restoredState);
442
+ log_1.log.debug('kernel-driver continue after interjection pause restored true suspended state from fresh persistence facts', undefined, {
443
+ dialogId: dialog.id.valueOf(),
444
+ restoredState,
445
+ waitingQ4H: suspension.q4h,
446
+ waitingSubdialogs: suspension.subdialogs,
447
+ });
448
+ return;
449
+ }
389
450
  const lastTrigger = dialog_global_registry_1.globalDialogRegistry.getLastDriveTrigger(dialog.id.rootId);
390
451
  const lastTriggerAgeMs = lastTrigger !== undefined ? Math.max(0, Date.now() - lastTrigger.emittedAtMs) : undefined;
391
452
  log_1.log.debug('kernel-driver skip queued auto-drive while dialog is suspended', undefined, {
@@ -413,6 +474,15 @@ async function executeDriveRound(args) {
413
474
  });
414
475
  return;
415
476
  }
477
+ if (resumeFromInterjectionPause) {
478
+ log_1.log.debug('kernel-driver continue after interjection pause passed fresh fact scan and will keep driving', undefined, {
479
+ dialogId: dialog.id.valueOf(),
480
+ waitingQ4H: suspension.q4h,
481
+ waitingSubdialogs: suspension.subdialogs,
482
+ hasQueuedUpNext: dialog.hasUpNext(),
483
+ queuedSubdialogPromptCanResume,
484
+ });
485
+ }
416
486
  }
417
487
  const minds = await (0, load_1.loadAgentMinds)(dialog.agentId, dialog);
418
488
  const policy = (0, guardrails_1.buildKernelDriverPolicy)({
@@ -508,6 +578,14 @@ async function executeDriveRound(args) {
508
578
  dlg: dialog,
509
579
  prompt: effectivePrompt,
510
580
  });
581
+ // Only park into the special interjection stopped-panel state when this user turn has
582
+ // suppressed a still-pending inter-dialog reply obligation that must be reasserted later.
583
+ // User interjections without a parked original task should simply finish and fall back to the
584
+ // dialog's true underlying state, without showing the special stopped panel.
585
+ shouldPauseAfterLocalUserInterjection =
586
+ effectivePrompt?.origin === 'user' &&
587
+ replyGuidance.suppressInterDialogReplyGuidance &&
588
+ replyGuidance.deferredReplyReassertionDirective !== undefined;
511
589
  activeTellaskReplyDirective = replyGuidance.activeReplyDirective;
512
590
  activePromptWasReplyToolReminder = isReplyToolReminderPrompt(effectivePrompt);
513
591
  if (effectivePrompt && effectivePrompt.userLanguageCode) {
@@ -573,28 +651,64 @@ async function executeDriveRound(args) {
573
651
  });
574
652
  }
575
653
  else {
576
- if (typeof driveResult.lastAssistantSayingGenseq !== 'number' ||
577
- !Number.isFinite(driveResult.lastAssistantSayingGenseq) ||
578
- driveResult.lastAssistantSayingGenseq <= 0) {
579
- throw new Error(`Subdialog response supply invariant violation: missing lastAssistantSayingGenseq for dialog=${dialog.id.valueOf()}`);
580
- }
581
- const responseGenseq = Math.floor(driveResult.lastAssistantSayingGenseq);
582
- const directFallbackCallId = `direct-fallback-${(0, id_1.generateShortId)()}`;
583
- let supplied = false;
584
- if (subdialogReplyTarget) {
585
- supplied = await (0, subdialog_1.supplySubdialogResponseToSpecificCallerIfPendingV2)({
586
- subdialog: dialog,
587
- responseText: driveResult.lastAssistantSayingContent,
588
- responseGenseq,
589
- target: subdialogReplyTarget,
590
- deliveryMode: 'direct_fallback',
591
- replyResolution: {
592
- callId: directFallbackCallId,
593
- replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
594
- },
595
- scheduleDrive: args.scheduleDrive,
654
+ if (!activePromptWasReplyToolReminder) {
655
+ const language = (0, work_language_1.getWorkLanguage)();
656
+ followUp = {
657
+ prompt: await buildReplyToolReminderPrompt({
658
+ dlg: dialog,
659
+ directive: activeTellaskReplyDirective,
660
+ language,
661
+ }),
662
+ msgId: (0, id_1.generateShortId)(),
663
+ grammar: 'markdown',
664
+ origin: 'runtime',
665
+ userLanguageCode: language,
666
+ tellaskReplyDirective: activeTellaskReplyDirective,
667
+ subdialogReplyTarget,
668
+ };
669
+ log_1.log.debug('kernel-driver queued subdialog replyTellask reminder after plain reply', undefined, {
670
+ dialogId: dialog.id.valueOf(),
671
+ targetCallId: activeTellaskReplyDirective.targetCallId,
672
+ targetOwnerDialogId: subdialogReplyTarget?.ownerDialogId,
596
673
  });
597
- if (!supplied) {
674
+ }
675
+ else {
676
+ if (typeof driveResult.lastAssistantSayingGenseq !== 'number' ||
677
+ !Number.isFinite(driveResult.lastAssistantSayingGenseq) ||
678
+ driveResult.lastAssistantSayingGenseq <= 0) {
679
+ throw new Error(`Subdialog response supply invariant violation: missing lastAssistantSayingGenseq for dialog=${dialog.id.valueOf()}`);
680
+ }
681
+ const responseGenseq = Math.floor(driveResult.lastAssistantSayingGenseq);
682
+ const directFallbackCallId = `direct-fallback-${(0, id_1.generateShortId)()}`;
683
+ let supplied = false;
684
+ if (subdialogReplyTarget) {
685
+ supplied = await (0, subdialog_1.supplySubdialogResponseToSpecificCallerIfPendingV2)({
686
+ subdialog: dialog,
687
+ responseText: driveResult.lastAssistantSayingContent,
688
+ responseGenseq,
689
+ target: subdialogReplyTarget,
690
+ deliveryMode: 'direct_fallback',
691
+ replyResolution: {
692
+ callId: directFallbackCallId,
693
+ replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
694
+ },
695
+ scheduleDrive: args.scheduleDrive,
696
+ });
697
+ if (!supplied) {
698
+ supplied = await (0, subdialog_1.supplySubdialogResponseToAssignedCallerIfPendingV2)({
699
+ subdialog: dialog,
700
+ responseText: driveResult.lastAssistantSayingContent,
701
+ responseGenseq,
702
+ deliveryMode: 'direct_fallback',
703
+ replyResolution: {
704
+ callId: directFallbackCallId,
705
+ replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
706
+ },
707
+ scheduleDrive: args.scheduleDrive,
708
+ });
709
+ }
710
+ }
711
+ else {
598
712
  supplied = await (0, subdialog_1.supplySubdialogResponseToAssignedCallerIfPendingV2)({
599
713
  subdialog: dialog,
600
714
  responseText: driveResult.lastAssistantSayingContent,
@@ -607,35 +721,21 @@ async function executeDriveRound(args) {
607
721
  scheduleDrive: args.scheduleDrive,
608
722
  });
609
723
  }
610
- }
611
- else {
612
- supplied = await (0, subdialog_1.supplySubdialogResponseToAssignedCallerIfPendingV2)({
613
- subdialog: dialog,
614
- responseText: driveResult.lastAssistantSayingContent,
615
- responseGenseq,
616
- deliveryMode: 'direct_fallback',
617
- replyResolution: {
618
- callId: directFallbackCallId,
619
- replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
620
- },
621
- scheduleDrive: args.scheduleDrive,
622
- });
623
- }
624
- if (!supplied && subdialogReplyTarget) {
625
- const diagnostics = await loadPendingDiagnosticsSnapshot({
626
- rootId: dialog.id.rootId,
627
- ownerDialogId: subdialogReplyTarget.ownerDialogId,
628
- expectedSubdialogId: dialog.id.selfId,
629
- status: dialog.status,
630
- });
631
- log_1.log.debug('kernel-driver failed to supply subdialog response to specific caller', undefined, {
632
- calleeId: dialog.id.valueOf(),
633
- targetOwner: subdialogReplyTarget.ownerDialogId,
634
- targetOwnerDialogId: subdialogReplyTarget.ownerDialogId,
635
- targetCallType: subdialogReplyTarget.callType,
636
- targetCallId: subdialogReplyTarget.callId,
637
- diagnostics,
638
- });
724
+ if (!supplied && subdialogReplyTarget) {
725
+ const diagnostics = await loadPendingDiagnosticsSnapshot({
726
+ rootId: dialog.id.rootId,
727
+ ownerDialogId: subdialogReplyTarget.ownerDialogId,
728
+ expectedSubdialogId: dialog.id.selfId,
729
+ status: dialog.status,
730
+ });
731
+ log_1.log.debug('kernel-driver failed to supply subdialog response to specific caller', undefined, {
732
+ calleeId: dialog.id.valueOf(),
733
+ targetOwnerDialogId: subdialogReplyTarget.ownerDialogId,
734
+ targetCallType: subdialogReplyTarget.callType,
735
+ targetCallId: subdialogReplyTarget.callId,
736
+ diagnostics,
737
+ });
738
+ }
639
739
  }
640
740
  }
641
741
  }
@@ -716,6 +816,26 @@ async function executeDriveRound(args) {
716
816
  },
717
817
  });
718
818
  }
819
+ if (shouldPauseAfterLocalUserInterjection &&
820
+ !interruptedBySignal &&
821
+ followUp === undefined &&
822
+ driveResult?.lastAssistantSayingContent !== null) {
823
+ const pauseReason = (0, interjection_pause_stop_1.buildUserInterjectionPauseStopReason)();
824
+ await (0, dialog_display_state_1.setDialogDisplayState)(dialog.id, {
825
+ kind: 'stopped',
826
+ reason: pauseReason,
827
+ continueEnabled: true,
828
+ });
829
+ (0, dialog_display_state_1.broadcastDisplayStateMarker)(dialog.id, {
830
+ kind: 'interrupted',
831
+ reason: pauseReason,
832
+ });
833
+ log_1.log.debug('kernel-driver paused original task after local user interjection reply', undefined, {
834
+ dialogId: dialog.id.valueOf(),
835
+ rootId: dialog.id.rootId,
836
+ selfId: dialog.id.selfId,
837
+ });
838
+ }
719
839
  }
720
840
  catch (error) {
721
841
  tailError = error;
@@ -6,6 +6,8 @@ exports.buildReplyObligationSuppressionGuide = buildReplyObligationSuppressionGu
6
6
  exports.buildReplyObligationReassertionPrompt = buildReplyObligationReassertionPrompt;
7
7
  const dialog_1 = require("../../dialog");
8
8
  const dialog_instance_registry_1 = require("../../dialog-instance-registry");
9
+ const persistence_1 = require("../../persistence");
10
+ const interjection_pause_stop_1 = require("../../runtime/interjection-pause-stop");
9
11
  const reply_prompt_copy_1 = require("../../runtime/reply-prompt-copy");
10
12
  const work_language_1 = require("../../runtime/work-language");
11
13
  const tellask_special_1 = require("./tellask-special");
@@ -81,6 +83,17 @@ function buildPromptContentWithExactReplyToolName(args) {
81
83
  return `${note}\n\n${args.prompt.content}`;
82
84
  }
83
85
  async function shouldSuppressInterDialogReplyGuidanceForUserInterjection(args) {
86
+ // WARNING:
87
+ // This suppression decision is not a cosmetic prompt tweak. It is one leg of the full
88
+ // interjection-pause state machine:
89
+ // 1. user interjection suppresses the live reply obligation here;
90
+ // 2. `flow.ts` answers locally and parks the original task in a resumable stopped state;
91
+ // 3. manual Continue later decides from fresh persistence facts whether the dialog should stay
92
+ // blocked or resume real driving.
93
+ //
94
+ // Do not "simplify" this into a pure display-state check or a pure pending-subdialog check.
95
+ // The business anchor is the deferred reply reassertion, while the paused execution marker keeps
96
+ // repeated interjection turns behaving as local side conversation until explicit Continue.
84
97
  const prompt = args.prompt;
85
98
  if (!prompt) {
86
99
  return false;
@@ -91,7 +104,18 @@ async function shouldSuppressInterDialogReplyGuidanceForUserInterjection(args) {
91
104
  if (prompt.tellaskReplyDirective !== undefined) {
92
105
  return false;
93
106
  }
94
- return await args.dlg.hasPendingSubdialogs();
107
+ const latest = await persistence_1.DialogPersistence.loadDialogLatest(args.dlg.id, args.dlg.status);
108
+ if (latest?.deferredReplyReassertion?.reason === 'user_interjection_while_pending_subdialog') {
109
+ return true;
110
+ }
111
+ if (latest?.executionMarker?.kind === 'interrupted' &&
112
+ (0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
113
+ return true;
114
+ }
115
+ // Use strict persistence reads here. This branch changes business behavior, so a read failure
116
+ // must loud-fail the round instead of being silently treated as "pending subdialogs exist".
117
+ const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(args.dlg.id, args.dlg.status);
118
+ return pendingSubdialogs.length > 0;
95
119
  }
96
120
  async function resolvePromptReplyGuidance(args) {
97
121
  const prompt = args.prompt;
@@ -140,8 +140,8 @@ async function deliverTellaskBackReplyFromDirective(args) {
140
140
  // the dialog running `replyTellaskBack` is the ask-back responder, while
141
141
  // directive.targetDialogId points to the ask-back requester that must receive the canonical
142
142
  // tellaskBack result. Keep those roles explicit, otherwise it is very easy to accidentally
143
- // write the same business result once from the reply-tool path and then again from a fallback
144
- // path that treats the responder's plain assistant words as if they were the canonical reply.
143
+ // write the same business result twice by confusing the responder's local plaintext with the
144
+ // canonical upstream delivery that must come only from an explicit reply tool call.
145
145
  const rootDialog = args.replyingDialog instanceof dialog_1.RootDialog
146
146
  ? args.replyingDialog
147
147
  : args.replyingDialog instanceof dialog_1.SubDialog
@@ -910,9 +910,6 @@ function findDeliveredTellaskBackReplyOnAskBackRequester(args) {
910
910
  return undefined;
911
911
  }
912
912
  async function extractAskBackResponderPlaintextFallback(args) {
913
- // This fallback is intentionally second-class: it exists only for legacy/plain-reply flows
914
- // where no explicit `replyTellaskBack` canonical result has been delivered. It must never
915
- // compete with or overwrite an already delivered canonical tellaskBack result.
916
913
  try {
917
914
  return extractLastAssistantResponse(args.responderDialog.msgs, 'Supdialog completed without producing output.');
918
915
  }
@@ -1253,6 +1250,7 @@ async function executeTellaskCall(dlg, mentionList, body, callId, callbacks, opt
1253
1250
  tellaskContent: body,
1254
1251
  responseBody: responseText,
1255
1252
  status: 'completed',
1253
+ deliveryMode: 'direct_fallback',
1256
1254
  language: (0, work_language_1.getWorkLanguage)(),
1257
1255
  });
1258
1256
  askBackRequesterDialog.setSuspensionState('resumed');
@@ -1553,7 +1553,7 @@ class DiskFileDialogStore extends dialog_1.DialogStore {
1553
1553
  // Duplicate final results are not harmless transcript noise. They mean two different program
1554
1554
  // paths both believed they owned the same business-level completion fact for one callId.
1555
1555
  // In ask-back flows this usually points to identity confusion between requester/responder or
1556
- // canonical reply-tool delivery versus fallback plaintext synthesis. We fail fast here so the
1556
+ // canonical reply-tool delivery versus another mistaken write path. We fail fast here so the
1557
1557
  // second writer keeps its own stack trace instead of silently corrupting the dialog transcript.
1558
1558
  const err = new Error(`${args.kind} duplicate callId invariant violation: rootId=${args.dialog.id.rootId} selfId=${args.dialog.id.selfId} ` +
1559
1559
  `callId=${args.callId} callName=${args.callName} existingCourse=${args.existingCourse} ` +
@@ -0,0 +1,5 @@
1
+ import type { DialogInterruptionReason } from '@longrun-ai/kernel/types/display-state';
2
+ export declare function buildUserInterjectionPauseStopReason(): Extract<DialogInterruptionReason, {
3
+ kind: 'system_stop';
4
+ }>;
5
+ export declare function isUserInterjectionPauseStopReason(reason: DialogInterruptionReason | undefined): boolean;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildUserInterjectionPauseStopReason = buildUserInterjectionPauseStopReason;
4
+ exports.isUserInterjectionPauseStopReason = isUserInterjectionPauseStopReason;
5
+ const USER_INTERJECTION_PAUSE_STOP_DETAIL = 'user_interjection_pause_resume_original_task';
6
+ // WARNING:
7
+ // This special stop reason is only a UI/run-control projection for "user interjected, and there
8
+ // is still an original task parked for explicit Continue". It is intentionally encoded as
9
+ // `system_stop`, but it does NOT mean the same thing as ordinary system-stop failure semantics.
10
+ //
11
+ // Not every user interjection should use this reason. If there is no parked original task to
12
+ // resume afterwards, the interjection should simply complete and the dialog should fall back to
13
+ // its true underlying state without showing this stopped panel.
14
+ //
15
+ // Do not change this file in isolation. The complete behavior depends on coordinated logic across:
16
+ // - `reply-guidance.ts` suppressing upstream reply obligation during interjection chat
17
+ // - `flow.ts` parking after the local reply, then re-running fresh-fact resume
18
+ // - `dialog-display-state.ts` preserving this paused projection until explicit Continue
19
+ // - `websocket-handler.ts` treating Continue as "resume attempt" rather than immediate success
20
+ //
21
+ // Reading only this stop reason or only `displayState.kind === 'stopped'` gives an incomplete and
22
+ // often wrong mental model.
23
+ function buildUserInterjectionPauseStopReason() {
24
+ return {
25
+ kind: 'system_stop',
26
+ detail: USER_INTERJECTION_PAUSE_STOP_DETAIL,
27
+ i18nStopReason: {
28
+ zh: '插话已处理;原任务已暂停。点击“继续”恢复原任务,继续发送新消息则继续这段临时对话。',
29
+ en: 'Interjection handled; the original task is paused. Click Continue to resume it, or send another message to keep this temporary side conversation going.',
30
+ },
31
+ };
32
+ }
33
+ function isUserInterjectionPauseStopReason(reason) {
34
+ return reason?.kind === 'system_stop' && reason.detail === USER_INTERJECTION_PAUSE_STOP_DETAIL;
35
+ }
@@ -102,6 +102,15 @@ function emitRunControlRefresh(reason) {
102
102
  });
103
103
  }
104
104
  function buildResumeIneligibleMessage(latest) {
105
+ // WARNING:
106
+ // `resume_dialog` eligibility is intentionally based on the freshly healed projection, not on a
107
+ // naive local check of raw blocker facts. In particular, the paused-interjection stopped state
108
+ // must remain resumable here so the user can explicitly press Continue even while the underlying
109
+ // dialog may still be blocked.
110
+ //
111
+ // The actual outcome of that Continue attempt is decided later in `flow.ts` from fresh facts:
112
+ // it may restore `blocked`, or it may immediately continue driving. Do not reinterpret a
113
+ // resumable stopped state here as "guaranteed to run now".
105
114
  const state = latest?.displayState;
106
115
  if (!state) {
107
116
  return {
@@ -1288,6 +1297,11 @@ async function handleResumeDialog(ws, packet) {
1288
1297
  }
1289
1298
  const dialogIdObj = new dialog_1.DialogID(dialog.selfId, dialog.rootId);
1290
1299
  const latest = await (0, dialog_display_state_1.refreshRunControlProjectionFromPersistenceFacts)(dialogIdObj, 'resume_dialog');
1300
+ // WARNING:
1301
+ // Passing this gate only means "a manual Continue attempt is allowed". It does not mean the
1302
+ // dialog is guaranteed to re-enter proceeding immediately. For the paused-interjection flow, the
1303
+ // resumed drive itself performs a second fresh-fact decision and may land in true `blocked`
1304
+ // instead of proceeding.
1291
1305
  if (!(0, dialog_display_state_1.isDialogLatestResumable)(latest)) {
1292
1306
  const ineligible = buildResumeIneligibleMessage(latest);
1293
1307
  log.warn('resume_dialog rejected after fresh fact scan', undefined, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dominds",
3
- "version": "1.16.7",
3
+ "version": "1.16.8",
4
4
  "description": "Dominds CLI and aggregation shell for the LongRun AI kernel/runtime packages.",
5
5
  "type": "commonjs",
6
6
  "publishConfig": {
@@ -52,8 +52,8 @@
52
52
  "ws": "^8.19.0",
53
53
  "yaml": "^2.8.2",
54
54
  "zod": "^4.3.6",
55
- "@longrun-ai/shell": "1.8.13",
56
55
  "@longrun-ai/kernel": "1.8.13",
56
+ "@longrun-ai/shell": "1.8.13",
57
57
  "@longrun-ai/codex-auth": "0.12.0"
58
58
  },
59
59
  "devDependencies": {