[Bug]: OmO Native Claude continuity: repeated full resends and unsafe checkpoint recovery (beta.68)
Prerequisites
- This report is in English.
- Existing issues were searched; related reports are linked below.
- The affected Native installation is the current npm beta:
npm view omo-ai@beta versionreturned5.0.0-0.beta.68on 2026-09-17. - The existing reports and installed implementation were investigated; the findings below distinguish separate failure paths rather than treating every continuity notice as the same bug.
Bug Description
OmO Native's claude-sdk-oauth adapter has several independent continuity/recovery defects. They cause avoidable full-history resends, and one detached-checkpoint case can retain the wrong branch's context.
This report consolidates a current-version reproduction, local fixes, and regression results. Some findings extend existing reports:
- https://github.com/code-yeongyu/senpi/issues/1472: eval summary mutation, still present in this installed beta before the local patch.
- https://github.com/code-yeongyu/oh-my-openagent/issues/7925: older, closed resume/rewrite cascade report.
- https://github.com/code-yeongyu/oh-my-openagent/issues/8308: separate model-selection invalidation/reason-loss case. The terminal handoff here did not require changing models.
The new findings are the startup-notification/restore interaction, startup-triggered turns racing restore, and missing/divergent SDK checkpoint recovery.
Environment
- Edition: OmO Native, not the OpenCode plugin.
omo-ai:5.0.0-0.beta.68.- Bundled Senpi and senpi-codemode:
2026.9.16-3. - macOS / Darwin
25.5.0, arm64, Apple M4. - Actual agent runtime: Node
24.15.0. - Bun
1.3.14was used only for local module compilation. - Provider:
claude-sdk-oauth. - Reported long-session failures used
claude-fable-5-1. - Separate small live verification used
claude-opus-5, thenclaude-sonnet-5.
Actual Behavior and Impact
These diagnostics were matched to the local transcript, not inferred from warning text alone:
| Event on 2026-09-17 | Diagnostic | Resent payload | Cache-write tokens | Cache-read tokens |
|---|---|---|---|---|
| 14:48:31 UTC | flatten / assistant_rewritten |
742,307 bytes; UI 724.9KB; 100 messages |
310,416 | 32,775 |
| 15:11:11 UTC | flatten / registry_miss after terminal handoff |
905,874 bytes; UI 884.6KB; 136 messages |
397,299 | 0 |
| 15:19:15 UTC | flatten / tainted_compaction |
99,466 bytes; UI 97.1KB; 29 compacted messages |
41,516 | 25,458 |
The first two are avoidable churn. The third is not being presented as another confirmed defect: an accepted compaction replaced history with a summary, one compacted-context rebuild followed, then one toolset-change reattach and seven healthy delta / prefix_matched turns. The same alarming "Session continuity lost" wording obscures this distinction.
Repeated large cache writes coincide with rapid allowance exhaustion, but the exact Anthropic subscription-limit charging formula is not exposed. These figures are observed usage, not a claimed quota-savings percentage.
Findings and Local Fixes
Paths below are relative to the installed Senpi package unless prefixed with senpi-codemode. The corresponding upstream source is under packages/coding-agent/src/ or packages/senpi-codemode/src/.
1. Eval argument normalization mutates the authoritative assistant
senpi-codemode/src/tool/eval-tool.ts, prepareArguments, modifies record.summary in place.
For the exact 724.9 KB resend, Claude's original tool arguments contained a 92-character summary; the committed OmO arguments contained 80 characters ending in .... All other tool arguments matched. The saved summary exactly matched the clamp operation.
That session had 11 rewrite invalidations, each accompanying a clamped 80-character summary. The raw-original comparison was independently made for the exact reported occurrence.
Local fix: prepare an execution copy; retain provider arguments verbatim. Independently clamp the raw-call renderer's display value in render.ts.
Do not normalize authoritative history or exclude summaries from the continuity hash: a genuine semantic edit, including a changed suffix beyond the display limit, must still invalidate.
2. Startup notifications prematurely reject a valid stored binding
dist/core/extensions/builtin/claude-sdk-oauth/session-binding.js, bindingFromStoredBranch, requires every entry after the anchored assistant to be ledger-only.
When a new terminal opens the same session while the old process is still alive, terminal startup appends:
Terminal monitors for this session are attached in another live process (pid <redacted>); nothing was restored here.That is a model-visible custom_message of type senpi-terminal:notification. The old validator rejects it; session-registry-wiring.js then deletes the stored binding on rejection.
An offline toggle using the captured startup tail and a reconstructed valid anchor restored successfully without the notification and rejected with it. The original pre-restart sidecar snapshot was not retained, so its exact historical contents are not claimed.
Local fix: accept append-only custom messages, user messages, and tool results after the anchor, while retaining the committed-assistant hash and subsequent transmitted-prefix verification. Compaction, branch summaries, uncheckpointed assistants, explicit invalidations, and changed transmitted prefixes still fail closed.
3. A startup-triggered turn can run before native restoration
Fixing tail validation alone was insufficient. A small real Sonnet RPC handoff still flattened.
The terminal startup handler can immediately trigger a model turn before the native Claude session_start handler restores its binding. Sequential handler registration order is therefore observable, not just a display concern.
Local fix: prioritize the native Claude extension only when dispatching session_start in dist/core/extensions/runner.js.
An initially broader builtin reorder was rejected and reverted: a new negative test showed that moving all Claude handlers earlier could hide a message_end hook rewrite. The final change preserves ordinary event and terminal-rewrite ordering. Both the startup-order test and genuine-hook-rewrite negative now pass.
4. Recoverable earlier checkpoints are unnecessarily abandoned
Two cases in session-continuity.js / session-stream.js:
- Same-turn retry has no
lastAssistantUuid, but an earlier mapped boundary exists inside the hash-proven prefix. - A live
resumeSessionAtUUID is absent from the SDK transcript, but an earlier mapped top-level assistant still exists.
Local fix: use an earlier mapped boundary when safe. For rejected initialization checkpoints, intersect candidates with the actual SDK transcript, require the same SDK session and top-level assistant, and require a matching transmitted prefix. Set both the UUID and resend offset from that same boundary. Each attempted candidate strictly decreases the index.
Config-directory cross-root restrictions, unreadable/mismatched transcripts, and subagent-only candidates remain fail-closed. Do not guess the latest transcript assistant or plain-resume an unrelated tail.
If no independently recoverable checkpoint exists, a complete resend of the available application history is the correct fallback. The persisted sidecar still stores only one checkpoint; this patch does not invent an older mapping across process restart.
5. Missing UUID errors can republish the same rejected retry checkpoint
The SDK can report a missing UUID during initialization, through its iterator, or through an error result:
No message found with message.uuid: <redacted>session-turn-attempt.js previously recognized a missing conversation ID, but not a missing message UUID. Its error and discard() paths could republish the rejected checkpoint, causing the next admission to request the same invalid UUID again.
Local fix: remove the failed pre-turn UUID from the checkpoint map before publishing retry state. Preserve earlier mapped checkpoints. A subsequent admission cannot reuse that known-missing point; the original streaming failure is still surfaced, not silently swallowed.
6. Detached divergence can combine an unsafe UUID with a different resend offset
The detached in-memory binding path computed the common prefix length but returned:
atUuid = binding.lastAssistantUuid
from = commonPrefixLengthThose can refer to different boundaries. An offline SDK-lineage fixture demonstrated that the adapter could retain an unrelated old-branch assistant while appending replacement user history.
Local fix: select a mapped assistant inside the hash-proven shared prefix and use that exact index for from. If there is no safe mapped boundary, rebuild from the available application history instead.
These are local installed-module changes, not an upstream PR. Replace <SENPI_ROOT> with the installed Senpi package root to interpret paths; maintainers should port the equivalent changes to source TypeScript and regenerate declarations/build output. No source maps are updated by these local patches.
*** Begin Patch
*** Update File: <SENPI_ROOT>/dist/core/extensions/builtin/claude-sdk-oauth/session-binding.js
@@
if (marker?.id !== stored.markerEntryId || !isBindingMarker(marker.data))
return undefined;
const assistantIndex = committedAssistantIndex(branch, markerIndex + 1);
- if (assistantIndex < 0 || !branch.slice(assistantIndex + 1).every(isLedgerOnlyEntry))
+ // Startup notices, user input, and tool results extend the saved prefix;
+ // they are unsent deltas, not rewrites of its committed assistant. The
+ // continuity decision still verifies sentPrefixHash before SDK reattachment.
+ if (assistantIndex < 0 || !branch.slice(assistantIndex + 1).every((entry) => isLedgerOnlyEntry(entry) ||
+ entry.type === "custom_message" ||
+ (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "toolResult"))))
return undefined;
const committedAssistant = branch[assistantIndex]?.message;
if (!isAssistantMessage(committedAssistant))
*** Update File: <SENPI_ROOT>/dist/core/extensions/builtin/claude-sdk-oauth/session-continuity.js
@@
: commonPrefixLength(binding.sentHashes, input.currentHashes) === binding.sentCount;
if (!prefixMatches)
return undefined;
- if (!binding.lastAssistantUuid)
- return { kind: "flatten", reason: "timeout_retry" };
- return {
- kind: "fork",
- sdkSessionId: binding.sdkSessionId,
- atUuid: binding.lastAssistantUuid,
- from: binding.sentCount,
- reason: "timeout_retry",
- };
-}
-/**
- * A binding whose SDK id was minted locally and never acknowledged (no init, no
- * replay echo before the attempt failed) must not be resumed: Claude Code
- * answers "No conversation found with session ID" and every retry would mint
- * another dead id (oh-my-openagent#7562). Cold-seed instead.
- */
-function withoutUnconfirmedResume(decision, binding) {
- if (binding.sdkSessionIdConfirmed !== false)
- return decision;
- if (decision.kind === "reattach" || decision.kind === "fork") {
- return { kind: "flatten", reason: "session_unconfirmed" };
- }
- return decision;
-}
-function decideFromBinding(input, binding) {
- if (!input.transcriptAvailable)
- return { kind: "flatten", reason: "transcript_missing" };
- const drift = identityDrift(input, binding);
- // Model identity drift fails closed: the persisted identity no longer matches the turn.
- // Account drift flattens only on the config-dir lane, whose per-account roots cannot
- // share a transcript; on shared-root lanes it falls through like prompt/toolset drift
- // (senpi#1432), so the retry checkpoint forks a same-turn failover at the pre-turn
- // boundary and a matching prefix reattaches with reason account_changed.
- if (drift === "model_changed")
- return { kind: "flatten", reason: drift };
- if (drift === "account_changed" && !input.crossAccountResumeSupported)
- return { kind: "flatten", reason: "cross_root_unsupported" };
- // Prompt/toolset drift instead reattaches like the live path
- // (oh-my-openagent#7884) - a restart has no live query, so the resume builds a
- // fresh query carrying the CURRENT options and hooks, and flattening would
- // re-send the whole conversation for drift the SDK applies per-query anyway.
- const retry = retryCheckpointDecision(input, binding);
- if (retry)
- return retry;
- if (binding.sentPrefixHash !== undefined) {
- const prefixMatches = input.currentHashes.length >= binding.sentCount &&
- sentHashPrefixDigest(input.currentHashes, binding.sentCount) === binding.sentPrefixHash;
- if (prefixMatches) {
- return {
- kind: "reattach",
- sdkSessionId: binding.sdkSessionId,
- from: binding.sentCount,
- reason: drift ?? "registry_miss",
- };
- }
- return {
- kind: "flatten",
- reason: input.currentHashes.length < binding.sentCount ? "history_rolled_back" : "sent_stream_diverged",
- };
- }
- const shared = commonPrefixLength(binding.sentHashes, input.currentHashes);
- if (shared === binding.sentCount) {
- return {
- kind: "reattach",
- sdkSessionId: binding.sdkSessionId,
- from: binding.sentCount,
- reason: drift ?? "registry_miss",
- };
- }
- if (!binding.lastAssistantUuid)
- return { kind: "flatten", reason: "registry_miss" };
- return {
- kind: "fork",
- sdkSessionId: binding.sdkSessionId,
- atUuid: binding.lastAssistantUuid,
- from: shared,
+ const boundary = binding.lastAssistantUuid
+ ? { index: binding.sentCount, uuid: binding.lastAssistantUuid }
+ : boundaryBefore({ assistantUuidByIndex: new Map(binding.assistantUuidByIndex ?? []) }, binding.sentCount + 1);
+ if (!boundary)
+ return { kind: "flatten", reason: "timeout_retry" };
+ return {
+ kind: "fork",
+ sdkSessionId: binding.sdkSessionId,
+ atUuid: boundary.uuid,
+ from: boundary.index,
+ reason: "timeout_retry",
+ };
+}
+/**
+ * A binding whose SDK id was minted locally and never acknowledged (no init, no
+ * replay echo before the attempt failed) must not be resumed: Claude Code
+ * answers "No conversation found with session ID" and every retry would mint
+ * another dead id (oh-my-openagent#7562). Cold-seed instead.
+ */
+function withoutUnconfirmedResume(decision, binding) {
+ if (binding.sdkSessionIdConfirmed !== false)
+ return decision;
+ if (decision.kind === "reattach" || decision.kind === "fork") {
+ return { kind: "flatten", reason: "session_unconfirmed" };
+ }
+ return decision;
+}
+function decideFromBinding(input, binding) {
+ if (!input.transcriptAvailable)
+ return { kind: "flatten", reason: "transcript_missing" };
+ const drift = identityDrift(input, binding);
+ // Model identity drift fails closed: the persisted identity no longer matches the turn.
+ // Account drift flattens only on the config-dir lane, whose per-account roots cannot
+ // share a transcript; on shared-root lanes it falls through like prompt/toolset drift
+ // (senpi#1432), so the retry checkpoint forks a same-turn failover at the pre-turn
+ // boundary and a matching prefix reattaches with reason account_changed.
+ if (drift === "model_changed")
+ return { kind: "flatten", reason: drift };
+ if (drift === "account_changed" && !input.crossAccountResumeSupported)
+ return { kind: "flatten", reason: "cross_root_unsupported" };
+ // Prompt/toolset drift instead reattaches like the live path
+ // (oh-my-openagent#7884) - a restart has no live query, so the resume builds a
+ // fresh query carrying the CURRENT options and hooks, and flattening would
+ // re-send the whole conversation for drift the SDK applies per-query anyway.
+ const retry = retryCheckpointDecision(input, binding);
+ if (retry)
+ return retry;
+ if (binding.sentPrefixHash !== undefined) {
+ const prefixMatches = input.currentHashes.length >= binding.sentCount &&
+ sentHashPrefixDigest(input.currentHashes, binding.sentCount) === binding.sentPrefixHash;
+ if (prefixMatches) {
+ return {
+ kind: "reattach",
+ sdkSessionId: binding.sdkSessionId,
+ from: binding.sentCount,
+ reason: drift ?? "registry_miss",
+ };
+ }
+ return {
+ kind: "flatten",
+ reason: input.currentHashes.length < binding.sentCount ? "history_rolled_back" : "sent_stream_diverged",
+ };
+ }
+ const shared = commonPrefixLength(binding.sentHashes, input.currentHashes);
+ if (shared === binding.sentCount) {
+ return {
+ kind: "reattach",
+ sdkSessionId: binding.sdkSessionId,
+ from: binding.sentCount,
+ reason: drift ?? "registry_miss",
+ };
+ }
+ // The SDK boundary and resend offset must describe the same proven prefix.
+ // The last assistant may belong to the divergent part of the old branch.
+ const boundary = boundaryBefore({ assistantUuidByIndex: new Map(binding.assistantUuidByIndex ?? []) }, shared + 1);
+ if (!boundary)
+ return { kind: "flatten", reason: "sent_stream_diverged" };
+ return {
+ kind: "fork",
+ sdkSessionId: binding.sdkSessionId,
+ atUuid: boundary.uuid,
+ from: boundary.index,
reason: shared < binding.sentCount ? "history_rolled_back" : "sent_stream_diverged",
};
}
*** Update File: <SENPI_ROOT>/dist/core/extensions/builtin/claude-sdk-oauth/session-continuity.d.ts
@@
sentPrefixHash?: string;
lastAssistantUuid: string | null;
accountName: string;
+ /** Known SDK boundaries paired with their transmitted-message prefix lengths. */
+ assistantUuidByIndex?: readonly (readonly [number, string])[];
modelId: string;
systemPromptHash: string;
toolsetHash: string;
*** Update File: <SENPI_ROOT>/dist/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.js
@@
forgetBinding(entry.senpiSessionId);
}
else {
+ // A rejected resume boundary must not be republished by
+ // catch or discard. Keep earlier mapped checkpoints.
+ if (error instanceof Error && /no message found with message\.uuid/i.test(error.message)) {
+ entry.assistantUuidByIndex.delete(entry.sentCount);
+ }
rememberRetryCheckpoint(entry, hashes);
}
throw error;
*** Update File: <SENPI_ROOT>/dist/core/extensions/builtin/claude-sdk-oauth/session-reattach.js
@@
message.uuid === binding.lastAssistantUuid &&
message.parent_tool_use_id === null);
}
+/** Recover only a mapped, hash-proven prefix before a rejected SDK boundary. */
+export async function earlierVerifiedCheckpoint({ binding, currentHashes, cwd, authLane }) {
+ if (cwd === undefined || authLane === "config-dir")
+ return undefined;
+ const candidates = (binding.assistantUuidByIndex ?? [])
+ .filter(([index]) => index > 0 && index < binding.sentCount &&
+ index <= binding.sentHashes.length && index <= currentHashes.length &&
+ binding.sentHashes.slice(0, index).every((hash, offset) => hash =Source: code-yeongyu/oh-my-openagent