diff --git a/docs/system-specs/modules/session.md b/docs/system-specs/modules/session.md
index 5371fb0c5a4..39368d76e30 100644
--- a/docs/system-specs/modules/session.md
+++ b/docs/system-specs/modules/session.md
@@ -3773,6 +3773,18 @@ so absence clears it.
drains an idle slot on boot, so the user sends, edits or deletes them. This is
the same rule `sendTurn.ts` follows for an indeterminate send: a prompt whose
turn may have run un-persisted must never be auto-resent.
+ A restored card is **held** (`slot_queue_repository.is_held_queue_entry`) for
+ as long as it waits, not only at boot. The drain passes it by: a turn that
+ ends, a Resume and a fresh send all run ahead of it, and only the card's own
+ Send now (the idle Run-now path, which names it by `queue_id`) starts it.
+ Every reader that asks whether the queue will drain counts
+ `slot.queue_ready_depth` instead of `queue_depth`: Continue's
+ `slot_queue_pending` refusal, the completion sound's `continuing` flag and the
+ session-status `queued` state. The slot frame carries `queue_held_depth` and
+ each held queue entry `meta.held`, so the sidebar, board and command center do
+ not count a held card as work in flight and the card drops its waiting
+ animation. An interrupted chat with only held cards therefore offers Resume,
+ as its restart notice says.
- **Durability does not depend on the mutation site.** `_queue_persisted_sig`
records what the last committed save wrote and `slot.queue_persist_pending`
compares it against the live queue, so an in-place rewrite — a reorder, a
diff --git a/src/kiro_crew/context.py b/src/kiro_crew/context.py
index 65fa802849d..53aea036050 100644
--- a/src/kiro_crew/context.py
+++ b/src/kiro_crew/context.py
@@ -1167,17 +1167,21 @@ def _replay_rows(
exclude_last_n: int = 0,
pending_messages: list[dict] | None = None,
current_message: dict | None = None,
+ skip_row: Callable[[dict], bool] | None = None,
) -> list[dict]:
"""Tail of the chain under per-role quotas, in chronological order.
Conversation rows get the full quota whatever the inject volume, which a
- single bounded query cannot guarantee.
+ single bounded query cannot guarantee. *skip_row* drops rows from the
+ merged disk and live view before the quotas are counted.
"""
messages = conversation_log.read_messages_chained(session_key) if conversation_log else []
if pending_messages is not None or current_message is not None:
messages = _merge_replay_rows(messages, pending_messages or [], current_message)
elif exclude_last_n > 0:
messages = messages[:-exclude_last_n]
+ if skip_row is not None:
+ messages = [row for row in messages if not skip_row(row)]
messages = _without_warm_task_brief_messages(messages)
messages = [row for row in messages if not _replay.is_context_cap_notice(row)]
return _replay._quota_tail(
@@ -1228,6 +1232,7 @@ def build_session_replay(
pending_messages: list[dict] | None = None,
current_message: dict | None = None,
budget_chars: int = _REPLAY_BUDGET_CHARS,
+ skip_row: Callable[[dict], bool] | None = None,
) -> str | None:
"""Build session replay from KiroCrew's conversation_log.
@@ -1242,6 +1247,7 @@ def build_session_replay(
With *pending_messages*, merge the disk and live window by delivery identity
and exclude *current_message* explicitly before applying quotas or budgets.
The legacy *exclude_last_n* applies only when no live snapshot is supplied.
+ *skip_row* drops rows from that merged view, disk copies included.
*model_window* scales the replay budget to the active model's context
window (the dashboard's primary history vehicle — it must shrink on a
@@ -1255,6 +1261,7 @@ def build_session_replay(
exclude_last_n=exclude_last_n,
pending_messages=pending_messages,
current_message=current_message,
+ skip_row=skip_row,
)
if not messages:
return None
diff --git a/src/kiro_crew/dashboard/chat_api/continue_turn.py b/src/kiro_crew/dashboard/chat_api/continue_turn.py
index d461de8e5a4..c1c1d4135ae 100644
--- a/src/kiro_crew/dashboard/chat_api/continue_turn.py
+++ b/src/kiro_crew/dashboard/chat_api/continue_turn.py
@@ -196,9 +196,12 @@ async def continue_slot_turn(
raise SlotContinueRefusal("slot is running", "slot_running")
if slot._stopping or slot._stop_state != "idle":
raise SlotContinueRefusal("a stop is in progress", "slot_stopping")
- if slot.queue_depth:
+ if slot.queue_ready_depth:
# The runner is about to pick the thread back up on its own; adding a
- # continuation would double-fire.
+ # continuation would double-fire. Held cards (restored after a
+ # restart) are not counted: nothing picks them up, so refusing on
+ # them left an interrupted chat with no way forward but a new
+ # message. The recovery runs ahead of them and they stay held.
raise SlotContinueRefusal("queued messages pending", "slot_queue_pending")
if any(not f.done() for f in slot._approval_futures.values()):
raise SlotContinueRefusal("approval pending", "slot_approval_pending")
diff --git a/src/kiro_crew/dashboard/chat_api/resume.py b/src/kiro_crew/dashboard/chat_api/resume.py
index ca8093eccf7..e9e864e6ab6 100644
--- a/src/kiro_crew/dashboard/chat_api/resume.py
+++ b/src/kiro_crew/dashboard/chat_api/resume.py
@@ -51,7 +51,7 @@
logger,
members_mod,
note_crew_log_class,
- queue_entry_view,
+ queue_entry_views,
read_bounded_json,
redact_credentials,
redact_exfiltration_urls,
@@ -274,7 +274,7 @@ async def _live_slot_resume_payload(state, existing) -> dict:
"ok": True,
"key": existing.key,
"messages": prepared,
- "queue": [queue_entry_view(q) for q in existing._queue],
+ "queue": queue_entry_views(existing),
"total": total,
"has_more": next_before > 0,
"next_before": next_before,
@@ -740,7 +740,7 @@ async def api_chat_slot_resume(request: web.Request) -> web.Response:
live_child=_live_child_instance(state, slot),
workspace=slot.workspace,
),
- "queue": [queue_entry_view(q) for q in slot._queue],
+ "queue": queue_entry_views(slot),
"total": total,
"has_more": total > len(recent),
"memory_mode": slot.memory_mode,
diff --git a/src/kiro_crew/dashboard/chat_api/slot_detail.py b/src/kiro_crew/dashboard/chat_api/slot_detail.py
index 6b2ba8c7a0a..620b8e01edc 100644
--- a/src/kiro_crew/dashboard/chat_api/slot_detail.py
+++ b/src/kiro_crew/dashboard/chat_api/slot_detail.py
@@ -18,6 +18,8 @@
_FLUSH_SNAPSHOT_RETRIES,
_TRANSIENT_ROLES,
_UNOWED_WINDOW_ROLES,
+ HOLD_RELEASED_KEY,
+ RESTORED_QUEUE_KEY,
SLOT_DETAIL_MAX_LIMIT,
DashboardState,
OversizedRecord,
@@ -37,6 +39,7 @@
effective_session_key,
folder_principal,
history_corpus_unreadable,
+ is_held_queue_entry,
logger,
parse_cls_meta,
queue_entry_view,
@@ -1285,6 +1288,9 @@ async def _read_page(view: SlotView) -> tuple[list[dict], int, bool, int]:
# the loop keeps editing the live one; the view helper does the redaction.
# The two origin stamps ride along because the view reads them to decide
# whether an entry is shown as typed (`queue_entry_is_user_origin`).
+ # circular import: responsive_chat reaches the chat handlers at module level.
+ from kiro_crew.dashboard import responsive_chat
+
queue_snapshot = [
{
"id": q["id"],
@@ -1293,6 +1299,12 @@ async def _read_page(view: SlotView) -> tuple[list[dict], int, bool, int]:
"meta": dict(q.get("meta") or {}),
"_directive_user_origin": q.get("_directive_user_origin", False),
"_directive_channel_origin": q.get("_directive_channel_origin", False),
+ # Read by the view to mark a held card (`is_held_queue_entry`):
+ # restored, and not yet released by the person's Send now.
+ RESTORED_QUEUE_KEY: bool(q.get(RESTORED_QUEUE_KEY)),
+ HOLD_RELEASED_KEY: bool(q.get(HOLD_RELEASED_KEY)),
+ # Decided here, on the loop, against the live transcript.
+ "_row_written": is_held_queue_entry(q) and responsive_chat.row_already_written(slot, q),
}
for q in slot._queue
]
@@ -1381,7 +1393,9 @@ def _render(live_child: str) -> str:
)
tail = json.dumps(
{
- "queue": [queue_entry_view(q) for q in queue_snapshot],
+ "queue": [
+ queue_entry_view(q, row_written=q["_row_written"]) for q in queue_snapshot
+ ],
"total": total,
**page_fields,
# Seeds the context meter on open. Turn-scoped WS frames alone
diff --git a/src/kiro_crew/dashboard/chat_api/turn_stop.py b/src/kiro_crew/dashboard/chat_api/turn_stop.py
index 9c4d2b079b9..376e85cc101 100644
--- a/src/kiro_crew/dashboard/chat_api/turn_stop.py
+++ b/src/kiro_crew/dashboard/chat_api/turn_stop.py
@@ -22,6 +22,7 @@
_ChatSlot,
_history_key_for,
_mark_permission_resolved,
+ _memory_preparation_pending,
_orphan_in_current_turn,
_slot_replaced_while_queued,
_start_next_queued_turn,
@@ -29,9 +30,11 @@
compaction_in_flight,
deny_app_slot_access,
effective_session_key,
+ is_held_queue_entry,
logger,
read_bounded_json,
relay_archive_refusal,
+ release_held_queue_entry,
sel,
slot_not_found,
stop_declined_armed,
@@ -950,6 +953,20 @@ async def _cancel_linked_turn() -> str:
)
if denied is not None:
return denied
+ # A held card (restored after a restart) that the drain would park
+ # here never runs: the drain that ends the parking names no card and
+ # passes held ones by. Refuse it as what it is, so the person sends
+ # it again once memory is ready.
+ if _memory_preparation_pending(state) and any(
+ item.get("id") == queue_id and is_held_queue_entry(item) for item in slot._queue
+ ):
+ return web.json_response(
+ {
+ "error": "memory is still being prepared; send it again when it is ready",
+ "code": "memory_preparing",
+ },
+ status=409,
+ )
started = await _start_next_queued_turn(
state,
slot,
@@ -1074,6 +1091,10 @@ async def _cancel_linked_turn() -> str:
# here would overwrite it. Escalation keeps the same generation and a
# cleared queue, so promotion there is a harmless no-op.
slot.queue_promote_by_id(queue_id)
+ # A held card (restored after a restart) is one the drain passes by.
+ # The person just chose it, and the turn-end drain that will start it
+ # names no card, so the choice has to live on the entry itself.
+ release_held_queue_entry(slot._queue, queue_id)
# A concurrent /stop can supersede our claim during the body await:
# escalate it (soft_pending → killing), or escalate-settle-and-be-followed
diff --git a/src/kiro_crew/dashboard/chat_delivery.py b/src/kiro_crew/dashboard/chat_delivery.py
index bbddf9ff1fc..b0f9accfdad 100644
--- a/src/kiro_crew/dashboard/chat_delivery.py
+++ b/src/kiro_crew/dashboard/chat_delivery.py
@@ -28,7 +28,11 @@
_redact_for_display,
_redact_meta,
)
-from kiro_crew.dashboard.slot_queue_repository import ATTACHMENT_META_KEYS, warn_if_not_durable
+from kiro_crew.dashboard.slot_queue_repository import (
+ ATTACHMENT_META_KEYS,
+ is_held_queue_entry,
+ warn_if_not_durable,
+)
from kiro_crew.history import HUMAN_TURN_META_KEY
from kiro_crew.security import redact_credentials, redact_exfiltration_urls
@@ -1454,7 +1458,7 @@ def attachment_meta(user_meta: dict | None) -> dict[str, list[str]]:
return {k: v for k, v in redacted.items() if isinstance(v, list)}
-def queue_entry_view(item: dict[str, Any]) -> dict[str, Any]:
+def queue_entry_view(item: dict[str, Any], *, row_written: bool = False) -> dict[str, Any]:
"""The wire form of one queue entry: ``id``, ``content``, and ``meta``
holding its attachment lists when it carries any.
@@ -1508,4 +1512,28 @@ def queue_entry_view(item: dict[str, Any]) -> dict[str, Any]:
label = (item.get("meta") or {}).get("appLabel")
if isinstance(label, str) and label:
view.setdefault("meta", {})["appLabel"] = label
+ # A held card (restored after a restart) waits for the person rather than
+ # the drain, so the card must not animate as if it is next to run. Omitted
+ # otherwise, like the other flags. See `slot_queue_repository.is_held_queue_entry`.
+ if is_held_queue_entry(item):
+ view.setdefault("meta", {})["held"] = True
+ # The caller knows whether the entry's user row is already in the
+ # transcript (a crewmate send writes it at send). Such an entry can be
+ # sent or discarded, not edited.
+ if row_written:
+ view["meta"]["rowWritten"] = True
return view
+
+
+def queue_entry_views(slot: Any) -> list[dict[str, Any]]:
+ """``queue_entry_view`` for every entry of *slot*, with ``rowWritten`` decided."""
+ # circular import: responsive_chat imports this module.
+ from kiro_crew.dashboard import responsive_chat
+
+ return [
+ queue_entry_view(
+ q,
+ row_written=is_held_queue_entry(q) and responsive_chat.row_already_written(slot, q),
+ )
+ for q in slot._queue
+ ]
diff --git a/src/kiro_crew/dashboard/chat_handlers.py b/src/kiro_crew/dashboard/chat_handlers.py
index 89dafd94122..2d016280403 100644
--- a/src/kiro_crew/dashboard/chat_handlers.py
+++ b/src/kiro_crew/dashboard/chat_handlers.py
@@ -320,6 +320,7 @@
normalize_send_id,
queue_entry_is_user_origin,
queue_entry_view,
+ queue_entry_views,
queue_for_next_turn,
queued_text_for_display,
quote_meta,
@@ -360,6 +361,7 @@
)
from kiro_crew.dashboard.chat_runner import ( # noqa: F401
_context_usage_payload,
+ _memory_preparation_pending,
_run_chat,
_start_next_queued_turn,
_sync_served_model,
@@ -469,7 +471,13 @@
resolved_row_identity,
stop_declined_armed,
)
-from kiro_crew.dashboard.slot_queue_repository import warn_if_not_durable # noqa: F401
+from kiro_crew.dashboard.slot_queue_repository import ( # noqa: F401
+ HOLD_RELEASED_KEY,
+ RESTORED_QUEUE_KEY,
+ is_held_queue_entry,
+ release_held_queue_entry,
+ warn_if_not_durable,
+)
from kiro_crew.dashboard.slot_retention import ( # noqa: F401
loop_slot_keys,
select_idle_slot_keys,
diff --git a/src/kiro_crew/dashboard/chat_runner.py b/src/kiro_crew/dashboard/chat_runner.py
index d37514b6d14..71ef1c0814c 100644
--- a/src/kiro_crew/dashboard/chat_runner.py
+++ b/src/kiro_crew/dashboard/chat_runner.py
@@ -114,6 +114,7 @@
from kiro_crew.dashboard import directive_queue
from kiro_crew.dashboard import git_repo_touches as _git_repo_touches
from kiro_crew.dashboard.chat_delivery import COMMANDS_OFF_META_KEY # noqa: F401
+from kiro_crew.dashboard.chat_delivery import start_queue_persist # noqa: F401
from kiro_crew.dashboard.chat_delivery import TURN_ACTOR_META_KEY as _TURN_ACTOR_META_KEY
from kiro_crew.dashboard.chat_persistence import save_slot_off_loop # noqa: F401
from kiro_crew.dashboard.chat_summary import generate_session_summary
@@ -181,6 +182,7 @@
)
from kiro_crew.dashboard.chat_turn.queue_drain import (
_arm_queued_delivery_settlement,
+ _memory_preparation_pending,
_start_next_queued_turn,
drain_idle_parent_queue_for_stall,
subagents_hold_user_messages,
diff --git a/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py b/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py
index e1b4c8c8ae7..6224a9df8d1 100644
--- a/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py
+++ b/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py
@@ -34,6 +34,7 @@
user_text_span,
)
from kiro_crew.dashboard.handlers import _prompt_read_within_root
+from kiro_crew.dashboard.slot_queue_repository import is_held_queue_entry
from kiro_crew.dashboard.state import TURN_OPENING_INJECT_KINDS
from kiro_crew.executors import run_in_embed_pool
from kiro_crew.hooks import FileTooLargeError, safe_read_file_bytes_nolink
@@ -44,6 +45,8 @@
)
if TYPE_CHECKING:
+ from collections.abc import Callable
+
from kiro_crew.context import ContextBuilder
from kiro_crew.dashboard.chat_runner import (
_STARTUP_QUERY_PREWARM_SECS,
@@ -82,6 +85,42 @@
from kiro_crew.dashboard.chat_turn.turn_locals import _ChatTurn
+def _unsent_send_filter(slot: _ChatSlot) -> Callable[[dict], bool] | None:
+ """Whether a row is the user row of a send that is not part of the cut turn.
+
+ A crewmate send writes its user row at send time (``write_row_at_send``),
+ tagged with its queue entry's id. When the restart hands that entry back
+ held, the row stays in the transcript but was never sent again. Without
+ this filter a Resume's replay or interrupted-turn preamble takes it, the
+ newest user row, as the request to continue.
+ ``None`` when no row can match.
+ """
+ # circular import: responsive_chat reaches the chat runner at module level.
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+
+ skipped = {item.get("id") for item in slot._queue if is_held_queue_entry(item)}
+ if not skipped:
+ return None
+
+ def _unsent(row: dict) -> bool:
+ meta = row.get("meta")
+ return (
+ row.get("role") == "user"
+ and isinstance(meta, dict)
+ and meta.get(QUEUED_ROW_META_KEY) in skipped
+ )
+
+ return _unsent
+
+
+def _rows_without_held_sends(slot: _ChatSlot) -> list[dict]:
+ """The slot's rows minus those :func:`_unsent_send_filter` matches."""
+ unsent = _unsent_send_filter(slot)
+ if unsent is None:
+ return list(slot.messages)
+ return [row for row in slot.messages if not unsent(row)]
+
+
async def _session_replay_history(
state: DashboardState,
context_builder: ContextBuilder,
@@ -137,7 +176,9 @@ async def _session_replay_history(
_budget = {"budget_chars": CONTEXT_CAP_TAIL_CHARS} if _trim_seed else {}
# Merge the disk transcript and a frozen live-window tail
# before one budget pass. Exclude this request by identity,
- # whether or not the periodic flush has persisted it yet.
+ # whether or not the periodic flush has persisted it yet, and
+ # the rows of held sends, from disk and live alike.
+ _unsent = _unsent_send_filter(slot)
compressed = (
await asyncio.to_thread(
build_session_replay,
@@ -146,6 +187,7 @@ async def _session_replay_history(
pending_messages=list(slot.messages),
current_message=_current_replay_message,
model_window=window_for_provider_client(client),
+ skip_row=_unsent,
**_budget,
)
or ""
@@ -668,7 +710,7 @@ async def _turn_build_prompt(turn: _ChatTurn) -> None:
)
preamble = build_interrupted_turn_preamble(
- list(slot.messages),
+ _rows_without_held_sends(slot),
current=_current_replay_message,
opener_inject_kinds=TURN_OPENING_INJECT_KINDS,
)
diff --git a/src/kiro_crew/dashboard/chat_turn/queue_drain.py b/src/kiro_crew/dashboard/chat_turn/queue_drain.py
index 84688d5c577..5c193bb12a4 100644
--- a/src/kiro_crew/dashboard/chat_turn/queue_drain.py
+++ b/src/kiro_crew/dashboard/chat_turn/queue_drain.py
@@ -35,7 +35,7 @@
REFUSAL_SELF_WAKE_META_KEY,
REFUSAL_WAKE_LOOP_ID_META_KEY,
)
-from kiro_crew.dashboard.slot_queue_repository import RESTORED_QUEUE_KEY
+from kiro_crew.dashboard.slot_queue_repository import RESTORED_QUEUE_KEY, is_held_queue_entry
from kiro_crew.dashboard.state import CRON_NOTIFY_RE, SUBAGENT_COMPLETION_PREFIXES
from kiro_crew.history import HUMAN_TURN_META_KEY
@@ -59,6 +59,7 @@
redact_exfiltration_urls,
shutdown_event,
spawn_guarded_turn,
+ start_queue_persist,
)
from kiro_crew.dashboard.chat_turn.recovery import (
_drop_revoked_replays,
@@ -232,6 +233,22 @@ def settle_drained_subagent_notices(state: Any, slot: Any, notices: list[str]) -
)
+def _next_dispatchable_entry(slot: _ChatSlot, required_queue_id: str | None) -> dict | None:
+ """The entry the drain would start: the named card, else the first one not held.
+
+ ``None`` means the drain has nothing it may start: a queue holding only
+ restored cards waits for Send now, an edit or a delete
+ (:func:`is_held_queue_entry`).
+ """
+ for item in slot._queue:
+ if required_queue_id:
+ if item.get("id") == required_queue_id:
+ return item
+ elif not is_held_queue_entry(item):
+ return item
+ return None
+
+
def _memory_preparation_pending(state: DashboardState) -> bool:
"""Whether the gateway's shared memory preparation is still running.
@@ -458,7 +475,9 @@ async def _start_next_queued_turn(
# first would consume input that never runs, so leave the queue as it is
# (cards stay visible, order unchanged) and let the preparation task's
# completion drain it (`_arm_parked_queue_drain`).
- if slot._queue and _memory_preparation_pending(state):
+ if _next_dispatchable_entry(
+ slot, required_queue_id
+ ) is not None and _memory_preparation_pending(state):
_arm_parked_queue_drain(state, slot, notice=True)
return False
@@ -488,8 +507,11 @@ async def _start_next_queued_turn(
# Withheld when the next queued item carries a structural origin tag -- a cron
# notification or a runner-injected recovery prompt -- for the same reason
# _finish_queue_cycle withholds from synthesis: a note is owed to the next
- # USER turn, and that cycle's own flush delivers it afterwards.
- if not (slot._queue and slot._queue[0].get("kind")):
+ # USER turn, and that cycle's own flush delivers it afterwards. "Next" is the
+ # entry this drain would start, not the queue head: a held card at the head
+ # is passed by, and the entry behind it decides.
+ next_entry = _next_dispatchable_entry(slot, required_queue_id)
+ if not (next_entry is not None and next_entry.get("kind")):
try:
slot.flush_deferred_notes()
except Exception:
@@ -503,7 +525,10 @@ async def _start_next_queued_turn(
exc_info=True,
)
- if not slot._queue:
+ # Held cards (restored from disk, see `is_held_queue_entry`) are not this
+ # drain's to start: a queue of only those is an empty queue here. A card the
+ # person named (`required_queue_id`) is the one way such an entry runs.
+ if _next_dispatchable_entry(slot, required_queue_id) is None:
return False
# The promise-only recovery continuation is queue_insert(0)'d at
@@ -550,6 +575,16 @@ async def _start_next_queued_turn(
if await _drop_revoked_replays(state, slot):
return False
+ # Again, right before the dequeue: the purges above may have dropped the
+ # only entry the drain owned, and every dequeue below reads from the head.
+ if _next_dispatchable_entry(slot, required_queue_id) is None:
+ return False
+ if not required_queue_id and is_held_queue_entry(slot._queue[0]):
+ # Bring the first entry the drain owns to the head. The held cards keep
+ # their own order behind it.
+ ready = next(item for item in slot._queue if not is_held_queue_entry(item))
+ slot.queue_promote_by_id(ready["id"])
+
try:
merge = KiroCrewConfig.load().dashboard.merge_queued_messages
except Exception:
@@ -987,6 +1022,13 @@ async def _start_next_queued_turn(
# Persist it on the row so auto-titling and refresh rebase both exclude it.
_drained_meta[RESTORED_TURN_META_KEY] = True
current_row = responsive_chat.reusable_row(slot, consumed, next_msg)
+ if current_row is not None:
+ # The entry's row was written when it was sent, so the queue on disk is
+ # now the one record that it has not run. Left to the periodic flush, a
+ # restart inside that interval hands it back as a held card, and Resume
+ # then skips its row (`_unsent_send_filter`) and continues an older
+ # request. Start the queue write now, as an accepted send does.
+ start_queue_persist(state, slot)
if current_row is None:
if is_automation_steer:
current_row = append_and_surface(
diff --git a/src/kiro_crew/dashboard/chat_turn/steer_queue.py b/src/kiro_crew/dashboard/chat_turn/steer_queue.py
index 9ab7af24497..e774100a5b6 100644
--- a/src/kiro_crew/dashboard/chat_turn/steer_queue.py
+++ b/src/kiro_crew/dashboard/chat_turn/steer_queue.py
@@ -27,6 +27,7 @@
is_system_injection_item,
)
from kiro_crew.dashboard.chat_utils_injected_rows import AUTOMATION_STEER_META
+from kiro_crew.dashboard.slot_queue_repository import is_held_queue_entry
if TYPE_CHECKING:
from kiro_crew.dashboard.chat_runner import (
@@ -467,8 +468,18 @@ def _queue_entry_is_orchestration(item: dict) -> bool:
have their intervention silently ignored while the announced action dispatches
anyway. A user message carries no enqueue tag, so it correctly counts as a
user follow-up and aborts the pending recovery; real cron / sub-agent events
- are tagged at their injection sites and stay excluded."""
- return is_synthetic_payload_item(item) or is_system_injection_item(item)
+ are tagged at their injection sites and stay excluded.
+
+ A HELD entry (restored after a restart, `is_held_queue_entry`) is excluded
+ too. The person said it to a process that is gone, and it waits for them to
+ send it; it is not this process's user intervening. Counted, it would keep
+ every recovery off for as long as the card sits, which is until the person
+ acts on it. Send now lifts the hold, and from then on it counts again."""
+ return (
+ is_synthetic_payload_item(item)
+ or is_system_injection_item(item)
+ or is_held_queue_entry(item)
+ )
#: Which turn actor an enqueue-time queue ``kind`` names. The tag is stamped by
diff --git a/src/kiro_crew/dashboard/chat_utils.py b/src/kiro_crew/dashboard/chat_utils.py
index 52a6f022b9e..d964c182432 100644
--- a/src/kiro_crew/dashboard/chat_utils.py
+++ b/src/kiro_crew/dashboard/chat_utils.py
@@ -816,6 +816,7 @@ def _validate_tool_name(tool_name: str, *, is_shell: bool = False, canonical_nam
)
from kiro_crew.dashboard.slot_queue_repository import ( # noqa: F401
ATTACHMENT_META_KEYS,
+ is_held_queue_entry,
)
from kiro_crew.dashboard.state import ( # noqa: F401
BUSY_RECOVERY_PREFIX,
diff --git a/src/kiro_crew/dashboard/chat_utils_completion.py b/src/kiro_crew/dashboard/chat_utils_completion.py
index 020a5621a6b..15b21a020ab 100644
--- a/src/kiro_crew/dashboard/chat_utils_completion.py
+++ b/src/kiro_crew/dashboard/chat_utils_completion.py
@@ -265,7 +265,7 @@ async def chat_done_payload(
continuing = bool(
continuing
or slot._pending_synthesis
- or (slot.queue_depth and not queue_held and not slot._last_turn_auth_required)
+ or (slot.queue_ready_depth and not queue_held and not slot._last_turn_auth_required)
or await subagents_attached_async(
state, slot, effective_session_key(slot), "completion_sound"
)
diff --git a/src/kiro_crew/dashboard/chat_utils_injected_rows.py b/src/kiro_crew/dashboard/chat_utils_injected_rows.py
index 556977e0f36..54225777a4c 100644
--- a/src/kiro_crew/dashboard/chat_utils_injected_rows.py
+++ b/src/kiro_crew/dashboard/chat_utils_injected_rows.py
@@ -15,7 +15,7 @@
from enum import Enum
from typing import Any
-from kiro_crew.dashboard.slot_queue_repository import ATTACHMENT_META_KEYS
+from kiro_crew.dashboard.slot_queue_repository import ATTACHMENT_META_KEYS, is_held_queue_entry
from kiro_crew.dashboard.state import (
BUSY_RECOVERY_PREFIX,
COMPACTION_RECOVERY_PREFIX,
@@ -563,7 +563,9 @@ def _dequeue_next_system_message(slot) -> tuple:
drained item, or ``(None, [])`` when only held (user) messages remain queued.
"""
for i, item in enumerate(slot._queue):
- if releases_past_subagent_hold(item):
+ # A held card (restored after a restart) runs only on its own Send now,
+ # never because this scan reached it: a restored steer keeps its mark.
+ if releases_past_subagent_hold(item) and not is_held_queue_entry(item):
popped = slot.queue_pop(i)
return popped["content"], [popped]
return None, []
diff --git a/src/kiro_crew/dashboard/handlers/members_thread_turns.py b/src/kiro_crew/dashboard/handlers/members_thread_turns.py
index b61880a534c..cc7fed3f02a 100644
--- a/src/kiro_crew/dashboard/handlers/members_thread_turns.py
+++ b/src/kiro_crew/dashboard/handlers/members_thread_turns.py
@@ -87,7 +87,7 @@ def _turn_busy(state: DashboardState, slot: Any, key: str) -> bool:
def _work_pending(slot: Any) -> bool:
"""Queued messages or unconsumed steers: work the runner starts on its own."""
- return bool(slot.queue_depth) or bool(getattr(slot, "_pending_steers", None))
+ return bool(slot.queue_ready_depth) or bool(getattr(slot, "_pending_steers", None))
def _queue_pending(name: str) -> web.Response:
diff --git a/src/kiro_crew/dashboard/session_control_parts/session_status.py b/src/kiro_crew/dashboard/session_control_parts/session_status.py
index 1b9e69ea0fa..5c4c2bb32c6 100644
--- a/src/kiro_crew/dashboard/session_control_parts/session_status.py
+++ b/src/kiro_crew/dashboard/session_control_parts/session_status.py
@@ -601,9 +601,16 @@ async def created_session_status(
{
"target": key,
"title": _bounded_status_title(slot.display_title),
- "status": "working" if running else ("queued" if queue_depth else "idle"),
+ # "queued" promises the runner will start it; held cards (restored
+ # after a restart) wait for the person, so they read as idle.
+ "status": (
+ "working" if running else ("queued" if slot.queue_ready_depth else "idle")
+ ),
"running": running,
"queue_depth": queue_depth,
+ # Restored cards no drain will start; agents read "queued" as
+ # "will run", so they get the split.
+ "queue_held_depth": slot.queue_held_depth,
"process": process_state(state, slot),
**_pending_approval_fields(state, slot),
"source": "+".join(sources),
@@ -790,6 +797,7 @@ def read_messages(
# "nothing happening".
**({"streaming": True} if durable_end < len(raw_window) else {}),
"queue_depth": len(slot._queue),
+ "queue_held_depth": slot.queue_held_depth,
# A turn parked on a tool approval is still ``running``; this says it is
# waiting on a person rather than working.
**_pending_approval_fields(state, slot),
diff --git a/src/kiro_crew/dashboard/slot_projection.py b/src/kiro_crew/dashboard/slot_projection.py
index 520b225d957..09d8480799f 100644
--- a/src/kiro_crew/dashboard/slot_projection.py
+++ b/src/kiro_crew/dashboard/slot_projection.py
@@ -483,6 +483,10 @@ def to_dict(
# the backend's answer cannot disagree.
"stop_declined": stop_declined_armed(slot),
"queue_depth": slot.queue_depth,
+ # How many of those cards no drain will start: restored after a
+ # restart, they wait for Send now, an edit or a delete. Readers that
+ # ask "is work coming?" subtract it; see `is_held_queue_entry`.
+ "queue_held_depth": slot.queue_held_depth,
"stopping": slot._stopping,
"pending_approval": pending_approval,
"pending_approval_info": pending_approval_info,
diff --git a/src/kiro_crew/dashboard/slot_queue_repository.py b/src/kiro_crew/dashboard/slot_queue_repository.py
index 4426988d032..014d8faa211 100644
--- a/src/kiro_crew/dashboard/slot_queue_repository.py
+++ b/src/kiro_crew/dashboard/slot_queue_repository.py
@@ -81,6 +81,49 @@
#: consumer asks instead of trying to tell the two apart from the actor alone.
RESTORED_QUEUE_KEY = "_restored_from_disk"
+
+def is_held_queue_entry(item: Any) -> bool:
+ """Whether *item* waits for an explicit action instead of the drain.
+
+ A restored entry is HELD: no process owns it, so nothing runs it on its
+ own. It is handed back as a card, and only the person moves it: Send now
+ on that card (the idle Run-now path names it by id), an edit, or a delete.
+ A turn that ends, a Resume and a fresh send all pass it by. The reason is
+ the one ``sendTurn.ts`` gives for an indeterminate send: the process that
+ accepted these words is gone, and a prompt is never re-sent for the person.
+
+ Every reader that asks "will the runner pick this queue up?" asks this
+ instead of ``queue_depth``. The drain skips held entries, Continue does
+ not count them as pending, and the slot projection reports them apart
+ (``queue_held_depth``). A held queue is then never shown as work in flight.
+ """
+ return (
+ isinstance(item, dict)
+ and bool(item.get(RESTORED_QUEUE_KEY))
+ and not item.get(HOLD_RELEASED_KEY)
+ )
+
+
+#: Marks a held entry the person chose with Send now. Process-local, like
+#: :data:`RESTORED_QUEUE_KEY`, and set beside it rather than in its place: the
+#: entry's provenance is still unknown (the drain keeps reading the restored key
+#: for that), only the hold is lifted. Send now does not always dispatch at
+#: once: on a running slot it promotes the card and stops the turn, and with
+#: memory preparation pending the drain parks. The release is what lets the
+#: deferred drain that follows start the chosen card. A restart drops the key
+#: with the rest of the entry's process state, so the card comes back held.
+HOLD_RELEASED_KEY = "_hold_released"
+
+
+def release_held_queue_entry(queue: list[dict[str, Any]], queue_id: str) -> bool:
+ """Lift the hold on the entry *queue_id* names; whether one was held."""
+ for item in queue:
+ if item.get("id") == queue_id and is_held_queue_entry(item):
+ item[HOLD_RELEASED_KEY] = True
+ return True
+ return False
+
+
_DURABLE_QUEUE_KEYS: tuple[str, ...] = (
"id",
"content",
diff --git a/src/kiro_crew/dashboard/state_parts/chat_slot.py b/src/kiro_crew/dashboard/state_parts/chat_slot.py
index 0d4871af40a..075b07a85a8 100644
--- a/src/kiro_crew/dashboard/state_parts/chat_slot.py
+++ b/src/kiro_crew/dashboard/state_parts/chat_slot.py
@@ -15,6 +15,7 @@
SlotQueueRepository,
durable_queue_entries,
durable_queue_view,
+ is_held_queue_entry,
queue_persist_signature,
)
from kiro_crew.dashboard.state_parts import slot_ladder as _slot_ladder
@@ -875,6 +876,25 @@ def queue_depth(self) -> int:
"""Number of prompts currently queued behind the active turn."""
return len(self._queue)
+ @property
+ def queue_held_depth(self) -> int:
+ """Queued entries no drain will start: restored cards waiting for the person.
+
+ See :func:`slot_queue_repository.is_held_queue_entry`.
+ """
+ return sum(1 for item in self._queue if is_held_queue_entry(item))
+
+ @property
+ def queue_ready_depth(self) -> int:
+ """Queued entries the runner starts on its own when the slot frees.
+
+ The number every "will this queue drain?" decision reads: Continue's
+ pending-queue refusal, the completion sound's ``continuing`` flag, the
+ session-status ``queued`` state. ``queue_depth`` still counts every
+ card the person can see.
+ """
+ return self.queue_depth - self.queue_held_depth
+
@property
def model_withheld(self) -> bool | None:
"""Whether the live session withholds this slot's pinned model.
diff --git a/src/kiro_crew/mcp_dashboard.py b/src/kiro_crew/mcp_dashboard.py
index 9f65cd3cb23..e5231238aff 100644
--- a/src/kiro_crew/mcp_dashboard.py
+++ b/src/kiro_crew/mcp_dashboard.py
@@ -798,6 +798,7 @@ def _board_tools() -> tuple[Tool, ...]:
_closed_at_display,
_create_refusal_remedy,
_pending_approval_phrase,
+ _queue_phrase,
_render_session_summary,
_resolve_folder_for_new_session,
_run_session_adopt,
diff --git a/src/kiro_crew/mcp_dashboard_session_runs.py b/src/kiro_crew/mcp_dashboard_session_runs.py
index 00e7bcf3e25..dd3ac0140a9 100644
--- a/src/kiro_crew/mcp_dashboard_session_runs.py
+++ b/src/kiro_crew/mcp_dashboard_session_runs.py
@@ -812,8 +812,7 @@ def _run_session_status(args: dict[str, Any], ctx: ToolContext) -> str:
)
continue
title = str(row.get("title", ""))
- depth = int(row.get("queue_depth", 0) or 0)
- queued = f", {depth} queued" if depth else ""
+ queued = _queue_phrase(row)
if status == "unknown":
# Its OWN mark, not the glyph map's default. Falling through to that
# default draws idle's sleep glyph -- "open and doing nothing", the
@@ -894,6 +893,18 @@ def _run_session_release(args: dict[str, Any], ctx: ToolContext) -> str:
)
+def _queue_phrase(resp: dict[str, Any], unit: str = "") -> str:
+ """ ", N queued", counting only the entries that will run.
+
+ A held entry (restored after a restart) runs only when the person presses
+ Send now on its card, and "queued" tells an agent the message still runs
+ after the turn, so held entries are left out of the count.
+ """
+ depth = int(resp.get("queue_depth", 0) or 0)
+ ready = depth - min(depth, int(resp.get("queue_held_depth", 0) or 0))
+ return f", {ready}{unit} queued" if ready else ""
+
+
def _pending_approval_phrase(resp: dict[str, Any]) -> str:
"""The state words for a session parked on a tool approval."""
tool = str(resp.get("pending_approval_tool") or "")
@@ -912,9 +923,7 @@ def _run_session_read_message(args: dict[str, Any], ctx: ToolContext) -> str:
state_line = "still working" if resp.get("running") else "idle"
if resp.get("pending_approval"):
state_line = _pending_approval_phrase(resp)
- queued = resp.get("queue_depth", 0)
- if queued:
- state_line += f", {queued} message(s) queued"
+ state_line += _queue_phrase(resp, unit=" message(s)")
if resp.get("model"):
state_line += f", model {redact(str(resp['model']))}"
if resp.get("pending_model"):
diff --git a/test/test_chat_mode_security_send.py b/test/test_chat_mode_security_send.py
index 1fbdaa0aad3..4fd0bfd7fb3 100644
--- a/test/test_chat_mode_security_send.py
+++ b/test/test_chat_mode_security_send.py
@@ -1014,6 +1014,21 @@ async def _drain_once(state, slot, **kwargs) -> dict:
return slot.messages[-1]
+async def _send_now_once(state, slot, **kwargs) -> dict:
+ """Run the head card the way a person's Send now does: named by its id.
+
+ A restored entry is held (`is_held_queue_entry`), so this is the one path
+ that still turns its text into a row, and the one these rules must cover.
+ """
+ return await _drain_once(
+ state,
+ slot,
+ allow_user_during_subagents=True,
+ required_queue_id=slot._queue[0]["id"],
+ **kwargs,
+ )
+
+
@pytest.mark.asyncio
async def test_a_queued_mcp_app_message_is_an_app_inject_whatever_its_text(state) -> None:
slot = state.get_or_create_slot("s1", origin=SlotOrigin.USER)
@@ -1051,7 +1066,7 @@ async def test_a_restored_queue_entry_is_never_a_cron_notification(state) -> Non
slot._queue = sanitize_restored_queue(durable_queue_entries(live._queue))
assert slot._queue, "a plain queued send must survive the restart"
- row = await _drain_once(state, slot)
+ row = await _send_now_once(state, slot)
assert row["role"] == "user"
assert "injectKind" not in (row.get("meta") or {})
@@ -1092,7 +1107,7 @@ async def test_restored_recovery_identity_cannot_continue_a_live_stream(state) -
)
slot._queue[0][RESTORED_QUEUE_KEY] = True
- await _drain_once(state, slot, predecessor_actor="app", predecessor_turn_id="turn-a")
+ await _send_now_once(state, slot, predecessor_actor="app", predecessor_turn_id="turn-a")
assert slot.drain()[0]["cls"] == TURN_END_WIRE_CLS
@@ -1105,7 +1120,7 @@ async def test_restored_app_turn_titles_only_an_app_owned_slot(state, owned: boo
slot = _user_slot(state)
slot._queue = sanitize_restored_queue(durable_queue_entries(live._queue))
assert TURN_ACTOR_META_KEY not in slot._queue[0].get("meta", {})
- row = await _drain_once(state, slot)
+ row = await _send_now_once(state, slot)
# Exercise the titler's owner exemption independently of queue admission:
# an app-bound restore may itself be held by containment revalidation.
slot._app = "crew-keyboard" if owned else ""
@@ -1142,20 +1157,21 @@ async def test_restored_and_fresh_queue_rows_never_merge(state, restored_first:
config = KiroCrewConfig()
config.dashboard.merge_queued_messages = True
with patch.object(KiroCrewConfig, "load", return_value=config):
- first = await _drain_once(state, slot)
+ # The drain takes the fresh entry wherever the held one sits, alone.
+ fresh = await _drain_once(state, slot)
slot.append("assistant", "first reply")
assert slot.queue_depth == 1
- second = await _drain_once(state, slot)
+ # The held card runs only when the person sends it.
+ restored_row = await _send_now_once(state, slot)
slot.append("assistant", "second reply")
assert slot.queue_depth == 0
- fresh, restored_row = (second, first) if restored_first else (first, second)
assert fresh["content"] == "fresh user request"
assert restored_row["content"] == "restored app request"
assert _counts_as_user_turn(slot, fresh)
assert not _counts_as_user_turn(slot, restored_row)
assert [row["content"] for row in _titling_messages(slot)] == [
"fresh user request",
- "second reply" if restored_first else "first reply",
+ "first reply",
]
# Keep the count above the fixed-milestone floor, so counting the restored
# row incorrectly changes the rebase result instead of hiding under it.
diff --git a/test/test_chat_slot_facade_contract.py b/test/test_chat_slot_facade_contract.py
index 92186246f28..c8f18854c37 100644
--- a/test/test_chat_slot_facade_contract.py
+++ b/test/test_chat_slot_facade_contract.py
@@ -54,6 +54,7 @@
"preparing",
"stop_declined",
"queue_depth",
+ "queue_held_depth",
"stopping",
"pending_approval",
"pending_approval_info",
diff --git a/test/test_dashboard_chat_handlers_coverage.py b/test/test_dashboard_chat_handlers_coverage.py
index a40e9a94859..ed219de280a 100644
--- a/test/test_dashboard_chat_handlers_coverage.py
+++ b/test/test_dashboard_chat_handlers_coverage.py
@@ -880,6 +880,31 @@ async def test_cancel_removes_the_item_and_broadcasts(self, _sel):
assert payload["queue_id"] == qid
state.push_slots_update.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_a_held_send_already_in_the_conversation_is_not_cancelled_either(self, _sel):
+ # Restored after a restart (held) with its row written at send: the
+ # row is the message, so Cancel is refused like any written send and
+ # Send now is the way out. The card stays, held.
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+ from kiro_crew.dashboard.slot_queue_repository import (
+ RESTORED_QUEUE_KEY,
+ is_held_queue_entry,
+ )
+
+ slot = _ChatSlot("s1")
+ qid = slot.queue_append("orig")
+ slot._queue[0][RESTORED_QUEUE_KEY] = True
+ slot.append("user", "orig", "msg msg-u", meta={QUEUED_ROW_META_KEY: qid})
+ state = _state(slot)
+
+ status, body = await self._delete(state, "s1", qid)
+
+ assert status == 409
+ assert body["code"] == "queue_item_already_sent"
+ assert [q["id"] for q in slot._queue] == [qid]
+ assert is_held_queue_entry(slot._queue[0])
+ state.broadcast_ws.assert_not_called()
+
@pytest.mark.asyncio
async def test_a_send_already_in_the_conversation_is_not_cancelled(self, _sel):
# Its row is in the transcript already: dropping only the entry would
diff --git a/test/test_gateway_appkit_endpoints_note.py b/test/test_gateway_appkit_endpoints_note.py
index fb03b467900..1fc9c539e67 100644
--- a/test/test_gateway_appkit_endpoints_note.py
+++ b/test/test_gateway_appkit_endpoints_note.py
@@ -1259,9 +1259,11 @@ def test_every_turn_start_seam_flushes_above_its_own_row(self):
assert queued.index("flush_deferred_notes") < queued.index("_dequeue_next")
# ...but guarded, so a queued item carrying a structural origin tag (a
# cron notification, a runner-injected recovery prompt) is not fed the
- # note; _finish_queue_cycle delivers it after that turn instead.
- assert 'slot._queue[0].get("kind")' in queued
- assert queued.index('slot._queue[0].get("kind")') < queued.index("flush_deferred_notes")
+ # note; _finish_queue_cycle delivers it after that turn instead. The
+ # guard reads the entry the drain would start, which passes a held
+ # (restored) card at the head by.
+ assert 'next_entry.get("kind")' in queued
+ assert queued.index('next_entry.get("kind")') < queued.index("flush_deferred_notes")
def test_every_queue_drain_seam_flushes_before_starting_the_successor(self):
"""A NEW successor-dispatch path that skips the flush must turn this red.
diff --git a/test/test_restart_held_queue.py b/test/test_restart_held_queue.py
new file mode 100644
index 00000000000..60c2050ddb4
--- /dev/null
+++ b/test/test_restart_held_queue.py
@@ -0,0 +1,578 @@
+"""A queue restored after a restart is HELD: no owner runs it, and nothing says it will.
+
+Handed back as cards (``docs/system-specs/modules/session.md``, "Restored entries
+are handed back as queue CARDS, not dispatched"), a restored entry moves only
+when the person acts on it: Send now on the card, an edit, or a delete. These
+tests pin each reader that could read such a queue as work about to run:
+
+- the drain passes held entries by (a turn ending, a fresh send, a Resume);
+- Continue does not refuse on them, so an interrupted chat keeps its Resume;
+- the slot projection and the session status report them apart;
+- the dequeue is durable in the same write as the turn marker, so a death
+ between the two never leaves the entry both as the interrupted opener and as
+ a restored card.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from aiohttp.test_utils import TestClient, TestServer
+from chat_runner_coverage_helpers import _slot, _state
+from chat_test_helpers import _make_state
+from test_chat_slot_continue import _make_app, _mock_state
+
+from kiro_crew.dashboard import chat_runner
+from kiro_crew.dashboard.chat_delivery import queue_entry_view
+from kiro_crew.dashboard.chat_persistence import _rehydrate_slot_from_history, _save_slot_to_history
+from kiro_crew.dashboard.chat_turn.steer_queue import _has_user_queued_followup
+from kiro_crew.dashboard.chat_utils import (
+ CRON_NOTIFICATION_KIND,
+ STEER_INTENT_META,
+ SYNTHETIC_RECOVERY_KIND,
+)
+from kiro_crew.dashboard.slot_queue_repository import (
+ RESTORED_QUEUE_KEY,
+ is_held_queue_entry,
+ release_held_queue_entry,
+)
+from kiro_crew.dashboard.state import _ChatSlot
+
+_RESTART_KIND = "gateway_restart_interruption"
+_RESTART_TEXT = (
+ "This turn was interrupted when the app restarted. "
+ "Review the partial output above, then resume to continue."
+)
+
+
+def _hold(slot: _ChatSlot, text: str) -> str:
+ """Queue *text* as a restore would hand it back: a plain prompt, held."""
+ qid = slot.queue_append(text)
+ slot._queue[-1][RESTORED_QUEUE_KEY] = True
+ return qid
+
+
+def _interrupted(slot: _ChatSlot) -> None:
+ slot.append("user", "first request", "msg msg-u")
+ slot.append("assistant", "partial", "msg msg-a")
+ slot.append("error", _RESTART_TEXT, "msg msg-err", meta={"kind": _RESTART_KIND})
+
+
+def _restart_rows(messages: list[dict]) -> list[dict]:
+ return [m for m in messages if (m.get("meta") or {}).get("kind") == _RESTART_KIND]
+
+
+# ── what the slot reports ──────────────────────────────────────────────────
+
+
+class TestHeldQueueProjection:
+ def test_held_entries_are_counted_apart_from_ready_ones(self):
+ slot = _ChatSlot("s")
+ _hold(slot, "restored one")
+ slot.queue_append("sent after the restart")
+
+ assert slot.queue_depth == 2
+ assert slot.queue_held_depth == 1
+ assert slot.queue_ready_depth == 1
+
+ def test_slot_frame_carries_the_held_count(self):
+ slot = _ChatSlot("s")
+ _interrupted(slot)
+ _hold(slot, "restored one")
+
+ frame = slot.to_dict()
+
+ assert frame["running"] is False
+ assert frame["interrupted"] is True
+ assert frame["queue_depth"] == 1
+ assert frame["queue_held_depth"] == 1
+
+ def test_a_released_card_is_no_longer_shown_held(self):
+ slot = _ChatSlot("s")
+ h1 = _hold(slot, "restored one")
+ release_held_queue_entry(slot._queue, h1)
+
+ assert "held" not in (queue_entry_view(slot._queue[0]).get("meta") or {})
+ assert slot.queue_held_depth == 0
+
+ def test_a_held_entry_with_its_row_written_is_marked_so(self):
+ """Its row is in the transcript, so the card offers Send now and Discard, no Edit."""
+ from kiro_crew.dashboard.chat_delivery import queue_entry_views
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+
+ slot = _ChatSlot("s")
+ written = _hold(slot, "sent from a crewmate chat")
+ slot.append(
+ "user", "sent from a crewmate chat", "msg msg-u", meta={QUEUED_ROW_META_KEY: written}
+ )
+ _hold(slot, "restored plain")
+
+ first, second = queue_entry_views(slot)
+
+ assert first["meta"] == {"held": True, "rowWritten": True}
+ assert second["meta"] == {"held": True}
+
+ def test_queue_entry_view_marks_only_held_entries(self):
+ slot = _ChatSlot("s")
+ _hold(slot, "restored one")
+ slot.queue_append("live one")
+
+ held, live = (queue_entry_view(item) for item in slot._queue)
+
+ assert held["meta"]["held"] is True
+ assert "held" not in (live.get("meta") or {})
+
+ def test_a_restore_holds_every_entry_it_hands_back(self, tmp_path):
+ state = _make_state(tmp_path)
+ slot = state.get_or_create_slot("chat-1")
+ _interrupted(slot)
+ slot.queue_append("queued before the restart")
+ assert _save_slot_to_history(state, slot, force=True)
+ state._slots.pop("chat-1", None)
+
+ restored = _rehydrate_slot_from_history(state, "chat-1")
+
+ assert restored is not None
+ assert [item["content"] for item in restored._queue] == ["queued before the restart"]
+ assert all(is_held_queue_entry(item) for item in restored._queue)
+ assert restored.to_dict()["queue_held_depth"] == 1
+
+
+# ── the drain passes held entries by ───────────────────────────────────────
+
+
+class TestDrainSkipsHeldEntries:
+ @pytest.mark.asyncio
+ async def test_a_queue_of_only_held_entries_starts_nothing(self, tmp_path):
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ qid = _hold(slot, "restored one")
+
+ with patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()) as spawn:
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is False
+ spawn.assert_not_called()
+ assert [item["id"] for item in slot._queue] == [qid]
+
+ @pytest.mark.asyncio
+ async def test_a_ready_entry_runs_and_the_held_ones_keep_their_order(self, tmp_path):
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ h1 = _hold(slot, "restored one")
+ h2 = _hold(slot, "restored two")
+ slot.queue_append("sent after the restart")
+
+ with (
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()) as spawn,
+ patch.object(
+ chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())
+ ) as run_chat,
+ ):
+ load.return_value.dashboard.merge_queued_messages = True
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is True
+ assert spawn.call_count == 1
+ # Alone, never merged with the held cards behind it.
+ assert run_chat.call_args.args[2] == "sent after the restart"
+ assert [item["id"] for item in slot._queue] == [h1, h2]
+
+ @pytest.mark.asyncio
+ async def test_a_held_steer_is_not_released_by_the_sub_agent_hold(self, tmp_path):
+ """A restored steer keeps its mark; the hold's system scan must pass it by."""
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = MagicMock(running_agents_for=MagicMock(return_value=["agent-1"]))
+ h1 = _hold(slot, "restored steer")
+ slot._queue[0].setdefault("meta", {})[STEER_INTENT_META] = True
+ fresh = slot.queue_append("sent while sub-agents run")
+
+ with patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()) as spawn:
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is False
+ spawn.assert_not_called()
+ # Both still queued: the fresh entry was brought ahead, the held one waits.
+ assert [item["id"] for item in slot._queue] == [fresh, h1]
+ assert is_held_queue_entry(slot._queue[1])
+
+ @pytest.mark.asyncio
+ async def test_a_note_is_not_flushed_into_a_cron_turn_behind_a_held_card(self, tmp_path):
+ """The deferred note waits for a user turn; the held head is not that turn."""
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ _hold(slot, "restored one")
+ slot.queue_append("cron fired", kind=CRON_NOTIFICATION_KIND)
+
+ with (
+ patch.object(_ChatSlot, "flush_deferred_notes") as flush,
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()),
+ patch.object(chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())),
+ ):
+ load.return_value.dashboard.merge_queued_messages = False
+ assert await chat_runner._start_next_queued_turn(state, slot) is True
+
+ flush.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_send_now_on_a_held_card_runs_exactly_that_card(self, tmp_path):
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ h1 = _hold(slot, "restored one")
+ h2 = _hold(slot, "restored two")
+
+ with (
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()),
+ patch.object(
+ chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())
+ ) as run_chat,
+ ):
+ load.return_value.dashboard.merge_queued_messages = False
+ started = await chat_runner._start_next_queued_turn(
+ state, slot, allow_user_during_subagents=True, required_queue_id=h2
+ )
+
+ assert started is True
+ assert run_chat.call_args.args[2] == "restored two"
+ assert [item["id"] for item in slot._queue] == [h1]
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("row_written", [True, False])
+ async def test_running_a_written_send_writes_the_queue_at_once(self, tmp_path, row_written):
+ """Its row is already on disk, so only the queue can say it has not run."""
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ qid = _hold(slot, "sent from a crewmate chat")
+ if row_written:
+ slot.append(
+ "user", "sent from a crewmate chat", "msg msg-u", meta={QUEUED_ROW_META_KEY: qid}
+ )
+
+ with (
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()),
+ patch.object(chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())),
+ patch.object(chat_runner, "start_queue_persist") as persist,
+ ):
+ load.return_value.dashboard.merge_queued_messages = False
+ started = await chat_runner._start_next_queued_turn(
+ state, slot, allow_user_during_subagents=True, required_queue_id=qid
+ )
+
+ assert started is True
+ assert slot._queue == []
+ assert persist.called is row_written
+
+ @pytest.mark.asyncio
+ async def test_a_released_card_parked_on_memory_preparation_runs_when_it_ends(self, tmp_path):
+ """Idle Send now while memory prepares: the parked drain names no card."""
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ h1 = _hold(slot, "restored one")
+ release_held_queue_entry(slot._queue, h1)
+ pending = MagicMock()
+ pending.done = MagicMock(return_value=False)
+ state.memory_startup_task = pending
+
+ parked = await chat_runner._start_next_queued_turn(state, slot, required_queue_id=h1)
+ assert parked is False
+ pending.add_done_callback.assert_called_once()
+
+ state.memory_startup_task = None
+ with (
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()),
+ patch.object(
+ chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())
+ ) as run_chat,
+ ):
+ load.return_value.dashboard.merge_queued_messages = False
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is True
+ assert run_chat.call_args.args[2] == "restored one"
+
+ @pytest.mark.asyncio
+ async def test_held_entries_do_not_park_on_memory_preparation(self, tmp_path):
+ """Only work the drain owns parks; a held card gets no "will run" notice."""
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ _hold(slot, "restored one")
+ pending = MagicMock()
+ pending.done = MagicMock(return_value=False)
+ state.memory_startup_task = pending
+
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is False
+ assert not any(m.get("role") == "notice" for m in slot.messages)
+ pending.add_done_callback.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_a_released_card_runs_on_the_next_drain_that_names_no_card(self, tmp_path):
+ """Send now on a running slot promotes and releases; the turn-end drain runs it."""
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ h1 = _hold(slot, "restored one")
+ slot.queue_append("sent after the restart")
+ # What /interrupt does for a running slot: promote, then lift the hold.
+ assert slot.queue_promote_by_id(h1)
+ assert release_held_queue_entry(slot._queue, h1)
+
+ with (
+ patch.object(chat_runner.KiroCrewConfig, "load") as load,
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()),
+ patch.object(
+ chat_runner, "_run_chat", new=MagicMock(return_value=MagicMock())
+ ) as run_chat,
+ ):
+ load.return_value.dashboard.merge_queued_messages = False
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is True
+ assert run_chat.call_args.args[2] == "restored one"
+
+ @pytest.mark.asyncio
+ async def test_a_purge_that_empties_the_ready_work_never_falls_through_to_a_held_card(
+ self, tmp_path
+ ):
+ state, slot = _state(tmp_path), _slot()
+ state.subagents = None
+ slot.queue_append("a replay the purge will revoke")
+ h1 = _hold(slot, "restored one")
+
+ async def _revoke_the_head(_state, s):
+ s._queue.pop(0)
+ return False # the queue is not empty, so the drain carries on
+
+ with (
+ patch.object(chat_runner, "_drop_revoked_replays", side_effect=_revoke_the_head),
+ patch.object(chat_runner, "spawn_guarded_turn", return_value=MagicMock()) as spawn,
+ ):
+ started = await chat_runner._start_next_queued_turn(state, slot)
+
+ assert started is False
+ spawn.assert_not_called()
+ assert [item["id"] for item in slot._queue] == [h1]
+
+
+class TestHeldCardsAreNotUserIntervention:
+ def test_held_cards_do_not_cancel_recovery_until_the_person_sends_one(self):
+ slot = _ChatSlot("s")
+ h1 = _hold(slot, "restored one")
+ assert _has_user_queued_followup(slot) is False
+
+ release_held_queue_entry(slot._queue, h1)
+
+ assert _has_user_queued_followup(slot) is True
+
+ def test_a_live_send_still_counts(self):
+ slot = _ChatSlot("s")
+ _hold(slot, "restored one")
+ slot.queue_append("sent after the restart")
+ assert _has_user_queued_followup(slot) is True
+
+
+# ── Continue counts only what the runner will pick up ─────────────────────
+
+
+class TestContinueWithHeldQueue:
+ @pytest.mark.asyncio
+ async def test_resume_runs_ahead_of_held_cards(self, _patched):
+ slot = _ChatSlot("s")
+ _interrupted(slot)
+ h1 = _hold(slot, "restored one")
+ state = _mock_state(slot)
+
+ async with TestClient(TestServer(_make_app(state))) as client:
+ resp = await client.post("/api/chat/slots/s/continue")
+ assert resp.status == 200
+
+ assert slot._queue[0]["kind"] == SYNTHETIC_RECOVERY_KIND
+ assert [item["id"] for item in slot._queue[1:]] == [h1]
+ assert is_held_queue_entry(slot._queue[1])
+ _patched.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_a_second_press_before_the_recovery_starts_is_refused(self, monkeypatch):
+ """Idempotent: the inserted recovery is ready work, so it blocks a duplicate."""
+ mock_sel = MagicMock()
+ parked = AsyncMock(return_value=False)
+ with (
+ patch("kiro_crew.dashboard.chat_handlers.sel", return_value=mock_sel),
+ patch("kiro_crew.dashboard.chat_handlers._start_next_queued_turn", parked),
+ ):
+ slot = _ChatSlot("s")
+ _interrupted(slot)
+ _hold(slot, "restored one")
+ state = _mock_state(slot)
+ async with TestClient(TestServer(_make_app(state))) as client:
+ first = await client.post("/api/chat/slots/s/continue")
+ second = await client.post("/api/chat/slots/s/continue")
+ assert first.status == 200
+ assert second.status == 409
+ assert (await second.json())["code"] == "slot_queue_pending"
+
+ assert sum(1 for item in slot._queue if item.get("kind") == SYNTHETIC_RECOVERY_KIND) == 1
+ assert slot.queue_held_depth == 1
+
+ @pytest.mark.asyncio
+ async def test_a_ready_entry_still_refuses(self, _patched):
+ slot = _ChatSlot("s")
+ _interrupted(slot)
+ _hold(slot, "restored one")
+ slot.queue_append("sent after the restart")
+ state = _mock_state(slot)
+
+ async with TestClient(TestServer(_make_app(state))) as client:
+ resp = await client.post("/api/chat/slots/s/continue")
+ assert resp.status == 409
+ assert (await resp.json())["code"] == "slot_queue_pending"
+
+ assert slot.queue_depth == 2
+
+
+class TestResumeNeverContinuesAHeldSend:
+ def test_the_interrupted_preamble_skips_a_held_sends_written_row(self):
+ """A crewmate send's row is in the transcript, but it is held, not the cut turn."""
+ from kiro_crew.context import build_interrupted_turn_preamble
+ from kiro_crew.dashboard.chat_turn.prompt_assembly import _rows_without_held_sends
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+ from kiro_crew.dashboard.state import TURN_OPENING_INJECT_KINDS
+
+ slot = _ChatSlot("s")
+ slot.append("user", "the request the restart cut", "msg msg-u")
+ slot.append("assistant", "partial", "msg msg-a")
+ held = _hold(slot, "sent while it ran")
+ slot.append("user", "sent while it ran", "msg msg-u", meta={QUEUED_ROW_META_KEY: held})
+ slot.append("error", _RESTART_TEXT, "msg msg-err", meta={"kind": _RESTART_KIND})
+
+ rows = _rows_without_held_sends(slot)
+ preamble = build_interrupted_turn_preamble(
+ rows, current=None, opener_inject_kinds=TURN_OPENING_INJECT_KINDS
+ )
+
+ assert "the request the restart cut" in preamble
+ assert "sent while it ran" not in preamble
+ # The transcript itself keeps the row.
+ assert any(m.get("content") == "sent while it ran" for m in slot.messages)
+
+ def test_the_cold_replay_skips_held_rows_on_disk_and_live(self, tmp_path):
+ """A cold-start Resume replays the transcript, which carries the held row on disk."""
+ from kiro_crew.context import build_session_replay
+ from kiro_crew.dashboard.chat_turn.prompt_assembly import _unsent_send_filter
+ from kiro_crew.dashboard.responsive_chat import QUEUED_ROW_META_KEY
+ from kiro_crew.history import ConversationLog
+
+ log = ConversationLog(base_dir=tmp_path)
+ slot = _ChatSlot("s")
+ slot.append("user", "the request the restart cut", "msg msg-u")
+ slot.append("assistant", "partial answer", "msg msg-a")
+ held = _hold(slot, "held while it ran")
+ slot.append("user", "held while it ran", "msg msg-u", meta={QUEUED_ROW_META_KEY: held})
+ log.rewrite_session("dashboard:s", [dict(row) for row in slot.messages])
+
+ unsent = _unsent_send_filter(slot)
+ assert unsent is not None
+ for pending in ([], list(slot.messages)):
+ replay = build_session_replay(
+ log, "dashboard:s", pending_messages=pending, current_message=None, skip_row=unsent
+ )
+ assert replay is not None
+ assert "the request the restart cut" in replay
+ assert "held while it ran" not in replay
+
+ def test_the_filter_is_off_when_nothing_is_held(self):
+ from kiro_crew.dashboard.chat_turn.prompt_assembly import _unsent_send_filter
+
+ assert _unsent_send_filter(_ChatSlot("s")) is None
+
+
+class TestAgentsAreToldHeldIsNotQueued:
+ def test_the_session_tools_count_only_entries_that_will_run(self):
+ from kiro_crew.mcp_dashboard_session_runs import _queue_phrase
+
+ assert _queue_phrase({"queue_depth": 2, "queue_held_depth": 2}) == ""
+ assert _queue_phrase({"queue_depth": 3, "queue_held_depth": 1}, unit=" message(s)") == (
+ ", 2 message(s) queued"
+ )
+ # An older gateway sends no held count: every entry reads as queued.
+ assert _queue_phrase({"queue_depth": 1}) == ", 1 queued"
+
+
+class TestSendNowWhileMemoryPrepares:
+ @pytest.mark.asyncio
+ async def test_send_now_on_a_held_card_is_refused_as_memory_preparing(
+ self, _patch_interrupt_sel
+ ):
+ """The drain would park it and never run it, so the route says why and keeps it held."""
+ from test_chat_slot_interrupt import _make_app as _interrupt_app
+ from test_chat_slot_interrupt import _mock_state as _interrupt_state
+
+ slot = _ChatSlot("test")
+ qid = _hold(slot, "restored one")
+ state = _interrupt_state(slot)
+ pending = MagicMock()
+ pending.done = MagicMock(return_value=False)
+ state.memory_startup_task = pending
+ start = AsyncMock(return_value=False)
+
+ with patch("kiro_crew.dashboard.chat_handlers._start_next_queued_turn", new=start):
+ async with TestClient(TestServer(_interrupt_app(state))) as client:
+ resp = await client.post("/api/chat/slots/test/interrupt", json={"queue_id": qid})
+ data = await resp.json()
+
+ assert resp.status == 409
+ assert data["code"] == "memory_preparing"
+ start.assert_not_called()
+ assert is_held_queue_entry(slot._queue[0])
+
+
+@pytest.fixture
+def _patch_interrupt_sel():
+ with patch("kiro_crew.dashboard.chat_handlers.sel", return_value=MagicMock()):
+ yield
+
+
+# ── repeated recovery is stable ────────────────────────────────────────────
+
+
+def test_repeated_restarts_keep_one_notice_and_the_same_held_cards(tmp_path):
+ state = _make_state(tmp_path)
+ slot = state.get_or_create_slot("chat-1")
+ slot.append("user", "first request", "msg msg-u")
+ slot._turn_in_flight_generation = 3
+ slot._turn_in_flight_prompt = None
+ slot.queue_append("queued one")
+ slot.queue_append("queued two")
+ assert _save_slot_to_history(state, slot, force=True)
+ state._slots.pop("chat-1", None)
+
+ first = _rehydrate_slot_from_history(state, "chat-1")
+ assert first is not None
+ assert _save_slot_to_history(state, first, force=True)
+ state._slots.pop("chat-1", None)
+ second = _rehydrate_slot_from_history(state, "chat-1")
+
+ assert second is not None
+ assert len(_restart_rows(second.messages)) <= 1
+ assert [item["content"] for item in second._queue] == ["queued one", "queued two"]
+ assert second.queue_held_depth == 2
+ assert second.queue_ready_depth == 0
+
+
+@pytest.fixture
+def _patched():
+ mock_sel = MagicMock()
+ started = AsyncMock(return_value=True)
+ with (
+ patch("kiro_crew.dashboard.chat_handlers.sel", return_value=mock_sel),
+ patch("kiro_crew.dashboard.chat_handlers._start_next_queued_turn", started),
+ ):
+ yield started
diff --git a/test/test_state_parts_facade.py b/test/test_state_parts_facade.py
index 5808629c589..25987f5ed9c 100644
--- a/test/test_state_parts_facade.py
+++ b/test/test_state_parts_facade.py
@@ -273,6 +273,8 @@
"False) -> 'str'",
),
"queue_depth": ("property", False, False, "(self) -> 'int'"),
+ "queue_held_depth": ("property", False, False, "(self) -> 'int'"),
+ "queue_ready_depth": ("property", False, False, "(self) -> 'int'"),
"queue_edit_by_id": (
"function",
False,
diff --git a/website/src/components/ChatPane.tsx b/website/src/components/ChatPane.tsx
index 728364e4b79..badc6cc63b3 100644
--- a/website/src/components/ChatPane.tsx
+++ b/website/src/components/ChatPane.tsx
@@ -303,8 +303,17 @@ export default function ChatPane({
const composerBusy = busy || awaitingReply
// Queued rows are drawn in place from the slot's full row list, so a parked
// message sits where it was sent (`withQueuedAsBubbles`).
+ // A held message (restored after a restart) is the exception: it is not
+ // waiting for the crewmate, nothing sends it, so it is not drawn as a sent
+ // bubble but as a Held card the person can edit or take back.
+ const crewmateHeld = useMemo(
+ () => (crewmate ? queuedMessages.filter(m => m.meta?.held === true) : []),
+ [crewmate, queuedMessages],
+ )
const messages = useMemo(
- () => (crewmate ? filterCrewmateChat(withQueuedAsBubbles(allMessages, queuedMessages)) : paneMessages),
+ () => (crewmate
+ ? filterCrewmateChat(withQueuedAsBubbles(allMessages, queuedMessages.filter(m => m.meta?.held !== true)))
+ : paneMessages),
[crewmate, allMessages, paneMessages, queuedMessages],
)
// The host's greeting is drawn while it is still the newest word: a reply
@@ -920,6 +929,13 @@ export default function ChatPane({
{!crewmate && queuedMessages.length > 0 && (
)}
+ {/* A crewmate's held messages (restored after a restart) are the one
+ queue a crewmate chat shows as cards, and the one place it offers
+ Send now: nothing else will send them. Edit and Cancel are withheld
+ when the gateway already wrote the message's row (rowWritten). */}
+ {crewmate && crewmateHeld.length > 0 && (
+
+ )}
{/* The pending ask_question card shares the message column's gutter and
--mc-content-width clamp, so it lines up with the messages above it
diff --git a/website/src/components/QueueStack.tsx b/website/src/components/QueueStack.tsx
index 1d9bb602aba..dd0b8043629 100644
--- a/website/src/components/QueueStack.tsx
+++ b/website/src/components/QueueStack.tsx
@@ -20,9 +20,21 @@ import { usePasteTokens } from '../chat-core/composer/pasteEditor'
* queue card is noise at scale — they collapse into one progress line
* (SubagentDeliveryProgress) instead of the interactive QueueStack. */
export function isSystemDelivery(m: ChatMessage): boolean {
+ // A held entry (restored after a restart) is the person's own words whatever
+ // its text opens with: the gateway restores only plain prompts. It must stay
+ // an actionable card on every surface, since only the person moves it.
+ if (isHeldQueued(m)) return false
return hasSubagentCompletionPrefix(m.content || '')
}
+/** A queued entry the gateway restored after a restart and holds for the
+ * person (`is_held_queue_entry`): no drain starts it, so every surface that
+ * draws queue cards must draw it with Send now. Read from `meta.held`, the
+ * server's structural flag, never from the text. */
+export function isHeldQueued(m: ChatMessage): boolean {
+ return m.meta?.held === true
+}
+
/** A queued entry that must NOT render as an interactive (edit/cancel) user
* card. Two families qualify, both machine orchestration rather than user
* speech:
@@ -35,6 +47,7 @@ export function isSystemDelivery(m: ChatMessage): boolean {
* filtered out of the QueueStack (sub-agent deliveries are still counted for
* the progress line via isSystemDelivery). */
export function isNonInteractiveQueued(m: ChatMessage): boolean {
+ if (isHeldQueued(m)) return false
return isSystemDelivery(m) || parseRecoveryMessage(m.content || '') !== null
}
@@ -635,6 +648,9 @@ function QueueStackInner({ messages, onCancel, onInterrupt, onEdit, onEditDraft,
) : null
+ // Restored after a restart: nothing will send it on its own, so the
+ // card says so in words and drops the "next up" hourglass motion.
+ const isHeld = m.meta?.held === true
return (
// The motion box only places the card (peek offset, scale, layer);
@@ -679,7 +695,17 @@ function QueueStackInner({ messages, onCancel, onInterrupt, onEdit, onEditDraft,
>
{i + 1}
- {isFrontCollapsed && (
+ {isHeld ? (
+
+ {i18nT('components.queueStack.held')}
+
+ ) : isFrontCollapsed && (
@@ -756,7 +782,7 @@ function QueueStackInner({ messages, onCancel, onInterrupt, onEdit, onEditDraft,
>
)}
- {onEdit && showActions && !isAppEntry && (
+ {onEdit && showActions && !isAppEntry && m.meta?.rowWritten !== true && (
)}
- {onCancel && showActions && (
+ {/* A held card whose row is already in the transcript has
+ no Cancel: the gateway refuses it (409
+ `queue_item_already_sent`), and Send now is its way out. */}
+ {onCancel && showActions && m.meta?.rowWritten !== true && (