Assistant sub-agents: local runtime, tabs UI, steer/stop, report injection#1561
Assistant sub-agents: local runtime, tabs UI, steer/stop, report injection#1561leonardmq wants to merge 29 commits into
Conversation
- 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.
📊 Coverage ReportOverall Coverage: 92% Diff: origin/leonard/kil-686-eval-job...HEAD
Summary
Line-by-lineView line-by-line diff coverageapp/desktop/studio_server/agent_api.pyLines 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.pyLines 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 theLines 303-311 303 left to consume their reports) and stop the conversation itself — a
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 returnapp/desktop/studio_server/chat/routes.pyLines 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.pyLines 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.pyLines 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 streamLines 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 itemLines 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.pyLines 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 flagLines 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.pyLines 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.pyLines 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.pyLines 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 oldLines 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 pathLines 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 recordLines 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 NoneLines 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 leafLines 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 reportsLines 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.pyLines 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 httpxLines 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)
|
There was a problem hiding this comment.
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.
WalkthroughThis 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. ChangesUnified conversation runtime
Web conversation client
Supporting updates
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winFix ruff formatting failure.
The pipeline reports
ruff format --checkfailed for this file. Runruff format app/desktop/studio_server/chat/subagents/test_runner.pyto 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 valueDuplicate "user-message" handling in
streamChatandresumePendingToolCalls.Both the main stream loop and the tool-continuation stream implement the identical "call
onUserMessage, thenprocessor.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 winWrap
_stop_subagent_childrenin a try/except to prevent sub-agent cleanup failures from blocking disable/stop.By the time
_stop_subagent_childrenis called, the auto run is already terminal (USER_DISABLED/USER_STOPPED, burst task cancelled). Ifstop_childrenraises, 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
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (46)
app/desktop/desktop_server.pyapp/desktop/pyproject.tomlapp/desktop/studio_server/_version.pyapp/desktop/studio_server/chat/__init__.pyapp/desktop/studio_server/chat/auto/registry.pyapp/desktop/studio_server/chat/auto/runner.pyapp/desktop/studio_server/chat/routes.pyapp/desktop/studio_server/chat/stream_session.pyapp/desktop/studio_server/chat/subagents/__init__.pyapp/desktop/studio_server/chat/subagents/api.pyapp/desktop/studio_server/chat/subagents/events.pyapp/desktop/studio_server/chat/subagents/models.pyapp/desktop/studio_server/chat/subagents/orchestration.pyapp/desktop/studio_server/chat/subagents/registry.pyapp/desktop/studio_server/chat/subagents/runner.pyapp/desktop/studio_server/chat/subagents/sse.pyapp/desktop/studio_server/chat/subagents/test_api.pyapp/desktop/studio_server/chat/subagents/test_orchestration.pyapp/desktop/studio_server/chat/subagents/test_registry.pyapp/desktop/studio_server/chat/subagents/test_runner.pyapp/desktop/studio_server/chat/test_routes.pyapp/web_ui/src/lib/api_schema.d.tsapp/web_ui/src/lib/chat/chat_session_store.test.tsapp/web_ui/src/lib/chat/chat_session_store.tsapp/web_ui/src/lib/chat/session_grouping.test.tsapp/web_ui/src/lib/chat/session_grouping.tsapp/web_ui/src/lib/chat/session_messages.test.tsapp/web_ui/src/lib/chat/session_messages.tsapp/web_ui/src/lib/chat/streaming_chat.test.tsapp/web_ui/src/lib/chat/streaming_chat.tsapp/web_ui/src/lib/chat/subagent_store.test.tsapp/web_ui/src/lib/chat/subagent_store.tsapp/web_ui/src/lib/versionapp/web_ui/src/routes/(app)/assistant/auto_mode_consent_dialog.svelteapp/web_ui/src/routes/(app)/assistant/chat.svelteapp/web_ui/src/routes/(app)/assistant/chat_history.svelteapp/web_ui/src/routes/(app)/assistant/chat_history_row.svelteapp/web_ui/src/routes/(app)/assistant/chat_transcript.svelteapp/web_ui/src/routes/(app)/assistant/chat_transcript.test.tsapp/web_ui/src/routes/(app)/assistant/subagent_consent_box.svelteapp/web_ui/src/routes/(app)/assistant/subagent_report_chip.svelteapp/web_ui/src/routes/(app)/assistant/subagent_status_dot.svelteapp/web_ui/src/routes/(app)/assistant/subagent_tabs.svelteapp/web_ui/src/routes/(app)/assistant/subagent_transcript.svelteapp/web_ui/src/routes/(app)/assistant/subagent_transcript.test.tsspecs/projects/assistant_subagents/implementation_plan.md
| 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] |
There was a problem hiding this comment.
🗄️ 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.
| 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' |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| if (selectedChild) { | ||
| if (!selectedChildRunning) return | ||
| const result = await subagent_store.sendMessage( | ||
| selectedChild.subagent_id, | ||
| text, | ||
| ) | ||
| if (!result.ok) return | ||
| input = "" | ||
| setTimeout(() => adjustTextareaHeight(), 0) |
There was a problem hiding this comment.
🎯 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.
| 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.
There was a problem hiding this comment.
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 winDuplicated engine-turn test helper across files.
_run_turn(and its_decideno-op +EngineIOwiring) is duplicated near-verbatim intest_stream_session.py's_run_interactive_turn. Sincetest_fakes.pyalready 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 winImporting a private helper across module boundaries.
_format_consent_required_sseis underscore-prefixed instream_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 ifstream_session.pyis 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 valueSleep-based synchronization in the test observer helper.
_observe_and_runrelies 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.paidintegration 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
📒 Files selected for processing (78)
.gitignoreapp/desktop/desktop_server.pyapp/desktop/git_sync/test_background_sync.pyapp/desktop/studio_server/chat/__init__.pyapp/desktop/studio_server/chat/auto/__init__.pyapp/desktop/studio_server/chat/auto/api.pyapp/desktop/studio_server/chat/auto/events.pyapp/desktop/studio_server/chat/auto/models.pyapp/desktop/studio_server/chat/auto/registry.pyapp/desktop/studio_server/chat/auto/runner.pyapp/desktop/studio_server/chat/auto/sse.pyapp/desktop/studio_server/chat/auto/test_api.pyapp/desktop/studio_server/chat/auto/test_registry.pyapp/desktop/studio_server/chat/auto/test_runner.pyapp/desktop/studio_server/chat/constants.pyapp/desktop/studio_server/chat/helpers.pyapp/desktop/studio_server/chat/orchestration.pyapp/desktop/studio_server/chat/routes.pyapp/desktop/studio_server/chat/runtime/__init__.pyapp/desktop/studio_server/chat/runtime/api.pyapp/desktop/studio_server/chat/runtime/bus.pyapp/desktop/studio_server/chat/runtime/engine.pyapp/desktop/studio_server/chat/runtime/golden/auto_disable_resolve.jsonapp/desktop/studio_server/chat/runtime/golden/auto_report_inbox_unwrapped.jsonapp/desktop/studio_server/chat/runtime/golden/auto_seed_and_tool_round.jsonapp/desktop/studio_server/chat/runtime/golden/interactive_approval_flow.jsonapp/desktop/studio_server/chat/runtime/golden/interactive_report_injection.jsonapp/desktop/studio_server/chat/runtime/golden/interactive_tool_round.jsonapp/desktop/studio_server/chat/runtime/golden/subagent_seed_and_steer.jsonapp/desktop/studio_server/chat/runtime/golden_scenarios.pyapp/desktop/studio_server/chat/runtime/interceptors.pyapp/desktop/studio_server/chat/runtime/models.pyapp/desktop/studio_server/chat/runtime/sse.pyapp/desktop/studio_server/chat/runtime/supervisor.pyapp/desktop/studio_server/chat/runtime/test_api.pyapp/desktop/studio_server/chat/runtime/test_bus.pyapp/desktop/studio_server/chat/runtime/test_engine.pyapp/desktop/studio_server/chat/runtime/test_golden_protocol.pyapp/desktop/studio_server/chat/runtime/test_interceptors.pyapp/desktop/studio_server/chat/runtime/test_supervisor.pyapp/desktop/studio_server/chat/stream_session.pyapp/desktop/studio_server/chat/test_fakes.pyapp/desktop/studio_server/chat/test_integration.pyapp/desktop/studio_server/chat/test_iter_upstream_round.pyapp/desktop/studio_server/chat/test_orchestration.pyapp/desktop/studio_server/chat/test_routes.pyapp/desktop/studio_server/chat/test_stream_session.pyapp/web_ui/src/lib/api_schema.d.tsapp/web_ui/src/lib/chat/auto_run_store.test.tsapp/web_ui/src/lib/chat/auto_run_store.tsapp/web_ui/src/lib/chat/chat_history_apply.tsapp/web_ui/src/lib/chat/chat_session_store.test.tsapp/web_ui/src/lib/chat/chat_session_store.tsapp/web_ui/src/lib/chat/conversation_store.test.tsapp/web_ui/src/lib/chat/conversation_store.tsapp/web_ui/src/lib/chat/session_messages.test.tsapp/web_ui/src/lib/chat/session_messages.tsapp/web_ui/src/lib/chat/streaming_chat.test.tsapp/web_ui/src/lib/chat/streaming_chat.tsapp/web_ui/src/routes/(app)/assistant/auto_mode_consent_dialog.test.tsapp/web_ui/src/routes/(app)/assistant/chat.svelteapp/web_ui/src/routes/(app)/assistant/chat_compacting_indicator.test.tsapp/web_ui/src/routes/(app)/assistant/chat_history.svelteapp/web_ui/src/routes/(app)/assistant/chat_queued_message.test.tsapp/web_ui/src/routes/(app)/assistant/subagent_status_dot.svelteapp/web_ui/src/routes/(app)/assistant/subagent_tabs.svelteapp/web_ui/src/routes/(app)/assistant/subagent_transcript.svelteapp/web_ui/src/routes/(app)/assistant/subagent_transcript.test.tslibs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations.jsonlibs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_events.jsonlibs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id.jsonlibs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_approvals.jsonlibs/server/kiln_server/utils/agent_checks/annotations/get_api_conversations_session_id_events.jsonlibs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations.jsonlibs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_approvals_decisions.jsonlibs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_auto.jsonlibs/server/kiln_server/utils/agent_checks/annotations/post_api_conversations_session_id_messages.jsonlibs/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
| 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)] |
There was a problem hiding this comment.
🔒 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 -SRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
| 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 })) |
There was a problem hiding this comment.
🗄️ 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 -nRepository: 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).
Stacked PR:
leonard/assistant-subagents→leonard/kil-686-eval-job.Desktop + web UI for assistant sub-agents:
/api/conversationssub-agent API surface.<subagent_report>messages); kickoff message echoed onto the child stream at run start.Follow-up work on this stack lands in PR #1558 (into this branch).
Latest: mid-turn message injection, event-loop stalls, forensic debug logging
/api/conversations?parent=hang/pile-ups):agent_overview,get_tags, andget_judge_feedback_batch_runsstill ran full dataset disk scans on the loop; now offloaded viaasyncio.to_thread(same treatment as a02cfeb).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 carriesX-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.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 firehoseEventSourcewedged before headers; failed reconciles are logged instead of swallowed. Forensics: sub-agent settles emitrun_settledto the debug log, and the empty-model-response error carriesfinish_reason.