Skip to content

fix: preserve durable session IDs across turns for Cursor/OpenCode - #34

Merged
ranvier2d2 merged 2 commits into
mainfrom
devin/1774797081-fix-harness-multi-turn
Mar 29, 2026
Merged

fix: preserve durable session IDs across turns for Cursor/OpenCode#34
ranvier2d2 merged 2 commits into
mainfrom
devin/1774797081-fix-harness-multi-turn

Conversation

@ranvier2d2

@ranvier2d2 ranvier2d2 commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator

What Changed

Three fixes for harness-backed providers (Cursor, OpenCode) failing on follow-up questions:

1. Cursor: stop clobbering the durable chat ID (primary fix)

handle_stream_message for system/init was unconditionally overwriting resume_session_id with the transient per-run session ID. On the second turn, --resume would reference this stale ID instead of the durable chat ID from create-chat. Now only captures system/init session_id when has_real_chat_id is false.

Also added map-shaped resumeCursor handling to extract_resume_session_id (D4 parity).

2. OpenCode: reuse persisted session on restart/resume

init/1 always created a fresh OpenCode session, discarding any persisted sessionId from resumeCursor. Now extracts the persisted ID, verifies it via GET /session/:id, and falls back to a new session only if verification fails.

3. HarnessClientAdapter: forward missing sendTurn params

sendTurn was only passing input and model to the harness — attachments and interactionMode were silently dropped. Now forwards them consistently with how ProviderCommandReactor.sendTurnForThread dispatches.

Why

Claude and Codex (direct adapters) handle multi-turn correctly. Cursor and OpenCode (harness path) fail on the second question because:

  • Cursor's --resume flag gets a stale session ID after turn 1
  • OpenCode loses conversation history when sessions restart without reuse
  • Missing attachments/interactionMode could cause subtle context loss

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • No UI changes

Review focus areas

  • Cursor system/init guard logic (cursor_session.ex:563-568): Verify that the transient system/init session_id is indeed never the correct resume ID when a durable chat ID already exists from create-chat.
  • OpenCode verify_opencode_session: The {:ok, _} -> :ok catch-all is permissive — a 200 response with unexpected shape will pass verification. Confirm this is safe given OpenCode's API contract.
  • _ = reused (opencode_session.ex:193): Suppresses unused variable warning. Harmless but worth noting — could be removed if the variable isn't needed for future logging.
  • Elixir-side consumption of attachments/interactionMode: The harness adapter now forwards these, but confirm the Elixir session handlers actually use them (or at least tolerate the extra keys in params).

Link to Devin session: https://app.devin.ai/sessions/7c753c05392f4faeab829e3b269299e8
Requested by: @ranvier2d2


Open with Devin

Summary by CodeRabbit

  • Bug Fixes

    • Preserve durable session bindings so resumed Cursor conversations keep their original chat IDs
  • New Features

    • Reuse persisted OpenCode sessions when valid, avoiding unnecessary session creation
    • Turn requests now include attachments and interaction mode when present
    • Resume tokens now support both JSON-string and already-decoded map formats

Cursor: The system/init handler was unconditionally overwriting
resume_session_id with the transient per-run session_id. This caused
the second turn's --resume flag to reference a stale/invalid ID instead
of the durable chat ID from create-chat. Now only captures system/init
session_id when no real chat ID exists yet.

Also accept object-shaped resumeCursor values (already-decoded maps from
normalize_resume_cursor) in addition to JSON strings.

OpenCode: Always created a fresh session on startup instead of reusing
a persisted sessionId from resumeCursor. Now extracts and verifies
persisted session IDs, falling back to new session creation only when
the persisted session is invalid.

HarnessClientAdapter: sendTurn was stripping attachments and
interactionMode when forwarding to the Elixir harness. Now passes all
parameters consistently with how ProviderCommandReactor dispatches them.

Co-Authored-By: Bastian Venegas Arevalo <r2d2@ranvier-technologies.com>
@devin-ai-integration

Copy link
Copy Markdown
Original prompt from Bastian

Fix the issue where the agent only responds to the initial question and fails on subsequent queries within the t3code-OTP repository.

  • Investigate why the Cursor agent and opencode are failing to answer the second question in a conversation.
  • Compare the behavior with Claude and Codex, which are reported to be working correctly, to identify discrepancies in state management or message handling.
  • Ensure that the agent maintains context or correctly processes the conversation history for follow-up questions.
  • Pull the most recent version from origin Ranvier-Technologies/t3code-OTP before starting.

Provide a summary of the root cause and the changes made.

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@coderabbitai

coderabbitai Bot commented Mar 29, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR enables persisted session reuse by extracting and validating session IDs from resumeCursor for Cursor and OpenCode providers, adjusts when Cursor captures durable session IDs, and makes Harness client send optional attachments and interactionMode when present.

Changes

Cohort / File(s) Summary
Cursor session provider
apps/harness/lib/harness/providers/cursor_session.ex
Normalize resumeCursor when already-decoded, extract cursorChatId/legacy resume if binary; only capture and persist session_id on system/init when no durable chat id exists (!state.has_real_chat_id).
OpenCode session provider
apps/harness/lib/harness/providers/opencode_session.ex
Extract persisted sessionId from params.resumeCursor (JSON or map) into state.opencode_session_id; verify persisted session via GET /session/:id and reuse if valid, otherwise create new session. Added extract_persisted_session_id/1 and verify_opencode_session/1.
Harness client & contract
apps/server/src/provider/Layers/HarnessClientAdapter.ts, apps/harness/lib/harness/dev/bridge_contract.ex
Client adapter now includes attachments (only if defined and non-empty) and interactionMode (if defined) in sendTurn payload. Bridge contract session.sendTurn params list updated to accept attachments.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Harness as Harness<br/>(Provider)
    participant OpenCode as OpenCode<br/>Backend
    participant SessionCreator as Session<br/>Creation

    Client->>Harness: Connect with resumeCursor
    Harness->>Harness: Normalize & extract sessionId from resumeCursor

    rect rgba(100, 200, 100, 0.5)
    Note over Harness,OpenCode: Reuse path (persisted ID present)
    Harness->>OpenCode: GET /session/:id (verify)
    OpenCode-->>Harness: 200 OK (valid)
    Harness->>Harness: Set state.opencode_session_id / ready, persist binding
    end

    rect rgba(100, 100, 200, 0.5)
    Note over Harness,SessionCreator: Fallback path (no/invalid ID)
    Harness->>SessionCreator: POST /session (create)
    SessionCreator-->>Harness: New session ID
    Harness->>Harness: Store new session id, persist binding
    end

    Harness-->>Client: Session ready (reused or new)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

size:L

Poem

🐰 I hop through cursors, map and string in paw,

I sniff the session id, then mind the law.
If old doors open, I quietly reuse,
If broken, I build one — no time to snooze!
Hooray for bindings, and fewer confused views.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: preserving durable session IDs across turns for two providers (Cursor/OpenCode).
Description check ✅ Passed The description comprehensively covers all required template sections: detailed explanation of what changed, clear justification for why changes are needed, explicit checklist completion, and targeted review focus areas.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1774797081-fix-harness-multi-turn

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 29, 2026

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

coderabbitai[bot]

This comment was marked as resolved.

…de verify, update bridge contract

Cursor (CodeRabbit major): system/init fallback now also updates
session_id and persists the binding via persist_binding/1 so the durable
ID survives restarts.

OpenCode (CodeRabbit major + Devin Review): verify_opencode_session now
requires the returned 'id' to exactly match state.opencode_session_id.
Any other 200-level response is treated as an error. Also cleaned up
_reused binding.

Bridge contract (Devin Review): Added 'attachments' to
session.sendTurn params for doc consistency.

Co-Authored-By: Bastian Venegas Arevalo <r2d2@ranvier-technologies.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/harness/lib/harness/dev/bridge_contract.ex`:
- Line 35: The bridge currently advertises an attachments param but OpenCode
doesn't support them end-to-end; update OpenCode handling to explicitly reject
attachments instead of silently dropping them: in
Harness.Providers.OpenCodeSession.handle_call/3 (and where send_prompt_async/3
is invoked) detect non-empty attachments on session.sendTurn requests and return
a clear error/validation response (or raise) indicating attachments are not
supported for OpenCode, and add tests; alternatively remove "attachments" from
the params list in bridge_contract.ex if you prefer to hide the capability
entirely.

In `@apps/harness/lib/harness/providers/opencode_session.ex`:
- Around line 172-190: The code reuses a persisted OpenCode session (via
verify_opencode_session and returning {:ok, state.opencode_session_id}) but
never repopulates state.messages before proceeding to the ready path, so
read_thread/2 still returns an empty conversation; update the branch that
handles {:ok, state.opencode_session_id} to call a hydration/readback routine
(e.g., invoke the existing read_thread/2 or a new fetch_messages_from_provider/1
helper) to populate state.messages (and any other session metadata) before
returning the {:ok, session_id} result so the subsequent ready path sees the
restored history; ensure this uses the same session ID returned by
create_opencode_session/1 when falling back.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08442217-3cdc-49b0-9e45-5889fa6bd4a5

📥 Commits

Reviewing files that changed from the base of the PR and between 59c5167 and fc191a7.

📒 Files selected for processing (3)
  • apps/harness/lib/harness/dev/bridge_contract.ex
  • apps/harness/lib/harness/providers/cursor_session.ex
  • apps/harness/lib/harness/providers/opencode_session.ex
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/harness/lib/harness/providers/cursor_session.ex

%{
event: "session.sendTurn",
params: ~w(threadId input model effort interactionMode modelOptions),
params: ~w(threadId input model effort interactionMode modelOptions attachments),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't advertise attachment support until OpenCode can consume it.

session.sendTurn now accepts attachments, but Harness.Providers.OpenCodeSession.handle_call/3 still strips the payload down to text and send_prompt_async/3 only posts a single text part (apps/harness/lib/harness/providers/opencode_session.ex:350-394 and apps/harness/lib/harness/providers/opencode_session.ex:1288-1307). For OpenCode, turns with attachments will still succeed while silently losing that context. Either plumb attachments through end-to-end or reject them explicitly for unsupported providers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/dev/bridge_contract.ex` at line 35, The bridge
currently advertises an attachments param but OpenCode doesn't support them
end-to-end; update OpenCode handling to explicitly reject attachments instead of
silently dropping them: in Harness.Providers.OpenCodeSession.handle_call/3 (and
where send_prompt_async/3 is invoked) detect non-empty attachments on
session.sendTurn requests and return a clear error/validation response (or
raise) indicating attachments are not supported for OpenCode, and add tests;
alternatively remove "attachments" from the params list in bridge_contract.ex if
you prefer to hide the capability entirely.

Comment on lines +172 to +190
# Reuse a persisted session when available; otherwise create a new one.
# This preserves conversation history across session restarts/resumes.
{session_result, _reused} =
if state.opencode_session_id do
# Verify the persisted session still exists on the server
case verify_opencode_session(state) do
:ok ->
Logger.info("Reusing persisted OpenCode session #{state.opencode_session_id} for thread #{state.thread_id}")
{{:ok, state.opencode_session_id}, true}

{:error, reason} ->
Logger.info("Persisted OpenCode session invalid (#{inspect(reason)}), creating new session for thread #{state.thread_id}")
{create_opencode_session(state), false}
end
else
{create_opencode_session(state), false}
end

case session_result do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reused sessions still come back with an empty harness thread.

This branch verifies the remote session and immediately moves to the ready path, but it never repopulates state.messages first. Since read_thread/2 is built entirely from that in-memory list, a restarted harness will still report no prior turns even though the provider-side session was reused. A hydration/readback step is still needed before emitting ready if we want full continuity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/providers/opencode_session.ex` around lines 172 -
190, The code reuses a persisted OpenCode session (via verify_opencode_session
and returning {:ok, state.opencode_session_id}) but never repopulates
state.messages before proceeding to the ready path, so read_thread/2 still
returns an empty conversation; update the branch that handles {:ok,
state.opencode_session_id} to call a hydration/readback routine (e.g., invoke
the existing read_thread/2 or a new fetch_messages_from_provider/1 helper) to
populate state.messages (and any other session metadata) before returning the
{:ok, session_id} result so the subsequent ready path sees the restored history;
ensure this uses the same session ID returned by create_opencode_session/1 when
falling back.

@ranvier2d2
ranvier2d2 merged commit eb8ee81 into main Mar 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant