Skip to content

Assistant sub-agents: local runtime, tabs UI, steer/stop, report injection#1561

Open
leonardmq wants to merge 29 commits into
leonard/kil-686-eval-jobfrom
leonard/assistant-subagents
Open

Assistant sub-agents: local runtime, tabs UI, steer/stop, report injection#1561
leonardmq wants to merge 29 commits into
leonard/kil-686-eval-jobfrom
leonard/assistant-subagents

Conversation

@leonardmq

@leonardmq leonardmq commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Stacked PR: leonard/assistant-subagentsleonard/kil-686-eval-job.

Desktop + web UI for assistant sub-agents:

  • Local sub-agent runtime: registry, runner, orchestration tools, and the /api/conversations sub-agent API surface.
  • Sub-agent UI: tab strip with overflow/status dots, live child transcripts with full main-transcript parity, steer (message a running child) and stop, consent flow, and report chips/panels.
  • Reports delivered exclusively via the injection channel (<subagent_report> messages); kickoff message echoed onto the child stream at run start.
  • Lifecycle fixes: children lookup for auto-mode parents (leaf trace id aliasing), working indicator for a child awaiting its first response, errorText surfaced in chat stream errors.
  • Regenerated OpenAPI web types; desktop version bumped to 1.0.4 (the release that ships sub-agents).

Follow-up work on this stack lands in PR #1558 (into this branch).

Latest: mid-turn message injection, event-loop stalls, forensic debug logging

  • Main-agent queued messages now inject mid-turn. Sends on a live interactive run POST straight to the server inbox (the transport sub-agents/cross-tab already used); the engine folds them into the next upstream round instead of holding them until the whole turn settles. The client-side queue remains only for non-injectable moments (approval box open, consent resolving, no session id, failed POST) and flushes into the live run as soon as it becomes injectable. Server complement: idle sends drain any stranded inbox (error-settle leftovers) into the fresh turn, in send order.
  • Remaining event-loop stalls fixed (the /api/conversations?parent= hang/pile-ups): agent_overview, get_tags, and get_judge_feedback_batch_runs still ran full dataset disk scans on the loop; now offloaded via asyncio.to_thread (same treatment as a02cfeb).
  • Forensic debug logging (KILN_CHAT_DEBUG_LOG, defaulted on in the dev server): JSONL timeline of run/round/tool-batch/inbox events (ids + timings, never content). Every upstream request now carries X-Kiln-Conversation-Id, joining the backend's debug log (kiln_server PR [Feature Request] Docs improvement - synthetic data gen example in libs/core/README.md #255) per conversation; the chat footer shows a copyable conversation id while the flag is on.
  • Sub-agent tab strip resilience (from the first debug-log post-mortem, cv_dvs5hq23cn23): the children-list fetch is now bounded (AbortSignal.timeout(10s)) so a request stuck behind the browser's per-origin connection cap can no longer permanently blind the reconcile loop; same-parent blips keep the current tabs (no flicker) while failed cross-parent switches still clear; a 15s watchdog reconnects a firehose EventSource wedged before headers; failed reconciles are logged instead of swallowed. Forensics: sub-agent settles emit run_settled to the debug log, and the empty-model-response error carries finish_reason.

leonardmq added 21 commits July 8, 2026 23:23
- chat/subagents/: SubAgentRunner (autonomous one-shot loop on the shared round
  primitives, agent block on first POST, steer-message drain, interception of
  orchestration/auto-mode calls), SubAgentRegistry (caps, wait, stop, consent
  memory, parent leaf-rotation aliasing, report delivery, TTL GC with
  undelivered-report pinning), per-run byte bus + status firehose.
- Orchestration dispatch: execute_tool_batch routes spawn/status/wait/stop to a
  local executor carrying the parent conversation's identity; ctx threaded from
  ChatStreamSession, AutoChatRunner, and /api/chat/execute-tools.
- Consent: spawn rides the existing approval flow with a one-time downgrade per
  conversation (registry-authoritative); auto parents implicitly consented.
- Completion injection: wait results, auto-run inbound injection, mid-stream
  continuation drain, and next-turn server-side injection in /api/chat.
- Cascades: auto stop/disable and session delete stop children.
- Local API under /api/chat/subagents (list/get/events/firehose/stop/message);
  session list stamped with durable lineage + live sub-agent status.
- Tests: runner/registry/orchestration/api suites on the shared fake upstream.
… chips

- subagent_store.ts: children list synced per conversation, status firehose
  with reconnect, per-child transcript observation (hydrate + live SSE tail via
  the shared StreamEventProcessor), sendMessage/stop.
- Claude-Code-style tabs under the message area: Main + one per child with
  live spinner/status glyphs and hover stop; selecting a child swaps in a
  read-only live transcript and routes the composer to that sub-agent while it
  runs (disabled with a hint once terminal).
- spawn_subagent approvals render a dedicated consent box (one-time per
  conversation); auto-mode consent dialog mentions sub-agents.
- <subagent_report> user messages render as collapsible report chips (hydration
  + live echo paths).
- Chat history nests child sessions under their parent via parent_root_id with
  a sub-agent badge + live status.
- vitest coverage for store, report-frame parsing, and history nesting.
The backend gates the sub-agent orchestration tools on
chat_subagents_min_client_version (1.0.4); a 1.0.3 client never receives the
spawn tool schemas.
Backend/provider errors ride the stream as {type:'error', errorText} while
desktop-shaped errors use {message}; the UI only read message, so provider
failures (e.g. a rate-limited sub-agent generation) rendered as 'An error
occurred.' with the real reason swallowed.
…e auto key

Children of an auto-run parent are keyed auto:<run_id>, but the UI lists them
by the conversation's leaf trace id — which was never aliased for auto parents,
so the tab strip never appeared. Spawning now registers the leaf alias and the
auto runner chains later leaf rotations, mirroring the interactive stream.
Extract the message loop from chat.svelte into a shared chat_transcript.svelte
(user bubbles, markdown, step-group collapsing, tool status lines, thinking/
retry indicators, report chips) consumed by both the main view and the child
view — one renderer, no drift. The child transcript now shows the whole stream
(kickoff message, tool calls, reasoning) read-only, with live activity/retry
state tracked per child in the store and steer-message system-reminder framing
stripped on echo.
…us dots

- Sub-agent reports injected into the parent now render live as collapsed
  panels in the main transcript: the desktop echoes user-message events at
  both injection points and the interactive stream path handles them (was
  auto-observer-only).
- Child kickoff message now carries the full briefing, so a sub-agent tab
  shows its instructions; empty running transcripts show the thinking state.
- Tab lifecycle: strip shows running children (plus the selected terminal one
  until deselected); visibility is derived from status, so a revived agent's
  tab reappears. >3 visible tabs collapse into an 'N agents running' chip
  with a picker dialog.
- Status dots (pulsing green running / green completed / red failed / gray
  stopped / amber timeout) replace the glyphs across tabs and picker.
The observer SSE only carries response events (the kickoff rides the request
body), so a tab opened before the child's first snapshot persisted showed no
instructions — while a History load (full hydration) did. The kickoff is now
echoed as a user-message event with a stable id so buffer replay always
includes it and clients can dedupe against hydrated state.
wait_for_subagents consumed reports into its tool result — buried in a
collapsed step group, never persisted as a <subagent_report> user message, so
the report panel usually never appeared (waiting after a fan-out is the common
pattern). Wait/status results now carry statuses only and reports always land
as injected user messages (persisted + rendered as panels). Also stop wrapping
report frames in the auto runner's side-note reminder, which broke panel
detection on hydration for auto-mode parents.
…ponse

A just-started child rendered its kickoff bubble and then looked stuck: the
shared transcript only hosts its thinking indicator inside an assistant
message. The child view now shows a standalone thinking indicator whenever the
run is active and the transcript tail isn't an assistant message, defaulting
ON before the first processor event and handing over to processor-driven
state. Also dedupe the replayed kickoff echo against a hydration-seeded
briefing so it never renders twice.
…protocol harness

Phase 1 of the assistant unified runtime: a new, self-contained
app/desktop/studio_server/chat/runtime/ package that will replace the three
parallel chat loops (ChatStreamSession, AutoChatRunner, SubAgentRunner) and
their two registries. Nothing is wired into the running app yet — the old
code paths remain untouched and authoritative.

- models.py: RunState + ConversationRecord (one lifecycle enum + auto_flag
  axis replacing AutoRunStatus/SubAgentStatus), frozen ConversationPolicy
  with per-kind factories, InboundMessage/SubAgentSeed/PendingApprovalBatch,
  byte-identical kickoff/seed-body/report-frame builders.
- bus.py: the ONE ByteEventBus + current-turn replay buffer (trace-boundary
  reset, on-subscribe state marker, terminal EOF, close-on-evict),
  generalized from the auto and sub-agent buses.
- engine.py: ConversationEngine — THE round loop, reusing stream_session.py's
  round primitives (iter_round_with_retries, execute_tool_batch,
  continuation builder) with policy-driven approval gating, framing,
  one-shot semantics, and graceful-stop handling.
- interceptors.py: signal-tool chain (enable/disable auto mode, depth guard,
  child noops) with the old scan-priority order preserved.
- supervisor.py: ConversationSupervisor — single session-id registry with one
  settle path (run-once, cancel-before-first-run backstop), caps, report
  delivery/queueing, wait/stop/cascades, and GC (terminal TTL +
  undelivered-report pinning, OFF-auto TTL, interactive LRU).
- sse.py: unified conversation-state control event + canonical copies of the
  generic per-run formatters.

Golden-protocol harness: golden_scenarios.py + checked-in fixtures under
runtime/golden/ pin the exact upstream request-body sequences the OLD loops
produce for seven scripted scenarios (tool round, approval flow, report
injection, auto seed + side-note, disable resolve, sub-agent seed + steer,
unwrapped report inbox); tests assert old loop == fixture == new engine, so
the fixtures remain the behavior contract after later phases delete the old
loops. Plus 90 unit tests across bus/interceptors/engine/supervisor
(modeled on the existing auto/sub-agent suites), including drift guards
pinning the runtime's canonical constant copies to the doomed originals.

Also fixes a pre-existing ruff-format drift in subagents/test_runner.py.
Sub-agents now run on the phase-1 ConversationEngine/ConversationSupervisor
instead of the dedicated SubAgentRunner/SubAgentRegistry, which are deleted
along with the rest of chat/subagents/ (events, sse, models, api + tests).

Backend:
- runtime/supervisor.py: live conversation_supervisor singleton; registry-
  level conversation-state firehose (BroadcastBus) fed by one _publish_state
  dual-publish helper; phase-2-only legacy_report_deliverer hook so a settled
  child's report still injects into the OLD auto registry's inbox for auto:*
  parents (the runtime never imports chat/auto/).
- chat/orchestration.py: the orchestration tool executor relocated (survives
  per architecture §1) and retargeted at the supervisor. Spawn caps as
  tool-result errors, statuses-only wait/status results + note, ownership
  scoping, stop outcome vocabulary, and consent marking are preserved
  verbatim. Hosts the phase-2 parent-identity bridge (ParentConversationIndex:
  trace→parent-key alias chain + consent set; children store the parent_key
  as parent_session_id) plus the report drains, delete cascade, and the
  legacy auto-parent report bridge — all annotated for phase-3/4 removal.
- Integration points retargeted from the deleted registry:
  stream_session.py (orchestration ctx, spawn-consent downgrade,
  note_parent_trace chaining, pending-report drain + echoes), routes.py
  (sessions join via the supervisor trace index — wire names kept, values are
  session ids; delete cascade; next-turn report injection), auto/registry.py
  child-stop cascade, auto/runner.py ctx + aliasing.
- New /api/conversations surface (runtime/api.py, registered in
  desktop_server.py) replacing /api/chat/subagents/*: list?parent=, events
  firehose, get (+include_report), per-conversation observer SSE emitting the
  unified conversation-state event instead of kiln-subagent-status, stop, and
  messages — old statuses (202/404/409) preserved.

Frontend:
- New src/lib/chat/conversation_store.ts (architecture §7) absorbing
  subagent_store.ts (deleted): /api/conversations endpoints +
  conversation-state vocabulary; observe() re-fetches the item for a fresh
  hydration leaf since state events no longer carry trace ids.
- subagent_tabs/transcript/status-dot + chat.svelte re-pointed with zero
  visual/UX change; api_schema.d.ts regenerated.

Tests:
- Old executor/API/registry/runner suites ported (chat/test_orchestration.py,
  runtime/test_api.py, supervisor/engine additions incl. the legacy report
  bridge, session-deleted cascade, framing matrix, steer drain-before-finish);
  conversation_store.test.ts ports the store suite + kind-filter and
  fresh-leaf re-fetch tests.
- Golden harness: subagent_seed_and_steer's old-loop side deleted with the
  runner (test skips with a comment); its fixture remains the durable
  contract the engine keeps matching; regeneration falls back to the engine
  for old-less scenarios.
- Drift guards for strings whose old home died become byte-pins.

uv run ./checks.sh --agent-mode exits 0.
Auto conversations now run on the ConversationSupervisor/Engine and
chat/auto/ (registry, runner, api, events, sse, models + tests) is
deleted. Interactive chat stays on the old loop until phase 4; the
consent accept/decline endpoints bridge into the runtime.

Supervisor: enable_auto (create-or-flip on the same record; ARMED-only
manual enable with no empty upstream POST; R2 no-trace seed; cap 429s;
trace adoption), disable_auto/disable_auto_for_trace (pre-marked
user_disabled, burst cancel, off publish, TTL GC, stop_children — the
phase-1 TODO(phase-3) interactive-disable cascade, wired), set_auto_flag,
resolve_auto_for_trace/auto_record_for_trace. Seed body ported verbatim
as models.build_auto_seed_body and pinned by the golden fixture. The
flag-off GC branch keys on the settled run's POLICY (not record.kind) so
flips can't TTL-GC a live interactive record; send_message refuses
flag-off auto records (old 404 semantics + consent safety). Queued
undelivered reports pin the OFF-auto parent past the terminal TTL so the
resumed interactive turn can still drain them.

Endpoints re-homed under /api/conversations: POST / (enable),
POST /auto/decline (interactive continuation), POST /{sid}/auto (flag
flips), GET /resolve (hard-refresh resync via the whole-chain trace
index); stop/messages/events already unified. The sessions-list join
reads auto state from the supervisor (auto_run_id now carries the
session id). Trace-handle resolution for supervisor parents falls back
to the supervisor trace index in both _resolve_parent_key (auto-parent
children visible via ?parent=<leaf>) and pending_reports_for_trace
(reports from children surviving an in-burst disable_auto_mode drain on
the next interactive turn), kind-guarded so a child's leaf is never a
parent handle.

OrchestrationContext carries parent_session_id (parent_auto_run_id,
auto:* alias keys, and the phase-2 legacy_report_deliverer bridge are
gone — auto parents' reports route natively through the supervisor
inbox). ParentConversationIndex survives only for old-loop interactive
parents (phase-4 removal).

Frontend: auto_run_store.ts folded into conversation_store.ts as
auto_conversation_store; one documented mapping translates the unified
conversation-state event back onto the exact old
auto-mode-on/off/idle/state UX (green dot, waiting-for-you, consent and
stop dialogs) with zero visual change. The attach-time on-subscribe
state marker updates flag/working without signaling the idle sink
(matching the old auto-mode-state marker). chat_session_store,
chat.svelte, chat_history.svelte re-pointed; api_schema.d.ts
regenerated.

Tests: auto endpoint/registry/runner suites ported to
supervisor/engine/api level (consent→burst, armed-only enable, flips,
disable cascade, resolve, off-auto refusal, GC pinning, sessions join,
interactive interception via /api/chat); golden auto scenarios skip
their deleted old-loop side (fixtures remain the durable contract, now
driving the ported seed builder); interceptor drift guards converted to
byte-pins; test_fakes.py relocated to chat/; full auto_run_store test
port in conversation_store.test.ts plus attach-marker and kind-guard
tests.
Interactive conversations — the most-used path — now run as supervised
turn tasks on chat/runtime/ (IDLE → RUNNING → IDLE), and the entire old
interactive surface is deleted: POST /api/chat, /api/chat/execute-tools,
ChatStreamSession (the loop class; the shared round primitives stay), the
/api/conversations/auto/decline bridge, and ParentConversationIndex (the
last old-world identity bridge). routes.py keeps only the history proxies
and the version policy.

Backend:
- POST /api/conversations grows a kind selector: kind="interactive" is
  create-or-adopt keyed by the (possibly stale) leaf trace id, idempotent
  via the whole-chain trace index (supervisor.adopt_interactive).
- POST /{sid}/messages on IDLE starts a turn (body byte-identical to the
  old POST /api/chat, incl. the next-turn sub-agent report injection
  re-homed from routes.post_chat) and returns {message_id} so the sending
  tab can dedupe its own echo; RUNNING/AWAITING_APPROVAL sends queue.
- Approvals are parked batches: GET /{sid}/approvals +
  POST /{sid}/approvals/decisions (batch id validated; first decision set
  wins, second tab gets 409). Runless batches (recovery) resume via
  engine.run(resume_batch=...) producing exactly the old /execute-tools
  continuation body.
- Restart/refresh recovery: pending approvals rehydrate from the persisted
  trace tail (unanswered calls only, conservatively gated; signal siblings
  resolve as declined via PendingApprovalBatch.preresolved_results so the
  trace keeps no dangling call); the pending event stays in the replay
  buffer while parked.
- The auto flip is the true "same run, new policy" flip: enable swaps an
  interactive record's policy AND kind to auto on the SAME record; every
  flag-off settle swaps it back — the OFF-auto TTL GC is replaced by the
  idle-interactive LRU pool (with the undelivered-report/live-children
  pinning re-homed), and the flag-off send refusal is lifted structurally.
- Consent decline folds into POST /{sid}/auto (enabled=false + decline
  ctx), byte-identical continuation, streaming on the observer. The engine
  interactive-disable interception wires the full old cascade via
  EngineIO.on_auto_flag_cleared. Interactive stop() cancels the turn
  without cascading to children (session deletion still cascades,
  explicitly). OrchestrationContext shrinks to {parent_session_id, depth}.

Frontend:
- streamChat/resumePendingToolCalls die; the auto store generalizes into
  main_conversation_store — ONE observer for both kinds (off transitions
  no longer close the stream; new onInteractiveIdle/onAwaitingApproval/
  onConsentRequired/onVersionNudge sink hooks; ensure/fetchApprovals/
  decide/decline operations; observer errors mid-turn surface like the old
  fetch-error path and the not-working signal resets a stuck composer
  without flushing the queue).
- chat_session_store.ts shrinks to UI-session state; sessionStorage keeps
  only {sessionId, traceId, ui prefs} — transcripts always rebuild from
  hydrate+observe. Approval box keys off AWAITING_APPROVAL + the fetched
  batch; queued-message UX, consent dialog, retry indicator, context
  gauge, version banners and welcome flows unchanged. api_schema.d.ts
  regenerated.

Tests: golden interactive scenarios run_old=None (fixtures remain the
durable contract; interactive_approval_flow now also pins a DENIED item);
old route/loop suites ported to the engine/supervisor/API surfaces incl.
rehydration, runless-batch resume, flip, decline and recovery coverage;
paid integration tests re-driven through the supervisor; frontend store
suites fully ported (refresh-convergence and observer-drop regressions
included). checks.sh green.
trace_id leaves the desktop↔browser surface (functional spec §4: browser
code never sees trace_id) — the browser now holds one opaque conversation
key (live cv_ session id / durable upstream root_id / legacy leaf) and the
desktop owns the key→leaf mapping while the upstream API stays trace-keyed
until phase 6.

Desktop:
- routes.py: resolve_conversation_key (live record → root→leaf via a
  bounded, snapshot-shape-gated upstream-list scan → legacy leaf); an
  INDETERMINATE scan (transport failure / upstream error / bound exhausted)
  raises UpstreamResolutionError and fails loudly with 503 — never the
  silent root-as-leaf fall-through. Sessions-list rows keyed live-sid →
  root_id → leaf; GET/DELETE accept any key and resolve the CURRENT leaf;
  the snapshot proxy passes root_id through.
- ConversationRecord.root_id: the durable upstream session id, stamped by
  the engine PRE-EMIT on a fresh record's first kiln_chat_trace (so the
  browser's item GET triggered by that byte always sees it), by
  adopt-by-root, and backfilled from the rehydration fetch.
- runtime/api.py: ConversationItem drops current_trace_id, gains root_id;
  POST /api/conversations re-keyed trace_id→session_id (interactive
  create/adopt by key incl. terminal-record and dead-cv_ shapes; auto
  enable keyed by the live sid, seeding from the record's own leaf —
  unknown sid 404s instead of forking); GET /api/conversations/resolve,
  supervisor.resolve_auto_for_trace, and _resolve_parent_key deleted; the
  consent control event drops trace_id.
- The trace index stays INTERNAL (sessions-list join; TODO(phase-6) notes
  where the desktop-side root→leaf resolution collapses).

Frontend:
- sessionStorage persists {sessionId, rootId} — rootId replaces the leaf
  traceId as the desktop-restart recovery key, learned from history rows,
  hydration snapshots, the resync item GET, and a one-shot item fetch on
  the first persisted turn (generation+sid stale-write guards so a
  conversation switch racing the fetch can never stamp the old root).
- traceIdForNextChatRequest and hydration trace stamping deleted; ensure/
  loadSession/resync/consent/armed/manual-enable all key on session ids;
  children sync keys on main_conversation_store.sessionId; child
  transcripts hydrate by session id (the desktop resolves the fresh leaf);
  the sink's onChatTrace(traceId) became onTurnPersisted().
- Zero visual/UX change; api_schema.d.ts regenerated.

Tests: 241 backend chat tests (+16 skipped paid) and 1314 frontend tests
green; new coverage for row keying, key resolution (incl. indeterminate-
scan 503s and paging bounds), root stamping/backfill/races, sid-keyed
enable and /resolve absence pins. uv run ./checks.sh --agent-mode exit 0.
…an (phase 6)

The backend now resolves either id kind (leaf trace id or durable root/session
id) itself — GET/DELETE /v1/chat/sessions/{id} and session_id continuation on
POST /v1/chat/ — so the desktop's phase-5 key→leaf machinery is deleted and
resume-after-restart needs no leaf bookkeeping anywhere.

- routes.py: delete _leaf_for_root_id, the scan bounds, and
  UpstreamResolutionError (+ both 503 branches — the indeterminate-resolution
  failure mode lives upstream now and passes through like any other error).
  resolve_conversation_key is synchronous and purely local: live records
  resolve to their freshest upstream identity (current leaf → root → adopted
  resume key); dead cv_ handles 404; anything else forwards VERBATIM. The
  sessions-list join gains a root-id fallback so a record adopted by root key
  (no leaf until its first persist) still joins its row.
- runtime: ConversationRecord.resume_session_key holds the opaque adopted key
  (root or legacy leaf — indistinguishable desktop-side, and deliberately
  never stamped into root_id, which must stay the TRUE durable recovery key).
  adopt_interactive(session_key) adopts cold keys verbatim, indexes them in
  the whole-chain trace index (idempotent re-adopt + list join) and seeds
  seen_trace_ids so the engine's root-stamp suppression holds unchanged.
  continuation_key_fields gives every idle-start body one precedence:
  trace_id for the normal in-process flow (kept deliberately — the engine
  holds the fresh leaf; switching would only add a backend resolution per
  turn), session_id for resume-by-key first turns and keyless rehydrated
  approval batches. Approval rehydration fetches by leaf-or-resume-key and
  keeps backfilling the true root_id from the response.
- engine/stream_session: continuation rebuilds drop session_id once a real
  trace id exists (the backend 400s the two keys together — same first-POST
  lifecycle as the agent block), and _build_openai_tool_continuation treats a
  session_id base as trace-only so resumed batch continuations stay
  role:tool-results-only.
- orchestration.handle_session_deleted accepts any conversation key (live sid
  or indexed trace/root key) since the delete proxy no longer resolves a leaf.
- Generated SDK deliberately NOT regenerated: the only wire change is a
  request-body field on an endpoint driven via raw httpx dict bodies, and the
  session endpoints take the id as a path param the SDK already passes
  verbatim.

Tests: scan-machinery suites replaced with verbatim-forwarding/live-key
resolution tests; adopt-by-key, session_id-first-turn → trace_id-after-persist
(API-level end-to-end and engine-level), rehydrated-batch session_id bases,
and the list-join root fallback. Golden protocol fixtures untouched — the
normal trace_id turn protocol is byte-identical.
Both are intermittent races reproduced live and root-caused:

BUG 1 (server inbox stranding): a send_message POST landing in the window
between the engine's last empty drain_inbox() and the state flip to IDLE
appended to conv.inbox but was never consumed — the turn settled and the
message stranded, leaving the browser stuck 'thinking' (echo rendered, no
reply) until a refresh. Fix: _finish_run restarts a fresh turn from a
non-empty inbox on a NATURAL settle only (ended_naturally gate excludes
cancel/exception so stop/disable can't resurrect a run or double-settle);
_restart_from_inbox mirrors send_message's idle re-arm exactly (unframed
messages, report injection, continuation_key_fields, auto_mode), preserving
echo-once and persisted-trace fidelity.

BUG 2 (client missed-live-idle flush): conversation-state idle events are
live-only (never buffered), so if the observer is momentarily detached at the
settle instant the browser gets only the on-subscribe idle MARKER, whose
handler suppressed the settle hooks — onInteractiveIdle/maybeFlush never fired
and a client-held queuedMessage stranded (chip stayed visible). Fix: a new
flush-only onIdleMarker sink hook fires on a flag-off idle marker and calls
maybeFlush; guarded so it no-ops in the refresh-brick sequence and can't
double-send (dispatchQueued clears before send). The two fixes act on disjoint
message sets (client-held vs already-POSTed), so no overlap.
Previously, a sub-agent's report woke an AUTO-mode parent immediately (inbox
injection) but an INTERACTIVE parent's report merely queued and waited for the
user's NEXT message to drain it — so you could spawn helpers, watch them
finish, and nothing happened until you poked the parent. That defeated the
point of sub-agents (the parent processing their results).

_deliver_report now delivers through the parent's inbox for EVERY live parent,
auto or interactive: an idle parent is woken to process the report in a normal
GATED turn (any mutating tool call still parks for approval; only the already-
consented spawns run unprompted), and report panels appear live as children
finish. This removes the auto_flag special-case and unifies the two paths.

Cascade safety: since delivery now wakes parents, stop_children (parent
stopped/disabled/deleted) pre-marks each child report_delivered so a teardown
cascade never wakes the parent it is tearing down — while a standalone
stop(child) still delivers the child's stop-note so the parent learns the
outcome. Queue fallback remains for a gone/off-auto parent (drained on its
next turn). Product decision confirmed with the user.
A running sub-agent's tab reached the strip only if the frontend received the
child's single spawn-time conversation-state 'running' firehose event AND a
resulting fetchChildren survived. Since per-round state publishes were removed,
a running child emits that event exactly once — so a firehose micro-drop, the
server snapshot/subscribe race, or a fetch-race edge left the running child
absent with no recovery until it settled (terminal tabs are dropped by design)
or the user refreshed.

Two complementary fixes:
- Server: BroadcastBus.subscribe gains a snapshot hook so the firehose
  registers the subscriber BEFORE building the list_records() snapshot — an
  event published during snapshot construction is now queued, not lost. Safe
  dedup drops only byte-identical backlog until the first genuinely-new event,
  so real transitions are never suppressed.
- Frontend: a 3.5s reconcile timer (active only while the firehose is
  connected and a parent is set) re-fetches children, so a missed running-child
  event converges within seconds instead of needing a manual refresh.

Tests: bus snapshot-gap + dedup, firehose end-to-end child-spawned-after-connect,
frontend reconcile timer. Live-verified across repeated spawns.
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

📊 Coverage Report

Overall Coverage: 92%

Diff: origin/leonard/kil-686-eval-job...HEAD

  • app/desktop/desktop_server.py (100%)
  • app/desktop/studio_server/_version.py (100%)
  • app/desktop/studio_server/agent_api.py (90.0%): Missing lines 557
  • app/desktop/studio_server/chat/init.py (100%)
  • app/desktop/studio_server/chat/debug_log.py (100%)
  • app/desktop/studio_server/chat/helpers.py (100%)
  • app/desktop/studio_server/chat/orchestration.py (88.0%): Missing lines 109,111,140-143,228-229,245,254-255,257,278,307
  • app/desktop/studio_server/chat/routes.py (97.6%): Missing lines 472
  • app/desktop/studio_server/chat/runtime/init.py (100%)
  • app/desktop/studio_server/chat/runtime/api.py (94.1%): Missing lines 288,321,337,457,461,465,492,609,614,623-624
  • app/desktop/studio_server/chat/runtime/bus.py (93.9%): Missing lines 72,75,78,83,182,190
  • app/desktop/studio_server/chat/runtime/engine.py (96.2%): Missing lines 353-354,381,666-667,670,678,684,686-687,889
  • app/desktop/studio_server/chat/runtime/golden_scenarios.py (88.1%): Missing lines 87,480-483,487,491-494,498,500
  • app/desktop/studio_server/chat/runtime/interceptors.py (100%)
  • app/desktop/studio_server/chat/runtime/models.py (91.1%): Missing lines 51,376,450-455,462-467
  • app/desktop/studio_server/chat/runtime/sse.py (100%)
  • app/desktop/studio_server/chat/runtime/supervisor.py (92.3%): Missing lines 102,141-146,162-168,206,210,220,225,231-232,410,663,669,676,749,751-753,765,874,931,946-947,1292,1403,1410-1411,1417,1493-1498,1701-1702,1887,1898,1935,1941,1975
  • app/desktop/studio_server/chat/stream_session.py (80.0%): Missing lines 35,509
  • app/desktop/studio_server/eval_api.py (100%)
  • app/desktop/studio_server/judge_feedback_batch_api.py (100%)
  • libs/core/kiln_ai/adapters/eval/eval_runner.py (100%)
  • libs/core/kiln_ai/adapters/model_adapters/adapter_stream.py (100%)
  • libs/server/kiln_server/run_api.py (100%)
  • libs/server/kiln_server/task_api.py (100%)

Summary

  • Total: 1845 lines
  • Missing: 123 lines
  • Coverage: 93%

Line-by-line

View line-by-line diff coverage

app/desktop/studio_server/agent_api.py

Lines 553-561

  553         def build_overview() -> AgentOverview:
  554             task = task_from_id(project_id, task_id)
  555             project = task.parent_project()
  556             if project is None:
! 557                 raise HTTPException(status_code=404, detail="Project not found")
  558 
  559             task_run_configs = get_all_run_configs(project_id, task_id)
  560 
  561             instruction_text = truncate_to_words_with_agent_sentinel(

app/desktop/studio_server/chat/orchestration.py

Lines 105-115

  105         "state": record.state.value,
  106         "rounds": record.rounds_used,
  107     }
  108     if record.current_leaf_trace_id is not None:
! 109         payload["session_id"] = record.current_leaf_trace_id
  110     if include_report and record.state.is_terminal and record.final_report:
! 111         payload["report"] = record.final_report
  112     return payload
  113 
  114 
  115 async def execute_orchestration_tool(

Lines 136-147

  136         if tool_name == WAIT_FOR_SUBAGENTS_TOOL_NAME:
  137             return await _wait(args, parent_key)
  138         if tool_name == STOP_SUBAGENT_TOOL_NAME:
  139             return await _stop(args, parent_key)
! 140     except Exception:
! 141         logger.exception("Orchestration tool %s failed", tool_name)
! 142         return _error("Internal error executing the sub-agent operation.")
! 143     return _error(f"Unknown orchestration tool: {tool_name}")
  144 
  145 
  146 async def _spawn(args: dict[str, Any], parent_key: str) -> str:
  147     agent_type = args.get("agent_type")

Lines 224-233

  224     if subagent_id:
  225         record = _owned_record(str(subagent_id), parent_key)
  226         if record is None:
  227             return _error(f"Unknown sub-agent id: {subagent_id}")
! 228         payload = _record_payload(record, include_report)
! 229         return json.dumps(
  230             {"subagents": [payload], "note": _REPORTS_AS_MESSAGES_NOTE},
  231             ensure_ascii=False,
  232         )

Lines 241-249

  241 
  242 async def _wait(args: dict[str, Any], parent_key: str) -> str:
  243     raw_ids = args.get("subagent_ids")
  244     if not isinstance(raw_ids, list) or not raw_ids:
! 245         return _error("wait_for_subagents requires a non-empty subagent_ids list.")
  246     ids = [str(sid) for sid in raw_ids]
  247     unknown = [sid for sid in ids if _owned_record(sid, parent_key) is None]
  248     if unknown:
  249         return _error(f"Unknown sub-agent ids: {', '.join(unknown)}")

Lines 250-261

  250 
  251     timeout = args.get("timeout_seconds")
  252     try:
  253         timeout_seconds = float(timeout) if timeout is not None else None
! 254     except (TypeError, ValueError):
! 255         timeout_seconds = None
  256     if timeout_seconds is None:
! 257         timeout_seconds = WAIT_DEFAULT_TIMEOUT_SECONDS
  258     timeout_seconds = min(max(timeout_seconds, 1.0), WAIT_MAX_TIMEOUT_SECONDS)
  259 
  260     records, timed_out = await conversation_supervisor.wait(ids, timeout_seconds)
  261     return json.dumps(

Lines 274-282

  274 
  275 async def _stop(args: dict[str, Any], parent_key: str) -> str:
  276     subagent_id = args.get("subagent_id")
  277     if not isinstance(subagent_id, str) or not subagent_id:
! 278         return _error("stop_subagent requires a subagent_id.")
  279     record = _owned_record(subagent_id, parent_key)
  280     if record is None:
  281         return json.dumps({"status": "not_found"}, ensure_ascii=False)
  282     # The supervisor's stop() is idempotent-void; the outcome vocabulary the

Lines 303-311

  303     left to consume their reports) and stop the conversation itselfa
  304     deleted CHILD session stops that child (old behavior), and a deleted
  305     parent's in-flight run is cancelled."""
  306     if conversation_supervisor.get(conversation_key) is not None:
! 307         session_id: str | None = conversation_key
  308     else:
  309         session_id = conversation_supervisor.session_for_trace(conversation_key)
  310     if session_id is None:
  311         return

app/desktop/studio_server/chat/routes.py

Lines 468-476

  468         leaf server-side, so the desktop no longer needs the leaf to delete a
  469         root-keyed session)."""
  470         resolved = resolve_conversation_key(session_id)
  471         if resolved.upstream_key is None:
! 472             raise HTTPException(
  473                 status_code=404, detail=f"Chat session not found: {session_id}"
  474             )
  475         api_key = get_copilot_api_key()
  476         client = get_authenticated_client(api_key)

app/desktop/studio_server/chat/runtime/api.py

Lines 284-292

  284     async for item in iter_with_keepalive(
  285         conversation_supervisor.subscribe(session_id), KEEPALIVE_SECONDS
  286     ):
  287         if isinstance(item, KeepalivePing):
! 288             yield b": ping\n\n"
  289         else:
  290             yield item
  291 

Lines 317-325

  317             yield payload
  318 
  319     async for item in iter_with_keepalive(_with_snapshot(), KEEPALIVE_SECONDS):
  320         if isinstance(item, KeepalivePing):
! 321             yield b": ping\n\n"
  322         else:
  323             yield item
  324 

Lines 333-341

  333     async def chat_debug_status() -> ChatDebugStatus:
  334         """Whether ``KILN_CHAT_DEBUG_LOG`` forensic logging is on — the UI
  335         surfaces the conversation id (the join key for the desktop and
  336         kiln_server debug logs) when it is."""
! 337         return ChatDebugStatus(debug_log_enabled=chat_debug_enabled())
  338 
  339     @app.get(
  340         "/api/conversations",
  341         summary="List conversations",

Lines 453-469

  453             )
  454         except ValueError as exc:
  455             # Sub-agent/terminal records can't enable auto mode (mirrors
  456             # set_auto_flag's "invalid" outcome).
! 457             raise HTTPException(status_code=409, detail=str(exc))
  458         except ConversationCapError as exc:
  459             # Same cap surface as today: HTTP 429 with the preserved message.
  460             raise HTTPException(status_code=429, detail=str(exc))
! 461         except RuntimeError as exc:
  462             # start_run refused because a run is already in flight for this
  463             # conversation (the old world silently spawned a duplicate
  464             # registry run here — a latent bug, not a contract).
! 465             raise HTTPException(status_code=409, detail=str(exc))
  466         if body.reason:
  467             # The model's stated reason for requesting auto mode. The old
  468             # record.reason field died with /api/chat/auto/sessions; keep the
  469             # observability in the log line instead.

Lines 488-496

  488     )
  489     async def stream_conversation_state_events() -> CancellableStreamingResponse:
  490         """Registry-level firehose of ``conversation-state`` events (snapshot
  491         then live)."""
! 492         return CancellableStreamingResponse(
  493             content=_state_firehose_stream(),
  494             media_type="text/event-stream",
  495         )

Lines 605-618

  605                 raise HTTPException(
  606                     status_code=404, detail=f"Conversation not found: {session_id}"
  607                 )
  608             if outcome == "invalid":
! 609                 raise HTTPException(
  610                     status_code=409,
  611                     detail=f"Conversation cannot decline auto mode: {session_id}",
  612                 )
  613             if outcome == "busy":
! 614                 raise HTTPException(
  615                     status_code=409,
  616                     detail=f"Conversation already has a run in flight: {session_id}",
  617                 )
  618             return Response(status_code=202)

Lines 619-628

  619         try:
  620             outcome = await conversation_supervisor.set_auto_flag(
  621                 session_id, body.enabled
  622             )
! 623         except ConversationCapError as exc:
! 624             raise HTTPException(status_code=429, detail=str(exc))
  625         if outcome == "not_found":
  626             raise HTTPException(
  627                 status_code=404, detail=f"Conversation not found: {session_id}"
  628             )

app/desktop/studio_server/chat/runtime/bus.py

Lines 68-87

  68         if not line.startswith(b"data: "):
  69             continue
  70         body = line[6:].strip()
  71         if not body or body == b"[DONE]":
! 72             continue
  73         try:
  74             event = json.loads(body)
! 75         except (ValueError, TypeError):
  76             # ValueError covers JSONDecodeError AND UnicodeDecodeError (a
  77             # non-UTF-8 byte payload raises the latter before JSON parsing).
! 78             continue
  79         if isinstance(event, dict) and event.get("type") == KILN_SSE_CHAT_TRACE:
  80             tid = event.get("trace_id")
  81             if isinstance(tid, str) and tid:
  82                 return tid
! 83     return None
  84 
  85 
  86 class _CloseSentinel:
  87     """Pushed onto every subscriber's queue by ``close()`` to end its stream

Lines 178-186

  178                 deduping = True
  179                 while not subscriber.queue.empty():
  180                     item = subscriber.queue.get_nowait()
  181                     if isinstance(item, _CloseSentinel):
! 182                         return
  183                     if deduping and item in emitted:
  184                         continue
  185                     deduping = False
  186                     yield item

Lines 186-194

  186                     yield item
  187             while True:
  188                 item = await subscriber.queue.get()
  189                 if isinstance(item, _CloseSentinel):
! 190                     return
  191                 yield item
  192         finally:
  193             self._subscribers.discard(subscriber)

app/desktop/studio_server/chat/runtime/engine.py

Lines 349-358

  349                     trace_id_for_error = round_state.trace_id_for_error
  350                 if result.status == "stopped":
  351                     # Stop pressed mid-retry: the helper surfaced no error;
  352                     # settle stopped (old auto USER_STOPPED / sub-agent STOPPED).
! 353                     self._finish_stopped(record, policy)
! 354                     return
  355                 if result.status != "ok" or round_state is None:
  356                     # Non-retryable or retry-exhausted upstream error; the
  357                     # error SSE was already emitted. One-shot runs FAIL (old
  358                     # sub-agent), others idle with reason "error" — the flag

Lines 377-385

  377                     # boundary backstop, idempotent via the None check.
  378                     # Adopted records (seen_trace_ids seeded at adopt) joined
  379                     # mid-chain and never stamp from a trace.
  380                     if record.root_id is None and not record.seen_trace_ids:
! 381                         record.root_id = round_state.trace_id
  382                     record.current_leaf_trace_id = round_state.trace_id
  383                     if round_state.trace_id not in record.seen_trace_ids:
  384                         record.seen_trace_ids.append(round_state.trace_id)
  385                     if io.on_trace is not None:

Lines 662-674

  662                 if not results:
  663                     if io.stop_requested():
  664                         # On graceful stop just settle — nothing to surface
  665                         # for approval (old auto empty-results stop branch).
! 666                         self._finish_stopped(record, policy)
! 667                         return
  668                     injected = io.drain_inbox()
  669                     if injected:
! 670                         chat_debug_log(
  671                             "inbox_injected",
  672                             conversation_id=record.session_id,
  673                             round=record.rounds_used,
  674                             site="server_only_batch",

Lines 674-682

  674                             site="server_only_batch",
  675                             count=len(injected),
  676                             message_ids=[m.id for m in injected],
  677                         )
! 678                         body = {
  679                             **body,
  680                             "messages": [
  681                                 _frame_inbox_message(m, policy) for m in injected
  682                             ],

Lines 680-691

  680                             "messages": [
  681                                 _frame_inbox_message(m, policy) for m in injected
  682                             ],
  683                         }
! 684                         continue
  685                     if policy.one_shot:
! 686                         record.state = RunState.COMPLETED
! 687                         return
  688                     # Old auto reason vocabulary: a tool batch that produced
  689                     # no results settles "done". (The old interactive stream
  690                     # simply ended its turn here.)
  691                     self._finish_idle(record, "done")

Lines 885-893

  885                 continue
  886             if res.kind == "resolve":
  887                 assert res.result_json is not None
  888                 return res.result_json
! 889             return None
  890         return None
  891 
  892     async def _resolve_terminal(
  893         self,

app/desktop/studio_server/chat/runtime/golden_scenarios.py

Lines 83-91

  83     return list(client.bodies)
  84 
  85 
  86 async def _consume(stream) -> list[bytes]:
! 87     return [chunk async for chunk in stream]
  88 
  89 
  90 async def _run_engine(
  91     *,

Lines 476-501

  476     return data["bodies"]
  477 
  478 
  479 def _write_fixture(name: str, bodies: list[dict[str, Any]]) -> None:
! 480     GOLDEN_DIR.mkdir(exist_ok=True)
! 481     with fixture_path(name).open("w") as f:
! 482         json.dump({"scenario": name, "bodies": bodies}, f, indent=2, ensure_ascii=False)
! 483         f.write("\n")
  484 
  485 
  486 async def _regenerate_all() -> None:
! 487     for scenario in SCENARIOS:
  488         # While an old loop exists it is the reference; once deleted the
  489         # engine IS the reference implementation (module docstring), so
  490         # deliberate scenario changes re-capture from it.
! 491         capture = scenario.run_old or scenario.run_engine
! 492         bodies = await capture()
! 493         _write_fixture(scenario.name, bodies)
! 494         print(f"wrote {fixture_path(scenario.name)} ({len(bodies)} bodies)")
  495 
  496 
  497 if __name__ == "__main__":
! 498     import asyncio
  499 
! 500     asyncio.run(_regenerate_all())

app/desktop/studio_server/chat/runtime/models.py

Lines 47-55

  47 
  48 if TYPE_CHECKING:
  49     # Import cycle avoidance only: interceptors.py imports models for the
  50     # context/result types, so the Interceptor callable type is quoted here.
! 51     from app.desktop.studio_server.chat.runtime.interceptors import Interceptor
  52 
  53 # Same id alphabet/length as the old auto (`ar_`) and sub-agent (`sa_`) ids so
  54 # ids stay visually consistent across the migration.
  55 _ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"

Lines 372-380

  372             }
  373         )
  374 
  375     for tc_id, output in sibling_results.items():
! 376         messages.append(
  377             {
  378                 "role": "tool",
  379                 "tool_call_id": tc_id,
  380                 "content": output,

Lines 446-459

  446 
  447 def _resolve_positive_int_env(env_var: str, default: int) -> int:
  448     raw = os.environ.get(env_var)
  449     if raw:
! 450         try:
! 451             value = int(raw)
! 452             if value > 0:
! 453                 return value
! 454         except ValueError:
! 455             pass
  456     return default
  457 
  458 
  459 def _resolve_positive_float_env(env_var: str, default: float) -> float:

Lines 458-471

  458 
  459 def _resolve_positive_float_env(env_var: str, default: float) -> float:
  460     raw = os.environ.get(env_var)
  461     if raw:
! 462         try:
! 463             value = float(raw)
! 464             if value > 0:
! 465                 return value
! 466         except ValueError:
! 467             pass
  468     return default
  469 
  470 
  471 MessageFraming = Literal["none", "side_note", "steer"]

app/desktop/studio_server/chat/runtime/supervisor.py

Lines 98-106

   98 if TYPE_CHECKING:
   99     # Import-cycle avoidance only: chat/orchestration.py imports this module
  100     # at module level (it targets the supervisor singleton), so the ctx type
  101     # is quoted and resolved lazily where instances are built.
! 102     from app.desktop.studio_server.chat.orchestration import OrchestrationContext
  103 
  104 logger = logging.getLogger(__name__)
  105 
  106 # ── Caps & TTLs: same defaults, env vars, and semantics as the old

Lines 137-150

  137 
  138 def _resolve_int_env(env_var: str, default: int) -> int:
  139     raw = os.environ.get(env_var)
  140     if raw:
! 141         try:
! 142             value = int(raw)
! 143             if value > 0:
! 144                 return value
! 145         except ValueError:
! 146             pass
  147     return default
  148 
  149 
  150 # Timeout for the one-off upstream snapshot GET the approval-rehydration path

Lines 158-172

  158     form some providers persist). Mirrors the web UI's ``extractTextContent``
  159     so both ends read the same tail."""
  160     if isinstance(content, str):
  161         return content
! 162     if isinstance(content, list):
! 163         parts: list[str] = []
! 164         for part in content:
! 165             if isinstance(part, dict) and "text" in part:
! 166                 parts.append(str(part["text"]))
! 167         return "".join(parts)
! 168     return ""
  169 
  170 
  171 def _pending_events_from_trace_tail(
  172     trace: list[dict[str, Any]],

Lines 202-214

  202             last_assistant = trace[idx]
  203             last_assistant_idx = idx
  204             break
  205     if last_assistant is None:
! 206         return [], [], ""
  207     assistant_text = _trace_text_content(last_assistant.get("content"))
  208     tool_calls = last_assistant.get("tool_calls") or []
  209     if not isinstance(tool_calls, list) or not tool_calls:
! 210         return [], [], assistant_text
  211     answered = {
  212         msg.get("tool_call_id")
  213         for msg in trace[last_assistant_idx + 1 :]
  214         if msg.get("role") == "tool"

Lines 216-229

  216     events: list[ToolInputAvailableEvent] = []
  217     signal_events: list[ToolInputAvailableEvent] = []
  218     for tc in tool_calls:
  219         if not isinstance(tc, dict):
! 220             continue
  221         tc_id = tc.get("id")
  222         function = tc.get("function") or {}
  223         name = function.get("name") if isinstance(function, dict) else None
  224         if not isinstance(tc_id, str) or not isinstance(name, str) or not name:
! 225             continue
  226         if tc_id in answered:
  227             continue
  228         raw_args = function.get("arguments") if isinstance(function, dict) else None
  229         try:

Lines 227-236

  227             continue
  228         raw_args = function.get("arguments") if isinstance(function, dict) else None
  229         try:
  230             parsed = json.loads(raw_args) if isinstance(raw_args, str) else {}
! 231         except json.JSONDecodeError:
! 232             parsed = {}
  233         event = ToolInputAvailableEvent(
  234             toolCallId=tc_id,
  235             toolName=name,
  236             input=parsed if isinstance(parsed, dict) else {},

Lines 406-414

  406         if session_id is None:
  407             return None
  408         conv = self._conversations.get(session_id)
  409         if conv is None:
! 410             return None
  411         record = conv.record
  412         if record.kind != "auto" or not record.auto_flag:
  413             return None
  414         return record

Lines 659-667

  659         every later subscriber while the batch is parked.
  660         """
  661         conv = self._conversations.get(session_id)
  662         if conv is None or conv.record.state.is_terminal:
! 663             return None
  664         if conv.pending_batch is not None and not conv.pending_batch.decided.is_set():
  665             # A live (or already-rehydrated) undecided batch is authoritative.
  666             return conv.pending_batch
  667         if conv.task is not None and not conv.task.done():

Lines 665-673

  665             # A live (or already-rehydrated) undecided batch is authoritative.
  666             return conv.pending_batch
  667         if conv.task is not None and not conv.task.done():
  668             # A live run owns its own round context — never second-guess it.
! 669             return None
  670         # The fetch key mirrors the continuation precedence (phase 6): the
  671         # record's own leaf when one is known, else the adopted resume key —
  672         # the upstream GET accepts either id kind and returns the session's
  673         # CURRENT leaf snapshot either way.

Lines 672-680

  672         # the upstream GET accepts either id kind and returns the session's
  673         # CURRENT leaf snapshot either way.
  674         fetch_key = conv.record.current_leaf_trace_id or conv.record.resume_session_key
  675         if fetch_key is None:
! 676             return None
  677 
  678         trace = await self._fetch_persisted_trace(conv, fetch_key)
  679         if not trace:
  680             return None

Lines 745-757

  745                 timeout=REHYDRATE_FETCH_TIMEOUT_SECONDS
  746             ) as client:
  747                 response = await client.get(url, headers=conv.headers)
  748             if response.status_code != 200:
! 749                 return None
  750             data = response.json()
! 751         except (httpx.HTTPError, json.JSONDecodeError, ValueError):
! 752             logger.debug("Approval rehydration fetch failed for %s", session_key)
! 753             return None
  754         if isinstance(data, dict) and conv.record.root_id is None:
  755             # Opportunistic backfill of the durable session handle (phase 5):
  756             # the upstream snapshot response carries ``session_meta.root_id``
  757             # at the top level, and a record adopted from a bare legacy leaf

Lines 761-769

  761                 conv.record.root_id = root_id
  762         task_run = data.get("task_run") if isinstance(data, dict) else None
  763         trace = task_run.get("trace") if isinstance(task_run, dict) else None
  764         if not isinstance(trace, list):
! 765             return None
  766         return [msg for msg in trace if isinstance(msg, dict)]
  767 
  768     # ── Auto mode enable / disable (old chat/auto/ registry + api flows). ──────

Lines 870-878

  870         # here is the same conversation identity, so spawn lineage/ownership
  871         # is unchanged.
  872         sibling_results: dict[str, str] = {}
  873         if pending_tool_calls:
! 874             sibling_results = await execute_tool_batch(
  875                 [
  876                     ToolCallInfo(
  877                         toolCallId=tc.tool_call_id,
  878                         toolName=tc.tool_name,

Lines 927-935

  927         record = conv.record
  928         if record.state.is_terminal:
  929             # Old parity: disable() on a terminal record reported success
  930             # without re-publishing (nothing left to clear).
! 931             return True
  932         if not record.auto_flag:
  933             return True
  934 
  935         # Pre-mark BEFORE any cancel (CR Moderate 2 — see docstring).

Lines 942-951

  942             try:
  943                 await task
  944             except asyncio.CancelledError:
  945                 pass
! 946             except Exception:
! 947                 logger.debug(
  948                     "Conversation %s raised during disable await",
  949                     session_id,
  950                     exc_info=True,
  951                 )

Lines 1288-1296

  1288             # Terminal bookkeeping (old SubAgentRegistry._settle).
  1289             if not record.state.is_terminal:
  1290                 # Defensive: a one-shot run that exits without a terminal
  1291                 # state failed in a way we didn't classify.
! 1292                 record.state = RunState.FAILED
  1293             record.final_report = self._final_report_for(record)
  1294             conv.inbox.clear()
  1295             conv.settled.set()
  1296             self._touch(conv)

Lines 1399-1407

  1399         """
  1400         record = conv.record
  1401         drained = conv.drain_inbox()
  1402         if not drained:
! 1403             return
  1404         messages: list[dict[str, Any]] = [m.as_chat_message() for m in drained]
  1405         # Next-turn report injection (parent kinds only — a child never owns a
  1406         # report queue), matching send_message's idle branch; reports were not
  1407         # echoed at enqueue, so echo them here (once).

Lines 1406-1415

  1406         # report queue), matching send_message's idle branch; reports were not
  1407         # echoed at enqueue, so echo them here (once).
  1408         if record.kind != "subagent":
  1409             for report in self.drain_reports(record.session_id):
! 1410                 messages.append({"role": "user", "content": report})
! 1411                 conv.bus.emit(format_user_message(report))
  1412         body: dict[str, Any] = {
  1413             "messages": messages,
  1414             **continuation_key_fields(record),
  1415         }

Lines 1413-1421

  1413             "messages": messages,
  1414             **continuation_key_fields(record),
  1415         }
  1416         if record.auto_flag:
! 1417             body["auto_mode"] = True
  1418         self.start_run(record.session_id, body)
  1419         logger.info(
  1420             "Restarted conversation %s from stranded inbox (%d message(s))",
  1421             record.session_id,

Lines 1489-1502

  1489             # children (their reports have nothing left to consume them).
  1490             # If the record had been flipped to the auto policy while this
  1491             # interactive turn was still in flight (a manual-enable race),
  1492             # swap it back — the model just disabled auto mode.
! 1493             record.idle_reason = "user_disabled"
! 1494             self._touch(conv)
! 1495             self._publish_state(conv)
! 1496             if conv.policy.approvals == "auto" and not conv.policy.one_shot:
! 1497                 self._swap_to_interactive(conv)
! 1498             await self.stop_children(record.session_id)
  1499 
  1500         return EngineIO(
  1501             emit=conv.bus.emit,
  1502             on_trace=on_trace,

Lines 1697-1706

  1697             try:
  1698                 await task
  1699             except asyncio.CancelledError:
  1700                 pass
! 1701             except Exception:
! 1702                 logger.debug(
  1703                     "Conversation %s raised during stop await",
  1704                     session_id,
  1705                     exc_info=True,
  1706                 )

Lines 1883-1891

  1883         reports: list[str] = []
  1884         for child_sid in queued:
  1885             conv = self._conversations.get(child_sid)
  1886             if conv is None or conv.record.report_delivered:
! 1887                 continue
  1888             reports.append(format_subagent_report(conv.record))
  1889             self._mark_report_delivered(conv)
  1890         return reports

Lines 1894-1902

  1894 
  1895     def _mark_report_delivered(self, conv: _Conversation) -> None:
  1896         record = conv.record
  1897         if record.report_delivered:
! 1898             return
  1899         record.report_delivered = True
  1900         if record.parent_session_id is not None:
  1901             queued = self._pending_reports.get(record.parent_session_id)
  1902             if queued and record.session_id in queued:

Lines 1931-1939

  1931 
  1932     def _schedule_gc(self, session_id: str) -> None:
  1933         existing = self._gc_tasks.get(session_id)
  1934         if existing is not None and not existing.done():
! 1935             return
  1936         self._gc_tasks[session_id] = asyncio.create_task(self._gc_after_ttl(session_id))
  1937 
  1938     def _cancel_gc(self, session_id: str) -> None:
  1939         gc_task = self._gc_tasks.pop(session_id, None)

Lines 1937-1945

  1937 
  1938     def _cancel_gc(self, session_id: str) -> None:
  1939         gc_task = self._gc_tasks.pop(session_id, None)
  1940         if gc_task is not None:
! 1941             gc_task.cancel()
  1942 
  1943     async def _gc_after_ttl(self, session_id: str) -> None:
  1944         try:
  1945             await asyncio.sleep(self._terminal_ttl_seconds)

Lines 1971-1979

  1971 
  1972     def _evict(self, session_id: str) -> None:
  1973         conv = self._conversations.pop(session_id, None)
  1974         if conv is None:
! 1975             return
  1976         # End any live observer streams: an evicted conversation's bus can
  1977         # never emit again, so a subscriber left attached (possible on the
  1978         # interactive LRU path in particular — CR n3) would otherwise park
  1979         # forever on a dead queue. EOF lets the client re-open from history,

app/desktop/studio_server/chat/stream_session.py

Lines 31-39

  31 from dataclasses import dataclass, field
  32 from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Literal
  33 
  34 if TYPE_CHECKING:
! 35     from app.desktop.studio_server.chat.orchestration import (
  36         OrchestrationContext,
  37     )
  38 
  39 import httpx

Lines 505-513

  505                     },
  506                     ensure_ascii=False,
  507                 )
  508             else:
! 509                 results[tc.tool_call_id] = await execute_orchestration_tool(
  510                     tc.tool_name, tc.input, orchestration_ctx
  511                 )
  512             continue
  513         tool_result = await execute_tool(tc.tool_name, tc.input)


@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a sub-agents feature to the Kiln Assistant, enabling the creation and management of autonomous background helper agents. It implements a local sub-agent registry, runner, and orchestration tools (such as spawning, waiting, and stopping) on the desktop server, alongside a tabbed UI in Svelte for monitoring transcripts, status tracking, and steering. The changes also include a version bump to 1.0.4 and extensive unit and integration tests across both backend and frontend. No review comments were provided, so there is no feedback to address.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This change introduces a unified desktop conversation runtime and API, replaces legacy chat and auto-mode wiring, adds sub-agent orchestration and lifecycle handling, and updates the web client with conversation stores, nested history, child transcripts, consent controls, and report rendering.

Changes

Unified conversation runtime

Layer / File(s) Summary
Runtime contracts and execution
app/desktop/studio_server/chat/runtime/*, app/desktop/studio_server/chat/orchestration.py
Conversation models, policies, event buses, orchestration tools, engine execution, supervision, approvals, retries, reports, and lifecycle state handling are introduced.
Conversation API and routing
app/desktop/studio_server/chat/runtime/api.py, app/desktop/studio_server/chat/routes.py, app/desktop/desktop_server.py
Conversation endpoints replace legacy auto/chat registration, while session routes resolve conversation keys, expose lineage, and cascade deletion.
Runtime validation
app/desktop/studio_server/chat/runtime/test_*, app/desktop/studio_server/chat/test_*
Tests cover runtime state transitions, SSE replay, orchestration, approvals, API behavior, integration flows, and golden request protocols.

Web conversation client

Layer / File(s) Summary
Conversation stores and persistence
app/web_ui/src/lib/chat/conversation_store.ts, app/web_ui/src/lib/chat/chat_session_store.ts, app/web_ui/src/lib/chat/session_messages.ts
Main and child conversation stores manage SSE observation, hydration, message echoes, approvals, auto mode, durable handles, and sub-agent report frames.
Sub-agent interface
app/web_ui/src/routes/(app)/assistant/*
The assistant view adds child tabs, read-only child transcripts, consent prompts, status indicators, report chips, and shared transcript rendering.
History and client tests
app/web_ui/src/lib/chat/*test.ts, app/web_ui/src/routes/(app)/assistant/*test.ts
Tests cover nested session grouping, conversation-store behavior, session persistence, report parsing, transcript rendering, and sub-agent interactions.

Supporting updates

Layer / File(s) Summary
Repository contracts and maintenance
app/*/version*, specs/projects/assistant_subagents/implementation_plan.md, libs/server/kiln_server/utils/agent_checks/annotations/*, .gitignore
Versions are bumped, the sub-agent implementation plan is added, conversation endpoints receive agent-check annotations, and Playwright artifacts are ignored.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • Kiln-AI/Kiln#1518: Rewires chat API registration from the legacy auto API to the conversations API.
  • Kiln-AI/Kiln#1209: Modifies the same session-store and chat UI state layers.
  • Kiln-AI/Kiln#1534: Overlaps with queued-message handling in the chat session store and assistant UI.

Suggested reviewers: scosman, sfierro, chiang-daniel

Poem

A rabbit watched conversations run,
With helper agents hopping one by one.
Reports became chips, tabs began to glow,
Old traces turned to handles in a row.
“Hop, hop!” cried Bun, “the runtime’s bright!”

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description summarizes the PR, but it does not follow the repo template or include Related Issues, CLA, or checklist sections. Reformat it to the template and add What does this PR do?, Related Issues, CLA confirmation, and completed checklist items.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding local sub-agent runtime and UI/reporting support.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch leonard/assistant-subagents

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/desktop/studio_server/chat/subagents/test_runner.py (1)

1-225: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix ruff formatting failure.

The pipeline reports ruff format --check failed for this file. Run ruff format app/desktop/studio_server/chat/subagents/test_runner.py to fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/subagents/test_runner.py` around lines 1 -
225, Run Ruff formatting on the test module containing _runner and the
SubAgentRunner tests, using `ruff format` on that file, and apply the resulting
formatting changes without altering test behavior.

Source: Pipeline failures

🧹 Nitpick comments (2)
app/web_ui/src/lib/chat/streaming_chat.ts (1)

824-837: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate "user-message" handling in streamChat and resumePendingToolCalls.

Both the main stream loop and the tool-continuation stream implement the identical "call onUserMessage, then processor.reset(), then skip default dispatch" sequence. Consider extracting a small shared helper (e.g. handleUserMessageEvent(event, onUserMessage, processor)) to avoid the two copies drifting apart as this logic evolves.

Also applies to: 1091-1099

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/web_ui/src/lib/chat/streaming_chat.ts` around lines 824 - 837, Extract
the duplicated user-message handling into a shared helper, such as
handleUserMessageEvent, accepting the event, onUserMessage callback, and
processor. Move the conditional callback invocation, processor.reset(), and
handled-status logic into that helper, then call it from both streamChat and
resumePendingToolCalls while preserving the existing continue/default-dispatch
behavior.
app/desktop/studio_server/chat/auto/registry.py (1)

549-560: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wrap _stop_subagent_children in a try/except to prevent sub-agent cleanup failures from blocking disable/stop.

By the time _stop_subagent_children is called, the auto run is already terminal (USER_DISABLED/USER_STOPPED, burst task cancelled). If stop_children raises, the API returns an error even though the primary operation succeeded, and sub-agent children may keep running. A retry would succeed (terminal check short-circuits), but the initial error is confusing.

♻️ Proposed fix
     `@staticmethod`
     async def _stop_subagent_children(run_id: str) -> None:
         """Cascade-stop sub-agents spawned by this auto run: their reports have
         nothing left to consume them once the parent's flag is off. Lazy import
         (the subagents package depends on this module)."""
-        from app.desktop.studio_server.chat.subagents.registry import (
-            subagent_registry,
-        )
-
-        await subagent_registry.stop_children(
-            subagent_registry.parent_key_for_auto_run(run_id)
-        )
+        try:
+            from app.desktop.studio_server.chat.subagents.registry import (
+                subagent_registry,
+            )
+            await subagent_registry.stop_children(
+                subagent_registry.parent_key_for_auto_run(run_id)
+            )
+        except Exception:
+            logger.warning(
+                "Failed to cascade-stop sub-agent children for run %s",
+                run_id,
+                exc_info=True,
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/auto/registry.py` around lines 549 - 560, Wrap
the subagent cleanup call in _stop_subagent_children with a try/except so
exceptions from subagent_registry.stop_children do not propagate and block an
already-completed disable/stop operation. Log the cleanup failure with the
run_id and exception details, while preserving the existing lazy import and
parent-key lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/desktop/studio_server/chat/routes.py`:
- Around line 399-406: Change pending-report handling so fetching reports
reserves them without removing or marking them delivered, then acknowledge them
only after generate() successfully persists the continuation; release or retain
the reservation on disconnect, cancellation, or upstream failure. Apply the same
reservation/acknowledgement contract in _append_pending_subagent_reports() and
update pending_reports_for_trace() usage accordingly.

In `@app/desktop/studio_server/chat/subagents/models.py`:
- Around line 96-99: Escape the dynamic agent_type attribute before inserting it
into the subagent_report opening tag. Update the report-building logic near
_escape_attr and the serialization function to apply
_escape_attr(record.agent_type), ensuring all dynamic attributes, including
subagent_id, status, and title as applicable, are safely encoded.

In `@app/web_ui/src/routes/`(app)/assistant/chat.svelte:
- Around line 417-425: Guard the selected-child submission flow in the composer
handler by adding a pending state around subagent_store.sendMessage, preventing
Enter or sending while a request is in flight. Disable the composer based on
this state, and clear the pending flag in a finally block so it resets on
success or failure; only clear input after the request succeeds and avoid
clearing text entered during the pending request.

---

Outside diff comments:
In `@app/desktop/studio_server/chat/subagents/test_runner.py`:
- Around line 1-225: Run Ruff formatting on the test module containing _runner
and the SubAgentRunner tests, using `ruff format` on that file, and apply the
resulting formatting changes without altering test behavior.

---

Nitpick comments:
In `@app/desktop/studio_server/chat/auto/registry.py`:
- Around line 549-560: Wrap the subagent cleanup call in _stop_subagent_children
with a try/except so exceptions from subagent_registry.stop_children do not
propagate and block an already-completed disable/stop operation. Log the cleanup
failure with the run_id and exception details, while preserving the existing
lazy import and parent-key lookup.

In `@app/web_ui/src/lib/chat/streaming_chat.ts`:
- Around line 824-837: Extract the duplicated user-message handling into a
shared helper, such as handleUserMessageEvent, accepting the event,
onUserMessage callback, and processor. Move the conditional callback invocation,
processor.reset(), and handled-status logic into that helper, then call it from
both streamChat and resumePendingToolCalls while preserving the existing
continue/default-dispatch behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b42ce6f3-320d-44b0-bf26-1f5822f869f3

📥 Commits

Reviewing files that changed from the base of the PR and between 51cbfb8 and ab73457.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • app/desktop/desktop_server.py
  • app/desktop/pyproject.toml
  • app/desktop/studio_server/_version.py
  • app/desktop/studio_server/chat/__init__.py
  • app/desktop/studio_server/chat/auto/registry.py
  • app/desktop/studio_server/chat/auto/runner.py
  • app/desktop/studio_server/chat/routes.py
  • app/desktop/studio_server/chat/stream_session.py
  • app/desktop/studio_server/chat/subagents/__init__.py
  • app/desktop/studio_server/chat/subagents/api.py
  • app/desktop/studio_server/chat/subagents/events.py
  • app/desktop/studio_server/chat/subagents/models.py
  • app/desktop/studio_server/chat/subagents/orchestration.py
  • app/desktop/studio_server/chat/subagents/registry.py
  • app/desktop/studio_server/chat/subagents/runner.py
  • app/desktop/studio_server/chat/subagents/sse.py
  • app/desktop/studio_server/chat/subagents/test_api.py
  • app/desktop/studio_server/chat/subagents/test_orchestration.py
  • app/desktop/studio_server/chat/subagents/test_registry.py
  • app/desktop/studio_server/chat/subagents/test_runner.py
  • app/desktop/studio_server/chat/test_routes.py
  • app/web_ui/src/lib/api_schema.d.ts
  • app/web_ui/src/lib/chat/chat_session_store.test.ts
  • app/web_ui/src/lib/chat/chat_session_store.ts
  • app/web_ui/src/lib/chat/session_grouping.test.ts
  • app/web_ui/src/lib/chat/session_grouping.ts
  • app/web_ui/src/lib/chat/session_messages.test.ts
  • app/web_ui/src/lib/chat/session_messages.ts
  • app/web_ui/src/lib/chat/streaming_chat.test.ts
  • app/web_ui/src/lib/chat/streaming_chat.ts
  • app/web_ui/src/lib/chat/subagent_store.test.ts
  • app/web_ui/src/lib/chat/subagent_store.ts
  • app/web_ui/src/lib/version
  • app/web_ui/src/routes/(app)/assistant/auto_mode_consent_dialog.svelte
  • app/web_ui/src/routes/(app)/assistant/chat.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_history.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_history_row.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_transcript.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_transcript.test.ts
  • app/web_ui/src/routes/(app)/assistant/subagent_consent_box.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_report_chip.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_status_dot.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_tabs.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.test.ts
  • specs/projects/assistant_subagents/implementation_plan.md

Comment on lines +399 to +406
reports = subagent_registry.pending_reports_for_trace(body.trace_id)
if reports:
messages = list(initial_body.get("messages", []))
messages.extend(
{"role": "user", "content": report} for report in reports
)
initial_body["messages"] = messages
report_echoes = [format_user_message(report) for report in reports]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Acknowledge reports only after upstream persistence.

pending_reports_for_trace() removes and marks reports delivered before generate() starts. A disconnect, cancellation, or upstream failure can therefore lose the only pending delivery.

Use reservation/ack semantics and acknowledge only after the continuation is successfully persisted. Apply the same contract to _append_pending_subagent_reports().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/routes.py` around lines 399 - 406, Change
pending-report handling so fetching reports reserves them without removing or
marking them delivered, then acknowledge them only after generate() successfully
persists the continuation; release or retain the reservation on disconnect,
cancellation, or upstream failure. Apply the same reservation/acknowledgement
contract in _append_pending_subagent_reports() and update
pending_reports_for_trace() usage accordingly.

Comment on lines +96 to +99
f'<subagent_report id="{record.subagent_id}" '
f'agent_type="{record.agent_type}" '
f'status="{record.status.value}" '
f'title="{_escape_attr(record.name)}">\n'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Escape every dynamic report attribute.

agent_type accepts arbitrary non-empty text but is inserted without escaping. Quotes or markup can corrupt the frame and prevent client hydration.

Proposed fix
-        f'<subagent_report id="{record.subagent_id}" '
-        f'agent_type="{record.agent_type}" '
-        f'status="{record.status.value}" '
+        f'<subagent_report id="{_escape_attr(record.subagent_id)}" '
+        f'agent_type="{_escape_attr(record.agent_type)}" '
+        f'status="{_escape_attr(record.status.value)}" '
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
f'<subagent_report id="{record.subagent_id}" '
f'agent_type="{record.agent_type}" '
f'status="{record.status.value}" '
f'title="{_escape_attr(record.name)}">\n'
f'<subagent_report id="{_escape_attr(record.subagent_id)}" '
f'agent_type="{_escape_attr(record.agent_type)}" '
f'status="{_escape_attr(record.status.value)}" '
f'title="{_escape_attr(record.name)}">\n'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/subagents/models.py` around lines 96 - 99,
Escape the dynamic agent_type attribute before inserting it into the
subagent_report opening tag. Update the report-building logic near _escape_attr
and the serialization function to apply _escape_attr(record.agent_type),
ensuring all dynamic attributes, including subagent_id, status, and title as
applicable, are safely encoded.

Comment on lines +417 to +425
if (selectedChild) {
if (!selectedChildRunning) return
const result = await subagent_store.sendMessage(
selectedChild.subagent_id,
text,
)
if (!result.ok) return
input = ""
setTimeout(() => adjustTextareaHeight(), 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard concurrent child submissions.

While sendMessage() is pending, Enter can send duplicates and newly typed input is cleared when the first request succeeds. Track a child-send pending state and disable the composer until it settles.

Proposed fix
+  let childSendPending = false
+
   $: inputDisabled =
-    versionRequired || (selectedChild !== null && !selectedChildRunning)
+    versionRequired ||
+    childSendPending ||
+    (selectedChild !== null && !selectedChildRunning)

     if (selectedChild) {
       if (!selectedChildRunning) return
-      const result = await subagent_store.sendMessage(
-        selectedChild.subagent_id,
-        text,
-      )
-      if (!result.ok) return
-      input = ""
-      setTimeout(() => adjustTextareaHeight(), 0)
+      childSendPending = true
+      try {
+        const result = await subagent_store.sendMessage(
+          selectedChild.subagent_id,
+          text,
+        )
+        if (!result.ok) return
+        input = ""
+        setTimeout(() => adjustTextareaHeight(), 0)
+      } finally {
+        childSendPending = false
+      }
       return
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (selectedChild) {
if (!selectedChildRunning) return
const result = await subagent_store.sendMessage(
selectedChild.subagent_id,
text,
)
if (!result.ok) return
input = ""
setTimeout(() => adjustTextareaHeight(), 0)
let childSendPending = false
$: inputDisabled =
versionRequired ||
childSendPending ||
(selectedChild !== null && !selectedChildRunning)
if (selectedChild) {
if (!selectedChildRunning) return
childSendPending = true
try {
const result = await subagent_store.sendMessage(
selectedChild.subagent_id,
text,
)
if (!result.ok) return
input = ""
setTimeout(() => adjustTextareaHeight(), 0)
} finally {
childSendPending = false
}
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/web_ui/src/routes/`(app)/assistant/chat.svelte around lines 417 - 425,
Guard the selected-child submission flow in the composer handler by adding a
pending state around subagent_store.sendMessage, preventing Enter or sending
while a request is in flight. Disable the composer based on this state, and
clear the pending flag in a finally block so it resets on success or failure;
only clear input after the request succeeds and avoid clearing text entered
during the pending request.

…lake background-sync test

- The unified runtime added ten /api/conversations endpoints without their
  agent_checks annotation files; the schema-bindings job requires them.
- test_idle_pause_and_resume raced a fixed 0.3s sleep against a real git
  fetch on loaded CI runners; poll for the head advance with a deadline
  instead (same for the fetch-and-fast-forward test).
… robust rehydrate URL join

- bus trace scan: catch ValueError (UnicodeDecodeError from non-UTF-8
  payloads subclasses it) instead of JSONDecodeError only.
- _evict: iterate the record's own seen chain instead of the full trace
  index; keep the ownership check so an adopted record's eviction never
  drops another record's entry.
- _fetch_persisted_trace: rstrip the upstream URL before joining so a
  bare base can't silently 404 as 'nothing persisted'.
Kiln Assistant: unified conversation runtime
… event-loop stalls

Seven fixes from live testing of the unified runtime + sub-agents:

- Kill an agent tree: /api/conversations/{sid}/stop gains ?cascade=true
  (stop children first with reports suppressed, then the conversation —
  session-deletion ordering). The main Stop button cascades; the
  interrupt-to-send-queued path deliberately does not. A selected running
  sub-agent gets its own composer stop button.
- Context gauge follows the selected tab: per-child context usage is
  seeded from the child's snapshot, updated live from its kiln_chat_trace
  events, and survives the observation ending.
- Tab switches scroll to the bottom: container-based scrolling replaces
  the main transcript's end anchor (a no-op while hidden), and the
  MutationObserver carries late-hydrating child content the rest of the way.
- Long unbroken strings (URLs) wrap in user bubbles, markdown, and error
  bubbles (break-words).
- The queued-message bubble renders only on the Main tab (sub-agent sends
  go straight to the child's server-side inbox).
- Event-loop stalls behind pending /api/conversations requests: run/task
  list endpoints and EvalRunner.collect_tasks move their file scans off
  the loop (to_thread); the heavy read-only eval endpoints become sync
  defs so FastAPI runs them in its threadpool; the UI children reconcile
  skips a tick while the previous fetch is pending instead of stacking.
- Regenerated OpenAPI schema for the cascade param.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
app/desktop/studio_server/chat/test_iter_upstream_round.py (1)

41-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated engine-turn test helper across files.

_run_turn (and its _decide no-op + EngineIO wiring) is duplicated near-verbatim in test_stream_session.py's _run_interactive_turn. Since test_fakes.py already centralizes the upstream fakes, consider moving this helper there so both suites (and any future interactive-golden suite) share one implementation.

♻️ Suggested consolidation
-async def _run_turn(client, initial_body: dict | None = None) -> list[bytes]:
-    """Drive one interactive turn on the engine, returning the emitted SSE
-    payloads — the exact bytes ChatStreamSession.stream() used to yield."""
-    emitted: list[bytes] = []
-    engine = ConversationEngine("https://example.test/v1/chat", {})
-    record = ConversationRecord(kind="interactive")
-
-    async def _decide(batch):  # pragma: no cover — these turns never park
-        return {}
-
-    io = EngineIO(emit=emitted.append, await_decisions=_decide)
-    with patch.object(httpx, "AsyncClient", return_value=client):
-        await engine.run(
-            record,
-            interactive_policy(),
-            io,
-            initial_body or {"messages": [{"role": "user", "content": "hi"}]},
-        )
-    return emitted
+from app.desktop.studio_server.chat.test_fakes import run_interactive_turn as _run_turn
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/test_iter_upstream_round.py` around lines 41 -
59, Move the duplicated _run_turn helper, including its _decide callback,
ConversationEngine setup, EngineIO wiring, and httpx.AsyncClient patching, into
test_fakes.py. Update both _run_turn in test_iter_upstream_round.py and
_run_interactive_turn in test_stream_session.py to import and use the shared
helper, preserving their existing inputs and emitted SSE behavior.
app/desktop/studio_server/chat/runtime/interceptors.py (1)

36-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Importing a private helper across module boundaries.

_format_consent_required_sse is underscore-prefixed in stream_session.py, signaling internal-only use, yet it's imported here as a dependency of the new canonical interceptor chain. Consider promoting it to a public name (or relocating it alongside the other canonical constants in this module) so its contract is explicit and doesn't silently break if stream_session.py is refactored further in later phases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/runtime/interceptors.py` around lines 36 - 39,
Promote _format_consent_required_sse to an explicit public helper, such as
format_consent_required_sse, in stream_session.py (or relocate it with the
canonical interceptor definitions), update its definition and all call sites
including the interceptor import, and remove cross-module reliance on the
underscore-prefixed symbol.
app/desktop/studio_server/chat/test_integration.py (1)

97-143: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Sleep-based synchronization in the test observer helper.

_observe_and_run relies on fixed sleeps (Line 118 pre-start, Line 132 post-settle) rather than an explicit "subscriber ready" / "drain complete" signal, which is inherently race-prone under slow CI or network jitter — bytes could arrive before the subscriber task is scheduled, or trail past the settle window.

Since this only affects opt-in @pytest.mark.paid integration tests against live network, the practical impact is limited.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/test_integration.py` around lines 97 - 143,
Replace the fixed sleeps in _observe_and_run with explicit synchronization:
signal when _drain has started and is ready before calling start(), and signal
completion after the run reaches a settled state and all queued payloads have
been consumed before cancelling the drain task. Preserve timeout handling and
cleanup while ensuring no stream bytes are lost.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/desktop/studio_server/chat/runtime/supervisor.py`:
- Around line 720-761: The rehydration URL in _fetch_persisted_trace
interpolates session_key without escaping path characters. Percent-encode
session_key as a single URL path segment before constructing the sessions URL,
preserving safe characters while preventing extra path segments, and continue
sending conv.headers only to the encoded URL.

In `@app/web_ui/src/lib/chat/chat_session_store.ts`:
- Around line 863-872: Guard the sessionId update in beginInteractiveTurn() with
the same generation/stale check used by resyncOnLoad(). After
conversationStore.ensure(sessionKey) resolves, verify the active conversation
has not changed due to reset() or loadSession(); if stale, do not call
persisted.update(), and return using the established stale-result behavior. Only
stamp ensured.sessionId when the generation and session key still match.

---

Nitpick comments:
In `@app/desktop/studio_server/chat/runtime/interceptors.py`:
- Around line 36-39: Promote _format_consent_required_sse to an explicit public
helper, such as format_consent_required_sse, in stream_session.py (or relocate
it with the canonical interceptor definitions), update its definition and all
call sites including the interceptor import, and remove cross-module reliance on
the underscore-prefixed symbol.

In `@app/desktop/studio_server/chat/test_integration.py`:
- Around line 97-143: Replace the fixed sleeps in _observe_and_run with explicit
synchronization: signal when _drain has started and is ready before calling
start(), and signal completion after the run reaches a settled state and all
queued payloads have been consumed before cancelling the drain task. Preserve
timeout handling and cleanup while ensuring no stream bytes are lost.

In `@app/desktop/studio_server/chat/test_iter_upstream_round.py`:
- Around line 41-59: Move the duplicated _run_turn helper, including its _decide
callback, ConversationEngine setup, EngineIO wiring, and httpx.AsyncClient
patching, into test_fakes.py. Update both _run_turn in
test_iter_upstream_round.py and _run_interactive_turn in test_stream_session.py
to import and use the shared helper, preserving their existing inputs and
emitted SSE behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: eb20ee92-f159-4fdd-ab9c-fe7b99df7d56

📥 Commits

Reviewing files that changed from the base of the PR and between ab73457 and 820b9ad.

📒 Files selected for processing (78)
  • .gitignore
  • app/desktop/desktop_server.py
  • app/desktop/git_sync/test_background_sync.py
  • app/desktop/studio_server/chat/__init__.py
  • app/desktop/studio_server/chat/auto/__init__.py
  • app/desktop/studio_server/chat/auto/api.py
  • app/desktop/studio_server/chat/auto/events.py
  • app/desktop/studio_server/chat/auto/models.py
  • app/desktop/studio_server/chat/auto/registry.py
  • app/desktop/studio_server/chat/auto/runner.py
  • app/desktop/studio_server/chat/auto/sse.py
  • app/desktop/studio_server/chat/auto/test_api.py
  • app/desktop/studio_server/chat/auto/test_registry.py
  • app/desktop/studio_server/chat/auto/test_runner.py
  • app/desktop/studio_server/chat/constants.py
  • app/desktop/studio_server/chat/helpers.py
  • app/desktop/studio_server/chat/orchestration.py
  • app/desktop/studio_server/chat/routes.py
  • app/desktop/studio_server/chat/runtime/__init__.py
  • app/desktop/studio_server/chat/runtime/api.py
  • app/desktop/studio_server/chat/runtime/bus.py
  • app/desktop/studio_server/chat/runtime/engine.py
  • app/desktop/studio_server/chat/runtime/golden/auto_disable_resolve.json
  • app/desktop/studio_server/chat/runtime/golden/auto_report_inbox_unwrapped.json
  • app/desktop/studio_server/chat/runtime/golden/auto_seed_and_tool_round.json
  • app/desktop/studio_server/chat/runtime/golden/interactive_approval_flow.json
  • app/desktop/studio_server/chat/runtime/golden/interactive_report_injection.json
  • app/desktop/studio_server/chat/runtime/golden/interactive_tool_round.json
  • app/desktop/studio_server/chat/runtime/golden/subagent_seed_and_steer.json
  • app/desktop/studio_server/chat/runtime/golden_scenarios.py
  • app/desktop/studio_server/chat/runtime/interceptors.py
  • app/desktop/studio_server/chat/runtime/models.py
  • app/desktop/studio_server/chat/runtime/sse.py
  • app/desktop/studio_server/chat/runtime/supervisor.py
  • app/desktop/studio_server/chat/runtime/test_api.py
  • app/desktop/studio_server/chat/runtime/test_bus.py
  • app/desktop/studio_server/chat/runtime/test_engine.py
  • app/desktop/studio_server/chat/runtime/test_golden_protocol.py
  • app/desktop/studio_server/chat/runtime/test_interceptors.py
  • app/desktop/studio_server/chat/runtime/test_supervisor.py
  • app/desktop/studio_server/chat/stream_session.py
  • app/desktop/studio_server/chat/test_fakes.py
  • app/desktop/studio_server/chat/test_integration.py
  • app/desktop/studio_server/chat/test_iter_upstream_round.py
  • app/desktop/studio_server/chat/test_orchestration.py
  • app/desktop/studio_server/chat/test_routes.py
  • app/desktop/studio_server/chat/test_stream_session.py
  • app/web_ui/src/lib/api_schema.d.ts
  • app/web_ui/src/lib/chat/auto_run_store.test.ts
  • app/web_ui/src/lib/chat/auto_run_store.ts
  • app/web_ui/src/lib/chat/chat_history_apply.ts
  • app/web_ui/src/lib/chat/chat_session_store.test.ts
  • app/web_ui/src/lib/chat/chat_session_store.ts
  • app/web_ui/src/lib/chat/conversation_store.test.ts
  • app/web_ui/src/lib/chat/conversation_store.ts
  • app/web_ui/src/lib/chat/session_messages.test.ts
  • app/web_ui/src/lib/chat/session_messages.ts
  • app/web_ui/src/lib/chat/streaming_chat.test.ts
  • app/web_ui/src/lib/chat/streaming_chat.ts
  • app/web_ui/src/routes/(app)/assistant/auto_mode_consent_dialog.test.ts
  • app/web_ui/src/routes/(app)/assistant/chat.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_compacting_indicator.test.ts
  • app/web_ui/src/routes/(app)/assistant/chat_history.svelte
  • app/web_ui/src/routes/(app)/assistant/chat_queued_message.test.ts
  • app/web_ui/src/routes/(app)/assistant/subagent_status_dot.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_tabs.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.test.ts
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_events.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_approvals.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_events.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_approvals_decisions.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_auto.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_messages.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_stop.json
💤 Files with no reviewable changes (11)
  • app/desktop/studio_server/chat/auto/test_api.py
  • app/desktop/studio_server/chat/auto/test_registry.py
  • app/web_ui/src/routes/(app)/assistant/auto_mode_consent_dialog.test.ts
  • app/desktop/studio_server/chat/auto/registry.py
  • app/desktop/studio_server/chat/auto/events.py
  • app/desktop/studio_server/chat/auto/init.py
  • app/desktop/studio_server/chat/auto/api.py
  • app/desktop/studio_server/chat/auto/models.py
  • app/desktop/studio_server/chat/auto/runner.py
  • app/desktop/studio_server/chat/auto/test_runner.py
  • app/desktop/studio_server/chat/auto/sse.py
✅ Files skipped from review due to trivial changes (14)
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_auto.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_events.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations.json
  • libs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_approvals.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_approvals_decisions.json
  • libs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_stop.json
  • app/desktop/studio_server/chat/runtime/golden/interactive_report_injection.json
  • .gitignore
  • app/desktop/studio_server/chat/runtime/golden/auto_report_inbox_unwrapped.json
  • app/desktop/studio_server/chat/runtime/golden/auto_seed_and_tool_round.json
  • app/desktop/studio_server/chat/runtime/init.py
  • app/desktop/studio_server/chat/test_fakes.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.test.ts
  • app/web_ui/src/routes/(app)/assistant/subagent_transcript.svelte
  • app/web_ui/src/routes/(app)/assistant/subagent_tabs.svelte

Comment on lines +720 to +761
async def _fetch_persisted_trace(
self, conv: _Conversation, session_key: str
) -> list[dict[str, Any]] | None:
"""Fetch the persisted snapshot's trace for a session key
(best-effort). ``session_key`` may be a leaf trace id OR a durable
root id — the upstream endpoint resolves either kind to the session's
current leaf (phase 6, architecture §8).

The conversation's ``upstream_url`` is the chat POST URL
(``…/v1/chat/``); the session snapshot lives beside it at
``…/v1/chat/sessions/{key}`` — same target the desktop's history
proxy reads, fetched directly here because rehydration is a
supervisor concern, not a browser round-trip.
"""
# rstrip guards the join: upstream_url is minted with a trailing slash
# (routes._chat_url), but a caller passing it bare would otherwise
# silently produce …/v1/chatsessions/… and read as "nothing persisted".
url = f"{conv.upstream_url.rstrip('/')}/sessions/{session_key}"
try:
async with httpx.AsyncClient(
timeout=REHYDRATE_FETCH_TIMEOUT_SECONDS
) as client:
response = await client.get(url, headers=conv.headers)
if response.status_code != 200:
return None
data = response.json()
except (httpx.HTTPError, json.JSONDecodeError, ValueError):
logger.debug("Approval rehydration fetch failed for %s", session_key)
return None
if isinstance(data, dict) and conv.record.root_id is None:
# Opportunistic backfill of the durable session handle (phase 5):
# the upstream snapshot response carries ``session_meta.root_id``
# at the top level, and a record adopted from a bare legacy leaf
# has no other way to learn it.
root_id = data.get("root_id")
if isinstance(root_id, str) and root_id:
conv.record.root_id = root_id
task_run = data.get("task_run") if isinstance(data, dict) else None
trace = task_run.get("trace") if isinstance(task_run, dict) else None
if not isinstance(trace, list):
return None
return [msg for msg in trace if isinstance(msg, dict)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '680,790p' app/desktop/studio_server/chat/runtime/supervisor.py
printf '\n---\n'
rg -n "resume_session_key|current_leaf_trace_id|session_key|root_id|upstream_url" app/desktop/studio_server/chat/runtime -S

Repository: Kiln-AI/Kiln

Length of output: 34861


🏁 Script executed:

sed -n '540,620p' app/desktop/studio_server/chat/runtime/supervisor.py
printf '\n---\n'
sed -n '120,190p' app/desktop/studio_server/chat/runtime/models.py
printf '\n---\n'
sed -n '1,220p' app/desktop/studio_server/chat/runtime/api.py
printf '\n---\n'
sed -n '1340,1415p' app/desktop/studio_server/chat/runtime/test_supervisor.py
printf '\n---\n'
sed -n '1535,1565p' app/desktop/studio_server/chat/runtime/test_supervisor.py

Repository: Kiln-AI/Kiln

Length of output: 24106


🏁 Script executed:

rg -n "def new_session_id|new_session_id\(|trace_id|session_id: str \| None|session_id=.*Path|session_id=.*Query|CreateConversationRequest|adopt_interactive\(" app/desktop/studio_server/chat/runtime -S
printf '\n---\n'
sed -n '1,120p' app/desktop/studio_server/chat/runtime/models.py
printf '\n---\n'
sed -n '360,460p' app/desktop/studio_server/chat/runtime/api.py
printf '\n---\n'
sed -n '1,120p' app/desktop/studio_server/chat/runtime/supervisor.py

Repository: Kiln-AI/Kiln

Length of output: 38208


🏁 Script executed:

sed -n '1,220p' app/desktop/studio_server/chat/routes.py
printf '\n---\n'
rg -n "def resolve_conversation_key|class .*Resolved|upstream_key|session_id" app/desktop/studio_server/chat/routes.py app/desktop/studio_server/chat -S
printf '\n---\n'
sed -n '220,360p' app/desktop/studio_server/chat/routes.py

Repository: Kiln-AI/Kiln

Length of output: 50368


Percent-encode the fallback session key before the rehydration GET.
session_id can reach the cold-key path verbatim, so interpolating it into .../sessions/{session_key} allows extra path segments on an authenticated upstream request. Quote the key (or build the path with a URL helper) before sending headers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/chat/runtime/supervisor.py` around lines 720 - 761,
The rehydration URL in _fetch_persisted_trace interpolates session_key without
escaping path characters. Percent-encode session_key as a single URL path
segment before constructing the sessions URL, preserving safe characters while
preventing extra path segments, and continue sending conv.headers only to the
encoded URL.

Comment on lines +863 to +872
const sessionKey = get(persisted).sessionId ?? get(persisted).rootId

const ensured = await conversationStore.ensure(sessionKey)
if (!ensured.ok || !ensured.sessionId) {
pushInlineError(
`Couldn't reach the assistant: ${ensured.error ?? "unknown error"}`,
)
return false
}
persisted.update((p) => ({ ...p, sessionId: ensured.sessionId ?? null }))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant area plus related lifecycle methods.
ast-grep outline app/web_ui/src/lib/chat/chat_session_store.ts --view expanded || true

echo '--- relevant ranges ---'
sed -n '780,940p' app/web_ui/src/lib/chat/chat_session_store.ts | cat -n

echo '--- search for generation/stale guards and session switching ---'
rg -n "generation|isStale|reset\\(|loadSession\\(|ensure\\(|sessionId|rootId|persisted.update|conversationStore" app/web_ui/src/lib/chat/chat_session_store.ts

echo '--- search for ConversationStore.ensure implementation ---'
rg -n "class .*ConversationStore|ensure\\(" app/web_ui/src/lib -g '*.ts' -g '*.tsx'

Repository: Kiln-AI/Kiln

Length of output: 15361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- chat_session_store: reset/loadSession/resyncOnLoad around generation guards ---'
sed -n '1000,1170p' app/web_ui/src/lib/chat/chat_session_store.ts | cat -n

echo '--- conversation_store: ensure implementation ---'
sed -n '1360,1465p' app/web_ui/src/lib/chat/conversation_store.ts | cat -n

echo '--- conversation_store: attach/detach/sessionId binding context ---'
sed -n '900,980p' app/web_ui/src/lib/chat/conversation_store.ts | cat -n

Repository: Kiln-AI/Kiln

Length of output: 16626


Guard the post-ensure() session stamp
beginInteractiveTurn() can still race with reset()/loadSession(): if conversationStore.ensure(sessionKey) resolves after the active conversation changes, the unconditional persisted.update(...sessionId: ensured.sessionId) writes the old handle back and the next send can target the wrong conversation. Add the same generation/stale check used in resyncOnLoad() before stamping sessionId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/web_ui/src/lib/chat/chat_session_store.ts` around lines 863 - 872, Guard
the sessionId update in beginInteractiveTurn() with the same generation/stale
check used by resyncOnLoad(). After conversationStore.ensure(sessionKey)
resolves, verify the active conversation has not changed due to reset() or
loadSession(); if stale, do not call persisted.update(), and return using the
established stale-result behavior. Only stamp ensured.sessionId when the
generation and session key still match.

…orensic debug logging

Queued-message fix (main agent): mid-turn sends on a live interactive run now
POST straight to the server inbox — the engine folds them into the next
upstream round — instead of waiting client-side for the whole turn to settle.
The client queue survives only for non-injectable moments (approval box open,
consent resolving, no session id, failed POST) and now flushes into the live
run the moment it becomes injectable. Server complement: an idle send drains
any stranded inbox (error-settle leftovers) into the fresh turn, in order.

Event-loop stalls (the /api/conversations pile-ups): three async endpoints
still ran full dataset scans on the loop — agent_overview/_dataset_stats,
get_tags, get_judge_feedback_batch_runs — now offloaded via asyncio.to_thread,
same treatment as a02cfeb.

Forensic debug logging: KILN_CHAT_DEBUG_LOG writes JSONL (ids, timings, round
progress — no content) from the runtime engine/supervisor; every upstream
request now carries X-Kiln-Conversation-Id so the backend's debug log joins on
the same conversation; the chat footer shows a copyable conversation id while
the flag is on (GET /api/chat/debug_status); dev server defaults the flag on.
…mortem

Root cause of "tabs never appear" (diagnosed live from the new debug logs on
conversation cv_dvs5hq23cn23): the server was healthy — the children endpoint
answered in 2.4ms mid-run — but the browser's children-list fetch had no
timeout, and the reconcile loop skips ticks while a fetch is pending, so one
request stuck behind the browser's per-origin connection cap blinded the tab
strip permanently (while report panels kept arriving on the already-open chat
stream).

conversation_store.ts: bound fetchChildren with AbortSignal.timeout(10s) and
release the in-flight flag on failure (a hang now costs one 3.5s interval, and
the abort frees its connection slot); keep current tabs on a same-parent blip
but still clear on a failed cross-parent switch; add a 15s connect watchdog
for a firehose EventSource wedged before headers; log failed reconciles
instead of swallowing them.

Forensics gaps found in the same post-mortem: sub-agent (one-shot) settles now
emit run_settled to the chat debug log, and the empty-model-response error
carries finish_reason (the failed child streamed 108s then produced an empty
assistant message — indistinguishable failure modes without it).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant