Merge branch 'main' into NathanFlurry/pi-bootstrap-fix

This commit is contained in:
Nathan Flurry 2026-03-15 18:52:05 -07:00 committed by GitHub
commit 7924e11a23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
671 changed files with 52836 additions and 28750 deletions

View file

@ -1,18 +1,47 @@
# Server Instructions
## Architecture
## ACP v1 Baseline
- Public API routes are defined in `server/packages/sandbox-agent/src/router.rs`.
- ACP proxy runtime is in `server/packages/sandbox-agent/src/acp_proxy_runtime.rs`.
- All API endpoints are under `/v1`.
- Keep binary filesystem transfer endpoints as dedicated HTTP APIs:
- v1 is ACP-native.
- `/v1/*` is removed and returns `410 Gone` (`application/problem+json`).
- `/opencode/*` is disabled during ACP core phases and returns `503`.
- Prompt/session traffic is ACP JSON-RPC over streamable HTTP on `/v1/rpc`:
- `POST /v1/rpc`
- `GET /v1/rpc` (SSE)
- `DELETE /v1/rpc`
- Control-plane endpoints:
- `GET /v1/health`
- `GET /v1/agents`
- `POST /v1/agents/{agent}/install`
- Binary filesystem transfer endpoints (intentionally HTTP, not ACP extension methods):
- `GET /v1/fs/file`
- `PUT /v1/fs/file`
- `POST /v1/fs/upload-batch`
- Rationale: host-owned cross-agent-consistent behavior and large binary transfer needs that ACP JSON-RPC is not suited to stream efficiently.
- Maintain ACP variants in parallel only when they share the same underlying filesystem implementation; SDK defaults should still prefer HTTP for large/binary transfers.
- `/opencode/*` stays disabled (`503`) until Phase 7.
- Agent install logic (native + ACP agent process + lazy install) is handled by `server/packages/agent-management/`.
- Sandbox Agent ACP extension method naming:
- Custom ACP methods use `_sandboxagent/...` (not `_sandboxagent/v1/...`).
- Session detach method is `_sandboxagent/session/detach`.
## API Scope
- ACP is the primary protocol for agent/session behavior and all functionality that talks directly to the agent.
- ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP.
- Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities).
- Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP.
- Do not make Sandbox Agent core flows depend on ACP client implementations of `fs/*` or `terminal/*`; in practice those client-side capabilities are often incomplete or inconsistent.
- ACP-native filesystem and terminal methods are also too limited for Sandbox Agent host/runtime needs, so prefer the native HTTP APIs for richer behavior.
- Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP:
- These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior.
- They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream.
- This is intentionally separate from ACP native `fs/read_text_file` and `fs/write_text_file`.
- ACP extension variants may exist in parallel, but SDK defaults should prefer HTTP for these binary transfer operations.
## Architecture
- HTTP contract and problem/error mapping: `server/packages/sandbox-agent/src/router.rs`
- ACP proxy runtime: `server/packages/sandbox-agent/src/acp_proxy_runtime.rs`
- ACP client runtime and agent process bridge: `server/packages/sandbox-agent/src/acp_runtime/mod.rs`
- Agent install logic (native + ACP agent process + lazy install): `server/packages/agent-management/`
- Inspector UI served at `/ui/` and bound to ACP over HTTP from `frontend/packages/inspector/`
## API Contract Rules
@ -21,6 +50,24 @@
- Regenerate `docs/openapi.json` after endpoint contract changes.
- Keep CLI and HTTP endpoint behavior aligned (`docs/cli.mdx`).
## ACP Protocol Compliance
- Before adding any new ACP method, property, or config option category to the SDK, verify it exists in the ACP spec at `https://agentclientprotocol.com/llms-full.txt`.
- Valid `SessionConfigOptionCategory` values are: `mode`, `model`, `thought_level`, `other`, or custom categories prefixed with `_` (e.g. `_permission_mode`).
- Do not invent ACP properties or categories (e.g. `permission_mode` is not a valid ACP category — use `_permission_mode` if it's a custom extension, or use existing ACP mechanisms like `session/set_mode`).
- `NewSessionRequest` only has `_meta`, `cwd`, and `mcpServers`. Do not add non-ACP fields to it.
- Sandbox Agent SDK abstractions (like `SessionCreateRequest`) may add convenience properties, but must clearly map to real ACP methods internally and not send fabricated fields over the wire.
## Source Documents
- ACP protocol specification (full LLM-readable reference): `https://agentclientprotocol.com/llms-full.txt`
- `~/misc/acp-docs/schema/schema.json`
- `~/misc/acp-docs/schema/meta.json`
- `research/acp/spec.md`
- `research/acp/v1-schema-to-acp-mapping.md`
- `research/acp/friction.md`
- `research/acp/todo.md`
## Tests
Primary v1 integration coverage:
@ -38,3 +85,9 @@ cargo test -p sandbox-agent --test v1_agent_process_matrix
- Keep `research/acp/spec.md` as the source spec.
- Update `research/acp/todo.md` when scope/status changes.
- Log blockers/decisions in `research/acp/friction.md`.
## Docker Examples (Dev Testing)
- When manually testing bleeding-edge (unreleased) versions of sandbox-agent in `examples/`, use `SANDBOX_AGENT_DEV=1` with the Docker-based examples.
- This triggers a local build of `docker/runtime/Dockerfile.full` which builds the server binary from local source and packages it into the Docker image.
- Example: `SANDBOX_AGENT_DEV=1 pnpm --filter @sandbox-agent/example-mcp start`

View file

@ -79,7 +79,7 @@ pub enum Command {
Opencode(OpencodeArgs),
/// Manage the sandbox-agent background daemon.
Daemon(DaemonArgs),
/// Install or reinstall an agent without running the server.
/// Install or reinstall one agent, or `all` supported agents, without running the server.
InstallAgent(InstallAgentArgs),
/// Inspect locally discovered credentials.
Credentials(CredentialsArgs),
@ -295,7 +295,10 @@ pub struct AcpCloseArgs {
#[derive(Args, Debug)]
pub struct InstallAgentArgs {
agent: String,
#[arg(required_unless_present = "all", conflicts_with = "all")]
agent: Option<String>,
#[arg(long, conflicts_with = "agent")]
all: bool,
#[arg(long, short = 'r')]
reinstall: bool,
#[arg(long = "agent-version")]
@ -946,24 +949,73 @@ fn load_json_payload(
}
fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> {
let agent_id = AgentId::parse(&args.agent)
.ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?;
if args.all && (args.agent_version.is_some() || args.agent_process_version.is_some()) {
return Err(CliError::Server(
"--agent-version and --agent-process-version are only supported for single-agent installs"
.to_string(),
));
}
let agents = resolve_install_agents(args)?;
let manager = AgentManager::new(default_install_dir())
.map_err(|err| CliError::Server(err.to_string()))?;
let result = manager
.install(
agent_id,
InstallOptions {
reinstall: args.reinstall,
version: args.agent_version.clone(),
agent_process_version: args.agent_process_version.clone(),
},
)
.map_err(|err| CliError::Server(err.to_string()))?;
if agents.len() == 1 {
let result = manager
.install(
agents[0],
InstallOptions {
reinstall: args.reinstall,
version: args.agent_version.clone(),
agent_process_version: args.agent_process_version.clone(),
},
)
.map_err(|err| CliError::Server(err.to_string()))?;
let output = install_result_json(result);
return write_stdout_line(&serde_json::to_string_pretty(&output)?);
}
let output = json!({
let mut results = Vec::with_capacity(agents.len());
for agent_id in agents {
let result = manager
.install(
agent_id,
InstallOptions {
reinstall: args.reinstall,
version: None,
agent_process_version: None,
},
)
.map_err(|err| CliError::Server(err.to_string()))?;
results.push(json!({
"agent": agent_id.as_str(),
"result": install_result_json(result),
}));
}
write_stdout_line(&serde_json::to_string_pretty(
&json!({ "agents": results }),
)?)
}
fn resolve_install_agents(args: &InstallAgentArgs) -> Result<Vec<AgentId>, CliError> {
if args.all {
return Ok(AgentId::all().to_vec());
}
let agent = args
.agent
.as_deref()
.ok_or_else(|| CliError::Server("missing agent: provide <AGENT> or --all".to_string()))?;
AgentId::parse(agent)
.map(|agent_id| vec![agent_id])
.ok_or_else(|| CliError::Server(format!("unsupported agent: {agent}")))
}
fn install_result_json(result: sandbox_agent_agent_management::agents::InstallResult) -> Value {
json!({
"alreadyInstalled": result.already_installed,
"artifacts": result.artifacts.into_iter().map(|artifact| json!({
"kind": format!("{:?}", artifact.kind),
@ -971,9 +1023,7 @@ fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> {
"source": format!("{:?}", artifact.source),
"version": artifact.version,
})).collect::<Vec<_>>()
});
write_stdout_line(&serde_json::to_string_pretty(&output)?)
})
}
#[derive(Serialize)]
@ -1416,6 +1466,60 @@ fn write_stderr_line(text: &str) -> Result<(), CliError> {
mod tests {
use super::*;
#[test]
fn resolve_install_agents_expands_all() {
assert_eq!(
resolve_install_agents(&InstallAgentArgs {
agent: None,
all: true,
reinstall: false,
agent_version: None,
agent_process_version: None,
})
.unwrap(),
AgentId::all().to_vec()
);
}
#[test]
fn resolve_install_agents_supports_single_agent() {
assert_eq!(
resolve_install_agents(&InstallAgentArgs {
agent: Some("codex".to_string()),
all: false,
reinstall: false,
agent_version: None,
agent_process_version: None,
})
.unwrap(),
vec![AgentId::Codex]
);
}
#[test]
fn resolve_install_agents_rejects_unknown_agent() {
assert!(resolve_install_agents(&InstallAgentArgs {
agent: Some("nope".to_string()),
all: false,
reinstall: false,
agent_version: None,
agent_process_version: None,
})
.is_err());
}
#[test]
fn resolve_install_agents_rejects_positional_all() {
assert!(resolve_install_agents(&InstallAgentArgs {
agent: Some("all".to_string()),
all: false,
reinstall: false,
agent_version: None,
agent_process_version: None,
})
.is_err());
}
#[test]
fn apply_last_event_id_header_sets_header_when_provided() {
let client = HttpClient::builder().build().expect("build client");

View file

@ -21,10 +21,7 @@ describe("OpenCode-compatible Event Streaming", () => {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function initSessionViaHttp(
sessionId: string,
body: Record<string, unknown>
): Promise<void> {
async function initSessionViaHttp(sessionId: string, body: Record<string, unknown>): Promise<void> {
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, {
method: "POST",
headers: {
@ -113,10 +110,7 @@ describe("OpenCode-compatible Event Streaming", () => {
for await (const event of (eventStream as any).stream) {
events.push(event);
// Look for message part updates or completion
if (
event.type === "message.part.updated" ||
event.type === "session.idle"
) {
if (event.type === "message.part.updated" || event.type === "session.idle") {
if (events.length >= 3) {
clearTimeout(timeout);
resolve();
@ -175,10 +169,7 @@ describe("OpenCode-compatible Event Streaming", () => {
const statuses: string[] = [];
const collectIdle = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for session.idle")),
15_000
);
const timeout = setTimeout(() => reject(new Error("Timed out waiting for session.idle")), 15_000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
@ -223,10 +214,7 @@ describe("OpenCode-compatible Event Streaming", () => {
let busySnapshot: string | undefined;
const waitForIdle = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for busy status snapshot + session.idle")),
15_000
);
const timeout = setTimeout(() => reject(new Error("Timed out waiting for busy status snapshot + session.idle")), 15_000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
@ -276,10 +264,7 @@ describe("OpenCode-compatible Event Streaming", () => {
const idles: any[] = [];
const collectErrorAndIdle = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for session.error + session.idle")),
15_000
);
const timeout = setTimeout(() => reject(new Error("Timed out waiting for session.error + session.idle")), 15_000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
@ -428,9 +413,7 @@ describe("OpenCode-compatible Event Streaming", () => {
expect(idleEvents.length).toBe(1);
// All tool parts should have been emitted before idle
const toolParts = allEvents.filter(
(e) => e.type === "message.part.updated" && e.properties?.part?.type === "tool"
);
const toolParts = allEvents.filter((e) => e.type === "message.part.updated" && e.properties?.part?.type === "tool");
expect(toolParts.length).toBeGreaterThan(0);
});
@ -459,12 +442,7 @@ describe("OpenCode-compatible Event Streaming", () => {
if (!targetMessageId && partType === "tool" && typeof messageId === "string") {
targetMessageId = messageId;
}
if (
targetMessageId &&
messageId === targetMessageId &&
typeof partId === "string" &&
!seenPartIds.includes(partId)
) {
if (targetMessageId && messageId === targetMessageId && typeof partId === "string" && !seenPartIds.includes(partId)) {
seenPartIds.push(partId);
}
}
@ -497,17 +475,12 @@ describe("OpenCode-compatible Event Streaming", () => {
expect(targetMessageId).toBeTruthy();
expect(seenPartIds.length).toBeGreaterThan(0);
const response = await fetch(
`${handle.baseUrl}/opencode/session/${sessionId}/message/${targetMessageId}`,
{
headers: { Authorization: `Bearer ${handle.token}` },
}
);
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message/${targetMessageId}`, {
headers: { Authorization: `Bearer ${handle.token}` },
});
expect(response.ok).toBe(true);
const message = (await response.json()) as any;
const returnedPartIds = (message?.parts ?? [])
.map((part: any) => part?.id)
.filter((id: any) => typeof id === "string");
const returnedPartIds = (message?.parts ?? []).map((part: any) => part?.id).filter((id: any) => typeof id === "string");
const expectedSet = new Set(seenPartIds);
const returnedFiltered = returnedPartIds.filter((id: string) => expectedSet.has(id));

View file

@ -33,10 +33,7 @@ function findBinary(): string | null {
}
// Check cargo build outputs (relative to tests/opencode-compat/helpers)
const cargoPaths = [
resolve(__dirname, "../../../../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../../../../target/release/sandbox-agent"),
];
const cargoPaths = [resolve(__dirname, "../../../../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../../../../target/release/sandbox-agent")];
for (const p of cargoPaths) {
if (existsSync(p)) {
@ -65,12 +62,7 @@ async function getFreePort(host: string): Promise<number> {
/**
* Wait for the server to become healthy
*/
async function waitForHealth(
baseUrl: string,
token: string,
timeoutMs: number,
child: ChildProcess
): Promise<void> {
async function waitForHealth(baseUrl: string, token: string, timeoutMs: number, child: ChildProcess): Promise<void> {
const start = Date.now();
let lastError: string | undefined;
@ -130,9 +122,7 @@ export interface SpawnOptions {
export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<SandboxAgentHandle> {
const binaryPath = findBinary();
if (!binaryPath) {
throw new Error(
"sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN."
);
throw new Error("sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN.");
}
const host = options.host ?? "127.0.0.1";
@ -222,7 +212,7 @@ export async function buildSandboxAgent(): Promise<void> {
console.log("Building sandbox-agent...");
const projectRoot = resolve(__dirname, "../../../../../..");
return new Promise((resolve, reject) => {
const proc = spawn("cargo", ["build", "-p", "sandbox-agent"], {
cwd: projectRoot,

View file

@ -60,8 +60,6 @@ describe("OpenCode-compatible Model API", () => {
expect(providerIds.has("claude")).toBe(true);
expect(providerIds.has("codex")).toBe(true);
expect(providerIds.has("pi")).toBe(true);
expect(
providerIds.has("opencode") || Array.from(providerIds).some((id) => id.startsWith("opencode:"))
).toBe(true);
expect(providerIds.has("opencode") || Array.from(providerIds).some((id) => id.startsWith("opencode:"))).toBe(true);
});
});

View file

@ -9,7 +9,7 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Permission API", () => {
@ -53,11 +53,7 @@ describe("OpenCode-compatible Permission API", () => {
throw new Error("Timed out waiting for permission request");
}
async function waitForCondition(
check: () => boolean | Promise<boolean>,
timeoutMs = 10_000,
intervalMs = 100,
) {
async function waitForCondition(check: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 100) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await check()) {
@ -68,11 +64,7 @@ describe("OpenCode-compatible Permission API", () => {
throw new Error("Timed out waiting for condition");
}
async function waitForValue<T>(
getValue: () => T | undefined | Promise<T | undefined>,
timeoutMs = 10_000,
intervalMs = 100,
): Promise<T> {
async function waitForValue<T>(getValue: () => T | undefined | Promise<T | undefined>, timeoutMs = 10_000, intervalMs = 100): Promise<T> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const value = await getValue();
@ -175,13 +167,7 @@ describe("OpenCode-compatible Permission API", () => {
});
expect(firstReply.error).toBeUndefined();
await waitForCondition(() =>
repliedEvents.some(
(event) =>
event?.properties?.requestID === firstRequestId &&
event?.properties?.reply === "always",
),
);
await waitForCondition(() => repliedEvents.some((event) => event?.properties?.requestID === firstRequestId && event?.properties?.reply === "always"));
await client.session.prompt({
sessionID: sessionId,
@ -190,11 +176,7 @@ describe("OpenCode-compatible Permission API", () => {
});
const autoReplyEvent = await waitForValue(() =>
repliedEvents.find(
(event) =>
event?.properties?.requestID !== firstRequestId &&
event?.properties?.reply === "always",
),
repliedEvents.find((event) => event?.properties?.requestID !== firstRequestId && event?.properties?.reply === "always"),
);
const autoRequestId = autoReplyEvent?.properties?.requestID;
expect(autoRequestId).toBeDefined();

View file

@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Question API", () => {

View file

@ -15,11 +15,7 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import {
spawnSandboxAgent,
buildSandboxAgent,
type SandboxAgentHandle,
} from "./helpers/spawn";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
const MODEL = process.env.TEST_AGENT_MODEL;
@ -62,22 +58,11 @@ describe.skipIf(!MODEL)("Real agent round-trip", () => {
* Uses a manual iterator to avoid closing the stream (for-await-of calls
* iterator.return() on early exit, which would close the SSE connection).
*/
function collectUntilIdle(
iter: AsyncIterator<any>,
timeoutMs = 30_000,
): Promise<{ events: any[]; text: string }> {
function collectUntilIdle(iter: AsyncIterator<any>, timeoutMs = 30_000): Promise<{ events: any[]; text: string }> {
const events: any[] = [];
let text = "";
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() =>
reject(
new Error(
`Timed out after ${timeoutMs}ms. Events: ${JSON.stringify(events.map((e) => e.type))}`,
),
),
timeoutMs,
);
const timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms. Events: ${JSON.stringify(events.map((e) => e.type))}`)), timeoutMs);
(async () => {
try {
while (true) {
@ -88,10 +73,7 @@ describe.skipIf(!MODEL)("Real agent round-trip", () => {
return;
}
events.push(event);
if (
event.type === "message.part.updated" &&
event.properties?.part?.type === "text"
) {
if (event.type === "message.part.updated" && event.properties?.part?.type === "text") {
// Prefer the delta (chunk) if present; otherwise use the full
// accumulated part.text (for non-streaming single-shot events).
text += event.properties.delta ?? event.properties.part.text ?? "";
@ -103,11 +85,7 @@ describe.skipIf(!MODEL)("Real agent round-trip", () => {
}
if (event.type === "session.error") {
clearTimeout(timeout);
reject(
new Error(
`session.error: ${JSON.stringify(event.properties?.error)}`,
),
);
reject(new Error(`session.error: ${JSON.stringify(event.properties?.error)}`));
return;
}
}

View file

@ -55,10 +55,7 @@ describe("OpenCode-compatible Session API", () => {
return (sessions ?? []).find((item: any) => item.id === sessionId);
}
async function initSessionViaHttp(
sessionId: string,
body: Record<string, unknown> = {}
): Promise<{ response: Response; data: any }> {
async function initSessionViaHttp(sessionId: string, body: Record<string, unknown> = {}): Promise<{ response: Response; data: any }> {
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, {
method: "POST",
headers: {
@ -300,7 +297,7 @@ describe("OpenCode-compatible Session API", () => {
});
expect(changed.response.status).toBe(400);
expect(changed.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."
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session.",
);
});
@ -337,9 +334,7 @@ describe("OpenCode-compatible Session API", () => {
it("should keep session.get available during first prompt after /new-style creation", async () => {
const providers = await getProvidersViaHttp();
const providerId = providers.connected.find(
(provider) => provider !== "mock" && typeof providers.default?.[provider] === "string"
);
const providerId = providers.connected.find((provider) => provider !== "mock" && typeof providers.default?.[provider] === "string");
if (!providerId) {
return;
}
@ -478,7 +473,7 @@ describe("OpenCode-compatible Session API", () => {
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."
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session.",
);
}
});
@ -511,7 +506,7 @@ describe("OpenCode-compatible Session API", () => {
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."
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session.",
);
});
});