Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

5531 lines
333 KiB
Markdown

# Troubleshooting: Agent Sessions And Lifecycle
[Agent runtime index](./agent-runtime.md) · [All troubleshooting](./README.md)
### A newly opened Agent window shows processing but no transcript
- **Symptom:** A Session is already running, but opening it in another window
shows only the processing status and omits the submitted user message. The
original window may still show the complete conversation.
- **Quick checks:** Correlate the new renderer's selected Session with its first
canonical detail and message reads. If the message read fails or times out
while the Session projection still reports active work, the transcript is
temporarily unhydrated rather than deleted.
- **Root cause:** The focused Session reconcile reached a terminal transport
failure, but the synchronization lease did not observe that failure or
request recovery. The new window therefore retained only the live processing
projection.
- **Fix:** Preserve timeout as an explicit reconcile failure. While the Session
remains focused, report the failure and perform one bounded state-and-message
retry. Stop focus-owned recovery when the window releases the synchronization
lease.
- **Validation:** Make the first focused message hydration fail, then return a
canonical user message on the retry. Assert exactly two reads, one reported
initial failure, and a restored transcript. Separately cover the reducer's
timeout outcome so it remains observable.
- **References:**
[sessionReconcile.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionReconcile.reducer.ts),
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
### Claude cancellation reaches `context deadline exceeded`
- **Symptom:** Canceling a Claude Code Turn, closing its Session, or leaving a
room waits until the caller deadline and reports `context deadline exceeded`.
Repeating the action leaves the same Turn active.
- **Quick checks:** Filter daemon logs by `CLAUDE_CODE_CANCEL_DIAGNOSTIC`, then
group by `requestId`, `agentSessionId`, `turnId`, and `generationId`. Compare
`interrupt_started` with `interrupt_succeeded`, `interrupt_timed_out`, or
`interrupt_failed`; then require `query_close_succeeded` and either
`consumption_settled` or `consumption_timed_out`. The payload deliberately
excludes prompts, tool inputs, credentials, and provider output.
- **Root cause:** The SDK interrupt is a control request whose Promise has no
native timeout. A wedged Claude Code process may never return its control
response. A caller-side Go deadline only stops the RPC waiter and cannot
terminate that Promise or the provider process.
- **Fix:** Treat cancellation as a Query-lifecycle protocol. Revoke the exact
generation, bound cooperative interrupt, close the owned SDK transport when
ACK is missing or fails, and separately bound consumer drain before settling
canonical Turns. Do not increase only the outer RPC timeout.
- **Validation:** Cover an interrupt Promise that never settles, immediate
interrupt rejection, and a consumer Promise that never drains. The first two
must close transport and cancel the Turn; the last must return an explicit
bounded failure.
- **References:**
[queryGeneration.ts](../../../packages/agent/claude-sdk-sidecar/src/queryGeneration.ts),
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts),
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
### Claude Goal stays active after the model becomes idle
- **Symptom:** Claude has stopped producing output and the Session becomes
idle, but the Goal banner remains active indefinitely. The elapsed time may
also reset after leaving and reopening the workspace.
- **Quick checks:** Inspect sidecar events and the root Claude transcript. A
failed evaluator has an active `active_goal` or `goal_status.met=false`, a
root `session_state_changed(state=idle)`, and no later
`goal_status.met=true`.
- **Root cause:** Claude's SDK stream may omit top-level `goal_status`
attachments. A native evaluator timeout can then reach idle without a
terminal verdict. Separately, a timer based on React mount time cannot
survive workspace navigation.
- **Fix:** Tail only new root transcript rows, drain once more at provider idle,
and transition a still-active Goal to canonical `blocked`. Persist one
`startedAtUnixMs` per Goal generation and derive active elapsed time from it.
- **Validation:** Cover both boundaries: terminal transcript evidence written
immediately before idle must win, while idle without terminal evidence must
emit `blocked`. Unmount and remount the Goal banner with the same canonical
start and require the elapsed value not to reset.
- **References:**
[goalProjection.ts](../../../packages/agent/claude-sdk-sidecar/src/goalProjection.ts),
[messageRouter.ts](../../../packages/agent/claude-sdk-sidecar/src/messageRouter.ts),
[AgentGoalBanner.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/AgentGoalBanner.tsx)
### Older extension session fails because its launch identity is incomplete
- **Symptom:** Sending a new message to a previously created extension session
fails with `session runtime snapshot is unavailable: launch identity is
incomplete`, while a newly created session can still launch.
- **Quick checks:** Compare the persisted Session provider with
`internal_runtime_context.sessionRuntimeSnapshot.provider`. The historical
defect has an open extension provider such as `acp:kimi-code` on the Session,
an empty provider in the snapshot, and a provider-native fingerprint produced
with the same empty provider. An intermediate build may retain the provider
field while still carrying that legacy empty-provider fingerprint.
- **Root cause:** Registered-only provider normalization was used when writing
the snapshot and its provider-native model fingerprint. Extension-owned ACP
provider IDs were therefore collapsed to empty even though the durable
Session retained the correct provider.
- **Fix:** Persist and fingerprint provider-native configurations with open
provider normalization. For existing records, recover only when the Session
carries a valid extension-owned provider and the snapshot exactly matches the
legacy empty-provider fingerprint; keep malformed, registered-provider, plan,
and fingerprint-mismatched snapshots fail-closed.
- **Validation:** Prove a newly written extension snapshot binds its provider,
the exact historical shape resumes through its current enabled Agent Target,
a strict context-only read still rejects the incomplete identity, and altered
fingerprints or registered-provider fallbacks remain unavailable.
- **References:**
[session_runtime_snapshot.go](../../../services/tuttid/service/agent/session_runtime_snapshot.go),
[model_plan_binding.go](../../../services/tuttid/service/agent/model_plan_binding.go)
### Initial Goal session remains unnamed
- Symptom:
A new session created from `/goal <objective>` keeps an empty canonical title
and the UI shows the localized unnamed-conversation label after the optimistic
activation disappears.
- Quick checks:
Read `workspace_agent_sessions.title` and the first
`workspace_agent_messages` row. The affected shape has `kind=session_audit`,
`role=user`, `goalControl=true`, and no normal text Turn.
- Root cause:
Typed initial Goal intentionally skips the ordinary initial Turn. Title
derivation existed only in the normal initial-content `Exec` path, so the
session was persisted without a title even though the Goal audit contained the
submitted command.
- Fix:
Derive the title in Host before provider startup from `InitialDisplayPrompt`;
when that field is absent, synthesize `/goal <objective>` or `/goal <action>`.
Mark the derived title established so later runtime state cannot replace it.
- Validation:
Run the shared initial-Goal conformance scenario through both direct Host and
service-adapter drivers. Assert the returned canonical title and verify the
zero-Turn Goal operation still replays without a second provider startup.
- References:
[packages/agent/host/README.md](../../../packages/agent/host/README.md)
[lifecycle.go](../../../packages/agent/host/lifecycle.go)
[session_lifecycle_scenarios.go](../../../packages/agent/host/conformance/session_lifecycle_scenarios.go)
### Goal-control row has no copy action
- Symptom:
A visible `/goal ...` user bubble has no copy button on hover, while ordinary
user text bubbles have one.
- Quick checks:
Inspect the row kind. `goal-control` renders through `AgentGoalControlRow`,
not `AgentMessageBlock`; search direct `AgentRichTextReadonly` uses for a
renderer that bypasses `AgentCopyableMessageGroup`.
- Root cause:
The special row reused the read-only rich-text renderer but not the shared
message action wrapper.
- Fix:
Wrap the row in `AgentCopyableMessageGroup` and write through the host
clipboard, with the browser clipboard as the renderer fallback.
- Validation:
Render a durable goal-control row, hover it, assert the copy button exists,
click it, and verify the exact command body is written.
- References:
[AgentGoalControlRow.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentGoalControlRow.tsx)
[AgentMessageActions.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentMessageActions.tsx)
### Replay startup reports only `building isolated tuttid`
- **Symptom:** Replay fails before the isolated Workspace becomes ready, but
the toast shows only the runner's first progress line instead of the daemon
startup failure.
- **Quick checks:** Inspect the managed Replay failure status. New builds store
`errorCause.code` and `errorCause.message`; the outer `errorMessage` remains
the supervision summary.
- **Root cause:** The former UI formatter selected the first line from one
combined process-log string. The actionable daemon stderr appeared on a
later line and was discarded.
- **Fix:** Preserve daemon startup stderr as a structured `{code, message}`
cause across Desktop startup, the Replay runner event, and primary Desktop
status. Render the structured cause before the outer summary.
- **Validation:** Make isolated `tuttid` reject a Cassette during startup and
require the toast to show the daemon rejection while logs retain both the
outer startup error and structured cause.
### Replay Workspace stays on the starting toast
- **Symptom:** The source Desktop keeps showing the Replay Workspace starting
toast. The isolated Desktop may appear only after the daemon startup timeout.
- **Quick checks:** Inspect the isolated runtime under
`.tmp/agent-session-replay-workspace-*`. The listener must appear at
`state/run/tuttid.listener.json`; inspect `logs/desktop.log` and the managed
child output before debugging renderer readiness.
- **Root cause:** The runner waited for the obsolete
`state/cassette/tuttid.listener.json` path after the daemon moved its listener
metadata into the canonical runtime `run` directory.
- **Fix:** Resolve Replay daemon discovery through
`state/run/tuttid.listener.json`, matching every other managed daemon
consumer. Keep the path in one runner helper and cover it directly.
- **Validation:** Launch a managed Replay Workspace and prove its isolated
daemon becomes discoverable without waiting for the startup timeout.
### Replay fails because Desktop and tuttid use different event catalogs
- **Symptom:** Replay stops during Desktop startup with
`desktop/tuttid event stream catalog mismatch (fail-fast)`, or the isolated
Desktop log contains `Event stream catalog revision mismatch`.
- **Quick checks:** Compare the revisions reported by the runner for the
prepared `apps/desktop/out` bundle, the event-protocol source, and the
`tuttid` binary. A prepared Electron bundle can be stale even when the
current source checkout and daemon build match.
- **Root cause:** The Desktop renderer and daemon perform a strict event-stream
catalog handshake. Reusing a prepared renderer or daemon from a different
generated-protocol revision makes the handshake fail before Replay can drive
a Session.
- **Fix:** For an unmanaged launch, the runner falls back to
`pnpm-dev-desktop` when the current event-protocol source matches `tuttid`.
For a managed launch, rebuild `apps/desktop/out` and `tuttid` from the same
checkout, or clear the prepared Electron environment before retrying.
- **Validation:** Confirm the runner reports matching revisions before launch,
then run the Replay scenario and require the first checkpoint to become
ready without a catalog mismatch in `desktop.log`.
- **References:**
[event-stream-catalog.mjs](../../../tools/scripts/agent-session-replay-runner/event-stream-catalog.mjs),
[agent-session-replay.md](../../../docs/architecture/agent-session-replay.md)
### Replay starts but the first Turn never becomes idle
- **Symptom:** The isolated Replay Workspace is ready, but playback waits on the
first Session until transport verification reports unconsumed connections.
- **Quick checks:** Query the Cassette-scoped transport playback state and
verification endpoint. If the provider tape still has outbound frames while
the current UI no longer emits the recorded probe sequence, the Cassette is
incompatible with the current strict transport contract.
- **Root cause:** Session Replay intentionally validates provider traffic in
order. Benign-looking UI probe or request-order changes can therefore make an
older Cassette unable to reach its recorded completion frame.
- **Fix:** Record a new Cassette with the current architecture. Do not weaken
transport ordering or add a compatibility fallback unless the product
contract explicitly changes.
- **Validation:** The new Cassette must consume every recorded provider
connection, reach the expected stable checkpoints, and pass final transport
verification.
### Replay checkpoint waits forever with `observation:null` while the session detail is visible
- **Symptom:** Replay playback dispatches intents and streams provider frames
normally, but a checkpoint wait stalls on
`{"ready":false,"observation":null,"hasDetail":true}` and the coordinator
binding reports `mounted:true, selectedAgentSessionId:null,
detailHydrated:false`.
- **Quick checks:** Enumerate `[data-workbench-window-id]` elements over CDP.
If two AgentGUI nodes exist — one showing the replayed session and one empty —
the cassette binding is attached to the wrong node.
- **Root cause:** Something launched an AgentGUI surface before the Replay
Workspace bootstrap (for example a dock-launch preparation step). The replay
driver then interacts with the pre-launched node through the DOM while the
canonical-observation binding waits on the coordinator-launched node, which
never selects a session.
- **Fix:** In replay mode, never open AgentGUI surfaces outside
`bridge.bootstrap`. The coordinator-launched node must be the only AgentGUI
node; the runner waits for its composer after bootstrap instead of
dock-launching first.
- **Validation:** During replay, exactly one AgentGUI node exists, its binding
reports the selected session, and the checkpoint wait resolves within the
recorded timing.
### Replay fails because a recorded SQLite column does not exist
- **Symptom:** Replay startup fails with an error such as
`table tutti_mode_turn_snapshots has no column named speed`.
- **Quick checks:** Confirm the Cassette schema. Schema v5 contains
`initial-state.json`/`expected-state.json`; it never contains table rows or
SQL column names.
- **Root cause:** An obsolete row-fixture experiment recorded `SELECT *`
output from a database migrated by a different checkout. Import coupled
Replay to that checkout's physical SQLite schema.
- **Fix:** Re-record with the current schema. Do not add column projection, a
legacy reader, or a migration fallback. The daemon restores typed semantic
state before Host recovery.
- **Validation:** Add or reorder an unrelated SQLite column and prove semantic
capture bytes and Replay behavior are unchanged.
### Managed Replay leaves an orphan Desktop that crashes with `EPIPE`
- **Symptom:** Closing or replacing a managed Replay run leaves an isolated
Electron process behind. It later shows an Electron main-process JavaScript
error with `Error: write EPIPE` from the desktop log sink.
- **Quick checks:** Inspect the Electron process parent PID and open files.
`PPID=1`, a Replay workspace log under
`.tmp/agent-session-replay-workspace-*`, and disconnected stdout/stderr
identify an orphaned managed Replay process. Map the built stack location
back to `apps/desktop/src/main/logging.ts` before attributing the crash to
cassette data or renderer code.
- **Root cause:** The multi-cassette managed Replay path launched a detached
Desktop with piped output but did not bind the existing managed shutdown
lifecycle. When the owner exited, Electron and its managed daemon survived
while their output pipe had no reader. A later log write emitted an
unhandled `EPIPE`.
- **Fix:** Bind every managed Replay Desktop to the original owner PID and stop
its process tree when that owner disappears or its output pipe breaks. Treat
desktop and forwarded daemon console output as best effort so a closed
diagnostic pipe cannot crash the Electron main process.
- **Validation:** Prove the shutdown binding stops Desktop once when the owner
is gone, coalesces a concurrent `EPIPE`, and removes its listeners on
disposal. Exercise the process log sink with a writable stream that emits
`EPIPE`, then verify later writes are ignored without an uncaught exception.
- **References:**
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs),
[logging.ts](../../../apps/desktop/src/main/logging.ts),
[tuttidManager.ts](../../../apps/desktop/src/main/daemon/tuttidManager.ts)
### Replay shows the user prompt but never releases assistant output
- **Symptom:** Replay creates the Session and persists the user prompt, but the
provider tape stops before `turn/started` or assistant notifications.
- **Quick checks:** Compare the next outbound provider frame with daemon RPC
logs. If the tape expects `thread/read` or `thread/goal/get`, check whether
that observer query was issued in this renderer lifecycle. Also compare
recorded and replay JSON-RPC request IDs separately from method and params.
- **Root cause:** Observer-only provider queries and their auxiliary probe
connections are timing-dependent. Treating them as causal byte-stream frames
deadlocks replay when the current renderer observes at a different moment.
Earlier skipped queries can also shift later request IDs.
- **Fix:** Keep causal method/params matching strict, map recorded request IDs
to replay request IDs, and allow only the declared observer RPCs and
query-only auxiliary connections to be absent. Suppress only their paired
responses; preserve interleaved provider notifications. If a causal
provider response arrives while replay is waiting briefly for a declared
observer RPC, skip that observer immediately and match the causal response
against the next recorded frame.
- **Validation:** Replay a real three-Turn Cassette through a fresh Replay
Workspace, verify every checkpoint and assistant response, require transport
exhaustion, and compare final durable state.
### One hung provider startup blocks unrelated Agent sessions
- **Symptom:** One provider process starts but never reaches runtime-ready, then
new or resumed Sessions for other providers also wait without starting their
own provider processes.
- **Quick checks:** Order `agent_session.process.start.sent` with Host
`runtime_started` and `runtime_session_ready` lifecycle steps. If later,
unrelated Sessions accumulate wait time before their own process-start event,
inspect Controller startup-lock ownership separately from the provider that
first stopped making progress.
- **Root cause:** The runtime Controller held one process-wide `startMu` across
`Adapter.Start` and `Adapter.Resume`. A slow or unbounded provider handshake
therefore formed a lock convoy across every room, Session, and provider.
- **Fix:** Serialize explicit startup requests by `(roomID, agentSessionID)`.
Keep the legacy no-ID start path idempotent with a narrower room-and-provider
key, and let startup-lock waiters return when their context is canceled.
Provider credential coordination remains a separate Host gate.
- **Validation:** Block each of `Start` and `Resume` for one provider and prove
both operations still complete for an independent provider. Also verify all
same-Session combinations remain serialized, anonymous start replay reuses
one Session, canceled waiters release their lock references, and repeated
shuffled race runs stay clean.
- **References:**
[controller_session_lifecycle.go](../../../packages/agent/daemon/runtime/controller_session_lifecycle.go),
[controller_test.go](../../../packages/agent/daemon/runtime/controller_test.go)
### A shared-device connection banner looks terminal while the host is still retrying
- **Symptom:** AgentGUI keeps showing a strong connection-lost notice for a
shared device, so the user cannot tell whether recovery is still running.
- **Quick checks:** Inspect host logs for
`shared-agent-query.caller.connection.retry`,
`shared-agent-query.caller.connection.dormant`, and
`shared-agent-query.caller.connection.dormant-summary`. Correlate them with
`desktop.device_link.diagnostic` when distinguishing direct, rendezvous, and
relay transport attempts. A dormant state means the host has moved from
short retry delays to low-frequency recovery; it is not a terminal failure.
- **Root cause:** The query channel correctly blocked runtime-dependent
commands, but its recoverable unavailable state was projected directly as a
terminal target-connection error. The target event also omitted retry
progress, so an unchanged banner made periodic recovery invisible.
- **Fix:** Keep Session runtime availability as the command-safety gate.
Separately project target-scoped connection status with a retry attempt.
Present recoverable unavailable states as neutral reconnecting progress,
reserve the strong unavailable notice for explicitly non-retryable failures,
and remove the notice silently after recovery.
- **Validation:** Force the query channel into dormant recovery and verify each
failed probe increments the target retry attempt while the composer remains
blocked. Confirm the reconnecting notice updates without restarting its
visibility delay, a non-retryable failure is still immediate, and recovery
produces no success banner.
- **References:**
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Deleting an Agent Session leaves a Tutti plan active or emits duplicate removal events
- **Symptom:** Deleting one Session removes the conversation but its pending
Tutti Mode Plan remains visible, or clients receive duplicate/missing
`session_deleted` and `workspace.workflow.updated` invalidations. A workspace
clear may leave orphan workflows in an active state.
- **Quick checks:** Confirm production wiring assigns the Tutti Mode Plan
service as the Agent service's source-session deletion coordinator. Inspect
the transaction result for the complete removed Session closure and affected
workflow/current-checkpoint identities. Verify workflow events are published
only after the transaction returns and that the activity projection is not
also running its persistence deleter on the coordinator path.
- **Root cause:** Session persistence, activation cleanup, and workflow
cancellation form one cross-aggregate use case. Letting
`data/workspace` choose cancellable states hides business policy in the data
layer and cannot publish the canonical post-commit workflow event. Calling a
service coordinator and the legacy projection deleter together instead
splits the transaction and duplicates `session_deleted` events.
- **Fix:** Keep cancellation policy in `service/tuttimodeplan`: allowed source
states, canceled targets, actor, reason, and timestamp are all explicit. Pass
one command to the workspace store so Session closure, Tutti activation/Turn
snapshots, and workflow transitions commit or roll back together. Return
affected identities, then publish workflow and Session invalidations from
the respective services after commit. Reserve persistence-only deletion and
standalone activation cleanup for isolated tests or legacy orphan fallback.
- **Validation:** Force the workflow update to fail and assert the Session and
activation still exist. Cover single, descendant batch, runtime-only, and
workspace-clear scopes; exact cancellation-state selection; no event on
rollback; one workflow event per reported change; and one
`session_deleted` event per unique removed Session.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[source_session_deletion.go](../../../services/tuttid/service/tuttimodeplan/source_session_deletion.go),
[source_session_deletion.go](../../../services/tuttid/data/workspace/source_session_deletion.go)
### Tutti badge is active but no Tutti Mode Plan panel opens
- **Symptom:** The composer shows the Tutti badge and the Agent may even emit a
provider-native Plan, but no Tutti Mode Plan review panel appears.
- **Quick checks:** Read
`GET /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/tutti-mode-activation`
and confirm the current revision is active. Then inspect whether the Agent
actually invoked `tutti plan propose` with a valid absolute Markdown file
and Agent Session context. Query
`GET /v1/workspaces/{workspaceID}/workflows?sourceSessionId=...` for a pending
authoritative snapshot. Do not inspect transcript markers or provider Plan
card or historical `capabilityRefs` as current activation or workflow
evidence.
- **Root cause:** `TuttiModeActivation` and `WorkspaceWorkflow` are independent
Tutti-owned roots. The badge records a user preference that is snapshotted
into each new Turn's Host Context; it does not fabricate a proposal. Durable
review state begins only when the Agent invokes the always-available Tutti
CLI and commits a `WorkspaceWorkflow`, immutable revision, and pending
checkpoint. A missing CLI invocation, invalid `tutti-mode-plan/v1` document,
wrong source Session, or disconnected desktop workflow runtime leaves
nothing recoverable to render.
- **Fix:** Keep `/plan` and `/tutti` as compatible independent modifiers. Let
AgentSessionEngine project the daemon activation and let the Agent freely
invoke the Tutti CLI. AgentGUI must list workflows by active source Session
and re-pull after `workspace.workflow.updated`; it must never synthesize a
panel from Agent text, a provider Plan card, the Tutti badge, or capability
audit records. User decisions go only through the checkpoint HTTP endpoint.
- **Validation:** Cover activation create/update/reload, immutable per-Turn
snapshot binding before provider dispatch, CLI proposal persistence,
immutable revision verification, Session-scoped list recovery,
advisory-event refresh, accept/reject/cancel decisions, and Plan plus Tutti
badge coexistence. Verify an accepted task graph materializes exactly one
Issue and reports `issue_created` rather than asking the Agent to create it
again.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[commands.go](../../../services/tuttid/service/cli/providers/tuttimodeplan/commands.go),
[useTuttiModePlanPanels.ts](../../../packages/agent/gui/workspaceWorkflow/tuttiModePlan/useTuttiModePlanPanels.ts)
### Existing Session shows Tutti active but the Agent reports Default mode
- **Symptom:** Tutti Mode is enabled on an already-existing Session, but the
next Turn says the conversation is still in provider Default mode or has not
entered Tutti Mode because `plan propose` has not run.
- **Quick checks:** Confirm the activation revision is active in the exported
Session or activation endpoint, then confirm the affected Turn owns an active
`TuttiModeTurnSnapshot`. For Codex, inspect that Turn's provider rollout
`turn_context`: the collaboration mode may legitimately remain `default`,
while its developer instructions must contain `<tutti-host-context>` with
`"state":"active"`. If both facts are present, existing-Session snapshot
delivery works and the response is an interpretation failure. If the host
context is absent, trace snapshot preparation and runtime dispatch instead.
- **Root cause:** Tutti activation, provider Default/Plan collaboration mode,
and Tutti workflow existence are three independent facts. An instruction
that says a workflow exists only after `plan propose` can make a provider
incorrectly use the missing workflow as evidence that activation is
inactive, even though the active snapshot reached the reused provider
Session.
- **Fix:** Make the Host Context's snapshot state the sole authority for
reporting Tutti Mode status. Provider Default/Plan mode and workflow
existence remain independent facts and cannot override it. A clear plan
request must still run `plan propose` instead of returning a chat-only plan.
- **Validation:** On an existing Session, activate Tutti Mode and send a new
Turn while the provider collaboration mode remains Default. Verify the
provider receives the active Host Context, a status-only question reports
active without proposing a workflow, and a clear plan-generation request
invokes `plan propose`. Repeat with an inactive revision and verify it
reports inactive.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[tutti_mode_host_context.go](../../../packages/agent/daemon/runtime/tutti_mode_host_context.go),
[tutti_mode_host_context_test.go](../../../packages/agent/daemon/runtime/tutti_mode_host_context_test.go)
### Tutti Mode review only offers request changes after preference drift
- **Symptom:** A pending plan review shows only a request-changes action, or
accepting an existing plan is silently converted into a rejection after the
Composer effect or speed changes. The plan may also contain values that never
matched the Turn that produced it.
- **Quick checks:** Compare three distinct values: the producing Turn's
`TuttiModeTurnSnapshot`, the current revision's explicit
`execution.effect`/`execution.speed`, and the Session's current Composer
preferences. If the first two differ, inspect the `plan propose` or
`plan revise` response for `tutti_mode_preference_snapshot_mismatch`. If only
the current Composer preferences differ, the drift happened legitimately
after proposal and both review decisions must remain available.
- **Root cause:** Range validation alone allowed an Agent to persist plausible
but invented preference values. A hardcoded Host Context example could teach
the Agent different values than the frozen Turn snapshot. AgentGUI then
treated every post-proposal preference difference as implicit rejection, so
the user lost a direct way to accept the already-reviewed document.
- **Fix:** Render Host Context examples from the exact Turn snapshot. Require
Agent CLI proposals and revisions to carry that active Turn ID, and have the
plan service validate both explicit values through the activation-service
snapshot reader before allocating IDs, writing revision content, or mutating
workflow state. In AgentGUI, expose `Request changes` beside `Accept` only
when both frozen values prove a real preference change; otherwise expose only
`Accept`. Accept must keep the visible checkpoint identity and never convert
into request-changes feedback.
- **Validation:** Prove mismatched, missing-value, and missing-snapshot
documents leave revision content and workflow state untouched; prove a
matching document succeeds. In the UI, verify the divergent state exposes
both actions, request-changes rejects with current-preference feedback,
explicit accept records `accepted`, and matching empty-send still accepts.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[agent-gui-node.md](../../architecture/agent-gui-node.md),
[service.go](../../../services/tuttid/service/tuttimodeplan/service.go),
[tutti_mode_host_context.go](../../../packages/agent/daemon/runtime/tutti_mode_host_context.go),
[useAgentGUITuttiWorkflow.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/view/useAgentGUITuttiWorkflow.ts)
### Tutti Mode Plan stops loading after a task-graph revision
- **Symptom:** The configuration review panel works and `tutti plan revise`
returns a pending task-review checkpoint, but AgentGUI replaces the panel
with its load-failure state.
- **Quick checks:** Call the session-scoped workspace-workflow list endpoint
and inspect every task in the returned revision. Required collection fields,
especially `dependsOn`, must be JSON arrays even when empty. A successful
CLI mutation and HTTP `200` do not prove that the response still satisfies
the generated client contract.
- **Root cause:** A Go transport projection copied an empty dependency slice
onto a nil slice. `encoding/json` emitted `null`, violating the non-nullable
OpenAPI array contract. The generated TypeScript client therefore exposed a
value typed as `string[]` whose runtime value was `null`, and the desktop
adapter failed while cloning it before panel projection.
- **Fix:** Normalize required collections at the daemon API boundary so empty
values serialize as `[]`. Keep the OpenAPI field non-nullable and do not move
the repair into AgentGUI business projection, where it would only conceal a
malformed wire response.
- **Validation:** Exercise the real HTTP route, decode its JSON, and assert an
empty task dependency list is exactly `[]`, not `null`. Then verify a
task-graph revision with a root task can be listed and rendered.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[daemon_workspace_workflows.go](../../../services/tuttid/api/daemon_workspace_workflows.go),
[desktopWorkspaceWorkflowRuntime.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/desktopWorkspaceWorkflowRuntime.ts)
### Retrying a Tutti Mode Plan mutation duplicates or rejects the revision
- **Symptom:** A retry after a CLI timeout creates another workflow/revision,
or intentionally submitting the same Markdown again with a new request ID
fails with a revision/document-path uniqueness error.
- **Quick checks:** Confirm `plan propose` or `plan revise` received a stable
`--request-id`. Read `workspace_workflow_mutations` for the exact workspace,
source Session, mutation kind, workflow scope, and request ID. Compare its
`input_sha256` and committed result IDs. For upgraded local databases, check
that `workspace_workflow_plan_revisions` no longer has a unique index on
`document_path`.
- **Root cause:** Transport response loss is not the same as repeated content.
Without a caller mutation key, the daemon cannot distinguish a retry from
intentional reapplication. Conversely, a content-addressed document path may
legitimately be shared by multiple immutable revision metadata rows, so it
cannot be revision identity.
- **Fix:** Reuse the same request ID only for a retry of the same bytes. Use a
new request ID for an intentional mutation, even when the bytes are equal.
Keep the ledger claim and workflow/revision/checkpoint write in one SQLite
transaction. Preserve revision-ID and sequence uniqueness, and let the
corrective migration remove legacy document-path uniqueness.
- **Validation:** Cover concurrent same-key claims, same key plus same digest
replay, same key plus different digest conflict, new key plus identical bytes
creating the next sequence, and upgrade fixtures that retain checkpoints,
operations, mutations, and a clean `PRAGMA foreign_key_check`.
### One corrupt Tutti Mode Plan prevents daemon startup recovery
- **Symptom:** The daemon cannot finish startup after a task graph was accepted,
especially when its immutable Markdown file is missing or corrupt and its
`create_issue` operation is pending or failed.
- **Quick checks:** Inspect accepted workflows with pending/failed
`create_issue` operations, verify the referenced revision path and SHA-256,
and check whether the operation received a durable
`startup_recovery_failed` outcome. Do not require an Agent `wait` call to
start recovery.
- **Root cause:** Returning the first operation-local read, parse, validation,
or materialization error from the startup scan makes one damaged workflow a
process-wide availability failure.
- **Fix:** Run one recovery scan after workflow and Issue service composition
but before exposing the daemon. Isolate each operation: persist a failed
outcome and continue. Abort startup only when the scan fails or the daemon
cannot write a durable outcome. Keep deterministic Issue materialization and
do not add a background workflow worker.
- **Validation:** Cover pending and failed recovery, failed-before-retry state,
missing/corrupt revisions, a later operation succeeding after an earlier
failure, second-scan non-duplication, and outcome-write failure stopping
startup.
### A stuck edit-and-retry operation crashes the daemon on every launch
- **Symptom:** After updating, `tuttid` exits immediately on every launch and the
desktop reports `tuttid exited before it published its listener info`; the daemon
log shows `recover agent host: process runtime operation <id>: agent session not
found` (or `... agent history was rolled back but the edited turn still needs to
be resent`). The app never opens.
- **Quick checks:** Look for a durable `edit_retry` runtime operation stuck at the
`resend_pending` checkpoint — the last Turn was rolled back on the provider but
the replacement was never re-sent. While the daemon runs, the live worker only
logs this at 1 Hz; the fatality appears only on the next cold restart. The
affected session's `workspace_agent_session_history.recovery_state` is fenced
(non-`ready`).
- **Root cause:** The cold-recovery pass (`RecoverRuntimeOperations`) treats a
per-operation error as fatal to `build tuttid server`, so a persisted edit-retry
operation that can never make progress becomes a boot poison pill. The live
worker tolerates the same error, which is why the app worked until the restart.
- **Fix:** Durable edit-and-retry is disabled (`Config.EditRetryDisabled`, set in
production wiring). Recovery quarantines any leftover `edit_retry` operation —
marks it failed AND clears the session's effective-history fence back to `ready`
so the conversation can still send — and returns non-fatally, so existing poison
pills self-heal on the next boot. New edit-retries are refused at the entry
points. Sessions fenced before the neutralization (owning operation already
failed, so recovery cannot see it) self-heal at the send gate: the first send
clears an abandoned fence instead of rejecting. The durable rule: never let one
runtime operation's error abort daemon startup.
- **Rescue:** For an install that cannot update yet, quit Tutti and run
`tools/scripts/rescue-edit-retry-poison-pill.sh`, which quarantines the stuck
rows and clears the session fence in `~/.tutti/tuttid.db` after backing it up.
- **Validation:** `packages/agent/host/edit_retry_disabled_test.go` builds a
genuinely stuck operation, asserts enabled recovery is boot-fatal, and asserts
the disabled path quarantines it, returns nil, and leaves the session at
`recovery_state = ready`.
### Many stopped Tutti Mode conversations start again when the app opens
- **Symptom:** Starting the desktop app makes several old Tutti Mode source
conversations begin new Turns even though their tasks were previously
stopped or already terminal.
- **Quick checks:** Correlate the startup time with
`workspace_tutti_mode_legacy_repair_v5`, listener-ready wake recovery, and
new canonical Turns. In the execution tables, look for a migration
checkpoint whose `creation_reason` is
`legacy_execution_startup_repair`, an idle execution changed to
`awaiting_main`, and a prepared or dispatched main wake. Do not attribute the
behavior to generic Agent Host resume unless the exact Session already had a
recoverable active Turn.
- **Root cause:** The former V5 legacy backfill treated ambiguous idle plans as
live executions and created a durable main wake. Listener readiness later
delivered those historical wakes into the source conversations. Startup Run
settlement repair could also create another checkpoint for an execution
that was already terminal.
- **Fix:** Make legacy migration side-effect-free: keep an execution running
only when an authoritative Run is running, classify idle history as
`completed`, and create no migration wake. Suppress settlement checkpoints
for terminal/orphaned executions. For databases that already applied V5,
run the exact-fingerprint V6 cleanup to cancel the old recovery chain and
normalize execution status from current Runs.
- **Validation:** Cover fresh idle and running legacy fixtures with zero
migration wakes, a V5-corrupted idle fixture repaired to `completed`, a
V5-corrupted running fixture retained as `running`, and terminal settlement
replay producing no checkpoint or wake. Re-run V6 to prove idempotency.
- **References:**
[issue-execution.md](../../architecture/issue-execution.md),
[migrations_tutti_mode_legacy.go](../../../services/tuttid/data/workspace/migrations_tutti_mode_legacy.go),
[sqlite_tutti_mode_settlement.go](../../../services/tuttid/data/workspace/sqlite_tutti_mode_settlement.go)
### Tutti capability audit persistence races the first Turn projection
- **Symptom:** Create or SendInput fails with `sql: no rows in result set`
immediately after `runtime.submitted`, or a live `turn_update` frame is
rejected because its outcome is null.
- **Quick checks:** Verify capability provenance is carried on the
controller-owned submitted event or a lifecycle-neutral same-Turn guidance
report. Check that no second post-Exec writer tries to update a Turn before
the asynchronous projection creates it. Confirm the event schema accepts a
null outcome while a Turn is live.
- **Root cause:** `capabilityRefs` are historical submission audit data, not
current Tutti activation. Writing them through a second store call races the
canonical Turn projection. Attaching a synthetic lifecycle phase to guidance
can also corrupt the running Turn. A bounded reporter queue that falls back
to inline reporting may reorder projection work or deadlock on reporter
re-entry.
- **Fix:** Persist provenance with the submitted `TurnTransition`; use a
lifecycle-neutral state report for same-Turn guidance. Route synchronous
reporter work through one non-blocking, single-consumer FIFO. Queue pressure
must neither report inline nor block a reporter observer that can re-enter
the Controller.
- **Validation:** Verify runtime acceptance may precede Turn projection, live
null-outcome events validate, later transitions retain references, and a
backlog beyond the former bounded queue plus reporter re-entry preserves FIFO
order without deadlocking. Unsupported capability/source values must fail at
HTTP ingress and event publication without changing activation.
### `tutti agent send` reports unknown delivery after Codex accepted the prompt
- **Symptom:** `tutti agent send` returns
`agent_submit_delivery_unknown` with an error saying the workspace, Session,
Turn, and client submit IDs are required. The prompt and attachment may still
appear in the transcript and Codex may complete the Turn, so retrying the
command can duplicate the work.
- **Quick checks:** Correlate the command timestamp with the target Session in
the durable store and runtime logs. If the user message, provider Turn, and
assistant response exist but the submission provenance has no
`clientSubmitId`, the runtime accepted the command and only the
post-dispatch delivery confirmation failed.
- **Root cause:** The Agent service requires a caller-stable client submit ID to
reconcile ambiguous delivery after runtime dispatch. The CLI Agent provider
populated workspace and Session context but omitted the typed
`ClientSubmitID` on both SendInput and initial Session creation.
- **Fix:** Generate one UUID per CLI command invocation and pass it through the
typed `ClientSubmitID` field. Keep Turn ID allocation in Agent Host; the CLI
identity exists only to make one semantic submission idempotent and
recoverable.
- **Validation:** Exercise both `agent start` and `agent send` through the CLI
provider and assert each service input contains a valid UUID client submit
identity.
- **References:**
[session_commands.go](../../../services/tuttid/service/cli/providers/agentcontext/session_commands.go)
### Codex finishes but AgentGUI keeps showing the Session as working
- **Symptom:** Codex has emitted its final assistant message, but AgentGUI keeps
showing a busy spinner and the Session still has an active Turn. The UI may
settle several minutes later without a restart.
- **Quick checks:** Search `tuttid.log` for
`agent_session.activity_report.queue_backlog` and compare its `queue_depth`
with the final message's `occurredAtUnixMs` and canonical
`updatedAtUnixMs`. A large gap means provider completion reached the runtime
before canonical persistence. When prod and dev are both running, identify
them by listener, PID, state, and workspace paths; a shared log file alone
does not prove they share a database.
- **Root cause:** Streaming text, reasoning, and tool-output snapshots entered
the unbounded report FIFO before coalescing. When the single reporter was
slower than producers, thousands of superseded snapshots accumulated ahead
of the same Session's terminal report. Tool-call output snapshots were not
eligible for coalescing at all.
- **Fix:** Coalesce pending streaming snapshots by Session and message while
they are enqueued. Preserve the latest cumulative tool output, original tool
input, and earliest start time. Keep terminal and submit-provenance reports
as same-Session FIFO barriers.
- **Validation:** Enqueue at least 2048 text and tool-output snapshots and prove
each pending Session occupies one queue slot. Verify the latest snapshot is
retained, terminal and submit-provenance barriers stay ordered, reporter
re-entry remains non-blocking, and focused race tests pass.
- **References:**
[controller_report_queue.go](../../../packages/agent/daemon/runtime/controller_report_queue.go),
[report_coalescer.go](../../../packages/agent/daemon/runtime/report_coalescer.go),
[controller_report_queue_test.go](../../../packages/agent/daemon/runtime/controller_report_queue_test.go)
### Claude fails before provider Turn identity but AgentGUI keeps thinking
- **Symptom:** Claude returns no assistant result and the provider invocation
has already failed, but AgentGUI keeps showing the Turn as processing. Later
sends may be rejected because the runtime still reports an active Turn.
- **Quick checks:** Correlate the canonical Turn ID across the submit report,
Claude sidecar event, and runtime controller. The characteristic sequence has
a durable submitted Turn, `turn_failed` without `providerTurnId`, dispatch
disposition `applied_without_provider_turn`, and a canonical failed report,
while `HasActiveTurn` remains true. Do not interpret
`applied_without_provider_turn` itself as a failure; cancel-before-acceptance
legitimately uses the same admission result.
- **Root cause:** Provider acceptance and canonical completion are independent
contracts. The acceptance wrapper treated an exact providerless
`turn.failed` as premature provider output, while the blocking controller
released its active-Turn fence only for failures carrying explicit rejection
metadata. A non-rejection failure could therefore be durable but never close
the runtime fence.
- **Fix:** Hold an exact canonical terminal behind the acceptance barrier and
return it to the controller without inventing provider identity. Classify
completion from the typed terminal event for the exact Turn, never from the
dispatch disposition or error text. Partition every pre-acceptance batch so
an exact terminal cannot carry provider-dependent assistant/tool output
through the barrier. Release the active-Turn fence only after the terminal
crosses the synchronous durable-report barrier. If that commit fails or its
acknowledgement is lost, retain the terminal and retry it idempotently until
the commit succeeds or daemon reconciliation proves the Turn already settled.
Provider-root completion with an identity continues through canonical
aggregation.
- **Validation:** Cover Claude translation of a providerless `turn_failed`,
mixed terminal/provider-output batches, controller settlement after a
successful terminal report, transient report failure, commit-success/ACK-loss,
explicit rejection, cancel-before-acceptance, and outcome-unknown. Run the
Host conformance scenario for initial and ordinary idempotent submissions.
Retain a root-provider lifecycle test proving provider-root completion does
not clear the canonical Turn before daemon reconciliation.
- **References:**
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go),
[controller_turn_completion.go](../../../packages/agent/daemon/runtime/controller_turn_completion.go),
[controller_turn_exec.go](../../../packages/agent/daemon/runtime/controller_turn_exec.go)
### Tutti mode is active in the composer but disappears after the first submit
- **Symptom:** The home composer shows Tutti enabled and the submit trace records
`tutti_mode_active=true`, but the created Session has no activation and its
first `tutti_mode_turn_snapshots` row is `inactive`.
- **Quick checks:** Trace `initialTuttiModeActivation` at the composer submit,
controller activation, desktop activity service, renderer HTTP adapter, and
daemon create ingress. Log only a presence boolean at each boundary. Compare
the Session export with the durable activation and Turn snapshot rows;
`capabilityRefs` do not prove current activation.
- **Root cause:** Session creation crosses several adapters that reconstruct
object literals. A manually projected create input can omit
`initialTuttiModeActivation` or its Tutti `capabilityRefs` even when the
upstream type carries them. Reading mutable draft state after submit can also
lose the exact composer selection. AgentGUI treats
`capabilityMenuState.tuttiMode.enabled === true` as the host capability for
the hero toggle, badge activation, and `/tutti`; Tutti Desktop always supplies
it, while hosts that omit it or set `enabled: false` fail closed.
- **Fix:** Snapshot active/inactive state and orchestration intensity atomically
with the composer submit. Preserve both activation and capability provenance
through every create projection, and use the same host capability as the
visible control for slash actions.
- **Validation:** Keep boundary tests for the service, engine host, and HTTP
adapter. In a real development launch, submit once with Tutti enabled and
verify the HTTP boundary sees the activation, the activation revision is
`active`, and the first Turn snapshot retains its source and intensity.
### A new Tutti conversation briefly reports session not found after submit
- **Symptom:** The optimistic conversation appears after the first submit and
briefly shows `workspace agent session not found`, then recovers after the
provider starts and the durable Session and Turn complete normally.
- **Quick checks:** Correlate the Session ID across desktop and daemon submit
traces. The characteristic order is
`renderer_adapter.create.http_requested`, then
`agent.activity.reconcile_session_absent`, then
`renderer_adapter.create.resolved` and `api.create.completed`. Confirm the
missing reconcile occurs while the engine still has a requested or uncertain
new-session activation.
- **Root cause:** Initial activation can publish activity or mode updates before
the create transaction is query-visible or its HTTP response returns. The
renderer treated those independent signals as permission to reconcile an
exact Session, so a normal not-yet-visible window became a transient 404 even
though the create command was still in flight. A topic-specific guard covered
only one of those signals and left the admission boundary fragmented.
- **Fix:** Keep the new Session behind the admission fence while its activation
is still `requested`. The activity-core reconcile reducer records demand but
defers transport reads whenever the exact Session has no canonical record
and the create command still owns visibility. If the create result becomes
uncertain or its confirmation expires, enqueue one authoritative recovery
reconcile; that recovery is allowed to discover a Session that was committed
even though the create response was lost. A normal successful create still
releases the merged demand through `session/upserted`, regardless of whether
the trigger was an activity event, a mode update, or direct Session
synchronization. Do not broadly swallow reconcile 404s: existing Sessions
and settled activations still report real failures. Tombstone only from
explicit deletion evidence such as `session_deleted` or a successful delete
command.
- **Validation:** Hold create in flight, publish both
`agent.activity.updated` and `workspace.tuttimode.updated`, and request direct
Session synchronization. Verify no Session read is called while activation
is requested. Then simulate a lost create response and verify one recovery
reconcile is admitted, discovers the canonical Session, and confirms the
activation. Also prove an existing Session still reconciles and preserves
its not-found diagnostic, while an explicit `session_deleted` event
tombstones it.
- **References:**
[sessionReconcile.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionReconcile.reducer.ts)
[rootReducer.ts](../../../packages/agent/activity-core/src/engine/rootReducer.ts)
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
### A Tutti submission remains `delivery is still being confirmed`
- **Symptom:** Retrying the same composer submission keeps returning
`agent submit delivery is still being confirmed`. The Session may already
exist and the Tutti badge may remain active, but the daemon does not start a
second Turn.
- **Quick checks:** Read the `workspace_agent_submit_claims` row for the exact
workspace, Session, and `clientSubmitId`. Compare its immutable
`canonical_turn_id` with `tutti_mode_turn_snapshots.turn_id` and with the
Turn found by durable message provenance for that `clientSubmitId`. Do not
change the claim to a runtime-returned mismatched Turn ID. If the barrier
reports `conflicts with durable submit provenance`, compare the JSON-encoded
existing and projected message payloads as well as their Go container types.
In particular, compare `payload.seq` and `occurred_at_unix_ms` from the
ordinary runtime report and the durable provenance report for the same
`clientSubmitId`.
Identical JSON with `[]any` on the SQLite-decoded side and
`[]map[string]any` on the fresh-report side is an in-memory normalization
defect, not conflicting submit evidence. Different sequence values for the
same canonical message mean the two report paths reconstructed its occurrence
independently.
- **Root cause:** Provider handoff crossed an ambiguity boundary: the daemon
reserved and snapshotted a canonical Turn before dispatch, but did not
durably confirm the exact accepted Turn. The ordinary reporter may have
persisted its Session/Turn state while the atomic client-submit message
barrier failed, or the process may have stopped around that barrier. A host
wired only to the compatibility `ActivityReporter` cannot satisfy the
runtime's required `DurableActivityReporter` contract. The barrier can also
falsely reject an ordinary message replay when a fresh JSON payload retains
typed Go slices but the same payload decoded from SQLite uses `[]any`, and
idempotency compares those concrete types directly. Retrying the provider
call would risk duplicate work. Separately, the runtime adapter and durable
provenance barrier can construct the same user message on different
goroutines. If each path samples its own transient event timestamp, whichever
path reaches SQLite second carries a different derived `payload.seq`; the
strict provenance guard correctly rejects it. Scheduling determines whether
the ordinary report exists before the barrier, so this form is intermittent.
A compatibility reporter can still supply different transport-only sequence,
source, or submission timestamps for the same protected user-message
provenance.
- **Fix:** Preserve the prepared submit claim, Tutti snapshot, activation, and
Session. Ensure the host supplies `DurableActivityReporter`; decorators
should embed or otherwise preserve that required interface instead of
probing an optional capability. Its provenance barrier must run outside the
Session lifecycle lock, after earlier same-FIFO reports, and atomically write
the stable user message against an existing Turn.
Normalize every incoming message payload through the durable JSON
representation before projection, merge, and idempotency comparison so
fresh reports and SQLite-decoded rows share one in-memory shape. Do not
special-case individual typed slices or bypass genuine payload conflicts.
For the two canonical user-message paths, preserve the durable submit claim's
immutable creation time through the typed Host and Runtime inputs and derive
both reports' occurrence and sequence from it. Use `clientSubmitId` only as
the idempotency identity, not as a numeric sequence. Build both messages with
the shared canonical user-message constructor. Keep the store's exact-payload
conflict check for all genuine conflicts; the protected provenance replay may
accept only transport-only metadata differences after Turn, role, kind,
status, semantics, `clientSubmitId`, content, content mode, display prompt,
and text all match.
Reconcile only from exact durable `clientSubmitId` provenance. If it resolves
to the reserved Turn, idempotently accept the snapshot and claim; if it is
absent or resolves elsewhere, keep delivery unknown and never re-dispatch
that client submission. Definite pre-dispatch rejection is the only path
allowed to abandon the prepared evidence.
- **Validation:** Cover exact acceptance, accepted Turn-ID mismatch, snapshot
and claim acceptance failures, message-write rollback, process interruption
after snapshot binding, reporter re-entry, duplicate replay without a new
message version, and multiple guidance submissions sharing one active Turn.
Include an ordinary-message-first replay whose payload contains nested typed
slices and integer values; assert the provenance barrier reuses the existing
message version while a real content change still conflicts.
Exercise both runtime-message-first and provenance-first orderings and assert
their complete message updates, occurrence times, and derived payload
sequences are identical. Reopen the submit-claim store and prove an
idempotent retry retains the original claim creation time. Exercise the
narrow protected replay with differing transport sequence, source, and
submission time, while proving a real content or semantics change still
conflicts.
Assert the unknown paths execute the provider zero additional times and never
close the provisional Session or delete its activation.
- **References:**
[workspace-workflows.md](../../architecture/workspace-workflows.md),
[submit_claims.go](../../../services/tuttid/service/agent/submit_claims.go),
[service_tutti_mode_activation.go](../../../services/tuttid/service/agent/service_tutti_mode_activation.go)
Turn state, loading, cancel, restore, file-change undo, rail projection, event updates, imports, and performance.
### AgentGUI restores a provisional conversation after creation fails
- Symptom:
A new conversation fails to start, but a renderer reload selects the same
Session ID again. The Rail may show an optimistic row while the canonical
store has no matching Session.
- Quick checks:
Correlate the activation request, Workbench
`lastActiveAgentSessionId`, renderer reload, and canonical Session lookup by
exact ID. If navigation persistence precedes canonical activation
confirmation and the lookup returns typed `session.not_found`, this is a
provisional-selection leak. A bounded Rail page that omits the Session is
not enough evidence.
- Root cause:
The new-conversation controller persisted its generated Session ID when it
selected the optimistic row. A crash or reload could therefore restore that
ID before Host committed a canonical Session. Reconcile errors also lost
their typed code in the frontend engine, so AgentGUI could not distinguish a
proven missing Session from a transient read failure.
- Fix:
Keep provisional selection in mounted UI only. Persist it after the engine
confirms activation from canonical Session state. Preserve reconcile
`errorCode`; clear only the exact global and per-target navigation memories
when the active reconcile settles with `session.not_found`. Preserve them
for timeout, transport failure, and bounded-list absence.
- Validation:
Verify a requested activation performs no persistence, canonical
confirmation writes once, create failure plus reload cannot restore the
provisional ID, typed `session.not_found` clears only the matching memories,
and a transient reconcile error keeps the selection.
- References:
[agent-gui-node.md](../../architecture/agent-gui-node.md)
[sessionReconcile.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionReconcile.reducer.ts)
[useAgentGUIConversationSelectionController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationSelectionController.ts)
### New Goal spinner stops before the first reply, then starts after it appears
- Symptom:
Creating a Session with an initial Goal starts the processing indicator, then
stops it as soon as the canonical Session appears. The provider continues
working without an indicator. When the first assistant message and canonical
Turn arrive, the indicator starts again until the Turn settles. The inverse
symptom is a completed or failed Turn whose rail status has settled while the
Composer action still spins.
- Quick checks:
Compare the Claude SDK `session_state_changed` lifecycle log, activity stream
connection, Engine runtime activity, canonical Session, and canonical latest
Turn. The characteristic gap has SDK state `running`, a canonical Session, no
latest Turn, and Engine runtime activity still `idle`. Do not use assistant
message arrival as Turn identity. If the desktop log reports `Event stream
catalog revision mismatch`, fully restart `dev:desktop`; renderer HMR cannot
replace the already running Go daemon binary.
- Root cause:
Claude emits an exact session-level `running` observation before the first
provider Turn identity, but the daemon previously logged and discarded it.
Goal-only creation correctly has `initialTurnExpected = false`, so neither a
pending prompt nor a canonical Turn can bridge that interval. For the inverse
symptom, AgentGUI bypassed the Engine's occurrence-time fence and read the
stale raw `running` flag directly after a canonical Turn had settled.
- Fix:
Normalize the SDK observation to provider-neutral `running`/`idle` runtime
activity, publish it as an ephemeral activity-stream event, and let the
workspace Engine drive AgentGUI and rail busy projection. Once a canonical
Session exists, AgentGUI must consume the Engine's fenced display status;
use raw runtime activity only before that projection exists. Clear ephemeral
runtime activity on disconnect. Keep Goal turnless and do not invent
lifecycle state, provider-specific timers, or synthetic Turn IDs.
- Validation:
Cover SDK projection without Turn identity, post-commit event publication,
activity-stream ingestion before canonical Session hydration, AgentGUI busy
projection, `idle`, disconnect cleanup, and both completed and failed Turns
remaining settled when an older raw runtime observation still says
`running`.
- References:
[claude_sdk_events.go](../../../packages/agent/daemon/runtime/claude_sdk_events.go)
[workspaceEventCoordinator.ts](../../../packages/agent/activity-core/src/workspaceEventCoordinator.ts)
[useAgentGUISessionPresentation.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUISessionPresentation.ts)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### AgentGUI detail misses or misplaces a canonical Turn error
- Symptom:
The AgentGUI rail marks a conversation failed, but opening the conversation
shows only the preceding tool or assistant rows. Reloading the session does
not reveal why the Turn failed. A related symptom is a failure from a much
older Turn appearing at the bottom of the currently loaded conversation,
immediately before current processing UI.
- Quick checks:
Inspect the canonical Turn snapshot before debugging React state. Confirm the
owning Turn is terminal with `outcome = failed` or `interrupted` and has a
non-empty `error.message`. Then inspect that Turn's timeline messages for a
structured `visibleError` or a plain assistant message with the same error
text. Finally, distinguish the session-wide Turn list from the hydrated
message window: check whether any currently loaded timeline item has the
exact owning `turnId` and whether older message pages remain.
- Root cause:
Turn outcome and error are durable canonical state, while provider transcript
messages are optional evidence. If detail rendering only projects transcript
messages, a runtime that settles `AgentActivityTurn.error` without emitting a
visible-error message leaves the rail and detail inconsistent. Reading only
the active Turn also loses the error as soon as settlement clears
`activeTurnId`. Conversely, treating the session-wide Turn list as transcript
membership manufactures an empty Turn for an error whose messages are outside
the current page, appends it after newer Turns, and makes an old failure look
current.
- Fix:
Reconcile terminal `AgentActivityTurn.error` in the shared transcript
projection by exact `turnId`. Reuse a structured visible error, upgrade a
matching plain assistant failure, or add one view-only row with a stable
`(agentSessionId, turnId)` identity. Normally the owning Turn must already
exist in the hydrated transcript projection. The exact latest failed Turn is
the narrow exception: if it emitted no transcript item, create its view-only
error row so the current failure reason remains visible. Historical Turns
outside the message window still wait for an older page to supply an anchor.
Do not restore session `lastError`, let session-operation selectors fall back
to Turn errors, reinterpret a successful attach as activation failure,
persist a duplicate message, or add component-local failure state.
- Validation:
Cover a failed Turn with no provider error message, a matching plain failure,
and an existing structured visible error. The first must render one fallback
row when its Turn is loaded, and the latter two must remain single rows. With
a full canonical Turn list and a newest-page-only transcript window, an older
failed or interrupted Turn must not create a row or change Turn order. After
prepending the older page, its error must appear exactly once on the owning
Turn. Also cover the exact latest failed Turn with zero hydrated transcript
items producing one error row, plus an older failed Turn with a newer active
Turn whose processing ownership remains current.
- References:
[workspaceAgentTurnErrorProjection.ts](../../../packages/agent/gui/shared/workspaceAgentTurnErrorProjection.ts)
[workspaceAgentTurnErrorProjection.spec.ts](../../../packages/agent/gui/shared/workspaceAgentTurnErrorProjection.spec.ts)
[workspaceAgentTimelineCanonical.ts](../../../packages/agent/gui/shared/workspaceAgentTimelineCanonical.ts)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Mid-turn provider failure renders a generic "request failed" card
- Symptom:
A root-provider-lifecycle turn (Codex, Cursor, OpenCode) fails mid-turn —
for example quota exhaustion — but the conversation shows only a generic
"request failed" card with no detail and no classification code. The real
reason appears only in the durable Turn record and daemon logs.
- Quick checks:
Confirm the canonical Turn settled with a non-empty `error.message`. If the
durable Turn carries the reason but the live card does not, inspect the
synthesized `daemonRootTurnSettlement` event metadata, not the provider
adapter — the adapter already reported the failure correctly.
- Root cause:
Root-provider adapters do not emit `EventTurnFailed`; the daemon synthesizes
it in `ReconcileRootTurnSettlement` after the durable settle commits. The
settlement previously forwarded only `turnId` and `outcome`, so the
synthesized event had an empty detail and the visible-error projection
classified it as `unknown` instead of (for example) `quota_or_rate_limit`.
- Fix:
Forward `Turn.ErrorMessage` through `RootTurnSettlement` and stamp it on the
synthesized event metadata under the `error` / `errorMessage` keys read by
`BestEffortErrorMessage`. Keep classification text-based in
`visibleFailureCode`; do not special-case providers at this layer.
- Validation:
`TestReconcileRootTurnSettlementPublishesFailureDetail` settles a failed
root turn with a quota message and asserts the live stream emits a
`quota_or_rate_limit` visible error carrying the original detail.
- References:
[controller_root_turn.go](../../../packages/agent/daemon/runtime/controller_root_turn.go)
[visible_error.go](../../../packages/agent/daemon/runtime/visible_error.go)
[agent_runtime_adapter.go](../../../services/tuttid/agent_runtime_adapter.go)
### Failed Claude Turn looks like no reply or renders raw 522 payload
- Symptom:
Claude produces no normal assistant reply, and the eventual failed message is
a raw `API Error: 522` payload instead of the standard timeout card.
- Quick checks:
Confirm the canonical Turn settled as failed with `request_timed_out`, while
its persisted assistant message is plain failed text containing the explicit
522 signature rather than a structured visible error.
- Root cause:
The SDK can report a gateway timeout as a failed assistant text message. The
generic failed-message recovery recognized network socket markers but not the
unambiguous HTTP 522 signature, so the raw provider payload reached Markdown.
- Fix:
Classify only explicit 522 markers as `request_timed_out` in the shared failed
message presentation. Keep generic timeout prose unclassified to avoid
rewriting ordinary assistant content.
- Validation:
Cover both classification and rendered output: the timeout card is visible
and the raw payload is absent.
- References:
[agentErrorPresentation.ts](../../../packages/agent/gui/shared/agentEnv/agentErrorPresentation.ts)
[AgentMessageBlock.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentMessageBlock.tsx)
### Codex WebSocket reconnect rejects a long prompt metadata header
- Symptom:
A long-running Codex turn disconnects, then its WebSocket reconnect fails with
`did not receive a valid HTTP request; Separator is found, but chunk is longer than limit`.
The same prompt may succeed when the original connection stays open.
- Quick checks:
Inspect the Codex app-server `turn/start` params without logging prompt text.
Compare prompt byte count with any client metadata byte count. An unbounded
prompt copy in `responsesapiClientMetadata` can cross a WebSocket HTTP
parser's roughly 32 KiB line limit during reconnect.
- Root cause:
Codex may forward Responses API client metadata through the WebSocket HTTP
handshake. Duplicating the full prompt into that metadata turns prompt size
into header size. Reconnect then fails before the provider can process the
normal `input` payload.
- Fix:
Keep the full materialized prompt in `turn/start.input`. Do not duplicate it
into auxiliary metadata, truncate it in AgentGUI, or compensate with reconnect
retries. Provider-specific metadata stays inside the provider adapter and must
follow a demonstrated provider contract.
- Validation:
Cover a prompt larger than 32 KiB. Assert `turn/start.input` preserves it
exactly and `responsesapiClientMetadata` contains no prompt copy. Run
`go test ./packages/agent/daemon/runtime`.
- References:
[codex_appserver_event_params.go](../../../packages/agent/daemon/runtime/codex_appserver_event_params.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Opening a historical Codex session starts two short provider processes
- Symptom:
Selecting one settled Codex conversation logs two process starts, each with
`initialize` followed by `thread/read`, even though no turn was resumed.
- Quick checks:
Confirm neither sequence contains `thread/resume`. Enable reconcile trace
diagnostics temporarily and count complete `state_and_messages` commands.
Two discovery/message/final sequences indicate two reconciles; one sequence
with two probes indicates that both detail projections resolved capabilities.
- Root cause:
There are two independent boundaries to verify. Combined reconciliation
intentionally reads detail twice to close races while messages load, but its
discovery read only needs hierarchy and `messageVersion`; using the full
Session projection there adds an accidental provider-history capability
probe. Separately, automatic selection restoration can be replayed while the
first reconcile is running. Treating that ordinary selection as
`force: true` appends a second complete reconcile, whose final detail read
launches another probe.
- Fix:
Preserve the two-read race fence. Send the first request with the
`messageHydration` projection and the final request with `full`; only the
latter resolves provider-backed lifecycle capabilities. Model automatic
selection as idempotent “ensure hydrated” work, leaving force refresh as an
explicit intent. Do not mask either symptom with a TTL cache, single-flight
delay, or by removing the final read.
- Validation:
Select a historical root-only Codex Session with no child hierarchy through
`make dev-gui`. One combined reconcile should issue two detail requests and
one message-list request, but only the final detail request should emit the short
`initialize`/`thread/read` capability probe. Replaying the same selection
while that reconcile is pending must not start another combined reconcile.
Sessions with child hierarchy or additional message pages may issue more than
one message-list request; each request must still follow the shared executor's
Session and cursor policy.
- References:
[sessionReconcileExecutor.ts](../../../packages/agent/activity-core/src/sessionReconcileExecutor.ts)
[useAgentConversationSelection.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentConversationSelection.ts)
[service_turns.go](../../../services/tuttid/service/agent/service_turns.go)
[codex_appserver_fork.go](../../../packages/agent/daemon/runtime/codex_appserver_fork.go)
### Codex turn stays working before any reply or tool activity
- Symptom:
A submitted Codex turn stays working without assistant text, reasoning, or
tool activity. Stop may return quickly, but the next prompt on the same
conversation can stall in the same way. This also occurs when a conversation
is left offline and its first later submit loses the app-server connection.
- Quick checks:
Correlate `agent.submit.trace` records by `client_submit_id`, `turn_id`, and
`agent_session_id`. A `turn.start.requested` without
`turn.start.succeeded` means the immediate app-server acknowledgement did
not arrive. After the bounded failure, confirm
`agent_session.app_server.turn_start.client_invalidated` appears and the next
submit starts a new local process with `thread/resume`. An immediate
`turn.start.failed` with `error=EOF` is an outcome-unknown transport loss and
must still project one visible failed assistant message.
- Root cause:
Codex `turn/start` should acknowledge immediately and stream the actual work
through notifications, but the adapter previously called it with no client
deadline. A graceful Stop could acknowledge `turn/interrupt` without proving
that the unacknowledged `turn/start` connection was healthy, so the adapter
retained and reused the same bad process. The live-session probe also treated
a retained client pointer as live after its `Done` channel closed. Finally, a
pre-acceptance `turn/start` failure was buffered as provider output even
though it had no provider Turn identity, hiding the canonical terminal from
the controller and GUI.
- Fix:
Bound only the `turn/start` acknowledgement to 30 seconds; do not bound the
subsequent running turn. Treat deadline, cancellation before acknowledgement,
or transport disconnect as an unhealthy client, remove exactly that client
from the live-session registry, and close it. Do not automatically replay the
prompt because delivery is unknown. The next explicit submit uses the
existing session recovery path to start a process and resume the provider
thread. A live-session probe must also reject terminated clients. Publish the
exact canonical `turn.failed` directly when `turn/start` fails before provider
acceptance, while dropping unaccepted provider-dependent output; this reuses
the normal visible-error projection without inventing provider identity.
Bound `turn/steer` acknowledgement separately to 10 seconds.
- Validation:
Run
`go test ./packages/agent/daemon/runtime -run 'Test(CodexAppServerAdapter(Turn(StartAckTimeoutInvalidatesClient|StartEOFProjectsVisibleFailureBeforeAcceptance|StartCancelBeforeAckInvalidatesClient|StartAckTimeoutDoesNotBoundRunningTurn|SteerTimesOut)|CanResumeAfterTurnStartAckTimeout|HasLiveSessionRejectsClosedClient)|StandardACPAdapterHasLiveSessionRejectsClosedClient)$'`.
Cover timeout, EOF before acceptance, terminated-client liveness, pre-ack
cancellation, a long post-ack turn, bounded guidance, and successful
`thread/resume` followed by a completed turn.
- References:
[codex_appserver_turn.go](../../../packages/agent/daemon/runtime/codex_appserver_turn.go)
[codex_appserver_registry.go](../../../packages/agent/daemon/runtime/codex_appserver_registry.go)
[codex_appserver_turn_timeout_test.go](../../../packages/agent/daemon/runtime/codex_appserver_turn_timeout_test.go)
### AgentGUI turn actions return plain-text route 404s
- Symptom:
A turn-scoped action such as Stop sends the documented OpenAPI URL but gets
`404 page not found` with `text/plain`, even though nearby daemon APIs work.
- Quick checks:
Distinguish the default mux response from a domain 404. A route-level miss is
plain text; a matched daemon handler returns the structured API error schema.
Then compare the operation in the OpenAPI document and generated server with
`services/tuttid/api/routes.go`.
- Root cause:
The daemon currently mounts generated handlers through a hand-maintained
`RegisterRoutes` table. Adding an operation to OpenAPI and regenerating the
server does not automatically add it to that runtime table, so the handler
can compile and pass direct tests while remaining unreachable over HTTP.
- Fix:
Register every new generated operation in `RegisterRoutes`, including its
exact method and path pattern. For related protocol operations introduced
together, audit the whole group rather than only the first reported URL.
- Validation:
Add a mux-level test that calls `RegisterRoutes`, sends the real method/path,
asserts path parameters reach the service, and checks the structured
response. Rebuild and restart the dev daemon, then verify the live endpoint
no longer returns the default plain-text 404.
- References:
[routes.go](../../../services/tuttid/api/routes.go)
[daemon_test.go](../../../services/tuttid/api/daemon_test.go)
[tuttid.v1.yaml](../../../services/tuttid/api/openapi/tuttid.v1.yaml)
### Goal banner shows Delete but no Restart action
- Symptom:
A provider supports goal pause/resume and the action is initially visible,
but a paused or blocked Goal later shows only Delete.
- Quick checks:
Compare provider composer capabilities with the selected Session's typed
capabilities. `capabilities: null` means no authoritative session snapshot
is available because it is not yet reported or legacy-ambiguous; only then
may presentation fall back to the provider composer descriptor. A non-null
object is complete. Inspect the private persistence compatibility carrier
for `capabilitiesReported` before treating an empty legacy list as
authoritative.
- Root cause:
Legacy persistence could not distinguish an unknown capability list from an
explicitly reported empty snapshot. Partial `RuntimeContext` reports could
also replace the full map and erase capability metadata. The API then closed
the missing list into `false`, which overrode the provider composer value.
- Fix:
Carry capabilities as a typed optional snapshot outside `RuntimeContext`.
Persist explicit report presence, preserve omitted snapshots on partial
updates, and use typed `set`/`unset` runtime-context patches. In presentation,
fall back to composer capabilities only for a null session snapshot; preserve
explicit session `false`. No database schema migration is required because
the report-presence marker uses the existing metadata JSON column and legacy
rows are normalized when decoded.
- Validation:
Cover legacy empty and non-empty metadata, reported empty snapshots,
partial state updates, API null/closed projections, and AgentGUI resolution.
Verify a Codex live snapshot contains `goalPause` after goal pause, cancel,
and session recovery.
- References:
[provider.go](../../../packages/agent/store-sqlite/canonical/provider.go)
[session_metadata.go](../../../packages/agent/store-sqlite/session_metadata.go)
[capabilities.ts](../../../packages/agent/activity-core/src/capabilities.ts)
### AgentGUI rejects a pasted image as unsupported before send
- Symptom:
Pasting or dropping a supported PNG, JPEG, or WebP into a provider that
advertises `imageInput` fails with
`agent prompt image input is unsupported`. Desktop diagnostics may show
`agent.gui.composer.image_upload.resolved` with `hasPath = true`, followed by
a daemon failure at `service.send.prompt_validated`; no provider turn starts.
- Quick checks:
Confirm the submitted image block is path-backed after the desktop host
archives the draft. If the block has `path` but no `data`, `url`, or
`attachmentId`, verify the controller is using preflight validation rather
than the strict runtime validator before `PersistRequestContent` runs.
- Root cause:
A managed desktop path is an ingress staging source. The daemon must accept
it during capability preflight, then copy and hydrate it before runtime
execution. Applying the strict provider-content validator during preflight
rejects the path before the attachment store can canonicalize it.
- Fix:
Keep separate preflight and runtime image validators. Preflight accepts and
preserves the managed path for adapter capability checks. Runtime execution
remains strict and receives only the hydrated image representation. Do not
retain base64 in the renderer or move attachment persistence before provider
capability checks.
- Validation:
Cover the full path-backed chain: controller preflight accepts the path,
service execution receives hydrated data without a path, direct runtime
execution still rejects path-only content, and unsupported providers create
no attachment files. Run `go test ./packages/agent/daemon/runtime
./services/tuttid/service/agent`.
- References:
[prompt_content.go](../../../packages/agent/daemon/runtime/prompt_content.go)
[controller.go](../../../packages/agent/daemon/runtime/controller.go)
[service_send_input.go](../../../services/tuttid/service/agent/service_send_input.go)
### New extension conversation renders a pasted image as a file
- Symptom:
A new Agent Extension conversation renders a pasted PNG, JPEG, or WebP as a
file mention instead of an image preview, while an existing conversation may
render the same image correctly. Direct ACP initialization still advertises
image prompt support, but target-scoped Composer Options returns
`imageInput = false`.
- Quick checks:
Compare the ACP `initialize` response, the daemon runtime
`SessionStateSnapshot.Capabilities`, the service-facing Session, and the exact
target-scoped Composer Options response. If ACP advertises
`promptCapabilities.image = true` but the service-facing Session has an
unknown capability snapshot, inspect the daemon-to-service runtime adapter
before changing the renderer paste path.
- Root cause:
ACP negotiation correctly stored capabilities in the daemon runtime's typed
Session state, but the tuttid runtime adapter copied settings and untyped
runtime context without copying `Capabilities`. New extension conversations
use that adapter result for hidden Composer discovery, so image support was
lost before the signed extension capability intersection. Existing
conversations could appear healthy because their persisted activity
projection supplied the typed snapshot through a different path.
- Fix:
Preserve the daemon runtime's typed capability snapshot at the tuttid runtime
adapter boundary, then project the exact live Session snapshot into Composer
runtime evidence before reading or caching it. Treat a non-nil snapshot as
authoritative even when empty: an explicit empty live or persisted snapshot
must stop cache fallback, and an empty discovery snapshot is already a ready
result rather than a reason to poll until timeout. Retain exact Target,
installation, project, and settings matching and the signed-profile
intersection.
- Validation:
Cover adapter preservation, live and persisted capability projection,
explicit empty live and persisted snapshots overriding stale untyped/cache
values, empty discovery returning without polling, and mismatched extension
identities remaining fail-closed. Confirm repeated `core`,
`capabilities`, and `full` target-scoped Composer Options requests return
`imageInput = true` for both Kimi Code and Hermes when their ACP runtimes and
signed profiles advertise it.
- References:
[agent_runtime_adapter.go](../../../services/tuttid/agent_runtime_adapter.go)
[composer_runtime_context.go](../../../services/tuttid/service/agent/composer_runtime_context.go)
[composer_live_model_discovery.go](../../../services/tuttid/service/agent/composer_live_model_discovery.go)
### Historical Standard ACP session cannot send after its process was released
- Symptom:
Continuing an idle historical Session fails before a new Turn starts. An
image submission may report `agent prompt image input is unsupported` even
though the same Session previously advertised image input. A text submission
may reconnect but then fail a redundant settings RPC, or the following prompt
may end without assistant, thought, or tool output.
- Quick checks:
Confirm the canonical Session still has a provider Session ID while the
adapter has no live process. For images, look for validation failure without
a preceding provider process start. For text, inspect `initialize`: an ACP
agent may advertise legacy `agentCapabilities.loadSession: true` together
with object-form `sessionCapabilities.resume: {}` (some agents place that
object under `agentCapabilities.sessionCapabilities`). If Tutti then calls
`session/load`, historical updates are replayed even though Tutti already has
the transcript. Also check whether an unchanged persisted permission/Plan
selection is followed by `session/set_mode`.
- Root cause:
Image capability is negotiated by the live ACP handshake, but preflight used
the released adapter state before reconnecting. Separately, the ACP
capability parser only recognized boolean/string values. ACP declares newer
session capabilities by object presence, so `{}` was treated as unsupported
and the legacy `session/load` path won over `session/resume`. Kimi's current
response uses the nested placement, which is accepted by the compatibility
parser. Load replays
history; resume reattaches without replay. Resume also reasserted an unchanged
mode even though the provider Session owns restored state, which fails for
non-idempotent mode implementations.
- Fix:
Reconnect a released Standard ACP process before image capability preflight,
so unsupported images are still rejected before attachment persistence using
the fresh handshake. Recognize object-form session capabilities and prefer
`session/resume` when advertised. Use Tutti's persisted permission and Plan
selection to avoid reasserting the same mode after reattachment, while still
sending a setting changed while detached. Keep the behavior provider- and
platform-neutral; do not match a provider ID or error string.
- Validation:
Release a resumable Standard ACP Session and prove that image preflight starts
one replacement process before validation. Given an initialize response with
both legacy load support and `sessionCapabilities.resume: {}` in standard and
nested placement, prove Tutti selects `session/resume`. Prove the persisted unchanged mode is not reasserted
but an actual offline permission or Plan change is. Use a native Windows-shaped
CWD in the image case and cross-compile the runtime test package for Windows in
addition to executing the focused tests on POSIX.
- References:
[controller_live_sessions.go](../../../packages/agent/daemon/runtime/controller_live_sessions.go)
[acp_update_events.go](../../../packages/agent/daemon/runtime/acp_update_events.go)
[standard_acp_settings.go](../../../packages/agent/daemon/runtime/standard_acp_settings.go)
[standard_acp_session.go](../../../packages/agent/daemon/runtime/standard_acp_session.go)
### Remote Agent image reaches the provider as an unsupported URL
- Symptom:
A URL-backed PNG, JPEG, or WebP is accepted and appears in the user activity,
but Codex rejects the turn with `remote image URLs are not supported; use an
inline data URL instead`. Claude or standard ACP may instead receive no
usable image data.
- Quick checks:
Confirm the durable prompt block intentionally contains an HTTPS `url`, then
inspect the final provider payload. Codex must receive a `data:` URL; Claude
SDK and standard ACP must receive base64 `data`. Search every provider send
and guidance path for `materializeProviderPromptImagesAtBoundary`; the helper
and its unit test can remain green even when a refactor removes all production
callers. If the visible failure detail is
`download remote prompt image: request failed`, compare the URL with a normal
system HTTP client and inspect its DNS result. A URL that succeeds normally
but resolves to a VPN or transparent-proxy synthetic address indicates that
the runtime is bypassing or second-guessing the system network path.
- Root cause:
Remote image URLs are the durable transport representation, while current
Codex app-server, Claude SDK, and standard ACP provider wires require inline
data. A provider-adapter refactor can preserve the materialization helper but
omit its call sites in newly split turn files. Separately, pre-resolving and
classifying destination IPs at this boundary is incompatible with VPN, TUN,
split-DNS, and Fake-IP network stacks: their synthetic address is intentionally
not the public origin address, but the system transport still knows how to
reach the origin.
- Fix:
Materialize only at the final provider boundary. Keep the original URL-backed
content for activity projection, and convert the provider-only copy after
local slash/control handling but immediately before Codex `turn/start` or
`turn/steer`, Claude SDK `exec` or `guide`, and standard ACP
`session/prompt`. Use the repository proxy-aware HTTP client and leave DNS,
proxy, VPN, and connect-target selection to the system network stack; do not
pre-resolve, pin, or reject resolved IP ranges at this provider compatibility
boundary. Continue accepting only HTTPS URLs, require every redirect hop to
remain HTTPS, strip redirect `Referer` headers so signed URL query parameters
cannot cross hops, and keep timeout, response-size, and image MIME checks.
Admit an asynchronous Codex guidance continuation before publishing its
provisional provider turn, and settle that exact attempt when preparation
ends without starting a real provider turn. For standard ACP, publish the
original user activity and provider-turn start before remote materialization;
if preparation fails, complete that same provider turn as failed so the
message remains durable and root settlement cannot strand.
- Validation:
Exercise URL-only content through the real adapter request paths and assert
the captured provider payload contains inline data. Cover Codex send and
guidance, Claude SDK exec and guide, and standard ACP exec; do not rely only
on direct helper tests. Verify the production client can materialize through a
system-routed reserved address, reject a redirect that downgrades to HTTP, and
assert redirects omit signed-URL referrers. Cover failed and concurrent
guidance continuation admission so every published provisional attempt either
yields a real provider lifecycle or receives its matching provider-turn
completion. Also fail standard ACP materialization and assert the original
user message, turn start, and failed provider-turn completion are all emitted
without sending `session/prompt`.
- References:
[prompt_content.go](../../../packages/agent/daemon/runtime/prompt_content.go)
[codex_appserver_turn.go](../../../packages/agent/daemon/runtime/codex_appserver_turn.go)
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
[standard_acp_turn.go](../../../packages/agent/daemon/runtime/standard_acp_turn.go)
### AgentGUI Stop reports no active turn after cancel succeeds
- Symptom:
Pressing Stop settles the AgentGUI turn as canceled, but the renderer also
logs a `workspace_operation_failed`/502 error whose daemon cause is
`agent session has no active turn`.
- Quick checks:
Compare daemon `agent_session.cancel.adapter_failed` with nearby activity
state patches. If the same turn reports `turnPhase = settled` and
`outcome = canceled` at the same timestamp, the cancel result won the event
race while the synchronous cancel RPC still observed a stale controller turn
record.
- Root cause:
The runtime controller and provider adapter keep separate active-turn views.
During cancel-after-settle races, the controller can still have a turn record
while the Codex app-server adapter has already cleared its active turn and
returns `ErrSessionNoActiveTurn`.
- Fix:
Treat `ErrSessionNoActiveTurn` from the controller active-turn cancel path as
an idempotent settled-turn result: clear the stale controller turn record,
reconcile any still-blocked view, and return without surfacing a 502.
- Validation:
Add controller coverage where `controller.turns` still has a record, the
stored session is already settled/canceled, and the adapter returns
`ErrSessionNoActiveTurn`.
- References:
[controller.go](../../../packages/agent/daemon/runtime/controller.go)
[controller_test.go](../../../packages/agent/daemon/runtime/controller_test.go)
### AgentGUI send blocked by active_turn after settled snapshot
- Symptom:
AgentGUI shows `turnPhase = settled` and no `activeTurnId`, but a follow-up
prompt fails with `agent session already has an active turn`, or the runtime
snapshot still reports `submitAvailabilityState = blocked` with
`reason = active_turn`.
- Quick checks:
Compare renderer state with `tuttid.log` submit traces. If `api.send.failed`
reports `agent session already has an active turn` after a settled/available
state patch, inspect whether the controller still has an in-memory `c.turns`
entry while the adapter lifecycle snapshot has already settled. For Codex
app-server sessions, also compare `turn/completed` notification timing with
the triggering `turn/start` RPC result; a stale provider turn id with no
active turn object indicates that the late result rebound an already-settled
slot.
- Root cause:
The controller's async turn registry is separate from adapter lifecycle
projection. Async execution must clear `c.turns` when the owning adapter
publishes a non-live `TurnLifecycleSnapshot`, even if the event type is not a
terminal `turn.completed`/`turn.failed` event. The settled session and the
registry cleanup must also become visible together; storing `ready` first
leaves a follow-up rejection window. Separately, app-server notifications can
settle a turn before the `turn/start` response is applied, so binding that
response without checking turn identity can recreate a stale active id.
- Fix:
Treat same-turn non-live lifecycle snapshots as async turn completion, in
addition to terminal event types and steered prompt messages. Clear the
matching controller turn record before publishing/storing the terminal
session view. Bind a provider turn id only while the exact active-turn object
that issued the request still owns the adapter slot.
- Validation:
Add controller coverage where an async adapter emits only a settled lifecycle
snapshot for the turn and no terminal event, then verify a follow-up `Exec`
no longer returns `ErrSessionActiveTurn`. Also cover a terminal snapshot that
waits for an open call and assert `ready` is never observable with an active
controller turn. For Codex, deliver `turn/completed` before the `turn/start`
result and verify the late result cannot restore the provider turn id. Run
`go test ./packages/agent/daemon/runtime`.
- References:
[controller.go](../../../packages/agent/daemon/runtime/controller.go)
[controller_test.go](../../../packages/agent/daemon/runtime/controller_test.go)
[codex_appserver_adapter.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
### AgentGUI loading disappears before active turn settles
- Symptom:
AgentGUI loses the in-progress/loading affordance while the app-server turn is
still active, normal composer sends hit the active-turn guard instead of
queueing, or a later terminal event arrives for a turn that the runtime
already cleared.
- Quick checks:
First compare the renderer `runtimeSession` and `sessionState` diagnostics.
If `runtimeSession.turnLifecycle.activeTurnId` is non-empty with a live phase
but `sessionState.turnLifecycle.phase = settled` and
`submitAvailabilityState = available`, the bug is in AgentGUI derived state:
the active composer/projection is trusting stale selected-session control
state over the runtime snapshot. Separately inspect app-server terminal
payloads. A `turn/completed` or `turn/failed` notification with an empty
provider turn id must not settle a bound active turn unless that turn was
explicitly adopted from a preceding goal-continuation `turn/started`.
- Root cause:
There are two distinct failure modes. In AgentGUI, the runtime activity
snapshot can be live while the selected session view/control state is stale;
composer loading, projection turn lifecycle, `canSubmit`, and local queue
decisions must prefer the runtime live lifecycle. In the Codex app-server
adapter, `settleActiveTurn` is allowed to adopt a mismatched provider turn id
only for the steer case where `turn/start` returned an unconfirmed stub id and
codex later completes the running turn with a non-empty provider id. Treating
an empty terminal id as a wildcard clears active turn state too early and
removes loading.
- Fix:
In AgentGUI, drive active projection, active live state, submit blocking, and
queue decisions from the live `AgentSessionEngine` returned by
`AgentGUIRuntime.getSessionEngine(...)` before falling back to
`activeSessionState`. Ordinary composer sends while busy should
queue; explicit send-now intents must use capability-selected native guidance
or exact-turn cancel-then-send. In the daemon,
keep the steer exception, but require the terminal provider id to be non-empty
and drop empty-id terminal notifications for bound active turns. Keep the
narrow exception for goal-adopted turns whose ownership came from
`turn/started`.
- Validation:
Keep tests for both sides: a stale settled `sessionState` plus live runtime
lifecycle should still render processing/loading, set `canSubmit = false`,
allow local queueing, and avoid direct `exec`; steered stub turns must settle
on the running turn's non-empty completion id; empty-id terminal notifications
must not settle confirmed or unconfirmed active turns; goal continuation must
still complete its adopted turn.
- References:
[useAgentGUINodeController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUINodeController.ts)
[useAgentGUINodeController.spec.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUINodeController.spec.tsx)
[codex_appserver_turn_machine.go](../../../packages/agent/daemon/runtime/codex_appserver_turn_machine.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
### Codex app-server Start/Resume waits after a lifecycle notification
- Symptom:
Session start or resume reaches the provider, but Tutti waits for the
lifecycle RPC timeout even though Codex sent `thread/started`. A required MCP
server can show the same symptom when the only failure evidence is
`mcpServer/startupStatus/updated` with `status = failed` and there is no
stderr or JSON-RPC response.
- Quick checks:
Inspect the app-server wire trace for `thread/started` or
`mcpServer/startupStatus/updated` before the missing `thread/start` or
`thread/resume` response. Distinguish this from a completely silent provider:
without an authoritative lifecycle notification, process termination, or
transport error, the call must still time out rather than manufacture success.
The Codex adapter allows 90 seconds for `thread/start`; `thread/resume` and
the `turn/start` acknowledgement retain their 30-second budgets.
- Root cause:
App-server notifications and the response for their triggering RPC have no
safe application-order guarantee. Waiting only on the response leaves the
lifecycle call blocked when Codex has already published the authoritative
thread snapshot. A required MCP failure is terminal only when Codex reports
it through the lifecycle RPC itself. MCP stderr and startup-status
notifications are diagnostic state, not independent lifecycle failures:
optional MCP startup can fail while the Codex session is still usable. Child
terminal notifications can arrive before `receiverThreadIds` registers their
thread; dropping those unknown-thread terminal events loses the only
completion fact.
- Fix:
Complete only the active method-matched `thread/start` or `thread/resume` wait
from a valid `thread/started` snapshot. Do not fail that wait from MCP stderr
or `mcpServer/startupStatus/updated`; record the warning and keep the session
alive. A real Codex lifecycle RPC error, process exit, or transport error
remains terminal and is still propagated. Keep ordinary foreign-thread
progress dropped, but retain a bounded terminal notification until child
registration and replay it with child identity. Warnings without a `turnId`
use a session audit event instead of a turn-scoped message.
Keep confirmed provider turn-id fences; only the existing unconfirmed steer
stub and goal-adopted exceptions may settle from a different or empty id.
- Validation:
Run the notification-only Start/Resume wire test, the delayed MCP
failed-status tests, the turnless warning projection test, and
the child terminal-before-registration replay test.
Run the focused app-server suite with `-race` and the full
`go test ./packages/agent/daemon/runtime` package suite.
- References:
[codex_appserver_client.go](../../../packages/agent/daemon/runtime/codex_appserver_client.go)
[codex_appserver_event_routing.go](../../../packages/agent/daemon/runtime/codex_appserver_event_routing.go)
[codex_appserver_turn_machine.go](../../../packages/agent/daemon/runtime/codex_appserver_turn_machine.go)
[codex_appserver_lifecycle_test.go](../../../packages/agent/daemon/runtime/codex_appserver_lifecycle_test.go)
[codex_appserver_events_test.go](../../../packages/agent/daemon/runtime/codex_appserver_events_test.go)
### Busy-turn message insertion fails or ends without sending the prompt
- Symptom:
Sending a prompt with the composer guidance shortcut or choosing “send now”
on a queued prompt fails for ACP-backed agents, reports a turn-scoped
cancellation/guidance error, or cancels the turn without sending the prompt.
- Quick checks:
Inspect the canonical session capabilities and engine commands. Codex and
Claude sessions should advertise `activeTurnGuidance`; standard ACP sessions
should advertise `interrupt` without `activeTurnGuidance`. Confirm that no
renderer branch selects behavior from a provider ID.
- Root cause:
Message insertion is one product intent with two transport realizations.
Treating every provider as native guidance sends a same-turn request that the
standard ACP protocol does not define. Treating every provider as
cancel-then-send discards the same-Turn semantics offered by Codex and the
Claude SDK. Native guidance semantics are provider-specific: Codex steering
deliberately leaves the current response running, while the Claude SDK must
interrupt its active Query before it can reliably enqueue guidance.
- Fix:
Keep the prompt queue in the workspace `AgentSessionEngine`. Resolve send-now
from typed runtime capabilities: use native guidance when
`activeTurnGuidance` is true, with no canonical Turn cancel. The provider
adapter must preserve its native semantics on the same canonical Turn.
Claude uses its SDK interrupt before enqueueing the prompt and closes the old
response projections. Codex/Tutti Agent sends `turn/steer` with the exact
active provider Turn ID and does not interrupt or start a replacement Turn.
If no provider response remains but the canonical Turn is still active for
child work, Codex may start a provider continuation through `turn/start`.
Otherwise use
exact-turn cancel when `interrupt` is true, retain the prompt in the frontend
queue, and send it normally only after validated cancellation or authoritative
turn settlement. Route both the composer shortcut and queued-item action
through the same atomic engine transition.
- Validation:
Cover both entry points and both capability combinations. Native guidance must
emit a guidance send with no cancel. ACP fallback must emit cancel with no
prompt send, then emit one normal prompt send after cancellation settles. At
the Claude provider boundary, assert that the old response terminal and all
old thinking-stream terminals precede guided output. For Codex, assert that
active-response guidance sends exactly one `turn/steer` with the expected
provider Turn ID and sends neither `turn/interrupt` nor another `turn/start`.
- References:
[promptQueue.reducer.ts](../../../packages/agent/activity-core/src/engine/promptQueue.reducer.ts)
[sessionLifecycle.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionLifecycle.reducer.ts)
[controller_exec.go](../../../packages/agent/daemon/runtime/controller_exec.go)
### Queued AgentGUI prompt stalls after no-active-turn failure
- Symptom:
A prompt submitted while an AgentGUI turn is busy appears in the local queue
or as an optimistic user row, but does not start after the previous turn
settles. Submit traces show the same `clientSubmitId` first failing with
`errorReason = agent.no_active_turn`, then succeeding only after a later
manual retry or another queue-draining trigger.
- Quick checks:
Search desktop and daemon logs for the queued `clientSubmitId`. A local queue
acceptance has `send_input.requested` with `queued=true` and
`optimistic_user_message_painted`. The failure pattern is a delayed
`renderer_adapter.send.failed` with `errorCode=invalid_request` and
`errorReason=agent.no_active_turn`, plus daemon `runtime_adapter.exec.failed`
with `agent session has no active turn`.
- Root cause:
The daemon exposes the domain-specific reason as the protocol error
`reason`, while the Agent session engine previously kept only the generic
`errorCode`. The queue reducer therefore treated the race like a permanent
send failure, set `failedPromptId`, and stopped automatic drain until
send-now or another retry path cleared the failure.
- Fix:
Preserve protocol `reason` on `EngineCommandResultIntent`. For
`queue/sendPrompt` failures whose reason is `agent.no_active_turn`, clear the
in-flight send, request a session reconcile, and skip same-reducer drain so
the queued prompt retries only after canonical state refresh. Keep ordinary
send failures blocked until explicit send-now retry.
- Validation:
Add reducer coverage where a queued send fails with
`errorReason = agent.no_active_turn`: it should emit one
`session/reconcile`, leave the prompt queued without `failedPromptId`, and
avoid an immediate second `queue/sendPrompt` until a later canonical lifecycle
update. Keep the existing generic failure test blocked until send-now.
- References:
[promptQueue.reducer.ts](../../../packages/agent/activity-core/src/engine/promptQueue.reducer.ts)
[effectExecutor.ts](../../../packages/agent/activity-core/src/engine/effectExecutor.ts)
[daemon_agent_submit_handlers.go](../../../services/tuttid/api/daemon_agent_submit_handlers.go)
### Cursor or OpenCode turn settles before late ACP activity arrives
- Symptom:
A Cursor or OpenCode turn appears complete, then a delayed tool or permission
event is projected onto the old turn; the composer may relock, persistence
may reject a settled-to-running transition, or a synthetic turn may appear.
A Cursor background Task may also appear launched successfully while its
detached child later requests permissions that never reach the UI.
- Quick checks:
Correlate `session/prompt` response timing with later `session/update` or
`session/request_permission` messages. If the prompt response arrived first,
the late event no longer has an active canonical turn owner. Also verify the
terminal report is `root_provider_turn.completed`, not a direct canonical
`turn.completed` from the ACP adapter. For Cursor Task/subagent probes,
correlate `agent_session.cursor.task_tool_update` with
`agent_session.cursor.task_extension`; the latter records only redacted
identity, ordering, field-presence, and duration facts. A background Task
tool result with `isBackground=true` and a very short duration is a launch
acknowledgement, not child terminal evidence. Permission requests arriving
after the root prompt result confirm the child is still running out of scope.
- Root cause:
Standard ACP has one active prompt handler and a session-level fallback
handler. Reusing a recent turn ID in the fallback path treats temporal
proximity as ownership. That can reopen a settled root or fabricate a turn,
and ordinary tool display fields do not supply the stable child identity and
terminal lifecycle required for a provider-native child session. Cursor's
background Task implementation records eventual completion in an internal
work registry, but Cursor ACP `2026.07.01-41b2de7` does not expose that
terminal to Tutti.
- Fix:
Route every Standard ACP prompt terminal through the daemon-owned root
provider lifecycle. Drop turn-scoped tool/message updates outside the active
prompt call and reject out-of-band permission callbacks; never synthesize a
canonical turn. Keep Cursor/OpenCode root-only until their ACP transports
expose stable child, parent, and child-terminal facts. Cursor Agent
`2026.07.01-41b2de7` does not merge `--plugin-dir` hooks into ACP, so the
dormant `preToolUse` Task guard is deliberately not advertised or
materialized. Do not treat background Task as supported or blocked, do not
write hooks into user/project configuration, and do not settle a detached
child from a guessed timeout.
- Validation:
Cover Cursor and OpenCode normal completion with
`root_provider_turn.started/completed` and no canonical terminal from the
adapter. Deliver a late tool update and late permission after prompt return
and verify neither creates a turn, interaction, or child session. Make a
`session/cancel` write fail and verify the error reaches the caller. Also
fail an automatic permission-response write and verify the adapter does not
report a false approval while the provider is still waiting. Verify the
dormant Cursor hook allows foreground Task inputs, rejects snake-case and
camel-case background flags, does not match flag-like text inside the Task
prompt, and fails closed for malformed input; separately verify the current
ACP plugin manifest does not advertise or materialize that hook.
- References:
[standard_acp_turn.go](../../../packages/agent/daemon/runtime/standard_acp_turn.go)
[standard_acp_stream.go](../../../packages/agent/daemon/runtime/standard_acp_stream.go)
[provider-native subagents](../../specs/2026-07-15-provider-native-subagents.md)
### Standard ACP prompts stay queued after a canceled turn
- Symptom:
A Standard ACP Agent finishes or cancels a visible turn, but every later
message reports that it was queued for the next turn and is never consumed.
The provider process remains alive and may still report its previous prompt
as running even though Tutti already presents the Session as idle.
- Quick checks:
Correlate Tutti's `session/cancel` notification, the outstanding
`session/prompt` response, and the next `session/prompt` request. If the next
request is dispatched before the canceled prompt returns, the provider may
queue it behind state that no worker will drain. Distinguish this provider
queue from AgentGUI's local queue by confirming that the later request
crossed the ACP transport.
- Root cause:
`session/cancel` is only cancellation delivery; it is not proof that the
outstanding `session/prompt` call has settled. Releasing Tutti's local turn
fence immediately after writing the notification lets another prompt enter
the same provider process while its internal running state is still true.
- Fix:
Keep a per-Session active-prompt drain barrier. Capture the exact active
prompt, deliver `session/cancel`, and mark that same prompt canceled only
after delivery succeeds; a failed notification must leave the prompt's
eventual result authoritative. Once cancellation is accepted, check it
before any retriable-error auto-continue branch and wait for the outstanding
prompt result before accepting another turn. If the provider does not settle
within the bounded drain window, close only the transport and let the next
command resume the provider Session in a fresh process. Do not call
destructive `session/close` during this recovery.
- Validation:
Use a deterministic ACP transport fixture whose first prompt returns only
after `session/cancel`. Assert that `Cancel` cannot return before that prompt
drains, that the first turn settles as canceled, and that a second prompt on
the Session completes. Also inject notification failure and prove the active
prompt remains uncanceled; race cancellation with a retriable tail and prove
no second prompt is sent. Run the focused test under Go's race detector.
- References:
[standard_acp_turn.go](../../../packages/agent/daemon/runtime/standard_acp_turn.go)
[standard_acp_turn_test.go](../../../packages/agent/daemon/runtime/standard_acp_turn_test.go)
### Codex goal stops after a turn while the goal remains active
- Symptom:
A `/goal` completes or fails one turn, remains `active`, but never starts a
continuation turn. The conversation looks idle even though the goal banner
still says it is running.
- Quick checks:
Enable `TUTTI_TEST_LOGS=1` for a focused runtime reproduction and compare
`agent_session.app_server.goal.status_changed` with
`agent_session.app_server.goal.continuation_nudge`. If the first turn settles
before the initial `thread/goal/set` response records the active goal, an
eager scheduling check can return without ever creating the continuation
timer.
- Root cause:
App-server notifications and the response for their triggering RPC do not
have a safe application-order guarantee. A `turn/completed` notification may
settle the first goal turn while `thread/goal/set` is still in flight, so
local goal state can still be empty when the settle path schedules its nudge.
- Fix:
Before sending a goal-setting RPC that can start a turn, record a local
`active` goal snapshot with the requested objective. This makes goal
activation causally visible to terminal notifications that overtake the RPC
response. Restore the previous local goal if the RPC fails; on success,
replace the provisional snapshot with the authoritative response. The
continuation timer must still re-read both active-turn and goal state after
its grace window before sending a nudge.
- Validation:
Keep a scripted protocol test that deliberately delivers goal turn
notifications before the `thread/goal/set` result, then verify the next turn
is adopted. Cover both a clean first turn and a failed mid-goal turn with
repeated focused runs; use event channels rather than polling shared slices.
- References:
[codex_appserver_goal.go](../../../packages/agent/daemon/runtime/codex_appserver_goal.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
### Codex goal shows active but produces no response
- Symptom:
Sending `/goal …` updates the Goal banner to active, but no assistant text or
tool activity appears. Logs show `turn/started`, followed shortly by
`agent_session.app_server.goal.turn_provenance_unproven` and an exact
`turn/interrupt`.
- Quick checks:
Inspect the installed Codex app-server schema and the raw
`thread/goal/updated` payload. In affected versions, `turnId` is optional and
the external `thread/goal/set` update omits it. Verify the Goal operation and
generation fingerprint were persisted successfully before investigating
transport or model failures.
- Root cause:
Tutti required `thread/goal/updated.turnId` as the only Goal-to-Turn
correlation signal. Codex externally applies the Goal, emits a session-scoped
update without `turnId`, then starts the continuation Turn. The adapter
therefore treated valid work as unproven and deliberately interrupted it
after the provenance grace window.
- Fix:
Keep exact turn-scoped Goal evidence as the preferred path. For versions that
omit `turnId`, use an adapter-local, single-use continuation claim scoped to
the successful Goal RPC's immutable operation/revision. Chain the next claim
only from settlement of an adopted Goal Turn, serialize Goal control against
ordinary submit setup, and invalidate the claim on revision change, inactive
status, restart, or multiple unowned Turns. Never bind an arbitrary unowned
Turn from the mutable current Goal snapshot and do not fix this by merely
extending the provenance timeout.
- Validation:
Reproduce the real protocol order
`goal/set response -> goal/updated without turnId -> turn/started` and verify
assistant output is persisted without an interrupt. Also cover chained
continuation Turns without another Goal RPC and a delayed old Turn after a
newer revision, which must still be interrupted.
- References:
[codex_appserver_goal_provenance.go](../../../packages/agent/daemon/runtime/codex_appserver_goal_provenance.go)
[codex_appserver_goal.go](../../../packages/agent/daemon/runtime/codex_appserver_goal.go)
[controller_exec.go](../../../packages/agent/daemon/runtime/controller_exec.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
### Active Codex goal has no elapsed timer
- Symptom:
The Goal banner shows an active objective but no elapsed value, even though
Codex continues working and its Goal database reports non-zero usage time.
- Root cause:
Codex exposes `timeUsedSeconds` and `tokensUsed`, while Tutti's canonical
session Goal contract exposes `durationMs` and `tokens`. Passing provider
fields through runtime context causes the typed durable session boundary to
discard them. A newly created Goal may also omit canonical `durationMs`
because zero is an optional counter.
- Fix:
Normalize provider counters in the Codex adapter before publishing session
state or Goal observations. The renderer reads only `durationMs`, hides time
while the Goal is an optimistic projection, and starts only after canonical
provider state arrives. If that canonical state omits the optional zero
baseline, start at zero and advance locally between server updates.
- Validation:
Verify provider-to-canonical normalization with non-zero counters, verify an
optimistic Goal shows no time, verify canonical confirmation adopts the
provider duration and advances, and verify a paused Goal without a duration
still omits the timer.
- References:
[codex_appserver_goal.go](../../../packages/agent/daemon/runtime/codex_appserver_goal.go)
[AgentGoalBanner.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/AgentGoalBanner.tsx)
### Codex goal reappears after pause, edit, or clear
- Symptom:
A goal control succeeds, but the banner shortly returns to an older objective
or status; a cleared goal can reappear as paused.
- Quick checks:
Compare the startup background `thread/goal/get` with later goal-control RPCs.
If the get began before the control and completed afterwards, inspect whether
its older snapshot was applied unconditionally.
- Root cause:
Startup restores the persisted thread goal asynchronously. Its response can
race with newer user controls or provider goal notifications, so arrival
order is not a safe freshness signal.
- Fix:
Version the session goal state. Capture the session identity and revision
before the startup fetch, and apply its result only when both are unchanged;
increment the revision on every update and clear.
- Validation:
Capture a startup refresh guard, clear the goal, then attempt to apply the
older paused snapshot and verify it is rejected.
- References:
[codex_appserver_adapter.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter.go)
[codex_appserver_events.go](../../../packages/agent/daemon/runtime/codex_appserver_events.go)
[codex_appserver_adapter_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_test.go)
### Clearing a goal hides Stop or appends a provider acknowledgement
- Symptom:
Clearing a goal while its current turn is still running leaves the composer
on a non-clickable send spinner. The transcript treats `/goal clear` as a
user Turn followed by a new processing row even though no new user work
started. Claude Code may instead append a native `Goal cleared: …` assistant
message at the bottom of the transcript after the interrupted turn.
- Quick checks:
Inspect the AgentGUI clear handler and the engine pending-submit records. If
clear calls `executePrompt` with an immediate `/goal clear`, the control has
entered the normal message pipeline and received a pseudo turn identity. For
Claude Code, correlate the bottom assistant message's turn ID with the
adapter-generated turn carrying the native clear command.
- Root cause:
Goal clear changes thread metadata rather than submitting user work. For a
provider such as Codex that leaves the active turn running, submitting clear
as a prompt creates a pending submit without a real provider turn. That local
submit owns the send spinner and its visible user message becomes the last
timeline turn, so canonical processing is projected under the wrong item.
Claude Code instead interrupts the live goal turn and requires a separate
native command turn to execute clear; projecting that control turn's provider
acknowledgement as ordinary assistant content creates an unrelated transcript
row at the bottom.
- Fix:
Route every goal action, including clear, through the dedicated runtime
goal-control API. Do not create a user Turn message, pending submit, or pseudo
turn. A surface may project the durable session audit as a dedicated
`goal-control` row, but that row must carry no Turn ID and must not affect
processing ownership or Turn counts.
If the provider needs an internal clear-command turn, register its generated
turn ID and suppress only that turn's assistant/thinking acknowledgement at
the runtime-adapter boundary before persistence. Do not filter by localized
acknowledgement text and do not move the message into the interrupted turn.
Preserve goal/session updates and terminal cleanup, but do not register the
internal command as a root provider turn or feed its terminal into canonical
root settlement.
Keep Stop and processing derived from the canonical active turn, and report a
successful clear with a localized transient toast. Render that toast in an
AgentGUI detail-scoped viewport and use UI System themed surface, foreground,
and border tokens so it centers within the content area and follows the
active light or dark theme instead of using the inverted neutral toast style.
- Validation:
Clear a goal while a turn is running and verify the goal-control API is called
without an engine submit dispatch. If the clear control is visible, it must
be a `goal-control` row with no Turn ID; the original processing row must
remain in place, and Stop must remain clickable until the active turn settles
or is interrupted. For Claude Code, verify the native acknowledgement is
absent both live and after reload, while identical text from an ordinary
assistant turn remains visible.
- References:
[useAgentGUISubmitInteractionActions.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUISubmitInteractionActions.ts)
[claude_sdk_goal.go](../../../packages/agent/daemon/runtime/claude_sdk_goal.go)
[claude_sdk_events.go](../../../packages/agent/daemon/runtime/claude_sdk_events.go)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Agent session stays loading after a completed turn
- Symptom:
AgentGUI shows the assistant response as completed, but the conversation or
sidebar remains in a loading/running state. Desktop logs may contain
`agent.activity.store.session_version_regression` where the previous session
is `settled`/`available` and the next session is older
`running`/`active_turn`.
- Quick checks:
Compare the desktop `reconcile.state_fetch.resolved` session timestamp with
the latest inline `state_patch` timestamp. In `tuttid.log`, check whether
runtime emitted a terminal `turn_phase=settled` event before the fetch
response was applied.
- Root cause:
Activity projection can accept and broadcast a newer completed state while
`GetWorkspaceAgentSession` still prefers an older live runtime snapshot for
the same session. The projection store has timestamp regression protection,
but the service read path can bypass it when a runtime session is present.
- Fix:
In service read paths, compare persisted projection freshness against the
runtime snapshot. If persisted state is newer, return the projected session
state and synthesize non-live turn lifecycle/submit availability instead of
exposing the stale runtime active turn.
- Validation:
Add service coverage where runtime reports `working/running/active_turn` with
an older `UpdatedAtUnixMS`, while persisted state reports
`completed/idle/available` with a newer `LastEventUnixMS`. Validate both
`Get` and `List` do not return the old active turn. Run
`go test ./services/tuttid/service/agent`.
- References:
[service_session.go](../../../services/tuttid/service/agent/service_session.go)
[service.go](../../../services/tuttid/service/agent/service.go)
[service_session_list.go](../../../services/tuttid/service/agent/service_session_list.go)
### Remote session stays planning while the provider already replied
- Symptom:
A remote or cloud-backed AgentGUI conversation remains on its planning or
working placeholder after the provider has already settled the Turn. Reading
the authoritative Session directly reports `ready` with no active Turn, and
opening the Session event stream immediately reveals the missing assistant
response.
- Quick checks:
First compare authoritative Session state with the renderer projection. Then
inspect host access logs for the exact Session event-stream subscription. If
the provider settled but the AgentGUI surface never requested the stream,
increasing HTTP or activation timeouts cannot repair the stale projection.
- Root cause:
The focused conversation was hydrated once but did not retain the host's
optional Session synchronization lease. Hosts that use that lease to keep a
per-Session event stream open therefore receive no terminal state or message
events until another read happens to reconcile the Session.
- Fix:
Keep synchronization ownership in the shared focused-conversation controller.
Acquire the exact Session lease on focus, keep repeated selection idempotent,
release the previous lease on switch or clear, and release the final lease on
disposal. Keep the host responsible for transport and authoritative
reconciliation; do not add provider-specific polling or longer timeouts.
- Validation:
Add controller coverage for acquire, repeated selection, switch, clear, and
disposal. In the affected host, verify that selecting the conversation opens
the exact Session stream and that a terminal event updates both state and
transcript without a manual refresh.
- References:
[agentConversationMessageController.ts](../../../packages/agent/gui/agentConversationMessageController.ts)
[useAgentConversationMessagePaging.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentConversationMessagePaging.ts)
[agent-activity-packages.md](../../architecture/agent-activity-packages.md)
### AgentGUI pin or unpin appears stuck for a live session
- Symptom:
Pinning or unpinning a conversation backed by a live runtime succeeds in the
daemon, but the rail does not move the row or update its action until a later
list refresh. Old conversations without a live runtime update immediately. In
another variant, the pin command completes quickly but the whole rail becomes
disabled or changes to a skeleton while section membership refreshes. A third
variant renders the mutation in two steps: unpin first removes the pinned row,
then removes the pinned section and inserts the ordinary row; delete first
shrinks the section, then shrinks and refills it.
- Quick checks:
Correlate `pin_result` with `agent.activity.store.session_version_regression`.
Compare the command response's `updatedAtUnixMs` with the engine's current
session version, then inspect the exported session for a newer
`pinnedAtUnixMs`. A fast command carrying the new pin value but an older
`updatedAtUnixMs` identifies a stale runtime projection, not a slow database
write. If the pin value applies promptly, correlate
`agent_gui.conversation_rail.first_pages_slow` with
`workspace.agent_session.sections.list_slow`. The renderer event should report
`refreshReason=membership_change`; the daemon event separates
`projects_ms`, `store_ms`, and `hydrate_ms` and reports current/non-empty
project counts, target-scoped visible sessions, and returned first-page rows.
- Root cause:
Durable metadata updates advance the persisted session timestamp. When a live
runtime session is also present, the service merges persisted metadata such
as `pinnedAtUnixMs` into the runtime projection. If that merge keeps the older
runtime timestamp, the frontend's monotonic session reducer correctly rejects
the whole stale response, including the new pin value.
When the entity update is accepted, pin membership still requires
authoritative pinned and ordinary first-page reads. Triggering an aggregate
section query or workspace activity load turns that targeted revalidation
into a multi-second blocking reload on large histories. Rendering the updated
or tombstoned canonical entity against the previous page membership creates
the two-step variant: entity projection changes before the authoritative page
replacement knows the final bounded list and refill row.
- Fix:
Merge session freshness monotonically across runtime and persistence using
the newer timestamp. Pin responses that advance the session version must also
include protocol-v2 active/latest turn state so accepting the metadata update
cannot clear a running turn. Do not weaken frontend version checks or hide the
mismatch behind delayed refetches. For an accepted membership update, compare
canonical before/after membership and request only pinned plus the exact
ordinary section page. Route pin and delete through engine mutation intents;
the command result and canonical follow-up must drain before subscribers are
notified. Make the rail query controller the sole publisher of a composite
entity-plus-membership snapshot, retain its previous committed value while
targeted reads run, and publish the complete next value once. The view must
not subscribe to live engine rows separately or keep stale section snapshots.
Do not inspect mutation history from the Rail; compare canonical membership
before and after each engine notification. Keep the committed snapshot on
targeted failure and lock membership actions until a scoped authoritative
refresh succeeds. Do not call
`listSessionSections` or workspace `load` from delete, pin/unpin, or rename.
Lock scope-sensitive actions only while displayed membership belongs to a
different or unresolved scope; do not patch daemon-owned membership locally.
- Validation:
Cover a live runtime session whose persisted pin update is newer, a newer
runtime snapshot that must not regress, and a running turn that remains
attached to the pin response. Run `go test ./services/tuttid/service/agent`
plus daemon lint, tests, and build when daemon persistence changed. Add
reducer interleaving coverage proving command success and canonical follow-up
share one engine notification. Add controller coverage proving same-scope
pin/unpin calls only the pinned and exact ordinary page endpoints, keeps the
old composite projection until both resolve, publishes once, and never calls
the aggregate section endpoint.
- References:
[service.go](../../../services/tuttid/service/agent/service.go)
[service_session.go](../../../services/tuttid/service/agent/service_session.go)
[sessionEntities.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionEntities.reducer.ts)
[AgentGUIConversationRailQueryController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/AgentGUIConversationRailQueryController.ts)
### AgentGUI Batch delete sessions does nothing
- Symptom:
The project or Chats overflow menu opens, but choosing **Batch delete
sessions** neither opens the confirmation dialog nor sends a delete request.
- Quick checks:
Look for
`agent.gui.conversation_batch_delete.capability_incomplete` in the host's
AgentGUI diagnostics. Its `missingMethods` field identifies whether the host
omitted `listSessionSectionDeletionCandidates` or `deleteSessionsBatch`. If
the event is absent and the action is enabled, correlate the candidate-list
and batch-delete transport requests before investigating the view.
- Root cause:
Batch deletion is a two-stage runtime capability: AgentGUI first resolves the
authoritative session ids for the rail section and only then submits the
selected ids. A host that manually assembled `AgentGUIRuntime` could
expose the mutation but omit the candidate query. The old optional-method
checks then returned an empty candidate list, making a valid click look like
a no-op.
- Fix:
Install the complete runtime cohort from
`@tutti-os/agent-gui/conversation-rail-runtime` and keep only transport DTO
mapping in the host adapter. Do not add view-local candidate discovery or
infer membership from loaded rows. AgentGUI treats a partial two-method
contract as unavailable, disables the action, and reports the missing method.
- Validation:
Cover the host composition with both batch-deletion methods, verify the shared
factory forwards the exact candidate and delete inputs, and cover a partial
runtime contract as disabled with one diagnostic. Run the Agent GUI package
tests and the host activity-runtime composition tests.
- References:
[agentConversationRailRuntime.ts](../../../packages/agent/gui/agentConversationRailRuntime.ts)
[useAgentGUIConversationRailQuery.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationRailQuery.ts)
[createDesktopAgentActivityRuntime.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/createDesktopAgentActivityRuntime.ts)
### AgentGUI model switch changes defaults but not the active session
- Symptom:
A user selects a different AgentGUI model, but the next provider call still
uses the previous model. The target-default patch may be acknowledged while
`workspace_agent_sessions.settings_json`, `runtimeContext.model`, or
app-server `turn/start` still show the old model. For an Agent Extension, the
selected model may also change back to Auto as soon as a new session is
created, even though the durable session row contains the requested model.
- Quick checks:
Search desktop and daemon logs for the full settings chain:
`agent.gui.composer_settings.default_only`,
`agent.gui.composer_settings.update_requested`,
`workspace.agent_session.settings.update_requested`,
`agent_session.settings.update.requested`,
`agent_session.app_server.settings.applied`, and
`agent_session.app_server.turn_start.params`. Also distinguish the dedicated
defaults patch intent from the Session update. If only the defaults ack is
present, the UI remembered a future target default but did not update the
active Session. If
daemon settings update completed but `turn_start.params.model` is old or
empty, inspect the app-server adapter path. If persistence and the provider
request both contain the selected model but the daemon session response omits
`settings.model`, inspect the service projection before debugging the
renderer selector.
- Root cause:
AgentGUI has two distinct composer surfaces. The target home composer writes
remembered defaults and a sparse local display draft. An active conversation composer must
additionally call `updateSessionSettings`; Codex app-server providers then
apply model changes as per-turn overrides on the next `turn/start`, not to an
already-running turn. If the daemon applies the settings but the update
response still reports the old model, check the service merge path:
`serviceSessionWithPersistedFreshness` must not let a newer activity
projection snapshot overwrite live runtime settings after an explicit
settings update. For extension-owned open provider IDs, established runtime
and persisted sessions must use open-provider-aware normalization. Applying
the closed built-in composer registry to an ID such as `acp:<extension>`
produces an empty built-in provider, clamps the model, and makes the UI
correctly render Auto from an already-corrupted session projection.
- Fix:
Preserve the dedicated target-default patch path, but make active-session model changes
observable at every layer. Do not conclude that a provider ignored the model
until the logs show the active session settings update reached the daemon and
the following `turn/start` carried the requested model. Keep closed
normalization for unverified composer requests, but preserve provider-owned
settings when projecting or resuming a session that was already authorized
through an Agent Target.
- Validation:
Reproduce by switching a model in a running session and sending a follow-up.
Confirm the logs include the update chain above and that
`workspace.agent_session.settings.update_completed` reports the requested
model and the next `turn/start` carries it. If the persisted
`workspace_agent_sessions.settings_json.model` is older while the runtime is
live, `Get` responses should still expose live runtime settings instead of
the stale projection value. Add a service regression with a generic open
provider ID and assert `serviceSession` retains its model; also assert an
invalid provider still loses stale settings.
- References:
[useAgentGUINodeController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUINodeController.ts)
[createDesktopAgentActivityRuntime.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/createDesktopAgentActivityRuntime.ts)
[workspaceAgentActivityService.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityService.ts)
[service_session.go](../../../services/tuttid/service/agent/service_session.go)
[controller.go](../../../packages/agent/daemon/runtime/controller.go)
[codex_appserver_adapter.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter.go)
### AgentGUI shows the selected settings but a new session does not inherit them
- Symptom:
The home composer continues to show the selected model, permission,
reasoning effort, or speed, but closing the Agent window, opening another
window, restarting Tutti, or creating a Session restores an older value. A
running Session may still use the selected value, which can make the problem
look provider-specific.
- Quick checks:
Start with the exact `agentTargetId`, not only the provider. Confirm the
`preferences.agent.composer.defaults.patch.requested` intent receives an ack,
then inspect
`desktop_preferences.agent_composer_defaults_by_agent_target_json` for that
target and field. Confirm a
`preferences.agent.composer.defaults.changed` event carries only the same
target id. Finally request target-scoped composer options and verify
`effectiveSettings`, then create a Session without explicit overrides and
inspect the daemon's resolved create settings. For an Agent Extension model,
confirm the daemon first observed that value in a live catalog for the exact
target; a catalog observed only for another target cannot validate the patch.
- Root cause:
Target defaults and current Session settings are separate durable concerns.
The renderer's home draft can display an optimistic selection even when a
defaults write failed. Conversely, a Session settings update can succeed
while the future-default patch fails. A stale renderer preferences snapshot
must never be merged and written back as the defaults map; options snapshots
must never sanitize a newly selected menu value before persistence.
- Fix:
Keep `rememberAgentComposerDefaultsForAgentTarget` on the dedicated patch
intent. Merge its sparse fields only in the tuttid SQLite transaction, publish
target invalidation after success, and reread defaults through
composer-options. Keep Create Session inheritance in `agent.Service.Create`;
if AgentGUI forwards a presented inherited model or reasoning value, it must
also carry `modelExplicit=false` / `reasoningEffortExplicit=false` through
Engine and the HTTP request; explicit overrides carry `true`. Do not repair
this with debounce,
localStorage, node/workbench overlays, or another full preferences write.
Do not add workspace/cwd to the target-default patch. Extension model
validation uses the daemon-observed last-known-good catalog for the exact
target. Its evidence survives the workspace/cwd display-cache TTL and is
cleared by explicit provider invalidation; Create performs the separate
actual-workspace/cwd validation.
- Validation:
Change different fields from two windows and confirm both survive. Repeat the
same SET, then change the same field again and confirm daemon acceptance order
determines the result. Force an options refresh with an older permission or
model list and confirm the explicit selection remains visible and is still
patched. Exercise A-to-B-to-A on one field and confirm only the exact latest
generation can leave the optimistic layer. Fail the first options reload
after a successful patch, then confirm a later successful target invalidation
read converges the acknowledged draft. Reopen the window and restart the app;
`effectiveSettings` and a new Session must resolve the remembered values.
Open a historical Session and confirm its settings do not change future
defaults.
- References:
[service.go](../../../services/tuttid/service/preferences/service.go)
[sqlite_preferences.go](../../../services/tuttid/data/workspace/sqlite_preferences.go)
[composer_options.go](../../../services/tuttid/service/agent/composer_options.go)
[desktopPreferencesService.ts](../../../apps/desktop/src/renderer/src/features/desktop-preferences/services/internal/desktopPreferencesService.ts)
[useAgentGUIComposerSettingsActions.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIComposerSettingsActions.ts)
### Agent interaction remains waiting after the user already responded
- Symptom:
An approval or question remains at an ask-user stop point after the user
submitted a response. The session may continue after Cancel or an app
restart, while retrying by request id alone can target an older Turn or be
rejected as ambiguous.
- Quick checks:
Inspect the canonical Interaction identity without logging its prompt or
response payload. Compare `workspace_id`, `agent_session_id`, `turn_id`, and
`request_id` across the UI/CLI command, Interaction row, and durable runtime
operation. A provider request id reused by two Turns is valid. An operation
id whose stored tuple differs from the command tuple is an invariant failure.
- Root cause:
Provider request ids are transport-local and are not session-wide durable
identities. Selecting an Interaction or deduplicating a runtime operation by
request id alone can bind a response to historical Turn state, leaving the
live Turn waiting.
- Fix:
Carry the typed exact tuple
`(workspaceId, agentSessionId, turnId, requestId)` into Host and select the
Interaction atomically by that tuple. Scope runtime-operation idempotency to
the same tuple. Keep the provider request id unchanged. Do not scan, guess,
auto-deduplicate, or rewrite historical rows. The V4 runtime-operation
migration removes the lossy `subject_id`, creates partial unique indexes for
each operation kind, verifies copied row distributions and foreign keys, and
rolls the whole transaction back when a conflicting durable identity exists.
- Recovery:
Existing affected sessions may be canceled and retried; restart settlement
may also interrupt a stale Turn. This forward fix deliberately does not
mutate historical Interaction rows.
- Validation:
Cover the same provider request id in two Turns, exact CLI response routing,
same-answer idempotency, different-answer supersession, operation identity
mismatch failure, lossless V1-to-V4 migration, and V4 preflight rollback.
- References:
[host README](../../../packages/agent/host/README.md)
[runtime_operations.go](../../../packages/agent/host/runtime_operations.go)
[migrations_runtime_operations_v4.go](../../../packages/agent/store-sqlite/migrations_runtime_operations_v4.go)
[tutti-cli-contract.md](../tutti-cli-contract.md)
### Approval feedback stops the Turn or reports a failed response
- Symptom:
Entering feedback on a denied approval aborts the provider Turn, loses the
feedback text, or lets the provider continue while the response API reports
`interactive request is no longer live`.
- Quick checks:
Inspect the canonical Interaction options and response. Confirm feedback uses
the non-terminal deny option, retains `payload.denyMessage`, and targets the
exact child Session and Turn. For Codex, confirm the approval response is
`decline` and the following `turn/steer` uses the child's provider thread and
provider Turn IDs.
- Root cause:
Treating abort as an ordinary feedback-capable deny ends the Turn before text
can be delivered. Dropping the payload at the adapter boundary has the same
visible result. Blocking Interaction completion on the provider's steer
acknowledgement can also race request cleanup and falsely fail an already
answered approval.
- Fix:
Keep deny and abort distinct in presentation. Preserve the payload through
the runtime adapter. Complete the approval response as answered, then deliver
feedback through provider-native active-turn guidance without replacing it
with a new root or child `Exec`.
- Validation:
Cover option selection, payload preservation, exact child provider
thread/Turn steering, a delayed steer acknowledgement, and a real
record-audit-replay cassette whose tool is rejected and whose child reports
the feedback.
- References:
[interactivePromptPresentation.tsx](../../../packages/agent/gui/shared/agentConversation/components/interactivePromptPresentation.tsx)
[codex_appserver_event_interactive.go](../../../packages/agent/daemon/runtime/codex_appserver_event_interactive.go)
### Historical AgentGUI permission changes time out or stop responding
- Symptom:
Changing permission mode in a historical conversation appears to do nothing,
or the first selection times out and later selections are ignored. Logs may
show a 30-second settings request with no provider settings application.
- Quick checks:
Confirm the selected session is absent from the live runtime but present in
`workspace_agent_sessions`. Check whether one menu choice emitted two settings
requests. After a timeout, inspect the engine settings operation: `unknown`
plus a later request without `retry` explains a silent drop.
- Root cause:
Historical settings were routed through runtime preparation, so a metadata
change could block on provider resume and sidecar startup. The permission
menu also handled both item pointer-down and Select value-change, duplicating
one user action. When the first command timed out, the engine correctly kept
uncertainty but the controller did not mark a later explicit choice as a
retry.
- Fix:
Send one settings intent from Select value-change. Read the current engine
operation at action time and mark an explicit choice as retry when its status
is `unknown`. In the daemon, update inactive-session settings through the
durable activity projection and publish reconciliation; reserve provider
runtime updates for sessions that are already live. Serialize runtime resume
with durable settings read-modify-write per session, preventing stale resume
inputs and lost concurrent partial patches. Do not copy an active session
setting into target defaults.
- Validation:
Cover one pointer selection producing one callback, timeout followed by an
explicit retry, and a historical Claude Code settings update that persists
while runtime resume and live adapter update counts remain zero. Run the
AgentGUI, activity-core, store-sqlite, and agent-service focused tests.
- References:
[AgentComposerSettingsMenus.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/AgentComposerSettingsMenus.tsx)
[useAgentGUIComposerSettingsActions.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIComposerSettingsActions.ts)
[service_settings.go](../../../services/tuttid/service/agent/service_settings.go)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Agent GUI provider tab shows fused or stale conversations
- Symptom:
Switching the Agent GUI aggregation rail between All, Cursor, Codex, or Claude
makes the selected row disappear, collapses a loaded page, or briefly flashes
missing Show more controls. The same switch may remain visibly blocked even
when each individual session query takes only tens of milliseconds, because
the first-page bootstrap repeats count, sort, entity projection, and turn /
interaction hydration once per current project section. Five page rows plus
one selected overlay may show
Show more/Show less even when only six sessions exist, or a nine-session
section may ignore the first Show more click. Restart can reproduce the same
selected-row loss. On cold startup, All may also show section headers without
session rows until selecting Codex and then returning to All.
- Quick checks:
Inspect `workspace_agent_sessions.agent_target_id` and `rail_section_key`.
Confirm section requests carry the selected `agentTargetId` before pagination
and responses preserve `totalCount`, `hasMore`, and `nextCursor`. In the
renderer, distinguish daemon-owned section membership ids from engine-owned
session entities; activating or hydrating one session must not rewrite the
loaded membership page. For latency, count repository section reads per
bootstrap: production must issue one `ListSessionSections` batch read, not one
`ListSessionSection` call per project plus pinned and Chats. Run
`EXPLAIN QUERY PLAN` and confirm ordinary branches use
`idx_workspace_agent_sessions_rail_section_page`, pinned branches use
`idx_workspace_agent_sessions_pinned_page`, and selected page entities load
by the session primary key after narrow id trimming. A target-filtered rail
must instead use `idx_workspace_agent_sessions_rail_section_target_page` and
`idx_workspace_agent_sessions_pinned_target_page` with an exact target
predicate; an optional `OR` predicate cannot narrow the index range. For a
rail that shows only the active/new session, inspect `*_failed` events for
`no such index`, then compare those required indexes with `sqlite_master`
even when their migration markers exist. Store startup must idempotently
restore missing rail pagination indexes; do not weaken `INDEXED BY` or fall
back to a full session scan. For a reported slow switch, correlate
`agent_gui.conversation_rail.first_pages_slow` with
`workspace.agent_session.sections.list_slow`: the renderer event separates
request and controller-apply time, while the daemon event separates current
projects, store, and hydration time. Corresponding `*_failed` events record
real failures. Successful requests below 250 ms, aborted requests, and stale
responses are intentionally silent; these diagnostics must not log project
paths, section keys, session titles, or prompts. Use `refreshReason` to
distinguish attach, scope change, and membership invalidation. Interpret
`rail_visible_session_count` as the target-scoped total across requested
sections and `returned_session_count` as only the bounded first-page rows;
neither requires another full-workspace count query.
For provider switching, inspect `agent_gui.provider_switch.completed` or
`agent_gui.provider_switch.failed`. They report source/target ids, fresh/stale/
miss cache status, request time, controller-apply time, and total controller
readiness time. Correlate target and timestamp with
`agent.composer_options.load`, which records composer transport duration and
status. A fresh rail hit with no composer transport indicates both caches were
reused; a long rail request isolates section loading, while a long composer
event isolates target option discovery.
For the cold-start empty-row variant, compare
`agent.gui.runtime.snapshot_changed` session counts with
`agent_gui.conversation_rail.first_pages_slow`. If historical reconciliation
finishes while the first-page request is unresolved, confirm no section-page
request is emitted with `refreshReason=membership_change`.
- Root cause:
A second React summary cache mixed entity data, section membership, active
selection, and visible-item limits. Effects manually patched section rows
from changing conversation summaries, so provider/detail reconciliation could
collapse pages or synthesize membership. Counting the active overlay as a
pageable row also corrupted Show more decisions. Bounded engine snapshots can
recreate the loss if omission is treated as deletion. The latency variant is
an N-section daemon read: current projects are a small requested set, while
the workspace DB retains history for removed projects. Scanning that full
history or repeating canonical turn / interaction hydration per section makes
the rail wait scale with project count even when every leaf query looks fast.
A database opened by different worktrees or intermediate builds can retain a
migration marker after a required physical index disappears; treating the
marker alone as proof of the schema invariant makes every section bootstrap
fail while an active-session overlay can misleadingly remain visible.
Historical `session/snapshotReceived` hydration can also be mistaken for a
live rail membership mutation, causing a targeted section refresh to race the
unresolved bootstrap request. Caching session payloads or a separate ingestion
marker makes that race worse by splitting entity ownership from the engine.
- Fix:
Keep page sessions in the workspace engine. Cache only ordered membership ids,
cursor, `hasMore`, and `totalCount` in the controller query, then join ids to
engine entities with a pure model projection. Keep active, pending, and all
exact-target in-progress root sessions as display overlays outside pagination
until canonical membership catches up. Preserve old scope chrome and metadata
atomically while a provider refetch is pending. Engine snapshots merge
monotonically; only explicit `session/removed` owns deletion. Keep first-page
bootstrap as a required narrow repository seam: one requested-section-driven
batch query, independent pinned and ordinary index branches, count/sort/limit
on narrow session ids, then one cross-section canonical entity hydration.
Reassert required rail indexes with idempotent `CREATE INDEX IF NOT EXISTS`
during every store migration pass, including when the corresponding marker is
already recorded, so schema drift repairs itself without rewriting session
data.
While workspace reconciliation is loading, update only the rail membership
comparison baseline; do not target-refresh pages for historical snapshot
hydration. Upsert first-page response entities into the workspace engine before
publishing their membership ids. Resolved query cache entries must not contain
session entities or ingestion ownership state.
Do not add one `UNION ALL` arm per section; that restores section-count scaling
and inherits SQLite's compound-select term limit.
- Validation:
Run `pnpm --filter @tutti-os/agent-gui test`,
`pnpm --filter @tutti-os/agent-activity-core test`, and
`pnpm check:agent-activity-runtime-boundaries`. Also run
`cd packages/agent/store-sqlite && go test ./... -run 'SessionSection|TurnsBackfill'`
and
`cd services/tuttid && go test ./service/agent ./api -run 'ListPage|SessionList|SessionSection'`
so cursor metadata and daemon ordering are covered. Run
`cd packages/agent/store-sqlite && go test -run '^$' -bench 'BenchmarkStoreListSessionSectionsLargeRemovedProjectHistory' -benchmem`
to compare the batch reader with the serial reference across sparse and dense
requested-section histories. Cover Codex -> All -> Codex,
client restart restore, active row outside first page, five-plus-active totals,
nine-session Show more, slow provider refetch, and bounded snapshot omission.
Add a cold-start ordering case where historical workspace hydration arrives
before the first section response and assert that no targeted membership refresh
is issued.
- References:
[useAgentGUIConversationRailQuery.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationRailQuery.ts)
[agentGuiConversationRail.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiConversationRail.ts)
[AgentGUIConversationRailSection.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/view/AgentGUIConversationRailSection.tsx)
[sessionEntities.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionEntities.reducer.ts)
[service_session_sections.go](../../../services/tuttid/service/agent/service_session_sections.go)
### Agent GUI sessions appear under the wrong user project
- Symptom:
A conversation started with the "No project" selection appears in the Agent
GUI rail under a parent user-project group such as the user's home directory.
Imported Codex or Claude Code conversations with `cwd` equal to `$HOME`, and
claude.ai data-export conversations that have no cwd at all, can show the
same symptom even though the user never selected a project. A related nested-
project variant shows an imported conversation under a broad parent folder
while its explicitly selected child-project section says `No chats yet`.
Switching provider filters may briefly retain the previous scope's row before
the exact persisted membership makes the child section empty again.
Delegated Issue tasks, Agent CLI handoffs, Automation follow-ups, or
AgentGUI “New conversation” / “Continue in new conversation” can show a
related variant: the new Session appears in Chats or under a temporary
worktree instead of the source project, and Git commands no longer operate
on the intended checkout.
- Quick checks:
Inspect the session `cwd` from the activity snapshot. Generated no-project
sessions should carry `runtimeContext.noProject: true` in the daemon report
before `cwd` is matched against parent user-project paths. If the create
request contains that marker but the reported state does not, inspect
`runtime.Controller.State`: it must preserve the session launch context while
adding provider adapter state. For imported sessions, inspect `runtimeContext`
for the daemon-owned `externalImportNoProject` marker. Claude data-export
sessions should also carry `externalImportResumeSupported: false`. Check both
the in-memory `rememberNoProjectPath` path and the restart fallback that
recognizes `Documents/tutti/session-<uuid>`. Codex external history can also
record its own scratch cwd under
`Documents/Codex/<yyyy-mm-dd>/<conversation>`.
For the nested-project variant, compare `cwd`, `rail_project_path`, and
`rail_section_key` in `workspace_agent_sessions`. If the cwd is inside a
registered child project but the persisted key names an ancestor, check
whether that child was registered only after the original import completed.
For a newly derived Session, compare those three fields with the source
Session and inspect whether the create adapter supplied both `cwd` and
`RailPlacement`. If an Issue has `source_session_id` but no Run was created,
verify that the source Session still resolves; dispatch intentionally waits
instead of guessing. For project Sessions with an empty cwd, verify that the
adapter used `rail_project_path` as the runtime fallback.
- Root cause:
Rail membership is classified once by the daemon when the session is first
persisted, using `cwd`, runtime no-project markers, and current user projects.
If a generated no-project marker is lost before that write, longest-parent
matching can assign the immutable `railSectionKey` to a broad project such as
`$HOME`. External import has a similar trap because provider transcripts may
record `$HOME` or a provider-owned scratch working directory as the cwd when
no project was selected; that intent must reach initial persistence as session
metadata. A second loss point is runtime state projection:
rebuilding `runtimeContext` from only `cwd`, title, permissions, and visibility,
or replacing it wholesale with `StateAdapter` output, drops launch-scoped
markers such as `noProject` before durable rail classification runs.
For project-backed import, registering user projects only after valid session
writes creates another ordering trap: the store may see an existing parent
project but not the selected child while assigning the session's first,
normally immutable rail key.
Derived-Session entry points add the inverse trap: copying only runtime cwd
loses logical ownership when the cwd is an isolated worktree, while copying
only rail placement can leave the Agent without the source checkout. Looking
up the source twice during one dispatch can also mix two different snapshots,
and silently accepting a missing source turns an invalid handoff into a
detached allocator-backed Session.
- Fix:
Build runtime state from a clone of the session launch `RuntimeContext`, overlay
canonical session fields, and merge provider `StateAdapter` context as a patch
instead of replacing the map. Provider values win on collisions, while
launch-only markers remain available to the durable classifier.
Persist Agent GUI rail grouping in daemon-owned
`workspace_agent_sessions.rail_section_*` fields from the shared
`services/tuttid/data/workspace` classifier. Migration and session-state
upsert should both use that classifier, matching exact user projects first,
then preserving no-project/provider scratch cwd shapes as conversations, then
applying longest parent-project matches. Project-backed external import must
pass its exact matched selection into the initial session report, independent
of whether API-level project registration has completed. The store validates
that this is an imported, project-backed session whose cwd is contained by the
selected path. An idempotent re-import may repair only Chats or an ancestor
assignment to that explicit descendant; it must not infer a migration from
the current project inventory or move an ordinary runtime session. Apart from
this evidence-backed import correction, preserve valid existing rail keys
across later cwd and project-list changes. A successful Create
response must synchronously read back the persisted session and its nonblank
key rather than racing the runtime's asynchronous activity reporter. AgentGUI
must project sessions only by exact key equality and must not retain a cwd-based
grouping fallback.
For handoff, Issue, Automation, and AgentGUI new-conversation flows, carry
runtime cwd and canonical rail placement independently. Resolve project
identity by exact section key, use the canonical project path when a
project-backed source cwd is empty, take one source snapshot per dispatch,
and fail closed when a required source Session cannot be resolved.
- Validation:
Run
`pnpm --filter @tutti-os/agent-gui test -- agent-gui/agentGuiNode/model/agentGuiConversationModel.spec.ts`,
`cd packages/agent/daemon && go test ./runtime`,
`cd services/tuttid && go test ./service/agent ./api -run 'ExternalImport|ParseCodex|ParseClaude'`,
`go test ./packages/agent/store-sqlite -run 'ImportedRail|ClassifiesRail'`,
`node --import ./test/register-asset-stub.mjs --test --experimental-strip-types ./src/renderer/src/features/workspace-user-project/services/internal/desktopWorkspaceUserProjectService.test.ts`
from `apps/desktop`, then run `pnpm check:changed`.
For derived-Session regressions, also run the focused AgentGUI
new-conversation tests plus
`go test ./service/workspace ./service/automationrule`.
- References:
[controller_state.go](../../../packages/agent/daemon/runtime/controller_state.go)
[controller_state_test.go](../../../packages/agent/daemon/runtime/controller_state_test.go)
[external_import_parse.go](../../../services/tuttid/service/agent/external_import_parse.go)
[external_import_projects.go](../../../services/tuttid/service/agent/external_import_projects.go)
[external_import.go](../../../services/tuttid/service/agent/external_import.go)
[rail.go](../../../packages/agent/store-sqlite/rail.go)
[agentGuiConversationModel.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiConversationModel.ts)
[desktopWorkspaceUserProjectService.ts](../../../apps/desktop/src/renderer/src/features/workspace-user-project/services/internal/desktopWorkspaceUserProjectService.ts)
[agentGuiConversationProjectResolver.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiConversationProjectResolver.ts)
[useAgentGuiConversationList.ts](../../../packages/agent/gui/contexts/workspace/presentation/renderer/agentGuiConversationList/useAgentGuiConversationList.ts)
[issue_sequential_dispatch.go](../../../services/tuttid/service/workspace/issue_sequential_dispatch.go)
[daemon_executor.go](../../../services/tuttid/service/automationrule/daemon_executor.go)
### AgentGUI new conversation does nothing after leaving a Chats session
- Symptom:
After using an ordinary Chats Session, the toolbar new-conversation action
opens the home composer but submitting by Enter or the send button creates no
visible Session and produces no `session/activate` command. Enabling Tutti
Mode before submitting can make the problem look mode-specific even though
activation is blocked before provider or Tutti Mode execution starts.
- Quick checks:
Inspect the active conversation projection before the new-conversation
request. If `railSectionKey` is `conversations` while `cwd` is a generated
runtime directory, confirm that directory is not copied into the home
composer's selected project. A subsequent activation that resolves the path
against user projects and returns no placement explains the missing
`session/activate`.
- Root cause:
Composer presentation intentionally exposes the active Session `cwd` for
file mentions, Git operations, and missing-directory checks. Treating that
presentation field as the user's home project selection conflates runtime
working directory with rail placement. The generated Chats directory then
enters project resolution and fails closed because it has no canonical
project section.
- Fix:
Normalize default project selection at the AgentGUI controller's
new-conversation command. Explicit section actions remain authoritative; an
active Chats Session replaces the home selection with no project, an active
project Session resolves its immutable section key back to the canonical
registered project path, and an action already on Home preserves the user's
explicit selection. The continuation action shares this resolver before it
moves the source mention draft to Home. Views forward the intent without
interpreting composer presentation fields.
- Validation:
Cover the command through final `session/activate` for three P0 scenarios:
active Chats clears a generated cwd, active Project with a nested/worktree
cwd preserves its canonical placement, Continue uses that same project, and
Home preserves an explicit project selection.
- References:
[agentGuiNewConversationRequest.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/agentGuiNewConversationRequest.ts)
[useAgentGUIOperationActions.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIOperationActions.ts)
[agentGuiNewConversationRequest.spec.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/agentGuiNewConversationRequest.spec.tsx)
### Extension history becomes non-resumable after daemon restart
- Symptom:
An Agent Extension conversation works until `tuttid` restarts. Its history
remains visible, but AgentGUI says it cannot resume on this device and only
offers continuing through an `@` mention. In an affected live extension
session, the `tutti` shim can be present on `PATH` while many public commands,
including `tutti agent list`, return `command_not_found`. After restart,
sending to the same session can fail with
`session runtime snapshot is unavailable: launch identity is incomplete`.
- Quick checks:
Confirm the persisted session still has `provider_session_id` and
`agent_target_id`. If the Target remains enabled and names a fixed extension
installation, compare list-time `resumable` calculation with the actual
Resume path. An empty process-local adapter registry after restart is not
evidence that the session cannot be restored.
For the CLI variant, first distinguish discovery from routing: look for
`tutti cli shim ready` in Desktop logs and `path_contains_tutti_bin=true` in
the provider process diagnostics. If both are present but the provider tool
reports `unknown command`, compare
`workspace_agent_sessions.provider` with
`internal_runtime_context_json.$.sessionRuntimeSnapshot.provider`. An
extension provider such as `acp:<name>` beside an empty snapshot provider,
followed by `launch identity is incomplete`, identifies the durable snapshot
path rather than a PATH or listener failure. A second historical shape keeps
`acp:<name>` in both locations but has a provider-native fingerprint computed
from an empty provider; it fails later with
`provider-native fingerprint does not match`.
- Root cause:
Dynamic Agent Extension adapters are created on demand and cached only for
the daemon lifetime. Computing `resumable` from that cache maps restart state
to a false domain result before Resume can re-resolve the persisted Target.
The same false result occurs when an adapter rebuilds the runtime resume input
but drops `agentTargetId`: the fixed Target ref then fails the controller's
complete binding check even though persistence and Target resolution are
correct. A separate snapshot variant occurs when provider-neutral metadata
uses the closed built-in-provider normalizer for an open extension identity.
The writer then persists an empty provider and fingerprints the
provider-native configuration with that empty value. Session-scoped CLI
capability projection validates the snapshot before returning the command
catalog; validation failure collapses discovery to an empty catalog, so
otherwise valid commands appear unknown. Runtime resume rejects the same
incomplete identity after daemon restart.
- Fix:
At the service boundary, re-derive `ProviderTargetRef` from the persisted
session's enabled `agentTargetId`. At the runtime boundary, validate the
provider, Target, and fixed installation binding; when a dynamic resolver is
configured, treat that authorized binding as eligible for a Resume attempt.
Keep installation validation and ACP `session/load` in the actual Resume
path. Never use an open provider id or adapter-cache presence as launch
authority.
Every bridge from the Host resume input to the runtime resume input must
preserve `agentTargetId` and `ProviderTargetRef` together. Cover the complete
service-to-adapter-to-controller path instead of testing each endpoint with
independently constructed valid inputs.
Persist and compare snapshot provider metadata with the open provider
normalizer; launch authority still comes exclusively from the exact enabled
Agent Target. For already-written empty-provider extension snapshots, recover
only when the canonical session provider is a valid unregistered open
identity, the snapshot declares provider-native configuration, and its
fingerprint exactly matches the historical empty-provider payload. Apply the
same narrow compatibility check to the transitional shape that already
retained the open provider in the snapshot: the canonical and snapshot
providers must match exactly before accepting the historical fingerprint.
Keep every other malformed or mismatched snapshot fail-closed, and do not
rewrite the database merely to make discovery succeed.
- Validation:
Start from a controller with no cached extension adapter. Assert a persisted
Target-bound session is resumable, malformed or mismatched bindings fail
closed, and the eligibility check does not launch the provider. Then run
`go test ./packages/agent/daemon/runtime ./services/tuttid/service/agent`.
Also cover new extension snapshots preserving `acp:*`, verified legacy
empty-provider recovery, transitional open-provider/legacy-fingerprint
recovery, registered or mismatched provider fallback rejection, CLI command
projection for the recovered session, and runtime preparation after restart.
- References:
[controller_session_registry.go](../../../packages/agent/daemon/runtime/controller_session_registry.go)
[agent_runtime_adapter.go](../../../services/tuttid/agent_runtime_adapter.go)
[service_session.go](../../../services/tuttid/service/agent/service_session.go)
[session_runtime_snapshot.go](../../../services/tuttid/service/agent/session_runtime_snapshot.go)
[agent-extensions.md](../../architecture/agent-extensions.md)
### An authorized observer loops unavailable while a session is resuming
- Symptom:
A remote or local observer has a durable Agent session ID and passes
authorization, but immediately receives `session not found` or
`Unavailable`. Its caller reconnects rapidly even though Agent Host restores
the provider session shortly afterward.
- Quick checks:
Separate durable identity from the Controller registry. Confirm the session
exists in canonical persistence while `Controller.Session` is temporarily
absent, then correlate the observer attempt with the later Host
`EnsureRuntimeSession`, runtime `Start`, or runtime `Resume`. If observation
itself launches the provider, lifecycle authority is inverted.
- Root cause:
A durable session record proves that work may be resumed; it does not prove a
live provider or runtime registry entry exists after daemon restart. A
one-shot `Controller.Subscribe` therefore turns a normal restore race into a
transport failure, and caller reconnect loops cannot know when the local
lifecycle owner has finished restoring ACP.
- Fix:
Use `Controller.SubscribeWhenAvailable` when the consumer already has an
authorized durable session identity and must observe the next live runtime.
It waits on a per-session Controller notification and atomically subscribes
with the initial state snapshot once `Start` or `Resume` registers the
session. Keep provider launch and resume in Agent Host; the observation
method must never call those lifecycle operations.
- Validation:
Start the observation against a missing runtime, assert that it neither
returns nor creates a session, then perform a real Controller `Resume` and
verify the observer receives the initial state snapshot. Also cancel a
missing-session wait and verify no waiter or runtime session remains, and
cover concurrent observers under the race detector.
- References:
[controller_stream.go](../../../packages/agent/daemon/runtime/controller_stream.go)
[controller_stream_wait_test.go](../../../packages/agent/daemon/runtime/controller_stream_wait_test.go)
### Agent session restore breaks when durable snapshot ownership is split
- Symptom:
Workspace agent sessions still appear recoverable after a renderer refresh,
but after a `tuttid` restart the session list is empty, the detail pane
falls back to an unavailable state, or a newly reported session overwrites
older history for the same workspace.
- Quick checks:
Confirm `services/tuttid/data/workspace` has a durable snapshot row for the
workspace and that `services/tuttid/wiring.go` hydrates the in-memory agent
activity store from it before new runtime reports are applied.
If restore reads use a different source than write-time projection, verify
both `List/Get` and message-history queries are reading the same durable
snapshot shape.
If the durable row has `provider_session_id` but ACP returns
`Resource not found`, confirm the restore path re-runs the agent sidecar
preparer and passes the prepared runtime environment, such as the per-session
`CODEX_HOME`, into runtime resume.
- Root cause:
Agent runtime reports are projected into an in-memory activity store, but
restore paths survive daemon restarts only if the projected snapshot is also
written to daemon-owned local state and reloaded before the next activity
report. If only the renderer cache or only the daemon process memory holds
the projection, session metadata and message history diverge after restart.
- Fix:
Make `tuttid` own a durable agent snapshot in `data/workspace`, persist it
from the activity-store update listener, and hydrate the in-memory activity
store from that snapshot on first room tracking. Service-level
session/message restore should read from the same durable snapshot source,
and runtime mutations should on-demand resume a persisted session before
accepting new input. Provider-session resume must use the same prepared
sidecar runtime root and env as the original session, because provider ids
are often scoped to provider-local state under that root.
- Validation:
Add store round-trip coverage for the snapshot row, service tests that fall
back to persisted sessions and resume them into runtime, then run
`pnpm lint:go` plus `cd services/tuttid && go test ./... && go build ./...`.
- References:
[service.go](../../../services/tuttid/service/agent/service.go)
[wiring.go](../../../services/tuttid/wiring.go)
### Agent activity live updates fail after event schema changes
- Symptom:
AgentGUI stays busy after a turn has finished, while durable
`workspace_agent_sessions` state is already idle and daemon logs show
`publish workspace agent activity update failed` with a
`decode ... data: json: unknown field` error.
- Quick checks:
Compare the new field in
`packages/events/protocol/definitions/agent/activity.updated.event.json`,
generated event protocol outputs, and the hand-written strict validators in
`services/tuttid/service/eventstream/catalog.go`.
- Root cause:
The shared business event schema and generated Go/TypeScript protocol files
can be current while the daemon event-stream catalog still rejects the same
payload through `DisallowUnknownFields` on a hand-written validation struct.
The activity projection may persist the correct session state, but the live
`agent.activity.updated` publish is rejected before the renderer runtime sees
the settling patch.
- Fix:
Keep `catalog.go` validation DTOs in sync with new event fields, especially
for `agent.activity.updated` top-level, `session_update`, and `state_patch`
payloads. Add a positive validator test for the new field, not only generated
protocol checks.
- Validation:
Run `go test ./services/tuttid/service/eventstream` and
`pnpm check:event-protocol-generated` when event protocol sources changed.
- References:
[catalog.go](../../../services/tuttid/service/eventstream/catalog.go)
[activity.updated.event.json](../../../packages/events/protocol/definitions/agent/activity.updated.event.json)
### AgentGUI file-change undo reports a generic failure
- Symptom:
Clicking Undo on a changed-files summary shows a failure even though the
target directory is a Git repository and the file appears unchanged since
the agent edit. The same loss can appear earlier as a missing changed-files
card, missing or incorrect line counts, or a "missing reversible patch data"
error after a provider overwrites a file.
- Quick checks:
Search desktop and daemon logs for the `agent-git-patch` diagnostic family.
Inspect `errorCode`, Git `stderr`, the diff byte count and hash, and the
affected paths. For `invalid-patch`, inspect the durable tool output for
malformed unified-diff control markers. A no-newline marker must begin with
`\`, not a leading context-space followed by `\`. For
a synthesized whole-file edit, verify that an empty old or new side has a
hunk count of `0`; `@@ -1,1 +1,N @@` with no old or context line is corrupt.
If only one side of a modified file was persisted, Undo must be unavailable
instead of guessing that the file was created. For
`patch-does-not-apply`, compare the recorded after-state with the current
file rather than assuming the original turn is still the latest writer. For
missing patch data, check whether Standard ACP logged an unsupported
`fs/write_text_file` request, whether Codex emitted
`item/fileChange/patchUpdated`, and whether a modified canonical file entry
contains only one of `oldString` and `newString`.
- Root cause:
Provider display diffs can contain syntax that a viewer tolerates but
`git apply` rejects. Treating that display payload as executable patch data
produces corrupt hunks. A separate failure occurs when the patch is valid
but later edits changed its context. On Windows, leaving an absolute drive
path in either a synthesized patch or an existing unified-diff header also
violates Git's cwd-relative patch contract and can report that an untracked
created file does not exist in the index.
Historical raw tool payloads can also contradict the canonical
`fileChanges` classification. Giving that raw payload priority can turn a
created file into a one-sided modified edit; forcing an empty side to count
as one then produces a syntactically corrupt patch.
created file does not exist in the index. Patch data is also lost when a
Standard ACP host advertises file writes as unsupported and therefore cannot
snapshot the old content, or when an app-server adapter ignores the
authoritative incremental file-change patch notification. Synthesizing the
missing side of a modified file as empty then produces a plausible-looking
but non-reversible patch.
- Fix:
Canonicalize provider file-change metadata at the runtime adapter boundary
before persistence, and canonicalize historical no-newline markers on read.
AgentGUI must make synthesized paths and existing unified-diff headers
relative to the patch cwd, using case-insensitive path identity for Windows.
Executable batches prefer canonical per-call `fileChanges`; incomplete raw
modified changes fail closed. Undo/Reapply is atomic for the settled Turn:
every canonical visible path and every source-batch change must have an
executable patch, otherwise the whole action stays unavailable. When both
modified-file sides are known, hunk counts use the actual line counts,
including zero, and text synthesis rejects NUL-containing binary content.
Standard ACP hosts that advertise `writeTextFile` must implement the matching
`fs/write_text_file` request, capture the exact old and new content, and emit
canonical turn file changes. App-server adapters must preserve authoritative
incremental patch notifications in the same canonical turn state. AgentGUI
must fail closed instead of synthesizing a modified-file patch when either
side is absent.
The daemon must preflight with `git apply --check` using the same execution
options, return `invalid-patch` for syntax failures and
`patch-does-not-apply` for state mismatch, and avoid mutating the worktree on
either result.
- Validation:
Cover leading-whitespace no-newline markers, historical activity projection,
corrupt-patch preflight without mutation, worktree divergence, reverse
application, cwd-relative Windows drive paths for synthesized and complete
diffs, zero-sided whole-file edits with LF and CRLF input, binary fail-closed
behavior, canonical-versus-raw conflicts, and the existing
diffs, Standard ACP overwrite capture and relative-path rejection, Codex
incremental patch projection, one-sided modified metadata, and the existing
untracked-created-file behavior.
- References:
[claude_sdk_activity.go](../../../packages/agent/daemon/runtime/claude_sdk_activity.go)
[standard_acp_filesystem.go](../../../packages/agent/daemon/runtime/standard_acp_filesystem.go)
[codex_appserver_reducer.go](../../../packages/agent/daemon/runtime/codex_appserver_reducer.go)
[agentTurnSummaryPatchDiff.ts](../../../packages/agent/gui/shared/agentConversation/rules/agentTurnSummaryPatchDiff.ts)
[agentPatchMetadata.ts](../../../packages/agent/gui/shared/agentConversation/rules/agentPatchMetadata.ts)
[agentTurnSummaryProjection.ts](../../../packages/agent/gui/shared/agentConversation/projection/agentTurnSummaryProjection.ts)
[agentTurnSummaryPatchDiff.ts](../../../packages/agent/gui/shared/agentConversation/rules/agentTurnSummaryPatchDiff.ts)
[git_patch.go](../../../services/tuttid/service/agent/git_patch.go)
### AgentGUI changed-files summary shows negative lines for a new file
- Symptom:
The file-edit row reports additions, but the settled changed-files summary
reports deletions, often matching the number of Markdown list items that
begin with `-`. The worktree file itself is present and complete.
- Quick checks:
Compare the final file's line count with the tool row, then inspect the
canonical `turn.fileChanges.files[]` entry. A real unified diff has a hunk
header such as `@@ -0,0 +1,13 @@`; a file body stored in `unifiedDiff` is
malformed metadata, not a diff.
- Root cause:
The summary projection used any non-empty `unifiedDiff` as a valid patch and
counted every line beginning with `-` as a removal. Its content fallback also
discarded blank lines, so the summary and tool row could use different line
totals for the same write.
- Fix:
Normalize `fileChanges` at the runtime and durable-payload boundaries: a
created file's raw body becomes `newString`, while `unifiedDiff` is retained
only when it has a real hunk. AgentGUI also requires a hunk before parsing
historical payloads and shares the line-count helper with tool render data,
so blank lines are counted consistently.
- Validation:
Cover a created Markdown body containing list items and blank lines at both
canonicalization boundaries, then run the Agent runtime and AgentGUI tests
and typechecks.
- References:
[tool_file_changes.go](../../../packages/agent/daemon/runtime/tool_file_changes.go)
[tool_payload.go](../../../packages/agent/store-sqlite/canonical/tool_payload.go)
[agentUnifiedDiff.ts](../../../packages/agent/gui/shared/agentConversation/components/tool-renderers/file-diff/agentUnifiedDiff.ts)
[AgentTurnSummaryRow.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentTurnSummaryRow.tsx)
### Cursor deleted files appear as created or modified
- Symptom:
After Cursor deletes a file, the settled turn's changed-files summary or tool
detail labels it as created or modified instead of deleted.
- Quick checks:
Inspect the original completed ACP tool payload, its following `turn.updated`
event, and the durable turn row. Cursor reports deletion with ACP
`kind = delete`, but its canonical tool name is still `Write`. The
`turn.updated.metadata.fileChanges.files[]` entry and durable
`file_changes_json` must both say `change = deleted`.
- Root cause:
The canonical tool name describes the file-writing tool family, not the
change direction. Inferring every `Write` as creation discards Cursor's
explicit ACP delete semantic. A second failure mode is normalizing the tool
call correctly but omitting canonical `fileChanges` from the subsequent turn
state patch, leaving AgentGUI without its authoritative response-tail input.
- Fix:
Normalize recognized provider change kinds inside the shared runtime
file-change projector, accumulate the result per turn, and persist it through
`turn.updated`. The session-detail response must return all durable root turns,
and the desktop reconcile bridge must insert them into the existing activity
engine turn store. AgentGUI reads only the matching canonical turn's
`fileChanges`; do not add Cursor/ACP/Codex/Claude field inference to
conversation projection. Keep tool `changes` payloads independently for
Undo/Reapply patch batches. Do not backfill sessions whose historical turns
have no canonical file changes.
- Validation:
Cover the Cursor completed-call to `turn.updated` path, durable turn state,
and canonical AgentGUI projection. Also cover Codex `changes[].kind.type` and
Claude Code started/completed tool merging because all three providers share
the same turn-level contract.
- References:
[tool_file_changes.go](../../../packages/agent/daemon/runtime/tool_file_changes.go)
[acp_turn_normalizer.go](../../../packages/agent/daemon/runtime/acp_turn_normalizer.go)
[reporter_message.go](../../../packages/agent/daemon/runtime/reporter_message.go)
[reporter_state.go](../../../packages/agent/daemon/runtime/reporter_state.go)
[agentTurnSummaryProjection.ts](../../../packages/agent/gui/shared/agentConversation/projection/agentTurnSummaryProjection.ts)
### Remote agent cancel does not stop the local turn
- Symptom:
A cancel request returns successfully and the provider adapter logs a remote
cancel notification, but the session remains `running` and continues to emit
model output.
- Quick checks:
Inspect the runtime controller path for the active turn's local
`context.CancelFunc`. A provider-level cancel or ACP notification is not
enough if the local `Exec` goroutine is still waiting on the original turn
context.
- Root cause:
Some providers treat cancel as a notification and may return no immediate
terminal events. If the controller does not also cancel the local active turn
context, `runExecTurn` cannot converge through its context-canceled path.
- Fix:
Once an active turn is found, cancel its local context as part of the
controller cancel flow, then call the provider adapter cancel hook so both
local and remote paths are interrupted.
- Validation:
Add a controller test with an adapter that returns no cancel events and only
exits when its `Exec` context is canceled. A direct API smoke should return
HTTP 200 and final session status `canceled`.
- References:
[controller.go](../../../packages/agent/daemon/runtime/controller.go)
[controller_test.go](../../../packages/agent/daemon/runtime/controller_test.go)
### Claude Code completes but the Turn has no Fork entry
- Symptom:
A Claude Code Turn is settled and has assistant output, but AgentGUI does not
offer Fork. The session capability reports `forkThroughTurn=false`, or its
supported provider Turn list is empty even though the Claude transcript is
readable. On a newly created Session, the same missing binding can make the
completed reply roll back to the new-conversation screen with
`provider turn was not durably accepted`.
- Quick checks:
Compare the canonical Turn's `root_provider_turn_id` with the UUID of the
matching root user message returned by the official Claude SDK
`getSessionMessages` API. If they differ, inspect the live sidecar event
sequence for `provider_turn_identity_resolved` and the canonical activity
sequence for `root_provider_turn.started`. Do not infer identity from
transcript position or substitute the canonical Tutti Turn ID.
- Root cause:
The outbound user-message UUID is only a prompt correlation value. Claude
Code may rewrite that UUID before persisting the transcript. Publishing the
caller-generated value as canonical provider identity makes strict prefix
verification correctly reject the Turn, which removes the Fork capability.
The SDK can also omit the persisted root user echo from a successful live
query. Activating on assistant output while waiting exclusively for that echo
can deadlock when approval or user input occurs before result: the Provider
waits for the user, the sidecar waits for result-time recovery, and the Host
waits for durable acceptance.
- Fix:
Mark the next root prompt echo as causally expected before submitting it.
Bind provider Turn identity from that observed root user-message UUID. Route
every root assistant, stream, tool, approval, user-input, and result path
through the shared single-flight identity barrier. Without an echo, resolve
exactly one root user UUID and checkpoint through the official
`getSessionMessages` transcript read with bounded cancellable retries.
Emit `provider_turn_identity_resolved`, synchronously persist the acceptance
binding in the Host, then publish canonical `root_provider_turn.started`
before any interaction or streaming event. Never substitute the outbound
correlation UUID.
Keep historical Turns without observed provider identity fail-closed rather
than guessing or backfilling them.
- Validation:
Cover a query whose root user echo rewrites the outbound UUID. Assert
`provider_turn_identity_resolved`, checkpoint, and terminal events all carry
the persisted UUID. Cover successful assistant/result, approval,
`AskUserQuestion`, and `ExitPlanMode` sequences with no root user echo.
Assert canonical `root_provider_turn.started` is durable and ordered before
every interaction, then verify Fork accepts the same identity. Restart the
daemon/sidecar with an accepted incomplete Turn and confirm it is recovered
without re-dispatch. Historical affected Turns remain intentionally
non-forkable.
- References:
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts)
[turnLifecycle.ts](../../../packages/agent/claude-sdk-sidecar/src/turnLifecycle.ts)
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
[claude_sdk_events.go](../../../packages/agent/daemon/runtime/claude_sdk_events.go)
### A completed Turn loses its Fork entry after a rail refresh
- Symptom:
A selected Session detail initially offers Fork on its latest completed Turn,
but switching provider scope, refreshing the conversation rail, or receiving
a section page removes the action. The canonical Turn still has
`root_provider_turn_id` and `provider_turn_binding_json`; a full Session
detail reports `providerForkBindingState=bound`, while the list projection
reports `recovery_required` for the same Turn and timestamp.
- Quick checks:
Compare the list and full-detail payloads for the exact Session and Turn.
Then inspect the activity-engine Turn before and after
`session/snapshotReceived`. An image attachment is unrelated unless the
provider binding itself is absent.
- Root cause:
Session list and section responses batch-project the latest Turn. Treating a
non-persisted provider-binding availability flag as canonical without
resolving it returns a fail-closed value. The rail then merges that
lightweight latest-Turn entity into the same canonical Turn as the
authoritative detail. Equal timestamps and outcomes permit the whole Turn
replacement, so the fail-closed list value overwrites `bound`.
- Fix:
Resolve settled latest/active Turn forkability in the shared batch response
projector and cache duplicate probes within the request. Keep list Session
capabilities lightweight, but mark full capability projections explicitly.
In Activity Core, a lightweight Session snapshot may upgrade a provider Turn
binding but must not downgrade an already bound Turn. Keep message and
TuttiMode revisions monotonic for the same reason; Goal synchronization needs
its own update version because one Goal revision has multiple sync states.
- Validation:
Cover the list entry point and duplicate active/latest projections through
the shared batch projector used by list and section reads, asserting one
cached provider probe per exact Turn and fail-closed provider errors.
Apply a lightweight `recovery_required` snapshot after an authoritative
`bound` snapshot and assert the canonical Turn remains bound. Also verify an
actual full projection can still supply authoritative lifecycle state.
- References:
[service_turns.go](../../../services/tuttid/service/agent/service_turns.go)
[sessionEntities.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionEntities.reducer.ts)
### Claude Code Fork fails after the action is clicked
- Symptom:
Fork is available for a completed Claude Code Turn, but clicking it produces
a failed operation. The durable operation becomes `unknown`, no target
canonical Session is committed, and the target provider Session is absent.
- Quick checks:
Inspect the latest `workspace_agent_session_fork_operations` row and query the
target UUID with the official SDK `getSessionInfo` and
`getSessionMessages`. If both are empty, check whether the sidecar used
`query({forkSession: true})` with an empty prompt and treated
`initializationResult()` as provider mutation completion.
- Root cause:
Query initialization only completes the SDK/CLI handshake. Without a prompt,
it does not durably create the requested child transcript. Unit tests can
hide the defect if their fake marks the child created inside
`initializationResult()`. A catch that replaces the original exception with
a generic Fork error also erases whether failure occurred during source
validation, provider mutation, or child verification.
- Fix:
Use the official `forkSession(source, {upToMessageId, title})` transcript
mutation. Accept its provider-generated child UUID, advertise the driver as
non-deterministic, and let Host keep only the canonical target Session ID
deterministic. Preserve the failure stage and original SDK reason.
After provider mutation starts, any failure is `unknown` and must never be
replayed.
- Validation:
Exercise the official SDK with an in-memory SessionStore, including a Turn
whose inclusive checkpoint ends in a system message. Verify the provider
child is independently readable, root user UUIDs are remapped, the canonical
child resumes by the returned provider Session ID, and a second Fork from
that child succeeds. Cover pre-mutation `not_started`, post-mutation
`unknown`, and Host's no-replay behavior for non-deterministic drivers.
- References:
[sessionFork.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionFork.ts)
[sessionFork.test.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionFork.test.ts)
[claude_sdk_fork.go](../../../packages/agent/daemon/runtime/claude_sdk_fork.go)
[session_fork.go](../../../packages/agent/host/session_fork.go)
### A provider-accepted Session Fork prevents Tutti from starting
- Symptom:
Tutti exits during cold startup with `materialize accepted session fork` and
a provider-owned binding count that differs from the frozen canonical
provider-bound Turn count. The source Session and its history still exist,
while the operation remains `provider_accepted`.
- Root cause:
Claude transcripts represent tool results as top-level `user` messages even
though they continue the current canonical Turn. Treating every top-level
Claude `user` message as a root Turn creates extra provider bindings. More
generally, after any provider accepts a Fork, recovery correctly refuses to
dispatch it again. Canonical materialization also correctly refuses to invent
missing provider Turn bindings, but the deterministic mismatch was returned
as an ordinary recovery error. One unrecoverable operation therefore poisoned
Host recovery and daemon construction on every restart.
- Fix:
Keep Claude `tool_result` messages inside the preceding root Turn checkpoint
instead of creating independent bindings. As a separate final safety net,
classify only deterministic provider-owned materialization evidence
mismatches as permanent. Atomically fail that accepted operation, retain its
provider acceptance evidence and the complete source history, remove any
incomplete target, and release its reservation and boundary barrier. Do not
re-dispatch the provider. Keep transient SQLite errors and failures to persist
the quarantine startup-fatal. After quarantine persists, emit a content-free
`agent_session.fork.materialization_quarantined` warning to `tuttid.log` with
operation identities, driver kind, returned binding count, and the mismatch.
- Validation:
Feed the Claude Fork adapter a real transcript shape containing root prompts,
assistant tool calls, and top-level tool-result messages. Require one binding
per root prompt and require the tool result to extend the preceding Turn's
checkpoint. Separately, seed a real temporary SQLite database with 29
provider-bound canonical Turns, three returned bindings, and a
`provider_accepted` operation. Recover through
`Host.Recover`, verify the source still has 29 Turns, the target is absent,
provider evidence remains, isolation rows are gone, no provider Fork call was
made, and a later valid operation commits. Inject quarantine persistence
failure as the negative control. Start the real `tuttidd` binary from a
poisoned state directory and require listener publication plus healthy
`/v1/health`.
- References:
[session_fork.go](../../../packages/agent/host/session_fork.go)
[session_fork_operations.go](../../../packages/agent/store-sqlite/session_fork_operations.go)
[session_fork_conformance_test.go](../../../packages/agent/host/session_fork_conformance_test.go)
[blackbox_test.go](../../../services/tuttid/integration/blackbox_test.go)
### Fork reports only `agent_session_fork_conflict`
- Symptom:
A through-Turn Fork returns HTTP 409 before any provider `thread/fork`
request. Desktop diagnostics contain only
`reason=agent_session_fork_conflict` or a developer-message length, so
provenance, attachment, descendant-lane, and provider-Turn failures are
indistinguishable.
- Quick checks:
Read `error.params.forkBoundaryReason` from the 409 response or the promoted
Desktop diagnostic `reason`. For example,
`agent_session_fork_turn_sequence_unverified` means the selected Turn has
unverified sequence provenance, while
`agent_session_fork_prefix_sequence_unverified` identifies an earlier Turn
in the inclusive prefix. The developer message carries the observed phase,
provenance, sequence, or identity condition without message content.
- Root cause:
`CheckSessionForkThroughTurn` collapsed every fail-closed boundary branch to
`supported=false`; Host replaced it with one generic Turn-state error, and
the service formatted the nested error with `%v`, which discarded the error
chain before transport classification.
- Fix:
Preserve one stable boundary rejection reason from the Store through Host
and Service. Keep the public 409 reason
`agent_session_fork_conflict` for compatibility, add the exact stable code
as `error.params.forkBoundaryReason`, and promote that code only for Desktop
diagnostics. Do not log transcript payloads or attachment contents.
- Validation:
Cover unverified selected/prefix sequences, duplicate provider Turn IDs,
descendant lanes, and session-local attachments at the Store. Verify Service
wrapping preserves both the generic conflict and typed boundary error, the
API includes the structured parameter, and Desktop promotes it to the
diagnostic reason.
- References:
[session_fork.go](../../../packages/agent/store-sqlite/session_fork.go)
[session_fork_types.go](../../../packages/agent/store-sqlite/session_fork_types.go)
[daemon_agent_session_fork.go](../../../services/tuttid/api/daemon_agent_session_fork.go)
[desktopAgentActivityAdapter.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/desktopAgentActivityAdapter.ts)
### Claude Code cancel leaves Write/tool cards or thinking stuck in progress
- Symptom:
User stops a Claude Code turn while a tool such as Write is running, or while
the assistant is still in a thinking disclosure. The turn settles as
canceled/interrupted, but the transcript still shows the tool as in progress
or thinking as forever-"thinking".
- Quick checks:
Compare durable tool-call / `assistant_thinking` message status with turn
outcome. If the turn is interrupted/canceled and an open `tool_call` is still
`running`, or thinking is still `streaming`/`working`, the Claude SDK turn
lifecycle did not finish dangling normalizer-owned rows. Confirm Codex/ACP
cancel of the same shape closes open tools and thinking via
`acpTurnNormalizer.FinishInterrupted`.
- Root cause:
Claude Code SDK first projected tool events without owning the shared turn
event lifecycle (`acpTurnNormalizer`), so cancel settled the turn without
`Finish*` and open tools never received terminal `call.failed`. A follow-on
gap kept thinking/assistant snapshots off that same normalizer: only tools
were tracked, so Stop could fail open Write cards while leaving an in-flight
thinking row at `streamState=streaming`.
- Fix:
Attach per-turn `acpTurnNormalizer` on the Claude SDK session. Track
`call.started/completed/failed` against that normalizer, route thinking and
assistant snapshots through the same normalizer, and call
`FinishInterrupted` / `FinishFailed` / `FinishCompleted` as part of turn
terminalization (`Cancel`, sidecar `turn_*`, reader failure). Drop late tool
events after the turn is already settled.
Also: controller Cancel cancels the Exec context before `adapter.Cancel`.
Claude Exec unregisters its waiter on that context cancel, so Cancel must
finish open tools/streams from the turn-normalizer map (not only live
waiters). The controller Exec context-canceled path must retain those
adapter-produced close events via `retainTurnCallLifecycleEvents` — not only
`call.failed`, but also failed/completed assistant/thinking message
snapshots — instead of replacing the whole event slice with a bare
turn.canceled. Otherwise FinishInterrupted runs in Exec, then the controller
drops the thinking settlement and the durable row stays `streaming`.
- Validation:
`go test ./packages/agent/daemon/runtime -run 'TestClaudeCodeSDKAdapter(CancelFailsOpenToolCalls|CancelFailsOpenToolsAfterWaiterUnregistered|TurnCanceledFailsOpenToolCalls|CancelFailsOpenThinking|MapsThinkingEvents)|TestRetainTurnCallLifecycleEvents'`.
Manually: rebuild/restart desktop so `tuttid` includes the fix, then start
Claude Code, stop during thinking and during a long Write; confirm thinking
leaves the active state and the tool card leaves "in progress". In
`~/.tutti-dev/tuttid.db`, the reasoning message status should leave
`streaming` after cancel.
- References:
[claude_sdk_turn.go](../../../packages/agent/daemon/runtime/claude_sdk_turn.go)
[claude_sdk_events.go](../../../packages/agent/daemon/runtime/claude_sdk_events.go)
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
[controller_turn_exec.go](../../../packages/agent/daemon/runtime/controller_turn_exec.go)
[controller_turn_state.go](../../../packages/agent/daemon/runtime/controller_turn_state.go)
[acp_turn_normalizer.go](../../../packages/agent/daemon/runtime/acp_turn_normalizer.go)
[acp_turn_normalizer_snapshots.go](../../../packages/agent/daemon/runtime/acp_turn_normalizer_snapshots.go)
### Claude Code starts another command after Stop
- Symptom:
User stops a Claude Code turn after a Bash command becomes a background task.
The turn settles as canceled, but background completion is followed by a new
assistant continuation, approval, or Bash command such as `ls`. The new
provider id may be synthetic even though no sub-agent exists.
- Quick checks:
Correlate provider session id, canonical root turn id, and sidecar events. If
a background `task_notification` arrives after `turn_canceled`, followed by
synthetic `turn_started` or `approval_requested`, inspect SDK Query lifetime.
Confirm the exported session has no child-session relation before classifying
the continuation as a sub-agent.
- Root cause:
Claude Agent SDK `interrupt()` stops current query execution but does not
terminate the Query or all background resources. Reusing that Query lets a
late background completion trigger another root inference. Filtering the
synthetic event in AgentGUI or daemon persistence is too late: provider code
may already have requested or executed the tool, especially under
`bypassPermissions`.
- Fix:
Treat each SDK Query as an execution generation. Cancel revokes the generation
before calling the SDK, rejects its pending interactions, awaits the interrupt
acknowledgment, then closes it in cleanup. Fence messages, hooks, and
`canUseTool` by generation identity. Give the next real user prompt a fresh
prompt queue and Query using `resume: providerSessionId`. Consume a canceled
generation's replayed terminal task notification and paired result before
they can settle the new canonical Turn. Keep normal non-canceled child
completion continuations enabled. As defense in depth, reject a new pending
Interaction whose canonical owning Turn is already settled.
- Validation:
Run `pnpm --filter @tutti-os/claude-sdk-sidecar test` and
`go test ./packages/agent/store-sqlite -run 'TestUpsertInteractionRejectsNewPendingRequestOnSettledTurn'`.
Cover default and `bypassPermissions`, assert no synthetic turn, approval, or
tool permission after cancel, then assert a new real prompt resumes the same
provider session.
- References:
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts)
[queryGeneration.ts](../../../packages/agent/claude-sdk-sidecar/src/queryGeneration.ts)
[activity_turns.go](../../../packages/agent/store-sqlite/activity_turns.go)
### Claude Code follow-up, settings, or cancel-resend becomes silent
- Symptom:
A completed Claude Code conversation accepts a follow-up but emits no SDK
frames. Changing model, reasoning, or speed can time out and make later sends
appear disabled. Canceling immediately after submit can leave the next send
rejected as `provider_session_not_established` even though the provider
Session exists.
- Quick checks:
Compare the sidecar request with the SDK Query generation. A follow-up on the
same quiet post-result generation, or `apply_settings` waiting inside that
generation, identifies the idle-query path. For cancel-resend, compare the
canonical Turn settlement with its durable provider-Turn binding; settlement
before the binding explains `HasSettledTurn && !Established`.
- Root cause:
A naturally completed SDK iterator was treated as a terminal Session, while
follow-up and live settings reused or mutated the now-quiet Query. Separately,
cancel could settle the canonical Turn before provider acceptance crossed the
durable identity barrier. The frontend could then race another send against
an unsettled settings write.
- Fix:
Keep Session lifetime separate from Query-generation lifetime. Retire an idle
post-turn Query and resume a fresh generation for the next prompt; serialize
settings flag application with turn dispatch and retire the idle generation
before a live settings mutation. The workspace Engine and composer gate both
block send while the settings operation is unsettled. For cancel, carry the
exact Turn ID to the sidecar and dispatch the native cancel before consulting
acceptance state. The sidecar returns `pre_accept`, `provider_active`,
`absent`, or `mismatch`: locally remove only an undispatched queue item; after
dispatch, publish the canceled terminal only after the bounded Query shutdown
protocol either receives the SDK interrupt acknowledgment or closes the owned
transport and drains its consumer. Wait for the exact Turn's durable
provider-acceptance outcome only for `provider_active`. Treat only `absent` as
authoritative not-found; mismatch, unknown disposition, drain failure, and
acceptance failure stay fail-closed.
- Validation:
Cover follow-up resume, settings timeout/retry gating, exact targeted cancel,
same-tick Goal cancellation, cancel before dispatch, interrupt acknowledgment,
missing acknowledgment, interrupt failure, consumer-drain timeout, durable
acceptance success and failure, mismatched/absent targets, and cancel followed
by a new send. Native guidance must interrupt active tool work before enqueueing
the steering prompt.
- References:
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts)
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
[controller_exec.go](../../../packages/agent/daemon/runtime/controller_exec.go)
[promptQueue.reducer.ts](../../../packages/agent/activity-core/src/engine/promptQueue.reducer.ts)
[agentGuiComposerGate.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiComposerGate.ts)
### AgentGUI freezes when session history is large
- Symptom:
The workspace renderer freezes, tears visually in screen recordings, or feels
stuck while opening AgentGUI or submitting an agent prompt in a workspace with
a long agent history.
- Quick checks:
Inspect developer logs for `agent.gui.runtime.snapshot_changed` diagnostics.
If `sessionCount` is in the hundreds or thousands, check whether the desktop
adapter is calling `listWorkspaceAgentSessions` without a `limit`.
- Root cause:
Unbounded session-list loads push every historical agent session into
`AgentGUIRuntime`, and each live event can make AgentGuiNode rebuild
conversation projections for history the visible rail does not need.
- Fix:
Keep broad runtime session-list requests bounded at the desktop adapter or
daemon API boundary. Use targeted message/session fetches for the selected
detail rather than widening the runtime snapshot.
- Validation:
Reproduce with a large session table and confirm runtime diagnostics report a
bounded `sessionCount`. Run the desktop adapter tests and `pnpm check:changed`
for mixed AgentGUI/desktop changes.
- References:
[desktopAgentActivityAdapter.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/desktopAgentActivityAdapter.ts)
[createDesktopAgentActivityRuntime.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/createDesktopAgentActivityRuntime.ts)
### AgentGUI @ Sessions tab is empty
- Symptom:
Opening the composer `@` palette (default Sessions tab) shows no session
rows, even though the workspace has agent history.
- Quick checks:
In `tuttid.log`, look for `event=workspace.agent_session.api.list_completed`
from `GET /v1/workspaces/{workspaceID}/agent-sessions` (the Sessions-tab
source). Check `session_count`. Do not confuse it with
`workspace.agent_session.messages.api.list_*` or
`workspace.agent_session.section.list_failed`.
- Root cause:
The Sessions tab loads through `listWorkspaceAgentSessions`. Successful calls
previously left no durable log, so empty palettes could not be distinguished
from "API never ran" or "API returned zero sessions" in exported logs.
- Fix:
Successful list responses now emit
`workspace.agent_session.api.list_completed` with `session_count`. If the
event is missing, the client never hit the endpoint; if `session_count=0`,
the daemon truly returned an empty list.
- Validation:
Click the composer `@` button, confirm a
`workspace.agent_session.api.list_completed` line appears with a non-zero
`session_count` when sessions exist.
- References:
[daemon_agent_session_list.go](../../../services/tuttid/api/daemon_agent_session_list.go)
[desktopRichTextAtAgentContributors.ts](../../../apps/desktop/src/renderer/src/features/rich-text-at/services/internal/desktopRichTextAtAgentContributors.ts)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### AgentGUI @ browse fails after reopening
- Symptom:
The AgentGUI `@` palette opens once, then shows a search failure after the
palette is closed and reopened, even though the workspace request itself is
healthy.
- Quick checks:
Inspect the AgentGUI lifecycle diagnostics for a `browse.fetch.start` entry
followed by `AbortError` on the next open. The failing request usually has
the same browse key as the request canceled when the first palette closed.
- Root cause:
A shared browse request was kept in its deduplication map until its promise's
asynchronous `finally` cleanup ran. A new consumer could arrive in that
window and attach to the already-aborted request, turning expected palette
cancellation into a visible search failure.
- Fix:
Use `AbortableSingleFlight` for shared abortable requests. Evict the entry
synchronously when its final consumer leaves, and make late cleanup delete
only the exact request instance. An operation owner may also abort a stuck
request immediately without leaving a retryable stale entry.
- Validation:
Cover concurrent deduplication, one-consumer cancellation, immediate retry
after final-consumer cancellation, operation-owner timeout cancellation, and
late completion of the old request after a replacement starts.
- References:
[abortableSingleFlight.ts](../../../packages/agent/gui/shared/query/abortableSingleFlight.ts)
[AgentMentionSearchCache.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/AgentMentionSearchCache.ts)
### Agent diagnostics flood while a turn is streaming
- Symptom:
Exported developer logs are dominated by agent diagnostics and the app feels
sluggish while a streaming turn is active or while switching AgentGUI sessions.
- Quick checks:
Count repeated `agent submit trace`, `agent.activity.reconcile.trace`, and
`agent.gui.node.render_state_changed` lines before blaming one visible click.
Runtime event emissions should appear as
`runtime.events_emitted.summary`/`runtime.async_events_emitted.summary`;
the old `runtime.events_emitted`/`runtime.async_events_emitted` names must not
appear at the default level. Successful reconcile steps, message-page reads,
ACP transport frames, and unchanged CuaDriver polls should appear only when
debug logging is enabled. A streaming message-version change alone must not
emit `agent.gui.node.render_state_changed` or
`agent.gui.runtime.snapshot_changed`.
- Root cause:
Per-token runtime events and renderer inline reconcile commits can produce
thousands of diagnostic writes. Those writes compete with rendering and also
inflate trace/log exports enough to obscure the actual session-switch work.
A later AgentGUI refactor can reintroduce this problem by replacing turn
summaries with per-batch logs or by adding message cursors to diagnostic
change keys.
- Fix:
Aggregate runtime emissions once per turn. Keep successful reconcile,
message-page, and ACP frame diagnostics at debug. Build renderer snapshot and
render-state keys from semantic lifecycle/interaction state rather than
streaming cursors. Log unchanged permission-poll results at debug, and keep
desktop log writes ordered through the asynchronous file writer.
- Validation:
Reproduce a streaming turn at the default info level. Confirm each turn has
at most one runtime emission summary, semantic render/snapshot diagnostics do
not advance for token-only updates, and reconcile/ACP frame diagnostics are
absent. Repeat with debug enabled when per-frame evidence is needed. Submit,
reconcile, and protocol failures must remain visible.
- References:
[controller_turn_exec.go](../../../packages/agent/daemon/runtime/controller_turn_exec.go)
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[desktopAgentRuntimeStateDiagnostics.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/desktopAgentRuntimeStateDiagnostics.ts)
[useAgentGUISessionPresentation.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUISessionPresentation.ts)
### Claude export leaks hidden data, flattens branches, or resumes as Claude Code
- Symptom:
Imported claude.ai history shows internal thinking/tool payload text, mixes
mutually exclusive edited/retried messages into one timeline, or the composer
tries to resume an imported web conversation as if its UUID were a local
Claude Code session id.
- Quick checks:
Inspect the source message shape without logging its content. Visible text
must come from ordered `content[type=text]` blocks; legacy human messages
with no content blocks may fall back to top-level `message.text`, but
assistant messages never may (that field mixes in hidden thinking and tool
material). Inspect the persisted runtime context for
`externalImportResumeSupported: false`, and confirm the source path is not
forwarded as an external Claude Code rollout path. For a branched fixture,
confirm every imported message belongs to the selected latest leaf's ancestor
path and carries the same `sourceBranchLeafId`.
- Root cause:
Claude data exports use top-level `message.text` as a convenience aggregate
that can include hidden thinking and tool material. Their conversation UUIDs
belong to claude.ai, not the local Claude Code runtime, and referenced files
in `conversations.json` do not imply that file payloads exist in the ZIP.
`chat_messages` is a parent graph rather than a guaranteed linear list, so
timestamp-sorting every node can combine incompatible sibling branches.
- Fix:
Parse only the exact root `conversations.json` entry without extracting the
archive. Project visible text from text blocks, keep file-only messages as
unavailable references, select one deterministic latest root-to-leaf branch,
seed persisted message ids from source UUIDs, and include the selected sibling
choices in imported session identity so a future retry-branch change cannot
append into the old branch. Place sessions in the no-project Chats section and
mark them non-resumable while preserving the normal continue-in-new-chat
recovery action.
- Validation:
Run `cd services/tuttid && go test ./service/agent ./api -run
'ExternalImport|ClaudeExport|ExternalRollout'`, then run
`pnpm --filter @tutti-os/desktop test` and `pnpm check:i18n`.
- References:
[external_import_claude_export.go](../../../services/tuttid/service/agent/external_import_claude_export.go)
[ExternalAgentSessionImportWizard.tsx](../../../apps/desktop/src/renderer/src/features/workspace-workbench/ui/ExternalAgentSessionImportWizard.tsx)
[service_session.go](../../../services/tuttid/service/agent/service_session.go)
### Imported long Turns show one disclosure per tool call
- Symptom:
A long imported Codex or Claude Code execution shows many adjacent
`1 tool call` disclosures instead of one grouped Turn, especially after
opening or paging into the middle of the conversation.
- Quick checks:
Inspect the affected `workspace_agent_messages` rows without printing
payload text. If the user/tool/assistant rows all have `turn_id = NULL`, then
compare the loaded page with the source transcript. A page that starts after
the initiating user message cannot recover the Turn boundary locally.
- Root cause:
The external importer historically used the turnless compatibility path for
every message. AgentGUI can make a temporary presentation group when the
leading user row is loaded, but that grouping cannot survive a page boundary;
assigning each orphan row from its current page position would manufacture
lifecycle identity in the renderer.
- Fix:
At the provider transcript boundary, start a stable historical Turn from each
retained real user message and carry it through following assistant and tool
messages. Persist the messages with settled backfilled Turns atomically.
The forward-only store migration repairs existing imported rows, while a
later re-import applies the same identity to any still-turnless rows. Content
before the first trustworthy user boundary remains turnless.
- Validation:
Run `go test ./packages/agent/store-sqlite -run HistoricalImport`, then
`cd services/tuttid && go test ./service/agent -run
'ServiceImportsExternalAgentSessionsByProject|ServiceReimportRepairsLegacyTurnlessExternalMessages'`.
- References:
[external_import.go](../../../services/tuttid/service/agent/external_import.go)
[activity_historical_import_turns.go](../../../packages/agent/store-sqlite/activity_historical_import_turns.go)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Imported sessions trigger fresh-completion indicators
- Symptom:
After importing Codex or Claude Code history, Agent GUI conversation rows show
unread-completion lamps, or Message Center's priority view briefly shows many
items under the recently-completed group, even though those sessions are
historical imports rather than newly finished local runs.
- Quick checks:
Inspect the session `runtimeContext`. Imported sessions should carry
`imported: true`. Conversation summaries and Message Center items derived from
them should preserve that marker before unread-completion or priority grouping
is derived.
- Root cause:
Agent GUI unread-completion lamps and Message Center's recently-completed
group are notification-style surfaces. Imported history is persisted as
completed agent activity, so if projection models treat imported sessions the
same as live runtime completions, a bulk import can look like a burst of fresh
completed work.
- Fix:
Keep imported sessions visible in Agent GUI, Message Center, and completed
filters, but exclude `runtimeContext.imported` items from unread-completion
lamps and recently-completed groups.
- Validation:
For Agent GUI rail projection changes, run
`pnpm --dir packages/agent/gui exec vitest run --environment jsdom contexts/workspace/presentation/renderer/agentGuiConversationList/useAgentGuiConversationList.spec.tsx agent-gui/agentGuiNode/model/agentGuiConversationModel.spec.ts`.
For Message Center grouping changes, run
`pnpm --dir packages/agent/gui exec vitest run --environment jsdom agent-message-center/workspaceAgentMessageCenterModel.spec.ts agent-message-center/workspaceAgentMessageCenterViewModel.spec.ts`.
- References:
[useAgentGuiConversationList.ts](../../../packages/agent/gui/contexts/workspace/presentation/renderer/agentGuiConversationList/useAgentGuiConversationList.ts)
[workspaceAgentMessageCenterModel.ts](../../../packages/agent/gui/agent-message-center/workspaceAgentMessageCenterModel.ts)
[workspaceAgentMessageCenterViewModel.ts](../../../packages/agent/gui/agent-message-center/workspaceAgentMessageCenterViewModel.ts)
### Realtime agent completion does not show unread attention
- Symptom:
A turn settles while Agent GUI is open, but its conversation row never shows
unread-completion attention. Historical sessions may behave correctly and
must not acquire attention merely because their snapshot was loaded.
- Quick checks:
Trace the event path into the activity engine. A realtime `turn_update`
should enter as one atomic Turn projection. When the Session is cached, the
same Engine transition must update the Turn and update or clear
`Session.activeTurnId` without rewriting Session timestamps from the event
envelope. Attention must observe the post-lifecycle canonical Turn, not the
raw event. A delayed settled event may clear only the matching active Turn.
The event should also request a state-only session reconcile with realtime
provenance. Initial, restored, and imported history should enter through
`session/snapshotReceived` only. Also confirm the projected desktop session
carries the shared local Agent GUI user id used by read-state actions.
- Root cause:
Realtime and historical data lost their provenance when both were folded into
a mutable controller snapshot and re-emitted as `session/snapshotReceived`.
The attention reducer correctly treats snapshots as non-live, so it recorded
the settled completion without producing unread attention; a later live
update with the same completion key could no longer recover the transition.
- Fix:
Keep the activity engine as the single mutable owner. Feed pull/bootstrap
results through `session/snapshotReceived`, and feed each realtime
`turn_update` through one intent that atomically projects the Turn and cached
Session reference. Reject a projection whose live/settled phase disagrees
with `activeTurnId`; reconcile instead of applying half of the wire fact.
Fence stale Session snapshots with canonical Turn versions rather than
event-envelope time, and let attention consume only a Turn that the lifecycle
reducer accepted.
Keep the realtime marker on the follow-up reconcile so an uncached Session
can hydrate and replay the latest Turn with live attention semantics. Use
inline message events only for message deltas and one shared local identity
for session projection and read-state commands.
- Validation:
Run
`pnpm --filter @tutti-os/desktop test -- workspaceAgentActivityService.test.ts`
and verify the service integration coverage proves that realtime completion
becomes unread while a settled historical load creates no attention record.
- References:
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[workspaceAgentActivityService.test.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityService.test.ts)
[attentionReadState.reducer.ts](../../../packages/agent/activity-core/src/engine/attentionReadState.reducer.ts)
### Unfocused AgentGUI Session completion becomes read
- Symptom:
A running Session settles after the user focuses another AgentGUI node or
window, but the original Session immediately loses its unread-completion lamp.
- Quick checks:
Inspect `agent.gui.attention_read.decision`. The diagnostic identifies the
node, completion key, host focus, document exposure, visibility, and whether
the completion was read or preserved. A retained node with
`isSurfaceActive=false`, `isSurfaceVisible=false`, or
`isSurfaceDocumentExposed=false` must report `decision=preserve_unread`; its
local `activeConversationId` is not evidence that the user is reading it.
- Root cause:
AgentGUI selection is local to each mounted controller, while attention/read
state is shared by the workspace engine. Treating every controller's active
Session as visually selected allowed a hidden or unfocused retained node to
dispatch the shared `attention/read` intent. Separately, historical Turns
without a durable marker were assigned `isUnread=false` and persisted into
`readIds`; a stale list/detail response could therefore turn an unseen live
completion into durable read state.
- Fix:
Use unread-only attention state. Only a live completion transition or an
explicit unread request creates an unread record; `attention/read` removes
that record, and historical snapshots never mutate attention. Hydration
ignores legacy `readIds` and the next write clears those buckets. Pass
host-projected focus, host visibility, and renderer document focus/visibility
into the selection controller, and require all three before dispatching
`attention/read`. Keep the existing manual unread provenance rule so a
user-marked unread completion stays unread until the Session is selected
again.
- Validation:
Keep two AgentGUI nodes mounted, run a Turn in the unfocused node's active
Session, and verify its completion key remains in `completed.unreadIds`.
Focusing and exposing that node should remove the exact key while leaving
`completed.readIds` empty.
- References:
[AgentGUINode.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/AgentGUINode.tsx)
[useAgentGUIConversationSelectionController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationSelectionController.ts)
### Completed agent session stays activating and disables the composer
- Symptom:
A new conversation visibly completes and its assistant reply is present, but
opening it leaves the composer disabled. Roughly one activation-expiry window
later, AgentGUI reports that the agent session could not be started.
- Quick checks:
Correlate activation diagnostics with the authoritative session updates. If
the session create and turn both succeeded while the presentation remains
`activating` until `engine/intentExpired`, inspect which session intent
reached the pending-activation reducer. Also check that a failed realtime
session fetch preserves `live: true` on the Engine's pending reconcile
record before a retry.
- Root cause:
An engine migration introduced `session/upserted` for authoritative mutation
and realtime results, while pending activation still confirmed only from the
historical `session/snapshotReceived` path. The canonical session therefore
existed and could render, but the independent activation intent expired and
overrode the composer with a false failure. Consuming realtime provenance
before a fallible fetch can produce a related retry-only mismatch.
- Fix:
Confirm activation from both authoritative session intents. Preserve the
semantic distinction only where it matters: historical snapshots remain
neutral for unread attention, while realtime reconciliation additionally
emits the live turn update. Carry realtime provenance on the Engine-owned
reconcile command, merge it into in-flight demand, and restore it to pending
demand after fetch failure until a live session is applied or the session is
deleted. Hosts must not keep a parallel marker set.
- Validation:
Cover the reducer with a pending activation followed by `session/upserted`.
At the desktop service boundary, run a real engine activation through the
create result, manually expire its old deadline, and verify the presentation
remains active. Also fail the first realtime reconciliation, retry it, and
verify the settled turn still gains unread attention.
- References:
[pendingIntents.reducer.ts](../../../packages/agent/activity-core/src/engine/pendingIntents.reducer.ts)
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[workspaceAgentActivityService.test.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityService.test.ts)
### Forked conversation disappears from the Rail after completion
- Symptom:
A newly forked conversation is visible and selected while its first Turn is
running, but disappears from the Rail as soon as that Turn settles. Opening
the exact Session still works, and a later full Rail reload may restore it.
- Quick checks:
Correlate `session/forkThroughTurnRequested`, the canonical child
`session/upserted`, and the bounded `listSessionSectionPage` request. If the
child becomes canonical while selected but no exact request is made for its
inherited `railSectionKey`, inspect pending-creation membership rather than
the fork persistence path. Also verify that the child was initially visible
only through the active or running overlay.
- Root cause:
Fork selected the child immediately, while membership reconciliation skipped
every newly observed active Session to avoid refreshing for historical detail
hydration. The fork child therefore never entered its bounded authoritative
section page. Its active/running overlay temporarily hid that omission and
settlement exposed it by removing the overlay row.
- Fix:
Project in-flight fork targets and pending activations as pending-creation
reconciliation evidence. Keep that semantic marker separate from render
provenance. When the same identity becomes canonical, refresh its exact
persisted Rail section even when selected; continue skipping ordinary
selected historical hydration when no pending-creation evidence exists.
- Validation:
Drive a real `AgentSessionEngine` and
`AgentGUIConversationRailQueryController` through a held fork command,
select the target before canonical upsert, then verify the controller requests
the inherited section and publishes the child in authoritative membership.
Retain the existing selected historical-Session no-refresh coverage.
- References:
[agentGuiConversationRailMembershipRecords.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiConversationRailMembershipRecords.ts)
[agentGuiConversationRailMembershipRefresh.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiConversationRailMembershipRefresh.ts)
[AgentGUIConversationRailQueryController.spec.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/AgentGUIConversationRailQueryController.spec.ts)
### Shared Agent composer stays disabled after the target connects
- Symptom:
A shared Agent target reaches `connected`, and diagnostics show submission
readiness has recovered, but the entire composer remains disabled and cannot
receive focus or input. This differs from an empty draft disabling only the
send button.
- Quick checks:
Inspect one rendered Composer gate snapshot. Its runtime, editor, and
submission branches must agree: a ready submission cannot coexist with a
target-connection runtime block. If logs instead compare fields from separate
Composer and readiness projections, inspect view-model memoization before
debugging P2P transport.
- Root cause:
Connection and submission facts were projected into independent memoized
slices. A missing dependency could retain the old target-connection block
while publishing the new submission-ready value, and a downstream detail
model then recombined those two render-time generations into a torn
`composerDisabled` decision.
- Fix:
Derive the canonical Composer gate once at the Session-presentation boundary.
Keep editor editability, submission readiness/queue/blocking, and
runtime-command availability in that one object, then pass it through one
view-model slice to the editor, send button, shortcuts, Stop control, and
Interaction paths. Keep draft-empty and upload conditions submission-local.
- Validation:
Drive an exact shared target from `connecting` to `connected` and assert the
same resulting snapshot reports runtime ready, editor editable, and
submission ready. Also cover busy queue behavior, collaborator read-only
behavior, and the invariant that submission ready never retains a runtime
connection block.
- References:
[agentGuiComposerGate.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/agentGuiComposerGate.ts)
[useAgentGUISessionPresentation.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUISessionPresentation.ts)
[useAgentGUIViewModel.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/model/useAgentGUIViewModel.ts)
### AgentGUI submit clears the composer but creates no session or turn
- Symptom:
Sending from AgentGUI clears or switches the composer, but the conversation
rail and transcript do not change. Renderer diagnostics stop at
`renderer_adapter.create.http_requested` or
`renderer_adapter.send.http_requested`, and the daemon has no matching
`clientSubmitId`.
- Quick checks:
Correlate `clientSubmitId` across the desktop and daemon logs. If the engine
records an immediate failed activation while the daemon has no create/send
business log, compare the adapter's exact JSON body with the generated
request type and the OpenAPI schema, including `additionalProperties`.
- Root cause:
A conditional object spread can add a stale property to an otherwise typed
request without triggering excess-property checking. Strict OpenAPI request
validation then rejects the body before the business handler, while eager
composer clearing makes the failed request look successful for an instant.
- Fix:
Keep `clientSubmitId` as the top-level idempotency field and carry optional
evidence through the typed `submitDiagnostics` contract from AgentGUI through
the session engine to the desktop adapter. Assign the final body to the
generated request type before sending. Clear a draft only after the engine
queues, accepts, or confirms the exact submitted content; failed sends retain
the draft.
- Validation:
Assert the adapter's complete create/send body with generated request types,
verify the generated client serializes `submitDiagnostics`, and cover that
Composer does not clear before its parent applies engine acknowledgment.
- References:
[agent-gui-node.md](../architecture/agent-gui-node.md)
[desktopAgentActivityAdapter.ts](../../apps/desktop/src/renderer/src/features/workspace-agent/services/desktopAgentActivityAdapter.ts)
[useAgentGUISubmitInteractionActions.ts](../../packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUISubmitInteractionActions.ts)
### AgentGUI send reports `workspace_agent.send_response_turn_required`
- Symptom:
Sending or continuing a conversation reaches the provider, but the renderer
rejects the HTTP result with `workspace_agent.send_response_turn_required`.
A realtime reconcile may make the conversation recover moments later, so the
failure can appear release- or timing-dependent.
- Quick checks:
Correlate one `clientSubmitId` across `runtime.submitted`,
`api.send.completed`, and the following `turn_update`. If the API completes
with a submitted runtime Turn while its session projection is still settled,
then the response raced the asynchronous activity report. Inspect the raw
response: a Turn-producing branch missing either `turnId` or `turn` violates
the protocol even if realtime state arrives afterward.
- Root cause:
`RuntimeController.Exec` queued the submitted Turn report asynchronously,
started provider work, and returned. The service immediately refreshed the
older durable Session, while the API conditionally copied `session.activeTurn`.
The exact Turn therefore disappeared from an otherwise successful response.
- Fix:
Make the submitted Turn report a synchronous durable-acceptance barrier.
Commit the session pointer and Turn atomically before publishing or starting
provider work; roll runtime state back on report failure. Read the exact Turn
by the runtime-returned `turnId` in the service, and model send output as an
OpenAPI discriminated union whose Turn branch requires both fields. Keep Goal
control Turn-less. Do not add renderer polling, sleeps, or synthetic Turns.
- Validation:
Block the activity reporter and prove both `Exec` and provider startup wait;
fail it and prove no provider execution or active Turn survives. Cover stale
Session plus exact-Turn service reads, required API Turn output, and the
Turn-less Goal-control branch. Run API generation drift checks and the Agent
session engine suite.
- References:
[agent-gui-node.md](../../architecture/agent-gui-node.md)
[controller_exec.go](../../../packages/agent/daemon/runtime/controller_exec.go)
[service_send_input.go](../../../services/tuttid/service/agent/service_send_input.go)
[daemon_agent_submit_handlers.go](../../../services/tuttid/api/daemon_agent_submit_handlers.go)
### AgentGUI new session times out and appears completed without a reply
- Symptom:
A new conversation shows the optimistic user prompt, then becomes idle or
completed with no assistant reply. The session has no canonical turn or
messages.
- Quick checks:
Correlate the create `clientSubmitId`. A characteristic sequence is desktop
`renderer_adapter.create.failed errorCode=ETIMEDOUT` at 30 seconds, followed
by daemon `provider_runtime_status=failed`, `agent session is not connected`,
and a rejected turnless `visible-error` message.
- Root cause:
Runtime command-guide construction asked the CLI registry to run live
capability filters. The agent-context filter probes provider availability,
so static guide construction could consume the entire create-request budget.
Cancellation then reached provider startup. The failed startup was also
published as a durable session plus a message without a turn, creating a
phantom session that the UI could only project as idle/completed.
- Fix:
Build runtime command guides from static capability registration with live
capability filters skipped. Treat provider startup as transactional: return a
typed runtime error when the provider fails to start; for create-with-prompt,
keep the runtime Session provisional until the first Turn is accepted. A
failed or rolled-back attempt must not publish or store a canonical Session,
Turn, command/config snapshot, or turnless message.
- Validation:
Cover the non-blocking command-catalog context, typed failed-start mapping,
provisional provider callback isolation and rollback, and controller behavior
proving that startup failure returns diagnostics but creates no canonical
session or activity report.
- References:
[command_catalog.go](../../services/tuttid/service/agentsidecar/command_catalog.go)
[controller_session_lifecycle.go](../../packages/agent/daemon/runtime/controller_session_lifecycle.go)
[agent_runtime_adapter.go](../../services/tuttid/agent_runtime_adapter.go)
### AgentGUI cannot stop Cursor immediately after the first message
- Symptom:
The first Cursor message enters an activating state, but Stop is absent or
does nothing until a canonical session and Turn appear. Codex and Claude Code
often appear unaffected because their startup completes before a user can hit
the same window.
- Quick checks:
Correlate the activation request with process spawn, ACP `initialize`,
`session/new`, and the first Turn. If Stop becomes available only after the
Turn id is known, the UI and engine are treating cancellation as turn-only.
Also inspect failed-create cleanup after request cancellation for provisional
runtime state left under the session id.
- Root cause:
New-session activation is an abortable engine command before it becomes a
canonical Turn. A turn-only Stop gate cannot target that command, and a cancel
operation stored only under existing session entities is lost on an empty
reconciliation snapshot. Cleanup that reuses the canceled request context
can then fail to close the provisional runtime.
- Fix:
Dispatch one provider-neutral session Stop intent. Abort the exact activation
command by command id, preserve a detached workspace/session-scoped
`awaitingTurn` cancel through snapshots, and convert it to exact-turn cancel
when the first Turn arrives. Expire the detached operation after a bounded
window. Run failed-create close and cleanup with a bounded context detached
from request cancellation.
- Validation:
Cover immediate activation abort, empty-snapshot preservation, late first-Turn
cancel, expiry before an unrelated future Turn, and identical lifecycle
behavior for Cursor, Codex, and Claude Code. Verify AgentGUI exposes Stop
while new-conversation activation is still submitting.
- References:
[createAgentSessionEngine.ts](../../../packages/agent/activity-core/src/engine/createAgentSessionEngine.ts)
[sessionLifecycle.reducer.ts](../../../packages/agent/activity-core/src/engine/sessionLifecycle.reducer.ts)
[pendingIntents.reducer.ts](../../../packages/agent/activity-core/src/engine/pendingIntents.reducer.ts)
[useAgentGUIDetailModel.tsx](../../../packages/agent/gui/agent-gui/agentGuiNode/view/useAgentGUIDetailModel.tsx)
[service.go](../../../services/tuttid/service/agent/service.go)
### Cursor session/new is canceled before its 30-second timeout
- Symptom:
Sending the first message to Cursor leaves AgentGUI activating until Tutti
reports a timeout. Cursor logs show `session/new` started, but it is canceled
substantially before the ACP runtime's configured 30-second deadline.
- Quick checks:
Correlate `service.create.entered`, provider-status completion,
`runtime_start_requested`, ACP `initialize`, and `session/new`. If a full
provider-status probe runs inside `Service.Create`, or the renderer activation
expires 30 seconds after the click rather than 30 seconds after `session/new`
starts, the protocol call is receiving only a leftover budget.
- Root cause:
Provider readiness was treated as a per-session precondition even though it
performs application-scoped binary, version, and auth detection. Cursor auth
fallback commands can consume several seconds before ACP starts. An outer
30-second activation timer then propagates cancellation into the daemon and
truncates the independent 30-second `session/new` timeout.
- Fix:
Cache provider readiness in `tuttid` per provider with completion-time TTL and
single-flight refresh. Explicit setup refreshes bypass that cache; ordinary
session creation does not run status, version, auth, network, or hidden model
discovery. Let the actual process spawn and ACP handshake be authoritative.
Give new-session activation a larger outer safety deadline while preserving
the ACP runtime's own 30-second `session/new` deadline.
- Validation:
Cover cache reuse across all-provider and single-provider requests, forced
refresh, concurrent single-flight reads, absence of availability checks from
`Service.Create`, and a new-session activation timeout larger than 30 seconds.
- References:
[status_cache.go](../../../services/tuttid/service/agentstatus/status_cache.go)
[service.go](../../../services/tuttid/service/agent/service.go)
[pendingIntents.reducer.ts](../../../packages/agent/activity-core/src/engine/pendingIntents.reducer.ts)
[acp_shared.go](../../../packages/agent/daemon/runtime/acp_shared.go)
### Claude Code keeps returning ConnectionRefused after network recovery
- Symptom:
A Claude Code Turn loses network access while sending and eventually reports
`API Error: Unable to connect to API (ConnectionRefused)`. After the machine
network recovers, later sends in the same Session keep failing, while a newly
created Claude Session succeeds.
- Quick checks:
Correlate `agent_session.claude_sdk.lifecycle_event` by Agent Session and
provider Session id. An SDK `system/api_retry` event now records
`sdk_connection_error`, `sdk_retry_attempt`, `sdk_max_retries`, and
`sdk_retry_delay_ms`. A terminal result should carry
`sdk_result_is_error=true`; a numeric `sdk_api_error_status` instead points to
an HTTP/auth/provider response rather than a connection failure.
- Root cause:
The Claude Agent SDK owns one long-lived Query and Claude Code subprocess for
streaming multi-Turn input. A connection failure can terminate the Turn while
leaving that Query unsuitable for later requests. Reusing it preserves the
bad per-process state even though host and VM networking have recovered.
Older sidecars also treated every `result/subtype=success` as a completed
Turn, ignoring the SDK's independent `is_error` flag.
- Fix:
Preserve the failed Turn and its provider error. When the SDK terminal result
explicitly reports a connection failure, revoke and close that Query. The
next user send creates a fresh Query with `resume` set to the same provider
Session id. Do not automatically resend the failed prompt because delivery
may be ambiguous. Do not recycle the Query for a transient retry that
eventually succeeds or for a terminal HTTP/authentication error.
This SDK-owned `api_retry` behavior is separate from AgentGUI presentation.
AgentGUI does not expose manual activation retry for a failed or canceled
Session. If authentication rejects the first submit, Host preserves the
failed history and discards that startup runtime; the auth notice offers
login, after which the user starts a new conversation.
Runtime discard keeps cleanup cancellation-independent but re-applies the
caller's deadline. If the Claude sidecar does not acknowledge close in time,
the adapter forcibly unregisters and closes the connection so a rejected
startup cannot remain live in the runtime registry.
- Validation:
Simulate `api_retry/error_status=null` followed by
`result/is_error=true/api_error_status=null`, then send a second Turn. Require
two Query generations, closure of the first, and `resume` on the second.
Also prove that retry-then-success and HTTP 401 failures retain the existing
Query. For rejected startup cleanup, block the sidecar close response and
require teardown at the caller deadline with no retained runtime Session.
- References:
[messageRouter.ts](../../../packages/agent/claude-sdk-sidecar/src/messageRouter.ts)
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts)
[sessionRuntime.recovery.test.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.recovery.test.ts)
### Cursor auto-continue invents interrupted work after a network drop
- Symptom:
After a Cursor `RetriableError` / TLS drop and Tutti's automatic
`transport_retry`, the agent does not answer the user's last message
(for example a simple greeting). Instead it talks about recovering prior
context, reading transcripts, or continuing an interrupted task that never
started.
- Quick checks:
In the session transcript, confirm the failed attempt produced only the
`Error: RetriableError:` / `Error: ConnectError:` tail (no useful assistant
text and no tool calls) before the retry notice. Check
`agent_session.acp.exec.auto_continue` in `tuttid` logs for
`has_useful_progress=false`.
- Root cause:
Cursor keeps conversation history on its backend; Tutti can mainly control the
synthetic auto-continue `session/prompt`. Mid-task wording
("Continue exactly where you left off") misleads the model when the attempt
died before any useful output.
- Fix:
Branch the auto-continue prompt by useful progress: zero-progress retries ask
the model to answer the user's most recent message normally and not invent
interrupted work; mid-task retries keep the continue wording. Progress is
assistant text after stripping the retriable error tail, or any observed tool
call.
- Validation:
`cd packages/agent/daemon && go test ./runtime/ -run
'TestACPAutoContinueHasUsefulProgress|TestACPAutoContinuePromptContentBranches|TestCursorAdapterAutoContinuesAfterRetriableTurnError|TestCursorAdapterAutoContinueMidTaskUsesContinuePrompt'`.
Live: send a short Cursor message that fails before any reply, confirm the
retry answers the user instead of recovering a phantom task, and that
mid-task drops still resume in place.
- References:
[acp_auto_continue.go](../../../packages/agent/daemon/runtime/acp_auto_continue.go)
[standard_acp_turn.go](../../../packages/agent/daemon/runtime/standard_acp_turn.go)
[acp_auto_continue_test.go](../../../packages/agent/daemon/runtime/acp_auto_continue_test.go)
### Canceling an old AgentGUI turn stops a newer turn
- Symptom:
A `cancelTurn(turnId)` request targets an older turn, but a newer turn in the
same session stops, or the requested turn is reported canceled even though
the runtime continued with a different active turn.
- Quick checks:
Compare `requested_turn_id` and `active_turn_id` in
`agent_session.cancel.turn_mismatch`. Also check whether a new `Exec` entered
between the exact-turn lookup and the provider cancel call.
- Root cause:
Session-level provider cancel APIs do not carry a turn id. Validating the id
before calling the adapter is insufficient unless validation and cancel are
protected by the same session lifecycle lock used to start turns.
- Fix:
Carry the requested turn id through HTTP service, runtime adapter, and
controller. Return an idempotent no-op on mismatch, and hold the per-session
lifecycle lock across the active-turn comparison and `adapter.Cancel` so a
new turn cannot enter the gap.
- Validation:
Cover both a mismatched active turn (the adapter must not be called) and a
blocking adapter cancel (a second lifecycle operation must remain blocked
until cancel returns).
- References:
[service_turns.go](../../services/tuttid/service/agent/service_turns.go)
[controller_cancel.go](../../packages/agent/daemon/runtime/controller_cancel.go)
[controller_test.go](../../packages/agent/daemon/runtime/controller_test.go)
### Historical Agent completions notify again when a workspace opens
- Symptom:
Opening a workspace produces completion or failure notifications for turns
that settled before the window was opened.
- Quick checks:
Compare notification-controller creation with the first agent activity
`load`. If the engine was empty at subscription time and the first populated
snapshot contains settled turns, verify those turns were treated as initial
hydration rather than live transitions.
- Root cause:
Taking an empty engine snapshot as the history baseline is insufficient when
durable loading happens asynchronously after subscribers are registered.
The first hydrated settled turn then looks indistinguishable from a newly
settled turn.
- Fix:
Keep outcome notifications behind an explicit hydration boundary. Record all
settled turns observed before the initial durable load resolves as baseline
history. After hydration, notify only the first observation of each
session-scoped turn key; a live non-settled turn can also establish readiness
when the initial load is unavailable.
- Validation:
Cover the real startup order: subscribe against an empty engine, hydrate a
historical settled turn without notifying, finish hydration, then verify a
later running-to-settled turn notifies exactly once.
- References:
[workspaceAgentOutcomeNotification.ts](../../apps/desktop/src/renderer/src/features/workspace-workbench/services/workspaceAgentOutcomeNotification.ts)
[workspaceAgentOutcomeNotification.test.ts](../../apps/desktop/src/renderer/src/features/workspace-workbench/services/workspaceAgentOutcomeNotification.test.ts)
### Agent GUI context usage is absent or has the wrong total
- Symptom:
The Agent GUI composer never shows context usage even though provider usage
logs are present. Alternatively, Claude Code GUI usage shows a 200k context
window for a model that should have 1M context, or a 200k model keeps showing
the prior 1M total after a model switch. After one Claude Code request runs
several tools, the used-token count may also jump from the latest iteration's
value to nearly the sum of every iteration in that turn. A related lifecycle
symptom is that Claude has rendered its final answer but Agent GUI remains in
the working state until a delayed context-usage control request returns.
- Quick checks:
Trace the provider update first: use
`agent_session.claude_sdk.usage_update` for Claude SDK and
`agent_session.acp.usage_update` for ACP providers. Then inspect the daemon
session response for its typed `usage.contextWindow` field and confirm the
desktop canonical session preserves it. If provider logs contain nonzero
used and total tokens but the API field is null, inspect the runtime-context
split into typed session metadata. If the API field is populated but the
footer is absent, inspect the desktop adapter and the active canonical
session passed to the composer capability projection. For Claude, if the
payload keys include
`modelUsage` but `raw_total_tokens` is `0`, the daemon did not parse the
model-usage context window. If `previous_context_model` and
`current_context_model` differ but `current_total_tokens` equals
`previous_total_tokens`, daemon usage normalization reused a stale context
window across models. For a tool-loop spike, compare the streamed
`normalized_used_tokens` values with the final Result update. If the final
value equals their sum, the cumulative Result usage was mistaken for current
context occupancy. For a stuck working indicator, compare the Claude SDK
`result` lifecycle timestamp with the later `context_window` usage update and
`turn_completed`; a long result-to-completion gap isolates telemetry on the
terminal path rather than unfinished provider work.
- Root cause:
Protocol v2 intentionally removed raw `runtimeContext` from the public
session model. If the refactor removes that legacy field without adding a
typed `usage` field across persistence, API generation, the desktop adapter,
and the canonical session, the provider still records correct telemetry but
Agent GUI has no public data to render. A wrong Claude total is a separate
normalization failure: Claude SDK result messages expose model usage as a map
keyed by model id, for example
`modelUsage["claude-sonnet-5"].contextWindow`. If either sidecar or daemon
only parses array-shaped `modelUsage`, the context-window total is missing and
daemon normalization falls back to 200k. Claude SDK Result `usage` is
cumulative across the model iterations in a client-side tool loop, whereas
each streamed usage update describes one iteration. The authoritative
`Query.getContextUsage()` snapshot must therefore win at Result time. Calling
that method after detaching it from the Query object loses its `this` binding;
if the best-effort error is swallowed, the cumulative fallback remains
visible as a false context spike. Conversely, awaiting a correctly bound
`getContextUsage()` call before emitting `turn_completed` makes optional
telemetry a lifecycle dependency and leaves the GUI working for the duration
of a slow control response.
- Fix:
Define usage in the protocol-v2 OpenAPI contract and carry it as typed durable
session metadata through the generated client, desktop adapter, canonical
activity session, and composer projection. Keep raw runtime context private
to provider recovery; do not restore a GUI runtime-context dependency.
Parse `modelUsage` recursively as both arrays and maps before using fallback
context-window values. Track the model associated with a cached context
window, and only reuse the previous total for the same model or when the model
is unknown. Do not hard-code alias-to-model mappings in Tutti. Invoke
`getContextUsage()` through its owning Query object after the Result has
terminalized the turn. Keep the authoritative snapshot and cumulative Result
fallback in an asynchronous telemetry path, and invalidate a delayed snapshot
when a newer snapshot request starts or a later root user prompt begins so
older usage cannot overwrite newer work.
- Validation:
Cover runtime-context splitting and metadata persistence, generated API
projection, desktop canonical-session adaptation, activity-core usage
resolution, and the composer hook.
Add sidecar and daemon coverage with map-shaped `modelUsage` carrying
`contextWindow: 1_000_000`, plus daemon coverage for Haiku -> Sonnet5 -> Haiku
usage updates where the last payload lacks `totalTokens`. Add a sidecar query
fixture whose `getContextUsage()` depends on `this`, and give its Result a
multi-iteration cumulative usage total; assert that only the authoritative
context snapshot is emitted. Also cover a context query that remains pending:
`turn_completed` must be emitted first, and a delayed snapshot must be dropped
after a newer snapshot request or the next root user prompt. Then run the
Claude SDK sidecar tests, daemon Go tests, AgentGUI tests, and typechecks.
- References:
[agent-activity-packages.md](../architecture/agent-activity-packages.md)
[session_metadata.go](../../packages/agent/store-sqlite/session_metadata.go)
[desktopAgentActivityAdapter.ts](../../apps/desktop/src/renderer/src/features/workspace-agent/services/desktopAgentActivityAdapter.ts)
[main.ts](../../packages/agent/claude-sdk-sidecar/src/main.ts)
[main.test.ts](../../packages/agent/claude-sdk-sidecar/src/main.test.ts)
[claude_sdk_adapter.go](../../packages/agent/daemon/runtime/claude_sdk_adapter.go)
[compaction.ts](../../../packages/agent/claude-sdk-sidecar/src/compaction.ts)
[messageRouter.ts](../../../packages/agent/claude-sdk-sidecar/src/messageRouter.ts)
[sessionRuntime.session.test.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.session.test.ts)
### Claude `/compact` finishes without a compaction divider
- Symptom:
After Claude Code `/compact`, AgentGUI shows only the turn duration footer
(for example `总用时 22 秒`) and never renders the
`Compacting context` / `Context compacted.` divider. Context usage may still
drop correctly.
- Quick checks:
Confirm the provider is Claude Code SDK. Inspect the Claude transcript under
`~/.claude/projects/.../<provider-session-id>.jsonl` for `compact_boundary`
or `<local-command-stdout>Compacted`. Check tuttíd for a
`claude-sdk:compact:<turnId>` system notice; if tokens fell but that notice is
missing, the sidecar never published `compact_started`.
- Root cause:
Claude Code 2.1.x often completes manual compaction without streaming
`status:compacting` or `compact_boundary` to the query iterator (the boundary
may exist only on disk, or arrive as `local_command` /
camelCase `compactMetadata`). Compaction banners were driven only by those
signals, so a silent success left no notice for AgentGUI to project. Even when
the sidecar emits `compact_started` immediately on `/compact`, that assistant
system notice arrives before provider-turn acceptance; the Claude acceptance
gate previously treated it as premature provider output and dropped it. The
daemon already settles an active compact notice when the turn closes; it needs
a held `compact_started` (or an adapter-emitted running notice) first. Flushing
those held events after acceptance must not re-observe their earlier provider
input units: that either regresses the Replay cursor or coalesces conflicting
`compaction.status` readiness onto the durable `turn.working` checkpoint.
- Fix:
Emit a running compact notice from the Claude adapter when `/compact` is
selected, allow compact system notices to precede provider-turn acceptance,
accept `local_command` / `local_command_output` and camelCase boundary
metadata in the sidecar. For the pinned Claude Code 2.1.220 contract, treat
`status.compact_result=failed` plus `compact_error` as the canonical compact
failure signal; assistant and local-command text are compatibility fallbacks
for streams that omit that status. Map the normalized
`conversation could not be reduced below the context limit` failure to
`compact_failed` before the successful SDK result settles the Turn. When the
acceptance barrier later flushes held events, strip their
`ProviderInputUnit` so they publish transcript/state only. If the compact
failure specifically reports that the hard context limit was exceeded,
project a typed `context_handoff_required` error. Do not replace the provider
session or automatically dispatch the next message. Tell the user to create
a new conversation and add an `agent-session` mention for this conversation,
making the handoff explicit while retaining Claude Code's normalized failure
detail. For restored Claude sessions, use `rawMaxTokens` as the fallback hard
window and log the SDK maximum, raw maximum, native
auto-compact threshold, and effective auto-compact flag separately.
- Validation:
Add daemon coverage that `/compact` banners stay held until durable
acceptance, then flush without provider-input units; add sidecar coverage for
silent `/compact` (result only), local_command failure, and camelCase
`compactMetadata`. Also cover exact overflow classification, raw hard-window
diagnostics, the typed handoff error projection, and localized Desktop and
Native guidance. Re-run L04-CLAUDE recording and confirm the progress divider
appears, then becomes `Context compacted.` (or the interrupted divider with
the failure detail), and that record+replay both pass.
- References:
[compaction.ts](../../../packages/agent/claude-sdk-sidecar/src/compaction.ts)
[claude_sdk_execution.go](../../../packages/agent/daemon/runtime/claude_sdk_execution.go)
[claude_sdk_turn.go](../../../packages/agent/daemon/runtime/claude_sdk_turn.go)
[AgentMessageBlock.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentMessageBlock.tsx)
### Inactive Claude resume times out then later sends stay queued
- Symptom:
On a large inactive Claude session, `/compact` or any send shows working then
fails after ~30s with `engine command timed out` / tuttíd `context canceled`.
Compact never runs. A later send shows 排队中 and never reaches tuttíd
(`api.send.received` missing).
- Quick checks:
Confirm `activeLiveState=inactive` and `resumable=true` before send. Order
desktop `renderer_adapter.send.http_requested` → tuttíd
`process_start.env_diagnostics` → `api.send.failed` (~30s, no
`runtime.exec`). Check Claude transcript size under
`~/.claude/projects/.../<provider-session-id>.jsonl`. After the timeout,
inspect whether a second submit stamps `queued:true` without a daemon send.
- Root cause:
`SendInput` blocks on `ensureRuntimeSession` / Claude `Resume`→`Start` until
the sidecar emits `session_started`. On restore, the sidecar previously
awaited `query.getContextUsage()` before that event; the SDK call has no
timeout and has hung for minutes. The Engine then treated the client send
timeout as `uncertainDelivery`, which blocks all queue drain and prevents
remove/send-now of the timed-out head, so later prompts stay visible as
排队中 forever even after reconcile finds no delivery proof.
- Fix:
Emit `session_started` before the restore usage snapshot, and refresh usage
in the background (same as turn completion). Keep queue send timeout at 90s
inside the 120s confirmation window as defense in depth. When an owned
`queue:reconcile:*` completes without exact turn proof, drop the timed-out
prompt (definitive non-delivery) or release uncertainty into a retryable
failed head so later queued work can drain.
- Validation:
Sidecar coverage that restore `start()` reaches `session_started` while
`getContextUsage` never resolves, then still emits `usage_updated` after
resolve. `promptQueue.reducer` coverage for owned reconcile without turn
proof. Manually: open a large inactive Claude session, send `/compact`,
confirm Resume completes without waiting on usage and later messages are not
stuck behind uncertain delivery.
- References:
[sessionRuntime.ts](../../../packages/agent/claude-sdk-sidecar/src/sessionRuntime.ts)
[compaction.ts](../../../packages/agent/claude-sdk-sidecar/src/compaction.ts)
[promptQueue.reducer.ts](../../../packages/agent/activity-core/src/engine/promptQueue.reducer.ts)
[promptQueue.ownedReconcile.ts](../../../packages/agent/activity-core/src/engine/promptQueue.ownedReconcile.ts)
[promptQueue.drainDecision.ts](../../../packages/agent/activity-core/src/engine/promptQueue.drainDecision.ts)
[lifecycle.go](../../../packages/agent/host/lifecycle.go)
[claude_sdk_lifecycle.go](../../../packages/agent/daemon/runtime/claude_sdk_lifecycle.go)
### AgentGUI compaction timer keeps running after compaction completed
- Symptom:
AgentGUI continues to show an increasing `Compacting context` duration after
the provider finished compaction. The durable compaction message and Turn are
already terminal, but the mounted renderer still projects the earlier
`noticeCommandStatus=running` snapshot.
- Quick checks:
Compare the message-list requests in desktop reconcile diagnostics with the
durable message versions. If the renderer pulled a running compaction at
version N, missed its terminal update at N+1, then next requested
`afterVersion` at a much higher value, the local cache advanced across a
version hole. Confirm that the terminal row uses the same `messageId`; a new
compaction row or a timer-specific state bug is a different failure.
- Root cause:
The realtime bridge treated the maximum version of materialized message rows
as a contiguous acknowledged change cursor. After an event-stream loss, it
applied a later inline message and advanced that maximum past the missed
terminal mutation. Every later `afterVersion` pull then started beyond the
mutation, so the authoritative completed snapshot could never repair the
cached running snapshot. The timer correctly kept rendering the stale running
lifecycle.
- Fix:
Before folding realtime messages inline, compare only their unseen versions
with the cached high-water boundary. If the first unseen version is not the
next cursor, do not apply any of that event's messages; retain the old cursor
and request an authoritative incremental reconcile. After a disconnected
event stream reconnects, also incrementally reconcile every session whose
messages are already cached; otherwise a missed final mutation has no later
event that can reveal its gap. Do not require the materialized cache itself to
contain every historical cursor value, because mutable message rows replace
older versions.
- Validation:
Cache a user message and a running compaction, omit the next terminal
compaction mutation, then deliver a later assistant message. Assert that the
later message is not applied inline, reconciliation requests from the
pre-gap cursor, and the stable compaction `messageId` becomes completed from
the authoritative response. Also cover valid snapshot gaps already present in
the cache plus duplicate and stale event delivery. Finally, omit the terminal
mutation as the last event, disconnect and reconnect without another activity
event, and verify the reconnect reconcile retrieves it from the pre-disconnect
cursor.
- References:
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[sessionReconcileExecutor.ts](../../../packages/agent/activity-core/src/sessionReconcileExecutor.ts)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### Root detail reconciliation repeatedly reloads unchanged child transcripts
- Symptom:
Opening or reconciling one root conversation repeatedly issues message-list
requests for every known child Session, including assistant-only and
tool-only children whose durable messages have not changed.
- Quick checks:
Compare each child Session's required `messageVersion` from both root-detail
reads with the largest cached message version that also has a durable
`sequence`. If the cache is current but the request still starts at zero,
inspect whether child reconciliation reused the root conversation's
user-message boundary heuristic.
- Root cause:
The root heuristic intentionally returns zero when cached history has no user
message, so it can repair an incomplete root conversation. Applying that
heuristic to provider-native child Sessions makes ordinary assistant/tool
histories look permanently unhydrated and forces the same reads forever.
- Fix:
Keep root and child cursor policies separate. For a child, derive the cursor
only from durable sequenced messages and skip its message request when that
cursor has reached the Session `messageVersion`. Preserve the bounded
newest-first initial read, but treat an existing empty child window as the
authoritative zero cursor and drain later messages from `afterVersion=0`.
After the first message pass, read root detail again and incrementally fetch
newly discovered children plus existing children whose `messageVersion`
advanced during the pass. Do not let optimistic/transient rows advance the
durable cursor, and do not add polling; later changes arrive through the
existing push-and-reconcile path.
- Validation:
Cover assistant-only and tool-only child caches, a transient row with a higher
synthetic version, an unchanged child that performs no request, initial
newest-first hydration, an empty known child that gains more than one page,
and a child advancing between the two detail reads. Keep a root assistant-only
case proving its existing repair still reads from zero.
- References:
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[sessionReconcileExecutor.ts](../../../packages/agent/activity-core/src/sessionReconcileExecutor.ts)
### Completed agent output appears only after switching Sessions
- Symptom:
A Turn finishes in the daemon, but the active AgentGUI timeline does not show
the final assistant output until the user selects another Session and returns.
This is especially visible after an edit-retry replacement Turn.
- Quick checks:
Follow one exact `(workspaceId, agentSessionId, turnId)` through durable
`turn_update` publication, the business-event WebSocket, and
`WorkspaceAgentActivityReconcileBridge`. Distinguish a daemon publication
gap from a disconnected/stale subscription and from a renderer event that
updated Turn state without requesting authoritative messages. Compare the
cached message cursor with `listWorkspaceAgentSessionMessages`.
- Root cause:
A terminal Turn event can be delivered while its final message event is
missed, reordered, or not folded into the active cache. Session navigation
performs a detail/message hydration and therefore hides the missing
reconcile trigger.
- Fix:
Treat realtime events as hints. Every terminal `turn_update` requests one
combined state-and-message reconcile through the existing workspace engine.
An accepted edit-retry completion or recovery command remains `reconciling`
and blocks another edit until the same authoritative detail reconciliation
confirms its revision and recovery state. Never replace canonical
availability with recovery actions inferred by Desktop. Do not add a second
WebSocket, component store, or edit-retry-specific polling loop.
- Validation:
Deliver a settled `turn_update` without the corresponding final
`message_update`; assert that the bridge fetches messages and updates the
mounted timeline without Session navigation. Repeat after edit retry and
after a transient reconcile failure.
- References:
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[workspaceAgentEditRetry.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentEditRetry.ts)
[agent-gui-node.md](../../architecture/agent-gui-node.md)
### AgentActivity replication repeatedly rejects message batches as invalid
- Symptom:
A downstream AgentActivity replica repeatedly returns `INVALID_ARGUMENT` for
the same message batch. The source session has a higher `messageVersion`, but
the destination has no messages or stops at an earlier version.
- Quick checks:
Compare the session watermark with the current message rows. Values such as
`1,3` or `1,5` are valid when an intermediate snapshot of the same
`messageId` was overwritten. Check whether the destination requires
`incomingVersion == maxStoredVersion + 1` or treats a message version as
immutable identity.
- Root cause:
`Message.Version` is a per-session change cursor on a mutable snapshot. Each
accepted update advances the cursor, and updating the same `messageId`
replaces its prior row. Current rows therefore need not contain every cursor
value, and the same message identity legitimately moves to a higher version.
- Fix:
Replicas must accept any positive version for a new message, accept a higher
version for an existing `messageId`, and ignore or reject only stale lower
versions. Use a version-guarded atomic upsert so concurrent stale snapshots
cannot overwrite newer state. Do not add an event-history table merely to
make the current snapshot appear contiguous.
- Validation:
Record message A at v1, message B at v2, then update B to v3. Verify the
current snapshot is `A@1,B@3`, `afterVersion=1` returns B at v3, and a
rejected projection does not consume another cursor. Downstream replication
coverage should also accept an initial v3, update it to v5, and preserve v5
when v4 arrives later.
- References:
[activity_messages.go](../../../packages/agent/store-sqlite/activity_messages.go)
[activity_message_read.go](../../../packages/agent/store-sqlite/activity_message_read.go)
[repository.go](../../../packages/agent/store-sqlite/repository.go)
### Goal clear stays planning and leaves the session running
- Symptom:
Immediately clearing a newly set Goal leaves the first response in thinking,
`/goal clear` in planning, or the conversation permanently running.
- Quick checks:
Inspect the Goal control response: its Turn ID must be empty. List persisted
Turns and verify no Turn was created solely for the clear action. Inspect the
Goal state endpoint for `desired`, `observed`, `revision`, `syncStatus`, and
`pendingOperationId`. For any real Goal Turn, verify `origin` and source Goal
operation/revision are present.
- Root cause:
The prompt path allocated a Turn ID before classifying `/goal`. Provider Goal
control is session-level and did not start that Turn, but message persistence
manufactured a row for the unknown ID. With no real `turn_started` or
terminal event, loading and session-running projections never settled.
- Fix:
Route Goal controls through the typed Goal API before Turn allocation. Persist
desired/observed Goal state and an independent operation, reject messages that
reference unknown Turns, and adopt only provider-started continuation Turns.
Use durable origin and revision correlation so clear does not cancel unrelated
user work and stale continuation timers cannot revive an older Goal.
- Validation:
Set then immediately clear a Goal and assert that no control Turn exists, the
operation reaches a terminal state, and the session has no phantom active
Turn. Cover unknown-Turn message rejection, Claude goal-arm quiescing, Codex
adopted continuation provenance, stale observation protection, and revision-
guarded continuation nudges.
- References:
[Agent Goal Control Design](../../specs/2026-07-15-agent-goal-control-design.md)
[controller_exec.go](../../../packages/agent/daemon/runtime/controller_exec.go)
[goal_state.go](../../../packages/agent/store-sqlite/goal_state.go)
### Claude Goal completes but the UI remains active
- Symptom:
Claude finishes the requested work and its session JSONL contains an
`attachment.type=goal_status` row with `met=true`, but Tutti emits no
`goal_observed`; durable desired and observed Goal state remain active.
- Quick checks:
Correlate the Tutti Session with the Claude provider Session ID. Inspect the
provider transcript for the final `goal_status` attachment, then check dev
logs for a matching sidecar `goal_observed`. Do not infer Goal completion
from a successful root result or from a successful Stop hook process.
- Root cause:
Local Claude Agent SDK `SDKMessage` streams through `0.3.222` omit transcript
attachments. `SDKActiveGoalMessage` is also absent from the public
`SDKMessage` union, and local Claude Code only publishes `active_goal` on its
remote path. A test that manually yielded `type=attachment` therefore proved
the projection but not the real SDK boundary.
- Fix:
Locate the provider transcript from root `system/init.session_id + cwd`
using the pinned SDK's project-key algorithm. Start restored Sessions from
their already known provider Session ID. Keep
`SessionStart.transcript_path` as an exact-path auxiliary signal, not the
required trigger. Read only newly appended complete JSONL rows, forward only
`goal_status` attachments through the existing Goal projection, drain before
root Turn completion, and keep following after the SDK result because the
native evaluator can flush its terminal row slightly later. Late evidence
retains the latest settled root Turn identity. Do not parse evaluator hook
stdout and do not configure an alpha `SessionStore` solely for this signal;
both change a larger and less reliable boundary.
- Validation:
Run a query fixture whose SDK iterator emits root `system/init`, the root
user message, and result. Reproduce the observed ordering where transcript
`met=false` precedes `turn_completed` and `met=true` lands afterward; require
the late completion to retain the original Turn identity. Also cover resume
without another init, delegated init exclusion, watcher cleanup,
historical-row skipping, partial JSONL rows, and repeated-drain
deduplication.
- References:
[goalTranscript.ts](../../../packages/agent/claude-sdk-sidecar/src/goalTranscript.ts)
[messageRouter.ts](../../../packages/agent/claude-sdk-sidecar/src/messageRouter.ts)
[Anthropic SDK issue #336](https://github.com/anthropics/claude-agent-sdk-typescript/issues/336)
### Accepted Goal clear is sent repeatedly until convergence times out
- Symptom:
One `/goal clear` produces repeated provider control requests at the Goal
worker interval. Each request is accepted, but the durable operation remains
`dispatched` with provider phase `accepted`, its attempt count keeps rising,
and it eventually fails with `accepted goal operation exceeded its
convergence deadline`.
- Quick checks:
Correlate logs by `agent_session_id`, operation ID, and Goal revision. Verify
there is one user control audit but multiple provider `action=clear` calls.
Inspect `workspace_agent_goal_control_operations`: an accepted operation with
increasing attempts and unchanged accepted evidence identifies Host replay,
not repeated user input.
- Root cause:
The steady-state Goal worker treated clear's provider idempotence as
permission to resubmit an already accepted command. Acceptance had already
crossed the delivery boundary, so every later worker claim queued another
native clear command while the Host was supposed to wait for applied
lifecycle evidence.
- Fix:
For every accepted Goal mutation, make the steady-state worker defer without
calling the provider again. Keep the convergence deadline so missing applied
evidence still terminates. Reserve replay for startup crash recovery and
gate it with the adapter recovery policy; query-incapable adapters may replay
an idempotent clear once, while unsafe set replay remains rejected.
- Validation:
Return `accepted` from a clear, advance the worker clock past its next claim,
and step the steady-state worker. The provider call count must remain one,
while the operation stays pending and `applying`. Existing deadline coverage
must still fail a lost-evidence operation without another provider call.
- References:
[goal_operation_worker.go](../../../packages/agent/host/goal_operation_worker.go)
[goal_scenarios.go](../../../packages/agent/host/conformance/goal_scenarios.go)
### Replaced Goal banner keeps the previous objective
- Symptom:
A second `/goal <objective>` is accepted and runs, but AgentGUI continues to
show the first objective. The Goal state table contains the second objective
at a newer revision while `workspace_agent_sessions.session_metadata_json`
or a runtime Session snapshot still contains the first.
- Quick checks:
Compare `workspace_agent_session_goals.desired_json`, `revision`, and
`updated_at_unix_ms` with the Session metadata Goal. Confirm that the second
Goal operation completed before attributing the mismatch to React rendering.
- Root cause:
Durable Goal state and provider Session metadata update on different
schedules. Session reads previously exposed the provider metadata Goal and
attached only `goalSyncState`, so a later Session reload could overwrite the
correct Goal Control response with an older objective.
- Fix:
Project Host-owned durable Goal state and its update timestamp onto every
single and batch Session read. Use `desired` while convergence is unresolved,
use `observed` after synchronization, and honor the durable tombstone.
- Validation:
Read a Session whose metadata contains objective A beside durable Goal
revision N+1 containing objective B. Single and batch projections must return
B with a Session timestamp at least as new as the Goal state. A durable
tombstone must return no Session Goal; a synchronized terminal observation
must remain terminal instead of reverting to active `desired` state.
- References:
[service_turns.go](../../../services/tuttid/service/agent/service_turns.go)
[goal_state.go](../../../packages/agent/store-sqlite/goal_state.go)
### Cleared Goal reappears as a newer provider-authored Goal
- Symptom:
Goal clear returns success and briefly shows “目标已移除”, but the active Goal
banner returns. The Goal table contains a completed clear followed by a new
provider-adoption `set` revision for the same objective. In a projection-only
variant, the table remains tombstoned with no later `set`, while the banner
still shows the pre-clear Goal.
- Quick checks:
Correlate `workspace_agent_session.goal_control.completed action=clear` with
`agent_session.app_server.goal.provider_adopted`. If adoption was scheduled
before clear but completed immediately after it, inspect the adoption's
expected revision and the canonical revision committed by clear.
If no later adoption exists, inspect the Goal Control response: `goal` must
be present as `null`, and its embedded `session.goal` must also be null.
- Root cause:
Provider Goal adoption runs off the app-server read loop. An observation
captured before clear could wait behind the serialized Goal actor; the old
implementation read the revision only after acquiring that actor, so the
delayed observation was mistaken for a new provider-authored Goal and
cleared the tombstone.
The projection-only variant made nullable `goal` optional on the wire. Go
therefore omitted a successful clear, the client fell back to a stale
runtime `session.goal`, and the Engine promoted that stale field after the
operation settled.
- Fix:
Capture the canonical Goal revision while scheduling provider adoption and
carry it through the runtime, Host, and store boundary. The store compares
it with the current revision inside the same transaction that would advance
the Goal. Mutable progress snapshots from an already owned provider Goal
bind to that Goal's current operation identity instead of entering adoption.
Goal Control responses require a nullable `goal`, project the Host result
onto `session.goal`, and let the Engine normalize a synced Session from that
same authoritative field.
- Validation:
Block provider adoption after it captures revision N, complete clear at
revision N+1, then release adoption. It must fail as superseded, leave the
Goal tombstoned at N+1, create no later `set` operation, and keep the runtime
Goal banner empty. Also return a synced clear beside a deliberately stale
Session Goal; JSON must contain `"goal": null`, and the Engine presentation
must remain empty.
- References:
[goal_provider_adoption.go](../../../packages/agent/host/goal_provider_adoption.go)
[codex_appserver_provider_goal_adoption.go](../../../packages/agent/daemon/runtime/codex_appserver_provider_goal_adoption.go)
[daemon_agent_sessions_goal.go](../../../services/tuttid/api/daemon_agent_sessions_goal.go)
[sessionGoalControl.validation.ts](../../../packages/agent/activity-core/src/engine/sessionGoalControl.validation.ts)
### Goal loops after pause/resume and never reaches complete
- Symptom:
Pause and resume succeed, the Goal keeps producing continuation Turns, and the
model may even emit the final marker text, but `thread/goal/updated` never
reaches `status=complete`. Logs show a `continuation_nudge` after a brief
inter-turn idle, then an unbounded stream of adopted Goal Turns.
- Quick checks:
Compare the nudge `thread/goal/set` params with the original set. If the nudge
re-sends `objective`, Codex may restart Goal work instead of letting the
current generation settle. Confirm pause/resume ops completed and the durable
generation fingerprint is still owned by the set/adoption operation.
- Root cause:
Pause/resume intentionally keep generation ownership on the set-time
fingerprint while advancing the live operation identity. Provider
`status=complete` for that fingerprint was classified as superseded
(`binding.identity != current`), so local goal state stayed `active`. The
per-turn continuation nudge then re-sent `thread/goal/set` with
`status=active` and revived the finished Goal.
- Fix:
Treat provider progress for the session's current generation fingerprint as
known-current after pause/resume (while the Goal is still present). Keep
nudges status-only, do not re-bind generation on pause/resume/nudge, and do
not schedule a delayed nudge from Resume itself.
- Validation:
Record a short one-digit-per-turn Goal, pause after the first digit, resume,
and verify the provider reaches `complete` with the final marker and no
post-complete continuation storm.
- References:
[codex_appserver_goal.go](../../../packages/agent/daemon/runtime/codex_appserver_goal.go)
[codex_appserver_adapter_goal_lifecycle_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_goal_lifecycle_test.go)
### Goal resume fails with permanently ambiguous generation fingerprint
- Symptom:
Pause succeeds and the banner shows Resume, but clicking Resume fails. Logs
show `persist goal provenance: provider goal generation fingerprint is
permanently ambiguous`. Provider status may already be `active` while the
Host operation stays failed and no continuation Turn starts.
- Quick checks:
Correlate the pause and resume durable operation IDs with
`workspace_agent_goal_generation_fences` for the same fingerprint. Confirm the
set/adoption that created the Goal already owns that fingerprint. Inspect
Codex `thread/goal/set` pause/resume responses for a returned Goal payload.
- Root cause:
Pause and resume are status-only controls that reuse the set-time provider
generation. Re-binding that fingerprint to the pause/resume operation id
collides with the set binding and marks the fingerprint permanently
ambiguous, failing closed before the local status mirror and continuation
nudge run.
- Fix:
Keep generation binding on durable set/adoption only. For pause/resume, mirror
the provider or local Goal status without calling `bindGoalGeneration`.
- Validation:
With a durable provenance sink, set → pause → resume with distinct operation
ids and the same generation fingerprint. Resume must succeed, status must be
`active`, and the fingerprint must remain owned by the set operation.
- References:
[codex_appserver_goal.go](../../../packages/agent/daemon/runtime/codex_appserver_goal.go)
[codex_appserver_goal_provenance.go](../../../packages/agent/daemon/runtime/codex_appserver_goal_provenance.go)
[codex_appserver_adapter_goal_recovery_test.go](../../../packages/agent/daemon/runtime/codex_appserver_adapter_goal_recovery_test.go)
### Goal disappears after pause or resume
- Symptom:
Pause or resume succeeds, but the Goal banner disappears while the durable
Goal still exists.
- Quick checks:
Compare the Goal Control response's top-level `goal` with `state.desired`,
`state.observed`, and `state.tombstoned`. If `observed` is empty but
`desired` remains populated and not tombstoned, the provider supplied no
current observation; it did not clear the Goal.
- Root cause:
The response projected the provider's per-action observation as the visible
Goal. Some providers can apply pause or resume while returning no Goal
observation, so the required nullable response serialized that absence as an
explicit clear.
- Fix:
Host returns the durable desired projection as `GoalControlResult.Goal` and
keeps provider output in `GoalState.Observed`. The daemon only maps that
Host-owned result. Empty observation may produce `diverged` state, but only a
durable tombstone produces `goal: null`.
- Validation:
Set a Goal, make the provider return no Goal for pause and resume, and verify
both responses retain the objective with the expected paused/active status,
report divergence, and carry no pending operation.
- References:
[goal_control.go](../../../packages/agent/host/goal_control.go)
[goal_scenarios.go](../../../packages/agent/host/conformance/goal_scenarios.go)
[daemon_agent_sessions_goal.go](../../../services/tuttid/api/daemon_agent_sessions_goal.go)
### Revoked shared Goal starts again after handoff or desktop restart
- Symptom:
After a shared binding is revoked, the old Goal starts or resumes provider
work, may replace the Owner's newer Goal, or an unrelated active Turn is
canceled.
- Quick checks:
Resolve the original Goal operation by its stable `clientSubmitId`. Inspect
`workspace_agent_goal_generation_fences` for the exact operation ID, revision,
and repair epoch. `pending` or `processing` means Host still owns delivery;
absence means the revocation was never durably accepted. For any canceled
Turn, compare all three immutable `source_goal_*` fields with the fence.
- Root cause:
Revocation was treated as a one-shot notification, or code guessed that the
session's current active Turn belonged to the revoked binding. A failed
notification was lost on restart, and recovery could replay the old prepared
Goal before runtime learned the revocation.
- Fix:
Persist an exact Goal-generation fence before provider delivery. Process
fences before Goal operations during startup and each worker tick. Restore
all durable fences when a provider session resumes, and retain the exact set
in the runtime Controller across idle connection release so a replacement
adapter is fenced before its first user operation. Never resume an offline
provider solely for background fence or cancel delivery. Prepare the local
clear with the target revision as a compare-and-swap guard, and call
`CancelTurn` only for a live canonical Turn whose operation ID, revision,
and repair epoch all match. Keep the fence pending after cancellation
acceptance until the Turn is canonically terminal.
- Validation:
Cover failed delivery followed by restart, a pending target operation and
fence recovered together, a newer Owner Goal, repeated runtime ensure, and
matching versus unrelated active Turns. Simulate connection replacement and
prove fence reinstallation happens before provider dispatch. Simulate a
disconnect immediately before cancel and prove background recovery makes
zero provider Resume calls. Provider tests must prove that the exact
Codex/Claude generation never becomes canonical while later generations
remain admissible.
- References:
[Agent Host contracts](../../../packages/agent/host/README.md)
[Agent Goal Control Design](../../specs/2026-07-15-agent-goal-control-design.md)
[goal_generation_fence.go](../../../packages/agent/host/goal_generation_fence.go)
### Initial Goal prompt disappears when the Agent starts responding
- Symptom:
A new conversation opened with `/goal <objective>` first shows the submitted
command, then removes it as soon as the durable session or the first provider
response arrives. The response and processing state remain visible.
- Quick checks:
Compare the optimistic activation message with the durable
`session_audit`. Verify both carry the same `clientSubmitId` and inspect the
transcript projection after the overlay removes the optimistic twin. If the
durable Goal audit is filtered from all presentation output, the replacement
leaves no visible row.
- Root cause:
The optimistic activation was projected as an ordinary user message while
the canonical Goal control was correctly persisted as a session-level audit.
Overlay reconciliation removed the optimistic message by `clientSubmitId`,
then the message projector discarded the durable audit, so two individually
correct policies composed into a disappearing row.
- Fix:
Mark the optimistic activation as Goal control when
`initialGoalControl` is present. Project both optimistic and durable forms as
a dedicated `goal-control` row outside the Turn model. Derive the row identity
from the audit payload's client-submit message identity before falling back
to the durable operation ID, so replacement preserves the renderer key.
- Validation:
Project the optimistic-only state and the durable-after-overlay state. Both
must contain one `goal-control` row with the same ID and zero Turns. Also
verify the dedicated row renders, the first real provider Turn remains the
sole Turn, and reload produces the same control row.
- References:
[workspaceAgentMessageOverlay.ts](../../../packages/agent/gui/shared/workspaceAgentMessageOverlay.ts)
[workspaceAgentMessageProjection.ts](../../../packages/agent/gui/shared/agentConversation/projection/workspaceAgentMessageProjection.ts)
[workspaceAgentTimelineCanonical.ts](../../../packages/agent/gui/shared/workspaceAgentTimelineCanonical.ts)
### Goal clear duplicates the live Agent work section
- Symptom:
While a Goal Turn is still streaming, clicking clear inserts `/goal clear`
between two identical Agent work sections. Both sections continue updating
together even though the daemon executed only one Turn.
- Quick checks:
Inspect canonical Turns and message ownership. If Agent messages on both
sides of the turnless Goal audit carry the same Turn ID, compare transcript
group keys before inspecting provider retries or duplicate persistence.
- Root cause:
Chronological insertion produced `Turn T -> goal-control -> Turn T`. A
grouping pass treated the turnless row as a hard boundary and created two
groups with key `T`; the work-section map retained the later model for that
shared key, so both Turn positions rendered the same rows.
- Fix:
Keep a turnless session row inside the current presentation group only when
the next lifecycle-owned row proves that the surrounding Turn ID is
unchanged. Preserve the session row's null Turn ID; presentation continuity
must not manufacture lifecycle ownership.
- Validation:
Cover `T -> goal-control -> T` and assert one Turn group, one processed-time
section, one Goal control row, and one copy of each Agent row. Also cover
`T1 -> goal-control -> T2` and verify the Goal control remains an independent
orphan between different Turns.
- References:
[agentTranscriptModel.ts](../../../packages/agent/gui/shared/agentConversation/components/agentTranscriptModel.ts)
[AgentTranscriptView.tsx](../../../packages/agent/gui/shared/agentConversation/components/AgentTranscriptView.tsx)
### Recording fails when a tool message contains its runtime CWD
- Symptom:
Recording finalization fails with `expected_state_export_failed` and reports
an absolute path at `$.agent.sessions[*].messages[*].payload.input.cwd`.
The provider tape may already be complete.
- Quick checks:
Inspect the failed Recording row and the matching canonical tool message.
Distinguish the normalized tool envelope's direct `input.cwd` from a
tool-owned nested argument.
- Root cause:
The provider tape tokenizes the Session CWD, but the semantic-state adapter
previously copied the canonical tool message unchanged. The direct
`input.cwd` is provider runtime context, not part of Tutti's portable
semantic state.
- Fix:
Project the Agent graph before semantic-state validation. Remove only the
runtime `input.cwd` fields from normalized `tool_call` messages and
Interaction envelopes, including the normalized
`toolCall.input.cwd`. Convert an absolute executable prefix in normalized
command/title display fields to its basename. Do not branch on tool name,
recursively remove tool-owned arguments, mutate canonical messages, or
weaken the absolute-path validator.
- Validation:
Capture a tool message and matching Interaction with absolute normalized
runtime CWDs and executable prefixes, plus a nested relative
`arguments.cwd`. Require capture and validation to succeed, retain the
tool-owned nested argument, and prove the source Agent graph remains
unchanged.
- References:
[state.go](../../../services/tuttid/biz/agentsessionreplay/state.go)
[agent_session_replay_state.go](../../../services/tuttid/data/workspace/agent_session_replay_state.go)
### Cassette replay loses the provider session before the second stimulus
- Symptom:
Replay reaches checkpoint 1, then a later `session.send` fails with
`provider session was never established`. The replay error also reports that
an outbound write arrived while a recorded stdout chunk was next.
- Quick checks:
Inspect `provider/frames.jsonl` around the reported chunk. If a provider
notification immediately follows a successful request response and precedes
the next outbound request, compare the reader and writer goroutine timing.
- Root cause:
The process cassette encoded the provider byte-stream order, but replay
treated that order as a required Go goroutine schedule. A writer could run
before the reader consumed an already-recorded notification, so normal
concurrency was rejected as a transport mismatch and provider startup was
torn down.
- Fix:
When the next recorded chunk is inbound, block the replay write until the
receive path consumes it. Continue strict payload validation when the next
recorded outbound chunk becomes current.
- Validation:
Start a replay stream with `outbound -> stdout -> outbound`. Launch the
second send before receiving stdout; it must wait, then succeed after the
receive. Keep mismatch, pause, fast-forward, close, and full runtime tests
passing.
- References:
[process_transport_replay.go](../../../packages/agent/daemon/runtime/process_transport_replay.go)
[process_transport_cassette_test.go](../../../packages/agent/daemon/runtime/process_transport_cassette_test.go)
### Continue-session replay sends initialize before the recorded turn
- Symptom:
A restored replay Session reaches prompt dispatch, then fails with
`process cassette outbound mismatch`: the expected request is `turn/start`
or `session/prompt`, while the actual request is `initialize`. A second form
has the same expected and actual `turn/start`, but the actual request is
missing a provider-derived field such as Codex `collaborationMode`.
- Quick checks:
Inspect `provider/manifest.json` and the first outbound frame for the failed
connection. If recording started on a Session that was already live, its
`captureOrigin` must be `attached-live-connection`.
- Root cause:
Continue-session recording attached to an initialized provider connection,
so its tape correctly began at the next business request. Replay started a
new virtual connection but treated every tape as `process-start`, causing the
adapter to repeat initialization and provider-session resume traffic that
was outside the captured boundary. If initialization is already skipped but
a provider-derived field is missing, the semantic `initial-state.json`
dropped the protocol checkpoint while projecting the canonical historical
Session graph.
- Fix:
Persist the connection capture origin in provider tape schema v3. For an
attached live connection, restore the provider adapter's protocol checkpoint
through the historical Session's narrow `providerResumeCheckpoint` and skip
only the initialization and provider-session bootstrap. Do not export the
full private runtime context and do not infer missing state from the first
tape frame. Keep strict matching on the first captured business request.
- Validation:
Record a Codex and Cursor Turn after arming capture on an existing live
connection. Recreate each adapter over replay transport, resume the
historical Session, execute the same Turn, and require complete tape
consumption. The regression must cross semantic state JSON capture and Host
restore; a test that copies an in-memory runtime context directly into the
replay Session does not cover the product boundary. Also keep new-session
cold bootstrap tests passing.
- References:
[process_cassette.go](../../../packages/agent/daemon/runtime/process_cassette.go)
[codex_appserver_session.go](../../../packages/agent/daemon/runtime/codex_appserver_session.go)
[standard_acp_session.go](../../../packages/agent/daemon/runtime/standard_acp_session.go)
### Final ACP Markdown loses spacing between blocks
- Symptom:
A completed ACP answer reaches the transcript, but headings, lists, or fenced
code blocks run together even though the provider's `session/prompt` result
contains the expected newlines.
- Root cause:
The final-result extractor trimmed each nested content block before joining
them. Whitespace that separated adjacent Markdown blocks was therefore
removed before the turn normalizer and GUI received the answer.
- Fix:
Use trimming only to decide whether a content block is empty. Preserve the
original non-empty block text while recursively extracting and joining the
final assistant content.
- Validation:
Exercise a complete standard-ACP turn whose final result contains a heading,
list, and fenced code block. Require the projected assistant snapshot to keep
all internal Markdown newlines.
- References:
[acp_update_events.go](../../../packages/agent/daemon/runtime/acp_update_events.go)
[standard_acp_turn_test.go](../../../packages/agent/daemon/runtime/standard_acp_turn_test.go)
### Cassette replay reports a false final Session state mismatch
- Symptom:
Every recorded stimulus succeeds, but final replay validation reports an
exact path such as `$.agent.sessions[0].messages[0].id` or a Tutti Mode
snapshot `turnId`.
- Quick checks:
Inspect `expected-state.json` and capture the actual typed semantic state.
Confirm that Turns and Messages were captured in canonical sequence rather
than UUID order, and that every reference points to the corresponding
structural object.
- Root cause:
Replay legitimately regenerates Turn, assistant Message, interaction, and
product-object IDs. Comparing those raw values, or ordering related arrays by
random UUID, mistakes alpha-equivalent semantic graphs for drift. Message
payload `seq` is also runtime timing metadata rather than a semantic oracle.
- Fix:
Keep original IDs in `initial-state.json` so Host can restore resumable
history. For final verification, alpha-normalize structural IDs and all their
references after ordering entities by canonical sequence; omit runtime-only
payload sequence values. Continue to compare semantic content exactly.
- Validation:
Cover regenerated Turn and Message IDs, changed payload sequence values, and
a real semantic content mismatch. Run both create-session and continue-session
current-build record/replay loops.
- References:
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs)
[run-agent-session-replay.test.mjs](../../../tools/scripts/run-agent-session-replay.test.mjs)
### Replay aborts with `Promise was collected (-32000)`
- Symptom:
A renderer intent has already started, but the temporary CDP runner aborts
while awaiting its result. Provider replay then reports a partially consumed
connection during shutdown.
- Root cause:
Chromium may collect the remote Promise backing `Runtime.evaluate` while the
renderer operation is still running. Retrying the renderer command directly
can duplicate a non-idempotent intent.
- Fix:
Register the invocation in the renderer by method and Activity Event ID
before dispatch. On this CDP error, evaluate the same lookup again and await
the stored result instead of invoking the command twice.
- Validation:
Make the first CDP evaluation fail with `Promise was collected`; the second
evaluation must reuse the identical invocation expression and complete the
event exactly once.
- References:
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs)
[run-agent-session-replay.test.mjs](../../../tools/scripts/run-agent-session-replay.test.mjs)
### Cassette replay times out before a recorded queued Turn
- Symptom:
Replay stops on `timed out waiting for renderer effect queue/sendPrompt`,
leaves later activity events untouched, and consumes only the provider frames
before a long-running Turn settles. The cassette itself contains the missing
intent, effect, and final Turn.
- Quick checks:
Compare adjacent `activity-events.jsonl` timestamps. If the effect was
recorded more than the renderer effect timeout after its intent, check
whether replay dispatched both events immediately instead of waiting for
their recorded interval.
- Root cause:
Provider frames followed the daemon playback clock, but the runner iterated
activity events as fast as JavaScript completed them. Effect verification
therefore began while the recorded Turn was still running and expired before
the effect could exist.
- Fix:
Advance activity events by relative `occurredAtUnixMs` using the daemon's
playback state. Pause freezes the activity clock, speed scales it, and
checkpoint fast-forward skips its remaining wait.
- Validation:
Cover normal recorded delay, processing time between events, pause/resume,
selected speed, and fast-forward. Then replay the failing cassette and require
the final checkpoint, all provider frames, and every expected Turn to pass.
- References:
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs)
[run-agent-session-replay.test.mjs](../../../tools/scripts/run-agent-session-replay.test.mjs)
### Edited prompt remains after edit-and-retry
- Symptom:
The replacement answer appears, but the original user prompt remains,
reappears after reconnect, or disappears only after switching Sessions.
- Quick checks:
Compare the daemon Session-detail Turn projection and effective message read
with the renderer snapshot. If SQLite marks the old Turn `retracted`, verify
Session detail uses `ListEffectiveSessionTurns`, not the complete audit
reader. Then inspect the SessionEngine required/applied history revisions and
any terminal optimistic message-delta row from the retracted Turn.
- Root cause:
Incremental reconciliation can add or update rows but cannot delete a cached
Turn missed during disconnect. A second variant replaces canonical messages
while leaving a confirmed optimistic projection alive; a third lets a
realtime event race a full-history read.
- Fix:
Serialize same-Session reads, require a full authoritative replacement for a
changed edit-retry revision, page backward from a newest-version anchor, then
drain its live tail and apply one composite Session/Turn/Message snapshot.
Source the detail projection from effective Turns. Reconcile pending intents,
attention, and terminal optimistic message-delta rows from that same Turn set
and stable `clientSubmitId` set; preserve unresolved turnless controls. Retry
transient projection failures while connected and recheck cached Sessions on
reconnect.
- Validation:
Cover missed events plus reconnect, stale unknown messages, transient
failures, revision changes during paging, optimistic initial prompts, and a
terminal delta for a retracted Turn. The final detail and transcript must
contain only effective Turns and messages while preserving attachments,
non-text input, turnless controls, and real filesystem effects.
- References:
[workspaceAgentActivityReconcileBridge.ts](../../../apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityReconcileBridge.ts)
[sessionReconcileExecutor.ts](../../../packages/agent/activity-core/src/sessionReconcileExecutor.ts)
[pendingIntents.authoritativeHistory.ts](../../../packages/agent/activity-core/src/engine/pendingIntents.authoritativeHistory.ts)
### Goal-mode recording stalls waiting for a `/goal` palette option
- Symptom:
A Session-Replay recording scenario (or any UI driver) waits forever for
`agent-gui-composer-slash-command-goal` after typing `/goal` into the
composer, while typing `/` shows only skill options.
- Quick checks:
Inspect `goalDraftObjectiveFromPrompt` in `composerDraftUtils.ts` and the
`isGoalModeActive` guard in `useComposerPaletteCatalog.ts`. Programmatic
probes that overwrite the draft via `execCommand('insertText')` can leave the
React palette draft stuck on a previous `/goal` prefix, which hides all
command entries and makes the palette look broken for unrelated queries.
- Root cause:
`/goal` is a prefix syntax, not a palette command. The moment the draft
matches `^\s*\/goal(\s+…)?$`, `isGoalModeActive` becomes true, the slash
query is nulled, and the command section of the palette closes. A palette
option for the full token can never appear.
- Fix:
Drive goal mode by typing the whole `/goal <objective>` draft, waiting for
the goal badge (`[data-agent-goal-badge="true"]`) plus an enabled send
button, then clicking send (`submitGoalPrompt` in the record-scenario CDP
helpers). Never wait for a `/goal` palette option.
- Validation:
`pnpm e2e:agent-gui -- --record … --scenario l01` proceeds past goal
activation (goal turn starts) instead of stalling on the palette wait.
- References:
`tutti-agent-session-replay-cases/scenario-runtime/cdp-helpers.mjs`
[composerDraftUtils.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/composer/composerDraftUtils.ts)
[useComposerPaletteCatalog.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/composer/useComposerPaletteCatalog.ts)
### Replay transport mismatch on `attachmentId`
- Symptom:
Replay fails with 409 `agent_session_replay_transport_mismatch` at a state
path like `$.agent.sessions[i].messages[j].payload.content[k].attachmentId`.
- Root cause:
Attachment identifiers are runtime-generated. Replay re-uploads prompt
attachments and receives fresh IDs, so byte-equality against the recorded
state can never hold.
- Fix:
Register message-content `attachmentId` values as alpha-equivalent
identities in `registerReplayIDs` (`services/tuttid/biz/agentsessionreplay/state.go`),
like session/turn/message IDs.
- Validation:
`TestCompareTuttiReplayStateTreatsAttachmentIDsAsAlphaEquivalent` in
`state_test.go`; a full record→replay of an image-input cassette passes.
- References:
[state.go](../../../services/tuttid/biz/agentsessionreplay/state.go)
### Replay transport mismatch on Goal Control `operationId`
- Symptom:
Goal cassette record and UI/provider playback succeed, but final transport
verification fails with 409 at
`$.agent.sessions[i].messages[j].payload.operationId` on the
`goal-control:*` session_audit row.
- Root cause:
Goal Control operation identities are minted per run. Replay creates a new
durable operation while replaying the same `/goal` activation, so the audit
payload's `operationId` cannot match byte-for-byte.
- Fix:
Register message-payload `operationId` values as alpha-equivalent identities
in `registerReplayIDs`, alongside session/turn/message/attachment IDs.
- Validation:
`TestCompareTuttiReplayStateTreatsGoalControlOperationIDsAsAlphaEquivalent`;
record→audit→replay of `l01_codex` (`l01`) passes.
- References:
[state.go](../../../services/tuttid/biz/agentsessionreplay/state.go)
`tutti-agent-session-replay-cases/cases/l01/scenario.mjs`
### Project-session replay never shows the restored session in the rail
- Symptom:
Replaying a project-session cassette stalls waiting for
`agent-gui-conversation-item-<sessionId>`; the conversation rail is empty.
- Root cause:
Recording seeds the project into `user_projects` before driving the UI, but
replay bootstrap did not. Sessions whose `railPlacement.kind` is `project`
render only under a project rail section, and that section only exists when
the project row is present.
- Fix:
During replay preparation, detect a `railPlacement.kind === "project"`
activity payload and seed the same project (`seedRecordingUserProject`)
before launching the desktop; enter the project surface before replaying the
activation intent.
- References:
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs)
[recording.mjs](../../../tools/scripts/agent-session-replay-runner/recording.mjs)
### Interaction replay times out at an `interaction.pending` checkpoint
- Symptom:
Replaying a cassette with an approval/question interaction fails with
`checkpoint_readiness_timeout` right before the
`interaction/responseRequested` intent; provider frames stop at the chunk
preceding the recorded decision write-back. A concurrent
`agent_session.process_start` log with empty `room_id`/`agent_session_id`
is the periodic provider availability probe, not a session relaunch — do
not chase it.
- Root cause:
Checkpoint readiness predicates compared recorded activity-layer vocabulary
(`turn.phase == "waiting_approval"`, `call.status == "running"`) against
the canonical store vocabulary (`waiting`, `waiting_approval`) by string
equality, so interaction-wait checkpoints could never become ready.
- Fix:
Fold both sides to canonical vocabulary before comparing
(`semantic_readiness.go`), and record canonical values into new checkpoint
plans (`checkpoint_provider_candidates.go`). Old cassettes stay compatible
through the replay-side fold.
- Validation:
`TestCanonicalTurnPhaseFoldsActivityVocabulary` and
`TestDescribeProviderEventFoldsTurnPhaseToCanonicalVocabulary`; replaying
approval, question, and child-approval cassettes passes.
- References:
[semantic_readiness.go](../../../services/tuttid/service/agentsessionreplay/semantic_readiness.go)
[checkpoint_provider_candidates.go](../../../services/tuttid/service/agentsessionreplay/checkpoint_provider_candidates.go)
### Replay times out waiting for idle before a busy-queue `submit/requested`
- Symptom:
Replaying a cassette that enqueues prompts while a turn is still running
(queue edit / remove / automatic drain) fails with
`timed out waiting for replay Session … to become idle` on a
`submit/requested` activity event. The session's `activeTurn.phase` stays
`running` because the filler turn only settles after later tape actions.
- Quick checks:
Inspect `activity-events.jsonl` around the failing sequence. Busy-queue
submits have no `queue/sendPrompt` / `session/activate` effect with
`causedByEventId` equal to that submit's `eventId` (they are later canceled
or drained after the active turn settles). Older cassettes may still show
`submitDiagnostics.queued: false` on those intents.
- Root cause:
The temporary runner treated every `submit/requested` with
`submitDiagnostics.queued === false` as an immediate send and waited for
session idle first. The GUI trace starts with `queued: false`, and until the
engine stamped real queue admission onto the intent, busy-queue submits were
recorded with that stale false. Waiting for idle deadlocks behind the turn
that caused the queue in the first place.
- Fix:
Stamp `submitDiagnostics.queued` from engine queue admission before the
intent is observed (`selectEngineSubmitWouldBeVisibleInQueue`). On replay,
wait for idle only when the submit caused a send/activate effect (and skip
for `queued: true`, `send_now`, and `immediate`). Re-record queue scenarios
so new tapes carry the correct diagnostic; older tapes replay via effect
causation.
- Validation:
`submit idle wait skips busy-queue submits and honors send causation`;
`semantic prompt submission reports visible queue admission for a busy Session`;
record and replay queue-only `c04_codex`; record and replay native-guidance
`c06_codex` separately.
- References:
[run-agent-session-replay.mjs](../../../tools/scripts/run-agent-session-replay.mjs)
[createAgentSessionEngine.ts](../../../packages/agent/activity-core/src/engine/createAgentSessionEngine.ts)
[promptQueue.admission.ts](../../../packages/agent/activity-core/src/engine/promptQueue.admission.ts)
### Replay deadlocks at a `turn.canceled` checkpoint of a canceled compaction
- Symptom:
Replay fails with `checkpoint_readiness_timeout` at the checkpoint after a
canceled `/compact` turn, and the transport reports a deterministic
`connection ... consumed N of M chunks` where the next unconsumed chunk is
the inbound frame right before the recorded `turn/interrupt` outbound. The
canceled turn settles only after Host's cancel grace timeout (~2 minutes),
the live connection is torn down, and the retry turn fails on a provider
relaunch that has no recorded connection.
- Root cause:
Activity-boundary checkpoint cursors were taken from the observation lane
(last provider unit that carried checkpoint observation events). A
compaction turn's interrupted `turn/completed` settles canonical state
without emitting observations, so the recorded `turn.canceled` cursor
stopped before the interrupt round trip its own readiness
(`turn.status == "canceled"`) requires. At replay the input barrier parks
the reader at that cursor, the daemon's `turn/interrupt` send waits behind
the unreleased inbound chunk, and readiness can never hold — an
unsatisfiable plan, not a replay-transport bug.
- Fix:
The recording transport already reports every completed provider input
unit; fold that handled lane into the checkpoint recorder
(`ObserveProviderInputUnit`) and record activity-boundary cursors as the
per-connection max of the observation lane and the handled lane
(`activityBoundaryCursor`). Provider-observation checkpoint cursors keep
using the observation lane only. Cassettes recorded with the stale cursor
must be re-recorded.
- Validation:
`TestActivityBoundaryCursorCoversHandledUnitsBeyondObservationLane`;
re-recording and replaying the compaction-cancel-retry cassette passes, and
the cancel-and-resend cassette replay stays green.
- References:
[checkpoint_provider_candidates.go](../../../services/tuttid/service/agentsessionreplay/checkpoint_provider_candidates.go)
[checkpoint_activity_boundaries.go](../../../services/tuttid/service/agentsessionreplay/checkpoint_activity_boundaries.go)
### New Agent conversation rejects a model that is no longer offered
- Symptom:
A newly submitted conversation fails with `model value is not supported by
agent target`, although the current model picker does not offer that model.
Alternatively, the selected model appears to start but the provider actually
continues on its own default.
- Root cause:
A target-scoped remembered composer default outlived a changed model catalog,
or a dependent reasoning default remained bound to the retired model after
model fallback,
or the ACP runtime treated a rejected startup model selection as a
best-effort setting and silently retained the provider default. Reapplying a
model that `session/new` already selected can also trigger an unnecessary
provider-side reconfiguration failure.
- Fix:
At Create, distinguish a target-scoped persisted default from a model
explicitly supplied by the caller. For a provider or Agent Extension,
resolve an obsolete persisted default to the current catalog default
reported by that same target; never use a different provider. Resolve
non-explicit per-model reasoning against that effective model while keeping
explicit caller values strict. A strict per-model reasoning catalog must
omit an inherited value when it cannot prove that the target model supports
it. Treat an explicit ACP model selection as identity-bearing: leave an
already-selected model unchanged, and if a real model change is rejected,
abort startup rather than falling back.
- Validation:
Cover an obsolete persisted default with a multi-model catalog whose reported
current model is not first, a stale dependent reasoning default, and an
unsupported explicit selection separately with generic extension fixtures.
Inject a `session/set_model` rejection into the standard ACP transport test.
### Agent send is stopped while an earlier provider process is still exiting
- Symptom:
After an idle Standard ACP, Codex, Tutti Agent, or Claude session is released,
the next message may reconnect once, but a later Start/Resume returns
`workspace_operation_failed` with reason
`agent.process_cleanup_pending`. The message does not reach the provider; in
AgentGUI the submitted draft is restored and a localized retry message is
shown.
- Quick checks:
Correlate `agent_session.acp.close` stages with
`agent_session.live_resource_cleanup.failed`. A close failure followed by one
replacement-process start, then no additional start for the rejected retry,
confirms cleanup backpressure rather than provider prompt rejection.
- Root cause:
A provider process can ignore graceful termination and fail the bounded
transport Close. Dropping that handle allows unbounded orphan processes;
allowing its late inbound handler to remain active can also attribute old
output or approval requests to the replacement Turn.
- Fix:
Keep failed and replaced clients under their adapter's ownership, quarantine
their message handlers, and retry at most one failed Close budget per adapter
sweep. Codex and Tutti Agent mark a close-failed current client unusable;
Claude also rejects every late event whose physical connection is no longer
the current Session generation.
Once a failed handle is retired, Start/Resume performs one bounded cleanup
attempt and refuses to spawn another process while cleanup remains pending.
Preserve the stable reason through the Host/API boundary so AgentGUI can
restore the draft and present i18n copy.
- Validation:
Cover release failure followed by one successful replacement, a blocked next
Resume with unchanged spawn/prompt counts, startup/load failure retention,
stale retired-client output, Plan-mode persistence without an eager spawn,
per-adapter sweep budgets, API classification, localized presentation, and
failed-submit draft restoration.
- References:
[standard_acp_session.go](../../../packages/agent/daemon/runtime/standard_acp_session.go)
[standard_acp_resource_ownership.go](../../../packages/agent/daemon/runtime/standard_acp_resource_ownership.go)
[codex_appserver_resource_ownership.go](../../../packages/agent/daemon/runtime/codex_appserver_resource_ownership.go)
[claude_sdk_resource_ownership.go](../../../packages/agent/daemon/runtime/claude_sdk_resource_ownership.go)
[AgentGUIEngineSettlementController.ts](../../../packages/agent/gui/agent-gui/agentGuiNode/controller/AgentGUIEngineSettlementController.ts)
### Codex rejects `turn/start` with `AbsolutePathBuf deserialized without a base path`
- Symptom:
Codex initializes and `thread/start` succeeds, but the first `turn/start`
fails with JSON-RPC `-32600` and `AbsolutePathBuf deserialized without a base
path`. On a managed POSIX runtime, another form accepts the Turn but every
tool command, including `pwd`, fails with
`Failed to create unified exec process: No such file or directory (os error 2)`
even though provider-process CWD preflight succeeded.
- Root cause:
Tutti sent the POSIX-only `/sandbox-tmp` writable root as though it were a
portable absolute host path. Codex's Windows `AbsolutePathBuf` parser rejects
it even when the request also carries `cwd`; the per-turn working-directory
override does not make a POSIX-rooted string into a Windows absolute path.
Tutti also once omitted the Session `cwd` from the Turn override. Supplying
that field from the raw persisted Session value introduced the inverse POSIX
failure: a stored `/workspace/<room-id>` mount path escaped the managed
Agent's logical `/workspace` view even though `thread/start` and the provider
process had already projected it. Request-shape mocks accepted these invalid
combinations without exercising the real process or Rust parser boundary.
- Fix:
Send the non-empty provider-visible Session `cwd` on every Codex
`turn/start`. Apply the same room-mount-to-logical-workspace projection used
by `thread/start` and provider launch while preserving native Windows paths.
Omit the POSIX `/sandbox-tmp` projection on Windows, and keep it on POSIX
hosts where it represents the logical `/tmp` write target.
- Validation:
Cover a stored room mount root and child path, an already-logical workspace
path, and a native Windows path in the emitted `turn/start.cwd`. Cover Windows
and POSIX sandbox policy construction, and run the Windows contract test
against a real pinned `codex.exe app-server`. The contract test submits the
historical payload both without and with `cwd` as negative controls, then
verifies that the production payload crosses the same parser without an
`AbsolutePathBuf` error.
### A Windows Codex session disappears while auth projection is starting
- Symptom:
session creation stalls until its request is canceled, and no runnable
session remains visible. The log ends in Mutagen `context canceled` during
auth projection.
- Root cause:
Mutagen setup inherited the transport request context. Closing or timing out
that request killed a partially-created external sync session before runtime
publication.
- Fix:
treat auth projection as Host-owned startup work: detach it from request
cancellation, bound each Mutagen command to 20 seconds, and serialize setup
for the same stable auth source. A guarded copy fallback is allowed only when
Mutagen is unavailable or a failed session was successfully terminated; if
cleanup cannot be confirmed, fail without starting a second projection.
- Validation:
cancel the caller context before projection and verify that the injected
resolver and runner receive a live bounded context. Cover create and flush
timeout, cleanup failure, copy reconciliation, and concurrent projections
sharing one auth source.