Skip to content

feat: Expose opencode's auto generated session titles#569

Open
kartikye wants to merge 6 commits into
ColeMurray:mainfrom
kartikye:codex/expose-opencode-session-titles
Open

feat: Expose opencode's auto generated session titles#569
kartikye wants to merge 6 commits into
ColeMurray:mainfrom
kartikye:codex/expose-opencode-session-titles

Conversation

@kartikye
Copy link
Copy Markdown
Contributor

@kartikye kartikye commented Apr 27, 2026

Expose opencode's autogenerated session titles to open inspect. It will get the title after the first turn and rename the session if a session has not been manually named.

Summary by CodeRabbit

  • New Features

    • Runtime-sourced session titles are fetched and emitted as real-time session_title events; clients update title and the sidebar session list is revalidated.
  • Bug Fixes

    • Title writes are sanitized, only applied when unset, and use monotonic timestamps; index updates ignore stale writes.
  • Tests

    • Added tests for title emit, conditional persistence, index sync, and client-side update/revalidation.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

Capture OpenCode-generated session titles at sandbox runtime, emit session_title sandbox events to the control plane, conditionally persist titles (only when unset or when newer), broadcast updates to connected clients, and revalidate sidebar session lists in the web client.

Changes

Cohort / File(s) Summary
Type Definitions
packages/shared/src/types/index.ts
Added SandboxEvent variant { type: "session_title"; title: string; sandboxId: string; timestamp: number }.
Control Plane Durable Object
packages/control-plane/src/session/durable-object.ts
Handles session_title sandbox events, sanitizes title, computes monotonic updatedAt, calls updateSessionTitleIfUnset, schedules async SessionIndexStore.updateTitleIfNewer, broadcasts {type:"session_title", title} to clients, and notifies parent sessions.
Session Repository & Tests
packages/control-plane/src/session/repository.ts, packages/control-plane/src/session/repository.test.ts
Extended SqlResult with optional rowsRead/rowsWritten; added SessionRepository.updateSessionTitleIfUnset(sessionId, title, updatedAt): boolean; test double supports rowsWritten seeding and asserts conditional UPDATE behavior.
Session Index Store & Tests
packages/control-plane/src/db/session-index.ts, packages/control-plane/src/db/session-index.test.ts
Added SessionIndexStore.updateTitleIfNewer(id, title, updatedAt): Promise<boolean> performing AND updated_at <= ? guarded update; tests validate current vs stale write behavior in fake DB runner.
Sandbox Runtime Bridge & Tests
packages/sandbox-runtime/src/sandbox_runtime/bridge.py, packages/sandbox-runtime/tests/test_bridge_sse.py
Bridge queries OpenCode for generated title when session becomes idle and emits session_title event to control plane; failures are logged and do not block final-state streaming; test asserts emission.
Web Client Hook & Tests
packages/web/src/hooks/use-session-socket.ts, packages/web/src/hooks/use-session-socket.test.tsx
Hook handles session_title WS events by updating sessionState.title and calling mutate(SIDEBAR_SESSIONS_KEY) to revalidate sidebar sessions; test added to assert state update and cache invalidation.

Sequence Diagram

sequenceDiagram
    participant SR as Sandbox Runtime
    participant CP as Control Plane
    participant SI as Session Index
    participant WC as Web Client

    SR->>SR: detect session idle
    SR->>SR: fetch OpenCode /session/{id}
    SR-->>CP: emit sandbox event: session_title (title, sandboxId, timestamp)

    CP->>CP: processSandboxEvent -> applyGeneratedSessionTitle
    CP->>CP: updateSessionTitleIfUnset(sessionId, title, updatedAt)
    CP->>SI: updateTitleIfNewer(id, title, updatedAt) (async)
    SI-->>CP: success/failure

    CP->>CP: broadcast WebSocket {type:"session_title", title}
    CP-->>WC: WebSocket delivers session_title
    WC->>WC: update sessionState.title
    WC->>WC: mutate(SIDEBAR_SESSIONS_KEY) -> revalidate sidebar list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Suggested reviewers

  • ColeMurray
  • MartinRoberts-Fountain

Poem

🐰 I hopped through logs and gentle queues,
I fetched a title, trimmed the dews,
From sandbox cave to control-plane tree,
I nudged the web to sing with glee,
A tiny name, set gracefully. 🎩✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being implemented: exposing OpenCode's auto-generated session titles. It is specific, concise, and directly reflects the PR's core objective.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

Inline comments:
In `@packages/control-plane/src/session/durable-object.ts`:
- Around line 1507-1519: The background write in syncSessionIndexTitle is using
SessionIndexStore.updateTitle unconditionally which can race and overwrite a
newer manual rename; change the background call to a conditional index update
such as SessionIndexStore.updateTitleIfUnset(sessionId, title) or
SessionIndexStore.updateTitleIfNewer(sessionId, title, updatedAt) so the index
write only applies when the index is empty or older, and keep the ctx.waitUntil
wrapper and existing error logging (log.error
"session_index.update_title.background_error") intact; locate the
syncSessionIndexTitle method and replace the updateTitle invocation with the
appropriate IfUnset/IfNewer variant and pass any required timestamp or sentinel
so manual renames are preserved.

In `@packages/control-plane/src/session/repository.test.ts`:
- Around line 155-162: The test claims to exercise updateSessionTitleIfUnset but
seeds a non-empty title; change the mock fixture in the test to return a row
with title null (or empty string) so the "unset" branch is exercised, then call
repo.updateSessionTitleIfUnset("sess-1", "Generated title", 4000) and assert it
returns true; keep the existing assertions that mock.calls[0].query contains the
WHERE clause and that mock.calls[0].params equals ["Generated title", 4000,
"sess-1"] so the SQL and params are still validated for the unset-title path in
updateSessionTitleIfUnset.

In `@packages/control-plane/src/session/repository.ts`:
- Around line 286-296: The method updateSessionTitleIfUnset can return true even
if no row was updated because it compares this.getSession()?.title to title;
instead use the database update result to determine success: capture the
result/metadata from this.sql.exec (e.g., affectedRows/changes/rowCount
depending on the DB driver) when calling UPDATE in updateSessionTitleIfUnset and
return true only if the exec result indicates at least one row was updated; do
not rely on getSession() for success detection. Ensure you reference
updateSessionTitleIfUnset, this.sql.exec, and getSession in the change so
reviewers can find the logic to adjust.
🪄 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: 0169aab2-b442-43d6-92e7-3a0560c7cbed

📥 Commits

Reviewing files that changed from the base of the PR and between 6a4fdc7 and 07a805c.

📒 Files selected for processing (8)
  • packages/control-plane/src/session/durable-object.ts
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/repository.ts
  • packages/sandbox-runtime/src/sandbox_runtime/bridge.py
  • packages/sandbox-runtime/tests/test_bridge_sse.py
  • packages/shared/src/types/index.ts
  • packages/web/src/hooks/use-session-socket.test.tsx
  • packages/web/src/hooks/use-session-socket.ts

Comment thread packages/control-plane/src/session/durable-object.ts Outdated
Comment thread packages/control-plane/src/session/repository.test.ts
Comment thread packages/control-plane/src/session/repository.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/control-plane/src/session/durable-object.ts (1)

1551-1575: Optional: notifyParentOfStatusChange is being reused for title-only updates.

At Line 1567 the call passes the unchanged session.status, so the parent fan-out is really a "child updated" notification rather than a status change. The method/payload still works, but the name is misleading and conflates two intents. Consider extracting a small notifyParentOfChildUpdate(childSessionId, { status, title }) helper (or renaming the existing one) so callers don't have to read the implementation to understand they can also propagate title changes through it.

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

In `@packages/control-plane/src/session/durable-object.ts` around lines 1551 -
1575, The call in applyGeneratedSessionTitle uses notifyParentOfStatusChange to
propagate a title-only update which is misleading; add a new helper (e.g.,
notifyParentOfChildUpdate or notifyParentOfTitleChange) that accepts the child
session (or child session id), publicSessionId, and the updated fields (at
minimum title and optionally status) and use that from
applyGeneratedSessionTitle instead of notifyParentOfStatusChange; keep
notifyParentOfStatusChange semantics for real status transitions and update
callers to use the new notifyParentOfChildUpdate helper where they only need to
propagate child metadata changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/control-plane/src/session/repository.ts`:
- Around line 214-215: The code reads result.rowsWritten immediately after
calling this.sql.exec(...) without consuming the returned cursor; consume the
cursor (e.g., call .toArray() or iterate it) before checking rowsWritten to
ensure the final count is accurate. Update the code around the
this.sql.exec(...) call (the variable named result or similar) to await
result.toArray() or iterate the cursor first, then read result.rowsWritten > 0
and return based on that consumed value.

---

Nitpick comments:
In `@packages/control-plane/src/session/durable-object.ts`:
- Around line 1551-1575: The call in applyGeneratedSessionTitle uses
notifyParentOfStatusChange to propagate a title-only update which is misleading;
add a new helper (e.g., notifyParentOfChildUpdate or notifyParentOfTitleChange)
that accepts the child session (or child session id), publicSessionId, and the
updated fields (at minimum title and optionally status) and use that from
applyGeneratedSessionTitle instead of notifyParentOfStatusChange; keep
notifyParentOfStatusChange semantics for real status transitions and update
callers to use the new notifyParentOfChildUpdate helper where they only need to
propagate child metadata changes.
🪄 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: 812ae65e-3e6e-4390-83c0-cb92d9032eb6

📥 Commits

Reviewing files that changed from the base of the PR and between 07a805c and d12d64f.

📒 Files selected for processing (5)
  • packages/control-plane/src/db/session-index.test.ts
  • packages/control-plane/src/db/session-index.ts
  • packages/control-plane/src/session/durable-object.ts
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/repository.ts

Comment on lines +214 to +215
readonly rowsRead?: number;
readonly rowsWritten?: number;
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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Cloudflare Workers Durable Object SqlStorageCursor rowsWritten per-query semantics for UPDATE statement

💡 Result:

For Cloudflare Durable Objects SQLite storage, SqlStorageCursor.rowsWritten is “per SQL query” (per sql.exec(...) call). It starts at the number of rows written so far as part of that query, can increase as you iterate the cursor, and the final value is what’s used for SQL billing. So for an UPDATE statement, the rowsWritten you observe will correspond to the rows written by that UPDATE statement within that single sql.exec("UPDATE ...") query—if you read/advance the cursor further, rowsWritten may increase, and you should treat the final rowsWritten value (after the cursor is fully consumed) as the definitive count for billing. Notes from the docs: - rowsWritten “may increase as you iterate the cursor” and “the final value is used for SQL billing.” - This applies to the cursor’s SQL query overall (not globally across multiple sql.exec calls). - When writing data, index updates also count as additional rows (so UPDATEs that change indexed columns can increase rowsWritten beyond the number of rows matched by the UPDATE).

Citations:


🏁 Script executed:

sed -n '280,305p' packages/control-plane/src/session/repository.ts

Repository: ColeMurray/background-agents

Length of output: 782


🌐 Web query:

Cloudflare Durable Objects sql.exec UPDATE return value rowsWritten consumption

💡 Result:

In Cloudflare Durable Objects (SQLite-backed), this.ctx.storage.sql.exec(...) returns a cursor (SqlStorageCursor) with accounting fields: 1) rowsWritten - Meaning: “The number of rows written so far as part of this SQL query.” It “may increase as you iterate the cursor.” The “final value is used for SQL billing.” - So for an UPDATE ... statement executed via sql.exec, the relevant billing/usage counter is the cursor’s rowsWritten (not the return value from .one / .toArray). 2) “Consumption” / billing relationship - Cloudflare’s Durable Objects SQL billing is based on row reads/writes usage counts, where writes are tracked via rowsWritten (final value after the cursor is fully consumed). 3) Return value vs rowsWritten - The SQL execution API is synchronous: you call exec(...) immediately and then read results from the cursor (e.g., via cursor.one / iterating / cursor.toArray). - For UPDATE queries that change rows, the cursor’s rowsWritten increases as the cursor is processed; if you don’t fully consume the cursor, the “final” rowsWritten used for billing may not be reached. Practical notes - If your UPDATE does not return rows (common), prefer relying on the cursor fields (e.g., cursor.rowsWritten) rather than expecting an .one-style row object. - Cloudflare also notes that index updates count as additional rows written (“every row update of an index counts as an additional row”). Grounding details (what to look at) - SqlStorageCursor properties include rowsRead and rowsWritten, and both specify that the final values are used for SQL billing. If you share an exact snippet (your UPDATE SQL plus how you consume the cursor), I can tell you exactly where to read rowsWritten and whether you need to fully consume the cursor.

Citations:


🌐 Web query:

Cloudflare Durable Objects sql.exec UPDATE statement cursor rowsWritten immediately available no cursor iteration

💡 Result:

In Cloudflare Durable Objects’ SQLite SQL API (ctx.storage.sql.exec), the sql.exec call returns a SqlStorageCursor. That cursor can be iterated (for...of or cursor.next) and it tracks billing counters like rowsRead and rowsWritten. Regarding your specific requirement: - You generally should NOT rely on “UPDATE … and rowsWritten is immediately available without cursor iteration”. The official docs only specify that rowsRead/rowsWritten may increase as you iterate the cursor and that the final value is used for SQL billing. That implies the final rowsWritten value is only known after the cursor has been consumed (e.g., by iterating, toArray, etc.). Practical implication / recommended pattern: - Execute the UPDATE and then fully consume the returned cursor so you don’t miss the final rowsWritten value (and to ensure the statement is fully processed). For example: - this.ctx.storage.sql.exec("UPDATE ...", ...).toArray; - or iterate/consume via for (let row of cursor) {} / cursor.toArray / cursor.raw.toArray depending on your API needs. If your goal is “fire-and-forget” updates, you should still be aware that rowsWritten accounting is tied to cursor consumption per the documented billing behavior.

Citations:


Cursor must be consumed before reading rowsWritten.

The code reads result.rowsWritten immediately after sql.exec() without consuming the cursor. According to Cloudflare's documentation, rowsWritten may increase as you iterate the cursor, and the final value is only guaranteed after the cursor is fully consumed (e.g., via .toArray() or iteration). Reading rowsWritten before consumption could yield an incomplete count and cause the function to return an incorrect boolean.

Add cursor consumption (e.g., this.sql.exec(...).toArray()) before checking rowsWritten > 0, or store the cursor and consume it before the return statement.

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

In `@packages/control-plane/src/session/repository.ts` around lines 214 - 215, The
code reads result.rowsWritten immediately after calling this.sql.exec(...)
without consuming the returned cursor; consume the cursor (e.g., call .toArray()
or iterate it) before checking rowsWritten to ensure the final count is
accurate. Update the code around the this.sql.exec(...) call (the variable named
result or similar) to await result.toArray() or iterate the cursor first, then
read result.rowsWritten > 0 and return based on that consumed value.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/control-plane/src/session/repository.ts (1)

288-299: Conditional update + rowsWritten-based success reporting looks good.

The WHERE id = ? AND (title IS NULL OR TRIM(title) = '') predicate correctly scopes the update to "unset" titles, parameter order matches the SQL placeholders, and consuming the cursor via result.toArray() before reading rowsWritten avoids the prior race where the count could be incomplete. Returning (result.rowsWritten ?? 0) > 0 is also resilient to Cloudflare's index-update accounting (where rowsWritten may exceed the matched row count).

One small observation (non-blocking): the result.toArray() return value is intentionally discarded purely to force cursor consumption. A short comment there would help future readers understand it isn't dead code, e.g.:

💡 Optional readability nit
-    result.toArray();
+    // Consume the cursor so rowsWritten reflects the final count (Cloudflare SqlStorageCursor semantics).
+    result.toArray();
     return (result.rowsWritten ?? 0) > 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/control-plane/src/session/repository.ts` around lines 288 - 299, The
call to result.toArray() in updateSessionTitleIfUnset is intentionally invoked
only to fully consume the DB cursor before checking result.rowsWritten, but that
intent isn't documented; add a short one-line comment immediately above the
result.toArray() call (referencing result and rowsWritten in the comment)
stating that the return value is intentionally ignored and the call forces
cursor consumption so rowsWritten is reliable, leaving the rest of the logic
(the SQL, parameters, and final return using (result.rowsWritten ?? 0) > 0)
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/control-plane/src/session/repository.ts`:
- Around line 288-299: The call to result.toArray() in updateSessionTitleIfUnset
is intentionally invoked only to fully consume the DB cursor before checking
result.rowsWritten, but that intent isn't documented; add a short one-line
comment immediately above the result.toArray() call (referencing result and
rowsWritten in the comment) stating that the return value is intentionally
ignored and the call forces cursor consumption so rowsWritten is reliable,
leaving the rest of the logic (the SQL, parameters, and final return using
(result.rowsWritten ?? 0) > 0) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 766433f2-58b9-4164-b5db-22dc1c7548a8

📥 Commits

Reviewing files that changed from the base of the PR and between d12d64f and 77da81b.

📒 Files selected for processing (2)
  • packages/control-plane/src/session/repository.test.ts
  • packages/control-plane/src/session/repository.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/sandbox-runtime/src/sandbox_runtime/bridge.py (1)

1186-1194: ⚠️ Potential issue | 🟠 Major

Move the title lookup off the completion hot path.

session_title is best-effort, but these awaits run inside the same timeout scope as the final-state fetch. If OpenCode is slow or the session is near its inactivity deadline, the extra HTTP round-trip can delay or cancel the actual prompt completion. Please fetch the title after final-state emission, or dispatch it separately with its own short timeout.

Suggested adjustment
-                                        await self._emit_session_title_event()
                                         async for final_event in self._fetch_final_message_state(
                                             message_id,
                                             opencode_message_id,
                                             cumulative_text,
                                             allowed_assistant_msg_ids,
                                             compaction_occurred=compaction_occurred,
                                         ):
                                             yield final_event
+                                        await self._emit_session_title_event()

Also applies to: 1211-1219

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

In `@packages/sandbox-runtime/src/sandbox_runtime/bridge.py` around lines 1186 -
1194, The current code awaits self._emit_session_title_event() before yielding
final events inside the same timeout as self._fetch_final_message_state (called
with message_id, opencode_message_id, etc.), which can delay or cancel
completion; change the flow so that _fetch_final_message_state yields all final
events first and only then invoke _emit_session_title_event() (or schedule it in
a separate task with its own short timeout) so the title lookup is off the
completion hot path; apply the same change where _emit_session_title_event() is
awaited around lines handling the same pattern (e.g., the block using
_fetch_final_message_state at the other location).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/sandbox-runtime/src/sandbox_runtime/bridge.py`:
- Around line 1186-1194: The current code awaits
self._emit_session_title_event() before yielding final events inside the same
timeout as self._fetch_final_message_state (called with message_id,
opencode_message_id, etc.), which can delay or cancel completion; change the
flow so that _fetch_final_message_state yields all final events first and only
then invoke _emit_session_title_event() (or schedule it in a separate task with
its own short timeout) so the title lookup is off the completion hot path; apply
the same change where _emit_session_title_event() is awaited around lines
handling the same pattern (e.g., the block using _fetch_final_message_state at
the other location).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ef968c1-bb5c-43ec-a508-1c47b5fd2f98

📥 Commits

Reviewing files that changed from the base of the PR and between 77da81b and a9d6aab.

📒 Files selected for processing (1)
  • packages/sandbox-runtime/src/sandbox_runtime/bridge.py

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