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.
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.
controller.go:341):cpTurnincrements only whenbeginCheckpoint()fires — once per real user turn.Transcript.tsx:173–177):turnincrements for everykind:"user"item in the transcript — including synthetic messages injected by the agent.When the agent injects synthetic
RoleUsermessages (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–180Checkpoint lookup —
Transcript.tsx:303, 354Backend checkpoint —
controller.go:335–346Called exactly once at
controller.go:452per user-initiated turn.Synthetic
RoleUserinjections (no checkpoint created)agent.goagent.goagent.goagent.gocontroller.gocompact.gocompact.gocompact.goHistory API sends ALL messages —
controller.go:1428–1433No filtering.
desktop/app.go:1280–1305(historyMessages) also does not filter.No
Internal/Syntheticmarker existsprovider.Messagestruct (provider/provider.go:28–41) has no field to distinguish real vs synthetic user messages.Concrete Example
Suggested Fix
Option A — Mark synthetic messages (minimal backend change):
Add an
Internalfield toprovider.Message:Set
Internal: trueat the 8 injection points listed above. Then filter incontroller.History():Option B — Send authoritative turn numbers from backend:
Add a
CheckpointTurn *intfield toHistoryMessage. 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.