feat: add session metadata (timestamps, directory, title) and use v1 SessionManager for OpenCode compat

This commit is contained in:
Nathan Flurry 2026-02-07 14:47:14 -08:00
parent 35ae25177b
commit 2b0507c3f5
5 changed files with 213 additions and 34 deletions

View file

@ -65,10 +65,36 @@ Universal schema guidance:
- `sandbox-agent api sessions reject-question``POST /v1/sessions/{sessionId}/questions/{questionId}/reject`
- `sandbox-agent api sessions reply-permission``POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply`
## OpenCode CLI (Experimental)
## OpenCode Compatibility Layer
`sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`).
### Session ownership
Sessions are stored **only** in sandbox-agent's v1 `SessionManager` — they are never sent to or stored in the native OpenCode server. The OpenCode TUI reads sessions via `GET /session` which the compat layer serves from the v1 store. The native OpenCode process has no knowledge of sessions.
### Proxy elimination strategy
The `/opencode` compat layer (`opencode_compat.rs`) historically proxied many endpoints to the native OpenCode server via `proxy_native_opencode()`. The goal is to **eliminate proxying** by implementing each endpoint natively using the v1 `SessionManager` as the single source of truth.
**Already de-proxied** (use v1 SessionManager directly):
- `GET /session``oc_session_list` reads from `SessionManager::list_sessions()`
- `GET /session/{id}``oc_session_get` reads from `SessionManager::get_session_info()`
- `GET /session/status``oc_session_status` derives busy/idle from v1 session `ended` flag
- `POST /tui/open-sessions` — returns `true` directly (TUI fetches sessions from `GET /session`)
- `POST /tui/select-session` — emits `tui.session.select` event via the OpenCode event broadcaster
**Still proxied** (none of these reference session IDs or the session list — all are session-agnostic):
- `GET /command` — command list
- `GET /config`, `PATCH /config` — project config read/write
- `GET /global/config`, `PATCH /global/config` — global config read/write
- `GET /tui/control/next`, `POST /tui/control/response` — TUI control loop
- `POST /tui/append-prompt`, `/tui/submit-prompt`, `/tui/clear-prompt` — prompt management
- `POST /tui/open-help`, `/tui/open-themes`, `/tui/open-models` — TUI navigation
- `POST /tui/execute-command`, `/tui/show-toast`, `/tui/publish` — TUI actions
When converting a proxied endpoint: add needed fields to `SessionState`/`SessionInfo` in `router.rs`, implement the logic natively in `opencode_compat.rs`, and use `session_info_to_opencode_value()` to format responses.
## Post-Release Testing
After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent.

View file

@ -703,6 +703,8 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr
model: args.model.clone(),
variant: args.variant.clone(),
agent_version: args.agent_version.clone(),
directory: None,
title: None,
};
let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
let response = ctx.post(&path, &body)?;

View file

@ -29,6 +29,7 @@ use utoipa::{IntoParams, OpenApi, ToSchema};
use crate::router::{
is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply,
SessionInfo,
};
use sandbox_agent_agent_management::agents::AgentId;
use sandbox_agent_agent_management::credentials::{
@ -153,6 +154,27 @@ impl OpenCodeSessionRecord {
}
}
/// Convert a v1 `SessionInfo` to the OpenCode session JSON format.
fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str) -> Value {
let title = info
.title
.clone()
.unwrap_or_else(|| format!("Session {}", info.session_id));
let directory = info.directory.clone().unwrap_or_default();
json!({
"id": info.session_id,
"slug": format!("session-{}", info.session_id),
"projectID": default_project_id,
"directory": directory,
"title": title,
"version": "0",
"time": {
"created": info.created_at,
"updated": info.updated_at,
}
})
}
#[derive(Clone, Debug)]
struct OpenCodeMessageRecord {
info: Value,
@ -479,6 +501,14 @@ async fn ensure_backing_session(
) -> Result<(), SandboxError> {
let model = model.filter(|value| !value.trim().is_empty());
let variant = variant.filter(|value| !value.trim().is_empty());
// Pull directory and title from the OpenCode session record if available.
let (directory, title) = {
let sessions = state.opencode.sessions.lock().await;
sessions
.get(session_id)
.map(|s| (Some(s.directory.clone()), Some(s.title.clone())))
.unwrap_or((None, None))
};
let request = CreateSessionRequest {
agent: agent.to_string(),
agent_mode: None,
@ -486,6 +516,8 @@ async fn ensure_backing_session(
model: model.clone(),
variant: variant.clone(),
agent_version: None,
directory,
title,
};
match state
.inner
@ -3505,8 +3537,12 @@ async fn oc_session_create(
tag = "opencode"
)]
async fn oc_session_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
let sessions = state.opencode.sessions.lock().await;
let values: Vec<Value> = sessions.values().map(|s| s.to_value()).collect();
let sessions = state.inner.session_manager().list_sessions().await;
let project_id = &state.opencode.default_project_id;
let values: Vec<Value> = sessions
.iter()
.map(|s| session_info_to_opencode_value(s, project_id))
.collect();
(StatusCode::OK, Json(json!(values)))
}
@ -3523,9 +3559,18 @@ async fn oc_session_get(
_headers: HeaderMap,
_query: Query<DirectoryQuery>,
) -> impl IntoResponse {
let sessions = state.opencode.sessions.lock().await;
if let Some(session) = sessions.get(&session_id) {
return (StatusCode::OK, Json(session.to_value())).into_response();
let project_id = &state.opencode.default_project_id;
if let Some(info) = state
.inner
.session_manager()
.get_session_info(&session_id)
.await
{
return (
StatusCode::OK,
Json(session_info_to_opencode_value(&info, project_id)),
)
.into_response();
}
not_found("Session not found").into_response()
}
@ -3586,10 +3631,11 @@ async fn oc_session_delete(
tag = "opencode"
)]
async fn oc_session_status(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
let sessions = state.opencode.sessions.lock().await;
let sessions = state.inner.session_manager().list_sessions().await;
let mut status_map = serde_json::Map::new();
for id in sessions.keys() {
status_map.insert(id.clone(), json!({"type": "idle"}));
for s in &sessions {
let status = if s.ended { "idle" } else { "busy" };
status_map.insert(s.session_id.clone(), json!({"type": status}));
}
(StatusCode::OK, Json(Value::Object(status_map)))
}
@ -5048,20 +5094,9 @@ async fn oc_tui_open_help(
tag = "opencode"
)]
async fn oc_tui_open_sessions(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
State(_state): State<Arc<OpenCodeAppState>>,
_headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/open-sessions",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
@ -5250,19 +5285,21 @@ async fn oc_tui_publish(
)]
async fn oc_tui_select_session(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
_headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/select-session",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
if let Some(Json(body)) = body {
// Emit a tui.session.select event so the TUI navigates to the session.
let session_id = body
.get("sessionID")
.and_then(Value::as_str)
.unwrap_or_default();
state.opencode.emit_event(json!({
"type": "tui.session.select",
"properties": {
"sessionID": session_id
}
}));
}
bool_ok(true).into_response()
}

View file

@ -429,6 +429,10 @@ struct SessionState {
claude_message_counter: u64,
pending_assistant_native_ids: VecDeque<String>,
pending_assistant_counter: u64,
created_at: i64,
updated_at: i64,
directory: Option<String>,
title: Option<String>,
}
#[derive(Debug, Clone)]
@ -455,6 +459,10 @@ impl SessionState {
request.permission_mode.as_deref(),
)?;
let (broadcaster, _rx) = broadcast::channel(256);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
Ok(Self {
session_id,
@ -488,6 +496,10 @@ impl SessionState {
claude_message_counter: 0,
pending_assistant_native_ids: VecDeque::new(),
pending_assistant_counter: 0,
created_at: now,
updated_at: now,
directory: request.directory.clone(),
title: request.title.clone(),
})
}
@ -529,6 +541,12 @@ impl SessionState {
}
}
}
if !events.is_empty() {
self.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(self.updated_at);
}
events
}
@ -2117,7 +2135,7 @@ impl SessionManager {
Ok(EventsResponse { events, has_more })
}
async fn list_sessions(&self) -> Vec<SessionInfo> {
pub(crate) async fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.lock().await;
sessions
.iter()
@ -2132,10 +2150,33 @@ impl SessionManager {
native_session_id: state.native_session_id.clone(),
ended: state.ended,
event_count: state.events.len() as u64,
created_at: state.created_at,
updated_at: state.updated_at,
directory: state.directory.clone(),
title: state.title.clone(),
})
.collect()
}
pub(crate) async fn get_session_info(&self, session_id: &str) -> Option<SessionInfo> {
let sessions = self.sessions.lock().await;
Self::session_ref(&sessions, session_id).map(|state| SessionInfo {
session_id: state.session_id.clone(),
agent: state.agent.as_str().to_string(),
agent_mode: state.agent_mode.clone(),
permission_mode: state.permission_mode.clone(),
model: state.model.clone(),
variant: state.variant.clone(),
native_session_id: state.native_session_id.clone(),
ended: state.ended,
event_count: state.events.len() as u64,
created_at: state.created_at,
updated_at: state.updated_at,
directory: state.directory.clone(),
title: state.title.clone(),
})
}
pub(crate) async fn subscribe(
&self,
session_id: &str,
@ -4373,6 +4414,10 @@ pub struct SessionInfo {
pub native_session_id: Option<String>,
pub ended: bool,
pub event_count: u64,
pub created_at: i64,
pub updated_at: i64,
pub directory: Option<String>,
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
@ -4400,6 +4445,10 @@ pub struct CreateSessionRequest {
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
@ -6363,6 +6412,8 @@ pub mod test_utils {
model: None,
variant: None,
agent_version: None,
directory: None,
title: None,
};
let mut session =
SessionState::new(session_id.to_string(), agent, &request).expect("session");

View file

@ -0,0 +1,63 @@
---
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 2
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 3
type: item.delta
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta