Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/harness/lib/harness/session_manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defmodule Harness.SessionManager do
end

defp start_session_with_module(session_module, thread_id, provider, params) do
params = maybe_inject_resume_cursor(params, thread_id, provider)

child_spec =
{session_module,
%{
Expand Down Expand Up @@ -258,6 +260,38 @@ defmodule Harness.SessionManager do
SnapshotServer.apply_event(event)
end

@doc false
# Inject resumeCursor from SQLite binding if one exists and the provider matches.
# If the caller already supplied a resumeCursor, respect it (don't overwrite).
def maybe_inject_resume_cursor(%{"resumeCursor" => _} = params, _thread_id, _provider) do
params
end

def maybe_inject_resume_cursor(params, thread_id, provider) do
case Harness.Storage.get_binding(thread_id) do
%{provider: ^provider, resume_cursor_json: cursor_json} when is_binary(cursor_json) ->
case Jason.decode(cursor_json) do
{:ok, cursor} ->
normalized = normalize_resume_cursor(cursor)
Logger.info("Injecting resumeCursor from binding for #{thread_id} (#{provider})")
Map.put(params, "resumeCursor", normalized)

{:error, reason} ->
Logger.warning("Failed to decode binding cursor for #{thread_id}: #{inspect(reason)}")
params
end

_ ->
# No binding, provider mismatch, or nil cursor — start fresh
params
end
end

# Codex stores {"threadId": "..."} but CodexSession expects the raw string.
# Other providers store richer objects their session modules know how to parse.
defp normalize_resume_cursor(%{"threadId" => tid}) when is_binary(tid), do: tid
defp normalize_resume_cursor(cursor), do: cursor
Comment on lines +270 to +293
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how each session module handles resumeCursor

echo "=== cursor_session.ex: extract_resume_session_id expects binary ==="
rg -n -A 15 'defp extract_resume_session_id' apps/harness/lib/harness/providers/cursor_session.ex

echo ""
echo "=== claude_session.ex: parse_resume_state expects binary ==="
rg -n -A 20 'defp parse_resume_state' apps/harness/lib/harness/providers/claude_session.ex

echo ""
echo "=== opencode_session.ex: check resumeCursor handling ==="
rg -n -B 2 -A 10 'resumeCursor' apps/harness/lib/harness/providers/opencode_session.ex || echo "No resumeCursor handling found"

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1794


🏁 Script executed:

# Get the full parse_resume_state function from claude_session
sed -n '1021,1080p' apps/harness/lib/harness/providers/claude_session.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1745


🏁 Script executed:

# Get the full extract_resume_session_id function from cursor_session
sed -n '374,410p' apps/harness/lib/harness/providers/cursor_session.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1270


🏁 Script executed:

# Check codex_session for resumeCursor handling
rg -n -B 2 -A 15 'resumeCursor' apps/harness/lib/harness/providers/codex_session.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 671


🏁 Script executed:

# List all session provider files to see what modules exist
fd -e ex$ 'session\.ex$' apps/harness/lib/harness/providers/

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 57


🏁 Script executed:

# Check mock_session for resumeCursor handling
rg -n -B 2 -A 10 'resumeCursor' apps/harness/lib/harness/providers/mock_session.ex

# Check if there's an opencode_session file and its resumeCursor handling
fd -e ex$ session apps/harness/lib/harness/providers/ | sort

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 57


🏁 Script executed:

# Check if mock_session even exists
fd -type f -name '*session.ex' apps/harness/lib/harness/providers/ | xargs -I {} basename {}

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 245


🏁 Script executed:

# Verify the current binding storage format to understand if cursor_json is stored as JSON string or map
rg -n 'resume_cursor_json' apps/harness/lib/harness/storage.ex | head -20

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1240


🏁 Script executed:

# List all session provider files
fd -name '*session.ex' apps/harness/lib/harness/providers/

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 305


🏁 Script executed:

# Check how cursor_session builds its resume cursor to understand what format it stores
rg -n -B 2 -A 10 'build_resume_cursor' apps/harness/lib/harness/providers/cursor_session.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 935


🏁 Script executed:

# List all session provider files
fd '*session.ex' apps/harness/lib/harness/providers/

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 365


🏁 Script executed:

# Verify if there's a mock_session file
ls -la apps/harness/lib/harness/providers/ | grep session

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 413


🏁 Script executed:

# Check mock_session for resumeCursor handling
rg 'resumeCursor' apps/harness/lib/harness/providers/mock_session.ex

# Check opencode_session for resumeCursor handling
rg 'resumeCursor' apps/harness/lib/harness/providers/opencode_session.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 114


🏁 Script executed:

# Now verify the actual issue: let's trace through what normalize_resume_cursor does
# Get the exact implementation from session_manager
sed -n '270,330p' apps/harness/lib/harness/session_manager.ex

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1523


Type mismatch in maybe_inject_resume_cursor: Cursor session receives decoded map instead of JSON string and silently ignores resume.

The function decodes cursor_json from storage and passes the decoded map through normalize_resume_cursor. For Codex, this correctly extracts the raw string. However, for Cursor, the decoded map is returned as-is.

cursor_session.ex:379 guards with cursor when is_binary(cursor) before decoding. When passed a map instead of a JSON string, this guard fails and extract_resume_session_id/1 returns nil, causing resume to be silently ignored.

claude_session.ex handles this correctly via an explicit map pattern at line 1041, so it works with decoded data. Cursor does not have this fallback.

The fix: move JSON decoding into normalize_resume_cursor so each provider receives the same format it stores (raw JSON string for Cursor and Claude, extracted string for Codex).

Proposed fix
 def maybe_inject_resume_cursor(params, thread_id, provider) do
   case Harness.Storage.get_binding(thread_id) do
     %{provider: ^provider, resume_cursor_json: cursor_json} when is_binary(cursor_json) ->
-      case Jason.decode(cursor_json) do
-        {:ok, cursor} ->
-          normalized = normalize_resume_cursor(cursor)
-          Logger.info("Injecting resumeCursor from binding for #{thread_id} (#{provider})")
-          Map.put(params, "resumeCursor", normalized)
-
-        {:error, reason} ->
-          Logger.warning("Failed to decode binding cursor for #{thread_id}: #{inspect(reason)}")
-          params
-      end
+      case normalize_resume_cursor(provider, cursor_json) do
+        {:ok, normalized} ->
+          Logger.info("Injecting resumeCursor from binding for #{thread_id} (#{provider})")
+          Map.put(params, "resumeCursor", normalized)
+
+        :error ->
+          Logger.warning("Failed to decode binding cursor for #{thread_id}")
+          params
+      end

     _ ->
       # No binding, provider mismatch, or nil cursor — start fresh
       params
   end
 end

-# Codex stores {"threadId": "..."} but CodexSession expects the raw string.
-# Other providers store richer objects their session modules know how to parse.
-defp normalize_resume_cursor(%{"threadId" => tid}) when is_binary(tid), do: tid
-defp normalize_resume_cursor(cursor), do: cursor
+defp normalize_resume_cursor("codex", cursor_json) do
+  case Jason.decode(cursor_json) do
+    {:ok, %{"threadId" => tid}} when is_binary(tid) -> {:ok, tid}
+    _ -> :error
+  end
+end
+defp normalize_resume_cursor(_provider, cursor_json), do: {:ok, cursor_json}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/lib/harness/session_manager.ex` around lines 270 - 293, The
decode of cursor_json should be moved out of maybe_inject_resume_cursor and into
normalize_resume_cursor so each provider receives the same raw-format value it
expects; change maybe_inject_resume_cursor to pass the stored cursor_json (not
the decoded map) into normalize_resume_cursor, and update
normalize_resume_cursor to perform Jason.decode(cursor_json) internally,
returning the extracted thread id string for Codex (like current %{"threadId" =>
tid} -> tid), or the raw decoded JSON/string for Cursor and others, and
log/return params on decode errors. Update references to
maybe_inject_resume_cursor and normalize_resume_cursor accordingly so Cursor's
cursor_session guard (cursor when is_binary(cursor)) receives a JSON string as
intended.


defp provider_module("codex"), do: {:ok, CodexSession}
defp provider_module("claudeAgent"), do: {:ok, ClaudeSession}
defp provider_module("opencode"), do: {:ok, OpenCodeSession}
Expand Down
79 changes: 79 additions & 0 deletions apps/harness/test/harness/storage_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,85 @@ defmodule Harness.StorageTest do
assert Storage.get_binding("t3").provider == "opencode"
end

# --- Resume cursor injection tests (SessionManager.maybe_inject_resume_cursor) ---

alias Harness.SessionManager

test "injects resumeCursor when binding exists and provider matches" do
cursor = Jason.encode!(%{"threadId" => "codex-abc"})
:ok = Storage.upsert_binding("t1", "codex", cursor)

params = %{"threadId" => "t1", "provider" => "codex"}
result = SessionManager.maybe_inject_resume_cursor(params, "t1", "codex")

# Codex cursors are normalized: {"threadId":"abc"} → "abc" (raw string)
assert result["resumeCursor"] == "codex-abc"
end

test "does NOT inject resumeCursor when provider mismatches" do
cursor = Jason.encode!(%{"threadId" => "codex-abc"})
:ok = Storage.upsert_binding("t1", "codex", cursor)

params = %{"threadId" => "t1", "provider" => "cursor"}
result = SessionManager.maybe_inject_resume_cursor(params, "t1", "cursor")

refute Map.has_key?(result, "resumeCursor")
end

test "does NOT inject resumeCursor when no binding exists" do
params = %{"threadId" => "t1", "provider" => "codex"}
result = SessionManager.maybe_inject_resume_cursor(params, "t1", "codex")

refute Map.has_key?(result, "resumeCursor")
end

test "does NOT overwrite caller-supplied resumeCursor" do
cursor = Jason.encode!(%{"threadId" => "codex-abc"})
:ok = Storage.upsert_binding("t1", "codex", cursor)

params = %{"threadId" => "t1", "provider" => "codex", "resumeCursor" => %{"threadId" => "explicit"}}
result = SessionManager.maybe_inject_resume_cursor(params, "t1", "codex")

assert result["resumeCursor"] == %{"threadId" => "explicit"}
end

test "does NOT inject resumeCursor when cursor_json is nil" do
:ok = Storage.upsert_binding("t1", "codex", nil)

params = %{"threadId" => "t1", "provider" => "codex"}
result = SessionManager.maybe_inject_resume_cursor(params, "t1", "codex")

refute Map.has_key?(result, "resumeCursor")
end

test "multi-session: concurrent bindings for different threads don't cross-contaminate" do
:ok = Storage.upsert_binding("t1", "codex", Jason.encode!(%{"threadId" => "codex-1"}))
:ok = Storage.upsert_binding("t2", "cursor", Jason.encode!(%{"cursorChatId" => "cursor-1"}))
:ok = Storage.upsert_binding("t3", "opencode", Jason.encode!(%{"sessionId" => "oc-1"}))

r1 = SessionManager.maybe_inject_resume_cursor(%{}, "t1", "codex")
r2 = SessionManager.maybe_inject_resume_cursor(%{}, "t2", "cursor")
r3 = SessionManager.maybe_inject_resume_cursor(%{}, "t3", "opencode")

# Codex cursor normalized to raw string; others kept as maps
assert r1["resumeCursor"] == "codex-1"
assert r2["resumeCursor"] == %{"cursorChatId" => "cursor-1"}
assert r3["resumeCursor"] == %{"sessionId" => "oc-1"}
end

test "multi-session: same thread_id with different provider ignores stale binding" do
# Codex wrote a binding for t1
:ok = Storage.upsert_binding("t1", "codex", Jason.encode!(%{"threadId" => "codex-abc"}))

# Cursor tries to start on same thread — should NOT get Codex's cursor
result = SessionManager.maybe_inject_resume_cursor(%{}, "t1", "cursor")
refute Map.has_key?(result, "resumeCursor")

# Codex should still get its own cursor (normalized to raw string)
result = SessionManager.maybe_inject_resume_cursor(%{}, "t1", "codex")
assert result["resumeCursor"] == "codex-abc"
end

# --- Integration: SnapshotServer recovery ---

describe "SnapshotServer recovery" do
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/provider/Layers/HarnessClientAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,10 +917,9 @@ function mapHarnessEventToRuntimeEvents(
return providerMapped;
}

// Silently drop unrecognised events. Provider-specific notifications that
// pass through the GenServer (e.g. Codex app-server lifecycle events) are
// not bugs — they're simply events with no canonical mapping. The raw event
// stream still contains them for debugging.
// Unmapped events are already logged by codexMapToRuntimeEvents (which
// handles both QUIET_UNMAPPED_EVENTS suppression and console.debug for
// genuinely unknown methods). No additional logging needed here.
return [];
}

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/Layers/codexEventMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ const QUIET_UNMAPPED_EVENTS = new Set([
"hook_response",
"user",
"thinking",
// Raw equivalents of item/agentMessage/delta (already mapped).
// Mapping these would cause duplicate deltas in the UI.
"codex/event/agent_message_delta",
"codex/event/agent_message",
"codex/event/agent_message_content_delta",
Expand Down
Loading