Skip to content

bug: rewind targets wrong checkpoint — frontend counts synthetic user messages as turns #3640

@expfukck

Description

@expfukck

Problem

The rewind/checkpoint system is broken: the frontend and backend use different turn-numbering schemes, so the UI looks up the wrong checkpoint (or no checkpoint) when the user clicks rewind on a message.

  • Backend (controller.go:341): cpTurn increments only when beginCheckpoint() fires — once per real user turn.
  • Frontend (Transcript.tsx:173–177): turn increments for every kind:"user" item in the transcript — including synthetic messages injected by the agent.

When the agent injects synthetic RoleUser messages (stream recovery, retries, compaction), the frontend's turn counter gets ahead of the backend's checkpoint counter. All subsequent rewind operations target the wrong checkpoint or fail entirely.

Exact Code Locations

Frontend turn counting — Transcript.tsx:171–180

const questions = useMemo<QuestionAnchor[]>(() => {
    const anchors: QuestionAnchor[] = [];
    let turn = 0;
    for (const it of items) {
      if (it.kind !== "user") continue;
      anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
      turn += 1;  // ← counts EVERY user item, including synthetics
    }
    return anchors;
}, [items]);

Checkpoint lookup — Transcript.tsx:303, 354

const checkpointsByTurn = new Map(checkpoints.map((cp) => [cp.turn, cp]));
// ...
checkpoint={checkpointsByTurn.get(turn)}  // ← turn is inflated, returns undefined

Backend checkpoint — controller.go:335–346

func (c *Controller) beginCheckpoint(input string) {
    turn := c.cpTurn
    c.cpTurn++  // ← only increments for real user turns
    // ...
}

Called exactly once at controller.go:452 per user-initiated turn.

Synthetic RoleUser injections (no checkpoint created)

File Line Trigger
agent.go 442–445 Stream recovery after interrupted stream
agent.go 489 Final-answer readiness check retry
agent.go 499 Empty final answer retry
agent.go 506 Planner→executor handoff retry
controller.go 500 Plan-approved sub-turn (re-enters agent.Run)
compact.go 220–226 Auto-compaction summary message
compact.go 264–267 SummarizeFrom summary message
compact.go 296–299 SummarizeUpTo summary message

History API sends ALL messages — controller.go:1428–1433

func (c *Controller) History() []provider.Message {
    return c.executor.Session().Snapshot()  // includes ALL synthetic messages
}

No filtering. desktop/app.go:1280–1305 (historyMessages) also does not filter.

No Internal/Synthetic marker exists

provider.Message struct (provider/provider.go:28–41) has no field to distinguish real vs synthetic user messages.

Concrete Example

User sends "fix the bug"     → beginCheckpoint fires (cpTurn=0)
                              → agent adds real RoleUser to session
Stream interrupted            → agent adds recovery RoleUser (NO checkpoint)
Model returns empty           → agent adds retry RoleUser (NO checkpoint)
User sends "also add tests"   → beginCheckpoint fires (cpTurn=1)
                              → agent adds real RoleUser to session

Backend checkpoints:  turn 0 (has files), turn 1 (has files)  — 2 total
Frontend user items:  turn 0, 1, 2, 3, 4                       — 5 total

checkpointsByTurn.get(0) → ✅ turn 0 checkpoint
checkpointsByTurn.get(1) → ✅ turn 1 checkpoint (WRONG — maps to stream recovery, not "also add tests")
checkpointsByTurn.get(2) → undefined → "no restore point"
checkpointsByTurn.get(3) → undefined → "no restore point"
checkpointsByTurn.get(4) → undefined → "no restore point"

Suggested Fix

Option A — Mark synthetic messages (minimal backend change):

Add an Internal field to provider.Message:

type Message struct {
    // ... existing fields ...
    Internal bool `json:"internal,omitempty"` // true for recovery/retry/compaction messages
}

Set Internal: true at the 8 injection points listed above. Then filter in controller.History():

func (c *Controller) History() []provider.Message {
    msgs := c.executor.Session().Snapshot()
    filtered := make([]provider.Message, 0, len(msgs))
    for _, m := range msgs {
        if m.Internal { continue }
        filtered = append(filtered, m)
    }
    return filtered
}

Option B — Send authoritative turn numbers from backend:

Add a CheckpointTurn *int field to HistoryMessage. Set it only for real user messages (those with a matching checkpoint). The frontend uses this instead of counting sequentially.

Option C — Frontend-only workaround:

In historyMessagesToItems (useController.ts:166–170), detect and skip synthetic messages by their known content patterns (e.g., messages starting with "The previous assistant response was interrupted" or "Please continue"). Fragile but requires no backend changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    agentCore agent loop (internal/agent, internal/control)data-lossData loss (sessions, config, history)desktopWails desktop app (desktop/**)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions