chore: commit remaining workspace updates

This commit is contained in:
Nathan Flurry 2026-02-08 11:51:47 -08:00
parent 98964f80ff
commit 3ba4c54c0c
4 changed files with 99 additions and 16 deletions

View file

@ -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<string, ItemState>();
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

View file

@ -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<String>,
#[schema(value_type = String)]
model: Option<Value>,
#[serde(rename = "providerID", alias = "provider_id")]
provider_id: Option<String>,
#[serde(rename = "modelID", alias = "model_id")]
model_id: Option<String>,
}
#[derive(Debug, Deserialize, IntoParams)]
@ -3850,11 +3857,30 @@ async fn oc_session_get(
async fn oc_session_update(
State(state): State<Arc<OpenCodeAppState>>,
Path(session_id): Path<String>,
Json(body): Json<OpenCodeUpdateSessionRequest>,
Json(body): Json<Value>,
) -> 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()

View file

@ -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", () => {

View file

@ -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",