mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
920 lines
31 KiB
Go
920 lines
31 KiB
Go
package tui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/execution"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/usage"
|
|
)
|
|
|
|
const tuiSessionTitleLimit = 80
|
|
|
|
type pendingSessionEvent struct {
|
|
Type sessions.EventType
|
|
Payload any
|
|
}
|
|
|
|
func (m model) ensureActiveSession(prompt string) (model, error) {
|
|
if m.activeSession.SessionID != "" {
|
|
return m, nil
|
|
}
|
|
|
|
title := strings.TrimSpace(m.pendingSessionTitle)
|
|
manuallyNamed := title != ""
|
|
if !manuallyNamed {
|
|
title = tuiSessionTitle(prompt)
|
|
}
|
|
session, err := m.sessionStore.Create(sessions.CreateInput{
|
|
Title: title,
|
|
Cwd: m.cwd,
|
|
ModelID: m.modelName,
|
|
Provider: m.providerName,
|
|
})
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
m.activeSession = session
|
|
m.pendingSessionTitle = ""
|
|
m.sessionEvents = []sessions.Event{}
|
|
if manuallyNamed {
|
|
if m.titledSessions == nil {
|
|
m.titledSessions = map[string]bool{}
|
|
}
|
|
m.titledSessions[session.SessionID] = true
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// startNewSession abandons the visible conversation and the agent's in-context
|
|
// history and begins a fresh session in place. The current session already lives
|
|
// on disk (its events are persisted as they happen), so it stays resumable via
|
|
// /resume; here we only clear the in-memory conversation and the per-session
|
|
// usage/compaction counters, then let the next prompt lazily create a new session
|
|
// through ensureActiveSession — the same seam a cold start uses. Model, provider,
|
|
// permission mode, and response style are intentionally preserved: /new starts a
|
|
// clean conversation, not a clean configuration.
|
|
func (m model) startNewSession() model {
|
|
previousID := m.activeSession.SessionID
|
|
|
|
m.activeSession = sessions.Metadata{}
|
|
m.pendingSessionTitle = ""
|
|
m.sessionEvents = nil
|
|
|
|
// Reset the per-session usage + compaction display so the new session starts
|
|
// from zero instead of inheriting the previous conversation's token/cost totals.
|
|
if m.usageTracker != nil {
|
|
m.usageTracker.Reset()
|
|
}
|
|
m.lastUsage = usage.Normalized{}
|
|
m.lastUsageSeen = false
|
|
m.unpricedRequests = 0
|
|
m.unpricedTokens = 0
|
|
m.compactRequests = 0
|
|
m.compactFrame = 0
|
|
m.lastCompactResult = nil
|
|
m.lastCompactError = ""
|
|
m.turnLatencySum = 0
|
|
m.turnLatencyCount = 0
|
|
m.turnTTFTSum = 0
|
|
m.turnTTFTCount = 0
|
|
|
|
// Staged input belongs to the previous conversation. Attachments and a queued
|
|
// message are only consumed at prompt-submit, so without clearing them here the
|
|
// fresh session's first prompt would silently inherit the old session's images,
|
|
// documents, or queued text.
|
|
m.pendingImages = nil
|
|
m.pendingImageLabels = nil
|
|
m.pendingDocuments = nil
|
|
m.queuedMessage = ""
|
|
// The remembered /retry attachment snapshot belongs to the previous session
|
|
// too — dropping it keeps a post-/new /retry from re-staging old images or
|
|
// documents. lastPrompt is composer history (like inputHistory) and stays.
|
|
m.lastImages = nil
|
|
m.lastImageLabels = nil
|
|
m.lastDocuments = nil
|
|
|
|
note := "Started a new session."
|
|
if previousID != "" {
|
|
note = "New session started. Previous session saved as " + previousID +
|
|
" — resume it anytime with /resume " + previousID + "."
|
|
}
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: note})
|
|
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
|
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
|
m.resetFlushFrontier("· new session ·")
|
|
// Loops belong to the previous session; stop them so they don't fire the old
|
|
// conversation's prompt into the fresh one.
|
|
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
|
m = updated
|
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (model, error) {
|
|
if m.activeSession.SessionID == "" {
|
|
return m, nil
|
|
}
|
|
|
|
event, err := m.sessionStore.AppendEvent(m.activeSession.SessionID, sessions.AppendEventInput{
|
|
Type: eventType,
|
|
Payload: payload,
|
|
})
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
m.activeSession.UpdatedAt = event.CreatedAt
|
|
m.activeSession.EventCount = event.Sequence
|
|
m.activeSession.LastEventType = event.Type
|
|
m.sessionEvents = append(m.sessionEvents, event)
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
|
|
rows := []transcriptRow{}
|
|
if m.activeSession.SessionID == "" || len(events) == 0 {
|
|
return m, rows
|
|
}
|
|
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
|
for _, event := range events {
|
|
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
|
}
|
|
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
|
|
if err != nil {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
|
return m, rows
|
|
}
|
|
if len(appended) > 0 {
|
|
last := appended[len(appended)-1]
|
|
m.activeSession.UpdatedAt = last.CreatedAt
|
|
m.activeSession.EventCount = last.Sequence
|
|
m.activeSession.LastEventType = last.Type
|
|
m.sessionEvents = append(m.sessionEvents, appended...)
|
|
}
|
|
return m, rows
|
|
}
|
|
|
|
// appendSessionEventsTo persists events into a specific (non-active) session —
|
|
// the late flush of a run cancelled before a /resume switched sessions. The
|
|
// active session's in-memory metadata is deliberately untouched.
|
|
func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEvent) []transcriptRow {
|
|
rows := []transcriptRow{}
|
|
if m.sessionStore == nil || sessionID == "" {
|
|
return rows
|
|
}
|
|
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
|
for _, event := range events {
|
|
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
|
}
|
|
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// flushableSessionEvents selects the events worth persisting from a run that was
|
|
// cancelled mid-flight. The cancel path already records a single "Run cancelled."
|
|
// error, so the goroutine's trailing EventError (the ctx-cancellation error) is
|
|
// dropped to avoid a duplicate; everything else it accumulated before the cancel
|
|
// — tool calls/results, permission events, usage, and the EventSessionCheckpoint
|
|
// blobs that /rewind depends on — is kept.
|
|
func flushableSessionEvents(events []pendingSessionEvent) []pendingSessionEvent {
|
|
flushable := make([]pendingSessionEvent, 0, len(events))
|
|
for _, event := range events {
|
|
if event.Type == sessions.EventError {
|
|
continue
|
|
}
|
|
flushable = append(flushable, event)
|
|
}
|
|
return flushable
|
|
}
|
|
|
|
func tuiSessionTitle(prompt string) string {
|
|
// cutRunes keeps the cut on a rune boundary — a bare byte slice could split
|
|
// a multi-byte rune and persist invalid UTF-8 into the session metadata.
|
|
title := cutRunes(strings.Join(strings.Fields(prompt), " "), tuiSessionTitleLimit)
|
|
if title == "" {
|
|
return "Zero TUI session"
|
|
}
|
|
return title
|
|
}
|
|
|
|
func (m model) handleResumeCommand(args string) (model, string) {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
return m, m.resumeText()
|
|
}
|
|
|
|
session, err := m.resolveResumeSession(args)
|
|
if err != nil {
|
|
return m, "Sessions\n" + err.Error()
|
|
}
|
|
events, err := m.resumeEvents(session.SessionID)
|
|
if err != nil {
|
|
return m, "Sessions\nerror: " + err.Error()
|
|
}
|
|
|
|
// Capture the current session id before switching so loops are only torn down
|
|
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
|
// the already-active session, whose loops belong to it, not a "previous" one.
|
|
previousID := m.activeSession.SessionID
|
|
m.activeSession = *session
|
|
m.pendingSessionTitle = ""
|
|
m.sessionEvents = append([]sessions.Event{}, events...)
|
|
if m.providerName == "" {
|
|
m.providerName = session.Provider
|
|
}
|
|
if m.modelName == "" {
|
|
m.modelName = session.ModelID
|
|
}
|
|
loopsCleared := 0
|
|
if session.SessionID != previousID {
|
|
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
|
}
|
|
|
|
rows := initialTranscript()
|
|
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
|
if loopsCleared > 0 {
|
|
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
|
}
|
|
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
|
m.transcript = rows
|
|
// Every rehydrated row is settled by construction, so resetting the flush
|
|
// frontier sends the whole resumed history to native scrollback in one
|
|
// batch — scrollable, selectable, and O(1) for every later frame.
|
|
m.resetFlushFrontier("· resumed ·")
|
|
return m, ""
|
|
}
|
|
|
|
func (m model) sessionPrompt(prompt string) string {
|
|
if m.activeSession.SessionID == "" || len(m.sessionEvents) == 0 {
|
|
return prompt
|
|
}
|
|
formatted := sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
|
Mode: sessions.ModeResume,
|
|
Session: m.activeSession,
|
|
ContextEvents: append([]sessions.Event{}, m.sessionEvents...),
|
|
})
|
|
if m.activeSession.SessionKind == sessions.SessionKindSide {
|
|
return btwContextBoundary + "\n\n" + formatted
|
|
}
|
|
return formatted
|
|
}
|
|
|
|
func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
|
if strings.EqualFold(args, "latest") {
|
|
// Latest *resumable* conversation IN THIS WORKSPACE, so "latest" never lands
|
|
// on a child, a spec sub-run, or a session from another project (matching the
|
|
// workspace-scoped picker). An explicit `/resume <id>` below still resolves
|
|
// any session regardless of workspace.
|
|
latest, err := m.latestResumableInWorkspace()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if latest == nil {
|
|
return nil, errors.New("no zero sessions available to resume")
|
|
}
|
|
return latest, nil
|
|
}
|
|
|
|
session, err := m.sessionStore.Get(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if session == nil {
|
|
return nil, fmt.Errorf("zero session not found: %s", args)
|
|
}
|
|
if !sessions.IsResumableKind(session.SessionKind) {
|
|
return nil, fmt.Errorf("zero session is not resumable: %s", args)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// resumeEvents reads a session's events for resume, preferring the rehydrated
|
|
// (compaction-aware) view so a resumed session honors a prior /compact — matching
|
|
// the CLI's `zero exec --resume` (readExecContextEvents) and the in-TUI /compact
|
|
// reload. Falls back to the raw log if rehydration fails.
|
|
func (m model) resumeEvents(sessionID string) ([]sessions.Event, error) {
|
|
events, err := m.sessionStore.ReadRehydratedEvents(sessionID)
|
|
if err == nil {
|
|
return events, nil
|
|
}
|
|
raw, rawErr := m.sessionStore.ReadEvents(sessionID)
|
|
if rawErr != nil {
|
|
// Surface the raw-read failure (the actual fallback error), not the earlier
|
|
// rehydration error, so the caller sees why the fallback itself failed.
|
|
return nil, rawErr
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// formatResumeSummary reports what the resumed conversation will actually continue
|
|
// with (the active model/provider), noting the session's recorded model/provider
|
|
// when it differs — resume keeps the current model rather than switching.
|
|
func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) string {
|
|
modelLine := "model: " + displayValue(m.modelName, "none")
|
|
if recorded := strings.TrimSpace(session.ModelID); recorded != "" && !strings.EqualFold(recorded, m.modelName) {
|
|
modelLine += " (recorded: " + recorded + ")"
|
|
}
|
|
providerLine := "provider: " + displayValue(m.providerName, "none")
|
|
if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) {
|
|
providerLine += " (recorded: " + recorded + ")"
|
|
}
|
|
lines := []string{
|
|
"id: " + session.SessionID,
|
|
"title: " + displayValue(session.Title, "untitled"),
|
|
modelLine,
|
|
providerLine,
|
|
fmt.Sprintf("events: %d", eventCount),
|
|
}
|
|
if session.Goal != nil {
|
|
goalLine := "goal: " + string(session.Goal.Status) + " — " + session.Goal.Objective
|
|
if session.Goal.Status == sessions.GoalStatusActive {
|
|
goalLine += " (run /goal resume to continue)"
|
|
}
|
|
lines = append(lines, goalLine)
|
|
}
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Resumed Zero session",
|
|
Status: commandStatusOK,
|
|
Sections: []commandSection{{
|
|
Title: "Session",
|
|
Lines: lines,
|
|
}},
|
|
})
|
|
}
|
|
|
|
// sessionWhen formats a session's RFC3339 timestamp for the picker: a precise
|
|
// clock time (with seconds) for today so same-minute sessions stay distinct, the
|
|
// month/day and time earlier this year, else the date. Empty on a parse error.
|
|
func sessionWhen(timestamp string, now time.Time) string {
|
|
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parsed, now = parsed.Local(), now.Local()
|
|
switch {
|
|
case parsed.Year() == now.Year() && parsed.YearDay() == now.YearDay():
|
|
return parsed.Format("15:04:05")
|
|
case parsed.Year() == now.Year():
|
|
return parsed.Format("Jan _2 15:04")
|
|
default:
|
|
return parsed.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
// newSessionPicker builds the interactive /resume picker (mirrors /model & /provider):
|
|
// one row per resumable session — title (Label) + id and relative age (Meta). Returns
|
|
// nil when there are no resumable sessions so the caller falls back to the text path.
|
|
func (m model) newSessionPicker() *commandPicker {
|
|
if m.sessionStore == nil {
|
|
return nil
|
|
}
|
|
metas, err := m.sessionStore.ListResumable()
|
|
if err != nil || len(metas) == 0 {
|
|
return nil
|
|
}
|
|
now := m.now()
|
|
items := make([]pickerItem, 0, len(metas))
|
|
for _, meta := range metas {
|
|
// Workspace-scoped: hide sessions from other project directories so /resume
|
|
// lists this workspace's history, not every project's. Checked BEFORE the
|
|
// per-session event read below, so a large global history doesn't pay 50
|
|
// full file reads to build one workspace's list. Sessions with no recorded
|
|
// Cwd (older runs) stay visible rather than vanishing.
|
|
if !sessionMatchesWorkspace(meta.Cwd, m.cwd) {
|
|
continue
|
|
}
|
|
// A zero-event session has nothing to resume — skip it without a file read.
|
|
if meta.EventCount == 0 {
|
|
continue
|
|
}
|
|
// Skip empty/failed runs (no assistant output, no tool calls) — e.g. the
|
|
// same prompt retried while the model wasn't responding. They have nothing
|
|
// to resume and otherwise flood the list with identical rows. Still on disk.
|
|
if !m.sessionHasResumableContent(meta.SessionID) {
|
|
continue
|
|
}
|
|
// Lead with a fixed-width timestamp so titles form one scannable column.
|
|
// The raw id remains the selection/search value but stays out of the row:
|
|
// rendering it consumed half the picker and truncated the useful title.
|
|
label := displayValue(meta.Title, "untitled")
|
|
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
|
|
label = sessionPickerLabel(when, label)
|
|
}
|
|
items = append(items, pickerItem{
|
|
Label: label,
|
|
Value: meta.SessionID,
|
|
})
|
|
}
|
|
if len(items) == 0 {
|
|
return nil // every resumable session was an empty/failed run
|
|
}
|
|
return &commandPicker{
|
|
kind: pickerSession,
|
|
title: "Resume a session",
|
|
items: items,
|
|
allItems: append([]pickerItem{}, items...),
|
|
selected: 0,
|
|
}
|
|
}
|
|
|
|
const sessionPickerTimeWidth = len("Jan 02 15:04")
|
|
|
|
func sessionPickerLabel(when, title string) string {
|
|
return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title)
|
|
}
|
|
|
|
// sessionHasResumableContent reports whether a session has anything worth
|
|
// resuming: a tool call/result, or a non-user message with real content (not the
|
|
// no-output guardrail stop). Empty/failed runs return false and are hidden from
|
|
// the picker (they stay on disk). Errors fail open (the session is kept).
|
|
// latestResumableInWorkspace returns the most-recently-updated resumable session
|
|
// belonging to the current workspace, or nil when none exist. ListResumable is
|
|
// ordered latest-first, so the first qualifying match is the latest. It applies
|
|
// the SAME filters as newSessionPicker — workspace membership, non-empty
|
|
// metadata, and real resumable content — so `/resume latest` never lands on a
|
|
// zero-event or empty/failed run the picker would have hidden.
|
|
func (m model) latestResumableInWorkspace() (*sessions.Metadata, error) {
|
|
metas, err := m.sessionStore.ListResumable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range metas {
|
|
if !sessionMatchesWorkspace(metas[i].Cwd, m.cwd) {
|
|
continue
|
|
}
|
|
if metas[i].EventCount == 0 {
|
|
continue
|
|
}
|
|
if !m.sessionHasResumableContent(metas[i].SessionID) {
|
|
continue
|
|
}
|
|
return &metas[i], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// sessionMatchesWorkspace reports whether a session recorded in sessionCwd
|
|
// belongs to the current workspaceCwd. A session with no recorded Cwd (older
|
|
// runs) or an unknown current workspace is kept visible rather than filtered out,
|
|
// so the scoping never hides history it can't confidently place elsewhere. On
|
|
// Windows the comparison is case-insensitive, since the filesystem is and the
|
|
// same workspace can be spelled with different casing (C:\Proj vs c:\proj).
|
|
func sessionMatchesWorkspace(sessionCwd, workspaceCwd string) bool {
|
|
sessionCwd = strings.TrimSpace(sessionCwd)
|
|
workspaceCwd = strings.TrimSpace(workspaceCwd)
|
|
if sessionCwd == "" || workspaceCwd == "" {
|
|
return true
|
|
}
|
|
a := filepath.Clean(sessionCwd)
|
|
b := filepath.Clean(workspaceCwd)
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(a, b)
|
|
}
|
|
return a == b
|
|
}
|
|
|
|
func (m model) sessionHasResumableContent(sessionID string) bool {
|
|
events, err := m.sessionStore.ReadEvents(sessionID)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return eventsHaveResumableContent(events)
|
|
}
|
|
|
|
// eventsHaveResumableContent reports whether already-loaded events contain
|
|
// anything worth resuming: a tool call/result, or a non-user message with real
|
|
// content (not the no-output guardrail stop). It is the pure core of
|
|
// sessionHasResumableContent so callers that already hold the events (e.g. the
|
|
// session picker refresh) don't re-read them.
|
|
func eventsHaveResumableContent(events []sessions.Event) bool {
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case sessions.EventToolCall, sessions.EventToolResult:
|
|
return true
|
|
case sessions.EventMessage:
|
|
payload := sessionPayload(event)
|
|
if strings.EqualFold(payloadString(payload, "role"), "user") {
|
|
continue
|
|
}
|
|
content := strings.TrimSpace(payloadString(payload, "content"))
|
|
if content != "" && !agent.IsNoProgressStop(content) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// openSessionPicker opens the /resume picker; ok is false when there is nothing to
|
|
// resume (the caller then falls back to the text list / "none" message).
|
|
func (m model) openSessionPicker() (model, bool) {
|
|
picker := m.newSessionPicker()
|
|
if picker == nil {
|
|
return m, false
|
|
}
|
|
m.picker = picker
|
|
return m, true
|
|
}
|
|
|
|
func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
|
|
rows := []transcriptRow{}
|
|
// Rehydrated rows all carry runID 0, so repeated provider tool-call ids
|
|
// (e.g. Gemini's per-turn gemini_tool_N) get the same per-occurrence
|
|
// disambiguation the live runner applies — without it, dedup would drop
|
|
// every tool card after the first occurrence of an id.
|
|
callSeq := map[string]int{}
|
|
// Pre-pass: collect the tool-call ids of Task delegations that actually started
|
|
// a specialist (each renders as a card below). Only those Task tool-call/result
|
|
// rows are redundant and skipped; a Task that failed before a specialist started
|
|
// has no card, so its rows are kept — otherwise the failed delegation vanishes
|
|
// on resume (M10).
|
|
specialistToolCalls := map[string]bool{}
|
|
for _, event := range events {
|
|
if event.Type == sessions.EventSpecialistStart {
|
|
if id := payloadString(sessionPayload(event), "toolCallId"); id != "" {
|
|
specialistToolCalls[id] = true
|
|
}
|
|
}
|
|
}
|
|
for _, event := range events {
|
|
payload := sessionPayload(event)
|
|
switch event.Type {
|
|
case sessions.EventMessage:
|
|
role := strings.ToLower(payloadString(payload, "role"))
|
|
switch role {
|
|
case "ask_user":
|
|
rows = append(rows, askUserTranscriptRow(askUserRequestFromPayload(payload)))
|
|
continue
|
|
case "ask_user_answers":
|
|
if text := askUserAnswersText(payload); text != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: text})
|
|
}
|
|
continue
|
|
}
|
|
content := payloadString(payload, "content")
|
|
if content == "" {
|
|
continue
|
|
}
|
|
switch role {
|
|
case "user":
|
|
rows = append(rows, transcriptRow{kind: rowUser, text: content})
|
|
case "assistant":
|
|
// A persisted assistant message was a turn's final answer. Tool/timing
|
|
// counters were not recorded; the completion line omits those segments.
|
|
rows = append(rows, transcriptRow{kind: rowAssistant, text: content, final: true})
|
|
default:
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: content})
|
|
}
|
|
case sessions.EventToolCall:
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
// Extract the id exactly as the tool-result branch and the specialist
|
|
// pre-pass do (toolCallId first, then id) so call and result rows key
|
|
// callSeq/effectiveToolRowID on the same string and the specialist-skip
|
|
// lookup matches — otherwise a payload that carries toolCallId (not id)
|
|
// desyncs call→result dedup and the M10 skip (L20).
|
|
id := firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id"))
|
|
if name == "Task" && specialistToolCalls[id] {
|
|
// A specialist card renders this delegation; skip the redundant
|
|
// "tool call: Task" row. A Task with no specialist (it failed before
|
|
// one started) keeps its row so the failure stays visible (M10).
|
|
continue
|
|
}
|
|
callSeq[id]++
|
|
rows = append(rows, transcriptRow{
|
|
kind: rowToolCall,
|
|
id: effectiveToolRowID(id, callSeq[id]),
|
|
text: "tool call: " + name,
|
|
tool: name,
|
|
detail: argHint(payloadString(payload, "arguments")),
|
|
arg: argHintSecondary(payloadString(payload, "arguments")),
|
|
})
|
|
case sessions.EventPermission, sessions.EventPermissionRequest, sessions.EventPermissionDecision:
|
|
// Mirror the live path: a silently auto-approved call recorded its
|
|
// audit event but rendered no row, so don't resurrect one on resume.
|
|
event := permissionEventFromPayload(payload)
|
|
if permissionEventIsNoteworthy(event) {
|
|
rows = append(rows, permissionTranscriptRow(event))
|
|
}
|
|
case sessions.EventToolResult:
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
id := firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id"))
|
|
if name == "Task" && specialistToolCalls[id] {
|
|
// The specialist card carries this Task's result; skip the redundant
|
|
// "tool result: Task" row. A Task with no specialist keeps its result
|
|
// row so a failed delegation's error stays visible (M10).
|
|
continue
|
|
}
|
|
status := tools.Status(payloadString(payload, "status"))
|
|
if status == "" {
|
|
status = tools.StatusOK
|
|
}
|
|
output := payloadString(payload, "output")
|
|
rows = append(rows, transcriptRow{
|
|
kind: rowToolResult,
|
|
id: effectiveToolRowID(id, callSeq[id]),
|
|
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
|
|
tool: name,
|
|
status: status,
|
|
detail: output,
|
|
meta: payloadStringMap(payload, "meta"),
|
|
changedFiles: payloadStringSlice(payload, "changedFiles"),
|
|
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
|
|
})
|
|
case sessions.EventError:
|
|
if message := payloadString(payload, "message"); message != "" {
|
|
rows = append(rows, transcriptRow{kind: rowError, text: message})
|
|
}
|
|
case sessions.EventCompaction:
|
|
if summary := payloadString(payload, "summary"); summary != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: summary})
|
|
}
|
|
case sessions.EventSessionFork:
|
|
parentID := payloadString(payload, "parentSessionId")
|
|
if parentID != "" {
|
|
rows = append(rows, transcriptRow{kind: rowSystem, text: "forked from session: " + parentID})
|
|
}
|
|
case sessions.EventSpecialistStart:
|
|
info := specialistInfoFromPayload(payload)
|
|
if info != nil {
|
|
rows = append(rows, transcriptRow{kind: rowSpecialist, specialistInfo: info})
|
|
}
|
|
case sessions.EventSpecialistStop:
|
|
info := specialistInfoFromPayload(payload)
|
|
if info != nil {
|
|
// Reconcile: update the existing Start row with the same
|
|
// childSessionID instead of appending a duplicate. On resume
|
|
// this prevents two cards per specialist (running + completed).
|
|
found := false
|
|
for i := range rows {
|
|
if rows[i].kind == rowSpecialist && rows[i].specialistInfo != nil &&
|
|
rows[i].specialistInfo.childSessionID == info.childSessionID {
|
|
rows[i].specialistInfo = info
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
rows = append(rows, transcriptRow{kind: rowSpecialist, specialistInfo: info})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func sessionPayload(event sessions.Event) map[string]any {
|
|
payload := map[string]any{}
|
|
if len(event.Payload) == 0 {
|
|
return payload
|
|
}
|
|
_ = json.Unmarshal(event.Payload, &payload)
|
|
return payload
|
|
}
|
|
|
|
func permissionEventFromPayload(payload map[string]any) agent.PermissionEvent {
|
|
name := payloadString(payload, "name")
|
|
if name == "" {
|
|
name = payloadString(payload, "toolName")
|
|
}
|
|
event := agent.PermissionEvent{
|
|
ToolCallID: firstNonEmptyString(payloadString(payload, "toolCallId"), payloadString(payload, "id")),
|
|
ToolName: name,
|
|
Action: agent.PermissionAction(payloadString(payload, "action")),
|
|
DecisionAction: agent.PermissionDecisionAction(payloadString(payload, "decisionAction")),
|
|
Permission: payloadString(payload, "permission"),
|
|
PermissionGranted: payloadBool(payload, "permissionGranted"),
|
|
PermissionMode: agent.PermissionMode(payloadString(payload, "permissionMode")),
|
|
Autonomy: payloadString(payload, "autonomy"),
|
|
SideEffect: payloadString(payload, "sideEffect"),
|
|
Reason: payloadString(payload, "reason"),
|
|
Scope: payloadString(payload, "scope"),
|
|
DecisionReason: payloadString(payload, "decisionReason"),
|
|
GrantMatched: payloadBool(payload, "grantMatched"),
|
|
}
|
|
if risk, ok := payloadMap(payload, "risk"); ok {
|
|
event.Risk = sandbox.Risk{
|
|
Level: sandbox.RiskLevel(payloadString(risk, "level")),
|
|
Reason: payloadString(risk, "reason"),
|
|
}
|
|
}
|
|
if block, ok := payloadMap(payload, "block"); ok {
|
|
event.Block = &sandbox.Block{
|
|
Code: sandbox.BlockCode(payloadString(block, "code")),
|
|
ToolName: payloadString(block, "toolName"),
|
|
Action: sandbox.Action(payloadString(block, "action")),
|
|
Risk: event.Risk,
|
|
Path: payloadString(block, "path"),
|
|
Reason: payloadString(block, "reason"),
|
|
Recoverable: payloadBool(block, "recoverable"),
|
|
}
|
|
if nestedRisk, ok := payloadMap(block, "risk"); ok {
|
|
event.Block.Risk = sandbox.Risk{
|
|
Level: sandbox.RiskLevel(payloadString(nestedRisk, "level")),
|
|
Reason: payloadString(nestedRisk, "reason"),
|
|
}
|
|
}
|
|
}
|
|
return event
|
|
}
|
|
|
|
// askUserRequestFromPayload rebuilds the request persisted by
|
|
// askUserSessionPayload, so ask_user exchanges survive /resume instead of
|
|
// silently vanishing from rehydrated history.
|
|
func askUserRequestFromPayload(payload map[string]any) agent.AskUserRequest {
|
|
request := agent.AskUserRequest{
|
|
ToolCallID: payloadString(payload, "toolCallId"),
|
|
Header: payloadString(payload, "header"),
|
|
}
|
|
raw, ok := payload["questions"].([]any)
|
|
if !ok {
|
|
return request
|
|
}
|
|
for _, entry := range raw {
|
|
fields, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
question := agent.AskUserQuestion{
|
|
Question: payloadString(fields, "question"),
|
|
MultiSelect: payloadBool(fields, "multiSelect"),
|
|
}
|
|
if options, ok := fields["options"].([]any); ok {
|
|
for _, option := range options {
|
|
if text, ok := option.(string); ok {
|
|
question.Options = append(question.Options, text)
|
|
}
|
|
}
|
|
}
|
|
request.Questions = append(request.Questions, question)
|
|
}
|
|
return request
|
|
}
|
|
|
|
// askUserAnswersText renders persisted ask_user answers for rehydration.
|
|
func askUserAnswersText(payload map[string]any) string {
|
|
raw, ok := payload["answers"].([]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, len(raw))
|
|
for index, entry := range raw {
|
|
text, _ := entry.(string)
|
|
if strings.TrimSpace(text) == "" {
|
|
text = "(skipped)"
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s", index+1, text))
|
|
}
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return "Answers\n" + strings.Join(lines, "\n")
|
|
}
|
|
|
|
func payloadString(payload map[string]any, key string) string {
|
|
value := payload[key]
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case float64, bool:
|
|
return fmt.Sprint(typed)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
data, err := json.Marshal(typed)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
}
|
|
|
|
// payloadStringSlice reads a []string persisted into a session payload (JSON
|
|
// round-trips it as []any), skipping non-string entries. Nil when absent.
|
|
func payloadStringSlice(payload map[string]any, key string) []string {
|
|
switch typed := payload[key].(type) {
|
|
case []string:
|
|
return typed
|
|
case []any:
|
|
out := make([]string, 0, len(typed))
|
|
for _, v := range typed {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func payloadStringMap(payload map[string]any, key string) map[string]string {
|
|
value, ok := payloadMap(payload, key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(value))
|
|
for name, raw := range value {
|
|
if text, ok := raw.(string); ok {
|
|
out[name] = text
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func payloadExecutionChanges(payload map[string]any, key string) []execution.Change {
|
|
value, ok := payload[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var changes []execution.Change
|
|
if err := json.Unmarshal(data, &changes); err != nil {
|
|
return nil
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func payloadBool(payload map[string]any, key string) bool {
|
|
value := payload[key]
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(typed), "true")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func payloadMap(payload map[string]any, key string) (map[string]any, bool) {
|
|
value, ok := payload[key].(map[string]any)
|
|
return value, ok
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// specialistInfoFromPayload builds a specialistInfo from a specialist_start or
|
|
// specialist_stop session event payload. Returns nil if the payload lacks a
|
|
// childSessionId (the minimum required field).
|
|
func specialistInfoFromPayload(payload map[string]any) *specialistInfo {
|
|
childSessionID := payloadString(payload, "childSessionId")
|
|
if childSessionID == "" {
|
|
return nil
|
|
}
|
|
info := &specialistInfo{
|
|
name: payloadString(payload, "specialist"),
|
|
description: payloadString(payload, "description"),
|
|
childSessionID: childSessionID,
|
|
}
|
|
statusStr := payloadString(payload, "status")
|
|
switch statusStr {
|
|
case "running":
|
|
info.status = specialistRunning
|
|
case "success":
|
|
info.status = specialistCompleted
|
|
case "completed":
|
|
info.status = specialistCompleted
|
|
default:
|
|
info.status = parseSpecialistStatus(statusStr)
|
|
}
|
|
if errMsg := payloadString(payload, "error"); errMsg != "" {
|
|
info.errorMsg = errMsg
|
|
}
|
|
return info
|
|
}
|