Skip to content

fix(gemini): preserve thought_signature on stored tool calls; retry burst 429s; enable compaction - #1256

Open
adminwat wants to merge 6 commits into
1jehuang:masterfrom
adminwat:v0840-gemini-fixes
Open

adminwat wants to merge 6 commits into
1jehuang:masterfrom
adminwat:v0840-gemini-fixes

Conversation

@adminwat

Copy link
Copy Markdown

Three fixes on top of v0.84.0 for Gemini "works on turn 1, fails on turn 2":

  1. thought_signature dropped when storing tool calls (turn_streaming_mpsc.rs, turn.rs). The signature was captured from StreamEvent::ToolUseSignature but the persisted ContentBlock::ToolUse was hand-built with thought_signature: None in the mpsc/TUI paths (only turn_loops.rs carried it). Turn 2 replayed an unsigned history and Gemini 3.x returned HTTP 400 Function call is missing a thought_signature. Construction now goes through ToolCall::to_tool_use_block(). Three regression tests in jcode-message-types; falsified by restoring None (2 of 3 fail).
  2. Burst 429 from Code Assist killed the turn. Gemini was the only runtime with no 429 retry. Now retries up to 5x with backoff.
  3. Gemini/Cursor/Antigravity had no compaction path (supports_compaction() == false skipped even the emergency hard-compact).

Verified on a daemon-hosted mpsc session: two sequential tool calls on gemini-2.5-pro (server-upgraded to gemini-3.1-pro-preview), persisted history carries thought_signature on every tool call, zero stream_error.

Root cause of the recurring HTTP 400 on the *second* message of a Gemini
session:

  Function call is missing a thought_signature in functionCall parts.
  ... function call `default_api:websearch`, position 10.

The signature was ingested and streamed correctly (ToolUseSignature ->
ToolCall.thought_signature), but every turn loop then hand-built the
persisted ContentBlock::ToolUse, and three of the four copies hardcoded
`thought_signature: None`:

  jcode-tui/src/tui/app/turn.rs:333,401  (interrupt paths)
  jcode-tui/src/tui/app/turn.rs:1087     (main TUI path - every turn)
  jcode-app-core/src/agent/turn_streaming_mpsc.rs:1081

Only turn_loops.rs:785 carried it through. So the signature was captured,
then discarded the moment the assistant message was stored: turn 2
replayed a fully-unsigned history and the backend rejected it. Retrying
could never help, because the stored history was already stripped.

Rather than fix four copies and leave the fifth to rot, the construction
moves to ToolCall::to_tool_use_block(), the single place a completed tool
call becomes a stored block. Duplication is what let this diverge.

This is the real fix for the failure that PR 1jehuang#1 (branch
fix/gemini-missing-thought-signature-recovery) only mitigated: that
downgrade-to-text retry salvages an already-stripped history, but with
signatures preserved it should now rarely be reached.

Tests: three regression tests in jcode-message-types covering signature
preservation, session-JSON round-trip (resumed sessions hit the same 400
if the field is lost on disk), and clean omission for providers that do
not use signatures. Falsified by restoring `None`: 2 of 3 fail.

jcode-app-core has 9 pre-existing unrelated failures, identical with and
without this change (verified via stash). fmt clean; jcode-tui clippy
warning count unchanged at 48.
supports_compaction() gates the entire compaction block in
Agent::messages_for_provider, including the emergency hard-compact and
payload truncation that fire at the 95% critical threshold. These three
runtimes returned a hardcoded false and implement no native_compact, so
they had no compaction of any kind: a long session grew unchecked until the
provider rejected the prompt, with nothing able to rescue it. Unlike OpenAI
there was no config escape hatch either.

None of the three has native server-side compaction to conflict with, and
all three inherit the default complete_simple(), which is what
generate_compaction_artifact falls back to for the text summary. So opting
in gives them the full engine: proactive/semantic folding, hard compact and
emergency truncation.

Latent rather than observed today only because no Gemini session on the
test machine had yet exceeded 50 messages; Gemini advertises a 1M window,
so the failure arrives late and all at once.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T21:35:17.175274Z a5489f9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5489f9c89

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// disabled the entire compaction block in `Agent::messages_for_provider`
// — including the emergency hard-compact and payload truncation at the
// critical threshold — leaving these sessions with no safety net at all.
true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Cursor compaction off until summaries bypass prompt truncation

When a Cursor session is large enough to compact, the generic compactor sends the selected history through complete_simple, which reaches build_cli_prompt; that function retains only the final 120,000 characters (MAX_PROMPT_CHARS, lines 28 and 96-105). The compaction manager nevertheless marks every selected message as covered by the resulting summary, so an oversized compaction prompt silently omits the earliest conversation while hiding all of those original messages from subsequent requests. Keep this disabled or provide a chunked/non-truncating summary path for Cursor.

Useful? React with 👍 / 👎.

Comment on lines +400 to +401
if status == reqwest::StatusCode::TOO_MANY_REQUESTS && attempt < MAX_429_RETRIES {
let delay = Duration::from_millis(1500u64.saturating_mul(1u64 << attempt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict retries to transient 429 responses

When Code Assist returns a persistent 429 for exhausted quota or billing limits, this condition retries solely by status and ignores the error body and any server retry interval. It therefore issues five doomed requests and delays the actionable error by 46.5 seconds; conversely, a transient limit with a retry interval longer than that can exhaust all attempts prematurely. Classify the body or honor the server-provided retry delay before entering this burst-retry loop.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

Not safe to merge until the outstanding request-retry and provider-context defects are addressed. The reproduced live-configuration issue also prevents a changed compaction limit from taking effect for existing sessions.

Findings

  1. P1 Refresh Compaction Cap
  2. P1 Honor Retry-After Timing
  3. P1 Keep Switched-Provider Context
  4. P1 Prevent Duplicate Generations
Fix with agent prompt
### Issue 1
crates/jcode-base/src/compaction.rs:245
When `[compaction] max_context_tokens` changes during a running session, the existing compaction manager continues to use the value captured when it was created. After the global configuration reloads from 50,000 to 10,000, calling `set_budget(1_000_000)` still permits 50,000 tokens for that session, while a newly created manager uses 10,000. This can leave a long-running session 40,000 tokens above an operator's newly lowered context and cost limit.

### Issue 2
crates/jcode-provider-gemini-runtime/src/lib.rs:406-416
The new 429 handling retries only after fixed 1.5/3/6/12/24-second delays and never reads `Retry-After`. When the provider directs the client to wait longer, all six attempts can be consumed before requests are permitted again. A response with `Retry-After: 60` exhausts the final attempt at about 46.6 seconds, causing the turn to fail even though waiting for the server-provided deadline would allow a later retry.

### Issue 3
crates/jcode-provider-gemini-runtime/src/lib.rs:1102-1109
Enabling compaction for Gemini exposes OpenAI-native compaction state to a conversion path that cannot replay it. Gemini and Antigravity omit the encrypted compaction block entirely, while Cursor replaces it with a fixed marker. After switching providers, the request loses the compacted earlier conversation and contains only the active suffix, so the model can no longer use that prior context.

### Issue 4
crates/jcode-provider-gemini-runtime/src/lib.rs:403-405
If Code Assist completes a `generateContent` request before responding with a 5xx, this loop sends the identical POST again without an idempotency or deduplication key. The provider can run and bill the generation twice while jcode discards the first result; `onboardUser` uses the same helper and has the same replay behavior. Retry only failures where no response was received, or use the provider-supported idempotency mechanism.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This change adds a configurable compaction context limit and applies it when model budgets are set. A running session, however, continues using the limit captured when its compaction manager was created after configuration reloads. The existing Gemini retry, duplicate-request, and provider-switching context issues also remain unresolved.

Reviews (3) · Last reviewed commit: "feat(compaction): max_context_tokens cap..."

Comment on lines +401 to +410
let delay = Duration::from_millis(1500u64.saturating_mul(1u64 << attempt));
jcode_base::logging::warn(&format!(
"Gemini {} hit burst 429 (attempt {}/{}); retrying in {:?}",
method,
attempt + 1,
MAX_429_RETRIES,
delay
));
attempt += 1;
tokio::time::sleep(delay).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Honor Retry-After Timing

The new 429 handling retries only after fixed 1.5/3/6/12/24-second delays and never reads Retry-After. When the provider directs the client to wait longer, all six attempts can be consumed before requests are permitted again. A response with Retry-After: 60 exhausts the final attempt at about 46.6 seconds, causing the turn to fail even though waiting for the server-provided deadline would allow a later retry.

Artifacts

Evidence from the check

  • Runs an injected in-module test against the parent and current revisions with a local 429 endpoint that sends Retry-After: 60, exercising the actual post_json retry path; it demonstrates the compared behavior.

Command output from the check

  • Captured command output for the parent revision shows one local HTTP 429 Too Many Requests response with Retry-After: 60 and one request at 30 ms; before the change there was no 429 retry.

Command output from the check

  • Captured command output for the current revision shows six local HTTP 429 Too Many Requests responses despite Retry-After: 60, with the final attempt at 46.568 seconds; the retry budget is exhausted before provider availability.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-provider-gemini-runtime/src/lib.rs
Line: 401-410

Comment:
**Honor Retry-After Timing**

The new 429 handling retries only after fixed 1.5/3/6/12/24-second delays and never reads `Retry-After`. When the provider directs the client to wait longer, all six attempts can be consumed before requests are permitted again. A response with `Retry-After: 60` exhausts the final attempt at about 46.6 seconds, causing the turn to fail even though waiting for the server-provided deadline would allow a later retry.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1096 to 1103
// No native server-side compaction exists for this provider, so jcode's
// own summary compaction is the only thing standing between a long
// session and a hard context-limit rejection. Returning `false` here
// disabled the entire compaction block in `Agent::messages_for_provider`
// — including the emergency hard-compact and payload truncation at the
// critical threshold — leaving these sessions with no safety net at all.
true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keep Switched-Provider Context

Enabling compaction for Gemini exposes OpenAI-native compaction state to a conversion path that cannot replay it. Gemini and Antigravity omit the encrypted compaction block entirely, while Cursor replaces it with a fixed marker. After switching providers, the request loses the compacted earlier conversation and contains only the active suffix, so the model can no longer use that prior context.

Artifacts

Evidence from the check

  • The authored executable creates isolated before and after worktrees, injects focused runtime tests, and captures the provider conversion results; it demonstrates the exact exercised source path.

Command output from the check

  • The executed `HEAD^` run shows all three providers had compaction disabled while the same Gemini, Antigravity, and Cursor conversions respectively omit or reduce the OpenAI-native block; the unsafe conversion existed but was not enabled by this PR.

Command output from the check

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-provider-gemini-runtime/src/lib.rs
Line: 1096-1103

Comment:
**Keep Switched-Provider Context**

Enabling compaction for Gemini exposes OpenAI-native compaction state to a conversion path that cannot replay it. Gemini and Antigravity omit the encrypted compaction block entirely, while Cursor replaces it with a fixed marker. After switching providers, the request loses the compacted earlier conversation and contains only the active suffix, so the model can no longer use that prior context.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

…onId

Gemini/Claude-CLI/Grok emit StreamEvent::SessionId with the *provider* resume
handle. The mpsc turn loop re-broadcast it as ServerEvent::SessionId, which
the TUI treats as the jcode session id and stores in remote_session_id. Every
later reload/reconnect then ran 'jcode --resume <provider-id>', the server
found no such session and created a fresh empty one, and the user saw their
entire conversation vanish while the transcript sat intact on disk.
Comment on lines +403 to +405
let transient =
status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
if transient && attempt < MAX_429_RETRIES {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Prevent Duplicate Generations

If Code Assist completes a generateContent request before responding with a 5xx, this loop sends the identical POST again without an idempotency or deduplication key. The provider can run and bill the generation twice while jcode discards the first result; onboardUser uses the same helper and has the same replay behavior. Retry only failures where no response was received, or use the provider-supported idempotency mechanism.

Artifacts

Evidence from the check

  • The executed shell harness starts a local Code Assist-compatible server, returns a 500 then 200 for generateContent, and records each received request, demonstrating the precise retry contract.

Command output from the check

  • Combined output from executing the local harness shows HTTP 500 followed by HTTP 200, identical request body and user prompt ID, and no idempotency or deduplication headers, confirming an unkeyed replay.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-provider-gemini-runtime/src/lib.rs
Line: 403-405

Comment:
**Prevent Duplicate Generations**

If Code Assist completes a `generateContent` request before responding with a 5xx, this loop sends the identical POST again without an idempotency or deduplication key. The provider can run and bill the generation twice while jcode discards the first result; `onboardUser` uses the same helper and has the same replay behavior. Retry only failures where no response was received, or use the provider-supported idempotency mechanism.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

…at a bounded size

Every turn re-sends the whole transcript. With Gemini's 1M window and the
0.80 trigger, a session could reach ~800k tokens per request before folding;
one real session hit 371k/turn (1.3 MB JSON) with compaction never firing.
[compaction] max_context_tokens bounds the budget in the single set_budget
funnel so every provider path (agent, TUI, model switch) honors it. 0 = off.
/// Update the token budget (e.g., when model changes)
pub fn set_budget(&mut self, budget: usize) {
self.token_budget = budget;
self.token_budget = Self::capped_budget(&self.compaction_config, budget);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Refresh Compaction Cap

When [compaction] max_context_tokens changes during a running session, the existing compaction manager continues to use the value captured when it was created. After the global configuration reloads from 50,000 to 10,000, calling set_budget(1_000_000) still permits 50,000 tokens for that session, while a newly created manager uses 10,000. This can leave a long-running session 40,000 tokens above an operator's newly lowered context and cost limit.

Artifacts

Initial compaction limit run

  • The initial run shows that a manager constructed with a 50,000-token limit applies that limit.

Reloaded compaction limit run

  • The reload run shows the existing manager remains at 50,000 while a new manager uses the reloaded 10,000 limit.

Compaction reload harness

  • The focused Rust harness keeps one manager alive across the configuration reload and compares it with a new manager.

Compaction reload harness manifest

  • The manifest defines the isolated executable used to exercise the reload behavior.

Compaction reload runner

  • The runner executes and records both the initial and reloaded configuration cases.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-base/src/compaction.rs
Line: 245

Comment:
**Refresh Compaction Cap**

When `[compaction] max_context_tokens` changes during a running session, the existing compaction manager continues to use the value captured when it was created. After the global configuration reloads from 50,000 to 10,000, calling `set_budget(1_000_000)` still permits 50,000 tokens for that session, while a newly created manager uses 10,000. This can leave a long-running session 40,000 tokens above an operator's newly lowered context and cost limit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant