Merge pull request #7 from bnema/feat/session-tree-escape
feat: add session tree and double escape navigation
22 files · +4633 -665
M README.md +13 -8
| @@ -277,8 +277,9 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 277 | 277 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 278 | 278 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 279 | 279 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 280 | - | | `/sessions` | Resume a previous session for this directory. | | |
| 281 | - | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | |
| 280 | + | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 281 | + | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | |
| 282 | + | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | |
| 282 | 283 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 283 | 284 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 284 | 285 | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). | | |
| @@ -301,7 +302,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 301 | 302 | ||
| 302 | 303 | ### `/sessions` | |
| 303 | 304 | ||
| 304 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed. | |
| 305 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. | |
| 305 | 306 | ||
| 306 | 307 | ### `/session` | |
| 307 | 308 | ||
| @@ -310,9 +311,13 @@ Four ops on the current session. `/session` alone opens a picker; each is also r | |||
| 310 | 311 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | |
| 311 | 312 | ||
| 312 | 313 | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually. | |
| 313 | - | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | |
| 314 | - | - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. | |
| 315 | - | - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable. | |
| 314 | + | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | |
| 315 | + | - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments. | |
| 316 | + | - **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots. | |
| 317 | + | ||
| 318 | + | Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant/tool row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree. | |
| 319 | + | ||
| 320 | + | `/session tree` and the double-Escape shortcut use the same checks: a complete current session family must be readable, and no turn, shell escape, compaction, startup/session load, dialog, picker, suggestion, or queued message may be active. The shortcut is two parsed, unmodified bare `Esc` events within 500 ms from an idle editor with no visible text or pending image. Dialogs, confirmations, panels, pickers, and suggestions keep their existing Escape behavior; busy Escape still cancels, modified Escape is not part of the gesture, and any intervening key or timeout resets it. The shortcut depends on the terminal input path producing those parsed events; `/session tree` is the explicit equivalent when it is unavailable. | |
| 316 | 321 | ||
| 317 | 322 | ### `/jump` | |
| 318 | 323 | ||
| @@ -420,7 +425,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||
| 420 | 425 | ||
| 421 | 426 | ## Sessions | |
| 422 | 427 | ||
| 423 | - | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 428 | + | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Session metadata stores the provider and model used by that session; resume honors that pair, rebuilding the active agent when it differs and refusing the switch without discarding the current session if rebuilding fails. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 424 | 429 | ||
| 425 | 430 | ## Providers | |
| 426 | 431 | ||
| @@ -865,7 +870,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 865 | 870 | | `enter` | Submit (queued if the agent is busy). | | |
| 866 | 871 | | `alt+enter` | Newline. | | |
| 867 | 872 | | `tab` | Complete the selected slash command. | | |
| 868 | - | | `esc` | Cancel the current turn (while busy); clear input (while idle). | | |
| 873 | + | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | |
| 869 | 874 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | |
| 870 | 875 | | `ctrl+d` | Exit on empty input. | | |
| 871 | 876 | | `ctrl+l` | Redraw the screen. | | |
M docs/zotfiles.md +1 -1
| @@ -487,7 +487,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o | |||
| 487 | 487 | $ZOT_HOME/sessions/agents/<name>/ | |
| 488 | 488 | ``` | |
| 489 | 489 | ||
| 490 | - | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root. | |
| 490 | + | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), while tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged. | |
| 491 | 491 | ||
| 492 | 492 | ## Commands | |
| 493 | 493 | ||
M packages/agent/cli.go +452 -57
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "os" | |
| 11 | 11 | "os/signal" | |
| 12 | 12 | "path/filepath" | |
| 13 | + | "slices" | |
| 13 | 14 | "strings" | |
| 14 | 15 | "sync" | |
| 15 | 16 | "syscall" | |
| @@ -184,6 +185,21 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||
| 184 | 185 | } | |
| 185 | 186 | var out []provider.Message | |
| 186 | 187 | start := len(msgs) - keepTail | |
| 188 | + | var carriedToolNames []string | |
| 189 | + | addToolNames := func(names []string) { | |
| 190 | + | for _, name := range names { | |
| 191 | + | if name == "" || slices.Contains(carriedToolNames, name) { | |
| 192 | + | continue | |
| 193 | + | } | |
| 194 | + | carriedToolNames = append(carriedToolNames, name) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | // Activation markers in the discarded prefix still affect the retained | |
| 198 | + | // provider request. Carry them onto its first message rather than dropping | |
| 199 | + | // deferred-tool availability when the resume tail is shortened. | |
| 200 | + | for _, msg := range msgs[:start] { | |
| 201 | + | addToolNames(msg.AddedToolNames) | |
| 202 | + | } | |
| 187 | 203 | // Preserve the synthetic compaction summary when present so an | |
| 188 | 204 | // already-compacted session stays compacted after resume. | |
| 189 | 205 | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 { | |
| @@ -195,9 +211,214 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||
| 195 | 211 | start++ | |
| 196 | 212 | } | |
| 197 | 213 | out = append(out, msgs[start:]...) | |
| 214 | + | if len(carriedToolNames) > 0 && len(out) > 0 { | |
| 215 | + | first := out[0] | |
| 216 | + | for _, name := range first.AddedToolNames { | |
| 217 | + | addToolNames([]string{name}) | |
| 218 | + | } | |
| 219 | + | first.AddedToolNames = append([]string(nil), carriedToolNames...) | |
| 220 | + | out[0] = first | |
| 221 | + | } | |
| 198 | 222 | return provider.RepairOrphanedToolResults(out) | |
| 199 | 223 | } | |
| 200 | 224 | ||
| 225 | + | // sessionResumeCandidate contains all state needed to resume a session. | |
| 226 | + | // It is deliberately assembled before the caller changes any live state: | |
| 227 | + | // opening the transcript, reading usage, and rebuilding a provider/model | |
| 228 | + | // must all succeed before the candidate can replace the current session. | |
| 229 | + | type sessionResumeCandidate struct { | |
| 230 | + | session *core.Session | |
| 231 | + | agent *core.Agent | |
| 232 | + | messages []provider.Message | |
| 233 | + | fullMessageCount int | |
| 234 | + | cumulative provider.Usage | |
| 235 | + | lastTurn provider.Usage | |
| 236 | + | provider string | |
| 237 | + | model string | |
| 238 | + | rebuilt bool | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // prepareSessionResume opens and validates a selected session without | |
| 242 | + | // touching the current agent. Missing provider/model metadata is treated as | |
| 243 | + | // legacy metadata: the current agent is reused, preserving compatibility | |
| 244 | + | // with sessions written before those fields were persisted. | |
| 245 | + | // | |
| 246 | + | // The returned candidate owns session until its caller commits it. On any | |
| 247 | + | // error this function closes the candidate session and leaves the current | |
| 248 | + | // agent untouched. | |
| 249 | + | func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | |
| 250 | + | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // prepareSessionResumeWithOptions is the provider/model-aware implementation. | |
| 254 | + | // An explicit CLI provider or model keeps that field authoritative; an empty | |
| 255 | + | // field falls back to the selected session's metadata when it is complete. | |
| 256 | + | func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) { | |
| 257 | + | if current == nil { | |
| 258 | + | return candidate, fmt.Errorf("no agent running; log in first") | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | sess, msgs, err := core.OpenSession(path) | |
| 262 | + | if err != nil { | |
| 263 | + | return candidate, err | |
| 264 | + | } | |
| 265 | + | keepSession := false | |
| 266 | + | defer func() { | |
| 267 | + | if !keepSession { | |
| 268 | + | _ = sess.Close() | |
| 269 | + | } | |
| 270 | + | }() | |
| 271 | + | ||
| 272 | + | cumulative, lastTurn, err := core.SessionUsageDetail(path) | |
| 273 | + | if err != nil { | |
| 274 | + | return candidate, fmt.Errorf("read session usage: %w", err) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | fullMessageCount := len(msgs) | |
| 278 | + | resumedMessages := trimMessagesForResume(msgs, 100) | |
| 279 | + | storedProvider := strings.TrimSpace(sess.Meta.Provider) | |
| 280 | + | storedModel := strings.TrimSpace(sess.Meta.Model) | |
| 281 | + | currentProvider = strings.TrimSpace(currentProvider) | |
| 282 | + | currentModel = strings.TrimSpace(currentModel) | |
| 283 | + | ||
| 284 | + | candidate = sessionResumeCandidate{ | |
| 285 | + | session: sess, | |
| 286 | + | agent: current, | |
| 287 | + | messages: resumedMessages, | |
| 288 | + | fullMessageCount: fullMessageCount, | |
| 289 | + | cumulative: cumulative, | |
| 290 | + | lastTurn: lastTurn, | |
| 291 | + | provider: currentProvider, | |
| 292 | + | model: currentModel, | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // Old sessions may have either field absent. Only a complete stored | |
| 296 | + | // selection is actionable; otherwise resume exactly as older zot did. | |
| 297 | + | if storedProvider == "" || storedModel == "" { | |
| 298 | + | keepSession = true | |
| 299 | + | return candidate, nil | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | resumeProvider := storedProvider | |
| 303 | + | if explicitProvider { | |
| 304 | + | resumeProvider = currentProvider | |
| 305 | + | } | |
| 306 | + | resumeModel := storedModel | |
| 307 | + | if explicitModel { | |
| 308 | + | resumeModel = currentModel | |
| 309 | + | } | |
| 310 | + | if resumeProvider == currentProvider && resumeModel == currentModel { | |
| 311 | + | keepSession = true | |
| 312 | + | return candidate, nil | |
| 313 | + | } | |
| 314 | + | if buildAgentFor == nil { | |
| 315 | + | return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", resumeProvider, resumeModel) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(resumeProvider, resumeModel) | |
| 319 | + | if err != nil { | |
| 320 | + | return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", resumeProvider, resumeModel, err) | |
| 321 | + | } | |
| 322 | + | if rebuilt == nil { | |
| 323 | + | return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", resumeProvider, resumeModel) | |
| 324 | + | } | |
| 325 | + | resolvedProvider = strings.TrimSpace(resolvedProvider) | |
| 326 | + | resolvedModel = strings.TrimSpace(resolvedModel) | |
| 327 | + | if resolvedProvider == "" { | |
| 328 | + | resolvedProvider = storedProvider | |
| 329 | + | } | |
| 330 | + | if resolvedModel == "" { | |
| 331 | + | resolvedModel = storedModel | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Hydrate and seed the replacement while it is still private. The live | |
| 335 | + | // agent is not modified until the caller commits the complete candidate. | |
| 336 | + | rebuilt.SetMessages(resumedMessages) | |
| 337 | + | rebuilt.SeedCost(cumulative) | |
| 338 | + | rebuilt.SeedLastTurnUsage(lastTurn) | |
| 339 | + | candidate.agent = rebuilt | |
| 340 | + | candidate.provider = resolvedProvider | |
| 341 | + | candidate.model = resolvedModel | |
| 342 | + | candidate.rebuilt = true | |
| 343 | + | keepSession = true | |
| 344 | + | return candidate, nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // buildNonInteractiveSessionAgent constructs a provider/model-specific agent | |
| 348 | + | // for startup resume while preserving the cwd sandbox and extension tools that | |
| 349 | + | // were attached to the initial resolution. | |
| 350 | + | func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) { | |
| 351 | + | next := args | |
| 352 | + | next.Provider = providerName | |
| 353 | + | next.Model = model | |
| 354 | + | resolved, err := Resolve(next, true) | |
| 355 | + | if err != nil { | |
| 356 | + | return nil, "", "", err | |
| 357 | + | } | |
| 358 | + | resolved.UseSandbox(base.Sandbox) | |
| 359 | + | if extMgr != nil { | |
| 360 | + | resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 361 | + | } | |
| 362 | + | ag := resolved.NewAgent() | |
| 363 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 364 | + | return ag, resolved.Provider, resolved.Model, nil | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // applySessionResume shares the startup gate, candidate hydration, and | |
| 368 | + | // commit order used by interactive and non-interactive session opening. The | |
| 369 | + | // old append handle is closed only after the candidate has been prepared and | |
| 370 | + | // seeded successfully. | |
| 371 | + | func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | |
| 372 | + | candidate := sessionResumeCandidate{ | |
| 373 | + | session: sess, | |
| 374 | + | agent: ag, | |
| 375 | + | fullMessageCount: 0, | |
| 376 | + | provider: currentProvider, | |
| 377 | + | model: currentModel, | |
| 378 | + | } | |
| 379 | + | if ag != nil { | |
| 380 | + | candidate.fullMessageCount = len(ag.Messages()) | |
| 381 | + | } | |
| 382 | + | if sess == nil || ag == nil { | |
| 383 | + | return candidate, nil | |
| 384 | + | } | |
| 385 | + | // A newly created meta-only session is already on the active pair. Avoid | |
| 386 | + | // reopening and closing it through the resume path: Close intentionally | |
| 387 | + | // removes fresh empty sessions, and a second append handle would otherwise | |
| 388 | + | // keep an unlinked file alive. | |
| 389 | + | if len(ag.Messages()) == 0 && | |
| 390 | + | strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) && | |
| 391 | + | strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) { | |
| 392 | + | return candidate, nil | |
| 393 | + | } | |
| 394 | + | candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor) | |
| 395 | + | if err != nil { | |
| 396 | + | _ = sess.Close() | |
| 397 | + | return sessionResumeCandidate{}, err | |
| 398 | + | } | |
| 399 | + | if !candidate.rebuilt { | |
| 400 | + | candidate.agent.SetMessages(candidate.messages) | |
| 401 | + | candidate.agent.SeedCost(candidate.cumulative) | |
| 402 | + | candidate.agent.SeedLastTurnUsage(candidate.lastTurn) | |
| 403 | + | } | |
| 404 | + | _ = sess.Close() | |
| 405 | + | return candidate, nil | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // applyInitialSessionResume applies the same provider/model-aware candidate | |
| 409 | + | // contract used by interactive session switching to non-interactive startup | |
| 410 | + | // modes. The returned session owns the replacement append handle. | |
| 411 | + | func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) { | |
| 412 | + | candidate, err := applySessionResume(sess, ag, base.Provider, base.Model, | |
| 413 | + | strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) { | |
| 414 | + | return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model) | |
| 415 | + | }) | |
| 416 | + | if err != nil { | |
| 417 | + | return nil, ag, "", "", err | |
| 418 | + | } | |
| 419 | + | return candidate.session, candidate.agent, candidate.provider, candidate.model, nil | |
| 420 | + | } | |
| 421 | + | ||
| 201 | 422 | // fanoutAgentEvent translates a core.AgentEvent into the wire-format | |
| 202 | 423 | // EventFromHost and pushes it through the extension manager. Only | |
| 203 | 424 | // the events that have a clear extension-facing meaning are | |
| @@ -426,8 +647,19 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 426 | 647 | ||
| 427 | 648 | ag := r.NewAgent() | |
| 428 | 649 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 429 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 430 | - | defer sess.Close() | |
| 650 | + | sess, err := openOrCreateSession(args, r, ag, version) | |
| 651 | + | if err != nil { | |
| 652 | + | return err | |
| 653 | + | } | |
| 654 | + | if sess != nil { | |
| 655 | + | var providerName, model string | |
| 656 | + | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |
| 657 | + | if err != nil { | |
| 658 | + | return err | |
| 659 | + | } | |
| 660 | + | r.Provider, r.Model = providerName, model | |
| 661 | + | defer sess.Close() | |
| 662 | + | } | |
| 431 | 663 | ||
| 432 | 664 | prompt := args.Prompt | |
| 433 | 665 | if prompt == "" { | |
| @@ -469,8 +701,19 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 469 | 701 | ||
| 470 | 702 | ag := r.NewAgent() | |
| 471 | 703 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 472 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 473 | - | defer sess.Close() | |
| 704 | + | sess, err := openOrCreateSession(args, r, ag, version) | |
| 705 | + | if err != nil { | |
| 706 | + | return err | |
| 707 | + | } | |
| 708 | + | if sess != nil { | |
| 709 | + | var providerName, model string | |
| 710 | + | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |
| 711 | + | if err != nil { | |
| 712 | + | return err | |
| 713 | + | } | |
| 714 | + | r.Provider, r.Model = providerName, model | |
| 715 | + | defer sess.Close() | |
| 716 | + | } | |
| 474 | 717 | ||
| 475 | 718 | prompt := args.Prompt | |
| 476 | 719 | if prompt == "" { | |
| @@ -551,8 +794,19 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 551 | 794 | ||
| 552 | 795 | ag := r.NewAgent() | |
| 553 | 796 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 554 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 555 | - | defer sess.Close() | |
| 797 | + | sess, err := openOrCreateSession(args, r, ag, version) | |
| 798 | + | if err != nil { | |
| 799 | + | return err | |
| 800 | + | } | |
| 801 | + | if sess != nil { | |
| 802 | + | var providerName, model string | |
| 803 | + | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |
| 804 | + | if err != nil { | |
| 805 | + | return err | |
| 806 | + | } | |
| 807 | + | r.Provider, r.Model = providerName, model | |
| 808 | + | defer sess.Close() | |
| 809 | + | } | |
| 556 | 810 | ||
| 557 | 811 | prompt := args.Prompt | |
| 558 | 812 | if prompt == "" { | |
| @@ -695,6 +949,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 695 | 949 | // rebuilt tool instances must share the same one so the lock sticks. | |
| 696 | 950 | sharedSandbox := r.Sandbox | |
| 697 | 951 | ||
| 952 | + | // persistMu guards the active session and selection across the agent | |
| 953 | + | // loop, TUI callbacks, and session swaps. Declare it before tool | |
| 954 | + | // construction because swarm defaults must read the live selection too. | |
| 955 | + | var persistMu sync.Mutex | |
| 956 | + | // Serialize session transitions with persistence callbacks so a candidate | |
| 957 | + | // is read only after the old agent is flushed and cannot be overwritten by | |
| 958 | + | // an in-flight append before commit. | |
| 959 | + | var sessionTransitionMu sync.RWMutex | |
| 960 | + | activeProvider := r.Provider | |
| 961 | + | activeModel := r.Model | |
| 962 | + | ||
| 698 | 963 | // Build the extension manager BEFORE the agent so we can fold | |
| 699 | 964 | // extension-defined tools into the registry. Attach the interactive | |
| 700 | 965 | // host after constructing it below so startup alerts can be buffered. | |
| @@ -792,10 +1057,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 792 | 1057 | return reg | |
| 793 | 1058 | } | |
| 794 | 1059 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 795 | - | Swarm: swarmMgr, | |
| 796 | - | Enabled: AutoSwarmEnabled, | |
| 797 | - | DefaultModel: func() string { return r.Model }, | |
| 798 | - | DefaultProvider: func() string { return r.Provider }, | |
| 1060 | + | Swarm: swarmMgr, | |
| 1061 | + | Enabled: AutoSwarmEnabled, | |
| 1062 | + | DefaultModel: func() string { | |
| 1063 | + | persistMu.Lock() | |
| 1064 | + | defer persistMu.Unlock() | |
| 1065 | + | return activeModel | |
| 1066 | + | }, | |
| 1067 | + | DefaultProvider: func() string { | |
| 1068 | + | persistMu.Lock() | |
| 1069 | + | defer persistMu.Unlock() | |
| 1070 | + | return activeProvider | |
| 1071 | + | }, | |
| 799 | 1072 | DefaultReasoning: func() string { return r.Reasoning }, | |
| 800 | 1073 | ResolveSubagent: resolveSubagent, | |
| 801 | 1074 | OnSpawned: onSpawnedSwarm, | |
| @@ -958,16 +1231,32 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 958 | 1231 | ||
| 959 | 1232 | var sess *core.Session | |
| 960 | 1233 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 961 | - | // persistMu guards sess + sessBaselineMsgs against concurrent access | |
| 962 | - | // from the agent loop's per-message persistence hook (runs on the | |
| 963 | - | // agent goroutine) and the TUI's session swap / flush callbacks | |
| 964 | - | // (run on the TUI goroutine). Without this, a /sessions swap that | |
| 965 | - | // races with a finishing turn could double-write or lose messages. | |
| 966 | - | var persistMu sync.Mutex | |
| 1234 | + | // persistMu also guards sess + sessBaselineMsgs, so a session swap | |
| 1235 | + | // commits the transcript writer and live selection under the same lock. | |
| 967 | 1236 | if !args.NoSess && ag != nil { | |
| 968 | - | sess, _ = openOrCreateSession(args, r, ag, version) | |
| 969 | - | if ag != nil { | |
| 970 | - | sessBaselineMsgs = len(ag.Messages()) | |
| 1237 | + | var sessErr error | |
| 1238 | + | sess, sessErr = openOrCreateSession(args, r, ag, version) | |
| 1239 | + | if sessErr != nil { | |
| 1240 | + | return sessErr | |
| 1241 | + | } | |
| 1242 | + | sessBaselineMsgs = len(ag.Messages()) | |
| 1243 | + | if sess != nil { | |
| 1244 | + | // Startup resume uses the same provider/model-aware preparation as | |
| 1245 | + | // the interactive session picker. This matters for --continue, | |
| 1246 | + | // --resume, and explicit --session paths whose latest meta row was | |
| 1247 | + | // written by another provider/model. | |
| 1248 | + | candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model, | |
| 1249 | + | strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor) | |
| 1250 | + | if resumeErr != nil { | |
| 1251 | + | return resumeErr | |
| 1252 | + | } | |
| 1253 | + | sess = candidate.session | |
| 1254 | + | ag = candidate.agent | |
| 1255 | + | sessBaselineMsgs = candidate.fullMessageCount | |
| 1256 | + | activeProvider = candidate.provider | |
| 1257 | + | activeModel = candidate.model | |
| 1258 | + | r.Provider = candidate.provider | |
| 1259 | + | r.Model = candidate.model | |
| 971 | 1260 | } | |
| 972 | 1261 | } | |
| 973 | 1262 | defer func() { | |
| @@ -985,6 +1274,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 985 | 1274 | // baseline counter advances in lock-step so the exit-time flush | |
| 986 | 1275 | // doesn't double-write rows already on disk. | |
| 987 | 1276 | persistMessage := func(m provider.Message) { | |
| 1277 | + | sessionTransitionMu.RLock() | |
| 1278 | + | defer sessionTransitionMu.RUnlock() | |
| 988 | 1279 | persistMu.Lock() | |
| 989 | 1280 | defer persistMu.Unlock() | |
| 990 | 1281 | if sess == nil { | |
| @@ -995,6 +1286,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 995 | 1286 | } | |
| 996 | 1287 | } | |
| 997 | 1288 | persistUsage := func(cum provider.Usage) { | |
| 1289 | + | sessionTransitionMu.RLock() | |
| 1290 | + | defer sessionTransitionMu.RUnlock() | |
| 998 | 1291 | persistMu.Lock() | |
| 999 | 1292 | defer persistMu.Unlock() | |
| 1000 | 1293 | if sess == nil { | |
| @@ -1003,6 +1296,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1003 | 1296 | _ = sess.AppendUsage(cum, cum) | |
| 1004 | 1297 | } | |
| 1005 | 1298 | persistCompaction := func(messages []provider.Message) { | |
| 1299 | + | sessionTransitionMu.RLock() | |
| 1300 | + | defer sessionTransitionMu.RUnlock() | |
| 1006 | 1301 | persistMu.Lock() | |
| 1007 | 1302 | defer persistMu.Unlock() | |
| 1008 | 1303 | if sess == nil { | |
| @@ -1026,7 +1321,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1026 | 1321 | // Re-wrap the build closures so any agent constructed by the TUI | |
| 1027 | 1322 | // (login, /model swap to a different provider) also gets the | |
| 1028 | 1323 | // persistence hooks. Without this, switching provider would | |
| 1029 | - | // silently revert to the old in-memory-only behaviour. | |
| 1324 | + | // silently revert to the old in-memory-only behaviour. Selection state | |
| 1325 | + | // is published by the successful transition itself, not by preparation. | |
| 1030 | 1326 | baseBuildAgent := buildAgent | |
| 1031 | 1327 | buildAgent = func() (*core.Agent, string, string, error) { | |
| 1032 | 1328 | a, p, m, err := baseBuildAgent() | |
| @@ -1046,47 +1342,100 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1046 | 1342 | // loadSession replaces the current session with the one at path and | |
| 1047 | 1343 | // hands its messages to the agent. Used by the /sessions picker. | |
| 1048 | 1344 | loadSession := func(path string) error { | |
| 1345 | + | // Hold the transition lock from the pre-flush through the commit. | |
| 1346 | + | // Persistence callbacks take the read side, so an active session | |
| 1347 | + | // cannot be snapshotted before its lazy writes land or overwritten by | |
| 1348 | + | // a callback while the candidate is being installed. | |
| 1349 | + | sessionTransitionMu.Lock() | |
| 1350 | + | defer sessionTransitionMu.Unlock() | |
| 1351 | + | ||
| 1049 | 1352 | currentAg := liveInteractiveAgent(iv, ag) | |
| 1050 | 1353 | if currentAg == nil { | |
| 1051 | 1354 | return fmt.Errorf("no agent running; log in first") | |
| 1052 | 1355 | } | |
| 1053 | - | newSess, msgs, err := core.OpenSession(path) | |
| 1356 | + | ||
| 1357 | + | persistMu.Lock() | |
| 1358 | + | currentProvider := activeProvider | |
| 1359 | + | currentModel := activeModel | |
| 1360 | + | oldSess := sess | |
| 1361 | + | // Flush before reading the candidate. Use the locked form rather | |
| 1362 | + | // than the public callback, which would acquire the read lock held | |
| 1363 | + | // by this transition. | |
| 1364 | + | if oldSess != nil { | |
| 1365 | + | writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs) | |
| 1366 | + | } | |
| 1367 | + | currentMessageCount := len(currentAg.Messages()) | |
| 1368 | + | currentCost := currentAg.Cost() | |
| 1369 | + | persistMu.Unlock() | |
| 1370 | + | ||
| 1371 | + | // A candidate build must not publish its provider/model as the live | |
| 1372 | + | // selection before the candidate commits. | |
| 1373 | + | candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 1054 | 1374 | if err != nil { | |
| 1375 | + | // prepareSessionResume closes its append handle on failure; | |
| 1376 | + | // no live agent or session has been changed. | |
| 1055 | 1377 | return err | |
| 1056 | 1378 | } | |
| 1057 | - | fullMsgCount := len(msgs) | |
| 1058 | - | msgs = trimMessagesForResume(msgs, 100) | |
| 1379 | + | committed := false | |
| 1380 | + | defer func() { | |
| 1381 | + | if !committed { | |
| 1382 | + | _ = candidate.session.Close() | |
| 1383 | + | } | |
| 1384 | + | }() | |
| 1385 | + | ||
| 1386 | + | // Wire the private candidate before it can become visible. This is | |
| 1387 | + | // also required for legacy same-provider resumes, where the current | |
| 1388 | + | // agent is reused and only its transcript/usage are refreshed. | |
| 1389 | + | wireAgentPersist(candidate.agent) | |
| 1390 | + | ||
| 1059 | 1391 | persistMu.Lock() | |
| 1060 | - | // Flush any unsaved messages to the old session before swapping. | |
| 1061 | - | // Per-message persistence keeps sessBaselineMsgs current, so | |
| 1062 | - | // this is a defensive no-op in the common case; it still | |
| 1063 | - | // matters for the rare race where a turn just finished and | |
| 1064 | - | // hadn't fired its hook yet. | |
| 1065 | - | if sess != nil { | |
| 1066 | - | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1067 | - | _ = sess.Close() | |
| 1392 | + | // A provider rebuild ran outside persistMu. Refuse to install it if | |
| 1393 | + | // the live agent, session, selection, transcript, or cost changed in | |
| 1394 | + | // the meantime; otherwise a slow resume could discard a newer turn or | |
| 1395 | + | // model swap. | |
| 1396 | + | live := liveInteractiveAgent(iv, ag) | |
| 1397 | + | changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg | |
| 1398 | + | if !changed && live != nil { | |
| 1399 | + | changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost | |
| 1400 | + | } | |
| 1401 | + | if changed { | |
| 1402 | + | persistMu.Unlock() | |
| 1403 | + | return fmt.Errorf("session changed while loading; please try again") | |
| 1068 | 1404 | } | |
| 1069 | - | sess = newSess | |
| 1070 | - | currentAg.SetMessages(msgs) | |
| 1071 | - | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | |
| 1072 | - | currentAg.SeedCost(cum) | |
| 1073 | - | currentAg.SeedLastTurnUsage(last) | |
| 1074 | - | } | |
| 1075 | - | // The live agent only receives a compact resume window, but | |
| 1076 | - | // the session file remains intact. Keep the persistence | |
| 1077 | - | // baseline at the original on-disk message count so future | |
| 1078 | - | // turns append after the full session instead of duplicating | |
| 1079 | - | // the hydrated tail. | |
| 1080 | - | sessBaselineMsgs = fullMsgCount | |
| 1081 | - | persistMu.Unlock() | |
| 1082 | - | // Re-scope the swarm dashboard to the new session so /swarm | |
| 1083 | - | // only shows agents this session spawned. swarmMgr may be nil | |
| 1084 | - | // here if we haven't reached the construction site yet (it | |
| 1085 | - | // shouldn't be, since the interactive loop is what triggers | |
| 1086 | - | // loadSession, but the nil check is cheap insurance). | |
| 1087 | - | if swarmMgr != nil && newSess != nil { | |
| 1088 | - | swarmMgr.SetActiveSession(newSess.ID) | |
| 1405 | + | ||
| 1406 | + | if oldSess != nil { | |
| 1407 | + | _ = oldSess.Close() | |
| 1089 | 1408 | } | |
| 1409 | + | sess = candidate.session | |
| 1410 | + | // The live agent only receives a compact resume window, but the | |
| 1411 | + | // session file remains intact. Keep the persistence baseline at | |
| 1412 | + | // the original on-disk message count so future turns append after | |
| 1413 | + | // the full session instead of duplicating the hydrated tail. | |
| 1414 | + | sessBaselineMsgs = candidate.fullMessageCount | |
| 1415 | + | ||
| 1416 | + | if candidate.rebuilt { | |
| 1417 | + | // Commit the TUI agent while persistMu still protects the session | |
| 1418 | + | // writer, so no new message can land on the old file between the | |
| 1419 | + | // two state changes. | |
| 1420 | + | if iv != nil { | |
| 1421 | + | iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model) | |
| 1422 | + | } | |
| 1423 | + | ag = candidate.agent | |
| 1424 | + | } else { | |
| 1425 | + | currentAg.SetMessages(candidate.messages) | |
| 1426 | + | currentAg.SeedCost(candidate.cumulative) | |
| 1427 | + | currentAg.SeedLastTurnUsage(candidate.lastTurn) | |
| 1428 | + | ag = currentAg | |
| 1429 | + | } | |
| 1430 | + | activeProvider = candidate.provider | |
| 1431 | + | activeModel = candidate.model | |
| 1432 | + | if swarmMgr != nil && candidate.session != nil { | |
| 1433 | + | // Keep the dashboard scope in the same commit as the session, | |
| 1434 | + | // agent, usage, and persistence baseline. | |
| 1435 | + | swarmMgr.SetActiveSession(candidate.session.ID) | |
| 1436 | + | } | |
| 1437 | + | committed = true | |
| 1438 | + | persistMu.Unlock() | |
| 1090 | 1439 | return nil | |
| 1091 | 1440 | } | |
| 1092 | 1441 | ||
| @@ -1106,6 +1455,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1106 | 1455 | // The /jail state is preserved verbatim: if the sandbox was locked | |
| 1107 | 1456 | // to the old cwd, it stays locked, just re-pointed at the new one. | |
| 1108 | 1457 | changeCWD := func(path string) error { | |
| 1458 | + | sessionTransitionMu.Lock() | |
| 1459 | + | defer sessionTransitionMu.Unlock() | |
| 1109 | 1460 | if path == "" { | |
| 1110 | 1461 | return fmt.Errorf("empty path") | |
| 1111 | 1462 | } | |
| @@ -1136,7 +1487,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1136 | 1487 | return fmt.Errorf("not a directory: %s", absPath) | |
| 1137 | 1488 | } | |
| 1138 | 1489 | ||
| 1139 | - | currentAg := ag | |
| 1490 | + | currentAg := liveInteractiveAgent(iv, ag) | |
| 1140 | 1491 | if currentAg == nil { | |
| 1141 | 1492 | return fmt.Errorf("no agent running; log in first") | |
| 1142 | 1493 | } | |
| @@ -1147,6 +1498,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1147 | 1498 | oldCWD := args.CWD | |
| 1148 | 1499 | oldResolvedCWD := r.CWD | |
| 1149 | 1500 | oldPermissionSet := args.PermissionSet | |
| 1501 | + | persistMu.Lock() | |
| 1502 | + | oldActiveProvider := activeProvider | |
| 1503 | + | oldActiveModel := activeModel | |
| 1504 | + | persistMu.Unlock() | |
| 1150 | 1505 | oldSwarmRoot := "" | |
| 1151 | 1506 | if swarmMgr != nil { | |
| 1152 | 1507 | oldSwarmRoot = swarmMgr.RepoRoot() | |
| @@ -1161,6 +1516,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1161 | 1516 | args.CWD = oldCWD | |
| 1162 | 1517 | r.CWD = oldResolvedCWD | |
| 1163 | 1518 | args.PermissionSet = oldPermissionSet | |
| 1519 | + | persistMu.Lock() | |
| 1520 | + | activeProvider = oldActiveProvider | |
| 1521 | + | activeModel = oldActiveModel | |
| 1522 | + | persistMu.Unlock() | |
| 1164 | 1523 | if swarmMgr != nil { | |
| 1165 | 1524 | swarmMgr.SetRepoRoot(oldSwarmRoot) | |
| 1166 | 1525 | } | |
| @@ -1225,6 +1584,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1225 | 1584 | } | |
| 1226 | 1585 | sess = newSess | |
| 1227 | 1586 | sessBaselineMsgs = 0 | |
| 1587 | + | activeProvider = newProvider | |
| 1588 | + | activeModel = newModel | |
| 1228 | 1589 | persistMu.Unlock() | |
| 1229 | 1590 | ag = newAg | |
| 1230 | 1591 | ||
| @@ -1416,12 +1777,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1416 | 1777 | LoadSession: loadSession, | |
| 1417 | 1778 | ChangeCWD: changeCWD, | |
| 1418 | 1779 | CurrentSessionPath: func() string { | |
| 1780 | + | persistMu.Lock() | |
| 1781 | + | defer persistMu.Unlock() | |
| 1419 | 1782 | if sess == nil { | |
| 1420 | 1783 | return "" | |
| 1421 | 1784 | } | |
| 1422 | 1785 | return sess.Path | |
| 1423 | 1786 | }, | |
| 1424 | 1787 | FlushSession: func() { | |
| 1788 | + | sessionTransitionMu.RLock() | |
| 1789 | + | defer sessionTransitionMu.RUnlock() | |
| 1425 | 1790 | // Append any not-yet-persisted agent messages to the | |
| 1426 | 1791 | // current session file, then advance the baseline so | |
| 1427 | 1792 | // the final WriteNewTranscript at exit doesn't write | |
| @@ -1442,6 +1807,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1442 | 1807 | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1443 | 1808 | sessBaselineMsgs = len(currentAg.Messages()) | |
| 1444 | 1809 | }, | |
| 1810 | + | SessionTransition: func(fn func()) { | |
| 1811 | + | sessionTransitionMu.Lock() | |
| 1812 | + | defer sessionTransitionMu.Unlock() | |
| 1813 | + | fn() | |
| 1814 | + | }, | |
| 1445 | 1815 | Extensions: extMgr, | |
| 1446 | 1816 | Swarm: swarmMgr, | |
| 1447 | 1817 | ResolveSubagent: resolveSubagent, | |
| @@ -1476,14 +1846,39 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1476 | 1846 | NoYolo: args.NoYolo, | |
| 1477 | 1847 | ConfirmGate: confirmGate, | |
| 1478 | 1848 | PersistModel: func(providerName, model string) { | |
| 1849 | + | providerName = strings.TrimSpace(providerName) | |
| 1850 | + | model = strings.TrimSpace(model) | |
| 1851 | + | // Persist the active session metadata before publishing the | |
| 1852 | + | // selection used by future resumes. The live agent has already | |
| 1853 | + | // switched pairs, so keep the in-memory selection aligned even | |
| 1854 | + | // when the append fails and report that persistence error below. | |
| 1855 | + | persistMu.Lock() | |
| 1856 | + | var sessionErr error | |
| 1857 | + | if sess != nil { | |
| 1858 | + | sessionErr = sess.UpdateModel(providerName, model) | |
| 1859 | + | } | |
| 1860 | + | activeProvider = providerName | |
| 1861 | + | activeModel = model | |
| 1862 | + | persistMu.Unlock() | |
| 1863 | + | if sessionErr != nil { | |
| 1864 | + | if iv != nil { | |
| 1865 | + | iv.Notify("session", "error", "model change was not persisted: "+sessionErr.Error()) | |
| 1866 | + | } | |
| 1867 | + | return | |
| 1868 | + | } | |
| 1869 | + | ||
| 1479 | 1870 | // Update config.json so next launch uses the same pick. | |
| 1480 | - | cfg, _ := LoadConfig() | |
| 1871 | + | cfg, cfgErr := LoadConfig() | |
| 1872 | + | if cfgErr != nil { | |
| 1873 | + | if iv != nil { | |
| 1874 | + | iv.Notify("session", "error", "model changed for this run, but config could not be read: "+cfgErr.Error()) | |
| 1875 | + | } | |
| 1876 | + | return | |
| 1877 | + | } | |
| 1481 | 1878 | cfg.Provider = providerName | |
| 1482 | 1879 | cfg.Model = model | |
| 1483 | - | _ = SaveConfig(cfg) | |
| 1484 | - | // Update the active session's meta so resume picks this up. | |
| 1485 | - | if sess != nil { | |
| 1486 | - | _ = sess.UpdateModel(providerName, model) | |
| 1880 | + | if cfgErr = SaveConfig(cfg); cfgErr != nil && iv != nil { | |
| 1881 | + | iv.Notify("session", "error", "model changed for this run, but config could not be saved: "+cfgErr.Error()) | |
| 1487 | 1882 | } | |
| 1488 | 1883 | }, | |
| 1489 | 1884 | }) | |
M packages/agent/cli_session_test.go +209 -0
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 4 | 7 | "testing" | |
| 5 | 8 | ||
| 6 | 9 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| @@ -8,6 +11,28 @@ import ( | |||
| 8 | 11 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 14 | + | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | |
| 15 | + | msgs := make([]provider.Message, 0, 101) | |
| 16 | + | msgs = append(msgs, provider.Message{ | |
| 17 | + | Role: provider.RoleTool, | |
| 18 | + | AddedToolNames: []string{"lookup_weather"}, | |
| 19 | + | Content: []provider.Content{provider.ToolResultBlock{CallID: "old-call"}}, | |
| 20 | + | }) | |
| 21 | + | for idx := 1; idx < 101; idx++ { | |
| 22 | + | msgs = append(msgs, provider.Message{ | |
| 23 | + | Role: provider.RoleUser, | |
| 24 | + | Content: []provider.Content{provider.TextBlock{Text: "message"}}, | |
| 25 | + | }) | |
| 26 | + | } | |
| 27 | + | trimmed := trimMessagesForResume(msgs, 100) | |
| 28 | + | if len(trimmed) != 100 { | |
| 29 | + | t.Fatalf("trimmed message count = %d, want 100", len(trimmed)) | |
| 30 | + | } | |
| 31 | + | if len(trimmed[0].AddedToolNames) != 1 || trimmed[0].AddedToolNames[0] != "lookup_weather" { | |
| 32 | + | t.Fatalf("trimmed activation markers = %v, want lookup_weather", trimmed[0].AddedToolNames) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 11 | 36 | func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) { | |
| 12 | 37 | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 13 | 38 | startup.SetMessages([]provider.Message{{ | |
| @@ -46,3 +71,187 @@ func firstMessageText(messages []provider.Message) string { | |||
| 46 | 71 | text, _ := messages[0].Content[0].(provider.TextBlock) | |
| 47 | 72 | return text.Text | |
| 48 | 73 | } | |
| 74 | + | ||
| 75 | + | func syntheticSession(t *testing.T, providerName, model string, usage provider.Usage) string { | |
| 76 | + | t.Helper() | |
| 77 | + | root := t.TempDir() | |
| 78 | + | cwd := t.TempDir() | |
| 79 | + | sess, err := core.NewSession(root, cwd, providerName, model, "test") | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | if err := sess.AppendMessage(provider.Message{ | |
| 84 | + | Role: provider.RoleUser, | |
| 85 | + | Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}}, | |
| 86 | + | }); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if usage != (provider.Usage{}) { | |
| 90 | + | if err := sess.AppendUsage(usage, usage); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | if err := sess.Close(); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | return sess.Path | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) { | |
| 101 | + | root := t.TempDir() | |
| 102 | + | sess, err := core.NewSession(root, "/workspace", "provider", "model", "test") | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 107 | + | gotSess, gotAgent, providerName, model, err := applyInitialSessionResume(context.Background(), Args{}, Resolved{Provider: "provider", Model: "model"}, nil, sess, ag) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | if gotSess != sess || gotAgent != ag || providerName != "provider" || model != "model" { | |
| 112 | + | t.Fatalf("empty startup resume = session %p/%p agent %p/%p provider/model %q/%q", gotSess, sess, gotAgent, ag, providerName, model) | |
| 113 | + | } | |
| 114 | + | if _, err := os.Stat(sess.Path); err != nil { | |
| 115 | + | t.Fatalf("fresh session disappeared before close: %v", err) | |
| 116 | + | } | |
| 117 | + | if err := sess.Close(); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if _, err := os.Stat(sess.Path); !errors.Is(err, os.ErrNotExist) { | |
| 121 | + | t.Fatalf("fresh empty session remained after close: %v", err) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) { | |
| 126 | + | old := core.NewAgent(nil, "old-model", "", nil) | |
| 127 | + | old.SetMessages([]provider.Message{{ | |
| 128 | + | Role: provider.RoleUser, | |
| 129 | + | Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}, | |
| 130 | + | }}) | |
| 131 | + | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{ | |
| 132 | + | InputTokens: 12, | |
| 133 | + | OutputTokens: 7, | |
| 134 | + | }) | |
| 135 | + | ||
| 136 | + | var gotProvider, gotModel string | |
| 137 | + | candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(providerName, model string) (*core.Agent, string, string, error) { | |
| 138 | + | gotProvider, gotModel = providerName, model | |
| 139 | + | return core.NewAgent(nil, model, "", nil), providerName, model, nil | |
| 140 | + | }) | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | defer candidate.session.Close() | |
| 145 | + | ||
| 146 | + | if gotProvider != "stored-provider" || gotModel != "stored-model" { | |
| 147 | + | t.Fatalf("builder selection = %q/%q, want stored-provider/stored-model", gotProvider, gotModel) | |
| 148 | + | } | |
| 149 | + | if !candidate.rebuilt || candidate.agent == old { | |
| 150 | + | t.Fatalf("candidate rebuilt = %v, agent=%p, old=%p", candidate.rebuilt, candidate.agent, old) | |
| 151 | + | } | |
| 152 | + | if got := firstMessageText(candidate.agent.Messages()); got != "resumed transcript" { | |
| 153 | + | t.Fatalf("candidate transcript = %q, want resumed transcript", got) | |
| 154 | + | } | |
| 155 | + | if got := candidate.agent.Cost(); got.InputTokens != 12 || got.OutputTokens != 7 { | |
| 156 | + | t.Fatalf("candidate usage = %+v, want input=12 output=7", got) | |
| 157 | + | } | |
| 158 | + | if got := old.Messages(); len(got) != 1 || firstMessageText(got) != "current transcript" { | |
| 159 | + | t.Fatalf("current transcript changed during preparation: %+v", got) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | |
| 164 | + | old := core.NewAgent(nil, "old-model", "", nil) | |
| 165 | + | old.SetMessages([]provider.Message{{ | |
| 166 | + | Role: provider.RoleUser, | |
| 167 | + | Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}, | |
| 168 | + | }}) | |
| 169 | + | path := syntheticSession(t, "", "", provider.Usage{}) | |
| 170 | + | ||
| 171 | + | candidate, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) { | |
| 172 | + | t.Fatal("legacy session unexpectedly requested a rebuild") | |
| 173 | + | return nil, "", "", nil | |
| 174 | + | }) | |
| 175 | + | if err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | defer candidate.session.Close() | |
| 179 | + | ||
| 180 | + | if candidate.rebuilt || candidate.agent != old { | |
| 181 | + | t.Fatalf("legacy candidate = rebuilt %v, agent=%p; want current agent without rebuild", candidate.rebuilt, candidate.agent) | |
| 182 | + | } | |
| 183 | + | if got := firstMessageText(old.Messages()); got != "current transcript" { | |
| 184 | + | t.Fatalf("current transcript changed before commit: %q", got) | |
| 185 | + | } | |
| 186 | + | if got := firstMessageText(candidate.messages); got != "resumed transcript" { | |
| 187 | + | t.Fatalf("legacy candidate transcript = %q, want resumed transcript", got) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) { | |
| 192 | + | old := core.NewAgent(nil, "old-model", "", nil) | |
| 193 | + | old.SetMessages([]provider.Message{{ | |
| 194 | + | Role: provider.RoleUser, | |
| 195 | + | Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}, | |
| 196 | + | }}) | |
| 197 | + | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | |
| 198 | + | wantErr := errors.New("synthetic builder failure") | |
| 199 | + | ||
| 200 | + | if _, err := prepareSessionResume(path, old, "current-provider", "old-model", func(string, string) (*core.Agent, string, string, error) { | |
| 201 | + | return nil, "", "", wantErr | |
| 202 | + | }); !errors.Is(err, wantErr) { | |
| 203 | + | t.Fatalf("prepare error = %v, want %v", err, wantErr) | |
| 204 | + | } | |
| 205 | + | if got := firstMessageText(old.Messages()); got != "current transcript" { | |
| 206 | + | t.Fatalf("current transcript changed after failed preparation: %q", got) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // The failed candidate must have released its append handle, leaving | |
| 210 | + | // the selected file readable and reopenable for a later retry. | |
| 211 | + | reopened, _, err := core.OpenSession(path) | |
| 212 | + | if err != nil { | |
| 213 | + | t.Fatalf("reopen candidate after failed preparation: %v", err) | |
| 214 | + | } | |
| 215 | + | if err := reopened.Close(); err != nil { | |
| 216 | + | t.Fatal(err) | |
| 217 | + | } | |
| 218 | + | if _, err := os.Stat(path); err != nil { | |
| 219 | + | t.Fatalf("candidate session disappeared after failed preparation: %v", err) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) { | |
| 224 | + | for _, tc := range []struct { | |
| 225 | + | name string | |
| 226 | + | explicitProvider bool | |
| 227 | + | explicitModel bool | |
| 228 | + | wantProvider string | |
| 229 | + | wantModel string | |
| 230 | + | wantBuild bool | |
| 231 | + | }{ | |
| 232 | + | {name: "both", explicitProvider: true, explicitModel: true, wantProvider: "current-provider", wantModel: "current-model"}, | |
| 233 | + | {name: "provider-only", explicitProvider: true, wantProvider: "current-provider", wantModel: "stored-model", wantBuild: true}, | |
| 234 | + | {name: "model-only", explicitModel: true, wantProvider: "stored-provider", wantModel: "current-model", wantBuild: true}, | |
| 235 | + | } { | |
| 236 | + | t.Run(tc.name, func(t *testing.T) { | |
| 237 | + | old := core.NewAgent(nil, "old-model", "", nil) | |
| 238 | + | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | |
| 239 | + | var gotProvider, gotModel string | |
| 240 | + | candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) { | |
| 241 | + | gotProvider, gotModel = providerName, model | |
| 242 | + | return core.NewAgent(nil, model, "", nil), providerName, model, nil | |
| 243 | + | }) | |
| 244 | + | if err != nil { | |
| 245 | + | t.Fatal(err) | |
| 246 | + | } | |
| 247 | + | defer candidate.session.Close() | |
| 248 | + | if tc.wantBuild { | |
| 249 | + | if gotProvider != tc.wantProvider || gotModel != tc.wantModel { | |
| 250 | + | t.Fatalf("builder selection = %q/%q, want %q/%q", gotProvider, gotModel, tc.wantProvider, tc.wantModel) | |
| 251 | + | } | |
| 252 | + | } else if candidate.provider != tc.wantProvider || candidate.model != tc.wantModel || candidate.rebuilt { | |
| 253 | + | t.Fatalf("candidate selection = %q/%q rebuilt=%v, want %q/%q without rebuild", candidate.provider, candidate.model, candidate.rebuilt, tc.wantProvider, tc.wantModel) | |
| 254 | + | } | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | } | |
A packages/agent/modes/double_escape.go +377 -0
| @@ -0,0 +1,377 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const sessionTreeEscapeWindow = 500 * time.Millisecond | |
| 14 | + | ||
| 15 | + | type doubleEscapeTracker struct { | |
| 16 | + | last time.Time | |
| 17 | + | armed bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (d *doubleEscapeTracker) Arm(now time.Time) { | |
| 21 | + | d.last = now | |
| 22 | + | d.armed = true | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Consume reports whether now is the second tap of a valid gesture. A clock | |
| 26 | + | // that moves backwards is treated as a reset rather than as a valid tap. | |
| 27 | + | func (d *doubleEscapeTracker) Consume(now time.Time) bool { | |
| 28 | + | last := d.last | |
| 29 | + | armed := d.armed | |
| 30 | + | d.last = time.Time{} | |
| 31 | + | d.armed = false | |
| 32 | + | if !armed { | |
| 33 | + | return false | |
| 34 | + | } | |
| 35 | + | elapsed := now.Sub(last) | |
| 36 | + | return elapsed >= 0 && elapsed <= sessionTreeEscapeWindow | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (d *doubleEscapeTracker) Reset() { | |
| 40 | + | d.last = time.Time{} | |
| 41 | + | d.armed = false | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func isUnmodifiedEscape(k tui.Key) bool { | |
| 45 | + | return k.Kind == tui.KeyEsc && !k.Ctrl && !k.Alt && !k.Shift && !k.Super | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (i *Interactive) sessionTreeEscapeNow() time.Time { | |
| 49 | + | if i.clock != nil { | |
| 50 | + | return i.clock() | |
| 51 | + | } | |
| 52 | + | return time.Now() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // currentSessionPath is the nil-safe session callback shared by slash | |
| 56 | + | // commands and tree operations. Running without persistence (for example | |
| 57 | + | // --no-session) is a supported mode, so callers must treat a missing | |
| 58 | + | // callback exactly like an empty path rather than invoking it blindly. | |
| 59 | + | func (i *Interactive) currentSessionPath() string { | |
| 60 | + | if i == nil || i.cfg.CurrentSessionPath == nil { | |
| 61 | + | return "" | |
| 62 | + | } | |
| 63 | + | return i.cfg.CurrentSessionPath() | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // sessionTreeEscapeBlocked reports interactions that must retain precedence | |
| 67 | + | // over the gesture. It is intentionally broader than the overlay switch in | |
| 68 | + | // handleKey: notes and suggestions also own Escape even though they are not | |
| 69 | + | // all rendered as dialogs. | |
| 70 | + | func (i *Interactive) sessionTreeEscapeBlocked() bool { | |
| 71 | + | if i == nil { | |
| 72 | + | return true | |
| 73 | + | } | |
| 74 | + | if i.ed == nil { | |
| 75 | + | return true | |
| 76 | + | } | |
| 77 | + | if i.dialog != nil && i.dialog.Active() || | |
| 78 | + | i.modelDialog != nil && i.modelDialog.Active() || | |
| 79 | + | i.llamaDialog != nil && i.llamaDialog.Active() || | |
| 80 | + | i.rescueDialog != nil && i.rescueDialog.Active() || | |
| 81 | + | i.sessionDialog != nil && i.sessionDialog.Active() || | |
| 82 | + | i.swarmDialog != nil && i.swarmDialog.Active() || | |
| 83 | + | i.jumpDialog != nil && i.jumpDialog.Active() || | |
| 84 | + | i.btwDialog != nil && i.btwDialog.Active() || | |
| 85 | + | i.skillsDialog != nil && i.skillsDialog.Active() || | |
| 86 | + | i.changelogDialog != nil && i.changelogDialog.Active() || | |
| 87 | + | i.logoutDialog != nil && i.logoutDialog.Active() || | |
| 88 | + | i.telegramDialog != nil && i.telegramDialog.Active() || | |
| 89 | + | i.settingsDialog != nil && i.settingsDialog.Active() || | |
| 90 | + | i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() || | |
| 91 | + | i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() || | |
| 92 | + | i.extPanel != nil && i.extPanel.Active() || | |
| 93 | + | i.confirmDialog != nil && i.confirmDialog.Active() { | |
| 94 | + | return true | |
| 95 | + | } | |
| 96 | + | if i.suggest != nil && i.suggest.Active(i.ed.Value()) || | |
| 97 | + | i.fileSuggest != nil && i.fileSuggest.Active(i.ed.Value()) { | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | i.mu.Lock() | |
| 101 | + | blocked := len(i.helpBlock) > 0 || len(i.extNotes) > 0 | |
| 102 | + | i.mu.Unlock() | |
| 103 | + | return blocked | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // canArmSessionTreeEscape checks only the cheap, local eligibility rules for | |
| 107 | + | // the first tap. The complete family read belongs to canOpenSessionTree on | |
| 108 | + | // the second tap; otherwise a malformed descendant would prevent arming and | |
| 109 | + | // the user would never receive the gate's status error. | |
| 110 | + | func (i *Interactive) canArmSessionTreeEscape() bool { | |
| 111 | + | if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" || | |
| 112 | + | i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |
| 113 | + | return false | |
| 114 | + | } | |
| 115 | + | if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | i.mu.Lock() | |
| 119 | + | busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 || | |
| 120 | + | i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing | |
| 121 | + | queued := len(i.queued) != 0 | |
| 122 | + | ag := i.agent | |
| 123 | + | pendingFork := i.pendingFork | |
| 124 | + | i.mu.Unlock() | |
| 125 | + | if busy || queued || pendingFork || ag == nil || ag.QueuedMessageCount() != 0 { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | return i.currentSessionPath() != "" | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // canOpenSessionTree is the shared, fail-closed precondition for both the | |
| 132 | + | // /session tree command and the bare-Escape gesture. It does not flush or | |
| 133 | + | // mutate session state. In particular, callers must not flush until this | |
| 134 | + | // check has returned true. | |
| 135 | + | func (i *Interactive) canOpenSessionTree() bool { | |
| 136 | + | if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" || | |
| 137 | + | i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() { | |
| 141 | + | return false | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | i.mu.Lock() | |
| 145 | + | busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 || | |
| 146 | + | i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing | |
| 147 | + | queued := len(i.queued) != 0 | |
| 148 | + | ag := i.agent | |
| 149 | + | pendingFork := i.pendingFork | |
| 150 | + | i.mu.Unlock() | |
| 151 | + | if busy || queued || pendingFork || ag == nil { | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | if ag.QueuedMessageCount() != 0 { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | current := i.currentSessionPath() | |
| 159 | + | if current == "" { | |
| 160 | + | return false | |
| 161 | + | } | |
| 162 | + | roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current) | |
| 163 | + | return err == nil && len(roots) != 0 | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // canCommitSessionTreeSelection is the open-gate subset used after the tree | |
| 167 | + | // already owns the keyboard. It deliberately permits the active tree dialog; | |
| 168 | + | // reusing canOpenSessionTree here would reject every Enter selection because | |
| 169 | + | // the dialog is itself an overlay. | |
| 170 | + | func (i *Interactive) canCommitSessionTreeSelection() bool { | |
| 171 | + | if i == nil || i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil || i.sessionsRoot() == "" || i.cfg.CWD == "" { | |
| 172 | + | return false | |
| 173 | + | } | |
| 174 | + | i.mu.Lock() | |
| 175 | + | busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 || | |
| 176 | + | i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing | |
| 177 | + | queued := len(i.queued) != 0 | |
| 178 | + | ag := i.agent | |
| 179 | + | i.mu.Unlock() | |
| 180 | + | if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 { | |
| 181 | + | return false | |
| 182 | + | } | |
| 183 | + | return i.currentSessionPath() != "" | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (i *Interactive) setSessionTreeError(message string) { | |
| 187 | + | if i.sessionTreeDialog != nil { | |
| 188 | + | i.sessionTreeDialog.Close() | |
| 189 | + | } | |
| 190 | + | i.mu.Lock() | |
| 191 | + | i.statusErr = message | |
| 192 | + | i.statusOK = "" | |
| 193 | + | i.mu.Unlock() | |
| 194 | + | i.invalidate() | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // openSessionTree is the only path that activates the session-tree overlay. | |
| 198 | + | // The gate runs before FlushSession, and a failed family read leaves the | |
| 199 | + | // dialog closed instead of falling back to a stale in-memory transcript. | |
| 200 | + | func (i *Interactive) openSessionTree() bool { | |
| 201 | + | if !i.canOpenSessionTree() { | |
| 202 | + | i.setSessionTreeError("tree: session tree is unavailable") | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | current := i.currentSessionPath() | |
| 206 | + | if current == "" { | |
| 207 | + | i.setSessionTreeError("tree: no session is active") | |
| 208 | + | return false | |
| 209 | + | } | |
| 210 | + | if i.cfg.FlushSession != nil { | |
| 211 | + | i.cfg.FlushSession() | |
| 212 | + | } | |
| 213 | + | if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) { | |
| 214 | + | i.setSessionTreeError("tree: no readable session family") | |
| 215 | + | return false | |
| 216 | + | } | |
| 217 | + | i.mu.Lock() | |
| 218 | + | i.statusErr = "" | |
| 219 | + | i.statusOK = "" | |
| 220 | + | i.mu.Unlock() | |
| 221 | + | i.invalidate() | |
| 222 | + | return true | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | type sessionTreeSelectionResult struct { | |
| 226 | + | upTo int | |
| 227 | + | restoreDraft bool | |
| 228 | + | draftText string | |
| 229 | + | images []clipboardImageAttachment | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // sessionTreeSelection turns a structured dialog target into a branch | |
| 233 | + | // boundary. User rows are boundaries before that message and restore the | |
| 234 | + | // full editable draft; assistant/tool rows include the selected completed | |
| 235 | + | // exchange. Empty and detached rows are already safe message-count | |
| 236 | + | // boundaries. A tool call and its result rows are never split. | |
| 237 | + | func sessionTreeSelection(msgs []provider.Message, target sessionTreeTarget) (sessionTreeSelectionResult, error) { | |
| 238 | + | if target.IsBoundary() { | |
| 239 | + | upTo := target.SelectionBoundary | |
| 240 | + | if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) { | |
| 241 | + | return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange") | |
| 242 | + | } | |
| 243 | + | return sessionTreeSelectionResult{upTo: upTo}, nil | |
| 244 | + | } | |
| 245 | + | msgIdx := target.EffectiveIndex | |
| 246 | + | if msgIdx < 0 || msgIdx >= len(msgs) { | |
| 247 | + | return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session") | |
| 248 | + | } | |
| 249 | + | selected := msgs[msgIdx] | |
| 250 | + | if selected.Role == provider.RoleUser { | |
| 251 | + | upTo := target.SelectionBoundary | |
| 252 | + | if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) { | |
| 253 | + | return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange") | |
| 254 | + | } | |
| 255 | + | text, images := sessionTreeDraft(selected, target.UserDraft) | |
| 256 | + | return sessionTreeSelectionResult{ | |
| 257 | + | upTo: upTo, | |
| 258 | + | restoreDraft: true, | |
| 259 | + | draftText: text, | |
| 260 | + | images: images, | |
| 261 | + | }, nil | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | upTo := target.SelectionBoundary | |
| 265 | + | if upTo < msgIdx+1 || upTo > len(msgs) { | |
| 266 | + | return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session") | |
| 267 | + | } | |
| 268 | + | if selected.Role == provider.RoleAssistant && messageHasToolCall(selected) || selected.Role == provider.RoleTool { | |
| 269 | + | // A provider tool exchange may occupy one assistant row followed by | |
| 270 | + | // one or more tool rows. Extend a selected row to the end of that | |
| 271 | + | // contiguous exchange before checking the boundary. | |
| 272 | + | for upTo < len(msgs) && msgs[upTo].Role == provider.RoleTool { | |
| 273 | + | upTo++ | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | if !safeSessionToolBoundary(msgs[:upTo]) { | |
| 277 | + | return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange") | |
| 278 | + | } | |
| 279 | + | return sessionTreeSelectionResult{upTo: upTo}, nil | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func messageHasToolCall(msg provider.Message) bool { | |
| 283 | + | for _, content := range msg.Content { | |
| 284 | + | if _, ok := content.(provider.ToolCallBlock); ok { | |
| 285 | + | return true | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | return false | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // safeSessionToolBoundary checks the provider's structural invariant at a | |
| 292 | + | // message cut: every tool call in the prefix has its matching result in the | |
| 293 | + | // immediately following tool row, and no result is orphaned. | |
| 294 | + | func safeSessionToolBoundary(msgs []provider.Message) bool { | |
| 295 | + | pending := map[string]bool{} | |
| 296 | + | for _, msg := range msgs { | |
| 297 | + | if len(pending) > 0 && msg.Role != provider.RoleTool { | |
| 298 | + | return false | |
| 299 | + | } | |
| 300 | + | switch msg.Role { | |
| 301 | + | case provider.RoleAssistant: | |
| 302 | + | if len(pending) != 0 { | |
| 303 | + | return false | |
| 304 | + | } | |
| 305 | + | for _, content := range msg.Content { | |
| 306 | + | if call, ok := content.(provider.ToolCallBlock); ok { | |
| 307 | + | if call.ID == "" || pending[call.ID] { | |
| 308 | + | return false | |
| 309 | + | } | |
| 310 | + | pending[call.ID] = true | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | case provider.RoleTool: | |
| 314 | + | if len(pending) == 0 { | |
| 315 | + | if len(msg.AddedToolNames) > 0 { | |
| 316 | + | // Deferred-tool activation rows intentionally carry | |
| 317 | + | // provider-local state without a preceding tool call. | |
| 318 | + | continue | |
| 319 | + | } | |
| 320 | + | return false | |
| 321 | + | } | |
| 322 | + | for _, content := range msg.Content { | |
| 323 | + | result, ok := content.(provider.ToolResultBlock) | |
| 324 | + | if !ok || !pending[result.CallID] { | |
| 325 | + | return false | |
| 326 | + | } | |
| 327 | + | delete(pending, result.CallID) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | return len(pending) == 0 | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func sessionTreeDraft(msg provider.Message, fallback string) (string, []clipboardImageAttachment) { | |
| 335 | + | // Agent.Prompt stores the text first and appends image blocks after it, | |
| 336 | + | // even when an older transcript contains several text blocks. Rebuild | |
| 337 | + | // that same editor representation: join every text block, then place | |
| 338 | + | // image markers after the complete text so submitting the restored draft | |
| 339 | + | // recreates the original text/images pair without losing any text. | |
| 340 | + | var textBlocks []string | |
| 341 | + | var imageBlocks []provider.ImageBlock | |
| 342 | + | for _, content := range msg.Content { | |
| 343 | + | switch block := content.(type) { | |
| 344 | + | case provider.TextBlock: | |
| 345 | + | textBlocks = append(textBlocks, block.Text) | |
| 346 | + | case provider.ImageBlock: | |
| 347 | + | imageBlocks = append(imageBlocks, block) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | var text strings.Builder | |
| 351 | + | if len(textBlocks) > 0 { | |
| 352 | + | text.WriteString(strings.Join(textBlocks, "\n")) | |
| 353 | + | } else { | |
| 354 | + | text.WriteString(fallback) | |
| 355 | + | } | |
| 356 | + | images := make([]clipboardImageAttachment, 0, len(imageBlocks)) | |
| 357 | + | for _, block := range imageBlocks { | |
| 358 | + | if text.Len() > 0 { | |
| 359 | + | last := text.String() | |
| 360 | + | if len(last) > 0 && last[len(last)-1] != ' ' && last[len(last)-1] != '\n' && last[len(last)-1] != '\t' { | |
| 361 | + | text.WriteByte(' ') | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | marker := fmt.Sprintf("[clipboard image #%d]", len(images)+1) | |
| 365 | + | text.WriteString(marker) | |
| 366 | + | text.WriteByte(' ') | |
| 367 | + | images = append(images, clipboardImageAttachment{ | |
| 368 | + | Marker: marker, | |
| 369 | + | Image: provider.ImageBlock{ | |
| 370 | + | MimeType: block.MimeType, | |
| 371 | + | Data: append([]byte(nil), block.Data...), | |
| 372 | + | ThoughtSignature: block.ThoughtSignature, | |
| 373 | + | }, | |
| 374 | + | }) | |
| 375 | + | } | |
| 376 | + | return text.String(), images | |
| 377 | + | } | |
A packages/agent/modes/double_escape_test.go +404 -0
| @@ -0,0 +1,404 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) { | |
| 14 | + | base := time.Unix(100, 0) | |
| 15 | + | var tracker doubleEscapeTracker | |
| 16 | + | ||
| 17 | + | tracker.Arm(base) | |
| 18 | + | if !tracker.Consume(base.Add(sessionTreeEscapeWindow)) { | |
| 19 | + | t.Fatal("tap at the 500ms boundary should count") | |
| 20 | + | } | |
| 21 | + | if tracker.Consume(base.Add(sessionTreeEscapeWindow)) { | |
| 22 | + | t.Fatal("a consumed gesture must not trigger again") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | tracker.Arm(base) | |
| 26 | + | if tracker.Consume(base.Add(sessionTreeEscapeWindow + time.Nanosecond)) { | |
| 27 | + | t.Fatal("tap after the gesture window should not count") | |
| 28 | + | } | |
| 29 | + | tracker.Arm(base) | |
| 30 | + | if tracker.Consume(base.Add(-time.Nanosecond)) { | |
| 31 | + | t.Fatal("a clock moving backwards must reset the gesture") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Zero is a valid value for a deterministic test clock; it must not be | |
| 35 | + | // confused with the tracker's unarmed sentinel. | |
| 36 | + | tracker.Arm(time.Time{}) | |
| 37 | + | if !tracker.Consume(time.Time{}.Add(100 * time.Millisecond)) { | |
| 38 | + | t.Fatal("zero-valued test timestamp should still arm the gesture") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestUnmodifiedEscapeRequiresBareParsedKey(t *testing.T) { | |
| 43 | + | bare := tui.Key{Kind: tui.KeyEsc} | |
| 44 | + | for _, key := range []tui.Key{ | |
| 45 | + | {Kind: tui.KeyEsc, Alt: true}, | |
| 46 | + | {Kind: tui.KeyEsc, Ctrl: true}, | |
| 47 | + | {Kind: tui.KeyEsc, Shift: true}, | |
| 48 | + | {Kind: tui.KeyEsc, Super: true}, | |
| 49 | + | } { | |
| 50 | + | if isUnmodifiedEscape(key) { | |
| 51 | + | t.Fatalf("modified key %#v was treated as a bare Escape", key) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | if !isUnmodifiedEscape(bare) { | |
| 55 | + | t.Fatal("bare parsed Escape was not recognized") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestSessionTreeSelectionRestoresCompleteUserDraft(t *testing.T) { | |
| 60 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "sig"} | |
| 61 | + | msgs := []provider.Message{ | |
| 62 | + | { | |
| 63 | + | Role: provider.RoleUser, | |
| 64 | + | Content: []provider.Content{ | |
| 65 | + | provider.TextBlock{Text: "before"}, | |
| 66 | + | image, | |
| 67 | + | provider.TextBlock{Text: "after"}, | |
| 68 | + | }, | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | target := sessionTreeTarget{ | |
| 72 | + | EffectiveIndex: 0, | |
| 73 | + | SelectionBoundary: 0, | |
| 74 | + | Role: provider.RoleUser, | |
| 75 | + | UserDraft: "fallback must not replace message text", | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | got, err := sessionTreeSelection(msgs, target) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("sessionTreeSelection: %v", err) | |
| 81 | + | } | |
| 82 | + | if got.upTo != 0 || !got.restoreDraft { | |
| 83 | + | t.Fatalf("selection = %#v, want a pre-user draft boundary", got) | |
| 84 | + | } | |
| 85 | + | if got.draftText != "before\nafter [clipboard image #1] " { | |
| 86 | + | t.Fatalf("draft text = %q", got.draftText) | |
| 87 | + | } | |
| 88 | + | if len(got.images) != 1 || got.images[0].Marker != "[clipboard image #1]" { | |
| 89 | + | t.Fatalf("restored images = %#v", got.images) | |
| 90 | + | } | |
| 91 | + | if string(got.images[0].Image.Data) != "png" || got.images[0].Image.MimeType != image.MimeType || got.images[0].Image.ThoughtSignature != image.ThoughtSignature { | |
| 92 | + | t.Fatalf("restored image = %#v", got.images[0].Image) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestSessionTreeSelectionKeepsToolExchangeAtomic(t *testing.T) { | |
| 97 | + | msgs := []provider.Message{ | |
| 98 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "ask"}}}, | |
| 99 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}}, | |
| 100 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}}, | |
| 101 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | got, err := sessionTreeSelection(msgs, sessionTreeTarget{ | |
| 105 | + | EffectiveIndex: 1, | |
| 106 | + | SelectionBoundary: 2, | |
| 107 | + | Role: provider.RoleAssistant, | |
| 108 | + | }) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatalf("tool-call selection: %v", err) | |
| 111 | + | } | |
| 112 | + | if got.upTo != 3 { | |
| 113 | + | t.Fatalf("tool-call boundary = %d, want 3", got.upTo) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | got, err = sessionTreeSelection(msgs, sessionTreeTarget{ | |
| 117 | + | EffectiveIndex: 2, | |
| 118 | + | SelectionBoundary: 3, | |
| 119 | + | Role: provider.RoleTool, | |
| 120 | + | }) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatalf("tool-result selection: %v", err) | |
| 123 | + | } | |
| 124 | + | if got.upTo != 3 { | |
| 125 | + | t.Fatalf("tool-result boundary = %d, want 3", got.upTo) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestSessionTreeSelectionAllowsDeferredToolActivation(t *testing.T) { | |
| 130 | + | msgs := []provider.Message{ | |
| 131 | + | { | |
| 132 | + | Role: provider.RoleTool, | |
| 133 | + | AddedToolNames: []string{"lookup_weather"}, | |
| 134 | + | Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}, | |
| 135 | + | }, | |
| 136 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}}, | |
| 137 | + | } | |
| 138 | + | got, err := sessionTreeSelection(msgs, sessionTreeTarget{ | |
| 139 | + | EffectiveIndex: 0, | |
| 140 | + | SelectionBoundary: 1, | |
| 141 | + | Role: provider.RoleTool, | |
| 142 | + | }) | |
| 143 | + | if err != nil { | |
| 144 | + | t.Fatalf("deferred-tool selection: %v", err) | |
| 145 | + | } | |
| 146 | + | if got.upTo != 1 { | |
| 147 | + | t.Fatalf("deferred-tool boundary = %d, want 1", got.upTo) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) { | |
| 152 | + | i := NewInteractive(InteractiveConfig{}) | |
| 153 | + | flushes := 0 | |
| 154 | + | i.cfg.FlushSession = func() { flushes++ } | |
| 155 | + | ||
| 156 | + | i.doSessionTree() | |
| 157 | + | if flushes != 0 { | |
| 158 | + | t.Fatalf("FlushSession called %d times on a failed gate", flushes) | |
| 159 | + | } | |
| 160 | + | if i.sessionTreeDialog.Active() { | |
| 161 | + | t.Fatal("session tree overlay opened after a failed gate") | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) { | |
| 166 | + | root := t.TempDir() | |
| 167 | + | cwd := "/workspace/model-refresh-gate" | |
| 168 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}} | |
| 173 | + | if err := session.AppendMessage(message); err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | path := session.Path | |
| 177 | + | if err := session.Close(); err != nil { | |
| 178 | + | t.Fatal(err) | |
| 179 | + | } | |
| 180 | + | i := NewInteractive(InteractiveConfig{ | |
| 181 | + | Agent: core.NewAgent(nil, "model", "", nil), | |
| 182 | + | CWD: cwd, | |
| 183 | + | SessionsRoot: root, | |
| 184 | + | CurrentSessionPath: func() string { return path }, | |
| 185 | + | LoadSession: func(string) error { return nil }, | |
| 186 | + | }) | |
| 187 | + | i.agent.SetMessages([]provider.Message{message}) | |
| 188 | + | i.mu.Lock() | |
| 189 | + | i.modelRefreshing = true | |
| 190 | + | i.mu.Unlock() | |
| 191 | + | if i.canOpenSessionTree() { | |
| 192 | + | t.Fatal("tree gate opened while model refresh was in flight") | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) { | |
| 197 | + | root := t.TempDir() | |
| 198 | + | cwd := "/workspace/selection-gate" | |
| 199 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 200 | + | if err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}} | |
| 204 | + | if err := session.AppendMessage(message); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | path := session.Path | |
| 208 | + | if err := session.Close(); err != nil { | |
| 209 | + | t.Fatal(err) | |
| 210 | + | } | |
| 211 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 212 | + | ag.SetMessages([]provider.Message{message}) | |
| 213 | + | i := NewInteractive(InteractiveConfig{ | |
| 214 | + | Agent: ag, | |
| 215 | + | CWD: cwd, | |
| 216 | + | SessionsRoot: root, | |
| 217 | + | CurrentSessionPath: func() string { | |
| 218 | + | return path | |
| 219 | + | }, | |
| 220 | + | LoadSession: func(string) error { return nil }, | |
| 221 | + | }) | |
| 222 | + | i.sessionTreeDialog.active = true | |
| 223 | + | if i.canOpenSessionTree() { | |
| 224 | + | t.Fatal("open gate accepted an already-active tree") | |
| 225 | + | } | |
| 226 | + | if !i.canCommitSessionTreeSelection() { | |
| 227 | + | t.Fatal("selection gate rejected the active tree dialog") | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) { | |
| 232 | + | root := t.TempDir() | |
| 233 | + | cwd := "/workspace/double-escape" | |
| 234 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 235 | + | if err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}} | |
| 239 | + | if err := session.AppendMessage(message); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | path := session.Path | |
| 243 | + | if err := session.Close(); err != nil { | |
| 244 | + | t.Fatal(err) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 248 | + | ag.SetMessages([]provider.Message{message}) | |
| 249 | + | now := time.Unix(200, 0) | |
| 250 | + | flushes := 0 | |
| 251 | + | i := NewInteractive(InteractiveConfig{ | |
| 252 | + | Agent: ag, | |
| 253 | + | CWD: cwd, | |
| 254 | + | SessionsRoot: root, | |
| 255 | + | CurrentSessionPath: func() string { | |
| 256 | + | return path | |
| 257 | + | }, | |
| 258 | + | LoadSession: func(string) error { return nil }, | |
| 259 | + | FlushSession: func() { | |
| 260 | + | flushes++ | |
| 261 | + | }, | |
| 262 | + | }) | |
| 263 | + | i.clock = func() time.Time { return now } | |
| 264 | + | ||
| 265 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 266 | + | t.Fatal("first Escape exited") | |
| 267 | + | } | |
| 268 | + | if i.sessionTreeDialog.Active() || flushes != 0 { | |
| 269 | + | t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes) | |
| 270 | + | } | |
| 271 | + | now = now.Add(499 * time.Millisecond) | |
| 272 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 273 | + | t.Fatal("second Escape exited") | |
| 274 | + | } | |
| 275 | + | if !i.sessionTreeDialog.Active() { | |
| 276 | + | t.Fatal("readable session tree did not open") | |
| 277 | + | } | |
| 278 | + | if flushes != 1 { | |
| 279 | + | t.Fatalf("FlushSession called %d times, want once after the gate", flushes) | |
| 280 | + | } | |
| 281 | + | if got := len(ag.Messages()); got != 1 { | |
| 282 | + | t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) { | |
| 287 | + | root := t.TempDir() | |
| 288 | + | cwd := "/workspace/double-escape-busy" | |
| 289 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 290 | + | if err != nil { | |
| 291 | + | t.Fatal(err) | |
| 292 | + | } | |
| 293 | + | message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}} | |
| 294 | + | if err := session.AppendMessage(message); err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | path := session.Path | |
| 298 | + | if err := session.Close(); err != nil { | |
| 299 | + | t.Fatal(err) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 303 | + | ag.SetMessages([]provider.Message{message}) | |
| 304 | + | now := time.Unix(300, 0) | |
| 305 | + | cancelled := 0 | |
| 306 | + | i := NewInteractive(InteractiveConfig{ | |
| 307 | + | Agent: ag, | |
| 308 | + | CWD: cwd, | |
| 309 | + | SessionsRoot: root, | |
| 310 | + | CurrentSessionPath: func() string { return path }, | |
| 311 | + | LoadSession: func(string) error { return nil }, | |
| 312 | + | }) | |
| 313 | + | i.clock = func() time.Time { return now } | |
| 314 | + | ||
| 315 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 316 | + | t.Fatal("first Escape exited") | |
| 317 | + | } | |
| 318 | + | i.mu.Lock() | |
| 319 | + | i.busy = true | |
| 320 | + | i.cancelTurn = func() { cancelled++ } | |
| 321 | + | i.mu.Unlock() | |
| 322 | + | now = now.Add(100 * time.Millisecond) | |
| 323 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 324 | + | t.Fatal("second Escape exited") | |
| 325 | + | } | |
| 326 | + | if cancelled != 1 { | |
| 327 | + | t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled) | |
| 328 | + | } | |
| 329 | + | if i.sessionTreeDialog.Active() { | |
| 330 | + | t.Fatal("session tree opened after the turn became busy") | |
| 331 | + | } | |
| 332 | + | if i.doubleEscape.armed { | |
| 333 | + | t.Fatal("stale double-Escape gesture remained armed after falling through") | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) { | |
| 338 | + | root := t.TempDir() | |
| 339 | + | cwd := "/workspace/double-escape-gate" | |
| 340 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 341 | + | if err != nil { | |
| 342 | + | t.Fatal(err) | |
| 343 | + | } | |
| 344 | + | message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}} | |
| 345 | + | if err := session.AppendMessage(message); err != nil { | |
| 346 | + | t.Fatal(err) | |
| 347 | + | } | |
| 348 | + | path := session.Path | |
| 349 | + | if err := session.Close(); err != nil { | |
| 350 | + | t.Fatal(err) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 354 | + | ag.SetMessages([]provider.Message{message}) | |
| 355 | + | now := time.Unix(400, 0) | |
| 356 | + | pathCalls := 0 | |
| 357 | + | flushes := 0 | |
| 358 | + | i := NewInteractive(InteractiveConfig{ | |
| 359 | + | Agent: ag, | |
| 360 | + | CWD: cwd, | |
| 361 | + | SessionsRoot: root, | |
| 362 | + | CurrentSessionPath: func() string { | |
| 363 | + | pathCalls++ | |
| 364 | + | if pathCalls == 1 { | |
| 365 | + | return path | |
| 366 | + | } | |
| 367 | + | // Keep the cheap gesture eligibility true while making the full | |
| 368 | + | // tree-family gate reject the second tap. | |
| 369 | + | return path + ".missing" | |
| 370 | + | }, | |
| 371 | + | LoadSession: func(string) error { return nil }, | |
| 372 | + | FlushSession: func() { | |
| 373 | + | flushes++ | |
| 374 | + | }, | |
| 375 | + | }) | |
| 376 | + | i.clock = func() time.Time { return now } | |
| 377 | + | ||
| 378 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 379 | + | t.Fatal("first Escape exited") | |
| 380 | + | } | |
| 381 | + | now = now.Add(100 * time.Millisecond) | |
| 382 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 383 | + | t.Fatal("second Escape exited") | |
| 384 | + | } | |
| 385 | + | if i.sessionTreeDialog.Active() { | |
| 386 | + | t.Fatal("fail-closed tree gate opened an overlay") | |
| 387 | + | } | |
| 388 | + | if i.statusErr != "tree: session tree is unavailable" { | |
| 389 | + | t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr) | |
| 390 | + | } | |
| 391 | + | if flushes != 0 { | |
| 392 | + | t.Fatalf("FlushSession called %d times after the rejected gate", flushes) | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestForkCommandsWithoutSessionPathReportError(t *testing.T) { | |
| 397 | + | for _, command := range []string{"/fork", "/session fork"} { | |
| 398 | + | i := NewInteractive(InteractiveConfig{}) | |
| 399 | + | i.runSlash(context.Background(), command) | |
| 400 | + | if i.statusErr != "fork: no session is active (running with --no-session?)" { | |
| 401 | + | t.Fatalf("%s status = %q", command, i.statusErr) | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | } | |
M packages/agent/modes/help.go +1 -1
| @@ -13,7 +13,7 @@ var helpKeyRows = [][2]string{ | |||
| 13 | 13 | {"enter", "submit the current input"}, | |
| 14 | 14 | {"shift+enter / alt+enter", "insert a newline"}, | |
| 15 | 15 | {"tab", "complete the highlighted slash command"}, | |
| 16 | - | {"esc", "cancel the current turn (while busy) - clear the input (while idle)"}, | |
| 16 | + | {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"}, | |
| 17 | 17 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | |
| 18 | 18 | {"ctrl+w", "delete previous word"}, | |
| 19 | 19 | {"alt+backspace", "delete previous word (same as ctrl+w)"}, | |
M packages/agent/modes/interactive.go +266 -102
| @@ -256,6 +256,12 @@ type InteractiveConfig struct { | |||
| 256 | 256 | // file that only has the meta row. | |
| 257 | 257 | FlushSession func() | |
| 258 | 258 | ||
| 259 | + | // SessionTransition serializes a state-changing session/model | |
| 260 | + | // operation with the host's session load. It is optional for embedders; | |
| 261 | + | // the CLI uses it to prevent a slow provider rebuild from being replaced | |
| 262 | + | // by a concurrent resume (or vice versa). | |
| 263 | + | SessionTransition func(func()) | |
| 264 | + | ||
| 259 | 265 | // PersistModel is called whenever the user switches model or provider. | |
| 260 | 266 | // It should update config.json and (if there's an active session) | |
| 261 | 267 | // write a new meta row so resume picks up the same model. | |
| @@ -433,6 +439,7 @@ type Interactive struct { | |||
| 433 | 439 | busy bool | |
| 434 | 440 | dirty chan struct{} | |
| 435 | 441 | modelRefresh chan modelRefreshResult | |
| 442 | + | modelRefreshing bool | |
| 436 | 443 | startupPreDone chan startupPreResult | |
| 437 | 444 | cancelTurn context.CancelFunc | |
| 438 | 445 | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| @@ -552,6 +559,11 @@ type Interactive struct { | |||
| 552 | 559 | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | |
| 553 | 560 | lastCtrlC time.Time | |
| 554 | 561 | ||
| 562 | + | // doubleEscape tracks the optional bare-Escape session-tree gesture. | |
| 563 | + | // clock is injectable so the gesture can be tested without sleeping. | |
| 564 | + | doubleEscape doubleEscapeTracker | |
| 565 | + | clock func() time.Time | |
| 566 | + | ||
| 555 | 567 | // welcomeStart is when the interactive run began. The welcome | |
| 556 | 568 | // banner shows the binary version for welcomeVersionDuration | |
| 557 | 569 | // after this point and reverts to plain text after. | |
| @@ -680,6 +692,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 680 | 692 | fileSuggest: newFileSuggester(), | |
| 681 | 693 | spin: newSpinner(cfg.Theme), | |
| 682 | 694 | inputHistoryIndex: -1, | |
| 695 | + | clock: time.Now, | |
| 683 | 696 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 684 | 697 | } | |
| 685 | 698 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| @@ -924,6 +937,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 924 | 937 | i.handleAuthEvent(ev) | |
| 925 | 938 | i.invalidate() | |
| 926 | 939 | case result := <-i.modelRefresh: | |
| 940 | + | i.mu.Lock() | |
| 941 | + | i.modelRefreshing = false | |
| 942 | + | i.mu.Unlock() | |
| 927 | 943 | i.openModelPickerAfterRefresh(result.err) | |
| 928 | 944 | i.invalidate() | |
| 929 | 945 | case result := <-i.startupPreDone: | |
| @@ -1332,6 +1348,14 @@ func (i *Interactive) redraw() { | |||
| 1332 | 1348 | case i.sessionOpsDialog.Active(): | |
| 1333 | 1349 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 1334 | 1350 | case i.sessionTreeDialog.Active(): | |
| 1351 | + | // Reserve rows for the editor, status, and tree chrome while using | |
| 1352 | + | // the same budget for rendering and PageUp/PageDown movement. | |
| 1353 | + | _, rows := i.cfg.Terminal.Size() | |
| 1354 | + | avail := rows - 12 | |
| 1355 | + | if avail < 3 { | |
| 1356 | + | avail = 3 | |
| 1357 | + | } | |
| 1358 | + | i.sessionTreeDialog.MaxRows = avail | |
| 1335 | 1359 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | |
| 1336 | 1360 | } | |
| 1337 | 1361 | if len(dialog) > 0 { | |
| @@ -2083,6 +2107,15 @@ func (i *Interactive) restoreConfirmFocus() { | |||
| 2083 | 2107 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 2084 | 2108 | defer i.restoreConfirmFocus() | |
| 2085 | 2109 | ||
| 2110 | + | // A bare Escape is eligible for the session-tree gesture only while the | |
| 2111 | + | // main input owns the key. Any other key, modified Escape, or visible | |
| 2112 | + | // child interaction resets the pending first tap before normal routing. | |
| 2113 | + | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |
| 2114 | + | i.mu.Lock() | |
| 2115 | + | i.doubleEscape.Reset() | |
| 2116 | + | i.mu.Unlock() | |
| 2117 | + | } | |
| 2118 | + | ||
| 2086 | 2119 | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 2087 | 2120 | // here when a child interaction owns input so every editor and filter sees | |
| 2088 | 2121 | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| @@ -2312,7 +2345,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2312 | 2345 | } | |
| 2313 | 2346 | act := i.sessionTreeDialog.HandleKey(k) | |
| 2314 | 2347 | if act.Select { | |
| 2315 | - | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) | |
| 2348 | + | if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" { | |
| 2349 | + | i.applySessionTreeTarget(act.Target, act.TurnNo) | |
| 2350 | + | } else { | |
| 2351 | + | // Keep package embedders that still return the scalar action | |
| 2352 | + | // source-compatible while the dialog migrates to Target. | |
| 2353 | + | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) | |
| 2354 | + | } | |
| 2316 | 2355 | } | |
| 2317 | 2356 | i.invalidate() | |
| 2318 | 2357 | return false | |
| @@ -2393,6 +2432,35 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2393 | 2432 | return false | |
| 2394 | 2433 | } | |
| 2395 | 2434 | ||
| 2435 | + | // The first eligible bare Escape is deliberately left to the existing | |
| 2436 | + | // Escape path (which clears idle input or cancels a turn). A second tap | |
| 2437 | + | // within the gesture window only gets first refusal while the cheap, | |
| 2438 | + | // local eligibility rules still hold. If a turn started, a message was | |
| 2439 | + | // queued, or the input became ineligible between taps, reset the detector | |
| 2440 | + | // and fall through so Escape retains its normal cancellation behavior. | |
| 2441 | + | // Once those rules still hold, the full tree gate owns the second tap: | |
| 2442 | + | // family/read failures are fail-closed but are nevertheless consumed so | |
| 2443 | + | // they cannot fall through into ordinary editor handling. | |
| 2444 | + | if isUnmodifiedEscape(k) { | |
| 2445 | + | now := i.sessionTreeEscapeNow() | |
| 2446 | + | i.mu.Lock() | |
| 2447 | + | consumed := i.doubleEscape.Consume(now) | |
| 2448 | + | i.mu.Unlock() | |
| 2449 | + | if consumed { | |
| 2450 | + | if i.canArmSessionTreeEscape() { | |
| 2451 | + | i.openSessionTree() | |
| 2452 | + | return false | |
| 2453 | + | } | |
| 2454 | + | i.mu.Lock() | |
| 2455 | + | i.doubleEscape.Reset() | |
| 2456 | + | i.mu.Unlock() | |
| 2457 | + | } else if i.canArmSessionTreeEscape() { | |
| 2458 | + | i.mu.Lock() | |
| 2459 | + | i.doubleEscape.Arm(now) | |
| 2460 | + | i.mu.Unlock() | |
| 2461 | + | } | |
| 2462 | + | } | |
| 2463 | + | ||
| 2396 | 2464 | // Global keys. | |
| 2397 | 2465 | switch k.Kind { | |
| 2398 | 2466 | case tui.KeyCtrlC: | |
| @@ -2699,18 +2767,27 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2699 | 2767 | if text == "" && len(images) == 0 { | |
| 2700 | 2768 | return false | |
| 2701 | 2769 | } | |
| 2702 | - | i.clipboardImages = nil | |
| 2703 | - | i.ed.Clear() | |
| 2704 | - | i.inputHistoryIndex = -1 | |
| 2705 | - | i.suggest.Reset() | |
| 2706 | - | i.fileSuggest.Reset() | |
| 2770 | + | clearSubmittedInput := func() { | |
| 2771 | + | i.clipboardImages = nil | |
| 2772 | + | i.ed.Clear() | |
| 2773 | + | i.inputHistoryIndex = -1 | |
| 2774 | + | i.suggest.Reset() | |
| 2775 | + | i.fileSuggest.Reset() | |
| 2776 | + | } | |
| 2707 | 2777 | ||
| 2708 | - | if cmd, ok := shellEscapeCommand(text); ok { | |
| 2709 | - | i.startShellEscape(ctx, cmd) | |
| 2710 | - | return false | |
| 2778 | + | // Shell escapes and slash commands are text-only. An image-bearing | |
| 2779 | + | // draft remains a normal provider prompt instead of silently dropping | |
| 2780 | + | // its attachments. | |
| 2781 | + | if len(images) == 0 { | |
| 2782 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 2783 | + | clearSubmittedInput() | |
| 2784 | + | i.startShellEscape(ctx, cmd) | |
| 2785 | + | return false | |
| 2786 | + | } | |
| 2711 | 2787 | } | |
| 2712 | 2788 | ||
| 2713 | - | if looksLikeSlashCommand(text) { | |
| 2789 | + | if len(images) == 0 && looksLikeSlashCommand(text) { | |
| 2790 | + | clearSubmittedInput() | |
| 2714 | 2791 | head := text | |
| 2715 | 2792 | rest := "" | |
| 2716 | 2793 | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| @@ -2733,12 +2810,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2733 | 2810 | } | |
| 2734 | 2811 | // Slash commands run regardless of busy state. Commands that | |
| 2735 | 2812 | // would mutate the transcript or replace the agent (/clear, | |
| 2736 | - | // /compact, /logout, /login, /model) cancel the active turn | |
| 2813 | + | // /compact, /logout, /login, /model, /fork, /session fork) | |
| 2814 | + | // cancel the active turn | |
| 2737 | 2815 | // first and wait for the goroutine to wind down so they don't | |
| 2738 | 2816 | // race with a streaming response. Safe commands (/help, | |
| 2739 | 2817 | // /jump, /sessions, /jail, /unjail, /exit) run immediately | |
| 2740 | 2818 | // without disturbing the active turn. | |
| 2741 | - | if slashCancelsTurn(head) { | |
| 2819 | + | if slashCommandCancelsTurn(text) { | |
| 2742 | 2820 | i.cancelAndWaitForIdle() | |
| 2743 | 2821 | } | |
| 2744 | 2822 | return i.runSlash(ctx, text) | |
| @@ -2774,6 +2852,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2774 | 2852 | i.invalidate() | |
| 2775 | 2853 | return false | |
| 2776 | 2854 | } | |
| 2855 | + | clearSubmittedInput() | |
| 2777 | 2856 | if ag != nil && !compacting { | |
| 2778 | 2857 | ag.QueueMessage(text) | |
| 2779 | 2858 | } else { | |
| @@ -2784,6 +2863,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2784 | 2863 | i.invalidate() | |
| 2785 | 2864 | return false | |
| 2786 | 2865 | } | |
| 2866 | + | clearSubmittedInput() | |
| 2787 | 2867 | i.startTurnWithImages(ctx, text, images) | |
| 2788 | 2868 | } | |
| 2789 | 2869 | return false | |
| @@ -3107,6 +3187,38 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |||
| 3107 | 3187 | } | |
| 3108 | 3188 | } | |
| 3109 | 3189 | ||
| 3190 | + | // ApplySessionAgent swaps the live agent after a validated session resume. | |
| 3191 | + | // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup | |
| 3192 | + | // context; the session loader is changing the transcript/provider only. | |
| 3193 | + | func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) { | |
| 3194 | + | if ag == nil { | |
| 3195 | + | return | |
| 3196 | + | } | |
| 3197 | + | i.mu.Lock() | |
| 3198 | + | i.agent = ag | |
| 3199 | + | i.cfg.Provider = providerName | |
| 3200 | + | i.cfg.Model = model | |
| 3201 | + | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | |
| 3202 | + | i.cumUsage = ag.Cost() | |
| 3203 | + | last := ag.LastTurnUsage() | |
| 3204 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 3205 | + | if len(i.view.Messages) > initialResumeTailLimit { | |
| 3206 | + | i.view.TailLimit = initialResumeTailLimit | |
| 3207 | + | } else { | |
| 3208 | + | i.view.TailLimit = 0 | |
| 3209 | + | } | |
| 3210 | + | i.view.InvalidateRenderCache() | |
| 3211 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 3212 | + | i.toolOrder = nil | |
| 3213 | + | i.toolGate = map[string]int{} | |
| 3214 | + | i.helpBlock = nil | |
| 3215 | + | i.extNotes = nil | |
| 3216 | + | i.parkedTurn = 0 | |
| 3217 | + | i.parkedTotal = 0 | |
| 3218 | + | i.mu.Unlock() | |
| 3219 | + | i.invalidate() | |
| 3220 | + | } | |
| 3221 | + | ||
| 3110 | 3222 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 3111 | 3223 | // not provide startup context metadata. | |
| 3112 | 3224 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| @@ -3167,15 +3279,14 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3167 | 3279 | // agent cancel the active turn first via the same path the editor | |
| 3168 | 3280 | // uses for typed slash commands. | |
| 3169 | 3281 | func (i *Interactive) SubmitSlash(text string) { | |
| 3282 | + | i.mu.Lock() | |
| 3283 | + | i.doubleEscape.Reset() | |
| 3284 | + | i.mu.Unlock() | |
| 3170 | 3285 | text = strings.TrimSpace(text) | |
| 3171 | 3286 | if !strings.HasPrefix(text, "/") { | |
| 3172 | 3287 | return | |
| 3173 | 3288 | } | |
| 3174 | - | head := text | |
| 3175 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 3176 | - | head = text[:idx] | |
| 3177 | - | } | |
| 3178 | - | if slashCancelsTurn(head) { | |
| 3289 | + | if slashCommandCancelsTurn(text) { | |
| 3179 | 3290 | i.cancelAndWaitForIdle() | |
| 3180 | 3291 | } | |
| 3181 | 3292 | i.runSlash(i.runCtx, text) | |
| @@ -4519,6 +4630,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4519 | 4630 | i.applyModelSelection("", parts[1]) | |
| 4520 | 4631 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 4521 | 4632 | i.mu.Lock() | |
| 4633 | + | i.modelRefreshing = true | |
| 4522 | 4634 | i.statusOK = "refreshing models" | |
| 4523 | 4635 | i.statusErr = "" | |
| 4524 | 4636 | i.mu.Unlock() | |
| @@ -4554,6 +4666,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4554 | 4666 | i.openSettingsDialog() | |
| 4555 | 4667 | case "/sessions": | |
| 4556 | 4668 | i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD) | |
| 4669 | + | case "/fork": | |
| 4670 | + | i.doSessionFork() | |
| 4557 | 4671 | case "/jump": | |
| 4558 | 4672 | i.openJumpDialog(parts[1:]) | |
| 4559 | 4673 | case "/btw": | |
| @@ -5393,6 +5507,16 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) { | |||
| 5393 | 5507 | // the supplied builder. rescue=true tags the success message so the | |
| 5394 | 5508 | // user can see that launch-time overrides were ignored. | |
| 5395 | 5509 | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | |
| 5510 | + | if i.cfg.SessionTransition != nil { | |
| 5511 | + | i.cfg.SessionTransition(func() { | |
| 5512 | + | i.swapModelUnserialized(prov, model, builder, rescue) | |
| 5513 | + | }) | |
| 5514 | + | return | |
| 5515 | + | } | |
| 5516 | + | i.swapModelUnserialized(prov, model, builder, rescue) | |
| 5517 | + | } | |
| 5518 | + | ||
| 5519 | + | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | |
| 5396 | 5520 | if model == "" { | |
| 5397 | 5521 | return | |
| 5398 | 5522 | } | |
| @@ -5495,15 +5619,27 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 5495 | 5619 | i.dialog.ShowResult(false, err.Error()) | |
| 5496 | 5620 | return | |
| 5497 | 5621 | } | |
| 5498 | - | i.mu.Lock() | |
| 5499 | - | i.agent = ag | |
| 5500 | - | i.cfg.Provider = prov | |
| 5501 | - | i.cfg.Model = model | |
| 5502 | - | i.statusErr = "" | |
| 5503 | - | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 5504 | - | i.mu.Unlock() | |
| 5505 | - | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 5506 | - | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 5622 | + | commitLogin := func() { | |
| 5623 | + | i.mu.Lock() | |
| 5624 | + | i.agent = ag | |
| 5625 | + | i.cfg.Provider = prov | |
| 5626 | + | i.cfg.Model = model | |
| 5627 | + | i.statusErr = "" | |
| 5628 | + | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 5629 | + | i.mu.Unlock() | |
| 5630 | + | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 5631 | + | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 5632 | + | // Authentication can change the provider used by the live | |
| 5633 | + | // agent. Persist it on the active session just like /model. | |
| 5634 | + | if i.cfg.PersistModel != nil { | |
| 5635 | + | i.cfg.PersistModel(prov, model) | |
| 5636 | + | } | |
| 5637 | + | } | |
| 5638 | + | if i.cfg.SessionTransition != nil { | |
| 5639 | + | i.cfg.SessionTransition(commitLogin) | |
| 5640 | + | } else { | |
| 5641 | + | commitLogin() | |
| 5642 | + | } | |
| 5507 | 5643 | i.dialog.ShowResult(true, "") | |
| 5508 | 5644 | } | |
| 5509 | 5645 | } | |
| @@ -6942,14 +7078,7 @@ func (i *Interactive) doSessionOp(action, arg string) { | |||
| 6942 | 7078 | // the user's home directory if it doesn't exist). The helper | |
| 6943 | 7079 | // expands a leading `~` and creates any missing parent directories. | |
| 6944 | 7080 | func (i *Interactive) doSessionExport(dst string) { | |
| 6945 | - | if i.cfg.CurrentSessionPath == nil { | |
| 6946 | - | i.mu.Lock() | |
| 6947 | - | i.statusErr = "export: no session is active (running with --no-session?)" | |
| 6948 | - | i.mu.Unlock() | |
| 6949 | - | i.invalidate() | |
| 6950 | - | return | |
| 6951 | - | } | |
| 6952 | - | src := i.cfg.CurrentSessionPath() | |
| 7081 | + | src := i.currentSessionPath() | |
| 6953 | 7082 | if src == "" { | |
| 6954 | 7083 | i.mu.Lock() | |
| 6955 | 7084 | i.statusErr = "export: no session is active (running with --no-session?)" | |
| @@ -7104,114 +7233,156 @@ func friendlyPath(p string) string { | |||
| 7104 | 7233 | // next selection branches the current session at that user turn | |
| 7105 | 7234 | // instead of scrolling the viewport to it. | |
| 7106 | 7235 | func (i *Interactive) doSessionFork() { | |
| 7107 | - | if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" { | |
| 7236 | + | path := i.currentSessionPath() | |
| 7237 | + | if path == "" { | |
| 7108 | 7238 | i.mu.Lock() | |
| 7109 | 7239 | i.statusErr = "fork: no session is active (running with --no-session?)" | |
| 7110 | 7240 | i.mu.Unlock() | |
| 7111 | 7241 | i.invalidate() | |
| 7112 | 7242 | return | |
| 7113 | 7243 | } | |
| 7114 | - | msgs := []provider.Message{} | |
| 7115 | - | if i.agent != nil { | |
| 7116 | - | msgs = i.agent.Messages() | |
| 7117 | - | } | |
| 7118 | - | if len(msgs) == 0 { | |
| 7244 | + | i.mu.Lock() | |
| 7245 | + | busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading | |
| 7246 | + | queued := len(i.queued) != 0 | |
| 7247 | + | ag := i.agent | |
| 7248 | + | i.mu.Unlock() | |
| 7249 | + | if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 { | |
| 7119 | 7250 | i.mu.Lock() | |
| 7120 | - | i.statusErr = "fork: transcript is empty; nothing to fork from" | |
| 7251 | + | i.statusErr = "fork: wait until the current turn and queued messages finish" | |
| 7121 | 7252 | i.mu.Unlock() | |
| 7122 | 7253 | i.invalidate() | |
| 7123 | 7254 | return | |
| 7124 | 7255 | } | |
| 7125 | - | i.pendingFork = true | |
| 7126 | - | i.jumpDialog.Open(msgs, "") | |
| 7127 | - | i.invalidate() | |
| 7128 | - | } | |
| 7129 | - | ||
| 7130 | - | // doSessionTree shows the current session family as an inline branch tree. | |
| 7131 | - | // Pick an entry to check out that point into a new branch. Subsequent | |
| 7132 | - | // prompts use only context up to the selected message. | |
| 7133 | - | func (i *Interactive) doSessionTree() { | |
| 7134 | - | current := "" | |
| 7135 | - | if i.cfg.CurrentSessionPath != nil { | |
| 7136 | - | current = i.cfg.CurrentSessionPath() | |
| 7256 | + | if i.cfg.FlushSession != nil { | |
| 7257 | + | i.cfg.FlushSession() | |
| 7137 | 7258 | } | |
| 7138 | - | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) { | |
| 7259 | + | snapshot, err := core.ReadSessionSnapshot(path) | |
| 7260 | + | if err != nil { | |
| 7261 | + | i.mu.Lock() | |
| 7262 | + | i.statusErr = "fork: " + err.Error() | |
| 7263 | + | i.mu.Unlock() | |
| 7139 | 7264 | i.invalidate() | |
| 7140 | 7265 | return | |
| 7141 | 7266 | } | |
| 7142 | - | msgs := []provider.Message{} | |
| 7143 | - | if i.agent != nil { | |
| 7144 | - | msgs = i.agent.Messages() | |
| 7145 | - | } | |
| 7267 | + | msgs := snapshot.Messages | |
| 7146 | 7268 | if len(msgs) == 0 { | |
| 7147 | 7269 | i.mu.Lock() | |
| 7148 | - | i.statusErr = "tree: no messages in this session yet" | |
| 7270 | + | i.statusErr = "fork: transcript is empty; nothing to fork from" | |
| 7149 | 7271 | i.mu.Unlock() | |
| 7150 | 7272 | i.invalidate() | |
| 7151 | 7273 | return | |
| 7152 | 7274 | } | |
| 7153 | - | if !i.sessionTreeDialog.OpenMessages(msgs) { | |
| 7154 | - | i.mu.Lock() | |
| 7155 | - | i.statusErr = "tree: no messages to show" | |
| 7156 | - | i.mu.Unlock() | |
| 7157 | - | } | |
| 7275 | + | i.pendingFork = true | |
| 7276 | + | i.jumpDialog.Open(msgs, "") | |
| 7158 | 7277 | i.invalidate() | |
| 7159 | 7278 | } | |
| 7160 | 7279 | ||
| 7161 | - | // applySessionTreeMessageSelection checks out the selected message into a | |
| 7162 | - | // new branch, then switches the running agent to that branch. Unlike /jump, | |
| 7163 | - | // this changes future model context: only messages up to the selected row are | |
| 7164 | - | // sent on subsequent turns. | |
| 7280 | + | // doSessionTree opens the current session family after the shared | |
| 7281 | + | // fail-closed gate succeeds. It intentionally does not fall back to the | |
| 7282 | + | // in-memory transcript: a tree that cannot be persisted and reloaded cannot | |
| 7283 | + | // safely create a navigation branch. | |
| 7284 | + | func (i *Interactive) doSessionTree() { | |
| 7285 | + | i.openSessionTree() | |
| 7286 | + | } | |
| 7287 | + | ||
| 7288 | + | // applySessionTreeMessageSelection keeps the old scalar integration | |
| 7289 | + | // source-compatible for package consumers. New dialog selections use the | |
| 7290 | + | // structured sessionTreeTarget path below. | |
| 7165 | 7291 | func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) { | |
| 7166 | - | if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |
| 7167 | - | i.mu.Lock() | |
| 7168 | - | i.statusErr = "tree: session branching is not available in this build" | |
| 7169 | - | i.mu.Unlock() | |
| 7170 | - | i.invalidate() | |
| 7292 | + | target := sessionTreeTarget{ | |
| 7293 | + | SourcePath: src, | |
| 7294 | + | EffectiveIndex: msgIdx, | |
| 7295 | + | SelectionBoundary: msgIdx + 1, | |
| 7296 | + | Role: role, | |
| 7297 | + | UserDraft: prompt, | |
| 7298 | + | Boundary: sessionTreeMessageBoundary, | |
| 7299 | + | } | |
| 7300 | + | if role == provider.RoleUser { | |
| 7301 | + | target.SelectionBoundary = msgIdx | |
| 7302 | + | } | |
| 7303 | + | i.applySessionTreeTarget(target, turnNo) | |
| 7304 | + | } | |
| 7305 | + | ||
| 7306 | + | // applySessionTreeTarget checks out a structured dialog target. The target's | |
| 7307 | + | // source and effective indices come from the same preflight snapshot the | |
| 7308 | + | // dialog rendered, while the live read below preserves compatibility with | |
| 7309 | + | // the existing LoadSession(path) error callback. | |
| 7310 | + | func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) { | |
| 7311 | + | // Selection is a state-changing operation too. Use the selection gate so | |
| 7312 | + | // a queued/busy turn cannot race the hidden branch or accidentally submit | |
| 7313 | + | // a provider turn while the session is being swapped. The open gate cannot | |
| 7314 | + | // be reused here because the tree dialog itself owns the keyboard. | |
| 7315 | + | if !i.canCommitSessionTreeSelection() { | |
| 7316 | + | i.setSessionTreeError("tree: session branching is not available in this build") | |
| 7171 | 7317 | return | |
| 7172 | 7318 | } | |
| 7319 | + | src := target.SourcePath | |
| 7173 | 7320 | if src == "" { | |
| 7174 | - | src = i.cfg.CurrentSessionPath() | |
| 7321 | + | src = i.currentSessionPath() | |
| 7175 | 7322 | } | |
| 7176 | 7323 | if src == "" { | |
| 7177 | - | i.mu.Lock() | |
| 7178 | - | i.statusErr = "tree: no session is active" | |
| 7179 | - | i.mu.Unlock() | |
| 7180 | - | i.invalidate() | |
| 7324 | + | i.setSessionTreeError("tree: no session is active") | |
| 7325 | + | return | |
| 7326 | + | } | |
| 7327 | + | ||
| 7328 | + | // Read and validate the selected row before flushing or creating anything. | |
| 7329 | + | // This gives tool-call/result pairs an atomic boundary and prevents a | |
| 7330 | + | // malformed selection from changing the active session. | |
| 7331 | + | sess, msgs, err := core.OpenSession(src) | |
| 7332 | + | if err != nil { | |
| 7333 | + | i.setSessionTreeError("tree: read selection: " + err.Error()) | |
| 7334 | + | return | |
| 7335 | + | } | |
| 7336 | + | _ = sess.Close() | |
| 7337 | + | selection, err := sessionTreeSelection(msgs, target) | |
| 7338 | + | if err != nil { | |
| 7339 | + | i.setSessionTreeError("tree: " + err.Error()) | |
| 7181 | 7340 | return | |
| 7182 | 7341 | } | |
| 7183 | 7342 | if i.cfg.FlushSession != nil { | |
| 7184 | 7343 | i.cfg.FlushSession() | |
| 7185 | 7344 | } | |
| 7186 | - | upTo := msgIdx + 1 | |
| 7187 | - | if role == provider.RoleUser { | |
| 7188 | - | upTo = msgIdx | |
| 7189 | - | } | |
| 7190 | - | newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) | |
| 7345 | + | newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo) | |
| 7191 | 7346 | if err != nil { | |
| 7192 | - | i.mu.Lock() | |
| 7193 | - | i.statusErr = "tree: " + err.Error() | |
| 7194 | - | i.mu.Unlock() | |
| 7195 | - | i.invalidate() | |
| 7347 | + | i.setSessionTreeError("tree: " + err.Error()) | |
| 7196 | 7348 | return | |
| 7197 | 7349 | } | |
| 7198 | 7350 | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 7199 | - | i.mu.Lock() | |
| 7200 | - | i.statusErr = "tree: checkout failed: " + err.Error() | |
| 7201 | - | i.mu.Unlock() | |
| 7202 | - | i.invalidate() | |
| 7351 | + | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | |
| 7203 | 7352 | return | |
| 7204 | 7353 | } | |
| 7205 | - | i.scrollToBottom() | |
| 7354 | + | ||
| 7355 | + | // LoadSession is intentionally the old func(string) error callback: the | |
| 7356 | + | // CLI and embedders already own agent/session swapping. Refresh the view | |
| 7357 | + | // from the swapped agent here instead of requiring a new callback result. | |
| 7206 | 7358 | i.mu.Lock() | |
| 7207 | - | if role == provider.RoleUser { | |
| 7208 | - | i.ed.SetValue(prompt) | |
| 7359 | + | i.lastCtxInput = 0 | |
| 7360 | + | if i.agent != nil { | |
| 7361 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 7362 | + | i.cumUsage = i.agent.Cost() | |
| 7363 | + | last := i.agent.LastTurnUsage() | |
| 7364 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 7365 | + | i.view.InvalidateRenderCache() | |
| 7366 | + | } | |
| 7367 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 7368 | + | i.toolOrder = nil | |
| 7369 | + | i.toolGate = map[string]int{} | |
| 7370 | + | i.extNotes = nil | |
| 7371 | + | if selection.restoreDraft { | |
| 7372 | + | i.ed.SetValue(selection.draftText) | |
| 7373 | + | i.clipboardImages = selection.images | |
| 7209 | 7374 | i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo) | |
| 7210 | 7375 | } else { | |
| 7376 | + | i.ed.Clear() | |
| 7377 | + | i.clipboardImages = nil | |
| 7211 | 7378 | i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo) | |
| 7212 | 7379 | } | |
| 7213 | 7380 | i.statusErr = "" | |
| 7214 | 7381 | i.mu.Unlock() | |
| 7382 | + | i.scrollToBottom() | |
| 7383 | + | // Selection only changes local/session state. In particular, do not call | |
| 7384 | + | // Submit, startTurn, or queue a message here; the restored user draft is | |
| 7385 | + | // submitted only by a later explicit Enter. | |
| 7215 | 7386 | i.invalidate() | |
| 7216 | 7387 | } | |
| 7217 | 7388 | ||
| @@ -7221,14 +7392,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN | |||
| 7221 | 7392 | // file. Called from the jump-dialog handler when pendingFork=true. | |
| 7222 | 7393 | func (i *Interactive) applyForkSelection(msgIdx int) { | |
| 7223 | 7394 | i.pendingFork = false | |
| 7224 | - | if i.cfg.CurrentSessionPath == nil { | |
| 7225 | - | i.mu.Lock() | |
| 7226 | - | i.statusErr = "fork: no session is active" | |
| 7227 | - | i.mu.Unlock() | |
| 7228 | - | i.invalidate() | |
| 7229 | - | return | |
| 7230 | - | } | |
| 7231 | - | src := i.cfg.CurrentSessionPath() | |
| 7395 | + | src := i.currentSessionPath() | |
| 7232 | 7396 | if src == "" { | |
| 7233 | 7397 | i.mu.Lock() | |
| 7234 | 7398 | i.statusErr = "fork: no session is active" | |
M packages/agent/modes/session_tree_dialog.go +604 -106
| @@ -2,13 +2,66 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "reflect" | |
| 5 | 6 | "strings" | |
| 7 | + | "unicode" | |
| 8 | + | ||
| 9 | + | "github.com/mattn/go-runewidth" | |
| 6 | 10 | ||
| 7 | 11 | "github.com/patriceckhart/zot/packages/core" | |
| 8 | 12 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 13 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 14 | ) | |
| 11 | 15 | ||
| 16 | + | // sessionTreeBoundary describes what a tree row points at. Message rows are | |
| 17 | + | // normal transcript rows. Empty and detached rows are real, selectable | |
| 18 | + | // boundaries even though they do not correspond to a message. | |
| 19 | + | type sessionTreeBoundary uint8 | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | sessionTreeMessageBoundary sessionTreeBoundary = iota | |
| 23 | + | sessionTreeEmptyBoundary | |
| 24 | + | sessionTreeDetachedBoundary | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | func (b sessionTreeBoundary) String() string { | |
| 28 | + | switch b { | |
| 29 | + | case sessionTreeEmptyBoundary: | |
| 30 | + | return "empty" | |
| 31 | + | case sessionTreeDetachedBoundary: | |
| 32 | + | return "detached" | |
| 33 | + | default: | |
| 34 | + | return "message" | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // sessionTreeTarget is the stable description of a row in the dialog. | |
| 39 | + | // EffectiveIndex is an index into SourcePath's effective transcript (the | |
| 40 | + | // snapshot returned by core.OpenSession), while SelectionBoundary is the | |
| 41 | + | // message count to pass to core.BranchSessionHidden. Keeping both values is | |
| 42 | + | // important for a user row: selecting it branches before that row so the | |
| 43 | + | // complete draft can be put back in the editor. | |
| 44 | + | type sessionTreeTarget struct { | |
| 45 | + | SourcePath string | |
| 46 | + | EffectiveIndex int | |
| 47 | + | SelectionBoundary int | |
| 48 | + | Role provider.Role | |
| 49 | + | UserDraft string | |
| 50 | + | Boundary sessionTreeBoundary | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (t sessionTreeTarget) IsBoundary() bool { | |
| 54 | + | return t.Boundary != sessionTreeMessageBoundary | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (t sessionTreeTarget) IsEmptyBoundary() bool { | |
| 58 | + | return t.Boundary == sessionTreeEmptyBoundary | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (t sessionTreeTarget) IsDetachedBoundary() bool { | |
| 62 | + | return t.Boundary == sessionTreeDetachedBoundary | |
| 63 | + | } | |
| 64 | + | ||
| 12 | 65 | // sessionTreeDialog renders a compact outline of the current session family. | |
| 13 | 66 | // Branches are shown inline at the point where they forked, using indentation | |
| 14 | 67 | // rather than file-level rows. Selecting a node checks out that point into a | |
| @@ -17,54 +70,108 @@ type sessionTreeDialog struct { | |||
| 17 | 70 | active bool | |
| 18 | 71 | items []sessionTreeItem | |
| 19 | 72 | cursor int | |
| 73 | + | ||
| 74 | + | // MaxRows is the maximum number of transcript/boundary rows rendered in | |
| 75 | + | // one viewport. The interactive host may set it from terminal height. A | |
| 76 | + | // bounded default is deliberately used when it is left at zero so a large | |
| 77 | + | // family cannot consume the whole chat pane. | |
| 78 | + | MaxRows int | |
| 79 | + | viewTop int | |
| 20 | 80 | } | |
| 21 | 81 | ||
| 22 | 82 | type sessionTreeItem struct { | |
| 23 | 83 | label string | |
| 24 | - | messageIdx int | |
| 84 | + | messageIdx int // compatibility alias for target.EffectiveIndex | |
| 25 | 85 | turnNo int | |
| 26 | 86 | role provider.Role | |
| 27 | - | prompt string | |
| 28 | - | path string | |
| 87 | + | prompt string // compatibility alias for target.UserDraft | |
| 88 | + | path string // compatibility alias for target.SourcePath | |
| 29 | 89 | depth int | |
| 30 | 90 | current bool | |
| 91 | + | target sessionTreeTarget | |
| 31 | 92 | } | |
| 32 | 93 | ||
| 94 | + | // sessionTreeAction is returned by HandleKey. Target is the structured API | |
| 95 | + | // for the integration layer. The scalar fields are retained for the existing | |
| 96 | + | // interactive.go integration and mirror a message target where possible. | |
| 33 | 97 | type sessionTreeAction struct { | |
| 34 | - | Select bool | |
| 98 | + | Select bool | |
| 99 | + | Target sessionTreeTarget | |
| 100 | + | ||
| 101 | + | // Legacy/package integration fields. New callers should use Target, | |
| 102 | + | // especially Target.SelectionBoundary for empty or detached rows. | |
| 35 | 103 | MessageIdx int | |
| 36 | 104 | TurnNo int | |
| 37 | 105 | Role provider.Role | |
| 38 | 106 | Prompt string | |
| 39 | 107 | Path string | |
| 108 | + | Boundary sessionTreeBoundary | |
| 40 | 109 | Close bool | |
| 41 | 110 | } | |
| 42 | 111 | ||
| 112 | + | type sessionTreeSnapshot struct { | |
| 113 | + | path string | |
| 114 | + | messages []provider.Message | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | const defaultSessionTreeRows = 12 | |
| 118 | + | ||
| 43 | 119 | func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} } | |
| 44 | 120 | ||
| 121 | + | // OpenMessages opens a snapshot supplied by the running agent. This is the | |
| 122 | + | // fallback used before a session has a persisted source path. | |
| 45 | 123 | func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool { | |
| 46 | - | d.items = buildSessionTreeItems("", msgs, 0, false) | |
| 47 | - | d.cursor = len(d.items) - 1 | |
| 48 | - | if d.cursor < 0 { | |
| 49 | - | d.cursor = 0 | |
| 124 | + | items := buildSessionTreeItems("", msgs, 0, false) | |
| 125 | + | if len(items) == 0 { | |
| 126 | + | return false | |
| 50 | 127 | } | |
| 51 | - | d.active = true | |
| 52 | - | return len(d.items) > 0 | |
| 128 | + | d.activate(items, len(items)-1) | |
| 129 | + | return true | |
| 53 | 130 | } | |
| 54 | 131 | ||
| 132 | + | // OpenSessionFamily preflights the complete family containing currentPath, | |
| 133 | + | // then activates the dialog in one step. A missing current path is not | |
| 134 | + | // recoverable by choosing an arbitrary forest root. Failed reads leave the | |
| 135 | + | // prior dialog state untouched. | |
| 55 | 136 | func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool { | |
| 56 | - | roots := core.BuildSessionTree(root, cwd) | |
| 57 | - | if len(roots) == 0 { | |
| 137 | + | roots, err := core.BuildSessionTreeFamilyStrict(root, cwd, currentPath) | |
| 138 | + | if err != nil || len(roots) == 0 { | |
| 58 | 139 | return false | |
| 59 | 140 | } | |
| 60 | - | rootNode := findTreeRootForPath(roots, currentPath) | |
| 61 | - | if rootNode == nil { | |
| 62 | - | rootNode = roots[0] | |
| 141 | + | familyRoot := roots[0] | |
| 142 | + | ||
| 143 | + | snapshots, err := preflightSessionTreeFamily(familyRoot) | |
| 144 | + | if err != nil { | |
| 145 | + | return false | |
| 146 | + | } | |
| 147 | + | items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots) | |
| 148 | + | if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) { | |
| 149 | + | return false | |
| 63 | 150 | } | |
| 64 | - | d.items = flattenSessionFamily(rootNode, currentPath) | |
| 65 | - | d.cursor = indexCurrentTreeItem(d.items) | |
| 151 | + | d.activate(items, indexCurrentTreeItem(items)) | |
| 152 | + | return true | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool { | |
| 156 | + | for _, item := range items { | |
| 157 | + | if !item.target.IsBoundary() || item.path != rootPath { | |
| 158 | + | return true | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return false | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) { | |
| 165 | + | if cursor < 0 { | |
| 166 | + | cursor = 0 | |
| 167 | + | } | |
| 168 | + | if cursor >= len(items) { | |
| 169 | + | cursor = len(items) - 1 | |
| 170 | + | } | |
| 171 | + | d.items = items | |
| 172 | + | d.cursor = cursor | |
| 173 | + | d.viewTop = 0 | |
| 66 | 174 | d.active = true | |
| 67 | - | return len(d.items) > 0 | |
| 68 | 175 | } | |
| 69 | 176 | ||
| 70 | 177 | // Close hides the dialog. | |
| @@ -76,30 +183,60 @@ func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 } | |||
| 76 | 183 | // Active reports whether the dialog consumes input. | |
| 77 | 184 | func (d *sessionTreeDialog) Active() bool { return d != nil && d.active } | |
| 78 | 185 | ||
| 186 | + | func (d *sessionTreeDialog) pageRows() int { | |
| 187 | + | if d.MaxRows > 0 { | |
| 188 | + | return d.MaxRows | |
| 189 | + | } | |
| 190 | + | return defaultSessionTreeRows | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (d *sessionTreeDialog) followCursor() { | |
| 194 | + | d.viewTop = clampSessionTreeViewTop(d.viewTop, d.cursor, d.pageRows(), len(d.items)) | |
| 195 | + | } | |
| 196 | + | ||
| 79 | 197 | // HandleKey advances the cursor or resolves the selection. | |
| 80 | 198 | func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |
| 199 | + | if d == nil { | |
| 200 | + | return sessionTreeAction{} | |
| 201 | + | } | |
| 202 | + | page := d.pageRows() | |
| 203 | + | if page < 1 { | |
| 204 | + | page = 1 | |
| 205 | + | } | |
| 81 | 206 | switch k.Kind { | |
| 82 | 207 | case tui.KeyUp: | |
| 83 | 208 | if d.cursor > 0 { | |
| 84 | 209 | d.cursor-- | |
| 85 | 210 | } | |
| 211 | + | d.followCursor() | |
| 86 | 212 | case tui.KeyDown: | |
| 87 | 213 | if d.cursor < len(d.items)-1 { | |
| 88 | 214 | d.cursor++ | |
| 89 | 215 | } | |
| 216 | + | d.followCursor() | |
| 90 | 217 | case tui.KeyPageUp: | |
| 91 | - | d.cursor -= 5 | |
| 218 | + | d.cursor -= page | |
| 92 | 219 | if d.cursor < 0 { | |
| 93 | 220 | d.cursor = 0 | |
| 94 | 221 | } | |
| 222 | + | d.followCursor() | |
| 95 | 223 | case tui.KeyPageDown: | |
| 96 | - | d.cursor += 5 | |
| 224 | + | d.cursor += page | |
| 97 | 225 | if d.cursor >= len(d.items) { | |
| 98 | 226 | d.cursor = len(d.items) - 1 | |
| 99 | 227 | } | |
| 100 | 228 | if d.cursor < 0 { | |
| 101 | 229 | d.cursor = 0 | |
| 102 | 230 | } | |
| 231 | + | d.followCursor() | |
| 232 | + | case tui.KeyHome: | |
| 233 | + | d.cursor = 0 | |
| 234 | + | d.followCursor() | |
| 235 | + | case tui.KeyEnd: | |
| 236 | + | if len(d.items) > 0 { | |
| 237 | + | d.cursor = len(d.items) - 1 | |
| 238 | + | } | |
| 239 | + | d.followCursor() | |
| 103 | 240 | case tui.KeyEsc: | |
| 104 | 241 | d.Close() | |
| 105 | 242 | return sessionTreeAction{Close: true} | |
| @@ -110,148 +247,395 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |||
| 110 | 247 | } | |
| 111 | 248 | it := d.items[d.cursor] | |
| 112 | 249 | d.Close() | |
| 113 | - | return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path} | |
| 250 | + | return sessionTreeActionForTarget(it.target, it.turnNo) | |
| 114 | 251 | } | |
| 115 | 252 | return sessionTreeAction{} | |
| 116 | 253 | } | |
| 117 | 254 | ||
| 118 | - | // Render returns the dialog lines. | |
| 255 | + | func sessionTreeActionForTarget(target sessionTreeTarget, turnNo int) sessionTreeAction { | |
| 256 | + | act := sessionTreeAction{ | |
| 257 | + | Select: true, | |
| 258 | + | Target: target, | |
| 259 | + | TurnNo: turnNo, | |
| 260 | + | Boundary: target.Boundary, | |
| 261 | + | Path: target.SourcePath, | |
| 262 | + | Role: target.Role, | |
| 263 | + | Prompt: target.UserDraft, | |
| 264 | + | // MessageIdx is the effective index for ordinary rows. For a | |
| 265 | + | // boundary, use the old integration's "last included row" form | |
| 266 | + | // so msgIdx+1 still equals the safe selection boundary. | |
| 267 | + | MessageIdx: target.EffectiveIndex, | |
| 268 | + | } | |
| 269 | + | if target.IsBoundary() { | |
| 270 | + | act.MessageIdx = target.SelectionBoundary - 1 | |
| 271 | + | act.Role = provider.RoleAssistant | |
| 272 | + | act.Prompt = "" | |
| 273 | + | } | |
| 274 | + | return act | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // Render returns the dialog lines. MaxRows controls the number of selectable | |
| 278 | + | // rows, not the chrome; every returned row is still hard-clipped to width. | |
| 119 | 279 | func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |
| 120 | 280 | if !d.Active() { | |
| 121 | 281 | return nil | |
| 122 | 282 | } | |
| 123 | - | var lines []string | |
| 124 | - | lines = append(lines, frameHeader(th, "session tree", width)) | |
| 283 | + | renderWidth := width | |
| 284 | + | if renderWidth < 0 { | |
| 285 | + | renderWidth = 0 | |
| 286 | + | } | |
| 287 | + | line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) } | |
| 288 | + | ||
| 289 | + | lines := []string{line(frameHeader(th, "session tree", renderWidth))} | |
| 125 | 290 | if len(d.items) == 0 { | |
| 126 | - | lines = append(lines, th.FG256(th.Muted, "no messages in this session yet")) | |
| 127 | - | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |
| 128 | - | lines = append(lines, frameRule(th, width)) | |
| 291 | + | lines = append(lines, | |
| 292 | + | line(th.FG256(th.Muted, "no messages in this session yet")), | |
| 293 | + | line(th.FG256(th.Muted, "press esc to close")), | |
| 294 | + | line(frameRule(th, renderWidth)), | |
| 295 | + | ) | |
| 129 | 296 | return lines | |
| 130 | 297 | } | |
| 131 | - | lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):")) | |
| 298 | + | lines = append(lines, line(th.FG256(th.Muted, | |
| 299 | + | "session branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):"))) | |
| 132 | 300 | ||
| 133 | - | const maxRows = 12 | |
| 134 | - | start := 0 | |
| 135 | - | end := len(d.items) | |
| 136 | - | if end > maxRows { | |
| 137 | - | start = d.cursor - maxRows/2 | |
| 138 | - | if start < 0 { | |
| 139 | - | start = 0 | |
| 140 | - | } | |
| 141 | - | end = start + maxRows | |
| 142 | - | if end > len(d.items) { | |
| 143 | - | end = len(d.items) | |
| 144 | - | start = end - maxRows | |
| 145 | - | } | |
| 301 | + | d.followCursor() | |
| 302 | + | start := d.viewTop | |
| 303 | + | end := start + d.pageRows() | |
| 304 | + | if end > len(d.items) { | |
| 305 | + | end = len(d.items) | |
| 306 | + | } | |
| 307 | + | if start > end { | |
| 308 | + | start = end | |
| 146 | 309 | } | |
| 147 | 310 | if start > 0 { | |
| 148 | - | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))) | |
| 311 | + | lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))) | |
| 149 | 312 | } | |
| 150 | 313 | for i := start; i < end; i++ { | |
| 151 | 314 | it := d.items[i] | |
| 152 | - | indent := strings.Repeat(" ", it.depth) | |
| 153 | - | plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent))) | |
| 154 | - | if it.current { | |
| 155 | - | plain += " [current]" | |
| 156 | - | } | |
| 315 | + | plain := fitSessionTreeItemPlain(it, renderWidth) | |
| 157 | 316 | if i == d.cursor { | |
| 158 | - | lines = append(lines, th.PadHighlight(plain, width)) | |
| 317 | + | lines = append(lines, line(th.PadHighlight(plain, renderWidth))) | |
| 159 | 318 | } else { | |
| 160 | - | lines = append(lines, colorSessionTreeLine(th, plain)) | |
| 319 | + | lines = append(lines, line(colorSessionTreeLine(th, plain))) | |
| 161 | 320 | } | |
| 162 | 321 | } | |
| 163 | 322 | if end < len(d.items) { | |
| 164 | - | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))) | |
| 323 | + | lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))) | |
| 165 | 324 | } | |
| 166 | - | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))) | |
| 167 | - | lines = append(lines, frameRule(th, width)) | |
| 325 | + | lines = append(lines, line(th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth))) | |
| 168 | 326 | return lines | |
| 169 | 327 | } | |
| 170 | 328 | ||
| 171 | - | func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem { | |
| 172 | - | childrenByFork := map[int][]*core.TreeNode{} | |
| 173 | - | for _, child := range root.Children { | |
| 174 | - | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) | |
| 329 | + | func clampSessionTreeViewTop(viewTop, cursor, window, total int) int { | |
| 330 | + | if window <= 0 || total <= 0 || window >= total { | |
| 331 | + | return 0 | |
| 175 | 332 | } | |
| 176 | - | sess, msgs, err := core.OpenSession(root.Summary.Path) | |
| 177 | - | if err != nil { | |
| 178 | - | return nil | |
| 333 | + | if cursor < viewTop { | |
| 334 | + | viewTop = cursor | |
| 179 | 335 | } | |
| 180 | - | _ = sess.Close() | |
| 181 | - | items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath) | |
| 182 | - | var out []sessionTreeItem | |
| 183 | - | for idx, item := range items { | |
| 184 | - | out = append(out, item) | |
| 185 | - | for _, child := range childrenByFork[idx+1] { | |
| 186 | - | out = append(out, flattenSessionBranch(child, currentPath, 1)...) | |
| 336 | + | if cursor >= viewTop+window { | |
| 337 | + | viewTop = cursor - window + 1 | |
| 338 | + | } | |
| 339 | + | if viewTop < 0 { | |
| 340 | + | viewTop = 0 | |
| 341 | + | } | |
| 342 | + | if viewTop+window > total { | |
| 343 | + | viewTop = total - window | |
| 344 | + | } | |
| 345 | + | return viewTop | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string { | |
| 349 | + | if maxWidth <= 0 { | |
| 350 | + | return "" | |
| 351 | + | } | |
| 352 | + | prefix := " " + strings.Repeat(" ", it.depth) | |
| 353 | + | suffix := "" | |
| 354 | + | if it.current { | |
| 355 | + | suffix = " [current]" | |
| 356 | + | } | |
| 357 | + | prefixWidth := runewidth.StringWidth(prefix) | |
| 358 | + | suffixWidth := runewidth.StringWidth(suffix) | |
| 359 | + | if prefixWidth+suffixWidth >= maxWidth { | |
| 360 | + | // A terminal narrower than the structural chrome cannot show the | |
| 361 | + | // complete row; retain the current marker as the highest-value suffix. | |
| 362 | + | return fitSessionTreeLabel(suffix, maxWidth) | |
| 363 | + | } | |
| 364 | + | labelWidth := maxWidth - prefixWidth - suffixWidth | |
| 365 | + | return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // preflightSessionTreeFamily reads every node before any dialog state is | |
| 369 | + | // changed. The returned messages are the shared effective snapshots used by | |
| 370 | + | // all flattening and target construction for this open operation. | |
| 371 | + | func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnapshot, error) { | |
| 372 | + | if root == nil { | |
| 373 | + | return nil, fmt.Errorf("session tree: nil family root") | |
| 374 | + | } | |
| 375 | + | snapshots := make(map[string]sessionTreeSnapshot) | |
| 376 | + | visiting := make(map[string]bool) | |
| 377 | + | var walk func(*core.TreeNode) error | |
| 378 | + | walk = func(node *core.TreeNode) error { | |
| 379 | + | if node == nil { | |
| 380 | + | return nil | |
| 381 | + | } | |
| 382 | + | path := node.Summary.Path | |
| 383 | + | if visiting[path] { | |
| 384 | + | return fmt.Errorf("session tree: cycle at %q", path) | |
| 187 | 385 | } | |
| 386 | + | if _, ok := snapshots[path]; ok { | |
| 387 | + | return nil | |
| 388 | + | } | |
| 389 | + | visiting[path] = true | |
| 390 | + | snapshot, err := core.ReadSessionSnapshot(path) | |
| 391 | + | if err != nil { | |
| 392 | + | delete(visiting, path) | |
| 393 | + | return fmt.Errorf("session tree: read %q: %w", path, err) | |
| 394 | + | } | |
| 395 | + | snapshots[path] = sessionTreeSnapshot{path: path, messages: snapshot.Messages} | |
| 396 | + | for _, child := range node.Children { | |
| 397 | + | if err := walk(child); err != nil { | |
| 398 | + | delete(visiting, path) | |
| 399 | + | return err | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | delete(visiting, path) | |
| 403 | + | return nil | |
| 188 | 404 | } | |
| 189 | - | for _, child := range childrenByFork[0] { | |
| 190 | - | out = append(out, flattenSessionBranch(child, currentPath, 1)...) | |
| 405 | + | if err := walk(root); err != nil { | |
| 406 | + | return nil, err | |
| 191 | 407 | } | |
| 192 | - | return out | |
| 408 | + | return snapshots, nil | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // flattenSessionFamily is retained for package callers/tests that already | |
| 412 | + | // provide a TreeNode. The dialog itself uses the snapshot variant so all | |
| 413 | + | // reads happen before activation. | |
| 414 | + | func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem { | |
| 415 | + | snapshots, err := preflightSessionTreeFamily(root) | |
| 416 | + | if err != nil { | |
| 417 | + | return nil | |
| 418 | + | } | |
| 419 | + | return flattenSessionFamilySnapshot(root, currentPath, snapshots) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func flattenSessionFamilySnapshot(root *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot) []sessionTreeItem { | |
| 423 | + | return flattenSessionNode(root, currentPath, snapshots, 0, 0, true, false) | |
| 193 | 424 | } | |
| 194 | 425 | ||
| 426 | + | // flattenSessionBranch remains a small compatibility helper. It is useful in | |
| 427 | + | // focused package tests and keeps the old helper available to nearby callers. | |
| 195 | 428 | func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem { | |
| 196 | - | sess, msgs, err := core.OpenSession(node.Summary.Path) | |
| 429 | + | snapshots, err := preflightSessionTreeFamily(node) | |
| 197 | 430 | if err != nil { | |
| 198 | 431 | return nil | |
| 199 | 432 | } | |
| 200 | - | _ = sess.Close() | |
| 201 | - | start := node.Meta.ForkPoint | |
| 202 | - | if start < 0 { | |
| 203 | - | start = 0 | |
| 433 | + | parentLen := 0 | |
| 434 | + | if snap, ok := snapshots[node.Summary.Path]; ok { | |
| 435 | + | parentLen = len(snap.messages) | |
| 204 | 436 | } | |
| 205 | - | if start > len(msgs) { | |
| 206 | - | start = len(msgs) | |
| 437 | + | return flattenSessionNode(node, currentPath, snapshots, depth, parentLen, false, false) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // flattenSessionNode emits the visible suffix for a node and inserts children | |
| 441 | + | // at their effective fork boundary. Fork points are counts, so fork N appears | |
| 442 | + | // after message N-1. A fork beyond the parent's effective snapshot is kept at | |
| 443 | + | // the end and gets a detached boundary instead of silently disappearing. | |
| 444 | + | func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot, depth, parentLen int, rootNode, detachedFromParent bool) []sessionTreeItem { | |
| 445 | + | if node == nil { | |
| 446 | + | return nil | |
| 207 | 447 | } | |
| 208 | - | items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath) | |
| 209 | - | for idx := range items { | |
| 210 | - | items[idx].messageIdx += start | |
| 448 | + | snapshot, ok := snapshots[node.Summary.Path] | |
| 449 | + | if !ok { | |
| 450 | + | return nil | |
| 211 | 451 | } | |
| 212 | - | childrenByFork := map[int][]*core.TreeNode{} | |
| 452 | + | msgs := snapshot.messages | |
| 453 | + | start := 0 | |
| 454 | + | var selectionBoundary int | |
| 455 | + | boundary := sessionTreeMessageBoundary | |
| 456 | + | rawFork := 0 | |
| 457 | + | if !rootNode { | |
| 458 | + | rawFork = node.Meta.ForkPoint | |
| 459 | + | switch { | |
| 460 | + | case detachedFromParent || rawFork < 0 || rawFork > parentLen || rawFork > len(msgs): | |
| 461 | + | // Historical placement no longer maps to either snapshot. Keep the | |
| 462 | + | // branch visible at the parent's tail, but render its complete | |
| 463 | + | // current snapshot rather than slicing it at the stale fork point. | |
| 464 | + | boundary = sessionTreeDetachedBoundary | |
| 465 | + | start = 0 | |
| 466 | + | selectionBoundary = len(msgs) | |
| 467 | + | default: | |
| 468 | + | start = rawFork | |
| 469 | + | selectionBoundary = rawFork | |
| 470 | + | if start >= len(msgs) { | |
| 471 | + | boundary = sessionTreeEmptyBoundary | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | } else if len(msgs) == 0 { | |
| 475 | + | boundary = sessionTreeEmptyBoundary | |
| 476 | + | selectionBoundary = 0 | |
| 477 | + | } else { | |
| 478 | + | selectionBoundary = len(msgs) | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | all := buildSessionTreeItems(snapshot.path, msgs, depth, snapshot.path == currentPath) | |
| 482 | + | childrenAt := make(map[int][]*core.TreeNode) | |
| 483 | + | var stale []*core.TreeNode | |
| 213 | 484 | for _, child := range node.Children { | |
| 214 | - | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) | |
| 485 | + | fork := child.Meta.ForkPoint | |
| 486 | + | attach := fork | |
| 487 | + | if attach < start { | |
| 488 | + | attach = start | |
| 489 | + | } | |
| 490 | + | childSnapshot, childLoaded := snapshots[child.Summary.Path] | |
| 491 | + | // A branch with no post-fork messages still needs its explicit empty | |
| 492 | + | // boundary even when the copied historical prefix is no longer | |
| 493 | + | // comparable after compaction; there is no suffix whose placement could | |
| 494 | + | // be wrong. Non-empty suffixes require the prefix identity check. | |
| 495 | + | emptySuffix := childLoaded && fork >= 0 && fork == len(childSnapshot.messages) | |
| 496 | + | if fork < 0 || fork > len(msgs) || !childLoaded || | |
| 497 | + | (!emptySuffix && !sessionTreePrefixMatches(msgs, childSnapshot.messages, fork)) { | |
| 498 | + | // Numeric fork points are only placement hints. A parent may have | |
| 499 | + | // been compacted and later grown back to the old length, so bounds | |
| 500 | + | // alone are insufficient: verify that the child's copied prefix is | |
| 501 | + | // still the parent's current prefix before placing the edge. | |
| 502 | + | stale = append(stale, child) | |
| 503 | + | continue | |
| 504 | + | } | |
| 505 | + | childrenAt[attach] = append(childrenAt[attach], child) | |
| 215 | 506 | } | |
| 507 | + | ||
| 216 | 508 | var out []sessionTreeItem | |
| 217 | - | for relIdx, item := range items { | |
| 218 | - | out = append(out, item) | |
| 219 | - | absAfter := start + relIdx + 1 | |
| 220 | - | for _, child := range childrenByFork[absAfter] { | |
| 221 | - | out = append(out, flattenSessionBranch(child, currentPath, depth+1)...) | |
| 509 | + | if boundary != sessionTreeMessageBoundary { | |
| 510 | + | turn := treeTurnAtBoundary(msgs, selectionBoundary) | |
| 511 | + | boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary) | |
| 512 | + | out = append(out, makeSessionTreeBoundaryItem(snapshot.path, selectionBoundary, depth, boundaryCurrent, boundary, rawFork, len(msgs), turn)) | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | emitChildren := func(children []*core.TreeNode, childDetached bool) { | |
| 516 | + | for _, child := range children { | |
| 517 | + | out = append(out, flattenSessionNode(child, currentPath, snapshots, depth+1, len(msgs), false, childDetached)...) | |
| 222 | 518 | } | |
| 223 | 519 | } | |
| 520 | + | ||
| 521 | + | // A fork at zero is shown before the first visible message. For a branch, | |
| 522 | + | // children that forked in the hidden copied prefix are normalized to its | |
| 523 | + | // first visible index above, so they remain discoverable too. | |
| 524 | + | emitChildren(childrenAt[start], false) | |
| 525 | + | for idx := start; idx < len(all); idx++ { | |
| 526 | + | out = append(out, all[idx]) | |
| 527 | + | emitChildren(childrenAt[idx+1], false) | |
| 528 | + | } | |
| 529 | + | // Stale children are attached after the current effective snapshot. Their | |
| 530 | + | // own row makes the missing historical boundary explicit. | |
| 531 | + | emitChildren(stale, true) | |
| 224 | 532 | return out | |
| 225 | 533 | } | |
| 226 | 534 | ||
| 535 | + | func makeSessionTreeBoundaryItem(path string, effectiveIndex, depth int, current bool, boundary sessionTreeBoundary, forkPoint, snapshotLen, turn int) sessionTreeItem { | |
| 536 | + | label := fmt.Sprintf("%s branch boundary", boundary.String()) | |
| 537 | + | if boundary == sessionTreeDetachedBoundary { | |
| 538 | + | label = fmt.Sprintf("detached branch boundary (fork %d; snapshot %d)", forkPoint, snapshotLen) | |
| 539 | + | } | |
| 540 | + | target := sessionTreeTarget{ | |
| 541 | + | SourcePath: path, | |
| 542 | + | EffectiveIndex: effectiveIndex, | |
| 543 | + | SelectionBoundary: effectiveIndex, | |
| 544 | + | Boundary: boundary, | |
| 545 | + | } | |
| 546 | + | return sessionTreeItem{ | |
| 547 | + | label: label, | |
| 548 | + | messageIdx: effectiveIndex, | |
| 549 | + | turnNo: turn, | |
| 550 | + | path: path, | |
| 551 | + | depth: depth, | |
| 552 | + | current: current, | |
| 553 | + | target: target, | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func sessionTreePrefixMatches(parent, child []provider.Message, fork int) bool { | |
| 558 | + | if fork < 0 || fork > len(parent) || fork > len(child) { | |
| 559 | + | return false | |
| 560 | + | } | |
| 561 | + | return reflect.DeepEqual(parent[:fork], child[:fork]) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func treeTurnAtBoundary(msgs []provider.Message, boundary int) int { | |
| 565 | + | if boundary < 0 { | |
| 566 | + | boundary = 0 | |
| 567 | + | } | |
| 568 | + | if boundary > len(msgs) { | |
| 569 | + | boundary = len(msgs) | |
| 570 | + | } | |
| 571 | + | turn := 0 | |
| 572 | + | for _, msg := range msgs[:boundary] { | |
| 573 | + | if msg.Role == provider.RoleUser { | |
| 574 | + | turn++ | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | if turn == 0 { | |
| 578 | + | return 1 | |
| 579 | + | } | |
| 580 | + | return turn | |
| 581 | + | } | |
| 582 | + | ||
| 227 | 583 | func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem { | |
| 228 | 584 | out := make([]sessionTreeItem, 0, len(msgs)) | |
| 229 | 585 | turn := 0 | |
| 230 | 586 | lastTurn := 0 | |
| 231 | 587 | for idx, msg := range msgs { | |
| 232 | - | label := sessionTreeRoleLabel(msg.Role) | |
| 233 | 588 | if msg.Role == provider.RoleUser { | |
| 234 | 589 | turn++ | |
| 235 | 590 | lastTurn = turn | |
| 236 | 591 | } else if lastTurn == 0 { | |
| 237 | 592 | lastTurn = 1 | |
| 238 | 593 | } | |
| 594 | + | roleLabel := sessionTreeRoleLabel(msg.Role) | |
| 239 | 595 | preview := sessionTreePreview(msg) | |
| 596 | + | boundary := idx + 1 | |
| 597 | + | draft := "" | |
| 598 | + | if msg.Role == provider.RoleUser { | |
| 599 | + | boundary = idx | |
| 600 | + | draft = completeUserDraft(msg) | |
| 601 | + | } | |
| 602 | + | target := sessionTreeTarget{ | |
| 603 | + | SourcePath: path, | |
| 604 | + | EffectiveIndex: idx, | |
| 605 | + | SelectionBoundary: boundary, | |
| 606 | + | Role: msg.Role, | |
| 607 | + | UserDraft: draft, | |
| 608 | + | Boundary: sessionTreeMessageBoundary, | |
| 609 | + | } | |
| 240 | 610 | out = append(out, sessionTreeItem{ | |
| 241 | - | label: fmt.Sprintf("%s: %s", label, preview), | |
| 611 | + | label: fmt.Sprintf("%s: %s", roleLabel, preview), | |
| 242 | 612 | messageIdx: idx, | |
| 243 | 613 | turnNo: lastTurn, | |
| 244 | 614 | role: msg.Role, | |
| 245 | - | prompt: firstTextFromTreeMessage(msg), | |
| 615 | + | prompt: draft, | |
| 246 | 616 | path: path, | |
| 247 | 617 | depth: depth, | |
| 248 | 618 | current: currentPath && idx == len(msgs)-1, | |
| 619 | + | target: target, | |
| 249 | 620 | }) | |
| 250 | 621 | } | |
| 251 | 622 | return out | |
| 252 | 623 | } | |
| 253 | 624 | ||
| 625 | + | func completeUserDraft(msg provider.Message) string { | |
| 626 | + | var text []string | |
| 627 | + | for _, c := range msg.Content { | |
| 628 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 629 | + | text = append(text, tb.Text) | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | return strings.Join(text, "\n") | |
| 633 | + | } | |
| 634 | + | ||
| 254 | 635 | func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode { | |
| 636 | + | if path == "" { | |
| 637 | + | return nil | |
| 638 | + | } | |
| 255 | 639 | for _, root := range roots { | |
| 256 | 640 | if treeContainsPath(root, path) { | |
| 257 | 641 | return root | |
| @@ -287,15 +671,6 @@ func indexCurrentTreeItem(items []sessionTreeItem) int { | |||
| 287 | 671 | return len(items) - 1 | |
| 288 | 672 | } | |
| 289 | 673 | ||
| 290 | - | func firstTextFromTreeMessage(msg provider.Message) string { | |
| 291 | - | for _, c := range msg.Content { | |
| 292 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 293 | - | return tb.Text | |
| 294 | - | } | |
| 295 | - | } | |
| 296 | - | return "" | |
| 297 | - | } | |
| 298 | - | ||
| 299 | 674 | func sessionTreeRoleLabel(role provider.Role) string { | |
| 300 | 675 | switch role { | |
| 301 | 676 | case provider.RoleUser: | |
| @@ -314,14 +689,13 @@ func sessionTreePreview(msg provider.Message) string { | |||
| 314 | 689 | for _, c := range msg.Content { | |
| 315 | 690 | switch b := c.(type) { | |
| 316 | 691 | case provider.TextBlock: | |
| 317 | - | text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " ")) | |
| 318 | - | if text != "" { | |
| 692 | + | if text := sanitizeSessionTreeText(b.Text); text != "" { | |
| 319 | 693 | parts = append(parts, text) | |
| 320 | 694 | } | |
| 321 | 695 | case provider.ImageBlock: | |
| 322 | 696 | parts = append(parts, "[image]") | |
| 323 | 697 | case provider.ToolCallBlock: | |
| 324 | - | parts = append(parts, "tool "+b.Name) | |
| 698 | + | parts = append(parts, "tool "+sanitizeSessionTreeText(b.Name)) | |
| 325 | 699 | case provider.ToolResultBlock: | |
| 326 | 700 | if b.IsError { | |
| 327 | 701 | parts = append(parts, "tool result error") | |
| @@ -340,6 +714,47 @@ func sessionTreePreview(msg provider.Message) string { | |||
| 340 | 714 | return strings.Join(parts, " ") | |
| 341 | 715 | } | |
| 342 | 716 | ||
| 717 | + | // sanitizeSessionTreeText keeps previews single-line and prevents transcript | |
| 718 | + | // content from injecting terminal control sequences into the dialog frame. | |
| 719 | + | func sanitizeSessionTreeText(s string) string { | |
| 720 | + | runes := []rune(s) | |
| 721 | + | var b strings.Builder | |
| 722 | + | for i := 0; i < len(runes); { | |
| 723 | + | if end, ok := sessionTreeCSIEnd(runes, i); ok { | |
| 724 | + | i = end | |
| 725 | + | continue | |
| 726 | + | } | |
| 727 | + | if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == ']' { | |
| 728 | + | // OSC sequences end at BEL or ST. Drop the whole sequence when | |
| 729 | + | // present. | |
| 730 | + | i += 2 | |
| 731 | + | for i < len(runes) && runes[i] != '\a' { | |
| 732 | + | if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '\\' { | |
| 733 | + | i += 2 | |
| 734 | + | break | |
| 735 | + | } | |
| 736 | + | i++ | |
| 737 | + | } | |
| 738 | + | if i < len(runes) && runes[i] == '\a' { | |
| 739 | + | i++ | |
| 740 | + | } | |
| 741 | + | continue | |
| 742 | + | } | |
| 743 | + | if runes[i] == '\x1b' { | |
| 744 | + | // An isolated ESC (or a two-byte escape) is dropped by itself. | |
| 745 | + | i++ | |
| 746 | + | continue | |
| 747 | + | } | |
| 748 | + | if unicode.IsControl(runes[i]) { | |
| 749 | + | b.WriteByte(' ') | |
| 750 | + | } else { | |
| 751 | + | b.WriteRune(runes[i]) | |
| 752 | + | } | |
| 753 | + | i++ | |
| 754 | + | } | |
| 755 | + | return strings.Join(strings.Fields(b.String()), " ") | |
| 756 | + | } | |
| 757 | + | ||
| 343 | 758 | func colorSessionTreeLine(th tui.Theme, line string) string { | |
| 344 | 759 | plain := strings.TrimSpace(line) | |
| 345 | 760 | switch { | |
| @@ -354,13 +769,96 @@ func colorSessionTreeLine(th tui.Theme, line string) string { | |||
| 354 | 769 | } | |
| 355 | 770 | } | |
| 356 | 771 | ||
| 772 | + | // fitSessionTreeLabel truncates by terminal cells, not Go runes. This is | |
| 773 | + | // intentionally also used for the indentation and current marker together so | |
| 774 | + | // neither can push a row past the terminal edge. | |
| 357 | 775 | func fitSessionTreeLabel(label string, maxWidth int) string { | |
| 358 | - | if maxWidth < 4 { | |
| 359 | - | maxWidth = 4 | |
| 776 | + | if maxWidth <= 0 { | |
| 777 | + | return "" | |
| 360 | 778 | } | |
| 361 | - | runes := []rune(label) | |
| 362 | - | if len(runes) <= maxWidth { | |
| 779 | + | if runewidth.StringWidth(label) <= maxWidth { | |
| 363 | 780 | return label | |
| 364 | 781 | } | |
| 365 | - | return string(runes[:maxWidth-3]) + "..." | |
| 782 | + | if maxWidth <= 3 { | |
| 783 | + | return strings.Repeat(".", maxWidth) | |
| 784 | + | } | |
| 785 | + | return truncateSessionTreePlain(label, maxWidth-3) + "..." | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func truncateSessionTreePlain(s string, maxWidth int) string { | |
| 789 | + | if maxWidth <= 0 { | |
| 790 | + | return "" | |
| 791 | + | } | |
| 792 | + | var b strings.Builder | |
| 793 | + | used := 0 | |
| 794 | + | for _, r := range s { | |
| 795 | + | w := runewidth.RuneWidth(r) | |
| 796 | + | if w > 0 && used+w > maxWidth { | |
| 797 | + | break | |
| 798 | + | } | |
| 799 | + | b.WriteRune(r) | |
| 800 | + | used += w | |
| 801 | + | } | |
| 802 | + | return b.String() | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | // truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer | |
| 806 | + | // clipping helper. Keeping it here avoids changing tui for one dialog while | |
| 807 | + | // still making headers, hints, colors, and highlighted rows width-safe. | |
| 808 | + | func truncateSessionTreeANSI(s string, maxWidth int) string { | |
| 809 | + | if maxWidth <= 0 { | |
| 810 | + | return "" | |
| 811 | + | } | |
| 812 | + | if sessionTreeANSIWidth(s) <= maxWidth { | |
| 813 | + | return s | |
| 814 | + | } | |
| 815 | + | runes := []rune(s) | |
| 816 | + | var b strings.Builder | |
| 817 | + | used := 0 | |
| 818 | + | clipped := false | |
| 819 | + | for i := 0; i < len(runes); { | |
| 820 | + | if end, ok := sessionTreeCSIEnd(runes, i); ok { | |
| 821 | + | b.WriteString(string(runes[i:end])) | |
| 822 | + | i = end | |
| 823 | + | continue | |
| 824 | + | } | |
| 825 | + | w := runewidth.RuneWidth(runes[i]) | |
| 826 | + | if w > 0 && used+w > maxWidth { | |
| 827 | + | clipped = true | |
| 828 | + | break | |
| 829 | + | } | |
| 830 | + | b.WriteRune(runes[i]) | |
| 831 | + | used += w | |
| 832 | + | i++ | |
| 833 | + | } | |
| 834 | + | if clipped && strings.Contains(s, "\x1b[") { | |
| 835 | + | b.WriteString("\x1b[0m") | |
| 836 | + | } | |
| 837 | + | return b.String() | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | func sessionTreeANSIWidth(s string) int { | |
| 841 | + | runes := []rune(s) | |
| 842 | + | width := 0 | |
| 843 | + | for i := 0; i < len(runes); { | |
| 844 | + | if end, ok := sessionTreeCSIEnd(runes, i); ok { | |
| 845 | + | i = end | |
| 846 | + | continue | |
| 847 | + | } | |
| 848 | + | width += runewidth.RuneWidth(runes[i]) | |
| 849 | + | i++ | |
| 850 | + | } | |
| 851 | + | return width | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | func sessionTreeCSIEnd(runes []rune, start int) (int, bool) { | |
| 855 | + | if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' { | |
| 856 | + | return start, false | |
| 857 | + | } | |
| 858 | + | for i := start + 2; i < len(runes); i++ { | |
| 859 | + | if runes[i] >= 0x40 && runes[i] <= 0x7e { | |
| 860 | + | return i + 1, true | |
| 861 | + | } | |
| 862 | + | } | |
| 863 | + | return len(runes), true | |
| 366 | 864 | } | |
A packages/agent/modes/session_tree_dialog_test.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) { | |
| 15 | + | msgs := []provider.Message{ | |
| 16 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}), | |
| 17 | + | treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}), | |
| 18 | + | treeTestMessage(provider.RoleUser, | |
| 19 | + | provider.TextBlock{Text: "line one"}, | |
| 20 | + | provider.ImageBlock{MimeType: "image/png", Data: []byte{1, 2}}, | |
| 21 | + | provider.TextBlock{Text: "line two"}), | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | d := newSessionTreeDialog() | |
| 25 | + | if !d.OpenMessages(msgs) { | |
| 26 | + | t.Fatal("OpenMessages returned false") | |
| 27 | + | } | |
| 28 | + | d.cursor = 2 | |
| 29 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 30 | + | if !act.Select { | |
| 31 | + | t.Fatal("enter did not select a target") | |
| 32 | + | } | |
| 33 | + | got := act.Target | |
| 34 | + | if got.SourcePath != "" || got.EffectiveIndex != 2 || got.SelectionBoundary != 2 { | |
| 35 | + | t.Fatalf("target indices/path = %+v", got) | |
| 36 | + | } | |
| 37 | + | if got.Role != provider.RoleUser || got.Boundary != sessionTreeMessageBoundary { | |
| 38 | + | t.Fatalf("target role/boundary = %+v", got) | |
| 39 | + | } | |
| 40 | + | if got.UserDraft != "line one\nline two" { | |
| 41 | + | t.Fatalf("user draft = %q", got.UserDraft) | |
| 42 | + | } | |
| 43 | + | // The scalar fields remain usable by the current integration layer. | |
| 44 | + | if act.MessageIdx != 2 || act.Role != provider.RoleUser || act.Prompt != got.UserDraft { | |
| 45 | + | t.Fatalf("legacy action fields = %+v", act) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) { | |
| 50 | + | root := t.TempDir() | |
| 51 | + | cwd := "/workspace/current-family" | |
| 52 | + | first := newTreeTestSession(t, root, cwd, []provider.Message{ | |
| 53 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "unrelated root"}), | |
| 54 | + | }) | |
| 55 | + | unrelatedFile, err := os.OpenFile(first, os.O_APPEND|os.O_WRONLY, 0o644) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := unrelatedFile.WriteString("{unrelated corruption}\n"); err != nil { | |
| 60 | + | _ = unrelatedFile.Close() | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | if err := unrelatedFile.Close(); err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | current := newTreeTestSession(t, root, cwd, []provider.Message{ | |
| 67 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "current root"}), | |
| 68 | + | }) | |
| 69 | + | ||
| 70 | + | d := newSessionTreeDialog() | |
| 71 | + | if !d.OpenSessionFamily(root, cwd, current) { | |
| 72 | + | t.Fatal("OpenSessionFamily returned false") | |
| 73 | + | } | |
| 74 | + | for _, item := range d.items { | |
| 75 | + | if item.path != current { | |
| 76 | + | t.Fatalf("item from unrelated family: %+v (first root %q)", item, first) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if !d.Active() { | |
| 80 | + | t.Fatal("dialog is not active") | |
| 81 | + | } | |
| 82 | + | oldItems := append([]sessionTreeItem(nil), d.items...) | |
| 83 | + | if d.OpenSessionFamily(root, cwd, t.TempDir()+"/not-a-session.jsonl") { | |
| 84 | + | t.Fatal("missing current path selected a forest root") | |
| 85 | + | } | |
| 86 | + | if !d.Active() || len(d.items) != len(oldItems) || d.items[0].path != oldItems[0].path { | |
| 87 | + | t.Fatal("failed open changed the active dialog") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestSessionTreeShowsEmptyAndDetachedBoundaries(t *testing.T) { | |
| 92 | + | root := t.TempDir() | |
| 93 | + | cwd := "/workspace/stale-compaction" | |
| 94 | + | parent := newTreeTestSession(t, root, cwd, []provider.Message{ | |
| 95 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "one"}), | |
| 96 | + | treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "two"}), | |
| 97 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "three"}), | |
| 98 | + | }) | |
| 99 | + | ||
| 100 | + | // Both children are created against the old effective parent. The first | |
| 101 | + | // has no post-fork rows; the second's fork point becomes stale after the | |
| 102 | + | // parent is compacted to one message. | |
| 103 | + | emptyChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 1) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | staleChild, err := core.BranchSessionHidden(parent, root, cwd, "test", 3) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | parentSession, _, err := core.OpenSession(parent) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | if err := parentSession.AppendCompaction([]provider.Message{ | |
| 116 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "compacted"}), | |
| 117 | + | }); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if err := parentSession.Close(); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | d := newSessionTreeDialog() | |
| 125 | + | if !d.OpenSessionFamily(root, cwd, parent) { | |
| 126 | + | t.Fatal("OpenSessionFamily returned false after compaction") | |
| 127 | + | } | |
| 128 | + | var empty, detached *sessionTreeItem | |
| 129 | + | staleMessageRows := 0 | |
| 130 | + | for i := range d.items { | |
| 131 | + | item := &d.items[i] | |
| 132 | + | switch { | |
| 133 | + | case item.target.IsEmptyBoundary() && item.path == emptyChild: | |
| 134 | + | empty = item | |
| 135 | + | case item.target.IsDetachedBoundary() && item.path == staleChild: | |
| 136 | + | detached = item | |
| 137 | + | case item.path == staleChild && !item.target.IsBoundary(): | |
| 138 | + | staleMessageRows++ | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | if empty == nil || detached == nil { | |
| 142 | + | t.Fatalf("boundaries missing from tree: empty=%v detached=%v items=%+v", empty != nil, detached != nil, d.items) | |
| 143 | + | } | |
| 144 | + | if staleMessageRows != 3 { | |
| 145 | + | t.Fatalf("detached branch rendered %d message rows, want its complete snapshot (3)", staleMessageRows) | |
| 146 | + | } | |
| 147 | + | if empty.target.SelectionBoundary != 1 || detached.target.SelectionBoundary != 3 { | |
| 148 | + | t.Fatalf("boundary indices = empty %+v detached %+v", empty.target, detached.target) | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(strings.ToLower(empty.label), "empty") || !strings.Contains(strings.ToLower(detached.label), "detached") { | |
| 151 | + | t.Fatalf("boundary labels = %q / %q", empty.label, detached.label) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestSessionTreePreflightIsAtomicOnMalformedFamilyMember(t *testing.T) { | |
| 156 | + | root := t.TempDir() | |
| 157 | + | cwd := "/workspace/atomic-tree" | |
| 158 | + | parent := newTreeTestSession(t, root, cwd, []provider.Message{ | |
| 159 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "parent"}), | |
| 160 | + | }) | |
| 161 | + | child, err := core.BranchSessionHidden(parent, root, cwd, "test", 1) | |
| 162 | + | if err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | f, err := os.OpenFile(child, os.O_APPEND|os.O_WRONLY, 0o644) | |
| 166 | + | if err != nil { | |
| 167 | + | t.Fatal(err) | |
| 168 | + | } | |
| 169 | + | if _, err := f.WriteString("{not valid json}\n"); err != nil { | |
| 170 | + | _ = f.Close() | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | if err := f.Close(); err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | d := newSessionTreeDialog() | |
| 178 | + | if !d.OpenMessages([]provider.Message{treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "keep"})}) { | |
| 179 | + | t.Fatal("failed to seed dialog") | |
| 180 | + | } | |
| 181 | + | before := append([]sessionTreeItem(nil), d.items...) | |
| 182 | + | if d.OpenSessionFamily(root, cwd, parent) { | |
| 183 | + | t.Fatal("malformed family member passed preflight") | |
| 184 | + | } | |
| 185 | + | if !d.Active() || len(d.items) != len(before) || d.items[0].label != before[0].label { | |
| 186 | + | t.Fatalf("preflight failure changed dialog: active=%v items=%+v", d.Active(), d.items) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestSanitizeSessionTreeTextPreservesTextAfterNonOSCExit(t *testing.T) { | |
| 191 | + | input := "before\x1bM and \x1b(B after \x1b]0;window title\a tail" | |
| 192 | + | if got, want := sanitizeSessionTreeText(input), "beforeM and (B after tail"; got != want { | |
| 193 | + | t.Fatalf("sanitizeSessionTreeText = %q, want %q", got, want) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestSessionTreeRenderAndPagingAreWidthAndHeightSafe(t *testing.T) { | |
| 198 | + | msgs := make([]provider.Message, 0, 20) | |
| 199 | + | for i := 0; i < 20; i++ { | |
| 200 | + | msgs = append(msgs, treeTestMessage(provider.RoleUser, | |
| 201 | + | provider.TextBlock{Text: strings.Repeat("界", 4) + " long row"})) | |
| 202 | + | } | |
| 203 | + | d := newSessionTreeDialog() | |
| 204 | + | d.MaxRows = 2 | |
| 205 | + | if !d.OpenMessages(msgs) { | |
| 206 | + | t.Fatal("OpenMessages returned false") | |
| 207 | + | } | |
| 208 | + | if got := d.HandleKey(tui.Key{Kind: tui.KeyPageUp}); got.Select || d.cursor != 17 { | |
| 209 | + | t.Fatalf("page up cursor = %d, action=%+v", d.cursor, got) | |
| 210 | + | } | |
| 211 | + | for _, width := range []int{0, 1, 7, 17} { | |
| 212 | + | for _, line := range d.Render(tui.Theme{}, width) { | |
| 213 | + | if got := sessionTreeANSIWidth(line); got > width { | |
| 214 | + | t.Fatalf("width %d rendered %d cells: %q", width, got, line) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | if d.viewTop < 0 || d.viewTop+d.MaxRows > len(d.items) { | |
| 219 | + | t.Fatalf("invalid viewport top=%d rows=%d items=%d", d.viewTop, d.MaxRows, len(d.items)) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestSessionTreeCheckoutRefreshesLastTurnUsageIncludingZero(t *testing.T) { | |
| 224 | + | root := t.TempDir() | |
| 225 | + | cwd := "/workspace/tree-usage" | |
| 226 | + | path := newTreeTestSession(t, root, cwd, []provider.Message{ | |
| 227 | + | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "question"}), | |
| 228 | + | treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"}), | |
| 229 | + | }) | |
| 230 | + | ||
| 231 | + | initial := core.NewAgent(nil, "model", "", nil) | |
| 232 | + | initial.SeedLastTurnUsage(provider.Usage{InputTokens: 99}) | |
| 233 | + | selected := core.NewAgent(nil, "model", "", nil) | |
| 234 | + | selected.SetMessages(initial.Messages()) | |
| 235 | + | selected.SeedLastTurnUsage(provider.Usage{ | |
| 236 | + | InputTokens: 7, | |
| 237 | + | CacheReadTokens: 11, | |
| 238 | + | CacheWriteTokens: 13, | |
| 239 | + | }) | |
| 240 | + | ||
| 241 | + | i := NewInteractive(InteractiveConfig{ | |
| 242 | + | Agent: initial, | |
| 243 | + | CWD: cwd, | |
| 244 | + | SessionsRoot: root, | |
| 245 | + | CurrentSessionPath: func() string { return path }, | |
| 246 | + | }) | |
| 247 | + | i.cfg.LoadSession = func(string) error { | |
| 248 | + | i.agent = selected | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | target := sessionTreeTarget{ | |
| 253 | + | SourcePath: path, | |
| 254 | + | EffectiveIndex: 1, | |
| 255 | + | SelectionBoundary: 2, | |
| 256 | + | Role: provider.RoleAssistant, | |
| 257 | + | } | |
| 258 | + | i.applySessionTreeTarget(target, 1) | |
| 259 | + | if i.lastCtxInput != 31 { | |
| 260 | + | t.Fatalf("checked-out context usage = %d, want 31", i.lastCtxInput) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | selected.SeedLastTurnUsage(provider.Usage{}) | |
| 264 | + | i.applySessionTreeTarget(target, 1) | |
| 265 | + | if i.lastCtxInput != 0 { | |
| 266 | + | t.Fatalf("checked-out zero usage left stale context value %d", i.lastCtxInput) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func treeTestMessage(role provider.Role, content ...provider.Content) provider.Message { | |
| 271 | + | return provider.Message{Role: role, Content: content, Time: time.Unix(1, 0)} | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func newTreeTestSession(t *testing.T, root, cwd string, msgs []provider.Message) string { | |
| 275 | + | t.Helper() | |
| 276 | + | sess, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 277 | + | if err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | for _, msg := range msgs { | |
| 281 | + | if err := sess.AppendMessage(msg); err != nil { | |
| 282 | + | _ = sess.Close() | |
| 283 | + | t.Fatal(err) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | if err := sess.Close(); err != nil { | |
| 287 | + | t.Fatal(err) | |
| 288 | + | } | |
| 289 | + | return sess.Path | |
| 290 | + | } | |
M packages/agent/modes/slash_suggest.go +14 -2
| @@ -26,12 +26,23 @@ type slashCommand struct { | |||
| 26 | 26 | // the streaming response without trouble. | |
| 27 | 27 | func slashCancelsTurn(head string) bool { | |
| 28 | 28 | switch strings.ToLower(head) { | |
| 29 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | |
| 29 | + | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd", "/fork": | |
| 30 | 30 | return true | |
| 31 | 31 | } | |
| 32 | 32 | return false | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | func slashCommandCancelsTurn(cmd string) bool { | |
| 36 | + | parts := strings.Fields(cmd) | |
| 37 | + | if len(parts) == 0 { | |
| 38 | + | return false | |
| 39 | + | } | |
| 40 | + | if slashCancelsTurn(parts[0]) { | |
| 41 | + | return true | |
| 42 | + | } | |
| 43 | + | return strings.EqualFold(parts[0], "/session") && len(parts) > 1 && strings.EqualFold(parts[1], "fork") | |
| 44 | + | } | |
| 45 | + | ||
| 35 | 46 | // slashCatalog lists every slash command the interactive mode handles. | |
| 36 | 47 | // Keep in sync with runSlash(). | |
| 37 | 48 | var slashCatalog = []slashCommand{ | |
| @@ -42,7 +53,8 @@ var slashCatalog = []slashCommand{ | |||
| 42 | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 43 | 54 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 44 | 55 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 45 | - | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | |
| 56 | + | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | |
| 57 | + | {Name: "/session", Desc: "export/import, fork, or browse the current session tree"}, | |
| 46 | 58 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 47 | 59 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| 48 | 60 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | |
M packages/agent/modes/slash_suggest_test.go +9 -0
| @@ -52,9 +52,18 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |||
| 52 | 52 | if !isKnownSlashCommand("/Exit") { | |
| 53 | 53 | t.Fatal("/Exit was not recognized as a built-in command") | |
| 54 | 54 | } | |
| 55 | + | if !isKnownSlashCommand("/Fork") { | |
| 56 | + | t.Fatal("/Fork was not recognized as a built-in command") | |
| 57 | + | } | |
| 55 | 58 | if !slashCancelsTurn("/CLEAR") { | |
| 56 | 59 | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | |
| 57 | 60 | } | |
| 61 | + | if !slashCommandCancelsTurn("/fork") || !slashCommandCancelsTurn("/session fork") { | |
| 62 | + | t.Fatal("fork commands did not retain idle-transition cancellation semantics") | |
| 63 | + | } | |
| 64 | + | if slashCommandCancelsTurn("/session tree") { | |
| 65 | + | t.Fatal("/session tree should use its fail-closed gate instead of cancelling a turn") | |
| 66 | + | } | |
| 58 | 67 | } | |
| 59 | 68 | ||
| 60 | 69 | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |
M packages/agent/swarm_agent.go +13 -2
| @@ -60,8 +60,19 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 60 | 60 | ||
| 61 | 61 | ag := r.NewAgent() | |
| 62 | 62 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 63 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 64 | - | defer sess.Close() | |
| 63 | + | sess, err := openOrCreateSession(args, r, ag, version) | |
| 64 | + | if err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | if sess != nil { | |
| 68 | + | var providerName, model string | |
| 69 | + | sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | r.Provider, r.Model = providerName, model | |
| 74 | + | defer sess.Close() | |
| 75 | + | } | |
| 65 | 76 | ||
| 66 | 77 | // Open the inbox listener BEFORE emitting agent_ready so the | |
| 67 | 78 | // supervisor can dial through on the very first send. The | |
M packages/core/agent.go +3 -0
| @@ -322,6 +322,9 @@ func (a *Agent) fireMessageAppended(m provider.Message) { | |||
| 322 | 322 | // stops or an error occurs. Events are delivered via sink in order. | |
| 323 | 323 | // sink must not block the caller for long; buffer as needed. | |
| 324 | 324 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | |
| 325 | + | if text == "" && len(images) == 0 { | |
| 326 | + | return errors.New("prompt is empty") | |
| 327 | + | } | |
| 325 | 328 | if sink == nil { | |
| 326 | 329 | sink = func(AgentEvent) {} | |
| 327 | 330 | } | |
M packages/core/core_test.go +11 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "os" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| @@ -8,6 +9,16 @@ import ( | |||
| 8 | 9 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | func TestAgentRejectsEmptyPrompt(t *testing.T) { | |
| 13 | + | ag := NewAgent(nil, "model", "", nil) | |
| 14 | + | if err := ag.Prompt(context.Background(), "", nil, nil); err == nil { | |
| 15 | + | t.Fatal("empty prompt was accepted") | |
| 16 | + | } | |
| 17 | + | if got := ag.Messages(); len(got) != 0 { | |
| 18 | + | t.Fatalf("empty prompt appended %d messages", len(got)) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 11 | 22 | func TestSessionRoundTrip(t *testing.T) { | |
| 12 | 23 | dir := t.TempDir() | |
| 13 | 24 | os.Setenv("ZOT_HOME", dir) | |
M packages/core/session.go +476 -243
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "crypto/sha256" | |
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "encoding/json" | |
| 9 | + | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "io" | |
| 11 | 12 | "os" | |
| @@ -69,18 +70,36 @@ type SessionMeta struct { | |||
| 69 | 70 | // the default unmarshaler cannot reconstruct); it is written with a | |
| 70 | 71 | // regular provider.Message value. | |
| 71 | 72 | type sessionLine struct { | |
| 72 | - | Type string `json:"type"` | |
| 73 | - | Meta *SessionMeta `json:"meta,omitempty"` | |
| 74 | - | Message *provider.Message `json:"message,omitempty"` | |
| 75 | - | Messages []provider.Message `json:"messages,omitempty"` | |
| 76 | - | Usage *provider.Usage `json:"usage,omitempty"` | |
| 77 | - | Cumulative *provider.Usage `json:"cumulative,omitempty"` | |
| 73 | + | Type string `json:"type"` | |
| 74 | + | Meta *SessionMeta `json:"meta,omitempty"` | |
| 75 | + | Message *provider.Message `json:"message,omitempty"` | |
| 76 | + | Messages *[]provider.Message `json:"messages,omitempty"` | |
| 77 | + | Usage *provider.Usage `json:"usage,omitempty"` | |
| 78 | + | Cumulative *provider.Usage `json:"cumulative,omitempty"` | |
| 78 | 79 | } | |
| 79 | 80 | ||
| 80 | 81 | type sessionLineHead struct { | |
| 81 | 82 | Type string `json:"type"` | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 85 | + | // SessionUsageCheckpoint records the cumulative usage known when a usage | |
| 86 | + | // row was written. MessageCount uses the effective transcript (after the | |
| 87 | + | // latest compaction and tool-pair repair), not the number of raw JSONL rows. | |
| 88 | + | type SessionUsageCheckpoint struct { | |
| 89 | + | MessageCount int | |
| 90 | + | Cumulative provider.Usage | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // SessionSnapshot is the effective, in-memory view of a session file. | |
| 94 | + | // Messages are the transcript that a resumed agent should use. The raw | |
| 95 | + | // JSONL audit history is deliberately not exposed here because message | |
| 96 | + | // indices used by tree navigation and branching must agree. | |
| 97 | + | type SessionSnapshot struct { | |
| 98 | + | Meta SessionMeta | |
| 99 | + | Messages []provider.Message | |
| 100 | + | UsageCheckpoints []SessionUsageCheckpoint | |
| 101 | + | } | |
| 102 | + | ||
| 84 | 103 | // SessionsDir returns the per-cwd sessions directory under root. | |
| 85 | 104 | func SessionsDir(root, cwd string) string { | |
| 86 | 105 | sum := sha256.Sum256([]byte(cwd)) | |
| @@ -168,69 +187,33 @@ func SessionUsage(path string) (provider.Usage, error) { | |||
| 168 | 187 | } | |
| 169 | 188 | ||
| 170 | 189 | // SessionUsageDetail returns the latest cumulative usage and the | |
| 171 | - | // per-turn usage of the final completed turn. The per-turn row drives | |
| 172 | - | // the live "context used" gauge in the status bar (input + cache | |
| 173 | - | // approximates the prompt size the model just saw), letting the TUI | |
| 174 | - | // rehydrate the gauge on resume instead of starting at 0% until the | |
| 175 | - | // next turn lands. | |
| 190 | + | // per-turn usage of the final completed turn. It reads through the same | |
| 191 | + | // strict snapshot path as OpenSession, so a truncated or malformed session | |
| 192 | + | // cannot silently reset the usage display. | |
| 176 | 193 | func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) { | |
| 177 | - | f, ferr := os.Open(path) | |
| 178 | - | if ferr != nil { | |
| 179 | - | return provider.Usage{}, provider.Usage{}, ferr | |
| 194 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 195 | + | if err != nil { | |
| 196 | + | return provider.Usage{}, provider.Usage{}, err | |
| 180 | 197 | } | |
| 181 | - | defer f.Close() | |
| 182 | - | ||
| 183 | - | // Some historical sessions logged the per-turn `usage` field as a copy | |
| 184 | - | // of `cumulative` instead of the true delta. To recover an accurate | |
| 185 | - | // last-turn snapshot (used by the status-bar context gauge on resume), | |
| 186 | - | // we always derive lastTurn from the delta between the final two | |
| 187 | - | // cumulative rows. For prompt-size purposes, cache_read/cache_write | |
| 188 | - | // reflect the most recent prompt directly, so we take those from the | |
| 189 | - | // final cumulative row as-is rather than as a delta. | |
| 190 | - | var prevCum provider.Usage | |
| 191 | - | var haveCum bool | |
| 192 | - | if ierr := forEachJSONLLine(f, func(line []byte) error { | |
| 193 | - | var head sessionLineHead | |
| 194 | - | if err := json.Unmarshal(line, &head); err != nil || head.Type != "usage" { | |
| 195 | - | return nil | |
| 196 | - | } | |
| 197 | - | var row struct { | |
| 198 | - | Cumulative provider.Usage `json:"cumulative"` | |
| 199 | - | } | |
| 200 | - | if err := json.Unmarshal(line, &row); err != nil { | |
| 201 | - | return nil | |
| 202 | - | } | |
| 203 | - | if haveCum { | |
| 204 | - | prevCum = cumulative | |
| 205 | - | } | |
| 206 | - | cumulative = row.Cumulative | |
| 207 | - | haveCum = true | |
| 208 | - | return nil | |
| 209 | - | }); ierr != nil { | |
| 210 | - | return provider.Usage{}, provider.Usage{}, ierr | |
| 198 | + | if len(snapshot.UsageCheckpoints) == 0 { | |
| 199 | + | return provider.Usage{}, provider.Usage{}, nil | |
| 211 | 200 | } | |
| 212 | - | if haveCum { | |
| 213 | - | // input/output are monotonic totals -> per-turn = delta. | |
| 214 | - | lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens) | |
| 215 | - | lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens) | |
| 216 | - | lastTurn.ReasoningTokens = nonNegDelta(cumulative.ReasoningTokens, prevCum.ReasoningTokens) | |
| 217 | - | lastTurn.ReasoningTokensKnown = cumulative.ReasoningTokensKnown | |
| 218 | - | // cache_read/write on the final row already represent the last prompt's | |
| 219 | - | // cache hit/creation, not a running total of bytes; use directly. | |
| 220 | - | lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens | |
| 221 | - | if lastTurn.CacheReadTokens < 0 { | |
| 222 | - | lastTurn.CacheReadTokens = cumulative.CacheReadTokens | |
| 223 | - | } | |
| 224 | - | lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens | |
| 225 | - | if lastTurn.CacheWriteTokens < 0 { | |
| 226 | - | lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens | |
| 227 | - | } | |
| 228 | - | lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD | |
| 229 | - | if lastTurn.CostUSD < 0 { | |
| 230 | - | lastTurn.CostUSD = 0 | |
| 231 | - | } | |
| 201 | + | last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative | |
| 202 | + | var prev provider.Usage | |
| 203 | + | if len(snapshot.UsageCheckpoints) > 1 { | |
| 204 | + | prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative | |
| 232 | 205 | } | |
| 233 | - | return cumulative, lastTurn, nil | |
| 206 | + | lastTurn.InputTokens = nonNegDelta(last.InputTokens, prev.InputTokens) | |
| 207 | + | lastTurn.OutputTokens = nonNegDelta(last.OutputTokens, prev.OutputTokens) | |
| 208 | + | lastTurn.ReasoningTokens = nonNegDelta(last.ReasoningTokens, prev.ReasoningTokens) | |
| 209 | + | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | |
| 210 | + | lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens) | |
| 211 | + | lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens) | |
| 212 | + | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | |
| 213 | + | if lastTurn.CostUSD < 0 { | |
| 214 | + | lastTurn.CostUSD = 0 | |
| 215 | + | } | |
| 216 | + | return last, lastTurn, nil | |
| 234 | 217 | } | |
| 235 | 218 | ||
| 236 | 219 | func nonNegDelta(cur, prev int) int { | |
| @@ -240,49 +223,329 @@ func nonNegDelta(cur, prev int) int { | |||
| 240 | 223 | return cur - prev | |
| 241 | 224 | } | |
| 242 | 225 | ||
| 243 | - | // OpenSession opens an existing session for appending. | |
| 244 | - | func OpenSession(path string) (*Session, []provider.Message, error) { | |
| 226 | + | // ReadSessionSnapshot reads a complete session file and reconstructs the | |
| 227 | + | // effective transcript. It is intentionally stricter than the lightweight | |
| 228 | + | // listing helpers: a caller that is about to resume, render, or branch a | |
| 229 | + | // session must not receive a plausible-looking partial transcript. | |
| 230 | + | func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |
| 245 | 231 | f, err := os.Open(path) | |
| 246 | 232 | if err != nil { | |
| 247 | - | return nil, nil, err | |
| 233 | + | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | |
| 248 | 234 | } | |
| 249 | 235 | defer f.Close() | |
| 250 | 236 | ||
| 251 | - | var meta SessionMeta | |
| 252 | - | var messages []provider.Message | |
| 253 | - | if err := forEachJSONLLine(f, func(line []byte) error { | |
| 237 | + | var snapshot SessionSnapshot | |
| 238 | + | var sawMeta bool | |
| 239 | + | generation := 0 | |
| 240 | + | type rawCheckpoint struct { | |
| 241 | + | messageCount int | |
| 242 | + | cumulative provider.Usage | |
| 243 | + | generation int | |
| 244 | + | } | |
| 245 | + | var rawCheckpoints []rawCheckpoint | |
| 246 | + | ||
| 247 | + | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 254 | 248 | var head sessionLineHead | |
| 255 | 249 | if err := json.Unmarshal(line, &head); err != nil { | |
| 256 | - | return nil | |
| 250 | + | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| 251 | + | } | |
| 252 | + | if head.Type == "" { | |
| 253 | + | return fmt.Errorf("line %d: missing row type", lineNo) | |
| 254 | + | } | |
| 255 | + | if !sawMeta && head.Type != "meta" { | |
| 256 | + | return fmt.Errorf("line %d: first row is not meta", lineNo) | |
| 257 | 257 | } | |
| 258 | + | ||
| 258 | 259 | switch head.Type { | |
| 259 | 260 | case "meta": | |
| 260 | - | var row struct { | |
| 261 | - | Meta SessionMeta `json:"meta"` | |
| 261 | + | var row sessionLine | |
| 262 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 263 | + | return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err) | |
| 262 | 264 | } | |
| 263 | - | if err := json.Unmarshal(line, &row); err == nil { | |
| 264 | - | meta = row.Meta | |
| 265 | + | if row.Meta == nil || row.Meta.ID == "" { | |
| 266 | + | return fmt.Errorf("line %d: meta row has no session id", lineNo) | |
| 265 | 267 | } | |
| 268 | + | snapshot.Meta = *row.Meta | |
| 269 | + | sawMeta = true | |
| 270 | + | ||
| 266 | 271 | case "message": | |
| 267 | - | if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 { | |
| 268 | - | messages = append(messages, msg) | |
| 272 | + | msg, err := hydrateMessage(line) | |
| 273 | + | if err != nil { | |
| 274 | + | return fmt.Errorf("line %d: invalid message row: %w", lineNo, err) | |
| 269 | 275 | } | |
| 276 | + | snapshot.Messages = append(snapshot.Messages, msg) | |
| 277 | + | ||
| 270 | 278 | case "compaction": | |
| 271 | - | if compacted, err := hydrateCompaction(line); err == nil { | |
| 272 | - | messages = compacted | |
| 279 | + | compacted, err := hydrateCompaction(line) | |
| 280 | + | if err != nil { | |
| 281 | + | return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err) | |
| 282 | + | } | |
| 283 | + | snapshot.Messages = compacted | |
| 284 | + | generation++ | |
| 285 | + | ||
| 286 | + | case "usage": | |
| 287 | + | var row struct { | |
| 288 | + | Usage *provider.Usage `json:"usage"` | |
| 289 | + | Cumulative *provider.Usage `json:"cumulative"` | |
| 290 | + | } | |
| 291 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 292 | + | return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err) | |
| 293 | + | } | |
| 294 | + | if row.Cumulative == nil { | |
| 295 | + | return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo) | |
| 296 | + | } | |
| 297 | + | rawCheckpoints = append(rawCheckpoints, rawCheckpoint{ | |
| 298 | + | messageCount: len(snapshot.Messages), | |
| 299 | + | cumulative: *row.Cumulative, | |
| 300 | + | generation: generation, | |
| 301 | + | }) | |
| 302 | + | ||
| 303 | + | case "rename": | |
| 304 | + | var row struct { | |
| 305 | + | Title *string `json:"title"` | |
| 306 | + | } | |
| 307 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 308 | + | return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err) | |
| 309 | + | } | |
| 310 | + | if row.Title == nil { | |
| 311 | + | return fmt.Errorf("line %d: rename row has no title", lineNo) | |
| 273 | 312 | } | |
| 313 | + | ||
| 314 | + | default: | |
| 315 | + | return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type) | |
| 274 | 316 | } | |
| 275 | 317 | return nil | |
| 276 | - | }); err != nil { | |
| 318 | + | }) | |
| 319 | + | if err != nil { | |
| 320 | + | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err) | |
| 321 | + | } | |
| 322 | + | if !sawMeta { | |
| 323 | + | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // Repair only after compaction replacement. This makes synthetic tool | |
| 327 | + | // results and the removal of orphaned results part of the same indexed | |
| 328 | + | // stream that OpenSession returns and that BranchSession materializes. | |
| 329 | + | rawMessages := snapshot.Messages | |
| 330 | + | repaired := repairSessionMessages(rawMessages) | |
| 331 | + | snapshot.Messages = repaired.messages | |
| 332 | + | for _, checkpoint := range rawCheckpoints { | |
| 333 | + | count := checkpoint.messageCount | |
| 334 | + | if checkpoint.generation == generation { | |
| 335 | + | count = repaired.prefixCount(count) | |
| 336 | + | } else if count > len(rawMessages) { | |
| 337 | + | // A compaction replaces the effective stream. Preserve the | |
| 338 | + | // cumulative cost represented by an older checkpoint at the | |
| 339 | + | // end of the replacement rather than making every post-summary | |
| 340 | + | // branch appear to have zero usage. | |
| 341 | + | count = len(rawMessages) | |
| 342 | + | } | |
| 343 | + | snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{ | |
| 344 | + | MessageCount: count, | |
| 345 | + | Cumulative: checkpoint.cumulative, | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | return snapshot, nil | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // forEachStrictJSONLLine is the complete-read counterpart to | |
| 352 | + | // forEachJSONLLine. It rejects malformed and blank rows and reports the | |
| 353 | + | // line number so callers cannot accidentally proceed with a partial file. | |
| 354 | + | func forEachStrictJSONLLine(r io.Reader, fn func([]byte, int) error) error { | |
| 355 | + | br := bufio.NewReader(r) | |
| 356 | + | lineNo := 0 | |
| 357 | + | for { | |
| 358 | + | line, err := br.ReadBytes('\n') | |
| 359 | + | if len(line) > 0 { | |
| 360 | + | lineNo++ | |
| 361 | + | line = bytes.TrimRight(line, "\r\n") | |
| 362 | + | if len(bytes.TrimSpace(line)) == 0 { | |
| 363 | + | return fmt.Errorf("line %d: blank row", lineNo) | |
| 364 | + | } | |
| 365 | + | if ferr := fn(line, lineNo); ferr != nil { | |
| 366 | + | return ferr | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if err == io.EOF { | |
| 370 | + | return nil | |
| 371 | + | } | |
| 372 | + | if err != nil { | |
| 373 | + | return err | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // repairedSessionMessage keeps the raw message index that produced an | |
| 379 | + | // effective message. Synthetic tool results use the assistant's raw index; | |
| 380 | + | // this lets usage checkpoints continue to refer to the same effective | |
| 381 | + | // message prefix after repair removes or inserts rows. | |
| 382 | + | type repairedSessionMessage struct { | |
| 383 | + | message provider.Message | |
| 384 | + | rawIndex int | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | type repairedSessionMessages struct { | |
| 388 | + | messages []provider.Message | |
| 389 | + | counts []int | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (r repairedSessionMessages) prefixCount(rawCount int) int { | |
| 393 | + | if rawCount < 0 { | |
| 394 | + | return 0 | |
| 395 | + | } | |
| 396 | + | if rawCount >= len(r.counts) { | |
| 397 | + | return r.counts[len(r.counts)-1] | |
| 398 | + | } | |
| 399 | + | return r.counts[rawCount] | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // repairSessionMessages repairs tool-call pairs and removes orphaned result | |
| 403 | + | // blocks while retaining a mapping from raw effective prefixes to repaired | |
| 404 | + | // prefixes. A tool result is valid only in the immediately following tool | |
| 405 | + | // message for the assistant tool calls; keeping an unrelated result there | |
| 406 | + | // would still make a resumed or branched transcript provider-invalid. | |
| 407 | + | func repairSessionMessages(msgs []provider.Message) repairedSessionMessages { | |
| 408 | + | if len(msgs) == 0 { | |
| 409 | + | return repairedSessionMessages{counts: []int{0}} | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | repaired := make([]repairedSessionMessage, 0, len(msgs)+2) | |
| 413 | + | for i := 0; i < len(msgs); i++ { | |
| 414 | + | m := msgs[i] | |
| 415 | + | if m.Role == provider.RoleAssistant && messageHasToolCalls(m) { | |
| 416 | + | ids := make(map[string]bool) | |
| 417 | + | for _, c := range m.Content { | |
| 418 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 419 | + | ids[tc.ID] = true | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i}) | |
| 423 | + | ||
| 424 | + | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |
| 425 | + | tool := msgs[i+1] | |
| 426 | + | seen := make(map[string]bool) | |
| 427 | + | filtered := make([]provider.Content, 0, len(tool.Content)+len(ids)) | |
| 428 | + | for _, c := range tool.Content { | |
| 429 | + | tr, ok := c.(provider.ToolResultBlock) | |
| 430 | + | if !ok { | |
| 431 | + | filtered = append(filtered, c) | |
| 432 | + | continue | |
| 433 | + | } | |
| 434 | + | if !ids[tr.CallID] || seen[tr.CallID] { | |
| 435 | + | continue | |
| 436 | + | } | |
| 437 | + | seen[tr.CallID] = true | |
| 438 | + | filtered = append(filtered, tr) | |
| 439 | + | } | |
| 440 | + | for _, c := range m.Content { | |
| 441 | + | if tc, ok := c.(provider.ToolCallBlock); ok && !seen[tc.ID] { | |
| 442 | + | filtered = append(filtered, provider.ToolResultBlock{ | |
| 443 | + | CallID: tc.ID, | |
| 444 | + | Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}}, | |
| 445 | + | IsError: true, | |
| 446 | + | }) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | if len(filtered) > 0 { | |
| 450 | + | tool.Content = filtered | |
| 451 | + | repaired = append(repaired, repairedSessionMessage{message: tool, rawIndex: i + 1}) | |
| 452 | + | } | |
| 453 | + | i++ | |
| 454 | + | continue | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | stubs := make([]provider.Content, 0, len(ids)) | |
| 458 | + | for _, c := range m.Content { | |
| 459 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 460 | + | stubs = append(stubs, provider.ToolResultBlock{ | |
| 461 | + | CallID: tc.ID, | |
| 462 | + | Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}}, | |
| 463 | + | IsError: true, | |
| 464 | + | }) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | repaired = append(repaired, repairedSessionMessage{message: provider.Message{ | |
| 468 | + | Role: provider.RoleTool, | |
| 469 | + | Content: stubs, | |
| 470 | + | Time: m.Time, | |
| 471 | + | }, rawIndex: i}) | |
| 472 | + | continue | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | if m.Role == provider.RoleTool { | |
| 476 | + | // Deferred-tool activation rows intentionally carry a result | |
| 477 | + | // without a preceding assistant call. Preserve those rows and | |
| 478 | + | // their AddedToolNames marker; they are consumed as local | |
| 479 | + | // transcript state rather than sent as a provider tool pair. | |
| 480 | + | if len(m.AddedToolNames) > 0 { | |
| 481 | + | repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i}) | |
| 482 | + | continue | |
| 483 | + | } | |
| 484 | + | // This tool row was not immediately paired with an assistant | |
| 485 | + | // tool-call row. Drop its result blocks; a bare tool row is an | |
| 486 | + | // orphan and is not safe to send to a provider. | |
| 487 | + | filtered := make([]provider.Content, 0, len(m.Content)) | |
| 488 | + | for _, c := range m.Content { | |
| 489 | + | if _, ok := c.(provider.ToolResultBlock); !ok { | |
| 490 | + | filtered = append(filtered, c) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | if len(filtered) > 0 { | |
| 494 | + | m.Content = filtered | |
| 495 | + | repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i}) | |
| 496 | + | } | |
| 497 | + | continue | |
| 498 | + | } | |
| 499 | + | repaired = append(repaired, repairedSessionMessage{message: m, rawIndex: i}) | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | out := repairedSessionMessages{ | |
| 503 | + | messages: make([]provider.Message, 0, len(repaired)), | |
| 504 | + | counts: make([]int, len(msgs)+1), | |
| 505 | + | } | |
| 506 | + | for _, item := range repaired { | |
| 507 | + | out.messages = append(out.messages, item.message) | |
| 508 | + | } | |
| 509 | + | // rawIndex values are produced in source order. Accumulate the number of | |
| 510 | + | // repaired messages before each raw prefix in one forward pass instead of | |
| 511 | + | // revisiting every remaining prefix for every repaired message. | |
| 512 | + | repairedIndex := 0 | |
| 513 | + | for rawCount := range out.counts { | |
| 514 | + | for repairedIndex < len(repaired) && repaired[repairedIndex].rawIndex < rawCount { | |
| 515 | + | repairedIndex++ | |
| 516 | + | } | |
| 517 | + | out.counts[rawCount] = repairedIndex | |
| 518 | + | } | |
| 519 | + | return out | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func messageHasToolCalls(msg provider.Message) bool { | |
| 523 | + | for _, content := range msg.Content { | |
| 524 | + | if _, ok := content.(provider.ToolCallBlock); ok { | |
| 525 | + | return true | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | return false | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | // OpenSession opens an existing session for appending. | |
| 532 | + | func OpenSession(path string) (*Session, []provider.Message, error) { | |
| 533 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 534 | + | if err != nil { | |
| 277 | 535 | return nil, nil, err | |
| 278 | 536 | } | |
| 279 | - | messages = repairToolUseResultPairs(messages) | |
| 280 | 537 | out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) | |
| 281 | 538 | if err != nil { | |
| 282 | 539 | return nil, nil, err | |
| 283 | 540 | } | |
| 284 | - | s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)} | |
| 285 | - | return s, messages, nil | |
| 541 | + | s := &Session{ | |
| 542 | + | ID: snapshot.Meta.ID, | |
| 543 | + | Path: path, | |
| 544 | + | Meta: snapshot.Meta, | |
| 545 | + | writer: out, | |
| 546 | + | buf: bufio.NewWriter(out), | |
| 547 | + | } | |
| 548 | + | return s, snapshot.Messages, nil | |
| 286 | 549 | } | |
| 287 | 550 | ||
| 288 | 551 | // repairToolUseResultPairs walks a restored transcript and | |
| @@ -311,68 +574,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||
| 311 | 574 | // | |
| 312 | 575 | // Runs once per OpenSession call. No cost on the hot path. | |
| 313 | 576 | func repairToolUseResultPairs(msgs []provider.Message) []provider.Message { | |
| 314 | - | if len(msgs) == 0 { | |
| 315 | - | return msgs | |
| 316 | - | } | |
| 317 | - | out := make([]provider.Message, 0, len(msgs)+2) | |
| 318 | - | for i, m := range msgs { | |
| 319 | - | out = append(out, m) | |
| 320 | - | if m.Role != provider.RoleAssistant { | |
| 321 | - | continue | |
| 322 | - | } | |
| 323 | - | // Collect tool_use ids in this assistant message. | |
| 324 | - | var ids []string | |
| 325 | - | for _, c := range m.Content { | |
| 326 | - | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 327 | - | ids = append(ids, tc.ID) | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | if len(ids) == 0 { | |
| 331 | - | continue | |
| 332 | - | } | |
| 333 | - | // Look at the next message (if any) and collect tool_result | |
| 334 | - | // CallIDs it covers. | |
| 335 | - | have := map[string]bool{} | |
| 336 | - | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |
| 337 | - | for _, c := range msgs[i+1].Content { | |
| 338 | - | if tr, ok := c.(provider.ToolResultBlock); ok { | |
| 339 | - | have[tr.CallID] = true | |
| 340 | - | } | |
| 341 | - | } | |
| 342 | - | } | |
| 343 | - | // Build stubs for any missing id. | |
| 344 | - | var stubs []provider.Content | |
| 345 | - | for _, id := range ids { | |
| 346 | - | if have[id] { | |
| 347 | - | continue | |
| 348 | - | } | |
| 349 | - | stubs = append(stubs, provider.ToolResultBlock{ | |
| 350 | - | CallID: id, | |
| 351 | - | Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}}, | |
| 352 | - | IsError: true, | |
| 353 | - | }) | |
| 354 | - | } | |
| 355 | - | if len(stubs) == 0 { | |
| 356 | - | continue | |
| 357 | - | } | |
| 358 | - | // Merge into the next tool-role message if present, | |
| 359 | - | // otherwise insert a synthetic one right after the | |
| 360 | - | // assistant message. Merging keeps the tool-role row | |
| 361 | - | // count stable; inserting handles the common case where | |
| 362 | - | // no tool message was persisted at all. | |
| 363 | - | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |
| 364 | - | msgs[i+1].Content = append(msgs[i+1].Content, stubs...) | |
| 365 | - | // We already appended m to out; the modified next | |
| 366 | - | // message will be appended on the following iteration. | |
| 367 | - | continue | |
| 368 | - | } | |
| 369 | - | out = append(out, provider.Message{ | |
| 370 | - | Role: provider.RoleTool, | |
| 371 | - | Content: stubs, | |
| 372 | - | Time: m.Time, | |
| 373 | - | }) | |
| 374 | - | } | |
| 375 | - | return out | |
| 577 | + | return repairSessionMessages(msgs).messages | |
| 376 | 578 | } | |
| 377 | 579 | ||
| 378 | 580 | // LatestSession returns the most recent session file for cwd, or "". | |
| @@ -425,8 +627,8 @@ func DeleteSession(path string) error { | |||
| 425 | 627 | } | |
| 426 | 628 | ||
| 427 | 629 | // DescribeSessions returns lightweight summaries for every session in | |
| 428 | - | // cwd, newest first. Parses only the first few lines and the last usage | |
| 429 | - | // line so it's cheap to run on every dialog open. | |
| 630 | + | // cwd, newest first. It uses the effective snapshot so message counts, | |
| 631 | + | // metadata, and usage agree with resume and branching behavior. | |
| 430 | 632 | func DescribeSessions(root, cwd string) []SessionSummary { | |
| 431 | 633 | paths := ListSessions(root, cwd) | |
| 432 | 634 | summaries := make([]SessionSummary, 0, len(paths)) | |
| @@ -470,73 +672,60 @@ func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[ | |||
| 470 | 672 | ||
| 471 | 673 | func describeSession(path string) SessionSummary { | |
| 472 | 674 | s := SessionSummary{Path: path} | |
| 473 | - | f, err := os.Open(path) | |
| 675 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 474 | 676 | if err != nil { | |
| 475 | 677 | return s | |
| 476 | 678 | } | |
| 477 | - | defer f.Close() | |
| 478 | - | isBranch := false | |
| 479 | - | forkPoint := 0 | |
| 679 | + | s.Started = snapshot.Meta.Started | |
| 680 | + | s.Model = snapshot.Meta.Model | |
| 681 | + | s.Provider = snapshot.Meta.Provider | |
| 682 | + | s.Title = snapshot.Meta.Title | |
| 683 | + | s.MessageCount = len(snapshot.Messages) | |
| 684 | + | s.FirstUserText = firstUserTextFromMessages(snapshot.Messages) | |
| 685 | + | lastUser := lastUserText(snapshot.Messages) | |
| 686 | + | isBranch := snapshot.Meta.Parent != "" | |
| 687 | + | forkPoint := snapshot.Meta.ForkPoint | |
| 688 | + | branchPrompt := firstUserTextFromMessagesAfter(snapshot.Messages, forkPoint) | |
| 689 | + | if len(snapshot.UsageCheckpoints) > 0 { | |
| 690 | + | s.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // Rename rows are append-only and are intentionally kept out of the | |
| 694 | + | // effective transcript. Scan only their placement so generated branch | |
| 695 | + | // titles can still be replaced by the first post-fork prompt while an | |
| 696 | + | // explicit rename after divergence keeps priority. | |
| 480 | 697 | messageIdx := 0 | |
| 481 | - | branchPrompt := "" | |
| 482 | - | lastUser := "" | |
| 483 | 698 | renameMessageIdx := -1 | |
| 484 | - | _ = forEachJSONLLine(f, func(line []byte) error { | |
| 485 | - | var head sessionLineHead | |
| 486 | - | if err := json.Unmarshal(line, &head); err != nil { | |
| 487 | - | return nil | |
| 699 | + | _ = func() error { | |
| 700 | + | f, err := os.Open(path) | |
| 701 | + | if err != nil { | |
| 702 | + | return err | |
| 488 | 703 | } | |
| 489 | - | switch head.Type { | |
| 490 | - | case "meta": | |
| 491 | - | var row struct { | |
| 492 | - | Meta SessionMeta `json:"meta"` | |
| 493 | - | } | |
| 494 | - | if err := json.Unmarshal(line, &row); err == nil { | |
| 495 | - | s.Started = row.Meta.Started | |
| 496 | - | s.Model = row.Meta.Model | |
| 497 | - | s.Provider = row.Meta.Provider | |
| 498 | - | s.Title = row.Meta.Title | |
| 499 | - | isBranch = row.Meta.Parent != "" | |
| 500 | - | forkPoint = row.Meta.ForkPoint | |
| 704 | + | defer f.Close() | |
| 705 | + | return forEachJSONLLine(f, func(line []byte) error { | |
| 706 | + | var head sessionLineHead | |
| 707 | + | if err := json.Unmarshal(line, &head); err != nil { | |
| 708 | + | return nil | |
| 501 | 709 | } | |
| 502 | - | case "message": | |
| 503 | - | if text := firstUserText(line); text != "" { | |
| 504 | - | lastUser = text | |
| 505 | - | if s.FirstUserText == "" { | |
| 506 | - | s.FirstUserText = text | |
| 710 | + | switch head.Type { | |
| 711 | + | case "message": | |
| 712 | + | messageIdx++ | |
| 713 | + | case "compaction": | |
| 714 | + | if compacted, err := hydrateCompaction(line); err == nil { | |
| 715 | + | messageIdx = len(compacted) | |
| 507 | 716 | } | |
| 508 | - | if isBranch && messageIdx >= forkPoint && branchPrompt == "" { | |
| 509 | - | branchPrompt = text | |
| 717 | + | case "rename": | |
| 718 | + | var row struct { | |
| 719 | + | Title string `json:"title"` | |
| 720 | + | } | |
| 721 | + | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | |
| 722 | + | s.Title = row.Title | |
| 723 | + | renameMessageIdx = messageIdx | |
| 510 | 724 | } | |
| 511 | 725 | } | |
| 512 | - | messageIdx++ | |
| 513 | - | s.MessageCount++ | |
| 514 | - | case "compaction": | |
| 515 | - | if compacted, err := hydrateCompaction(line); err == nil { | |
| 516 | - | s.MessageCount = len(compacted) | |
| 517 | - | messageIdx = len(compacted) | |
| 518 | - | s.FirstUserText = firstUserTextFromMessages(compacted) | |
| 519 | - | lastUser = lastUserText(compacted) | |
| 520 | - | branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint) | |
| 521 | - | } | |
| 522 | - | case "rename": | |
| 523 | - | var row struct { | |
| 524 | - | Title string `json:"title"` | |
| 525 | - | } | |
| 526 | - | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | |
| 527 | - | s.Title = row.Title | |
| 528 | - | renameMessageIdx = messageIdx | |
| 529 | - | } | |
| 530 | - | case "usage": | |
| 531 | - | var row struct { | |
| 532 | - | Cumulative provider.Usage `json:"cumulative"` | |
| 533 | - | } | |
| 534 | - | if err := json.Unmarshal(line, &row); err == nil { | |
| 535 | - | s.TotalCost = row.Cumulative.CostUSD | |
| 536 | - | } | |
| 537 | - | } | |
| 538 | - | return nil | |
| 539 | - | }) | |
| 726 | + | return nil | |
| 727 | + | }) | |
| 728 | + | }() | |
| 540 | 729 | if isBranch { | |
| 541 | 730 | // A rename written after the branch has diverged is user intent | |
| 542 | 731 | // and must win. Older generated branch titles were written at | |
| @@ -552,29 +741,6 @@ func describeSession(path string) SessionSummary { | |||
| 552 | 741 | return s | |
| 553 | 742 | } | |
| 554 | 743 | ||
| 555 | - | func firstUserText(line []byte) string { | |
| 556 | - | var row struct { | |
| 557 | - | Message struct { | |
| 558 | - | Role string `json:"role"` | |
| 559 | - | Content []struct { | |
| 560 | - | Text string `json:"text"` | |
| 561 | - | } `json:"content"` | |
| 562 | - | } `json:"message"` | |
| 563 | - | } | |
| 564 | - | if err := json.Unmarshal(line, &row); err != nil { | |
| 565 | - | return "" | |
| 566 | - | } | |
| 567 | - | if row.Message.Role != "user" { | |
| 568 | - | return "" | |
| 569 | - | } | |
| 570 | - | for _, c := range row.Message.Content { | |
| 571 | - | if c.Text != "" { | |
| 572 | - | return c.Text | |
| 573 | - | } | |
| 574 | - | } | |
| 575 | - | return "" | |
| 576 | - | } | |
| 577 | - | ||
| 578 | 744 | func firstUserTextFromMessages(msgs []provider.Message) string { | |
| 579 | 745 | return firstUserTextFromMessagesAfter(msgs, 0) | |
| 580 | 746 | } | |
| @@ -697,6 +863,9 @@ func (s *Session) AppendMessage(m provider.Message) error { | |||
| 697 | 863 | if s == nil { | |
| 698 | 864 | return nil | |
| 699 | 865 | } | |
| 866 | + | if len(m.Content) == 0 { | |
| 867 | + | return errors.New("message has no content") | |
| 868 | + | } | |
| 700 | 869 | if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil { | |
| 701 | 870 | return err | |
| 702 | 871 | } | |
| @@ -712,10 +881,14 @@ func (s *Session) AppendCompaction(messages []provider.Message) error { | |||
| 712 | 881 | if s == nil { | |
| 713 | 882 | return nil | |
| 714 | 883 | } | |
| 715 | - | if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil { | |
| 884 | + | compactionMessages := messages | |
| 885 | + | if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil { | |
| 716 | 886 | return err | |
| 717 | 887 | } | |
| 718 | - | s.messagesAppended = len(messages) | |
| 888 | + | // The compaction row itself is meaningful even when it replaces the | |
| 889 | + | // transcript with an empty or nil slice. Count the append operation so a | |
| 890 | + | // fresh session containing an empty checkpoint is not deleted on Close. | |
| 891 | + | s.messagesAppended++ | |
| 719 | 892 | return nil | |
| 720 | 893 | } | |
| 721 | 894 | ||
| @@ -726,9 +899,15 @@ func (s *Session) UpdateModel(providerName, model string) error { | |||
| 726 | 899 | if s == nil { | |
| 727 | 900 | return nil | |
| 728 | 901 | } | |
| 902 | + | oldProvider, oldModel := s.Meta.Provider, s.Meta.Model | |
| 729 | 903 | s.Meta.Provider = providerName | |
| 730 | 904 | s.Meta.Model = model | |
| 731 | - | return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}) | |
| 905 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 906 | + | s.Meta.Provider = oldProvider | |
| 907 | + | s.Meta.Model = oldModel | |
| 908 | + | return err | |
| 909 | + | } | |
| 910 | + | return nil | |
| 732 | 911 | } | |
| 733 | 912 | ||
| 734 | 913 | // AppendUsage writes a usage row to the session. | |
| @@ -784,17 +963,27 @@ func (s *Session) writeLine(row sessionLine) error { | |||
| 784 | 963 | ||
| 785 | 964 | func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) { | |
| 786 | 965 | var row struct { | |
| 787 | - | Messages []json.RawMessage `json:"messages"` | |
| 966 | + | Messages json.RawMessage `json:"messages"` | |
| 788 | 967 | } | |
| 789 | 968 | if err := json.Unmarshal(lineBytes, &row); err != nil { | |
| 790 | 969 | return nil, err | |
| 791 | 970 | } | |
| 792 | - | messages := make([]provider.Message, 0, len(row.Messages)) | |
| 793 | - | for _, raw := range row.Messages { | |
| 971 | + | if len(row.Messages) == 0 || bytes.Equal(bytes.TrimSpace(row.Messages), []byte("null")) { | |
| 972 | + | // Older writers omitted the field for an empty compaction and some | |
| 973 | + | // wrote it as null. Both represent a valid empty replacement. | |
| 974 | + | return []provider.Message{}, nil | |
| 975 | + | } | |
| 976 | + | var rawMessages []json.RawMessage | |
| 977 | + | if err := json.Unmarshal(row.Messages, &rawMessages); err != nil { | |
| 978 | + | return nil, fmt.Errorf("invalid messages: %w", err) | |
| 979 | + | } | |
| 980 | + | messages := make([]provider.Message, 0, len(rawMessages)) | |
| 981 | + | for idx, raw := range rawMessages { | |
| 794 | 982 | msg, err := hydrateMessageObject(raw) | |
| 795 | - | if err == nil && len(msg.Content) > 0 { | |
| 796 | - | messages = append(messages, msg) | |
| 983 | + | if err != nil { | |
| 984 | + | return nil, fmt.Errorf("message %d: %w", idx, err) | |
| 797 | 985 | } | |
| 986 | + | messages = append(messages, msg) | |
| 798 | 987 | } | |
| 799 | 988 | return messages, nil | |
| 800 | 989 | } | |
| @@ -806,6 +995,9 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) { | |||
| 806 | 995 | if err := json.Unmarshal(lineBytes, &row); err != nil { | |
| 807 | 996 | return provider.Message{}, err | |
| 808 | 997 | } | |
| 998 | + | if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) { | |
| 999 | + | return provider.Message{}, fmt.Errorf("message row has no message") | |
| 1000 | + | } | |
| 809 | 1001 | return hydrateMessageObject(row.Message) | |
| 810 | 1002 | } | |
| 811 | 1003 | ||
| @@ -820,8 +1012,17 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 820 | 1012 | if err := json.Unmarshal(rawMessage, &row); err != nil { | |
| 821 | 1013 | return provider.Message{}, err | |
| 822 | 1014 | } | |
| 1015 | + | if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool { | |
| 1016 | + | return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role) | |
| 1017 | + | } | |
| 1018 | + | if len(row.Content) == 0 { | |
| 1019 | + | return provider.Message{}, fmt.Errorf("message has no content") | |
| 1020 | + | } | |
| 823 | 1021 | msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames} | |
| 824 | - | for _, raw := range row.Content { | |
| 1022 | + | for idx, raw := range row.Content { | |
| 1023 | + | if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { | |
| 1024 | + | return provider.Message{}, fmt.Errorf("content block %d is empty", idx) | |
| 1025 | + | } | |
| 825 | 1026 | var head struct { | |
| 826 | 1027 | Text string `json:"text"` | |
| 827 | 1028 | MimeType string `json:"mime_type"` | |
| @@ -836,7 +1037,14 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 836 | 1037 | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | |
| 837 | 1038 | } | |
| 838 | 1039 | if err := json.Unmarshal(raw, &head); err != nil { | |
| 839 | - | continue | |
| 1040 | + | return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err) | |
| 1041 | + | } | |
| 1042 | + | var fields map[string]json.RawMessage | |
| 1043 | + | if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { | |
| 1044 | + | if err == nil { | |
| 1045 | + | err = fmt.Errorf("content block is not an object") | |
| 1046 | + | } | |
| 1047 | + | return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err) | |
| 840 | 1048 | } | |
| 841 | 1049 | // Discriminate by presence of fields. | |
| 842 | 1050 | switch { | |
| @@ -846,7 +1054,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 846 | 1054 | Summary: head.Summary, | |
| 847 | 1055 | Encrypted: head.Encrypted, | |
| 848 | 1056 | }) | |
| 849 | - | case head.Name != "" && head.ID != "": | |
| 1057 | + | case head.Name != "" || head.ID != "" || fields["arguments"] != nil: | |
| 1058 | + | if head.Name == "" || head.ID == "" { | |
| 1059 | + | return provider.Message{}, fmt.Errorf("content block %d: incomplete tool call", idx) | |
| 1060 | + | } | |
| 850 | 1061 | var tc struct { | |
| 851 | 1062 | ID string `json:"id"` | |
| 852 | 1063 | Name string `json:"name"` | |
| @@ -866,21 +1077,40 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 866 | 1077 | Content []json.RawMessage `json:"content"` | |
| 867 | 1078 | IsError bool `json:"is_error"` | |
| 868 | 1079 | } | |
| 869 | - | _ = json.Unmarshal(raw, &tr) | |
| 1080 | + | if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil { | |
| 1081 | + | if err == nil { | |
| 1082 | + | err = fmt.Errorf("tool result has no content") | |
| 1083 | + | } | |
| 1084 | + | return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err) | |
| 1085 | + | } | |
| 870 | 1086 | block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError} | |
| 871 | - | for _, c := range tr.Content { | |
| 1087 | + | for innerIdx, c := range tr.Content { | |
| 872 | 1088 | var inner struct { | |
| 873 | 1089 | Text string `json:"text"` | |
| 874 | 1090 | MimeType string `json:"mime_type"` | |
| 875 | 1091 | Data []byte `json:"data"` | |
| 876 | 1092 | } | |
| 877 | - | _ = json.Unmarshal(c, &inner) | |
| 1093 | + | if err := json.Unmarshal(c, &inner); err != nil { | |
| 1094 | + | return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err) | |
| 1095 | + | } | |
| 1096 | + | var innerFields map[string]json.RawMessage | |
| 1097 | + | if err := json.Unmarshal(c, &innerFields); err != nil || innerFields == nil { | |
| 1098 | + | if err == nil { | |
| 1099 | + | err = fmt.Errorf("result content is not an object") | |
| 1100 | + | } | |
| 1101 | + | return provider.Message{}, fmt.Errorf("content block %d result %d: %w", idx, innerIdx, err) | |
| 1102 | + | } | |
| 878 | 1103 | if inner.MimeType != "" { | |
| 879 | 1104 | block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data}) | |
| 880 | - | } else { | |
| 1105 | + | } else if _, ok := innerFields["text"]; ok { | |
| 881 | 1106 | block.Content = append(block.Content, provider.TextBlock{Text: inner.Text}) | |
| 1107 | + | } else { | |
| 1108 | + | return provider.Message{}, fmt.Errorf("content block %d result %d: unknown content", idx, innerIdx) | |
| 882 | 1109 | } | |
| 883 | 1110 | } | |
| 1111 | + | if len(block.Content) == 0 { | |
| 1112 | + | return provider.Message{}, fmt.Errorf("content block %d tool result has no content", idx) | |
| 1113 | + | } | |
| 884 | 1114 | msg.Content = append(msg.Content, block) | |
| 885 | 1115 | case head.MimeType != "": | |
| 886 | 1116 | msg.Content = append(msg.Content, provider.ImageBlock{ | |
| @@ -889,6 +1119,9 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 889 | 1119 | ThoughtSignature: head.ThoughtSignature, | |
| 890 | 1120 | }) | |
| 891 | 1121 | default: | |
| 1122 | + | if _, ok := fields["text"]; !ok { | |
| 1123 | + | return provider.Message{}, fmt.Errorf("content block %d: unknown content", idx) | |
| 1124 | + | } | |
| 892 | 1125 | msg.Content = append(msg.Content, provider.TextBlock{ | |
| 893 | 1126 | Text: head.Text, | |
| 894 | 1127 | ThoughtSignature: head.ThoughtSignature, | |
M packages/core/session_portable.go +339 -117
| @@ -40,6 +40,13 @@ func ExportSession(srcPath, dstPath string) (string, error) { | |||
| 40 | 40 | return "", errors.New("export: destination path is empty") | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | // Read the effective snapshot so a later model/provider update is not | |
| 44 | + | // replaced by the stale metadata in the first row. | |
| 45 | + | snapshot, err := ReadSessionSnapshot(srcPath) | |
| 46 | + | if err != nil { | |
| 47 | + | return "", fmt.Errorf("export: read session snapshot: %w", err) | |
| 48 | + | } | |
| 49 | + | ||
| 43 | 50 | // Read the source meta up-front so we can name the output sensibly | |
| 44 | 51 | // when dstPath is a directory, and so we can validate it's a real | |
| 45 | 52 | // session before starting to write. | |
| @@ -103,7 +110,7 @@ func ExportSession(srcPath, dstPath string) (string, error) { | |||
| 103 | 110 | // Rewrite the meta row: strip the cwd (the importing user has | |
| 104 | 111 | // their own) and keep everything else identical. ID stays so the | |
| 105 | 112 | // export is traceable; the importer will rotate to a fresh ID. | |
| 106 | - | exportMeta := *head.Meta | |
| 113 | + | exportMeta := snapshot.Meta | |
| 107 | 114 | exportMeta.CWD = "" | |
| 108 | 115 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta}) | |
| 109 | 116 | if err != nil { | |
| @@ -167,6 +174,14 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 167 | 174 | } | |
| 168 | 175 | defer src.Close() | |
| 169 | 176 | ||
| 177 | + | // Read the effective metadata before committing to a destination. Meta | |
| 178 | + | // rows are append-only, so the first row may describe an older provider or | |
| 179 | + | // model after a runtime switch. | |
| 180 | + | snapshot, err := ReadSessionSnapshot(srcPath) | |
| 181 | + | if err != nil { | |
| 182 | + | return "", fmt.Errorf("import: read session snapshot: %w", err) | |
| 183 | + | } | |
| 184 | + | ||
| 170 | 185 | // Validate the file header before committing to a destination. | |
| 171 | 186 | sc := bufio.NewScanner(src) | |
| 172 | 187 | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| @@ -201,8 +216,8 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 201 | 216 | importMeta := SessionMeta{ | |
| 202 | 217 | ID: newID, | |
| 203 | 218 | CWD: cwd, | |
| 204 | - | Model: head.Meta.Model, | |
| 205 | - | Provider: head.Meta.Provider, | |
| 219 | + | Model: snapshot.Meta.Model, | |
| 220 | + | Provider: snapshot.Meta.Provider, | |
| 206 | 221 | Started: time.Now().UTC(), | |
| 207 | 222 | Version: version, | |
| 208 | 223 | } | |
| @@ -245,14 +260,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 245 | 260 | // parent's messages 0..upToMessageIdx-1 (i.e. the first N user+ | |
| 246 | 261 | // assistant+tool rows). The new meta records Parent=<parent id> and | |
| 247 | 262 | // ForkPoint=N so /session tree can rebuild the branch topology | |
| 248 | - | // later. All non-message rows (usage) are preserved up to the cut | |
| 249 | - | // point so the running cost tracker stays accurate. | |
| 263 | + | // later. The effective repaired prefix is materialized, and all eligible | |
| 264 | + | // usage checkpoints at or before the cut are preserved so the running cost | |
| 265 | + | // tracker can reconstruct the final-turn delta. A boundary that ends on a | |
| 266 | + | // tool call is extended through its paired tool result. | |
| 250 | 267 | // | |
| 251 | 268 | // upToMessageIdx is a count over the flat message stream as | |
| 252 | 269 | // returned by OpenSession. To "branch at user turn 3" the caller | |
| 253 | 270 | // passes the index of that user message in msgs + 1 (so the | |
| 254 | 271 | // message itself is included). The caller figures that out; this | |
| 255 | - | // helper just copies the first N message rows. | |
| 272 | + | // helper rejects a count outside the current effective snapshot. | |
| 256 | 273 | // | |
| 257 | 274 | // Returns the path of the new session file, ready for OpenSession. | |
| 258 | 275 | func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | |
| @@ -273,29 +290,23 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 273 | 290 | return "", errors.New("branch: upToMessageIdx must be >= 0") | |
| 274 | 291 | } | |
| 275 | 292 | ||
| 276 | - | src, err := os.Open(parentPath) | |
| 293 | + | snapshot, err := ReadSessionSnapshot(parentPath) | |
| 277 | 294 | if err != nil { | |
| 278 | - | return "", fmt.Errorf("branch: open parent: %w", err) | |
| 295 | + | return "", fmt.Errorf("branch: read parent snapshot: %w", err) | |
| 279 | 296 | } | |
| 280 | - | defer src.Close() | |
| 281 | - | ||
| 282 | - | // Read the parent meta so we can copy model/provider and record | |
| 283 | - | // the parent id on the child. | |
| 284 | - | sc := bufio.NewScanner(src) | |
| 285 | - | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| 286 | - | if !sc.Scan() { | |
| 287 | - | return "", errors.New("branch: parent session is empty") | |
| 297 | + | if upToMessageIdx > len(snapshot.Messages) { | |
| 298 | + | return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages)) | |
| 288 | 299 | } | |
| 289 | - | var head sessionLine | |
| 290 | - | if err := json.Unmarshal(sc.Bytes(), &head); err != nil { | |
| 291 | - | return "", fmt.Errorf("branch: parse parent meta: %w", err) | |
| 292 | - | } | |
| 293 | - | if head.Type != "meta" || head.Meta == nil { | |
| 294 | - | return "", errors.New("branch: parent first line is not a meta row") | |
| 300 | + | ||
| 301 | + | // A tool-call row is not a safe provider boundary by itself. Accept the | |
| 302 | + | // convenient row index used by existing callers, but extend it through the | |
| 303 | + | // immediately paired tool result when necessary. Orphan calls already have | |
| 304 | + | // a synthetic result in the shared snapshot. | |
| 305 | + | limit := upToMessageIdx | |
| 306 | + | if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool { | |
| 307 | + | limit++ | |
| 295 | 308 | } | |
| 296 | - | parentMeta := *head.Meta | |
| 297 | 309 | ||
| 298 | - | // Build the destination file. | |
| 299 | 310 | dir := SessionsDir(root, cwd) | |
| 300 | 311 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 301 | 312 | return "", err | |
| @@ -303,23 +314,38 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 303 | 314 | newID := uuid.NewString() | |
| 304 | 315 | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8]) | |
| 305 | 316 | outPath := filepath.Join(dir, name) | |
| 306 | - | dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | |
| 317 | + | // Build in a same-directory temporary file. Rename is atomic only when | |
| 318 | + | // the temporary and final paths share a directory; closing the file first | |
| 319 | + | // also ensures no buffered writer or descriptor can expose a partial branch. | |
| 320 | + | dst, err := os.CreateTemp(dir, ".zot-branch-*.tmp") | |
| 307 | 321 | if err != nil { | |
| 308 | - | return "", fmt.Errorf("branch: create dst: %w", err) | |
| 309 | - | } | |
| 310 | - | defer dst.Close() | |
| 322 | + | return "", fmt.Errorf("branch: create temp: %w", err) | |
| 323 | + | } | |
| 324 | + | tmpPath := dst.Name() | |
| 325 | + | if err := dst.Chmod(0o644); err != nil { | |
| 326 | + | _ = dst.Close() | |
| 327 | + | _ = os.Remove(tmpPath) | |
| 328 | + | return "", fmt.Errorf("branch: chmod temp: %w", err) | |
| 329 | + | } | |
| 330 | + | committed := false | |
| 331 | + | defer func() { | |
| 332 | + | _ = dst.Close() | |
| 333 | + | if !committed { | |
| 334 | + | _ = os.Remove(tmpPath) | |
| 335 | + | _ = os.Remove(outPath) | |
| 336 | + | } | |
| 337 | + | }() | |
| 311 | 338 | bw := bufio.NewWriter(dst) | |
| 312 | 339 | ||
| 313 | - | // Write the branch meta. | |
| 314 | 340 | branchMeta := SessionMeta{ | |
| 315 | 341 | ID: newID, | |
| 316 | 342 | CWD: cwd, | |
| 317 | - | Model: parentMeta.Model, | |
| 318 | - | Provider: parentMeta.Provider, | |
| 343 | + | Model: snapshot.Meta.Model, | |
| 344 | + | Provider: snapshot.Meta.Provider, | |
| 319 | 345 | Started: time.Now().UTC(), | |
| 320 | 346 | Version: version, | |
| 321 | - | Parent: parentMeta.ID, | |
| 322 | - | ForkPoint: upToMessageIdx, | |
| 347 | + | Parent: snapshot.Meta.ID, | |
| 348 | + | ForkPoint: limit, | |
| 323 | 349 | HideFromSessions: hideFromSessions, | |
| 324 | 350 | } | |
| 325 | 351 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| @@ -333,79 +359,52 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 333 | 359 | return "", err | |
| 334 | 360 | } | |
| 335 | 361 | ||
| 336 | - | // Reconstruct the effective transcript the same way OpenSession | |
| 337 | - | // does: message rows append, and compaction rows replace everything | |
| 338 | - | // before them. The fork index is defined over that effective stream, | |
| 339 | - | // not over the raw audit rows kept on disk before a compaction. | |
| 340 | - | if _, err := src.Seek(0, io.SeekStart); err != nil { | |
| 341 | - | return "", fmt.Errorf("branch: rewind parent: %w", err) | |
| 342 | - | } | |
| 343 | - | var effective []provider.Message | |
| 344 | - | var nonCompactedRows [][]byte | |
| 345 | - | effectiveCount := 0 | |
| 346 | - | sawCompaction := false | |
| 347 | - | if err := forEachJSONLLine(src, func(line []byte) error { | |
| 348 | - | var h sessionLineHead | |
| 349 | - | if err := json.Unmarshal(line, &h); err != nil { | |
| 350 | - | return nil | |
| 362 | + | // Always materialize the shared effective prefix. Replaying raw rows here | |
| 363 | + | // would lose compaction replacement or synthetic tool results and make the | |
| 364 | + | // child differ from the transcript that was displayed and selected. | |
| 365 | + | for idx := 0; idx < limit; idx++ { | |
| 366 | + | msg := snapshot.Messages[idx] | |
| 367 | + | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | |
| 368 | + | if err != nil { | |
| 369 | + | return "", fmt.Errorf("branch: marshal message %d: %w", idx, err) | |
| 351 | 370 | } | |
| 352 | - | switch h.Type { | |
| 353 | - | case "message": | |
| 354 | - | if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 { | |
| 355 | - | effective = append(effective, msg) | |
| 356 | - | if !sawCompaction && effectiveCount < upToMessageIdx { | |
| 357 | - | raw := append([]byte(nil), line...) | |
| 358 | - | nonCompactedRows = append(nonCompactedRows, raw) | |
| 359 | - | } | |
| 360 | - | effectiveCount++ | |
| 361 | - | } | |
| 362 | - | case "compaction": | |
| 363 | - | if compacted, err := hydrateCompaction(line); err == nil { | |
| 364 | - | effective = compacted | |
| 365 | - | effectiveCount = len(effective) | |
| 366 | - | sawCompaction = true | |
| 367 | - | } | |
| 368 | - | case "usage": | |
| 369 | - | if !sawCompaction && effectiveCount < upToMessageIdx { | |
| 370 | - | raw := append([]byte(nil), line...) | |
| 371 | - | nonCompactedRows = append(nonCompactedRows, raw) | |
| 372 | - | } | |
| 371 | + | if _, err := bw.Write(line); err != nil { | |
| 372 | + | return "", err | |
| 373 | + | } | |
| 374 | + | if err := bw.WriteByte('\n'); err != nil { | |
| 375 | + | return "", err | |
| 373 | 376 | } | |
| 374 | - | return nil | |
| 375 | - | }); err != nil && err != io.EOF { | |
| 376 | - | return "", fmt.Errorf("branch: read parent: %w", err) | |
| 377 | 377 | } | |
| 378 | - | limit := upToMessageIdx | |
| 379 | - | if limit > len(effective) { | |
| 380 | - | limit = len(effective) | |
| 381 | - | } | |
| 382 | - | branchTitle := lastUserText(effective[:limit]) | |
| 383 | - | if sawCompaction { | |
| 384 | - | for i := 0; i < limit; i++ { | |
| 385 | - | msg := effective[i] | |
| 386 | - | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | |
| 387 | - | if err != nil { | |
| 388 | - | return "", fmt.Errorf("branch: marshal message: %w", err) | |
| 389 | - | } | |
| 390 | - | if _, err := bw.Write(line); err != nil { | |
| 391 | - | return "", err | |
| 392 | - | } | |
| 393 | - | if err := bw.WriteByte('\n'); err != nil { | |
| 394 | - | return "", err | |
| 395 | - | } | |
| 378 | + | ||
| 379 | + | // Preserve every cumulative checkpoint that belongs to this prefix. The | |
| 380 | + | // final two rows are needed to reconstruct LastTurnUsage as a delta; a | |
| 381 | + | // branch containing only the latest row would incorrectly report the | |
| 382 | + | // whole cumulative total as its final turn. | |
| 383 | + | for _, checkpoint := range snapshot.UsageCheckpoints { | |
| 384 | + | if checkpoint.MessageCount > limit { | |
| 385 | + | continue | |
| 396 | 386 | } | |
| 397 | - | } else { | |
| 398 | - | for _, row := range nonCompactedRows { | |
| 399 | - | if _, err := bw.Write(row); err != nil { | |
| 400 | - | return "", err | |
| 401 | - | } | |
| 402 | - | if err := bw.WriteByte('\n'); err != nil { | |
| 403 | - | return "", err | |
| 404 | - | } | |
| 387 | + | usageLine, err := json.Marshal(sessionLine{ | |
| 388 | + | Type: "usage", | |
| 389 | + | Usage: &checkpoint.Cumulative, | |
| 390 | + | Cumulative: &checkpoint.Cumulative, | |
| 391 | + | }) | |
| 392 | + | if err != nil { | |
| 393 | + | return "", fmt.Errorf("branch: marshal usage: %w", err) | |
| 394 | + | } | |
| 395 | + | if _, err := bw.Write(usageLine); err != nil { | |
| 396 | + | return "", err | |
| 397 | + | } | |
| 398 | + | if err := bw.WriteByte('\n'); err != nil { | |
| 399 | + | return "", err | |
| 405 | 400 | } | |
| 406 | 401 | } | |
| 407 | - | if branchTitle != "" { | |
| 408 | - | line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle}) | |
| 402 | + | ||
| 403 | + | if branchTitle := lastUserText(snapshot.Messages[:limit]); branchTitle != "" { | |
| 404 | + | line, err := json.Marshal(map[string]string{"type": "rename", "title": branchTitle}) | |
| 405 | + | if err != nil { | |
| 406 | + | return "", fmt.Errorf("branch: marshal title: %w", err) | |
| 407 | + | } | |
| 409 | 408 | if _, err := bw.Write(line); err != nil { | |
| 410 | 409 | return "", err | |
| 411 | 410 | } | |
| @@ -416,6 +415,16 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 416 | 415 | if err := bw.Flush(); err != nil { | |
| 417 | 416 | return "", err | |
| 418 | 417 | } | |
| 418 | + | if err := dst.Sync(); err != nil { | |
| 419 | + | return "", fmt.Errorf("branch: sync temp: %w", err) | |
| 420 | + | } | |
| 421 | + | if err := dst.Close(); err != nil { | |
| 422 | + | return "", fmt.Errorf("branch: close temp: %w", err) | |
| 423 | + | } | |
| 424 | + | if err := os.Rename(tmpPath, outPath); err != nil { | |
| 425 | + | return "", fmt.Errorf("branch: commit: %w", err) | |
| 426 | + | } | |
| 427 | + | committed = true | |
| 419 | 428 | return outPath, nil | |
| 420 | 429 | } | |
| 421 | 430 | ||
| @@ -464,6 +473,183 @@ func BuildSessionTree(root, cwd string) []*TreeNode { | |||
| 464 | 473 | nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta} | |
| 465 | 474 | order = append(order, meta.ID) | |
| 466 | 475 | } | |
| 476 | + | return linkSessionTreeNodes(nodes, order) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // BuildSessionTreeStrict is the complete-read counterpart to the permissive | |
| 480 | + | // forest builder. It validates every session file before linking any node, so | |
| 481 | + | // callers that need a complete forest cannot silently omit malformed members. | |
| 482 | + | func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) { | |
| 483 | + | dir := SessionsDir(root, cwd) | |
| 484 | + | entries, err := os.ReadDir(dir) | |
| 485 | + | if err != nil { | |
| 486 | + | if errors.Is(err, os.ErrNotExist) { | |
| 487 | + | return nil, nil | |
| 488 | + | } | |
| 489 | + | return nil, fmt.Errorf("build session tree: read directory: %w", err) | |
| 490 | + | } | |
| 491 | + | nodes := make(map[string]*TreeNode) | |
| 492 | + | order := make([]string, 0, len(entries)) | |
| 493 | + | for _, e := range entries { | |
| 494 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 495 | + | continue | |
| 496 | + | } | |
| 497 | + | path := filepath.Join(dir, e.Name()) | |
| 498 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 499 | + | if err != nil { | |
| 500 | + | return nil, fmt.Errorf("build session tree: read %q: %w", path, err) | |
| 501 | + | } | |
| 502 | + | if snapshot.Meta.ID == "" { | |
| 503 | + | return nil, fmt.Errorf("build session tree: %q has no session id", path) | |
| 504 | + | } | |
| 505 | + | summary := SessionSummary{ | |
| 506 | + | Path: path, | |
| 507 | + | Started: snapshot.Meta.Started, | |
| 508 | + | Model: snapshot.Meta.Model, | |
| 509 | + | Provider: snapshot.Meta.Provider, | |
| 510 | + | MessageCount: len(snapshot.Messages), | |
| 511 | + | FirstUserText: firstUserTextFromMessages(snapshot.Messages), | |
| 512 | + | Title: snapshot.Meta.Title, | |
| 513 | + | } | |
| 514 | + | if len(snapshot.UsageCheckpoints) > 0 { | |
| 515 | + | summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD | |
| 516 | + | } | |
| 517 | + | if _, exists := nodes[snapshot.Meta.ID]; exists { | |
| 518 | + | return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID) | |
| 519 | + | } | |
| 520 | + | nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta} | |
| 521 | + | order = append(order, snapshot.Meta.ID) | |
| 522 | + | } | |
| 523 | + | return linkSessionTreeNodes(nodes, order), nil | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | // BuildSessionTreeFamilyStrict locates the current session using lightweight | |
| 527 | + | // metadata headers, then strictly validates only that root and its descendants. | |
| 528 | + | // A corrupt unrelated root in the same cwd bucket must not hide an otherwise | |
| 529 | + | // readable current family, while a corrupt member of the selected family still | |
| 530 | + | // fails the whole preflight. | |
| 531 | + | func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) { | |
| 532 | + | if currentPath == "" { | |
| 533 | + | return nil, errors.New("build session family: current path is empty") | |
| 534 | + | } | |
| 535 | + | dir := SessionsDir(root, cwd) | |
| 536 | + | entries, err := os.ReadDir(dir) | |
| 537 | + | if err != nil { | |
| 538 | + | if errors.Is(err, os.ErrNotExist) { | |
| 539 | + | return nil, nil | |
| 540 | + | } | |
| 541 | + | return nil, fmt.Errorf("build session family: read directory: %w", err) | |
| 542 | + | } | |
| 543 | + | cleanCurrent := filepath.Clean(currentPath) | |
| 544 | + | nodes := make(map[string]*TreeNode) | |
| 545 | + | order := make([]string, 0, len(entries)) | |
| 546 | + | for _, entry := range entries { | |
| 547 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { | |
| 548 | + | continue | |
| 549 | + | } | |
| 550 | + | path := filepath.Join(dir, entry.Name()) | |
| 551 | + | meta, err := readSessionMetaHeader(path) | |
| 552 | + | if err != nil { | |
| 553 | + | if filepath.Clean(path) == cleanCurrent { | |
| 554 | + | return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err) | |
| 555 | + | } | |
| 556 | + | // A file whose header is unreadable cannot be associated with the | |
| 557 | + | // selected family. Valid descendants are linked below and fully | |
| 558 | + | // preflighted; unrelated corrupt roots stay out of this family. | |
| 559 | + | continue | |
| 560 | + | } | |
| 561 | + | if _, exists := nodes[meta.ID]; exists { | |
| 562 | + | return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID) | |
| 563 | + | } | |
| 564 | + | nodes[meta.ID] = &TreeNode{ | |
| 565 | + | Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider}, | |
| 566 | + | Meta: meta, | |
| 567 | + | } | |
| 568 | + | order = append(order, meta.ID) | |
| 569 | + | } | |
| 570 | + | roots := linkSessionTreeNodes(nodes, order) | |
| 571 | + | var familyRoot *TreeNode | |
| 572 | + | for _, candidate := range roots { | |
| 573 | + | if treeNodeContainsPath(candidate, cleanCurrent) { | |
| 574 | + | familyRoot = candidate | |
| 575 | + | break | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | if familyRoot == nil { | |
| 579 | + | return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath) | |
| 580 | + | } | |
| 581 | + | var validate func(*TreeNode) error | |
| 582 | + | validate = func(node *TreeNode) error { | |
| 583 | + | snapshot, err := ReadSessionSnapshot(node.Summary.Path) | |
| 584 | + | if err != nil { | |
| 585 | + | return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err) | |
| 586 | + | } | |
| 587 | + | if snapshot.Meta.ID != node.Meta.ID { | |
| 588 | + | return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path) | |
| 589 | + | } | |
| 590 | + | node.Meta = snapshot.Meta | |
| 591 | + | node.Summary = SessionSummary{ | |
| 592 | + | Path: node.Summary.Path, | |
| 593 | + | Started: snapshot.Meta.Started, | |
| 594 | + | Model: snapshot.Meta.Model, | |
| 595 | + | Provider: snapshot.Meta.Provider, | |
| 596 | + | MessageCount: len(snapshot.Messages), | |
| 597 | + | FirstUserText: firstUserTextFromMessages(snapshot.Messages), | |
| 598 | + | Title: snapshot.Meta.Title, | |
| 599 | + | } | |
| 600 | + | if len(snapshot.UsageCheckpoints) > 0 { | |
| 601 | + | node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD | |
| 602 | + | } | |
| 603 | + | for _, child := range node.Children { | |
| 604 | + | if err := validate(child); err != nil { | |
| 605 | + | return err | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | return nil | |
| 609 | + | } | |
| 610 | + | if err := validate(familyRoot); err != nil { | |
| 611 | + | return nil, err | |
| 612 | + | } | |
| 613 | + | return []*TreeNode{familyRoot}, nil | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | func readSessionMetaHeader(path string) (SessionMeta, error) { | |
| 617 | + | f, err := os.Open(path) | |
| 618 | + | if err != nil { | |
| 619 | + | return SessionMeta{}, err | |
| 620 | + | } | |
| 621 | + | defer f.Close() | |
| 622 | + | line, err := bufio.NewReader(f).ReadBytes('\n') | |
| 623 | + | if err != nil && len(line) == 0 { | |
| 624 | + | return SessionMeta{}, err | |
| 625 | + | } | |
| 626 | + | line = bytes.TrimSpace(line) | |
| 627 | + | var row sessionLine | |
| 628 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 629 | + | return SessionMeta{}, err | |
| 630 | + | } | |
| 631 | + | if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" { | |
| 632 | + | return SessionMeta{}, errors.New("first row is not a valid meta row") | |
| 633 | + | } | |
| 634 | + | return *row.Meta, nil | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | func treeNodeContainsPath(node *TreeNode, path string) bool { | |
| 638 | + | if node == nil { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | if filepath.Clean(node.Summary.Path) == filepath.Clean(path) { | |
| 642 | + | return true | |
| 643 | + | } | |
| 644 | + | for _, child := range node.Children { | |
| 645 | + | if treeNodeContainsPath(child, path) { | |
| 646 | + | return true | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | return false | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode { | |
| 467 | 653 | var roots []*TreeNode | |
| 468 | 654 | for _, id := range order { | |
| 469 | 655 | n := nodes[id] | |
| @@ -474,35 +660,71 @@ func BuildSessionTree(root, cwd string) []*TreeNode { | |||
| 474 | 660 | if parent, ok := nodes[n.Meta.Parent]; ok { | |
| 475 | 661 | parent.Children = append(parent.Children, n) | |
| 476 | 662 | } else { | |
| 477 | - | // Parent file missing (was manually deleted). Treat as | |
| 478 | - | // a root so it still shows up in the tree. | |
| 663 | + | // Parent file missing (was manually deleted). Treat as a root so it | |
| 664 | + | // still shows up in the tree. | |
| 479 | 665 | roots = append(roots, n) | |
| 480 | 666 | } | |
| 481 | 667 | } | |
| 482 | 668 | return roots | |
| 483 | 669 | } | |
| 484 | 670 | ||
| 485 | - | // readSessionMeta opens path, reads the meta row, and returns it. | |
| 486 | - | // Empty SessionMeta when the file is missing or not a valid session. | |
| 487 | - | func readSessionMeta(path string) (SessionMeta, error) { | |
| 671 | + | // scanSessionMeta reads JSONL rows without hydrating transcript content and | |
| 672 | + | // returns the latest valid metadata row. Listing and ID lookup only need this | |
| 673 | + | // small projection; full snapshot validation remains the responsibility of | |
| 674 | + | // resume, export, branching, and tree preflight callers. | |
| 675 | + | func scanSessionMeta(path string) (meta SessionMeta, err error) { | |
| 488 | 676 | f, err := os.Open(path) | |
| 489 | 677 | if err != nil { | |
| 490 | 678 | return SessionMeta{}, err | |
| 491 | 679 | } | |
| 492 | 680 | defer f.Close() | |
| 493 | - | sc := bufio.NewScanner(f) | |
| 494 | - | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| 495 | - | if !sc.Scan() { | |
| 496 | - | return SessionMeta{}, errors.New("empty file") | |
| 497 | - | } | |
| 498 | - | var line sessionLine | |
| 499 | - | if err := json.Unmarshal(sc.Bytes(), &line); err != nil { | |
| 500 | - | return SessionMeta{}, err | |
| 681 | + | ||
| 682 | + | var sawMeta bool | |
| 683 | + | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 684 | + | var head sessionLineHead | |
| 685 | + | if err := json.Unmarshal(line, &head); err != nil { | |
| 686 | + | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| 687 | + | } | |
| 688 | + | if head.Type == "" { | |
| 689 | + | return fmt.Errorf("line %d: missing row type", lineNo) | |
| 690 | + | } | |
| 691 | + | if !sawMeta && head.Type != "meta" { | |
| 692 | + | return fmt.Errorf("line %d: first row is not meta", lineNo) | |
| 693 | + | } | |
| 694 | + | switch head.Type { | |
| 695 | + | case "meta": | |
| 696 | + | var row struct { | |
| 697 | + | Meta *SessionMeta `json:"meta"` | |
| 698 | + | } | |
| 699 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 700 | + | return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err) | |
| 701 | + | } | |
| 702 | + | if row.Meta == nil || row.Meta.ID == "" { | |
| 703 | + | return fmt.Errorf("line %d: meta row has no session id", lineNo) | |
| 704 | + | } | |
| 705 | + | meta = *row.Meta | |
| 706 | + | sawMeta = true | |
| 707 | + | case "message", "compaction", "usage", "rename": | |
| 708 | + | // These rows are intentionally not hydrated here. | |
| 709 | + | default: | |
| 710 | + | return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type) | |
| 711 | + | } | |
| 712 | + | return nil | |
| 713 | + | }) | |
| 714 | + | if err != nil { | |
| 715 | + | return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err) | |
| 501 | 716 | } | |
| 502 | - | if line.Type != "meta" || line.Meta == nil { | |
| 503 | - | return SessionMeta{}, errors.New("first line is not meta") | |
| 717 | + | if !sawMeta { | |
| 718 | + | return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path) | |
| 504 | 719 | } | |
| 505 | - | return *line.Meta, nil | |
| 720 | + | return meta, nil | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | // readSessionMeta returns the latest validated metadata for path without | |
| 724 | + | // reconstructing its transcript. Full snapshot validation is performed by | |
| 725 | + | // callers that need messages rather than listing metadata. | |
| 726 | + | func readSessionMeta(path string) (SessionMeta, error) { | |
| 727 | + | return scanSessionMeta(path) | |
| 506 | 728 | } | |
| 507 | 729 | ||
| 508 | 730 | // FindSessionByID looks up a session file in root/cwd whose meta id | |
| @@ -519,7 +741,7 @@ func FindSessionByID(root, cwd, id string) string { | |||
| 519 | 741 | continue | |
| 520 | 742 | } | |
| 521 | 743 | path := filepath.Join(dir, e.Name()) | |
| 522 | - | meta, err := readSessionMeta(path) | |
| 744 | + | meta, err := scanSessionMeta(path) | |
| 523 | 745 | if err != nil { | |
| 524 | 746 | continue | |
| 525 | 747 | } | |
M packages/core/session_portable_test.go +78 -3
| @@ -82,9 +82,84 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | - | // TestExportToFilePath writes to an explicit file path (no | |
| 86 | - | // directory guessing) and checks the .zotsession extension is | |
| 87 | - | // appended when missing. | |
| 85 | + | func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) { | |
| 86 | + | root := t.TempDir() | |
| 87 | + | session, err := NewSession(root, "/source", "old-provider", "old-model", "test") | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | _ = session.AppendMessage(provider.Message{ | |
| 92 | + | Role: provider.RoleUser, | |
| 93 | + | Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}}, | |
| 94 | + | }) | |
| 95 | + | if err := session.UpdateModel("new-provider", "new-model"); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | path := session.Path | |
| 99 | + | if err := session.Close(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt)) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatalf("ExportSession: %v", err) | |
| 106 | + | } | |
| 107 | + | exported, err := ReadSessionSnapshot(exportPath) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatalf("Read exported snapshot: %v", err) | |
| 110 | + | } | |
| 111 | + | if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" { | |
| 112 | + | t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test") | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatalf("ImportSession: %v", err) | |
| 118 | + | } | |
| 119 | + | imported, _, err := OpenSession(importedPath) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatalf("Open imported session: %v", err) | |
| 122 | + | } | |
| 123 | + | defer imported.Close() | |
| 124 | + | if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" { | |
| 125 | + | t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that | |
| 130 | + | // metadata lookup follows append-only model updates without reconstructing the | |
| 131 | + | // transcript. | |
| 132 | + | func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) { | |
| 133 | + | root := t.TempDir() | |
| 134 | + | session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test") | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if err := session.AppendMessage(provider.Message{ | |
| 139 | + | Role: provider.RoleUser, | |
| 140 | + | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}}, | |
| 141 | + | }); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if err := session.UpdateModel("new-provider", "new-model"); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | path := session.Path | |
| 148 | + | if err := session.Close(); err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | meta, err := scanSessionMeta(path) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | if meta.Provider != "new-provider" || meta.Model != "new-model" { | |
| 157 | + | t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // TestExportToFilePath writes to an explicit file path (no directory | |
| 162 | + | // guessing) and checks the .zotsession extension is appended when missing. | |
| 88 | 163 | func TestExportToFilePath(t *testing.T) { | |
| 89 | 164 | root := t.TempDir() | |
| 90 | 165 | sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test") | |
A packages/core/session_snapshot_test.go +628 -0
| @@ -0,0 +1,628 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestAppendMessageRejectsEmptyContent(t *testing.T) { | |
| 15 | + | session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test") | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | if err := session.AppendMessage(provider.Message{Role: provider.RoleUser}); err == nil { | |
| 20 | + | t.Fatal("empty message content was accepted") | |
| 21 | + | } | |
| 22 | + | if err := session.Close(); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestReadSessionSnapshotUsesLatestCompactionAndRepairsToolPairs(t *testing.T) { | |
| 28 | + | root := t.TempDir() | |
| 29 | + | session, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if err := session.AppendMessage(textMessage(provider.RoleUser, "old history")); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := session.AppendCompaction([]provider.Message{ | |
| 37 | + | textMessage(provider.RoleAssistant, "summary"), | |
| 38 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 39 | + | provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: []byte(`{"path":"/tmp/file"}`)}, | |
| 40 | + | }}, | |
| 41 | + | }); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if err := session.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | path := session.Path | |
| 48 | + | if err := session.Close(); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatalf("ReadSessionSnapshot: %v", err) | |
| 55 | + | } | |
| 56 | + | if snapshot.Meta.ID == "" { | |
| 57 | + | t.Fatal("snapshot lost session metadata") | |
| 58 | + | } | |
| 59 | + | assertMessageTexts(t, snapshot.Messages, []string{"summary", "", ""}) | |
| 60 | + | stub := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock) | |
| 61 | + | if stub.Text != "tool call was aborted; no result recorded." { | |
| 62 | + | t.Errorf("repaired result text = %q", stub.Text) | |
| 63 | + | } | |
| 64 | + | if got := snapshot.Messages[1].Role; got != provider.RoleAssistant { | |
| 65 | + | t.Fatalf("repaired transcript message 1 role = %q, want assistant", got) | |
| 66 | + | } | |
| 67 | + | if got := snapshot.Messages[2].Role; got != provider.RoleTool { | |
| 68 | + | t.Fatalf("repaired transcript message 2 role = %q, want tool", got) | |
| 69 | + | } | |
| 70 | + | if got := snapshot.Messages[2].Content[0].(provider.ToolResultBlock).CallID; got != "call-1" { | |
| 71 | + | t.Errorf("repaired result call id = %q, want call-1", got) | |
| 72 | + | } | |
| 73 | + | if len(snapshot.UsageCheckpoints) != 1 { | |
| 74 | + | t.Fatalf("usage checkpoints = %d, want 1", len(snapshot.UsageCheckpoints)) | |
| 75 | + | } | |
| 76 | + | if got := snapshot.UsageCheckpoints[0].MessageCount; got != 3 { | |
| 77 | + | t.Fatalf("usage checkpoint message count = %d, want repaired count 3", got) | |
| 78 | + | } | |
| 79 | + | if got := snapshot.UsageCheckpoints[0].Cumulative.InputTokens; got != 10 { | |
| 80 | + | t.Errorf("checkpoint cumulative input = %d, want 10", got) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | opened, messages, err := OpenSession(path) | |
| 84 | + | if err != nil { | |
| 85 | + | t.Fatalf("OpenSession: %v", err) | |
| 86 | + | } | |
| 87 | + | defer opened.Close() | |
| 88 | + | if len(messages) != len(snapshot.Messages) { | |
| 89 | + | t.Fatalf("OpenSession messages = %d, snapshot messages = %d", len(messages), len(snapshot.Messages)) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestReadSessionSnapshotMergesExistingToolResultWithoutDuplicateRepair(t *testing.T) { | |
| 94 | + | root := t.TempDir() | |
| 95 | + | session, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | if err := session.AppendMessage(provider.Message{ | |
| 100 | + | Role: provider.RoleAssistant, | |
| 101 | + | Content: []provider.Content{ | |
| 102 | + | provider.ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 103 | + | provider.ToolCallBlock{ID: "call-2", Name: "bash"}, | |
| 104 | + | }, | |
| 105 | + | }); err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | if err := session.AppendMessage(provider.Message{ | |
| 109 | + | Role: provider.RoleTool, | |
| 110 | + | Content: []provider.Content{provider.ToolResultBlock{ | |
| 111 | + | CallID: "call-1", | |
| 112 | + | Content: []provider.Content{provider.TextBlock{Text: "ok"}}, | |
| 113 | + | }}, | |
| 114 | + | }); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | path := session.Path | |
| 118 | + | if err := session.Close(); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 123 | + | if err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | if len(snapshot.Messages) != 2 { | |
| 127 | + | t.Fatalf("messages = %d, want existing tool row plus assistant row", len(snapshot.Messages)) | |
| 128 | + | } | |
| 129 | + | tool := snapshot.Messages[1] | |
| 130 | + | if len(tool.Content) != 2 { | |
| 131 | + | t.Fatalf("tool result blocks = %d, want 2", len(tool.Content)) | |
| 132 | + | } | |
| 133 | + | if got := tool.Content[0].(provider.ToolResultBlock).CallID; got != "call-1" { | |
| 134 | + | t.Errorf("existing result call id = %q", got) | |
| 135 | + | } | |
| 136 | + | if got := tool.Content[1].(provider.ToolResultBlock).CallID; got != "call-2" { | |
| 137 | + | t.Errorf("repaired result call id = %q", got) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestReadSessionSnapshotRecordsCheckpointsAtEffectiveBoundaries(t *testing.T) { | |
| 142 | + | root := t.TempDir() | |
| 143 | + | session, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 144 | + | if err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | _ = session.AppendMessage(textMessage(provider.RoleUser, "one")) | |
| 148 | + | _ = session.AppendUsage(provider.Usage{InputTokens: 3}, provider.Usage{InputTokens: 3}) | |
| 149 | + | _ = session.AppendMessage(textMessage(provider.RoleAssistant, "two")) | |
| 150 | + | _ = session.AppendUsage(provider.Usage{InputTokens: 4}, provider.Usage{InputTokens: 7}) | |
| 151 | + | _ = session.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "compact")}) | |
| 152 | + | _ = session.AppendMessage(textMessage(provider.RoleUser, "tail")) | |
| 153 | + | _ = session.AppendUsage(provider.Usage{InputTokens: 2}, provider.Usage{InputTokens: 9}) | |
| 154 | + | path := session.Path | |
| 155 | + | if err := session.Close(); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | assertMessageTexts(t, snapshot.Messages, []string{"compact", "tail"}) | |
| 164 | + | if len(snapshot.UsageCheckpoints) != 3 { | |
| 165 | + | t.Fatalf("checkpoints = %d, want 3", len(snapshot.UsageCheckpoints)) | |
| 166 | + | } | |
| 167 | + | wantCounts := []int{1, 2, 2} | |
| 168 | + | wantCosts := []int{3, 7, 9} | |
| 169 | + | for i, checkpoint := range snapshot.UsageCheckpoints { | |
| 170 | + | if checkpoint.MessageCount != wantCounts[i] { | |
| 171 | + | t.Errorf("checkpoint %d count = %d, want %d", i, checkpoint.MessageCount, wantCounts[i]) | |
| 172 | + | } | |
| 173 | + | if checkpoint.Cumulative.InputTokens != wantCosts[i] { | |
| 174 | + | t.Errorf("checkpoint %d input = %d, want %d", i, checkpoint.Cumulative.InputTokens, wantCosts[i]) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestReadSessionSnapshotRejectsMalformedRows(t *testing.T) { | |
| 180 | + | meta := SessionMeta{ID: "session-1", CWD: "/workspace", Started: time.Now().UTC()} | |
| 181 | + | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta}) | |
| 182 | + | if err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | cases := []string{ | |
| 186 | + | string(metaLine) + "\nnot-json\n", | |
| 187 | + | string(metaLine) + ` | |
| 188 | + | {"type":"message","message":{"role":"user"}} | |
| 189 | + | `, | |
| 190 | + | string(metaLine) + ` | |
| 191 | + | {"type":"usage","cumulative":null} | |
| 192 | + | `, | |
| 193 | + | string(metaLine) + ` | |
| 194 | + | {"type":"future-row"} | |
| 195 | + | `, | |
| 196 | + | } | |
| 197 | + | for idx, contents := range cases { | |
| 198 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 199 | + | if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { | |
| 200 | + | t.Fatal(err) | |
| 201 | + | } | |
| 202 | + | if _, err := ReadSessionSnapshot(path); err == nil { | |
| 203 | + | t.Errorf("case %d: ReadSessionSnapshot returned nil error", idx) | |
| 204 | + | } | |
| 205 | + | if _, _, err := OpenSession(path); err == nil { | |
| 206 | + | t.Errorf("case %d: OpenSession returned nil error", idx) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestReadSessionSnapshotKeepsOldOptionalMetadataReadable(t *testing.T) { | |
| 212 | + | path := filepath.Join(t.TempDir(), "old.jsonl") | |
| 213 | + | contents := `{"type":"meta","meta":{"id":"old-id","cwd":"/workspace","model":"claude","provider":"anthropic"}} | |
| 214 | + | {"type":"message","message":{"role":"user","content":[{"text":"hello"}]}} | |
| 215 | + | ` | |
| 216 | + | if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { | |
| 217 | + | t.Fatal(err) | |
| 218 | + | } | |
| 219 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 220 | + | if err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | if snapshot.Meta.Parent != "" || snapshot.Meta.HideFromSessions { | |
| 224 | + | t.Fatalf("old optional metadata unexpectedly populated: %+v", snapshot.Meta) | |
| 225 | + | } | |
| 226 | + | assertMessageTexts(t, snapshot.Messages, []string{"hello"}) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestBranchAfterCompactionRetainsPreCompactionUsageAtReplacementBoundary(t *testing.T) { | |
| 230 | + | root := t.TempDir() | |
| 231 | + | parent, err := NewSession(root, "/workspace", "test", "model", "test") | |
| 232 | + | if err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | for _, text := range []string{"one", "two", "three"} { | |
| 236 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, text)) | |
| 237 | + | } | |
| 238 | + | _ = parent.AppendUsage(provider.Usage{InputTokens: 10}, provider.Usage{InputTokens: 10}) | |
| 239 | + | _ = parent.AppendCompaction([]provider.Message{textMessage(provider.RoleAssistant, "summary")}) | |
| 240 | + | path := parent.Path | |
| 241 | + | if err := parent.Close(); err != nil { | |
| 242 | + | t.Fatal(err) | |
| 243 | + | } | |
| 244 | + | branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", 1) | |
| 245 | + | if err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | usage, err := SessionUsage(branchPath) | |
| 249 | + | if err != nil { | |
| 250 | + | t.Fatal(err) | |
| 251 | + | } | |
| 252 | + | if usage.InputTokens != 10 { | |
| 253 | + | t.Fatalf("branch usage input = %d, want pre-compaction cumulative usage", usage.InputTokens) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestBranchSessionUsesSharedSnapshotAndPreservesUsage(t *testing.T) { | |
| 258 | + | root := t.TempDir() | |
| 259 | + | parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 260 | + | if err != nil { | |
| 261 | + | t.Fatal(err) | |
| 262 | + | } | |
| 263 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "first")) | |
| 264 | + | _ = parent.AppendUsage(provider.Usage{InputTokens: 11}, provider.Usage{InputTokens: 11}) | |
| 265 | + | _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "reply")) | |
| 266 | + | _ = parent.AppendUsage(provider.Usage{InputTokens: 12}, provider.Usage{InputTokens: 23}) | |
| 267 | + | parentPath := parent.Path | |
| 268 | + | if err := parent.Close(); err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | branchPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1) | |
| 273 | + | if err != nil { | |
| 274 | + | t.Fatalf("BranchSessionHidden: %v", err) | |
| 275 | + | } | |
| 276 | + | branch, messages, err := OpenSession(branchPath) | |
| 277 | + | if err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | branch.Close() | |
| 281 | + | assertMessageTexts(t, messages, []string{"first"}) | |
| 282 | + | if branch.Meta.ForkPoint != 1 { | |
| 283 | + | t.Errorf("fork point = %d, want 1", branch.Meta.ForkPoint) | |
| 284 | + | } | |
| 285 | + | if !branch.Meta.HideFromSessions { | |
| 286 | + | t.Error("hidden branch did not set HideFromSessions") | |
| 287 | + | } | |
| 288 | + | usage, err := SessionUsage(branchPath) | |
| 289 | + | if err != nil { | |
| 290 | + | t.Fatal(err) | |
| 291 | + | } | |
| 292 | + | if usage.InputTokens != 11 { | |
| 293 | + | t.Errorf("branch usage input = %d, want 11", usage.InputTokens) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | if _, err := BranchSession(parentPath, root, "/workspace", "test", 99); err == nil { | |
| 297 | + | t.Error("out-of-range fork returned nil error") | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestBranchSessionRepairsToolBoundaryAndSupportsZeroAndEnd(t *testing.T) { | |
| 302 | + | root := t.TempDir() | |
| 303 | + | parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 304 | + | if err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "before")) | |
| 308 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 309 | + | provider.ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 310 | + | }}) | |
| 311 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 312 | + | provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "result"}}}, | |
| 313 | + | }}) | |
| 314 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "after")) | |
| 315 | + | path := parent.Path | |
| 316 | + | if err := parent.Close(); err != nil { | |
| 317 | + | t.Fatal(err) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | toolBranchPath, err := BranchSession(parent.Path, root, "/workspace", "test", 2) | |
| 321 | + | if err != nil { | |
| 322 | + | t.Fatal(err) | |
| 323 | + | } | |
| 324 | + | toolBranch, toolMessages, err := OpenSession(toolBranchPath) | |
| 325 | + | if err != nil { | |
| 326 | + | t.Fatal(err) | |
| 327 | + | } | |
| 328 | + | toolBranch.Close() | |
| 329 | + | if len(toolMessages) != 3 { | |
| 330 | + | t.Fatalf("tool boundary branch messages = %d, want 3", len(toolMessages)) | |
| 331 | + | } | |
| 332 | + | if toolBranch.Meta.ForkPoint != 3 { | |
| 333 | + | t.Errorf("tool boundary fork point = %d, want 3", toolBranch.Meta.ForkPoint) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | zeroPath, err := BranchSessionHidden(path, root, "/workspace", "test", 0) | |
| 337 | + | if err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | zero, zeroMessages, err := OpenSession(zeroPath) | |
| 341 | + | if err != nil { | |
| 342 | + | t.Fatal(err) | |
| 343 | + | } | |
| 344 | + | zero.Close() | |
| 345 | + | if len(zeroMessages) != 0 || zero.Meta.ForkPoint != 0 { | |
| 346 | + | t.Errorf("zero branch = %d messages, fork %d", len(zeroMessages), zero.Meta.ForkPoint) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | endPath, err := BranchSession(path, root, "/workspace", "test", 4) | |
| 350 | + | if err != nil { | |
| 351 | + | t.Fatal(err) | |
| 352 | + | } | |
| 353 | + | end, endMessages, err := OpenSession(endPath) | |
| 354 | + | if err != nil { | |
| 355 | + | t.Fatal(err) | |
| 356 | + | } | |
| 357 | + | end.Close() | |
| 358 | + | if len(endMessages) != 4 || end.Meta.ForkPoint != 4 { | |
| 359 | + | t.Errorf("end branch = %d messages, fork %d", len(endMessages), end.Meta.ForkPoint) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestHiddenBranchesAreTreeVisibleButFlatListHidden(t *testing.T) { | |
| 364 | + | root := t.TempDir() | |
| 365 | + | parent, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 366 | + | if err != nil { | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "root")) | |
| 370 | + | parentPath := parent.Path | |
| 371 | + | parentID := parent.ID | |
| 372 | + | if err := parent.Close(); err != nil { | |
| 373 | + | t.Fatal(err) | |
| 374 | + | } | |
| 375 | + | childPath, err := BranchSessionHidden(parentPath, root, "/workspace", "test", 1) | |
| 376 | + | if err != nil { | |
| 377 | + | t.Fatal(err) | |
| 378 | + | } | |
| 379 | + | if got := DescribeSessions(root, "/workspace"); len(got) != 1 || got[0].Path != parentPath { | |
| 380 | + | t.Fatalf("DescribeSessions = %+v, want only parent", got) | |
| 381 | + | } | |
| 382 | + | tree := BuildSessionTree(root, "/workspace") | |
| 383 | + | if len(tree) != 1 || tree[0].Meta.ID != parentID { | |
| 384 | + | t.Fatalf("tree roots = %+v, want parent root", tree) | |
| 385 | + | } | |
| 386 | + | if len(tree[0].Children) != 1 || tree[0].Children[0].Summary.Path != childPath { | |
| 387 | + | t.Fatalf("tree children = %+v, want hidden child", tree[0].Children) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func TestBuildSessionTreeStrictRejectsMalformedSessionMember(t *testing.T) { | |
| 392 | + | root := t.TempDir() | |
| 393 | + | cwd := "/workspace/strict-tree" | |
| 394 | + | parent, err := NewSession(root, cwd, "test", "model", "test") | |
| 395 | + | if err != nil { | |
| 396 | + | t.Fatal(err) | |
| 397 | + | } | |
| 398 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "parent")) | |
| 399 | + | parentPath := parent.Path | |
| 400 | + | if err := parent.Close(); err != nil { | |
| 401 | + | t.Fatal(err) | |
| 402 | + | } | |
| 403 | + | badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl") | |
| 404 | + | if err := os.WriteFile(badPath, []byte("{not-json}\n"), 0o644); err != nil { | |
| 405 | + | t.Fatal(err) | |
| 406 | + | } | |
| 407 | + | if _, err := BuildSessionTreeStrict(root, cwd); err == nil { | |
| 408 | + | t.Fatal("BuildSessionTreeStrict accepted a malformed session member") | |
| 409 | + | } | |
| 410 | + | if _, err := os.Stat(parentPath); err != nil { | |
| 411 | + | t.Fatalf("valid parent disappeared: %v", err) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestSessionUsageDetailUsesCurrentCacheAfterCounterReset(t *testing.T) { | |
| 416 | + | root := t.TempDir() | |
| 417 | + | session, err := NewSession(root, "/workspace", "anthropic", "model", "test") | |
| 418 | + | if err != nil { | |
| 419 | + | t.Fatal(err) | |
| 420 | + | } | |
| 421 | + | if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil { | |
| 422 | + | t.Fatal(err) | |
| 423 | + | } | |
| 424 | + | if err := session.AppendUsage(provider.Usage{}, provider.Usage{ | |
| 425 | + | InputTokens: 10, | |
| 426 | + | OutputTokens: 4, | |
| 427 | + | ReasoningTokens: 2, | |
| 428 | + | ReasoningTokensKnown: true, | |
| 429 | + | CacheReadTokens: 100, | |
| 430 | + | CacheWriteTokens: 40, | |
| 431 | + | CostUSD: 0.5, | |
| 432 | + | }); err != nil { | |
| 433 | + | t.Fatal(err) | |
| 434 | + | } | |
| 435 | + | if err := session.AppendUsage(provider.Usage{}, provider.Usage{ | |
| 436 | + | InputTokens: 13, | |
| 437 | + | OutputTokens: 7, | |
| 438 | + | ReasoningTokens: 3, | |
| 439 | + | ReasoningTokensKnown: true, | |
| 440 | + | CacheReadTokens: 6, | |
| 441 | + | CacheWriteTokens: 2, | |
| 442 | + | CostUSD: 0.7, | |
| 443 | + | }); err != nil { | |
| 444 | + | t.Fatal(err) | |
| 445 | + | } | |
| 446 | + | path := session.Path | |
| 447 | + | if err := session.Close(); err != nil { | |
| 448 | + | t.Fatal(err) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | cumulative, last, err := SessionUsageDetail(path) | |
| 452 | + | if err != nil { | |
| 453 | + | t.Fatal(err) | |
| 454 | + | } | |
| 455 | + | if cumulative.CacheReadTokens != 6 || cumulative.CacheWriteTokens != 2 { | |
| 456 | + | t.Fatalf("cumulative cache = (%d, %d), want (6, 2)", cumulative.CacheReadTokens, cumulative.CacheWriteTokens) | |
| 457 | + | } | |
| 458 | + | if last.InputTokens != 3 || last.OutputTokens != 3 || last.ReasoningTokens != 1 { | |
| 459 | + | t.Fatalf("last token delta = %+v, want input=3 output=3 reasoning=1", last) | |
| 460 | + | } | |
| 461 | + | if last.CacheReadTokens != 6 || last.CacheWriteTokens != 2 { | |
| 462 | + | t.Fatalf("last cache = (%d, %d), want current checkpoint values (6, 2)", last.CacheReadTokens, last.CacheWriteTokens) | |
| 463 | + | } | |
| 464 | + | if !last.ReasoningTokensKnown { | |
| 465 | + | t.Fatal("last-turn reasoning-token known flag was lost") | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) { | |
| 470 | + | root := t.TempDir() | |
| 471 | + | session, err := NewSession(root, "/workspace", "anthropic", "model", "test") | |
| 472 | + | if err != nil { | |
| 473 | + | t.Fatal(err) | |
| 474 | + | } | |
| 475 | + | _ = session.AppendMessage(textMessage(provider.RoleUser, "prompt")) | |
| 476 | + | _ = session.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 477 | + | provider.ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 478 | + | }}) | |
| 479 | + | _ = session.AppendMessage(provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 480 | + | provider.ToolResultBlock{CallID: "orphan", Content: []provider.Content{provider.TextBlock{Text: "stale"}}}, | |
| 481 | + | }}) | |
| 482 | + | _ = session.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 9}) | |
| 483 | + | path := session.Path | |
| 484 | + | if err := session.Close(); err != nil { | |
| 485 | + | t.Fatal(err) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 489 | + | if err != nil { | |
| 490 | + | t.Fatal(err) | |
| 491 | + | } | |
| 492 | + | if len(snapshot.Messages) != 3 { | |
| 493 | + | t.Fatalf("repaired messages = %d, want 3", len(snapshot.Messages)) | |
| 494 | + | } | |
| 495 | + | tool := snapshot.Messages[2] | |
| 496 | + | if len(tool.Content) != 1 { | |
| 497 | + | t.Fatalf("repaired tool blocks = %d, want synthetic result only", len(tool.Content)) | |
| 498 | + | } | |
| 499 | + | result, ok := tool.Content[0].(provider.ToolResultBlock) | |
| 500 | + | if !ok || result.CallID != "call-1" { | |
| 501 | + | t.Fatalf("repaired result = %#v, want call-1", tool.Content[0]) | |
| 502 | + | } | |
| 503 | + | if snapshot.UsageCheckpoints[0].MessageCount != 3 { | |
| 504 | + | t.Fatalf("checkpoint message count = %d, want repaired count 3", snapshot.UsageCheckpoints[0].MessageCount) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | branchPath, err := BranchSessionHidden(path, root, "/workspace", "test", len(snapshot.Messages)) | |
| 508 | + | if err != nil { | |
| 509 | + | t.Fatal(err) | |
| 510 | + | } | |
| 511 | + | _, branchMessages, err := OpenSession(branchPath) | |
| 512 | + | if err != nil { | |
| 513 | + | t.Fatal(err) | |
| 514 | + | } | |
| 515 | + | if len(branchMessages) != 3 { | |
| 516 | + | t.Fatalf("branch messages = %d, want 3", len(branchMessages)) | |
| 517 | + | } | |
| 518 | + | branchResult := branchMessages[2].Content[0].(provider.ToolResultBlock) | |
| 519 | + | if branchResult.CallID != "call-1" { | |
| 520 | + | t.Fatalf("branch kept orphan result call id %q", branchResult.CallID) | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func TestAppendCompactionEmptyAndHistoricalNullRowsRoundTrip(t *testing.T) { | |
| 525 | + | root := t.TempDir() | |
| 526 | + | session, err := NewSession(root, "/workspace", "anthropic", "model", "test") | |
| 527 | + | if err != nil { | |
| 528 | + | t.Fatal(err) | |
| 529 | + | } | |
| 530 | + | _ = session.AppendMessage(textMessage(provider.RoleUser, "before")) | |
| 531 | + | if err := session.AppendCompaction(nil); err != nil { | |
| 532 | + | t.Fatal(err) | |
| 533 | + | } | |
| 534 | + | if err := session.AppendCompaction([]provider.Message{}); err != nil { | |
| 535 | + | t.Fatal(err) | |
| 536 | + | } | |
| 537 | + | path := session.Path | |
| 538 | + | if err := session.Close(); err != nil { | |
| 539 | + | t.Fatal(err) | |
| 540 | + | } | |
| 541 | + | if _, err := os.Stat(path); err != nil { | |
| 542 | + | t.Fatalf("empty compaction session was removed: %v", err) | |
| 543 | + | } | |
| 544 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 545 | + | if err != nil { | |
| 546 | + | t.Fatal(err) | |
| 547 | + | } | |
| 548 | + | if len(snapshot.Messages) != 0 { | |
| 549 | + | t.Fatalf("empty compaction messages = %d, want 0", len(snapshot.Messages)) | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | meta := SessionMeta{ID: "historical-empty", CWD: "/workspace"} | |
| 553 | + | metaLine, _ := json.Marshal(sessionLine{Type: "meta", Meta: &meta}) | |
| 554 | + | for _, compaction := range []string{`{"type":"compaction"}`, `{"type":"compaction","messages":null}`} { | |
| 555 | + | historicalPath := filepath.Join(t.TempDir(), "historical.jsonl") | |
| 556 | + | contents := string(metaLine) + "\n" + compaction + "\n" | |
| 557 | + | if err := os.WriteFile(historicalPath, []byte(contents), 0o644); err != nil { | |
| 558 | + | t.Fatal(err) | |
| 559 | + | } | |
| 560 | + | historical, err := ReadSessionSnapshot(historicalPath) | |
| 561 | + | if err != nil { | |
| 562 | + | t.Fatalf("historical %s: %v", compaction, err) | |
| 563 | + | } | |
| 564 | + | if len(historical.Messages) != 0 { | |
| 565 | + | t.Fatalf("historical %s messages = %d, want 0", compaction, len(historical.Messages)) | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | func TestBranchPreservesCumulativeCheckpointsForLastTurnDelta(t *testing.T) { | |
| 571 | + | root := t.TempDir() | |
| 572 | + | parent, err := NewSession(root, "/workspace", "anthropic", "model", "test") | |
| 573 | + | if err != nil { | |
| 574 | + | t.Fatal(err) | |
| 575 | + | } | |
| 576 | + | _ = parent.AppendMessage(textMessage(provider.RoleUser, "first")) | |
| 577 | + | _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 10, CacheReadTokens: 4}) | |
| 578 | + | _ = parent.AppendMessage(textMessage(provider.RoleAssistant, "second")) | |
| 579 | + | _ = parent.AppendUsage(provider.Usage{}, provider.Usage{InputTokens: 25, CacheReadTokens: 7}) | |
| 580 | + | parentPath := parent.Path | |
| 581 | + | if err := parent.Close(); err != nil { | |
| 582 | + | t.Fatal(err) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | branchPath, err := BranchSession(parentPath, root, "/workspace", "test", 2) | |
| 586 | + | if err != nil { | |
| 587 | + | t.Fatal(err) | |
| 588 | + | } | |
| 589 | + | cumulative, last, err := SessionUsageDetail(branchPath) | |
| 590 | + | if err != nil { | |
| 591 | + | t.Fatal(err) | |
| 592 | + | } | |
| 593 | + | if cumulative.InputTokens != 25 || cumulative.CacheReadTokens != 7 { | |
| 594 | + | t.Fatalf("branch cumulative = %+v, want input=25 cache_read=7", cumulative) | |
| 595 | + | } | |
| 596 | + | if last.InputTokens != 15 || last.CacheReadTokens != 3 { | |
| 597 | + | t.Fatalf("branch last turn = %+v, want input=15 cache_read=3", last) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func textMessage(role provider.Role, text string) provider.Message { | |
| 602 | + | return provider.Message{Role: role, Content: []provider.Content{provider.TextBlock{Text: text}}} | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func TestSnapshotMessagesDoNotExposeMutableRawCompactionState(t *testing.T) { | |
| 606 | + | // This is a small API contract check: callers can safely materialize a | |
| 607 | + | // branch from the returned slice without changing the source file. | |
| 608 | + | root := t.TempDir() | |
| 609 | + | session, err := NewSession(root, "/workspace", "anthropic", "claude", "test") | |
| 610 | + | if err != nil { | |
| 611 | + | t.Fatal(err) | |
| 612 | + | } | |
| 613 | + | _ = session.AppendMessage(textMessage(provider.RoleUser, "original")) | |
| 614 | + | path := session.Path | |
| 615 | + | _ = session.Close() | |
| 616 | + | snapshot, err := ReadSessionSnapshot(path) | |
| 617 | + | if err != nil { | |
| 618 | + | t.Fatal(err) | |
| 619 | + | } | |
| 620 | + | snapshot.Messages[0].Content[0] = provider.TextBlock{Text: "changed in memory"} | |
| 621 | + | again, err := ReadSessionSnapshot(path) | |
| 622 | + | if err != nil { | |
| 623 | + | t.Fatal(err) | |
| 624 | + | } | |
| 625 | + | if got := extractText(again.Messages[0]); strings.Contains(got, "changed") { | |
| 626 | + | t.Fatal("mutating snapshot changed on-disk transcript") | |
| 627 | + | } | |
| 628 | + | } | |
M packages/tui/input.go +111 -12
| @@ -56,6 +56,43 @@ const ( | |||
| 56 | 56 | type Reader struct { | |
| 57 | 57 | src func() (byte, error) | |
| 58 | 58 | peek func(time.Duration) (byte, bool, error) // optional; may be nil | |
| 59 | + | ||
| 60 | + | // pending contains bytes consumed while deciding whether an Esc was | |
| 61 | + | // bare. In particular, a second Esc belongs to the next Read, not to | |
| 62 | + | // the Alt+<char> case handled by readEscape. | |
| 63 | + | pending []byte | |
| 64 | + | ||
| 65 | + | // A Reader without a peek callback still needs to preserve the old | |
| 66 | + | // NewReader API and parse Alt/CSI input. The pump lets its bounded | |
| 67 | + | // lookahead wait without calling the blocking source on the Read caller's | |
| 68 | + | // goroutine. There is never more than one outstanding source call: after a | |
| 69 | + | // timeout that call is reused by the next Read rather than replaced. | |
| 70 | + | // | |
| 71 | + | // The source callback has no cancellation contract, so an implementation | |
| 72 | + | // that blocks forever can keep this one goroutine alive forever. That | |
| 73 | + | // limitation is intentional; starting another goroutine for each isolated | |
| 74 | + | // Esc would leak an unbounded number of blocked goroutines and could also | |
| 75 | + | // reorder bytes from an uncancellable source. | |
| 76 | + | pump *readerPump | |
| 77 | + | pumpErr error | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | type readerResult struct { | |
| 81 | + | b byte | |
| 82 | + | err error | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | type readerPump struct { | |
| 86 | + | result chan readerResult | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func newReaderPump(read func() (byte, error)) *readerPump { | |
| 90 | + | p := &readerPump{result: make(chan readerResult, 1)} | |
| 91 | + | go func() { | |
| 92 | + | b, err := read() | |
| 93 | + | p.result <- readerResult{b: b, err: err} | |
| 94 | + | }() | |
| 95 | + | return p | |
| 59 | 96 | } | |
| 60 | 97 | ||
| 61 | 98 | // NewReader returns a Reader that pulls bytes from read. | |
| @@ -67,9 +104,65 @@ func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte | |||
| 67 | 104 | return &Reader{src: read, peek: peek} | |
| 68 | 105 | } | |
| 69 | 106 | ||
| 107 | + | // readByte returns the next source byte, including any byte held back after | |
| 108 | + | // an escape lookahead. | |
| 109 | + | func (r *Reader) readByte() (byte, error) { | |
| 110 | + | if len(r.pending) != 0 { | |
| 111 | + | b := r.pending[0] | |
| 112 | + | r.pending = r.pending[1:] | |
| 113 | + | return b, nil | |
| 114 | + | } | |
| 115 | + | if r.pumpErr != nil { | |
| 116 | + | return 0, r.pumpErr | |
| 117 | + | } | |
| 118 | + | if r.pump == nil { | |
| 119 | + | return r.src() | |
| 120 | + | } | |
| 121 | + | result := <-r.pump.result | |
| 122 | + | r.pump = nil | |
| 123 | + | if result.err != nil { | |
| 124 | + | r.pumpErr = result.err | |
| 125 | + | } | |
| 126 | + | return result.b, result.err | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // readByteTimeout returns one source byte without waiting longer than d. A | |
| 130 | + | // source call left outstanding after a timeout is consumed by the next Read. | |
| 131 | + | func (r *Reader) readByteTimeout(d time.Duration) (byte, bool, error) { | |
| 132 | + | if r.pumpErr != nil { | |
| 133 | + | return 0, false, r.pumpErr | |
| 134 | + | } | |
| 135 | + | if r.pump == nil { | |
| 136 | + | r.pump = newReaderPump(r.src) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | var result readerResult | |
| 140 | + | if d <= 0 { | |
| 141 | + | select { | |
| 142 | + | case result = <-r.pump.result: | |
| 143 | + | default: | |
| 144 | + | return 0, false, nil | |
| 145 | + | } | |
| 146 | + | } else { | |
| 147 | + | timer := time.NewTimer(d) | |
| 148 | + | defer timer.Stop() | |
| 149 | + | select { | |
| 150 | + | case result = <-r.pump.result: | |
| 151 | + | case <-timer.C: | |
| 152 | + | return 0, false, nil | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | r.pump = nil | |
| 156 | + | if result.err != nil { | |
| 157 | + | r.pumpErr = result.err | |
| 158 | + | return 0, false, result.err | |
| 159 | + | } | |
| 160 | + | return result.b, true, nil | |
| 161 | + | } | |
| 162 | + | ||
| 70 | 163 | // Read returns the next parsed Key. | |
| 71 | 164 | func (r *Reader) Read() (Key, error) { | |
| 72 | - | b, err := r.src() | |
| 165 | + | b, err := r.readByte() | |
| 73 | 166 | if err != nil { | |
| 74 | 167 | return Key{}, err | |
| 75 | 168 | } | |
| @@ -113,7 +206,7 @@ func (r *Reader) Read() (Key, error) { | |||
| 113 | 206 | n := utf8Len(b) | |
| 114 | 207 | buf := []byte{b} | |
| 115 | 208 | for i := 1; i < n; i++ { | |
| 116 | - | bb, err := r.src() | |
| 209 | + | bb, err := r.readByte() | |
| 117 | 210 | if err != nil { | |
| 118 | 211 | return Key{}, err | |
| 119 | 212 | } | |
| @@ -161,12 +254,19 @@ func (r *Reader) readEscape() (Key, error) { | |||
| 161 | 254 | if err != nil || !have { | |
| 162 | 255 | return Key{Kind: KeyEsc}, nil | |
| 163 | 256 | } | |
| 257 | + | if b == 0x1b { | |
| 258 | + | // A second raw ESC is a separate key, not Alt+ESC. Keep it for the | |
| 259 | + | // next Read so Esc Esc produces two bare KeyEsc events. This is the | |
| 260 | + | // one lookahead case that is not intentionally treated as Alt input. | |
| 261 | + | r.pending = append(r.pending, b) | |
| 262 | + | return Key{Kind: KeyEsc}, nil | |
| 263 | + | } | |
| 164 | 264 | switch b { | |
| 165 | 265 | case '[': | |
| 166 | 266 | return r.readCSI() | |
| 167 | 267 | case 'O': | |
| 168 | 268 | // SS3 sequences (function keys in some terminals). | |
| 169 | - | c, err := r.src() | |
| 269 | + | c, err := r.readByte() | |
| 170 | 270 | if err != nil { | |
| 171 | 271 | return Key{}, err | |
| 172 | 272 | } | |
| @@ -199,24 +299,23 @@ func (r *Reader) readEscape() (Key, error) { | |||
| 199 | 299 | } | |
| 200 | 300 | ||
| 201 | 301 | // readEscapeNext tries to read one byte within d. If peek is available | |
| 202 | - | // we use it (true non-blocking). Otherwise we fall back to a blocking | |
| 203 | - | // read, which means bare Esc is only detected after the next keystroke. | |
| 302 | + | // we use it. NewReader's source pump supplies the same bounded behavior | |
| 303 | + | // when no peek callback is provided, so a bare Esc never waits forever for | |
| 304 | + | // a second byte. A follow-on printable byte, DEL, b, or f remains | |
| 305 | + | // intentionally ambiguous with Alt input and keeps the historical mapping | |
| 306 | + | // in readEscape. | |
| 204 | 307 | func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) { | |
| 205 | 308 | if r.peek != nil { | |
| 206 | 309 | return r.peek(d) | |
| 207 | 310 | } | |
| 208 | - | b, err := r.src() | |
| 209 | - | if err != nil { | |
| 210 | - | return 0, false, err | |
| 211 | - | } | |
| 212 | - | return b, true, nil | |
| 311 | + | return r.readByteTimeout(d) | |
| 213 | 312 | } | |
| 214 | 313 | ||
| 215 | 314 | // readCSI parses a CSI sequence after ESC [. | |
| 216 | 315 | func (r *Reader) readCSI() (Key, error) { | |
| 217 | 316 | var params []byte | |
| 218 | 317 | for { | |
| 219 | - | c, err := r.src() | |
| 318 | + | c, err := r.readByte() | |
| 220 | 319 | if err != nil { | |
| 221 | 320 | return Key{}, err | |
| 222 | 321 | } | |
| @@ -411,7 +510,7 @@ func (r *Reader) readPaste() Key { | |||
| 411 | 510 | const end = "\x1b[201~" | |
| 412 | 511 | tail := make([]byte, 0, len(end)) | |
| 413 | 512 | for { | |
| 414 | - | b, err := r.src() | |
| 513 | + | b, err := r.readByte() | |
| 415 | 514 | if err != nil { | |
| 416 | 515 | break | |
| 417 | 516 | } | |
M packages/tui/input_test.go +140 -4
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestReaderParsesCSIUShiftEnter(t *testing.T) { | |
| 6 | 10 | k := readKey(t, "\x1b[13;2u") | |
| @@ -91,6 +95,111 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |||
| 91 | 95 | } | |
| 92 | 96 | } | |
| 93 | 97 | ||
| 98 | + | func TestReaderParsesRawEscapeEscapeAsTwoEscapes(t *testing.T) { | |
| 99 | + | r := newPeekReader("\x1b\x1b") | |
| 100 | + | if k := readReaderKey(t, r); k.Kind != KeyEsc { | |
| 101 | + | t.Fatalf("first Read kind=%v, want esc", k.Kind) | |
| 102 | + | } | |
| 103 | + | if k := readReaderKey(t, r); k.Kind != KeyEsc { | |
| 104 | + | t.Fatalf("second Read kind=%v, want esc", k.Kind) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestReaderParsesRawEscapeEscapeCSIAsEscapeThenUp(t *testing.T) { | |
| 109 | + | r := newPeekReader("\x1b\x1b[A") | |
| 110 | + | if k := readReaderKey(t, r); k.Kind != KeyEsc { | |
| 111 | + | t.Fatalf("first Read kind=%v, want esc", k.Kind) | |
| 112 | + | } | |
| 113 | + | if k := readReaderKey(t, r); k.Kind != KeyUp { | |
| 114 | + | t.Fatalf("second Read kind=%v, want up", k.Kind) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestReaderPreservesDoubleEscapePrintableAmbiguity(t *testing.T) { | |
| 119 | + | r := newPeekReader("\x1b\x1bx") | |
| 120 | + | if got := readReaderKey(t, r); got.Kind != KeyEsc { | |
| 121 | + | t.Fatalf("first Read = %+v, want bare escape", got) | |
| 122 | + | } | |
| 123 | + | if got := readReaderKey(t, r); got.Kind != KeyRune || got.Rune != 'x' || !got.Alt { | |
| 124 | + | t.Fatalf("second Read = %+v, want Alt+x after the documented ambiguity", got) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestReaderPreservesAmbiguousAltEscapeMappings(t *testing.T) { | |
| 129 | + | cases := []struct { | |
| 130 | + | name string | |
| 131 | + | seq string | |
| 132 | + | want Key | |
| 133 | + | }{ | |
| 134 | + | {name: "printable", seq: "\x1bx", want: Key{Kind: KeyRune, Rune: 'x', Alt: true}}, | |
| 135 | + | {name: "delete", seq: "\x1b\x7f", want: Key{Kind: KeyBackspace, Alt: true}}, | |
| 136 | + | {name: "word-left", seq: "\x1bb", want: Key{Kind: KeyLeft, Alt: true}}, | |
| 137 | + | {name: "word-right", seq: "\x1bf", want: Key{Kind: KeyRight, Alt: true}}, | |
| 138 | + | } | |
| 139 | + | for _, tc := range cases { | |
| 140 | + | t.Run(tc.name, func(t *testing.T) { | |
| 141 | + | got := readReaderKey(t, newPeekReader(tc.seq)) | |
| 142 | + | if got != tc.want { | |
| 143 | + | t.Fatalf("Read(%q) = %+v, want %+v", tc.seq, got, tc.want) | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestReaderWithoutPeekDoesNotBlockBareEscapeOrStartAnotherPump(t *testing.T) { | |
| 150 | + | release := make(chan struct{}) | |
| 151 | + | lookaheadStarted := make(chan struct{}) | |
| 152 | + | calls := 0 | |
| 153 | + | r := NewReader(func() (byte, error) { | |
| 154 | + | calls++ | |
| 155 | + | if calls == 1 { | |
| 156 | + | return 0x1b, nil | |
| 157 | + | } | |
| 158 | + | if calls == 2 { | |
| 159 | + | close(lookaheadStarted) | |
| 160 | + | <-release | |
| 161 | + | return 'x', nil | |
| 162 | + | } | |
| 163 | + | return 0, io.EOF | |
| 164 | + | }) | |
| 165 | + | ||
| 166 | + | readDone := make(chan struct{}) | |
| 167 | + | var got Key | |
| 168 | + | var err error | |
| 169 | + | go func() { | |
| 170 | + | got, err = r.Read() | |
| 171 | + | close(readDone) | |
| 172 | + | }() | |
| 173 | + | select { | |
| 174 | + | case <-lookaheadStarted: | |
| 175 | + | case <-time.After(500 * time.Millisecond): | |
| 176 | + | t.Fatal("lookahead source was not started") | |
| 177 | + | } | |
| 178 | + | select { | |
| 179 | + | case <-readDone: | |
| 180 | + | case <-time.After(500 * time.Millisecond): | |
| 181 | + | t.Fatal("Read blocked while disambiguating bare escape") | |
| 182 | + | } | |
| 183 | + | if err != nil { | |
| 184 | + | t.Fatalf("Read: %v", err) | |
| 185 | + | } | |
| 186 | + | if got.Kind != KeyEsc { | |
| 187 | + | t.Fatalf("Read kind=%v, want esc", got.Kind) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // The timed-out source call is still the sole owner of the next byte. | |
| 191 | + | // Releasing it must make that byte available to the next Read without | |
| 192 | + | // starting a second blocked callback. | |
| 193 | + | close(release) | |
| 194 | + | got, err = r.Read() | |
| 195 | + | if err != nil { | |
| 196 | + | t.Fatalf("Read after lookahead: %v", err) | |
| 197 | + | } | |
| 198 | + | if got.Kind != KeyRune || got.Rune != 'x' || calls != 2 { | |
| 199 | + | t.Fatalf("Read after lookahead = %+v with %d source calls, want x with 2 calls", got, calls) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 94 | 203 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 95 | 204 | cases := []struct { | |
| 96 | 205 | seq string | |
| @@ -109,15 +218,42 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) { | |||
| 109 | 218 | ||
| 110 | 219 | func readKey(t *testing.T, seq string) Key { | |
| 111 | 220 | t.Helper() | |
| 221 | + | return readReaderKey(t, NewReader(func() (byte, error) { | |
| 222 | + | if len(seq) == 0 { | |
| 223 | + | return 0, io.EOF | |
| 224 | + | } | |
| 225 | + | b := seq[0] | |
| 226 | + | seq = seq[1:] | |
| 227 | + | return b, nil | |
| 228 | + | })) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func newPeekReader(seq string) *Reader { | |
| 112 | 232 | idx := 0 | |
| 113 | - | r := NewReader(func() (byte, error) { | |
| 233 | + | read := func() (byte, error) { | |
| 234 | + | if idx >= len(seq) { | |
| 235 | + | return 0, io.EOF | |
| 236 | + | } | |
| 114 | 237 | b := seq[idx] | |
| 115 | 238 | idx++ | |
| 116 | 239 | return b, nil | |
| 117 | - | }) | |
| 240 | + | } | |
| 241 | + | peek := func(time.Duration) (byte, bool, error) { | |
| 242 | + | if idx >= len(seq) { | |
| 243 | + | return 0, false, nil | |
| 244 | + | } | |
| 245 | + | b := seq[idx] | |
| 246 | + | idx++ | |
| 247 | + | return b, true, nil | |
| 248 | + | } | |
| 249 | + | return NewReaderWithPeek(read, peek) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func readReaderKey(t *testing.T, r *Reader) Key { | |
| 253 | + | t.Helper() | |
| 118 | 254 | k, err := r.Read() | |
| 119 | 255 | if err != nil { | |
| 120 | - | t.Fatalf("Read(%q): %v", seq, err) | |
| 256 | + | t.Fatalf("Read: %v", err) | |
| 121 | 257 | } | |
| 122 | 258 | return k | |
| 123 | 259 | } | |
M packages/tui/resize_windows.go +194 -7
| @@ -3,8 +3,12 @@ | |||
| 3 | 3 | package tui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/binary" | |
| 6 | 7 | "os" | |
| 7 | 8 | "time" | |
| 9 | + | "unsafe" | |
| 10 | + | ||
| 11 | + | "golang.org/x/sys/windows" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| 10 | 14 | // Windows doesn't have SIGWINCH; resize events are signaled via input | |
| @@ -14,14 +18,197 @@ func (p *ProcTerm) installResizeHandler() {} | |||
| 14 | 18 | ||
| 15 | 19 | func (p *ProcTerm) SetNonblock(enable bool) error { return nil } | |
| 16 | 20 | ||
| 17 | - | // peekStdin on Windows: no non-blocking primitive in v1; fall through | |
| 18 | - | // to a blocking read. This means bare Esc on Windows is only detected | |
| 19 | - | // after the next keystroke. Good enough for v1. | |
| 20 | - | func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) { | |
| 21 | - | var b [1]byte | |
| 22 | - | _, err := in.Read(b[:]) | |
| 21 | + | // peekStdin waits for one byte on a supported Windows stdin handle without | |
| 22 | + | // consuming anything unless a byte is available. Console input handles can be | |
| 23 | + | // waited on directly. Pipes are checked with PeekNamedPipe, which does not | |
| 24 | + | // consume data; polling that non-consuming check keeps the wait bounded. | |
| 25 | + | // | |
| 26 | + | // Disk files and other character handles are deliberately unsupported. A | |
| 27 | + | // speculative os.File.Read on either can block (or consume a byte that cannot | |
| 28 | + | // be put back safely), so those handles report no data instead of corrupting | |
| 29 | + | // the escape parser. In particular, do not replace this with a blocking Read | |
| 30 | + | // fallback: an isolated Esc must remain observable within d. | |
| 31 | + | func peekStdin(in *os.File, d time.Duration) (byte, bool, error) { | |
| 32 | + | handle := windows.Handle(in.Fd()) | |
| 33 | + | fileType, err := windows.GetFileType(handle) | |
| 23 | 34 | if err != nil { | |
| 24 | 35 | return 0, false, err | |
| 25 | 36 | } | |
| 26 | - | return b[0], true, nil | |
| 37 | + | ||
| 38 | + | switch fileType { | |
| 39 | + | case windows.FILE_TYPE_CHAR: | |
| 40 | + | // FILE_TYPE_CHAR also includes devices such as NUL. GetConsoleMode | |
| 41 | + | // distinguishes an actual console input handle from those devices. | |
| 42 | + | var mode uint32 | |
| 43 | + | if err := windows.GetConsoleMode(handle, &mode); err != nil { | |
| 44 | + | return 0, false, windows.ERROR_INVALID_FUNCTION | |
| 45 | + | } | |
| 46 | + | return peekConsole(in, handle, d) | |
| 47 | + | case windows.FILE_TYPE_PIPE: | |
| 48 | + | return peekPipe(in, handle, d) | |
| 49 | + | case windows.FILE_TYPE_DISK: | |
| 50 | + | return 0, false, nil | |
| 51 | + | default: | |
| 52 | + | return 0, false, nil | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | const consoleKeyEvent = 0x0001 | |
| 57 | + | ||
| 58 | + | type consoleInputRecord struct { | |
| 59 | + | eventType uint16 | |
| 60 | + | _ uint16 | |
| 61 | + | event [16]byte | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | var ( | |
| 65 | + | peekConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekConsoleInputW") | |
| 66 | + | readConsoleInputProc = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputW") | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | func consoleInputRecordCall(proc *windows.LazyProc, handle windows.Handle, record *consoleInputRecord) (uint32, error) { | |
| 70 | + | if err := proc.Find(); err != nil { | |
| 71 | + | return 0, err | |
| 72 | + | } | |
| 73 | + | var read uint32 | |
| 74 | + | r1, _, callErr := proc.Call( | |
| 75 | + | uintptr(handle), | |
| 76 | + | uintptr(unsafe.Pointer(record)), | |
| 77 | + | 1, | |
| 78 | + | uintptr(unsafe.Pointer(&read)), | |
| 79 | + | ) | |
| 80 | + | if r1 == 0 { | |
| 81 | + | if callErr != nil && callErr != windows.ERROR_SUCCESS { | |
| 82 | + | return 0, callErr | |
| 83 | + | } | |
| 84 | + | return 0, windows.ERROR_INVALID_FUNCTION | |
| 85 | + | } | |
| 86 | + | return read, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func peekConsole(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) { | |
| 90 | + | deadline := time.Now().Add(d) | |
| 91 | + | for { | |
| 92 | + | remaining := d | |
| 93 | + | if d > 0 { | |
| 94 | + | remaining = time.Until(deadline) | |
| 95 | + | if remaining <= 0 { | |
| 96 | + | return 0, false, nil | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | event, err := windows.WaitForSingleObject(handle, waitMilliseconds(remaining)) | |
| 100 | + | if err != nil { | |
| 101 | + | return 0, false, err | |
| 102 | + | } | |
| 103 | + | switch event { | |
| 104 | + | case uint32(windows.WAIT_TIMEOUT): | |
| 105 | + | return 0, false, nil | |
| 106 | + | case windows.WAIT_OBJECT_0: | |
| 107 | + | var record consoleInputRecord | |
| 108 | + | count, err := consoleInputRecordCall(peekConsoleInputProc, handle, &record) | |
| 109 | + | if err != nil { | |
| 110 | + | return 0, false, err | |
| 111 | + | } | |
| 112 | + | if count == 0 { | |
| 113 | + | if d <= 0 { | |
| 114 | + | return 0, false, nil | |
| 115 | + | } | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | if record.eventType == consoleKeyEvent { | |
| 119 | + | keyDown := binary.LittleEndian.Uint32(record.event[0:4]) != 0 | |
| 120 | + | char := binary.LittleEndian.Uint16(record.event[10:12]) | |
| 121 | + | if keyDown && char != 0 { | |
| 122 | + | // The callback returns one byte, so leave non-ASCII | |
| 123 | + | // characters queued for the normal reader rather than | |
| 124 | + | // consuming them and truncating their encoding. | |
| 125 | + | if char > 0xff { | |
| 126 | + | return 0, false, nil | |
| 127 | + | } | |
| 128 | + | count, err := consoleInputRecordCall(readConsoleInputProc, handle, &record) | |
| 129 | + | if err != nil { | |
| 130 | + | return 0, false, err | |
| 131 | + | } | |
| 132 | + | if count != 0 { | |
| 133 | + | return byte(char), true, nil | |
| 134 | + | } | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | // Consume non-character, key-up, and non-printing records so | |
| 139 | + | // a ready console handle cannot make the next wait spin. | |
| 140 | + | if _, err := consoleInputRecordCall(readConsoleInputProc, handle, &record); err != nil { | |
| 141 | + | return 0, false, err | |
| 142 | + | } | |
| 143 | + | if d <= 0 || time.Until(deadline) <= 0 { | |
| 144 | + | return 0, false, nil | |
| 145 | + | } | |
| 146 | + | continue | |
| 147 | + | default: | |
| 148 | + | return 0, false, windows.ERROR_INVALID_FUNCTION | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func waitMilliseconds(d time.Duration) uint32 { | |
| 154 | + | if d <= 0 { | |
| 155 | + | return 0 | |
| 156 | + | } | |
| 157 | + | milliseconds := uint64(d / time.Millisecond) | |
| 158 | + | if d%time.Millisecond != 0 { | |
| 159 | + | milliseconds++ | |
| 160 | + | } | |
| 161 | + | const maxWaitMilliseconds = uint64(^uint32(0) - 1) // reserve INFINITE | |
| 162 | + | if milliseconds > maxWaitMilliseconds { | |
| 163 | + | return uint32(maxWaitMilliseconds) | |
| 164 | + | } | |
| 165 | + | return uint32(milliseconds) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | var peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe") | |
| 169 | + | ||
| 170 | + | func peekPipe(in *os.File, handle windows.Handle, d time.Duration) (byte, bool, error) { | |
| 171 | + | if err := peekNamedPipe.Find(); err != nil { | |
| 172 | + | return 0, false, err | |
| 173 | + | } | |
| 174 | + | deadline := time.Now().Add(d) | |
| 175 | + | for { | |
| 176 | + | var available uint32 | |
| 177 | + | ok, _, callErr := peekNamedPipe.Call( | |
| 178 | + | uintptr(handle), | |
| 179 | + | 0, 0, 0, | |
| 180 | + | uintptr(unsafe.Pointer(&available)), | |
| 181 | + | 0, | |
| 182 | + | ) | |
| 183 | + | if ok == 0 { | |
| 184 | + | if callErr != nil && callErr != windows.ERROR_SUCCESS { | |
| 185 | + | return 0, false, callErr | |
| 186 | + | } | |
| 187 | + | return 0, false, windows.ERROR_INVALID_FUNCTION | |
| 188 | + | } | |
| 189 | + | if available != 0 { | |
| 190 | + | var b [1]byte | |
| 191 | + | n, err := in.Read(b[:]) | |
| 192 | + | if err != nil { | |
| 193 | + | return 0, false, err | |
| 194 | + | } | |
| 195 | + | if n != len(b) { | |
| 196 | + | return 0, false, windows.ERROR_INVALID_FUNCTION | |
| 197 | + | } | |
| 198 | + | return b[0], true, nil | |
| 199 | + | } | |
| 200 | + | if d <= 0 { | |
| 201 | + | return 0, false, nil | |
| 202 | + | } | |
| 203 | + | remaining := time.Until(deadline) | |
| 204 | + | if remaining <= 0 { | |
| 205 | + | return 0, false, nil | |
| 206 | + | } | |
| 207 | + | // PeekNamedPipe is a non-consuming availability check. A short | |
| 208 | + | // sleep avoids a busy loop while the deadline remains authoritative. | |
| 209 | + | if remaining > time.Millisecond { | |
| 210 | + | remaining = time.Millisecond | |
| 211 | + | } | |
| 212 | + | time.Sleep(remaining) | |
| 213 | + | } | |
| 27 | 214 | } | |