From 3ba4c54c0cb6e86ac1d7e4e69489cb0eaa3b3e40 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 8 Feb 2026 11:51:47 -0800 Subject: [PATCH] chore: commit remaining workspace updates --- docs/building-chat-ui.mdx | 32 +++++++++++-------- .../sandbox-agent/src/opencode_compat.rs | 30 +++++++++++++++-- .../tests/opencode-compat/session.test.ts | 23 +++++++++++++ spec/universal-schema.json | 30 +++++++++++++++++ 4 files changed, 99 insertions(+), 16 deletions(-) diff --git a/docs/building-chat-ui.mdx b/docs/building-chat-ui.mdx index 8f124a6..da706ff 100644 --- a/docs/building-chat-ui.mdx +++ b/docs/building-chat-ui.mdx @@ -70,7 +70,7 @@ Use `offset` to track the last seen `sequence` number and resume from where you ### Bare minimum -Handle these three events to render a basic chat: +Handle item lifecycle plus turn lifecycle to render a basic chat: ```ts type ItemState = { @@ -79,9 +79,20 @@ type ItemState = { }; const items = new Map(); +let turnInProgress = false; function handleEvent(event: UniversalEvent) { switch (event.type) { + case "turn.started": { + turnInProgress = true; + break; + } + + case "turn.ended": { + turnInProgress = false; + break; + } + case "item.started": { const { item } = event.data as ItemEventData; items.set(item.item_id, { item, deltas: [] }); @@ -110,12 +121,14 @@ function handleEvent(event: UniversalEvent) { } ``` -When rendering, show a loading indicator while `item.status === "in_progress"`: +When rendering: +- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.). +- Use `item.status === "in_progress"` for per-item streaming state. ```ts function renderItem(state: ItemState) { const { item, deltas } = state; - const isLoading = item.status === "in_progress"; + const isItemLoading = item.status === "in_progress"; // For streaming text, combine item content with accumulated deltas const text = item.content @@ -126,7 +139,8 @@ function renderItem(state: ItemState) { return { content: streamedText, - isLoading, + isItemLoading, + isTurnLoading: turnInProgress, role: item.role, kind: item.kind, }; @@ -155,16 +169,6 @@ function handleEvent(event: UniversalEvent) { break; } - case "turn.started": { - // Turn began (useful for showing per-turn loading state) - break; - } - - case "turn.ended": { - // Turn completed (useful for ending per-turn loading state) - break; - } - case "error": { const { message, code } = event.data as ErrorData; // Display error to user diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 406f3bd..e668fed 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -52,6 +52,7 @@ const OPENCODE_EVENT_LOG_SIZE: usize = 4096; const OPENCODE_DEFAULT_MODEL_ID: &str = "mock"; const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock"; const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; +const OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR: &str = "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."; #[derive(Clone, Debug)] struct OpenCodeStreamEvent { @@ -668,6 +669,12 @@ struct OpenCodeCreateSessionRequest { #[serde(rename_all = "camelCase")] struct OpenCodeUpdateSessionRequest { title: Option, + #[schema(value_type = String)] + model: Option, + #[serde(rename = "providerID", alias = "provider_id")] + provider_id: Option, + #[serde(rename = "modelID", alias = "model_id")] + model_id: Option, } #[derive(Debug, Deserialize, IntoParams)] @@ -3850,11 +3857,30 @@ async fn oc_session_get( async fn oc_session_update( State(state): State>, Path(session_id): Path, - Json(body): Json, + Json(body): Json, ) -> impl IntoResponse { let mut sessions = state.opencode.sessions.lock().await; if let Some(session) = sessions.get_mut(&session_id) { - if let Some(title) = body.title { + let requests_model_change = body + .as_object() + .map(|obj| { + obj.contains_key("model") + || obj.contains_key("providerID") + || obj.contains_key("modelID") + || obj.contains_key("provider_id") + || obj.contains_key("model_id") + || obj.contains_key("providerId") + || obj.contains_key("modelId") + }) + .unwrap_or(false); + if requests_model_change { + return bad_request(OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR).into_response(); + } + if let Some(title) = body + .get("title") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + { if let Err(err) = state .inner .session_manager() diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts index 8279598..1aafa19 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -313,6 +313,29 @@ describe("OpenCode-compatible Session API", () => { const response = await client.session.get({ path: { id: sessionId } }); expect(response.data?.title).toBe("Updated"); }); + + it("should reject model changes after session creation", async () => { + const created = await client.session.create({ body: { title: "Original" } }); + const sessionId = created.data?.id!; + + const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + providerID: "codex", + modelID: "gpt-5", + }), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data?.errors?.[0]?.message).toBe( + "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session." + ); + }); }); describe("session.delete", () => { diff --git a/spec/universal-schema.json b/spec/universal-schema.json index 3d6fd89..ede37c6 100644 --- a/spec/universal-schema.json +++ b/spec/universal-schema.json @@ -519,8 +519,36 @@ "daemon" ] }, + "TurnEventData": { + "type": "object", + "required": [ + "phase" + ], + "properties": { + "metadata": true, + "phase": { + "$ref": "#/definitions/TurnPhase" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "TurnPhase": { + "type": "string", + "enum": [ + "started", + "ended" + ] + }, "UniversalEventData": { "anyOf": [ + { + "$ref": "#/definitions/TurnEventData" + }, { "$ref": "#/definitions/SessionStartedData" }, @@ -552,6 +580,8 @@ "enum": [ "session.started", "session.ended", + "turn.started", + "turn.ended", "item.started", "item.delta", "item.completed",