mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
feat: add session metadata (timestamps, directory, title) and use v1 SessionManager for OpenCode compat
This commit is contained in:
parent
35ae25177b
commit
2b0507c3f5
5 changed files with 213 additions and 34 deletions
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue