subagent handling + 1m context resume
This commit is contained in:
156
CLAUDE.md
156
CLAUDE.md
@@ -36,7 +36,12 @@ src/proxy.rs axum fallback handler: buffers request body (for session metadata
|
||||
response back unbuffered, tees SSE
|
||||
src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8
|
||||
src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
|
||||
translates SSE events → session Entries (Drop closes it out)
|
||||
translates SSE events → session Entries (Drop closes it out).
|
||||
Each Tap belongs to a *lane* (`Lane`/`LaneId`): lane 0 is the
|
||||
main chain, every subagent gets its own. Entries stay in one
|
||||
append-only Vec tagged with `Entry::lane`; per-agent state
|
||||
(model, tokens, tool count, system/tools signatures, label,
|
||||
parent, finished) lives on `Lane`
|
||||
src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
(FeedCache: per-entry rendered lines + wrapped heights, only
|
||||
changed entries re-render; the viewport window of lines is
|
||||
@@ -58,6 +63,16 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
minimap: `*` markers show where each user message sits in the
|
||||
whole conversation, with the scroll thumb drawn on top where they
|
||||
coincide.
|
||||
Subagents never touch this feed: it renders lane
|
||||
`MAIN_LANE` only, at full width, whatever the agents are doing.
|
||||
They live in the `A` popup (`popup_rect` = 80% of the *feed*
|
||||
rect, centred): `draw_agent_list` is the picker,
|
||||
`draw_feed` the chosen agent's own stream — same function as the
|
||||
main feed, own FeedCache from the `FeedCaches` pool, own
|
||||
scroll/follow from `App::lane_cols`, so it follows its own tail
|
||||
and the border title carries the identity (`⟳ Explore · find the
|
||||
retry helper · sonnet · out 2.1k · 2/3`). See the
|
||||
subagent-popup invariant.
|
||||
Sessions panel is a uniform 50% of the main area: each session
|
||||
is a multi-line item — full white title (live = first user
|
||||
prompt via `live_title`, stub = disk label, word-wrapped by
|
||||
@@ -67,7 +82,8 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
|
||||
width-fitted wrapped columns) and strips heading `#` markers —
|
||||
the pinned tui-markdown 0.3.5 does neither
|
||||
src/sessions.rs on-disk session history: background scanner thread keeps
|
||||
src/sessions.rs on-disk session history (main chain *and* subagents):
|
||||
background scanner thread keeps
|
||||
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
|
||||
mtime change — one pass yields the label *and* the session's
|
||||
last main-chain model, which `App::resume_model` turns into the
|
||||
@@ -75,14 +91,21 @@ src/sessions.rs on-disk session history: background scanner thread keeps
|
||||
from a JSONL transcript (lazily, on first view); build_tree
|
||||
parses uuid/parentUuid chains into a TurnTree (one node per
|
||||
real user prompt; rewinds leave fork points); materialize
|
||||
writes a new session file from a chosen set of turns
|
||||
writes a new session file from a chosen set of turns.
|
||||
`scan_agents` reads the `<session>/subagents/agent-*.meta.json`
|
||||
sidecars (cheap: the transcripts themselves can be MBs) and
|
||||
`splice_agents` inserts each agent's entries into its own lane
|
||||
right after the `Agent` tool call that spawned it
|
||||
src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||||
portable-pty routed through the proxy; wezterm-term models the
|
||||
screen (and answers terminal queries); renderer paints cells
|
||||
into the ratatui buffer. Each spawn injects a fresh per-pane
|
||||
token via `ANTHROPIC_CUSTOM_HEADERS` (`PANE_TOKEN_HEADER` =
|
||||
`x-claude-cloak-pane`), the correlation handle the proxy uses to
|
||||
recognise the pane's own traffic (see the embed-identity invariant)
|
||||
recognise the pane's own traffic (see the embed-identity invariant).
|
||||
`cc_default_model` reads Claude Code's *own* configured default
|
||||
model out of its settings — the only persisted record of a
|
||||
`[1m]` pick (see the 1M-context invariant)
|
||||
```
|
||||
|
||||
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
|
||||
@@ -106,6 +129,80 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
builds `user_…_session_<uuid>`; `proxy::session_key` handles both.
|
||||
Concurrent requests (subagents) share a session but each `Tap` tracks its own
|
||||
current entry index — entries/sessions are append-only, so indices stay stable.
|
||||
`session_key` tolerates whitespace around the JSON colon (a pretty-printed
|
||||
blob used to fall through to the legacy `session_` split and yield `id": "…`).
|
||||
- **Subagent identity comes from Claude Code's own header, never a heuristic.**
|
||||
A subagent's request reports the *parent's* `session_id` and no agent id in
|
||||
`metadata`, but Claude Code stamps `x-claude-code-agent-id` (and, from spawn
|
||||
depth 2, `x-claude-code-parent-agent-id`) on every one of them. That id is
|
||||
unique per agent — **including byte-identical sibling prompts**, which do
|
||||
occur and which a prompt hash cannot separate — stable across the agent's
|
||||
inner-loop turns, and equal to the `agentId` of its on-disk
|
||||
`subagents/agent-<id>.jsonl`. `proxy.rs` reads both headers (`AGENT_ID_HEADER`
|
||||
/ `PARENT_AGENT_ID_HEADER`) and **forwards them untouched** — they are Claude
|
||||
Code's, not ours; only `x-claude-cloak-pane` is ours to consume.
|
||||
`Session::lane_for` maps the id to a lane (appended on first sight, so a
|
||||
`LaneId` stays valid forever).
|
||||
Labels come from a *separate*, later fact: `Session::label_lane_from_prompt`
|
||||
matches the subagent's opening prompt against an unclaimed `Agent` tool call's
|
||||
`prompt` (byte-identical on the wire) to learn subagent_type/description/
|
||||
parent, and `close_lane_from_result` scrapes `agentId: <hex>` out of the
|
||||
`Agent` tool_result to tie the lane to that call and mark it finished. A lane
|
||||
must never wait for either: a synchronous agent's result only lands when it
|
||||
has already finished, and the child's first request can beat the parent's next
|
||||
one, so lanes are born anonymous and adopted later.
|
||||
- **One entry vec, tagged with lanes.** Per-lane vecs would double every index
|
||||
site (`Tap::cur`), break `FeedCache`'s positional alignment with
|
||||
`Session::entries`, turn the viewport window into a k-way merge inside the
|
||||
mutex the tap shares, and lose global wire order. Lane membership is a field;
|
||||
showing one lane is a filter (`e.lane == args.lane`), which is also why there
|
||||
is no "show everything interleaved" mode.
|
||||
- **"The agent finished" is a `<task-notification>`, not the tool_result.**
|
||||
Claude Code launches *every* `Agent` call asynchronously: the tool_result
|
||||
comes back immediately and says so (`Async agent launched successfully… \
|
||||
agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
user turn as `<task-notification>` … `<task-id><agent id></task-id>`. So
|
||||
`close_lane_from_result` only ties the lane to its tool call (and finishes it
|
||||
in the non-async wording, kept for older builds), while
|
||||
`Session::finish_lanes_from_notifications` — called from
|
||||
`record_user_prompt` on the trailing user run, before its early returns — is
|
||||
what stamps `Lane::finished_at`. Background *bash* tasks share the
|
||||
notification shape with a short id that matches no lane. Reading `finished`
|
||||
off the tool_result alone is why a finished agent used to keep reading as
|
||||
running.
|
||||
- **Subagents live in a popup; they never share the feed.** The main feed
|
||||
always renders `MAIN_LANE` at full width, so how many agents run changes
|
||||
nothing about reading the main chain — no split, no rows, no reserved space,
|
||||
no interleaved entries (`draw_feed` filters `e.lane == args.lane`). `A`
|
||||
(`App::toggle_agent_popup` → `App::agent_popup`) opens the one place they are
|
||||
shown: `AgentPopup::List` picks an agent, `AgentPopup::Feed` gives one agent
|
||||
the whole popup (80% of the feed rect, `ui::popup_rect`). Opening takes the
|
||||
shortest path — a lone agent goes straight to its stream, several land on the
|
||||
picker with the first *running* one preselected — and `A` closes whatever is
|
||||
open. The popup is **modal**: while it is up it takes every key (and the
|
||||
wheel), which is why it needs no focus/column model at all. Esc unwinds one
|
||||
layer (feed → picker → closed), `[`/`]` step between agents from inside a
|
||||
feed. `App::agent_list_of` orders it: running first (`Lane::running`), then
|
||||
idle/finished, each group in spawn order — but **every** lane is listed,
|
||||
disk lanes included, because this popup is the only way to read a finished
|
||||
agent's output. State is session-local: `draw` clears `agent_popup` and
|
||||
`lane_cols` when the displayed session changes, and `validate_agent_popup`
|
||||
drops a popup whose lane the displayed session doesn't have (a rebuilt
|
||||
on-disk view), so the render path never sees a dangling `LaneId`.
|
||||
- **`Lane::running` is a sort key, never a gate**: streaming (`active > 0`), or
|
||||
no finish signal and quiet for less than `LANE_IDLE_MAX` (60s); a
|
||||
`finished_at` (the `<task-notification>`) or no traffic at all (a lane read
|
||||
from disk) means not running. The long idle net matters because a gap between
|
||||
an agent's turns (a slow local tool call) looks exactly like "done"; only the
|
||||
notification distinguishes them. Being wrong therefore costs an ordering and
|
||||
a `⟳`/`·` mark — never a hidden stream, which is what the old row-collapse
|
||||
timers could do.
|
||||
- **Only the main lane drives the pane and the session header.** `embed_grow`,
|
||||
the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, `n`/`N` and
|
||||
`Session::model`/context are gated on `MAIN_LANE`; `last_system_len` and
|
||||
`last_tools_sig` live per lane (a subagent's system prompt and restricted tool
|
||||
set differ, so session-wide state re-emitted both lines on every
|
||||
main↔subagent alternation), and the prompt dedup is scoped to the lane.
|
||||
- **Embed identity is learned from traffic, never assumed from `--session-id`.**
|
||||
Claude Code's interactive `--session-id` is *not* guaranteed to equal the id
|
||||
it reports in request metadata (and a `--resume` can mint a fresh one), so the
|
||||
@@ -153,11 +250,25 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
|
||||
id, so a retired snapshot can't pin the pane; an id with no alias inside is
|
||||
passed through verbatim (`--model` takes full names too). The transcript is
|
||||
authoritative, so a mid-session `/model` switch is honoured. `[1m]`
|
||||
(1M-context) picks are the one thing it cannot see — the transcript records
|
||||
the same base id either way — so `Session::spawn_model` remembers the exact
|
||||
argument the pane was spawned with in-process and wins **only** while it
|
||||
still names the model the transcript reports.
|
||||
authoritative, so a mid-session `/model` switch is honoured.
|
||||
- **The 1M context window is a header, and a resume must keep it.**
|
||||
`--model opus[1m]` differs from `opus` only by `anthropic-beta:
|
||||
…,context-1m-…` — same body `model`, same transcript record — so no amount of
|
||||
transcript reading can tell them apart. The proxy is the only place that
|
||||
sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only**
|
||||
(a side/title call runs haiku without the flag, a subagent runs its own
|
||||
model) and `app::record_long_context` stores it as `Session::long_context`.
|
||||
`App::resume_arg` then picks the window: the wire observation wins, else the
|
||||
`[1m]` in `Session::spawn_model` (our own spawn, while it still names the
|
||||
same model), and with **neither** — a session that predates this process —
|
||||
it falls back to `term::cc_default_model()`, Claude Code's configured default
|
||||
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the
|
||||
one place a `[1m]` pick is persisted (`/model` writes it there). When that
|
||||
default names the same base model the resume passes **no `--model` at all**
|
||||
and inherits it whole, window included; any explicit knowledge overrides it,
|
||||
including "this session ran the *short* window", which is why an observed
|
||||
non-1m session is resumed with an explicit `--model opus`. The suffix is only
|
||||
ever added for an alias that `model_choices` says has a `[1m]` variant.
|
||||
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
|
||||
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
|
||||
real user prompt, abandoned rewind branches indented `⑂` under their fork
|
||||
@@ -283,6 +394,14 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
quit). n/N jump the feed scroll to the next/previous user prompt
|
||||
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
|
||||
feed scrolls only via wheel / PgUp / PgDn / g / G / n / N.
|
||||
`A` toggles the subagent popup (the footer leads with `A agents (N)` when
|
||||
the displayed session has any — it is the only route to them). Inside it:
|
||||
j/k move the picker or scroll the agent feed one line, enter/→ opens the
|
||||
highlighted agent, `[`/`]` step to the previous/next agent, PgUp/PgDn/g/G
|
||||
scroll, Esc goes feed → picker → closed, `A`/`q` closes outright. Being modal
|
||||
it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved.
|
||||
Switching the displayed session clears `App::lane_cols` and closes the popup,
|
||||
so a lane id can't inherit another session's scroll offset.
|
||||
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
|
||||
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
|
||||
left-drag selects screen text, copied on release via OSC 52 (like Claude
|
||||
@@ -321,7 +440,8 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
fully offline end-to-end tests with zero API usage. That fake server is
|
||||
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
|
||||
`claude` child** can be made to render its client-side tool UIs on demand
|
||||
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | text`, switchable
|
||||
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | agent | text`,
|
||||
switchable
|
||||
mid-run) — this is how the pane's frame detector is developed against what
|
||||
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
|
||||
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
|
||||
@@ -331,6 +451,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
## Not yet handled (known MVP limits)
|
||||
|
||||
- Non-streaming requests pass through untapped (e.g. `count_tokens`).
|
||||
- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts),
|
||||
and lanes loaded from disk have no token counts (a transcript records no
|
||||
usage) — the picker shows tool counts too. Only one agent is readable at a
|
||||
time (a modal popup, by design: the alternative was the split feed this
|
||||
replaced). A lane is never closed, only
|
||||
marked `finished`: a background agent (`x-app: cli-bg`) can wake up again
|
||||
long after its launch result landed, and `SendMessage` can revive a finished
|
||||
one. An agent transcript over `MAX_AGENT_BYTES` (8 MB) is summarised instead
|
||||
of parsed, because the view is built while the app mutex is held.
|
||||
- Materialized branch files carry no subagent transcripts: the `Agent`
|
||||
tool_results in them still hold the reports the parent model saw, and copying
|
||||
`subagents/` would duplicate `agentId`s across two sessions and contradict
|
||||
the agent files' own `sessionId`. Deliberate — don't "fix" it by copying.
|
||||
- A subagent's *first* turn is what labels its lane, so if we attach mid-run
|
||||
(the parent's `Agent` call never passed through us) it stays listed as
|
||||
`agent <id-prefix>` until its result lands.
|
||||
- Sessions are never pruned (entry memory grows for the process lifetime);
|
||||
the same goes for viewed disk transcripts (`App::history`).
|
||||
- Request bodies are fully buffered (up to 512 MB) before forwarding — needed
|
||||
|
||||
@@ -7,7 +7,8 @@ lets us make Claude Code render its client-side tool UIs (AskUserQuestion,
|
||||
ExitPlanMode, TodoWrite/Task*) on demand, so the pane's frame detector in
|
||||
`src/term.rs` can be developed against what Ink actually draws.
|
||||
|
||||
Scenario is picked per turn from `CT_FAKE_SCENARIO` (ask | plan | todo | text).
|
||||
Scenario is picked per turn from `CT_FAKE_SCENARIO`
|
||||
(ask | plan | todo | taskupdate | agent | text).
|
||||
Each incoming request is logged to `dev/fake_upstream.log` (declared tool names
|
||||
+ the trailing user text) so we can see what CC sends.
|
||||
"""
|
||||
@@ -136,6 +137,19 @@ TASK_INPUTS = [
|
||||
]
|
||||
|
||||
|
||||
# Two parallel subagents with the *same* type and byte-identical prompts: the
|
||||
# worst case for correlation (only Claude Code's `x-claude-code-agent-id` tells
|
||||
# them apart), and what makes the subagent rows worth having.
|
||||
AGENT_INPUTS = [
|
||||
{"subagent_type": "Explore", "description": "find the retry helper",
|
||||
"prompt": "Locate the retry helper and report back.", "run_in_background": False},
|
||||
{"subagent_type": "Explore", "description": "find the retry helper",
|
||||
"prompt": "Locate the retry helper and report back.", "run_in_background": False},
|
||||
{"subagent_type": "oracle", "description": "review the lane design",
|
||||
"prompt": "Review the lane design and report back.", "run_in_background": True},
|
||||
]
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
@@ -161,7 +175,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
has_result = bool(blocks) and all(
|
||||
b.get("type") == "tool_result" for b in blocks if isinstance(b, dict)
|
||||
) and "toolu_fake" in json.dumps(blocks)
|
||||
log(f"[{time.strftime('%H:%M:%S')}] {self.path} tools={tools} tail={tail}")
|
||||
# `model` + `anthropic-beta` show how a `--model opus[1m]` pick reaches
|
||||
# the wire (the 1M context window is a beta header, not a model id).
|
||||
log(
|
||||
f"[{time.strftime('%H:%M:%S')}] {self.path} model={body.get('model')} "
|
||||
f"betas={self.headers.get('anthropic-beta', '')} tools={tools} tail={tail}"
|
||||
)
|
||||
|
||||
if "count_tokens" in self.path:
|
||||
out = json.dumps({"input_tokens": 100}).encode()
|
||||
@@ -190,6 +209,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
gen = stream_tools(
|
||||
[("TaskCreate", t) for t in TASK_INPUTS], "Setting up the task list."
|
||||
)
|
||||
elif scenario == "agent":
|
||||
# Spawn subagents. A real child then issues their requests itself,
|
||||
# each carrying `x-claude-code-agent-id`.
|
||||
gen = stream_tools(
|
||||
[("Agent", a) for a in AGENT_INPUTS], "Delegating this."
|
||||
)
|
||||
elif scenario == "taskupdate":
|
||||
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
|
||||
"Starting the first task.")
|
||||
|
||||
1582
src/app.rs
1582
src/app.rs
File diff suppressed because it is too large
Load Diff
73
src/proxy.rs
73
src/proxy.rs
@@ -1,15 +1,36 @@
|
||||
use crate::app::{attach_tool_results, lock_app, record_user_prompt, SharedApp, Tap};
|
||||
use crate::app::{
|
||||
AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context,
|
||||
record_user_prompt,
|
||||
};
|
||||
use crate::sse::SseParser;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::Method;
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::Value;
|
||||
|
||||
const UPSTREAM: &str = "https://api.anthropic.com";
|
||||
|
||||
/// Claude Code stamps a subagent's requests with the agent's own id (and its
|
||||
/// parent's, from spawn depth 2). These are *its* headers, so they are read and
|
||||
/// forwarded untouched — only `x-claude-cloak-pane` is ours to consume. This is
|
||||
/// the exact correlation that separates a subagent's stream from the main
|
||||
/// chain: unique per agent even for byte-identical sibling prompts, stable
|
||||
/// across the agent's inner turns, and equal to the `agentId` in its on-disk
|
||||
/// `subagents/agent-<id>.jsonl`.
|
||||
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
|
||||
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
|
||||
|
||||
/// Claude Code asks for the **1M-context window** with a beta flag, not a
|
||||
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
|
||||
/// while plain `opus` does not, and both report the same `model` in the body
|
||||
/// (and in the transcript). So this header is the only place the window is
|
||||
/// observable — `App::resume_model` needs it to resume a session the way it ran.
|
||||
pub const BETA_HEADER: &str = "anthropic-beta";
|
||||
pub const LONG_CONTEXT_BETA: &str = "context-1m";
|
||||
|
||||
/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a
|
||||
/// fake server (the relay itself is identical either way).
|
||||
fn upstream() -> String {
|
||||
@@ -20,8 +41,17 @@ fn upstream() -> String {
|
||||
/// sends a JSON-ish blob containing `"session_id":"<uuid>"`; older builds
|
||||
/// used `user_<hash>_account_<uuid>_session_<uuid>`.
|
||||
fn session_key(u: &str) -> Option<&str> {
|
||||
if let Some(rest) = u.split(r#"session_id":""#).nth(1) {
|
||||
return rest.split('"').next().filter(|s| !s.is_empty());
|
||||
// Tolerate whitespace around the colon: Claude Code sends compact JSON,
|
||||
// but a pretty-printed blob would otherwise fall through to the legacy
|
||||
// `session_` split and yield a garbage key (`id": "…`).
|
||||
if let Some(rest) = u.split(r#"session_id""#).nth(1) {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(rest) = rest.strip_prefix(':') {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(rest) = rest.strip_prefix('"') {
|
||||
return rest.split('"').next().filter(|s| !s.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
// `.filter`: a trailing "session_" would otherwise yield Some("") — an
|
||||
// empty key that every malformed user_id would then collide on.
|
||||
@@ -98,12 +128,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
.get(crate::term::PANE_TOKEN_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let header = |name: &str| {
|
||||
parts
|
||||
.headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let agent = AgentTag {
|
||||
id: header(AGENT_ID_HEADER),
|
||||
parent: header(PARENT_AGENT_ID_HEADER),
|
||||
};
|
||||
// Tool results ride along in the request body; surface them
|
||||
// on the tool entries from the previous turn.
|
||||
attach_tool_results(&ctx.app, &key, &v);
|
||||
tap = Some(Tap::new(ctx.app.clone(), key.clone(), model, pane_token));
|
||||
// After Tap::new: the session must exist for the entry to land.
|
||||
record_user_prompt(&ctx.app, &key, &v);
|
||||
let t = Tap::new(ctx.app.clone(), key.clone(), model, pane_token, &agent);
|
||||
// After Tap::new: the session (and the lane) must exist for the entry
|
||||
// to land.
|
||||
record_user_prompt(&ctx.app, &key, t.lane(), &v);
|
||||
// Context window of the *main chain*, from this request's betas. Only a
|
||||
// turn-starting main-chain request counts: a side/title call runs haiku
|
||||
// without the flag and a subagent runs its own model, so either would
|
||||
// report a window that is not the session's.
|
||||
if t.lane() == crate::app::MAIN_LANE
|
||||
&& v.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty())
|
||||
{
|
||||
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
|
||||
record_long_context(&ctx.app, &key, long);
|
||||
}
|
||||
tap = Some(t);
|
||||
}
|
||||
|
||||
let mut rb = ctx.client.request(parts.method.clone(), &url);
|
||||
@@ -196,5 +250,10 @@ mod tests {
|
||||
// Degenerate inputs must not produce an empty (colliding) key.
|
||||
assert_eq!(session_key("user_x_session_"), None);
|
||||
assert_eq!(session_key(r#"{"session_id":""}"#), None);
|
||||
// Pretty-printed metadata must not fall through to the legacy split.
|
||||
assert_eq!(
|
||||
session_key(r#"{"session_id": "29bd3436-aaaa-bbbb-cccc-111122223333"}"#),
|
||||
Some("29bd3436-aaaa-bbbb-cccc-111122223333")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
511
src/sessions.rs
511
src/sessions.rs
@@ -10,8 +10,8 @@
|
||||
//! spaces … all become `-`) — see `encode_cwd`.
|
||||
|
||||
use crate::app::{
|
||||
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
|
||||
ToolResult,
|
||||
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content,
|
||||
lock_app, strip_injected,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -22,6 +22,11 @@ use std::time::SystemTime;
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DiskSession {
|
||||
pub uuid: String,
|
||||
/// Subagent transcripts recorded alongside this session, shown as `⑂N` in
|
||||
/// the list. Counted in the same cached pass as the label (a subagent run
|
||||
/// always writes the parent's `Agent` records too, so the parent's mtime
|
||||
/// moves whenever this can change).
|
||||
pub agents: usize,
|
||||
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
|
||||
pub label: String,
|
||||
/// API model id of the session's last main-chain assistant message
|
||||
@@ -38,9 +43,9 @@ pub struct DiskSession {
|
||||
/// only taken when the list actually changed.
|
||||
pub fn spawn_scanner(app: SharedApp) {
|
||||
std::thread::spawn(move || {
|
||||
// uuid → (mtime when read, label, model): skip re-parsing unchanged
|
||||
// files (one pass yields both — see `read_meta`).
|
||||
let mut meta: HashMap<String, (SystemTime, String, String)> = HashMap::new();
|
||||
// uuid → (mtime when read, label, model, subagent count): skip
|
||||
// re-parsing unchanged files (one pass yields all — see `read_meta`).
|
||||
let mut meta: HashMap<String, (SystemTime, String, String, usize)> = HashMap::new();
|
||||
let mut last: Vec<DiskSession> = Vec::new();
|
||||
loop {
|
||||
let list = scan(&mut meta).unwrap_or_default();
|
||||
@@ -55,7 +60,7 @@ pub fn spawn_scanner(app: SharedApp) {
|
||||
|
||||
/// One scan of the project directory, newest first.
|
||||
fn scan(
|
||||
meta: &mut HashMap<String, (SystemTime, String, String)>,
|
||||
meta: &mut HashMap<String, (SystemTime, String, String, usize)>,
|
||||
) -> Result<Vec<DiskSession>, String> {
|
||||
let dir = project_dir()?;
|
||||
let rd = std::fs::read_dir(&dir)
|
||||
@@ -69,15 +74,25 @@ fn scan(
|
||||
}
|
||||
let uuid = path.file_stem()?.to_str()?.to_string();
|
||||
let modified = e.metadata().ok()?.modified().ok()?;
|
||||
let (label, model) = match meta.get(&uuid) {
|
||||
Some((m, l, md)) if *m == modified => (l.clone(), md.clone()),
|
||||
let (label, model, agents) = match meta.get(&uuid) {
|
||||
Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n),
|
||||
_ => {
|
||||
let read = read_meta(&path, &uuid);
|
||||
meta.insert(uuid.clone(), (modified, read.0.clone(), read.1.clone()));
|
||||
read
|
||||
let (label, model) = read_meta(&path, &uuid);
|
||||
let agents = scan_agents(&uuid).len();
|
||||
meta.insert(
|
||||
uuid.clone(),
|
||||
(modified, label.clone(), model.clone(), agents),
|
||||
);
|
||||
(label, model, agents)
|
||||
}
|
||||
};
|
||||
Some(DiskSession { uuid, label, model, modified })
|
||||
Some(DiskSession {
|
||||
uuid,
|
||||
label,
|
||||
model,
|
||||
agents,
|
||||
modified,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
@@ -320,6 +335,82 @@ fn materialize_in(
|
||||
Ok(new_uuid)
|
||||
}
|
||||
|
||||
/// One subagent transcript on disk. Claude Code ≥2.1.2x writes each subagent
|
||||
/// to `<project>/<session-uuid>/subagents/agent-<agentId>.jsonl` with a small
|
||||
/// `agent-<agentId>.meta.json` sidecar; the sidecar alone reconstructs the whole
|
||||
/// lane tree, so a multi-megabyte transcript is only read when its lane is
|
||||
/// actually rendered.
|
||||
pub struct DiskAgent {
|
||||
pub agent_id: String,
|
||||
pub agent_type: String,
|
||||
pub description: String,
|
||||
/// The parent's `Agent` tool_use id — where this lane is spliced in.
|
||||
pub tool_use_id: String,
|
||||
/// Set from spawn depth 2 (a subagent of a subagent).
|
||||
pub parent_agent_id: Option<String>,
|
||||
pub spawn_depth: u8,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// An agent transcript larger than this is summarised instead of parsed: the
|
||||
/// view is built synchronously while the app mutex is held, and a few MB of
|
||||
/// JSONL would stall the UI (and the proxy tap that shares the mutex).
|
||||
const MAX_AGENT_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// Subagent transcripts recorded for `uuid`, in spawn order. Reads only the
|
||||
/// `.meta.json` sidecars.
|
||||
pub fn scan_agents(uuid: &str) -> Vec<DiskAgent> {
|
||||
let Ok(dir) = project_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan_agents_in(&dir.join(uuid).join("subagents"))
|
||||
}
|
||||
|
||||
/// `scan_agents` against an explicit directory (the I/O seam tests use).
|
||||
pub(crate) fn scan_agents_in(subdir: &std::path::Path) -> Vec<DiskAgent> {
|
||||
let Ok(rd) = std::fs::read_dir(subdir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<DiskAgent> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let meta_path = e.path();
|
||||
let name = meta_path.file_name()?.to_str()?;
|
||||
let agent_id = name.strip_prefix("agent-")?.strip_suffix(".meta.json")?;
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).ok()?).ok()?;
|
||||
let field = |k: &str| {
|
||||
v.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
Some(DiskAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
agent_type: field("agentType"),
|
||||
description: field("description"),
|
||||
tool_use_id: field("toolUseId"),
|
||||
parent_agent_id: v
|
||||
.get("parentAgentId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
spawn_depth: v
|
||||
.get("spawnDepth")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
.try_into()
|
||||
.unwrap_or(1),
|
||||
path: subdir.join(format!("agent-{agent_id}.jsonl")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| {
|
||||
a.spawn_depth
|
||||
.cmp(&b.spawn_depth)
|
||||
.then_with(|| a.agent_id.cmp(&b.agent_id))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A rendered transcript: the feed `Session` plus which tree path it shows.
|
||||
pub struct HistoryView {
|
||||
pub session: Session,
|
||||
@@ -341,10 +432,13 @@ pub fn load_history(uuid: &str) -> Option<Session> {
|
||||
/// preamble + the root→leaf chain is rendered (dead branches excluded) and
|
||||
/// per-turn entry offsets are recorded; with None, the whole file.
|
||||
pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<HistoryView> {
|
||||
// Subagent transcripts live in their own files next to the session's, so
|
||||
// they are spliced in after the main chain is parsed (see `splice_agents`).
|
||||
let agents = scan_agents(uuid);
|
||||
match path {
|
||||
None => {
|
||||
let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl"));
|
||||
load_file_view(&fpath, uuid)
|
||||
load_file_view(&fpath, uuid, &agents)
|
||||
}
|
||||
Some((tree, leaf)) => {
|
||||
let mut p = EntryParser::new();
|
||||
@@ -358,20 +452,195 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<History
|
||||
p.line(l);
|
||||
}
|
||||
}
|
||||
p.into_view(uuid, Some(leaf), turn_entries)
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
|
||||
splice_agents(&mut view, anchors, &agents);
|
||||
Some(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-file transcript view from an explicit path (the I/O-location seam:
|
||||
/// tests parse temp files without touching `HOME`).
|
||||
pub(crate) fn load_file_view(path: &std::path::Path, uuid: &str) -> Option<HistoryView> {
|
||||
pub(crate) fn load_file_view(
|
||||
path: &std::path::Path,
|
||||
uuid: &str,
|
||||
agents: &[DiskAgent],
|
||||
) -> Option<HistoryView> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let mut p = EntryParser::new();
|
||||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||||
p.line(&line);
|
||||
}
|
||||
p.into_view(uuid, None, Vec::new())
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let mut view = p.into_view(uuid, None, Vec::new())?;
|
||||
splice_agents(&mut view, anchors, agents);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
/// Parse one subagent transcript into its own lane. Oversized files are
|
||||
/// summarised rather than parsed: the view is built while the app mutex is
|
||||
/// held (the proxy tap shares it), so a few MB of JSONL must not stall it.
|
||||
fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMap<String, usize>) {
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_AGENT_BYTES {
|
||||
let mb = size / (1024 * 1024);
|
||||
let note =
|
||||
Entry::meta(format!("(subagent transcript too large to show: {mb} MB)")).in_lane(lane);
|
||||
return (vec![note], HashMap::new());
|
||||
}
|
||||
let Ok(f) = std::fs::File::open(path) else {
|
||||
return (Vec::new(), HashMap::new());
|
||||
};
|
||||
let mut p = EntryParser::new_lane(lane);
|
||||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||||
p.line(&line);
|
||||
}
|
||||
(p.entries, p.agent_tools)
|
||||
}
|
||||
|
||||
/// Tool calls per lane, for the agent picker's summary line.
|
||||
fn count_tools(entries: &[Entry]) -> HashMap<LaneId, usize> {
|
||||
let mut out: HashMap<LaneId, usize> = HashMap::new();
|
||||
for e in entries {
|
||||
if matches!(e.kind, Kind::Tool { .. }) {
|
||||
*out.entry(e.lane).or_default() += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert `add` at `at`, keeping the anchor map pointing at the same entries.
|
||||
/// Anchors *at* the insertion point shift; the spawning tool call itself sits
|
||||
/// at `at - 1` and stays put.
|
||||
fn insert_entries(
|
||||
entries: &mut Vec<Entry>,
|
||||
anchors: &mut HashMap<String, usize>,
|
||||
at: usize,
|
||||
add: Vec<Entry>,
|
||||
) {
|
||||
let n = add.len();
|
||||
let at = at.min(entries.len());
|
||||
entries.splice(at..at, add);
|
||||
for v in anchors.values_mut() {
|
||||
if *v >= at {
|
||||
*v += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splice each subagent transcript into the view right after the `Agent` tool
|
||||
/// call that spawned it, as its own lane.
|
||||
///
|
||||
/// Deepest agents go first, so a depth-2 transcript is nested into its parent's
|
||||
/// entry list *before* that list is spliced into the main chain; within a level
|
||||
/// the highest anchor goes first. A lane whose spawn point is not in this view
|
||||
/// (a path view can exclude that turn) keeps no entries, so it never shows up
|
||||
/// in the agent popup.
|
||||
fn splice_agents(
|
||||
view: &mut HistoryView,
|
||||
mut anchors: HashMap<String, usize>,
|
||||
agents: &[DiskAgent],
|
||||
) {
|
||||
if agents.is_empty() {
|
||||
return;
|
||||
}
|
||||
struct Loaded {
|
||||
id: String,
|
||||
lane: LaneId,
|
||||
entries: Vec<Entry>,
|
||||
anchors: HashMap<String, usize>,
|
||||
tool_use_id: String,
|
||||
parent: Option<String>,
|
||||
depth: u8,
|
||||
spliced: bool,
|
||||
}
|
||||
let mut loaded: Vec<Loaded> = agents
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let lane = view.session.add_lane(
|
||||
a.agent_id.clone(),
|
||||
a.agent_type.clone(),
|
||||
a.description.clone(),
|
||||
Some(a.tool_use_id.clone()),
|
||||
None,
|
||||
a.spawn_depth,
|
||||
);
|
||||
let (entries, anchors) = parse_agent_file(&a.path, lane);
|
||||
Loaded {
|
||||
id: a.agent_id.clone(),
|
||||
lane,
|
||||
entries,
|
||||
anchors,
|
||||
tool_use_id: a.tool_use_id.clone(),
|
||||
parent: a.parent_agent_id.clone(),
|
||||
depth: a.spawn_depth,
|
||||
spliced: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Nest children into their parents, deepest first.
|
||||
let mut order: Vec<usize> = (0..loaded.len()).collect();
|
||||
order.sort_by_key(|&i| std::cmp::Reverse(loaded[i].depth));
|
||||
for ci in order {
|
||||
let Some(pid) = loaded[ci].parent.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(pi) = loaded.iter().position(|l| l.id == pid) else {
|
||||
continue;
|
||||
};
|
||||
if pi == ci || loaded[ci].entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let child = std::mem::take(&mut loaded[ci].entries);
|
||||
let tuid = loaded[ci].tool_use_id.clone();
|
||||
let parent_lane = loaded[pi].lane;
|
||||
{
|
||||
let p = &mut loaded[pi];
|
||||
match p.anchors.get(&tuid).copied() {
|
||||
Some(at) => insert_entries(&mut p.entries, &mut p.anchors, at + 1, child),
|
||||
// The parent's transcript doesn't contain the spawn point:
|
||||
// keep the entries rather than lose them.
|
||||
None => p.entries.extend(child),
|
||||
}
|
||||
}
|
||||
loaded[ci].spliced = true;
|
||||
let l = &mut view.session.lanes[loaded[ci].lane as usize];
|
||||
l.parent = Some(parent_lane);
|
||||
}
|
||||
|
||||
// Splice what's left into the main chain, highest anchor first.
|
||||
let mut top: Vec<usize> = (0..loaded.len()).filter(|&i| !loaded[i].spliced).collect();
|
||||
top.sort_by_key(|&i| {
|
||||
std::cmp::Reverse(anchors.get(&loaded[i].tool_use_id).copied().unwrap_or(0))
|
||||
});
|
||||
for i in top {
|
||||
let entries = std::mem::take(&mut loaded[i].entries);
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(at) = anchors.get(&loaded[i].tool_use_id).copied() else {
|
||||
continue; // spawn point outside this view
|
||||
};
|
||||
let n = entries.len();
|
||||
insert_entries(&mut view.session.entries, &mut anchors, at + 1, entries);
|
||||
// Turn offsets after the insertion point move with it.
|
||||
for (_, e) in view.turn_entries.iter_mut() {
|
||||
if *e > at {
|
||||
*e += n;
|
||||
}
|
||||
}
|
||||
let l = &mut view.session.lanes[loaded[i].lane as usize];
|
||||
l.anchor = Some(at);
|
||||
l.parent = Some(MAIN_LANE);
|
||||
}
|
||||
view.session.reindex_lanes();
|
||||
// Tool counts for the agent picker (a transcript carries no usage, so
|
||||
// token totals stay zero for on-disk lanes).
|
||||
for (lane, n) in count_tools(&view.session.entries) {
|
||||
view.session.lanes[lane as usize].tool_calls = n;
|
||||
}
|
||||
}
|
||||
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
|
||||
/// file and path views).
|
||||
@@ -380,6 +649,16 @@ struct EntryParser {
|
||||
model: String,
|
||||
/// tool_use id → entry index, to attach results from later user lines.
|
||||
tool_idx: HashMap<String, usize>,
|
||||
/// `Agent`/`Task` tool_use id → entry index. Kept separately from
|
||||
/// `tool_idx`, which is *drained* as results attach — the anchor is still
|
||||
/// needed afterwards to splice the subagent's transcript in.
|
||||
agent_tools: HashMap<String, usize>,
|
||||
/// Lane every parsed entry is tagged with (0 = the main chain).
|
||||
lane: LaneId,
|
||||
/// Keep `isSidechain` records instead of skipping them. A subagent file
|
||||
/// (`subagents/agent-<id>.jsonl`) consists *entirely* of such records, so
|
||||
/// the main-chain skip would yield an empty lane.
|
||||
keep_sidechain: bool,
|
||||
}
|
||||
|
||||
impl EntryParser {
|
||||
@@ -388,6 +667,19 @@ impl EntryParser {
|
||||
entries: Vec::new(),
|
||||
model: String::from("(resumed)"),
|
||||
tool_idx: HashMap::new(),
|
||||
agent_tools: HashMap::new(),
|
||||
lane: MAIN_LANE,
|
||||
keep_sidechain: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parser for one subagent transcript: entries land in `lane` and the
|
||||
/// sidechain records that make up the file are kept.
|
||||
fn new_lane(lane: LaneId) -> Self {
|
||||
Self {
|
||||
lane,
|
||||
keep_sidechain: true,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,33 +693,31 @@ impl EntryParser {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// One construction site for a `Session`, so a new field only needs a
|
||||
// default in `Session::new`.
|
||||
let mut session = Session::new(uuid.to_string(), self.model);
|
||||
session.entries = self.entries;
|
||||
Some(HistoryView {
|
||||
session: Session {
|
||||
key: uuid.to_string(),
|
||||
model: self.model,
|
||||
entries: self.entries,
|
||||
active: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
last_activity: std::time::Instant::now(),
|
||||
tool_ids: HashMap::new(),
|
||||
last_system_len: None,
|
||||
last_tools_sig: None,
|
||||
spawn_model: None,
|
||||
},
|
||||
session,
|
||||
leaf,
|
||||
turn_entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &str) {
|
||||
let lane = self.lane;
|
||||
let entries = &mut self.entries;
|
||||
let tool_idx = &mut self.tool_idx;
|
||||
let agent_tools = &mut self.agent_tools;
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
||||
return;
|
||||
};
|
||||
// Skip subagent transcripts and synthetic/meta user lines.
|
||||
if v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||||
// Skip synthetic/meta user lines, and — for the main chain — subagent
|
||||
// records. `new_lane` parsers keep the latter: an agent file is made of
|
||||
// nothing else. (Claude Code ≥2.1.2x writes subagents to their own
|
||||
// files, so the main-chain guard is also belt-and-braces for older
|
||||
// transcripts that inlined them.)
|
||||
if (!self.keep_sidechain && v.get("isSidechain").and_then(Value::as_bool) == Some(true))
|
||||
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return;
|
||||
@@ -443,57 +733,44 @@ impl EntryParser {
|
||||
};
|
||||
for b in blocks {
|
||||
let entry = match b.get("type").and_then(Value::as_str) {
|
||||
Some("thinking") => Some(Entry {
|
||||
kind: Kind::Thinking,
|
||||
content: text_of(b, "thinking"),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("redacted_thinking") => Some(Entry {
|
||||
kind: Kind::Thinking,
|
||||
content: "[redacted]".into(),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("text") => Some(Entry {
|
||||
kind: Kind::Text,
|
||||
content: text_of(b, "text"),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("thinking") => {
|
||||
Some(Entry::done(Kind::Thinking, text_of(b, "thinking")))
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
Some(Entry::done(Kind::Thinking, "[redacted]".into()))
|
||||
}
|
||||
Some("text") => Some(Entry::done(Kind::Text, text_of(b, "text"))),
|
||||
Some("tool_use" | "server_tool_use" | "mcp_tool_use") => {
|
||||
let tool_name = b.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if let Some(id) = b.get("id").and_then(Value::as_str) {
|
||||
tool_idx.insert(id.to_string(), entries.len());
|
||||
if crate::app::is_agent_tool(tool_name) {
|
||||
agent_tools.insert(id.to_string(), entries.len());
|
||||
}
|
||||
}
|
||||
let input = b.get("input").map_or(String::new(), |i| {
|
||||
serde_json::to_string_pretty(i).unwrap_or_default()
|
||||
});
|
||||
Some(Entry {
|
||||
kind: Kind::Tool {
|
||||
name: b
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool")
|
||||
.to_string(),
|
||||
},
|
||||
content: input,
|
||||
done: true,
|
||||
result: None,
|
||||
})
|
||||
let name = b
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool")
|
||||
.to_string();
|
||||
Some(Entry::done(Kind::Tool { name }, input))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(e) = entry {
|
||||
entries.push(e);
|
||||
entries.push(e.in_lane(lane));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("user") => match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => push_user_text(entries, s),
|
||||
Some(Value::String(s)) => push_user_text(entries, s, lane),
|
||||
Some(Value::Array(blocks)) => {
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(Value::as_str) {
|
||||
Some("text") => push_user_text(entries, &text_of(b, "text")),
|
||||
Some("text") => push_user_text(entries, &text_of(b, "text"), lane),
|
||||
Some("tool_result") => {
|
||||
let Some(idx) = b
|
||||
.get("tool_use_id")
|
||||
@@ -529,13 +806,13 @@ fn text_of(b: &Value, key: &str) -> String {
|
||||
|
||||
/// Translate a user text block into feed entries: each injected reminder as a
|
||||
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
|
||||
fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
|
||||
fn push_user_text(entries: &mut Vec<Entry>, text: &str, lane: LaneId) {
|
||||
let (reminders, prompt) = crate::app::extract_user_text(text);
|
||||
for r in reminders {
|
||||
entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None });
|
||||
entries.push(Entry::done(Kind::Reminder, r).in_lane(lane));
|
||||
}
|
||||
if !prompt.is_empty() {
|
||||
entries.push(Entry { kind: Kind::User, content: prompt, done: true, result: None });
|
||||
entries.push(Entry::done(Kind::User, prompt).in_lane(lane));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,6 +996,102 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent transcript is a separate file linked by `toolUseId`; the view
|
||||
/// must splice it in right after the `Agent` call, in its own lane, with a
|
||||
/// nested (depth-2) agent inside its parent's lane — and the main chain's
|
||||
/// turn offsets must survive the insertion.
|
||||
#[test]
|
||||
fn subagent_files_splice_into_their_agent_call() {
|
||||
let dir = std::env::temp_dir().join(format!("ct-agents-{}", std::process::id()));
|
||||
let subs = dir.join("subagents");
|
||||
std::fs::create_dir_all(&subs).unwrap();
|
||||
let main = dir.join("s1.jsonl");
|
||||
std::fs::write(
|
||||
&main,
|
||||
[
|
||||
r#"{"type":"user","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"go"}}"#,
|
||||
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"model":"claude-x","content":[{"type":"tool_use","id":"toolu_A","name":"Agent","input":{"subagent_type":"Explore","description":"sweep","prompt":"p"}}]}}"#,
|
||||
r#"{"type":"user","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"done"}]}}"#,
|
||||
r#"{"type":"assistant","uuid":"a2","parentUuid":"u2","message":{"model":"claude-x","content":[{"type":"text","text":"after"}]}}"#,
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
.unwrap();
|
||||
// Depth-1 agent: makes its own nested Agent call.
|
||||
std::fs::write(
|
||||
subs.join("agent-aaa1.meta.json"),
|
||||
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
subs.join("agent-aaa1.jsonl"),
|
||||
[
|
||||
r#"{"type":"user","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"role":"user","content":"p"}}"#,
|
||||
r#"{"type":"assistant","isSidechain":true,"uuid":"s2","parentUuid":"s1","message":{"model":"claude-y","content":[{"type":"text","text":"child text"}]}}"#,
|
||||
r#"{"type":"assistant","isSidechain":true,"uuid":"s3","parentUuid":"s2","message":{"model":"claude-y","content":[{"type":"tool_use","id":"toolu_B","name":"Agent","input":{"subagent_type":"oracle","description":"deep","prompt":"q"}}]}}"#,
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
.unwrap();
|
||||
// Depth-2 agent, spawned by the first one.
|
||||
std::fs::write(
|
||||
subs.join("agent-bbb2.meta.json"),
|
||||
r#"{"agentType":"oracle","description":"deep","toolUseId":"toolu_B","parentAgentId":"aaa1","spawnDepth":2}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
subs.join("agent-bbb2.jsonl"),
|
||||
r#"{"type":"assistant","isSidechain":true,"uuid":"n1","parentUuid":null,"message":{"model":"claude-z","content":[{"type":"text","text":"nested text"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let agents = scan_agents_in(&subs);
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents[0].agent_id, "aaa1");
|
||||
assert_eq!(agents[1].parent_agent_id.as_deref(), Some("aaa1"));
|
||||
|
||||
let view = load_file_view(&main, "s1", &agents).expect("view");
|
||||
let s = &view.session;
|
||||
// Lanes: main + the two agents, labelled from their sidecars.
|
||||
assert_eq!(s.lanes.len(), 3);
|
||||
assert_eq!(s.lanes[1].title(), "Explore · sweep");
|
||||
assert_eq!(
|
||||
s.lanes[2].parent,
|
||||
Some(1),
|
||||
"nested agent hangs off its parent lane"
|
||||
);
|
||||
assert!(
|
||||
s.lanes[1].finished(),
|
||||
"a transcript on disk is a finished run"
|
||||
);
|
||||
assert_eq!(s.agent_lanes(), vec![1, 2]);
|
||||
// Order: the child's entries sit between the Agent call and what
|
||||
// followed it, and the nested lane sits inside its parent's stretch.
|
||||
let order: Vec<(LaneId, &str)> = s
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| (e.lane, e.content.as_str()))
|
||||
.collect();
|
||||
let pos = |needle: &str| {
|
||||
order
|
||||
.iter()
|
||||
.position(|(_, c)| c.contains(needle))
|
||||
.unwrap_or_else(|| panic!("missing {needle} in {order:?}"))
|
||||
};
|
||||
// A tool entry's content is its pretty-printed input.
|
||||
assert!(pos("\"subagent_type\": \"Explore\"") < pos("child text"));
|
||||
assert!(pos("child text") < pos("nested text"));
|
||||
assert!(pos("nested text") < pos("after"));
|
||||
assert_eq!(order[pos("child text")].0, 1);
|
||||
assert_eq!(order[pos("nested text")].0, 2);
|
||||
assert_eq!(order[pos("after")].0, MAIN_LANE);
|
||||
// first_entry is recomputed after splicing, so a lane can anchor.
|
||||
let first_of = |l: LaneId| s.entries.iter().position(|e| e.lane == l);
|
||||
assert_eq!(s.lanes[1].first_entry, first_of(1));
|
||||
assert_eq!(s.lanes[2].first_entry, first_of(2));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_chain_and_stitch() {
|
||||
let tree = build_tree(branched());
|
||||
@@ -874,12 +1247,12 @@ mod tests {
|
||||
// Parse via the explicit-path seam: no HOME mutation (an unsafe
|
||||
// set_var would race the env reads of concurrently running tests).
|
||||
let uuid = "11111111-2222-3333-4444-555555555555";
|
||||
let s = load_file_view(&p, uuid).map(|h| h.session);
|
||||
let s = load_file_view(&p, uuid, &[]).map(|h| h.session);
|
||||
std::fs::remove_file(&p).ok();
|
||||
|
||||
let s = s.expect("history loaded");
|
||||
assert_eq!(s.key, uuid);
|
||||
assert_eq!(s.model, "claude-x");
|
||||
assert_eq!(s.main().model, "claude-x");
|
||||
// user prompt, thinking, tool, text — sidechain line skipped.
|
||||
assert_eq!(s.entries.len(), 4);
|
||||
assert!(matches!(s.entries[0].kind, Kind::User));
|
||||
|
||||
37
src/term.rs
37
src/term.rs
@@ -720,6 +720,43 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code's own default model, as a `--model` argument (`opus`,
|
||||
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
|
||||
/// choice is written down — `/model` saves the pick there, suffix and all,
|
||||
/// while a transcript records the same base model id either way.
|
||||
///
|
||||
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
|
||||
/// project-local, project, and user settings. `None` when nothing sets one
|
||||
/// (Claude Code then picks for itself). Read fresh on every call — a `/model`
|
||||
/// during the session must not be answered from a stale cache.
|
||||
pub fn cc_default_model() -> Option<String> {
|
||||
if let Ok(m) = std::env::var("ANTHROPIC_MODEL")
|
||||
&& !m.is_empty()
|
||||
{
|
||||
return Some(m);
|
||||
}
|
||||
let user = std::env::var_os("HOME")
|
||||
.map(|h| std::path::PathBuf::from(h).join(".claude/settings.json"));
|
||||
[
|
||||
Some(std::path::PathBuf::from(".claude/settings.local.json")),
|
||||
Some(std::path::PathBuf::from(".claude/settings.json")),
|
||||
user,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|p| settings_model(&p))
|
||||
}
|
||||
|
||||
/// `model` field of one settings file (absent/unreadable/invalid → None).
|
||||
fn settings_model(path: &std::path::Path) -> Option<String> {
|
||||
let body = std::fs::read_to_string(path).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||
v.get("model")?
|
||||
.as_str()
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Background scan that replaces `App::model_choices` with the live alias set
|
||||
/// read from the installed `claude` binary (see `discover_model_aliases`).
|
||||
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
|
||||
|
||||
Reference in New Issue
Block a user