From b6335144e4e1be49ba14a0e65919a905bb1e75a8 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:59:48 +0200 Subject: [PATCH 1/5] feat: /compact, /undo, /redo and /share in OpenCode sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These commands are run by OpenCode's terminal itself, so its server does not list them: typed on the phone they reached the model as plain text. The driver now runs them through the server's API — summarize, revert and unrevert, share and unshare — lists them in the command menu, and reports each outcome as a status line. Undo takes back the last turn not yet undone, and the line says the transcript keeps it until the next message; a command a project defines under the same name still wins. Co-Authored-By: Claude Code --- .../opencode/__tests__/opencodeDriver.test.ts | 21 ++- .../__tests__/opencodeSessionCommands.test.ts | 81 +++++++++++ .../src/drivers/opencode/adapter.ts | 9 ++ .../agent-host/src/drivers/opencode/driver.ts | 27 +++- .../src/drivers/opencode/sessionCommands.ts | 134 ++++++++++++++++++ 5 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts create mode 100644 packages/agent-host/src/drivers/opencode/sessionCommands.ts diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts index cddf7cc2..8df769ca 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts @@ -533,16 +533,18 @@ describe('OpenCode slash commands', () => { return client as FakeClient & { session: { command: ReturnType; promptAsync: ReturnType } }; }; - it('lists its commands with their argument placeholders as hints', async () => { + it('lists its commands with their argument placeholders as hints, then the ones the driver runs', async () => { const client = withCommands(clientWith([])); const ctx = recordingContext(); const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', model: 'a/b', resume: 'x' }, ctx); (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1' }, error: undefined }); - expect(await session.listCommands!()).toEqual([ + const commands = await session.listCommands!(); + expect(commands.slice(0, 3)).toEqual([ { name: 'init', description: 'create/update AGENTS.md' }, { name: 'review', description: 'review changes', argumentHint: '' }, { name: 'fix', argumentHint: ' ' }, ]); + expect(commands.slice(3).map((c) => c.name)).toEqual(['compact', 'undo', 'redo', 'share', 'unshare']); await session.end(); }); @@ -562,6 +564,21 @@ describe('OpenCode slash commands', () => { expect(client.session.promptAsync.mock.calls.map((c) => c[0].parts[0].text).sort()).toEqual(['/etc/hosts is broken', '/unknown thing']); await session.end(); }); + + it('runs a typed /undo itself, never as a prompt, and reports the outcome', async () => { + const client = withCommands(clientWith([])); + const ctx = recordingContext(); + const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', resume: 'x' }, ctx); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1' }, error: undefined }); + Object.assign(client.session, { + messages: vi.fn().mockResolvedValue({ data: [{ info: { id: 'm1', role: 'user' }, parts: [{ type: 'text', text: 'fix it' }] }] }), + revert: vi.fn().mockResolvedValue({ data: {} }), + }); + session.prompt('/undo'); + await ctx.waitFor(() => ctx.entries().some((e) => e.entryType === 'status' && e.text.startsWith('Undid "fix it"'))); + expect(client.session.promptAsync).not.toHaveBeenCalled(); + await session.end(); + }); }); describe('OpenCode model checks', () => { diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts new file mode 100644 index 00000000..faa8f1e0 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts @@ -0,0 +1,81 @@ +/** + * The commands OpenCode's terminal runs itself (compact, undo, redo, + * share), run through its server API. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { SESSION_COMMANDS, sessionCommand, sessionSlashCommands, type SessionCommandContext } from '../sessionCommands'; + +const user = (id: string, text: string) => ({ info: { id, role: 'user' }, parts: [{ type: 'text', text }] }); +const assistant = (id: string) => ({ info: { id, role: 'assistant' }, parts: [{ type: 'text', text: 'done' }] }); + +function context(revert?: string) { + const session = { + messages: vi.fn().mockResolvedValue({ data: [user('m1', 'add a test'), assistant('m2'), user('m3', 'now rename it\nplease'), assistant('m4')] }), + get: vi.fn().mockResolvedValue({ data: { id: 'ses_1', ...(revert ? { revert: { messageID: revert } } : {}) } }), + revert: vi.fn().mockResolvedValue({ data: {} }), + unrevert: vi.fn().mockResolvedValue({ data: {} }), + summarize: vi.fn().mockResolvedValue({ data: true }), + share: vi.fn().mockResolvedValue({ data: { share: { url: 'https://opncd.ai/s/abc' } } }), + unshare: vi.fn().mockResolvedValue({ data: {} }), + }; + const lines: string[] = []; + const ctx: SessionCommandContext = { + client: { session } as unknown as OpencodeClient, + sessionID: 'ses_1', + directory: '/w', + model: { providerID: 'ccr', modelID: 'm' }, + status: (text) => lines.push(text), + }; + return { ctx, session, lines }; +} + +const run = (name: string, ctx: SessionCommandContext) => SESSION_COMMANDS.find((c) => c.name === name)!.run(ctx); + +describe('the built-in session commands', () => { + it('undo takes back the last turn not yet undone', async () => { + const { ctx, session, lines } = context(); + await run('undo', ctx); + expect(session.revert).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', messageID: 'm3' }); + expect(lines.at(-1)).toMatch(/^Undid "now rename it" and the file changes/); + + const again = context('m3'); + await run('undo', again.ctx); + expect(again.session.revert).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', messageID: 'm1' }); + + const none = context('m1'); + await run('undo', none.ctx); + expect(none.session.revert).not.toHaveBeenCalled(); + expect(none.lines).toEqual(['Nothing to undo.']); + }); + + it('redo brings undone turns back, and says so when there are none', async () => { + const undone = context('m3'); + await run('redo', undone.ctx); + expect(undone.session.unrevert).toHaveBeenCalled(); + const none = context(); + await run('redo', none.ctx); + expect(none.session.unrevert).not.toHaveBeenCalled(); + expect(none.lines).toEqual(['Nothing to redo.']); + }); + + it("compact summarizes on the session's model; share names the link", async () => { + const { ctx, session, lines } = context(); + await run('compact', ctx); + expect(session.summarize).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', providerID: 'ccr', modelID: 'm' }); + await run('share', ctx); + expect(lines.at(-1)).toContain('https://opncd.ai/s/abc'); + }); + + it('a failed call is an error naming what failed', async () => { + const { ctx, session } = context(); + session.revert.mockResolvedValue({ error: { data: { message: 'session is busy' } } }); + await expect(run('undo', ctx)).rejects.toThrow('OpenCode could not undo the last turn: {"message":"session is busy"}'); + }); + + it("gives way to a command of the server's with the same name", () => { + expect(sessionCommand('undo', new Set())?.name).toBe('undo'); + expect(sessionCommand('undo', new Set(['undo']))).toBeUndefined(); + expect(sessionSlashCommands(new Set(['share'])).map((c) => c.name)).toEqual(['compact', 'undo', 'redo', 'unshare']); + }); +}); diff --git a/packages/agent-host/src/drivers/opencode/adapter.ts b/packages/agent-host/src/drivers/opencode/adapter.ts index b7b086d8..76258fb9 100644 --- a/packages/agent-host/src/drivers/opencode/adapter.ts +++ b/packages/agent-host/src/drivers/opencode/adapter.ts @@ -60,6 +60,12 @@ export interface OpenCodeError { content: string; } +/** A line about the session, not from the model (a command's outcome). */ +export interface OpenCodeStatus { + type: 'status'; + text: string; +} + /** The session to resume no longer exists server-side and a fresh one was * created instead — the model does not remember earlier turns, which the * user must not learn only through a silent id change. */ @@ -102,6 +108,7 @@ export type OpenCodeEvent = | OpenCodeIdle | OpenCodePart | OpenCodeError + | OpenCodeStatus | OpenCodeResumeLost | OpenCodeDiff | OpenCodeQuestion @@ -122,6 +129,8 @@ export function opencodeEventToEntries(event: OpenCodeEvent, ctx: TranslateConte return parsePart(event, ctx); case 'error': return [{ entryType: 'error', text: event.content, timestamp: ts }]; + case 'status': + return [{ entryType: 'status', text: event.text, timestamp: ts }]; case 'resume-lost': return [{ entryType: 'notice', diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 6be47498..4095e4e0 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -52,6 +52,7 @@ import type { Subagent, UsageData, } from '../../sdk/types'; +import { SESSION_COMMANDS, sessionCommand, sessionSlashCommands } from './sessionCommands'; import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; @@ -841,10 +842,16 @@ export class OpenCodeSession implements DriverSession { const command = parseSlashCommand(text); this.ready .then(async ({ client, session }) => { - if (command && (await this.knownCommands(client)).has(command.name)) { + const listed = command ? await this.knownCommands(client) : new Set(); + if (command && listed.has(command.name)) { this.runCommand(client, session.id, command.name, command.args); return; } + const builtin = command && sessionCommand(command.name, listed); + if (builtin) { + this.runSessionCommand(client, session.id, builtin); + return; + } const { error } = await client.session.promptAsync({ sessionID: session.id, directory: this.cwd, @@ -859,6 +866,20 @@ export class OpenCodeSession implements DriverSession { }); } + /** One of the commands OpenCode's terminal runs itself; its outcome is a + * status line, its failure an error entry. */ + private runSessionCommand(client: OpencodeClient, sessionID: string, command: (typeof SESSION_COMMANDS)[number]): void { + command + .run({ + client, + sessionID, + directory: this.cwd, + ...(this.model ? { model: this.model } : {}), + status: (text) => this.deliver({ type: 'status', text }), + }) + .catch((err) => this.deliver({ type: 'error', content: err instanceof Error ? err.message : String(err) })); + } + /** The OpenCode agent a prompt names: the plan agent in the plan mode, * none (OpenCode's default) otherwise. */ private agentField(): { agent?: string } { @@ -897,9 +918,11 @@ export class OpenCodeSession implements DriverSession { return this.commandNames; } + /** The server's commands, then the ones the driver runs itself. */ async listCommands(): Promise { const { client } = await this.ready; - return toSlashCommands(await this.fetchCommands(client)); + const commands = await this.fetchCommands(client); + return [...toSlashCommands(commands), ...sessionSlashCommands(new Set(commands.map((c) => c.name)))]; } async mcpStatus(): Promise { diff --git a/packages/agent-host/src/drivers/opencode/sessionCommands.ts b/packages/agent-host/src/drivers/opencode/sessionCommands.ts new file mode 100644 index 00000000..acc598d5 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/sessionCommands.ts @@ -0,0 +1,134 @@ +/** + * Commands OpenCode's terminal runs itself and its server does not list: + * compacting the conversation, undoing and redoing turns, sharing it. Each + * runs through the server's API, and its outcome is a status line in the + * transcript (the transcript keeps what an undo took back; the line says + * so). A command the server lists under the same name — a project's own — + * is the one that runs. + */ +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { slashCommand } from '../../sdk/commands'; +import type { SlashCommand } from '../../sdk/types'; + +/** What a command works on and how it reports back. */ +export interface SessionCommandContext { + client: OpencodeClient; + sessionID: string; + directory: string; + /** The model the session's prompts name, if one was chosen. */ + model?: { providerID: string; modelID: string }; + status(text: string): void; +} + +interface SessionCommand { + name: string; + description: string; + run(ctx: SessionCommandContext): Promise; +} + +/** A failed call's reason, for the error entry. */ +function failure(what: string, error: unknown): Error { + const detail = typeof error === 'object' && error !== null && 'data' in error ? (error as { data: unknown }).data : error; + return new Error(`OpenCode could not ${what}: ${JSON.stringify(detail)}`); +} + +/** The first line of a prompt, short enough to name it in a status line. */ +function gist(text: string): string { + const line = text.trim().split('\n')[0] ?? ''; + return line.length > 60 ? `${line.slice(0, 57)}…` : line; +} + +/** The user turns of a session, oldest first, with their text. */ +async function userTurns(ctx: SessionCommandContext): Promise> { + const { data, error } = await ctx.client.session.messages({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('read the conversation', error); + return data + .filter((m) => m.info.role === 'user') + .map((m) => ({ + id: m.info.id, + text: m.parts.flatMap((p) => (p.type === 'text' && !p.synthetic ? [p.text] : [])).join('\n'), + })); +} + +async function revertPoint(ctx: SessionCommandContext): Promise { + const { data, error } = await ctx.client.session.get({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('read the session', error); + return data.revert?.messageID; +} + +export const SESSION_COMMANDS: SessionCommand[] = [ + { + name: 'compact', + description: 'Summarize the conversation so far, to free up context', + async run(ctx) { + ctx.status('Compacting the conversation…'); + const { error } = await ctx.client.session.summarize({ + sessionID: ctx.sessionID, + directory: ctx.directory, + ...(ctx.model ?? {}), + }); + if (error) throw failure('compact the conversation', error); + ctx.status('Compacted: OpenCode continues from a summary of the conversation.'); + }, + }, + { + name: 'undo', + description: 'Take back the last turn and the file changes it made', + async run(ctx) { + const [turns, point] = await Promise.all([userTurns(ctx), revertPoint(ctx)]); + // Turns from the revert point on are already undone. + const live = point ? turns.slice(0, Math.max(0, turns.findIndex((t) => t.id === point))) : turns; + const last = live.at(-1); + if (!last) { + ctx.status('Nothing to undo.'); + return; + } + const { error } = await ctx.client.session.revert({ sessionID: ctx.sessionID, directory: ctx.directory, messageID: last.id }); + if (error) throw failure('undo the last turn', error); + ctx.status( + `Undid "${gist(last.text)}" and the file changes it made. OpenCode forgets it with your next message; /redo brings it back before then.`, + ); + }, + }, + { + name: 'redo', + description: 'Bring back the turns /undo took back', + async run(ctx) { + if (!(await revertPoint(ctx))) { + ctx.status('Nothing to redo.'); + return; + } + const { error } = await ctx.client.session.unrevert({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error) throw failure('redo', error); + ctx.status('Brought back the undone turns and their file changes.'); + }, + }, + { + name: 'share', + description: 'Publish the conversation at a link anyone can open', + async run(ctx) { + const { data, error } = await ctx.client.session.share({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('share the conversation', error); + ctx.status(data.share?.url ? `Shared at ${data.share.url} — anyone with the link can read it. /unshare stops it.` : 'Shared.'); + }, + }, + { + name: 'unshare', + description: 'Stop sharing the conversation', + async run(ctx) { + const { error } = await ctx.client.session.unshare({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error) throw failure('stop sharing the conversation', error); + ctx.status('The conversation is no longer shared.'); + }, + }, +]; + +/** The built-in command named `name`, unless the server lists one so named. */ +export function sessionCommand(name: string, listed: ReadonlySet): SessionCommand | undefined { + return listed.has(name) ? undefined : SESSION_COMMANDS.find((c) => c.name === name); +} + +/** The built-ins for the command menu, beside the `listed` ones. */ +export function sessionSlashCommands(listed: ReadonlySet): SlashCommand[] { + return SESSION_COMMANDS.filter((c) => !listed.has(c.name)).map((c) => slashCommand(c.name, c.description)); +} From 90a8657ee95bfbeec9e77c88043b2757c31332aa Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:05:13 +0200 Subject: [PATCH 2/5] feat: OpenCode names its sessions The driver created every OpenCode session titled with the bridge's session id, which stops OpenCode from naming it after its first message. Sessions are now created untitled, and the title OpenCode gives one reaches the bridge in the session's info (a new `title` field): it wins over the title the bridge takes from the first message and over the session-meta topic it asks the agent for, which still sets the project. Co-Authored-By: Claude Code --- crates/agent-protocol/src/messages.rs | 5 +++ crates/bridge-core/src/engine/host.rs | 35 ++++++++++++------- crates/bridge-core/src/session.rs | 4 +++ crates/bridge-core/tests/cards.rs | 2 +- crates/bridge-core/tests/sessions.rs | 27 +++++++++++++- docs/PROTOCOL.md | 2 +- .../opencode/__tests__/opencodeDriver.test.ts | 17 +++++++++ .../agent-host/src/drivers/opencode/driver.ts | 32 ++++++++++++++--- packages/agent-host/src/generated/protocol.ts | 28 ++++++++++----- 9 files changed, 125 insertions(+), 27 deletions(-) diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index 6f26d665..e442ebd8 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -195,6 +195,11 @@ pub enum SessionEvent { /// plan approval switched it). #[serde(default, skip_serializing_if = "Option::is_none")] mode: Option, + /// The agent named the session itself (OpenCode titles a session + /// after its first message). It wins over the title the bridge takes + /// from that message and over the topic it asks the agent for. + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(type = Option)] context_window: Option, diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index 3e39d5d7..45999158 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -64,6 +64,16 @@ pub(crate) enum HostCall { DeleteConversation(ConversationDelete), } +/// The facts a `SessionEvent::Info` changed; absent = unchanged. +struct InfoUpdate { + native_session_id: Option, + model: Option, + mode: Option, + title: Option, + context_window: Option, + context_percentage: Option, +} + /// A conversation of a deleted session, for its agent to delete. #[derive(Debug, Clone)] pub(crate) struct ConversationDelete { @@ -503,8 +513,9 @@ impl Engine { } match event { SessionEvent::Ready {} => self.on_ready(session_id), - SessionEvent::Info { native_session_id, model, mode, context_window, context_percentage } => { - self.on_info(session_id, native_session_id, model, mode, context_window, context_percentage); + SessionEvent::Info { native_session_id, model, mode, title, context_window, context_percentage } => { + let info = InfoUpdate { native_session_id, model, mode, title, context_window, context_percentage }; + self.on_info(session_id, info); } SessionEvent::Entries { entries } => self.on_entries(session_id, entries), SessionEvent::Turn { state } => { @@ -542,16 +553,15 @@ impl Engine { self.publish_all(BridgeToPhone::SessionReady(SessionReadyMsg { pending_id: session_id.to_string(), session: info })); } - fn on_info( - &mut self, - session_id: &str, - native_session_id: Option, - model: Option, - mode: Option, - context_window: Option, - context_percentage: Option, - ) { + fn on_info(&mut self, session_id: &str, info: InfoUpdate) { + let InfoUpdate { native_session_id, model, mode, title, context_window, context_percentage } = info; let Some(session) = self.sessions.get_mut(session_id) else { return }; + let title = title.and_then(|t| title_from(&t)); + if title.is_some() { + if let Some(run) = session.run.as_mut() { + run.agent_titled = true; + } + } let rec = &mut session.rec; let mut changed = false; let mut set = |field: &mut Option, value: Option| { @@ -562,6 +572,7 @@ impl Engine { }; set(&mut rec.native_session_id, native_session_id); set(&mut rec.model, model); + set(&mut rec.title, title); let mode_changed = mode.is_some() && rec.mode != mode; set(&mut rec.mode, mode.clone()); if context_window.is_some_and(|w| w > 0) && rec.context_window != context_window { @@ -606,7 +617,7 @@ impl Engine { *text = stripped; if let Some(meta) = meta.filter(|_| !run.summarized) { run.summarized = true; - if let Some(topic) = meta.topic { + if let Some(topic) = meta.topic.filter(|_| !run.agent_titled) { session.rec.title = Some(topic); } if let Some(project) = meta.project { diff --git a/crates/bridge-core/src/session.rs b/crates/bridge-core/src/session.rs index 5a788127..734ed8be 100644 --- a/crates/bridge-core/src/session.rs +++ b/crates/bridge-core/src/session.rs @@ -71,6 +71,9 @@ pub(crate) struct Runner { pub meta_requested: bool, /// The session-meta tag has been read once; later ones are only stripped. pub summarized: bool, + /// The agent named the session itself: a session-meta topic no longer + /// renames it. + pub agent_titled: bool, /// git HEAD when the session started; a different HEAD later = a commit. pub base_head: Option, } @@ -89,6 +92,7 @@ impl Runner { authored: VecDeque::new(), meta_requested: titled, summarized: titled, + agent_titled: false, base_head: None, } } diff --git a/crates/bridge-core/tests/cards.rs b/crates/bridge-core/tests/cards.rs index e6836f9a..82705036 100644 --- a/crates/bridge-core/tests/cards.rs +++ b/crates/bridge-core/tests/cards.rs @@ -178,7 +178,7 @@ fn a_plan_answer_and_the_agents_mode_switch_reach_the_phone() { ); assert_eq!(resolved(&rig.messages()), ["YOLO"]); - rig.host_event(&s, SessionEvent::Info { native_session_id: None, model: None, mode: Some("yolo".into()), context_window: None, context_percentage: None }); + rig.host_event(&s, SessionEvent::Info { native_session_id: None, model: None, mode: Some("yolo".into()), title: None, context_window: None, context_percentage: None }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::OptionConfirmed(o) if o.value == "yolo"))); assert_eq!(last_heartbeat(&msgs).sessions[0].mode.as_deref(), Some("yolo")); diff --git a/crates/bridge-core/tests/sessions.rs b/crates/bridge-core/tests/sessions.rs index b0c13a89..31f289b8 100644 --- a/crates/bridge-core/tests/sessions.rs +++ b/crates/bridge-core/tests/sessions.rs @@ -198,6 +198,7 @@ fn a_session_started_on_the_default_model_is_listed_with_it() { native_session_id: None, model: Some("m-default".into()), mode: None, + title: None, context_window: None, context_percentage: None, }); @@ -291,6 +292,30 @@ fn the_meta_tag_titles_the_session_once_and_is_always_stripped() { assert!(heartbeats(&msgs).iter().all(|h| h.sessions[0].title.as_deref() == Some("Fix login"))); } +#[test] +fn a_title_the_agent_gives_wins_over_the_meta_topic() { + let mut rig = Rig::new(); + rig.host_up(); + let s = rig.ready_session("alpha"); + let info = |title: &str| SessionEvent::Info { + native_session_id: None, + model: None, + mode: None, + title: Some(title.into()), + context_window: None, + context_percentage: None, + }; + rig.host_event(&s, info("Fix the login redirect")); + rig.say(&s, "Done.\n"); + let hb = last_heartbeat(&rig.messages()); + assert_eq!((hb.sessions[0].title.as_deref(), hb.sessions[0].project.as_str()), (Some("Fix the login redirect"), "web")); + // A later title from the agent still renames it; a blank one does not. + rig.host_event(&s, info("Fix the OAuth login redirect")); + rig.host_event(&s, info(" ")); + let hb = last_heartbeat(&rig.messages()); + assert_eq!(hb.sessions[0].title.as_deref(), Some("Fix the OAuth login redirect")); +} + #[test] fn input_to_an_unknown_session_is_no_session_and_to_an_ended_one_error() { let mut rig = Rig::new(); @@ -319,7 +344,7 @@ fn turn_events_drive_the_listed_state() { fn info_native(rig: &mut Rig, s: &str, id: &str) { rig.host_event( s, - SessionEvent::Info { native_session_id: Some(id.into()), model: None, mode: None, context_window: None, context_percentage: None }, + SessionEvent::Info { native_session_id: Some(id.into()), model: None, mode: None, title: None, context_window: None, context_percentage: None }, ); } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b3615802..2d90538d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -538,7 +538,7 @@ Session events are notifications: `session-event {sessionId, event}` with | Event | Meaning | |---|---| | `ready` | The agent accepts prompts (once per `start-session`). | -| `info` | Changed facts only: `nativeSessionId` (the resume target), `model`, `mode`, `contextWindow`, `contextPercentage`. | +| `info` | Changed facts only: `nativeSessionId` (the resume target), `model`, `mode`, `title` (the agent named the session; wins over the bridge's title from the first message and the session-meta topic), `contextWindow`, `contextPercentage`. | | `entries` | Transcript entries, in order (the bridge assigns seqs). | | `turn` | `running` / `idle`. | | `ended` | The session is gone: no `error` = a normal end; `resumeLost` = the conversation to resume no longer exists. The host forgets the session. | diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts index 8df769ca..ed44c091 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts @@ -45,6 +45,23 @@ function start(client: FakeClient, overrides: Partial = {}, handle return ctx; } +describe('OpenCode session titles', () => { + const updated = (title: string, sessionID = 'ses_1') => ({ type: 'session.updated', properties: { sessionID, info: { id: sessionID, title } } }); + + it('creates a session untitled, then reports the title OpenCode gives it, each new one once', async () => { + const client = clientWith([ + updated('New session - 2026-10-08T10:00:00.000Z'), + updated('Fix the login redirect'), + updated('Fix the login redirect'), + updated('Other session', 'ses_other'), + ]); + const ctx = start(client); + await ctx.ended(); + expect(client.session.create).toHaveBeenCalledWith({ directory: '/tmp' }); + expect(ctx.events.filter((e) => e.type === 'info' && 'title' in e)).toEqual([{ type: 'info', title: 'Fix the login redirect' }]); + }); +}); + describe('OpenCode conversation delete', () => { const deleting = (result: unknown) => { const client = clientWith([]) as FakeClient & { session: { delete: ReturnType } }; diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 4095e4e0..c845d80e 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -204,6 +204,14 @@ export function pickDefaultModel( /** Why OpenCode cannot run `model`: none of its providers offers it. When * the model list could not be fetched, nothing is refused. */ +/** The name OpenCode gives a session it has not titled yet + * ("New session - "; "Child session - …" for a sub-agent's). */ +const PLACEHOLDER_TITLE = /^(New|Child) session - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export function isPlaceholderTitle(title: string): boolean { + return PLACEHOLDER_TITLE.test(title); +} + export function unsupportedModelReason(model: string, models: ModelEntry[]): string | undefined { if (models.length === 0 || models.some((m) => m.id === model)) return undefined; return `OpenCode does not offer the model '${model}' — none of its configured providers serves it; choose one from its model list.`; @@ -296,6 +304,8 @@ export class OpenCodeSession implements DriverSession { private readonly translate = newTranslateContext(); private ended = false; private mode: string; + /** The title last reported for the session. */ + private title: string | undefined; private model?: { providerID: string; modelID: string }; /** messageID -> role, seeded from message.updated events, so a later @@ -412,6 +422,7 @@ export class OpenCodeSession implements DriverSession { if (resumeLost) this.deliver({ type: 'resume-lost' }); if (!this.ended) { this.ctx.emit({ type: 'info', nativeSessionId: session.id, ...(model ? { model } : {}), mode: this.mode }); + this.reportTitle(session.title); this.deliver({ type: 'started', ...(model ? { model } : {}) }); this.ctx.emit({ type: 'ready' }); } @@ -448,13 +459,14 @@ export class OpenCodeSession implements DriverSession { `[opencode] resume ${params.resume} not found server-side ` + `(${error ? JSON.stringify(error) : 'no session returned'}) — starting a fresh session instead`, ); - return { session: await this.createSessionRemote(client, params), resumeLost: true }; + return { session: await this.createSessionRemote(client), resumeLost: true }; } - return { session: await this.createSessionRemote(client, params), resumeLost: false }; + return { session: await this.createSessionRemote(client), resumeLost: false }; } - private async createSessionRemote(client: OpencodeClient, params: StartSession): Promise { - const { data, error } = await client.session.create({ directory: this.cwd, title: params.sessionId }); + /** Created untitled, so OpenCode names it after its first message. */ + private async createSessionRemote(client: OpencodeClient): Promise { + const { data, error } = await client.session.create({ directory: this.cwd }); if (error || !data) { throw new Error(`OpenCode session.create failed: ${JSON.stringify(error ?? 'no session returned')}`); } @@ -522,6 +534,10 @@ export class OpenCodeSession implements DriverSession { if (!this.ended) this.ctx.emit({ type: 'turn', state: status === 'busy' ? 'running' : 'idle' }); break; } + case 'session.updated': { + if (event.properties.sessionID === sessionId) this.reportTitle(event.properties.info.title); + break; + } case 'session.diff': { if (event.properties.sessionID !== sessionId) continue; const files = this.changedDiffs(event.properties.diff); @@ -880,6 +896,14 @@ export class OpenCodeSession implements DriverSession { .catch((err) => this.deliver({ type: 'error', content: err instanceof Error ? err.message : String(err) })); } + /** The title OpenCode gave the session, once it has one of its own + * (not the timestamp a new session starts with) — each new one once. */ + private reportTitle(title: string | undefined): void { + if (!title || isPlaceholderTitle(title) || title === this.title || this.ended) return; + this.title = title; + this.ctx.emit({ type: 'info', title }); + } + /** The OpenCode agent a prompt names: the plan agent in the plan mode, * none (OpenCode's default) otherwise. */ private agentField(): { agent?: string } { diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index 65517ca0..84c075c4 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -1327,7 +1327,7 @@ export type SessionEvent = SessionEvent_Serialize | SessionEvent_Deserialize; */ export type SessionEvent_Deserialize = /** The agent came up and accepts prompts. Sent once per `start-session`. */ -({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | +({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | /** Session facts changed. Only the fields that changed are set. */ ({ type: "info"; /** The agent's own conversation id — the `resume` target. */ @@ -1338,11 +1338,17 @@ model?: string | null; * The agent changed its own mode (e.g. it entered plan mode, or a * plan approval switched it). */ -mode?: string | null; contextWindow?: number | null; +mode?: string | null; +/** + * The agent named the session itself (OpenCode titles a session + * after its first message). It wins over the title the bridge takes + * from that message and over the topic it asks the agent for. + */ +title?: string | null; contextWindow?: number | null; /** 0–100. */ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeLost?: never; state?: never } | /** Transcript entries, in order. The bridge assigns their seqs. */ -({ type: "entries"; entries: OutputEntry_Deserialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never } | +({ type: "entries"; entries: OutputEntry_Deserialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; title?: never } | /** * The session is gone. Absent `error` = it ended normally. The host * forgets the session after sending this. @@ -1352,7 +1358,7 @@ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeL * The agent could not find the `resume` conversation, so continuing * it is impossible; a new start must be fresh. */ -resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never }; +resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never; title?: never }; /** * Something a running session reports. The host translates its agent's own @@ -1360,7 +1366,7 @@ resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; en */ export type SessionEvent_Serialize = /** The agent came up and accepts prompts. Sent once per `start-session`. */ -({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | +({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | /** Session facts changed. Only the fields that changed are set. */ ({ type: "info"; /** The agent's own conversation id — the `resume` target. */ @@ -1371,11 +1377,17 @@ model?: string | null; * The agent changed its own mode (e.g. it entered plan mode, or a * plan approval switched it). */ -mode?: string | null; contextWindow?: number | null; +mode?: string | null; +/** + * The agent named the session itself (OpenCode titles a session + * after its first message). It wins over the title the bridge takes + * from that message and over the topic it asks the agent for. + */ +title?: string | null; contextWindow?: number | null; /** 0–100. */ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeLost?: never; state?: never } | /** Transcript entries, in order. The bridge assigns their seqs. */ -({ type: "entries"; entries: OutputEntry_Serialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never } | +({ type: "entries"; entries: OutputEntry_Serialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; title?: never } | /** * The session is gone. Absent `error` = it ended normally. The host * forgets the session after sending this. @@ -1385,7 +1397,7 @@ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeL * The agent could not find the `resume` conversation, so continuing * it is impossible; a new start must be fresh. */ -resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never }; +resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never; title?: never }; /** One MCP server of a running session. */ export type SessionMcpServer = SessionMcpServer_Serialize | SessionMcpServer_Deserialize; From 7a86aaa91877e9727b1df3418d98bfdaa552a390 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:12:36 +0200 Subject: [PATCH 3/5] feat: the session's cost and its model's name in the controls bar OpenCode reports what a session has cost, as it prices its models, so its catalog entry now says it has usage: the cost, with no subscription windows. The controls bar shows a session's cost as a pill whenever the agent reports one above zero (Claude Code's too, which was never shown), and the phone asks for usage again each time a turn ends, so the figure follows the session. The model chip ran every id through the Claude tag table, which passes anything else through whole: an OpenCode or gateway session read "ccr/OpenCode Go/deepseek-v4.1-flash". It now shows the name the agent's model list (or a provider profile's) gives the model, as the picker does; the session screen asks for that list on open. Co-Authored-By: Claude Code --- .../codedeck/plus/ui/session/SessionScreen.kt | 74 ++++++++++++++++-- .../codedeck/plus/ui/DesignSnapshotTest.kt | 12 +++ .../plus/ui/session/ModelLabelTest.kt | 13 +++ .../ui/session/SessionChromeSnapshotTest.kt | 2 +- .../plus/ui/session/SessionCostTest.kt | 17 ++++ ...SnapshotTest_session_controls_opencode.png | Bin 0 -> 12785 bytes .../opencode/__tests__/opencodeDriver.test.ts | 15 ++++ .../agent-host/src/drivers/opencode/driver.ts | 14 +++- 8 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt index 801f7c07..7943a73b 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt @@ -98,6 +98,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withTimeoutOrNull import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.UniffiModelEntry import uniffi.client_ffi.UniffiOptionChoice import uniffi.client_ffi.UniffiSessionMcp import uniffi.client_ffi.UniffiTranscriptDelta @@ -495,13 +496,21 @@ fun SessionScreen( inputFocus.requestFocus() } - // Refresh the subscription-usage snapshot on open: the bridge only - // publishes usage when asked. An agent whose catalog entry says it has - // no usage is not asked (it would publish nothing); until the catalog - // is known the effect waits, and fires once it says yes. + // Refresh the usage snapshot on open and whenever a turn ends (the + // session's cost grows with each): the bridge only publishes usage when + // asked. An agent whose catalog entry says it has no usage is not asked + // (it would publish nothing); until the catalog is known the effect + // waits, and fires once it says yes. + // The agent's model list names the session's model in the controls bar. + LaunchedEffect(machine, agent?.id, agent?.supportsModels) { + val id = agent?.id + if (id != null && agent.supportsModels) core.dispatch(UniffiIntent.RequestModels(machine = machine, agent = id)) + } + val supportsUsage = agent?.supportsUsage == true - LaunchedEffect(machine, sessionId, supportsUsage) { - if (supportsUsage) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId)) + val turnRunning = session?.state == "running" + LaunchedEffect(machine, sessionId, supportsUsage, turnRunning) { + if (supportsUsage && !turnRunning) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId)) } // Slash commands: the menu opens while the draft is a bare `/name` (not @@ -671,6 +680,11 @@ fun SessionScreen( modeLabel = modeLabel?.takeIf { modes.size >= 2 }, modePending = modeCycle.pending != null, model = session?.model, + modelName = listedModelName( + session?.model, + listOfNotNull(machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models) + + machineSummary?.providerProfiles.orEmpty().filter { it.agent == session?.agent }.map { it.models }, + ), contextPercentage = session?.contextPercentage, contextWindow = session?.contextWindow?.toLong(), onEffortSelect = { level -> @@ -855,6 +869,8 @@ internal fun SessionControlsBar( /** `null` hides the MCP pill (no servers, or an agent without MCP). */ mcp: UniffiSessionMcp? = null, onMcpTap: () -> Unit = {}, + /** The model list's name for [model] ([listedModelName]); null shows its tag. */ + modelName: String? = null, ) { Row( Modifier @@ -865,7 +881,7 @@ internal fun SessionControlsBar( horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), ) { if (model != null) { - ModelContextChip(model = model, contextPercentage = contextPercentage, contextWindow = contextWindow) + ModelContextChip(model = model, modelName = modelName, contextPercentage = contextPercentage, contextWindow = contextWindow) } if (modeLabel != null) { ModeButton(modeLabel, modePending, onModeTap) @@ -876,6 +892,9 @@ internal fun SessionControlsBar( if (mcp != null) { McpChip(mcp, onMcpTap) } + // What the session has cost so far: the agent's figure, shown whether + // or not the subscription windows are. + usage?.sessionCostUsd?.let(::sessionCost)?.let { CostPill(it) } val badges = usageBadges(usage, System.currentTimeMillis()) if (showUsageBadge && badges.isNotEmpty()) { UsageBox(usage, badges) @@ -916,6 +935,31 @@ internal fun SendFailedBar(text: String, failedCount: Int, onRetry: () -> Unit) } } +/** A session's cost as a pill: "$0.42", "<$0.01" below a cent; nothing for + * a free (or unpriced) session. */ +internal fun sessionCost(usd: Double): String? = when { + !usd.isFinite() || usd <= 0.0 -> null + usd < 0.01 -> "<$0.01" + usd < 100.0 -> "$" + String.format(java.util.Locale.ROOT, "%.2f", usd) + else -> "$" + String.format(java.util.Locale.ROOT, "%.0f", usd) +} + +@Composable +private fun CostPill(cost: String) { + Text( + cost, + color = Tokens.TextMuted, + fontSize = Tokens.TextXs, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(Tokens.RadiusPill)) + .background(Tokens.SurfaceHover) + .padding(horizontal = ControlPadH, vertical = ControlPadV) + .semantics { contentDescription = "Session cost $cost" }, + ) +} + /** Effort dropdown over the agent's advertised levels: shows the current * level ("effort…" until the bridge reports one), opens the list on tap. The * placeholder carries the muted "effort…" trigger for the not-yet-reported @@ -1078,6 +1122,18 @@ internal fun modelLabel(id: String?): String { return base.replace(Regex("^claude-"), "").replace(Regex("-\\d{8}$"), "") } +/** + * The name a model list gives the session's model [id] — its agent's list, + * or a provider profile's — for a model [MODEL_TAGS] has no compact tag + * for: an OpenCode or gateway id such as `ccr/OpenCode Go/deepseek-v4.1-flash` + * reads as `deepseek-v4.1-flash`, as in the model picker. Null when the tag + * table knows it, or no list names it. + */ +internal fun listedModelName(id: String?, lists: List>): String? { + if (id.isNullOrEmpty() || MODEL_TAGS.any { (modelId) -> modelId == stripContextMarker(id) }) return null + return lists.firstNotNullOfOrNull { list -> list.firstOrNull { it.id == id }?.label?.takeIf { it.isNotBlank() } } +} + /** Widest the model tag in [ModelContextChip] gets before it marquees. */ private val MODEL_TAG_MAX_WIDTH = 160.dp @@ -1109,13 +1165,15 @@ private val MODEL_TAG_MAX_WIDTH = 160.dp @Composable private fun ModelContextChip( model: String, + /** The model list's name for it ([listedModelName]), when it has one. */ + modelName: String?, contextPercentage: Double?, contextWindow: Long?, ) { Layout( content = { Text( - modelLabel(model), + modelName ?: modelLabel(model), color = Tokens.Accent, fontWeight = FontWeight.Bold, fontSize = Tokens.TextXs, diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 812a10b2..7fe766dd 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -35,6 +35,7 @@ import com.codedeck.plus.ui.transcript.DisplayEntry import com.codedeck.plus.ui.screens.AccountPage import com.codedeck.plus.ui.screens.BackupPage import com.codedeck.plus.ui.screens.RestoreContent +import uniffi.client_ffi.UniffiUsageData import uniffi.client_ffi.UniffiBackupStatus import uniffi.client_ffi.UniffiBackupView import com.codedeck.plus.ui.screens.SettingsHub @@ -93,6 +94,16 @@ private val pages: Map Unit> = linkedMapOf( ) } }, + "session_controls_opencode" to { + Column(Modifier.background(Tokens.Bg)) { + SessionControlsBar( + effort = null, efforts = emptyList(), modeLabel = "Plan", modePending = false, model = "home-gateway/OpenCode Go/deepseek-v4.1-flash", + contextPercentage = 18.0, contextWindow = 1_000_000, onEffortSelect = {}, onModeTap = {}, showUsageBadge = true, + usage = UniffiUsageData(available = true, plan = null, windows = emptyList(), sessionCostUsd = 0.0371, fetchedAt = "2026-10-08T10:00:00Z"), + modelName = "deepseek-v4.1-flash", + ) + } + }, "session_commands" to { Column(Modifier.background(Tokens.Bg)) { SessionTopBar(title = "Fix the flaky reconnect test", workspace = "/home/me/code/codedeck-plus", sessionState = "idle", onBack = {}) @@ -276,6 +287,7 @@ class DesignSnapshotTest { @Test fun home() = paparazzi.page("home") @Test fun home_nothing_paired() = paparazzi.page("home_nothing_paired") @Test fun session() = paparazzi.page("session") + @Test fun session_controls_opencode() = paparazzi.page("session_controls_opencode") @Test fun session_commands() = paparazzi.page("session_commands") @Test fun transcript() = paparazzi.page("transcript") @Test fun transcript_plan() = paparazzi.page("transcript_plan") diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt index ca4596d0..f480e15d 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt @@ -1,7 +1,9 @@ package com.codedeck.plus.ui.session import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test +import uniffi.client_ffi.UniffiModelEntry /** Port of `apps/mobile/src/ui/__tests__/modelLabel.test.ts` — the same * cases, asserting the Kotlin port of `modelLabel` (SessionScreen.kt) keeps @@ -45,4 +47,15 @@ class ModelLabelTest { assertEquals("?", modelLabel(null)) assertEquals("?", modelLabel("")) } + + @Test + fun aListedModelReadsByItsListName() { + val opencode = listOf(UniffiModelEntry("ccr/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "CCR · OpenCode Go", null)) + val profile = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Moonshot", null)) + assertEquals("deepseek-v4.1-flash", listedModelName("ccr/OpenCode Go/deepseek-v4.1-flash", listOf(opencode, profile))) + assertEquals("Kimi K3", listedModelName("kimi-k3", listOf(opencode, profile))) + // Claude's compact tags stay; an unlisted model has no name. + assertNull(listedModelName("claude-opus-5", listOf(listOf(UniffiModelEntry("claude-opus-5", "Opus 5", null, null))))) + assertNull(listedModelName("unlisted/model", listOf(opencode))) + } } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt index b651d8b5..39e502b4 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt @@ -74,7 +74,7 @@ class SessionChromeSnapshotTest { UniffiUsageWindow(label = "5h", utilization = 61.0, resetsAt = null), UniffiUsageWindow(label = "7d", utilization = 23.0, resetsAt = null), ), - sessionCostUsd = null, + sessionCostUsd = 0.42, fetchedAt = "2026-09-23T10:00:00Z", ), ) diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt new file mode 100644 index 00000000..7ee1e8e4 --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt @@ -0,0 +1,17 @@ +package com.codedeck.plus.ui.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionCostTest { + @Test + fun a_cost_reads_in_dollars_and_a_free_session_shows_none() { + assertEquals("$0.42", sessionCost(0.4213)) + assertEquals("<$0.01", sessionCost(0.004)) + assertEquals("$12.30", sessionCost(12.3)) + assertEquals("$140", sessionCost(139.6)) + assertNull(sessionCost(0.0)) + assertNull(sessionCost(Double.NaN)) + } +} diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..6200265e5fee8b9b399e2ad6396b26340bc373d1 GIT binary patch literal 12785 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 { }); }); +describe('OpenCode usage', () => { + it("is the session's cost, none for a free session", async () => { + const client = clientWith([]); + const ctx = recordingContext(); + const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp' }, ctx); + await ctx.waitFor((e) => e.type === 'ready'); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1', cost: 0.0371 } }); + expect(await session.getUsage!()).toMatchObject({ available: true, windows: [], sessionCostUsd: 0.0371 }); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1', cost: 0 } }); + expect(await session.getUsage!()).not.toHaveProperty('sessionCostUsd'); + expect(OpenCodeDriver.withClient(client).info().supports?.usage).toBe(true); + await session.end(); + }); +}); + describe('OpenCode conversation delete', () => { const deleting = (result: unknown) => { const client = clientWith([]) as FakeClient & { session: { delete: ReturnType } }; diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index c845d80e..e733c238 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -1000,8 +1000,14 @@ export class OpenCodeSession implements DriverSession { if (error) throw new Error(`OpenCode could not stop the task: ${JSON.stringify(error)}`); } + /** What the session has cost so far, as OpenCode prices its models (no + * subscription windows: OpenCode has none to report). A free or + * unpriced model's session reports no cost. */ async getUsage(): Promise { - return null; + const { client, session } = await this.ready; + const { data, error } = await client.session.get({ sessionID: session.id, directory: this.cwd }); + if (error || !data) return null; + return { available: true, windows: [], ...(data.cost ? { sessionCostUsd: data.cost } : {}), fetchedAt: new Date().toISOString() }; } async end(): Promise { @@ -1229,11 +1235,11 @@ export class OpenCodeDriver implements Driver { modes: OPENCODE_MODES, efforts: [], defaultMode: DEFAULT_MODE, - // No subscription usage. Provider profiles add models to the server - // this driver starts; sessions are never bound to one. + // Usage is the session's cost. Provider profiles add models to the + // server this driver starts; sessions are never bound to one. supports: { models: true, - usage: false, + usage: true, providers: false, providerModels: this.manages, gsd: true, From 282c205ec07b8583d6d9557302a9fdef3e728db2 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:20:02 +0200 Subject: [PATCH 4/5] feat: OpenCode's catalog places each provider profile OpenCode ships the models.dev catalog of providers and models, and its server lists it with the base URL each provider is served at. A profile is now placed by it: - an endpoint the catalog knows (DeepSeek's API, OpenRouter's) signs in to that provider, as /connect would: the catalog's models, prices and limits under the name the user gave the profile, plus any model the endpoint lists that the catalog lacks. One OpenCode already uses with a key of its own is left out rather than replaced; - any other endpoint stays a provider of its own, its models filled in from the catalog where it knows them (a routed "OpenCode Go/..." model is OpenCode Go's): limits, tool calls, image input and reasoning, which gives it OpenCode's reasoning levels. Prices are not copied, a gateway's being its own. Checked on 1.18.32: a gateway model filled in so is offered the low, medium, high and max levels and image input, with its real limits. Co-Authored-By: Claude Code --- docs/AGENT-CANDIDATES.md | 7 +- .../__tests__/opencodeProviders.test.ts | 158 +++++++++++--- .../src/drivers/opencode/catalog.ts | 123 +++++++++++ .../agent-host/src/drivers/opencode/driver.ts | 58 ++--- .../src/drivers/opencode/providers.ts | 199 +++++++++++------- 5 files changed, 407 insertions(+), 138 deletions(-) create mode 100644 packages/agent-host/src/drivers/opencode/catalog.ts diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index 232529a2..d6eb8f84 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -105,7 +105,12 @@ offer no tool that only works on its vendor's API. OpenCode meets all three: its web search is offered only on its own provider (checked on 1.18.32), its web fetch is local, and a provider profile adds the endpoint's models to its list beside OpenCode Zen's free ones — read from -the endpoint, never typed by hand. The server the bridge starts sets +the endpoint, never typed by hand. OpenCode's own catalog (models.dev) +places each profile: an endpoint it knows (DeepSeek's, OpenRouter's) signs +in to that provider as `/connect` would; a gateway's models are filled in +from the catalog — limits, tool calls, image input, and with reasoning +OpenCode's own reasoning levels — but never its prices, a gateway's being +its own. The server the bridge starts sets `OPENCODE_ENABLE_EXA`, so that web search (Exa's keyless endpoint, behind OpenCode's `websearch` permission) is offered to every model, not only to OpenCode's own providers'. Codex is out (its client speaks only the diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index eb66505e..1f9d46ee 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -1,11 +1,13 @@ /** - * Provider profiles as OpenCode providers: the config the server starts - * with, the password guarding it, and the restart a changed list takes. + * Provider profiles as OpenCode providers: where OpenCode's catalog places + * each, the config the server starts with, the password guarding it, and + * the restart a changed list takes. */ import { describe, expect, it, vi } from 'vitest'; -import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import type { Model, OpencodeClient, Provider } from '@opencode-ai/sdk/v2/client'; import { OpenCodeDriver } from '../driver'; -import { admitProfiles, profileModelGroup, profileProviderId, providersConfig, serverSetup } from '../providers'; +import { type Catalog, EMPTY_CATALOG } from '../catalog'; +import { placeProfiles, profileModelGroup, profileProviderId, providersConfig, servedBy, serverSetup } from '../providers'; import type { StartOpenCodeServerOptions } from '../server'; import type { ProviderBinding } from '../../../sdk/types'; @@ -19,9 +21,91 @@ const router = (over: Partial = {}): ProviderBinding => ({ ...over, }); +/** A catalog provider, as OpenCode's server lists it. */ +function catalogProvider(id: string, name: string, url: string, models: Record> = {}): Provider { + return { + id, + name, + source: 'api', + env: [], + options: {}, + models: Object.fromEntries( + Object.entries(models).map(([mid, m]) => [mid, { id: mid, providerID: id, api: { id: mid, url, npm: '@ai-sdk/openai-compatible' }, name: mid, ...m } as Model]), + ), + } as Provider; +} + +const reasoningModel: Partial = { + name: 'DeepSeek V4.1 Flash', + family: 'deepseek', + capabilities: { + temperature: true, reasoning: true, attachment: true, toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { field: 'reasoning_content' }, + }, + cost: { input: 0.15, output: 0.6, cache: { read: 0.003, write: 0 } }, + limit: { context: 1_000_000, output: 393_216 }, +}; + +const catalog: Catalog = { + providers: new Map([ + ['deepseek', catalogProvider('deepseek', 'DeepSeek', 'https://api.deepseek.com', { 'deepseek-flash': reasoningModel })], + ['opencode-go', catalogProvider('opencode-go', 'OpenCode Go', 'https://opencode.ai/zen/go/v1', { 'deepseek-v4.1-flash': reasoningModel })], + ['openrouter', catalogProvider('openrouter', 'OpenRouter', 'https://openrouter.ai/api/v1', { 'deepseek/deepseek-chat': {} })], + ['opencode', catalogProvider('opencode', 'OpenCode Zen', 'https://opencode.ai/zen/v1')], + ]), + connected: new Set(['opencode', 'deepseek']), +}; + +const providerOf = (config: Record, id: string) => (config.provider as Record>)[id]!; + +describe('placing profiles', () => { + it('names a profile of its own as the user named it', () => { + expect(profileProviderId(router({ label: 'CCR' }))).toBe('ccr'); + expect(profileProviderId(router({ label: 'OpenCode Go (OpenAI)' }))).toBe('opencode-go-openai'); + expect(profileProviderId(router({ label: 'Café / LAN' }))).toBe('cafe-lan'); + expect(profileProviderId(router({ label: '★', id: 'p-7' }))).toBe('p-7'); + }); + + it("signs in to a provider OpenCode knows by its endpoint, unless OpenCode already uses it", () => { + const openrouter = router({ id: 'or', label: 'My OpenRouter', baseUrl: 'https://openrouter.ai/api' }); + const deepseek = router({ id: 'ds', label: 'DeepSeek API', baseUrl: 'https://api.deepseek.com/v1' }); + const { placed, refused } = placeProfiles([openrouter, deepseek], catalog); + expect(placed.map((p) => [p.profile.id, p.providerId, p.known?.id])).toEqual([['or', 'openrouter', 'openrouter']]); + expect(refused).toEqual([{ id: 'ds', reason: 'OpenCode already uses DeepSeek with a key of its own; this profile would replace it.' }]); + }); + + it("leaves out a name one of OpenCode's providers, or an earlier profile, already has", () => { + const ccr = router({ id: 'a', label: 'CCR' }); + const again = router({ id: 'b', label: 'ccr' }); + const named = router({ id: 'c', label: 'DeepSeek' }); + const { placed, refused } = placeProfiles([ccr, again, named], catalog); + expect(placed.map((p) => p.providerId)).toEqual(['ccr']); + expect(refused).toEqual([ + { id: 'b', reason: "The provider profile 'CCR' already goes by 'ccr' in OpenCode. Give this one another name." }, + { id: 'c', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, + ]); + }); + + it('never takes what profiles already made of the running server for OpenCode\'s own', () => { + // The running server lists the profile's provider, and the catalog one + // it signed in to as connected. + const running: Catalog = { + providers: new Map([...catalog.providers, ['ccr', catalogProvider('ccr', 'CCR', 'http://192.168.1.2:3458/v1', { m: {} })]]), + connected: new Set([...catalog.connected, 'ccr', 'openrouter']), + }; + const served = servedBy(placeProfiles([router({ label: 'CCR' }), router({ id: 'or', label: 'OR', baseUrl: 'https://openrouter.ai/api' })], catalog).placed); + const again = placeProfiles([router({ label: 'CCR' }), router({ id: 'or', label: 'OR', baseUrl: 'https://openrouter.ai/api' })], running, served); + expect(again.refused).toEqual([]); + expect(again.placed.map((p) => p.providerId)).toEqual(['ccr', 'openrouter']); + }); +}); + describe('providersConfig', () => { - it("adds each profile as a provider of its own, beside the operator's, with the token left to the environment", () => { - const config = providersConfig([router()], { provider: { mine: { npm: 'x' } }, theme: 'dark' }); + it("adds a profile of its own beside the operator's providers, with the token left to the environment", () => { + const { placed } = placeProfiles([router()], catalog); + const config = providersConfig(placed, catalog, { provider: { mine: { npm: 'x' } }, theme: 'dark' }); expect(config).toEqual({ theme: 'dark', provider: { @@ -37,37 +121,43 @@ describe('providersConfig', () => { expect(JSON.stringify(config)).not.toContain('tok-secret'); }); - it('names a routed model without its upstream, which its group shows, and gives OpenCode a known context window', () => { - const routed = router({ models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 1_000_000 }] }); - const provider = (providersConfig([routed]).provider as Record }>)['home-router']!; - expect(provider.models).toEqual({ - 'OpenCode Go/deepseek-v4.1-flash': { name: 'deepseek-v4.1-flash', limit: { context: 1_000_000, output: 0 } }, + it("fills a gateway's routed model in from the catalog, without its price", () => { + const routed = router({ label: 'CCR', models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go' }] }); + const { placed } = placeProfiles([routed], catalog); + expect(providerOf(providersConfig(placed, catalog), 'ccr').models).toEqual({ + 'OpenCode Go/deepseek-v4.1-flash': { + name: 'deepseek-v4.1-flash', + family: 'deepseek', + limit: { context: 1_000_000, output: 393_216 }, + reasoning: true, + tool_call: true, + temperature: true, + attachment: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, }); expect(profileModelGroup('CCR', routed.models[0])).toBe('CCR · OpenCode Go'); expect(profileModelGroup('CCR', { id: 'kimi-k3' })).toBe('CCR'); }); - it('names each provider as the user named the profile', () => { - expect(profileProviderId(router({ label: 'CCR' }))).toBe('ccr'); - expect(profileProviderId(router({ label: 'OpenCode Go (OpenAI)' }))).toBe('opencode-go-openai'); - expect(profileProviderId(router({ label: 'Café / LAN' }))).toBe('cafe-lan'); - expect(profileProviderId(router({ label: '★', id: 'p-7' }))).toBe('p-7'); + it("keeps the endpoint's own context window over the catalog's", () => { + const routed = router({ label: 'CCR', models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 128_000 }] }); + const config = providersConfig(placeProfiles([routed], catalog).placed, catalog); + expect((providerOf(config, 'ccr').models as Record)['OpenCode Go/deepseek-v4.1-flash']!.limit).toEqual({ context: 128_000, output: 393_216 }); }); - it("leaves out a profile whose name one of OpenCode's providers, or an earlier profile, already has", () => { - const ccr = router({ id: 'a', label: 'CCR' }); - const again = router({ id: 'b', label: 'ccr' }); - const deepseek = router({ id: 'c', label: 'DeepSeek' }); - const { admitted, refused } = admitProfiles([ccr, again, deepseek], new Set(['deepseek', 'opencode'])); - expect(admitted.map((p) => p.id)).toEqual(['a']); - expect(refused).toEqual([ - { id: 'b', reason: "The provider profile 'CCR' already goes by 'ccr' in OpenCode. Give this one another name." }, - { id: 'c', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, - ]); + it("signs a known provider in by its key alone, adding only the endpoint's models the catalog lacks", () => { + const openrouter = router({ id: 'or', label: 'My OpenRouter', baseUrl: 'https://openrouter.ai/api', models: [{ id: 'deepseek/deepseek-chat' }, { id: 'new/model', label: 'New' }] }); + const config = providersConfig(placeProfiles([openrouter], catalog).placed, catalog); + expect(providerOf(config, 'openrouter')).toEqual({ + name: 'My OpenRouter', + options: { apiKey: '{env:CODEDECK_PROVIDER_KEY_0}' }, + models: { 'new/model': { name: 'New' } }, + }); }); it('does not restrict the providers OpenCode already has', () => { - const config = providersConfig([router()]); + const config = providersConfig(placeProfiles([router()], catalog).placed, catalog); expect(config).not.toHaveProperty('enabled_providers'); expect(config).not.toHaveProperty('disabled_providers'); expect(config).not.toHaveProperty('model'); @@ -75,23 +165,25 @@ describe('providersConfig', () => { }); describe('serverSetup', () => { + const placedOf = (...profiles: ProviderBinding[]) => placeProfiles(profiles, EMPTY_CATALOG).placed; + it('passes the tokens in the environment and guards the server with a password of its own', () => { - const a = serverSetup([router(), router({ id: 'or', label: 'My OpenRouter', authToken: 'tok-2' })], {}); + const a = serverSetup(placedOf(router(), router({ id: 'or', label: 'My OpenRouter', authToken: 'tok-2' })), EMPTY_CATALOG, {}); expect(a.env.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); expect(a.env.CODEDECK_PROVIDER_KEY_1).toBe('tok-2'); expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('my-openrouter'); const password = a.env.OPENCODE_SERVER_PASSWORD!; expect(password).toMatch(/^[0-9a-f]{48}$/); expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); - expect(serverSetup([], {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + expect(serverSetup([], EMPTY_CATALOG, {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); // Web search for every model, not only OpenCode's own providers'. - expect(serverSetup([], {}).env.OPENCODE_ENABLE_EXA).toBe('1'); + expect(serverSetup([], EMPTY_CATALOG, {}).env.OPENCODE_ENABLE_EXA).toBe('1'); // No profiles: the operator's config stands as it is. - expect(serverSetup([], { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + expect(serverSetup([], EMPTY_CATALOG, { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); }); it("keeps the operator's own environment config underneath", () => { - const { env } = serverSetup([router()], { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); + const { env } = serverSetup(placedOf(router()), EMPTY_CATALOG, { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'home-router']); }); }); @@ -189,7 +281,7 @@ describe('an OpenCode driver given provider profiles', () => { return { url: `http://127.0.0.1:${4100 + starts.length}`, pid: starts.length, exited: new Promise(() => {}), close: async () => {} }; }, // The running server lists what it has, the profiles it was given too. - connect: () => ({ provider: { list: async () => ({ data: { all: ids.map((id) => ({ id })), default: {}, connected: [] } }) } }) as unknown as OpencodeClient, + connect: () => ({ provider: { list: async () => ({ data: { all: ids.map((id) => ({ id, name: id, models: {} })), default: {}, connected: [] } }) } }) as unknown as OpencodeClient, }); expect(await driver.setProviders([router({ label: 'DeepSeek' })])).toEqual([ { id: 'router', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, diff --git a/packages/agent-host/src/drivers/opencode/catalog.ts b/packages/agent-host/src/drivers/opencode/catalog.ts new file mode 100644 index 00000000..a0e64cdd --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/catalog.ts @@ -0,0 +1,123 @@ +/** + * OpenCode's catalog of providers and their models — models.dev, which + * OpenCode ships and keeps current, plus what the operator configured — as + * its server lists it (`GET /provider`). A provider profile learns two + * things from it: + * + * - whether its endpoint is a provider OpenCode already knows (DeepSeek, + * OpenRouter, Moonshot…), by the base URL the catalog records for it; + * such a profile signs in to that provider, the way `/connect` would, + * and gets the catalog's models, prices and limits whole; + * - what its models are, when the endpoint is a gateway: a routed + * `OpenCode Go/deepseek-v4.1-flash` is the catalog's OpenCode Go model, + * whose context and output limits, reasoning (and with it OpenCode's + * reasoning levels), tool calls and image input the gateway's model list + * never says. Prices are left out: a gateway's are its own (a + * subscription, a local model, a markup), not the upstream's list price. + */ +import type { Model, Provider } from '@opencode-ai/sdk/v2/client'; +import type { ProviderModel } from '../../sdk/types'; + +/** `name` as an OpenCode provider id: lower case, each run of anything but + * letters and digits one `-` (a `/` would split its model ids). */ +export function providerIdOf(name: string): string { + return name + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export interface Catalog { + /** Every provider OpenCode knows, by id. */ + providers: ReadonlyMap; + /** Those it can already use: configured, signed in to, or given a key in + * its environment. */ + connected: ReadonlySet; +} + +export const EMPTY_CATALOG: Catalog = { providers: new Map(), connected: new Set() }; + +/** A base URL as endpoints are compared: lower case, without a trailing + * slash or `/v1` (both spellings are common for one endpoint). */ +export function endpointKey(url: string): string { + return url.trim().toLowerCase().replace(/\/+$/, '').replace(/\/v1$/, ''); +} + +/** The base URLs a catalog provider is served at. */ +function providerEndpoints(provider: Provider): string[] { + const urls = Object.values(provider.models).map((m) => m.api?.url); + const configured = (provider.options as { baseURL?: unknown } | undefined)?.baseURL; + if (typeof configured === 'string') urls.push(configured); + return [...new Set(urls.filter((u): u is string => typeof u === 'string' && u.trim() !== '').map(endpointKey))]; +} + +/** The catalog provider served at `baseUrl`, leaving out the `skip` ids + * (the providers profiles themselves became). */ +export function knownProviderAt(catalog: Catalog, baseUrl: string, skip: ReadonlySet): Provider | undefined { + const key = endpointKey(baseUrl); + for (const provider of catalog.providers.values()) { + if (!skip.has(provider.id) && providerEndpoints(provider).includes(key)) return provider; + } + return undefined; +} + +/** The catalog's record of a profile model: under the provider a gateway + * routes it to when the endpoint names one (by name or id), else under the + * first provider that lists its id. */ +export function catalogModel(catalog: Catalog, model: ProviderModel, skip: ReadonlySet): Model | undefined { + const upstream = model.provider?.trim(); + if (upstream) { + const bare = model.id.startsWith(`${upstream}/`) ? model.id.slice(upstream.length + 1) : model.id; + const wanted = upstream.toLowerCase(); + const slug = providerIdOf(upstream); + for (const provider of catalog.providers.values()) { + if (skip.has(provider.id)) continue; + if (provider.name.toLowerCase() === wanted || provider.id === slug) { + const found = provider.models[bare]; + if (found) return found; + } + } + } + for (const provider of catalog.providers.values()) { + if (skip.has(provider.id)) continue; + const found = provider.models[model.id]; + if (found) return found; + } + return undefined; +} + +type Modality = 'text' | 'audio' | 'image' | 'video' | 'pdf'; + +function modalities(flags: Record): Modality[] { + return (Object.keys(flags) as Modality[]).filter((k) => flags[k]); +} + +/** + * A profile model as OpenCode's config holds it: named without the upstream + * provider its group already shows, with what the catalog knows of it + * (`known`) — never its prices. The endpoint's own context window wins over + * the catalog's; without any, the limits stay unset, OpenCode's "unknown". + */ +export function profileModelConfig(model: ProviderModel, known: Model | undefined): Record { + const name = + model.label ?? + (model.provider && model.id.startsWith(`${model.provider}/`) ? model.id.slice(model.provider.length + 1) : undefined) ?? + known?.name; + const context = model.contextWindow || known?.limit.context || 0; + return { + ...(name ? { name } : {}), + ...(context > 0 ? { limit: { context, output: known?.limit.output ?? 0 } } : {}), + ...(known + ? { + ...(known.family ? { family: known.family } : {}), + reasoning: known.capabilities.reasoning, + tool_call: known.capabilities.toolcall, + temperature: known.capabilities.temperature, + attachment: known.capabilities.attachment, + modalities: { input: modalities(known.capabilities.input), output: modalities(known.capabilities.output) }, + } + : {}), + }; +} diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index e733c238..47046cff 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -56,7 +56,8 @@ import { SESSION_COMMANDS, sessionCommand, sessionSlashCommands } from './sessio import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { admitProfiles, profileModelGroup, profileProviderId, providersFingerprint, serverSetup } from './providers'; +import { type Catalog, EMPTY_CATALOG } from './catalog'; +import { NOTHING_SERVED, type Placement, placeProfiles, profileModelGroup, providersFingerprint, type Served, servedBy, serverSetup } from './providers'; import { resolveOpenCodePath, startOpenCodeServer, @@ -1065,12 +1066,14 @@ export class OpenCodeDriver implements Driver { * session. */ private installs = false; private stopped = false; - /** The provider profiles the server should have, and those the running - * one was started with. */ - private providers: ProviderBinding[] = []; + /** Where the provider profiles go among the server's providers, and the + * catalog that placed them (it fills in their models). */ + private placed: Placement[] = []; + private catalog: Catalog = EMPTY_CATALOG; + /** The placements the running server was started with: their + * fingerprint, and the providers they made. */ private served = providersFingerprint([]); - /** The provider ids the running server has from profiles. */ - private servedIds = new Set(); + private serving: Served = NOTHING_SERVED; /** Provider changes are applied one at a time, in order. */ private applying: Promise = Promise.resolve(); readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); @@ -1132,8 +1135,8 @@ export class OpenCodeDriver implements Driver { /** Start `opencode serve` with the provider profiles it should have, and * connect to it. */ private async startServer(bin: string): Promise { - const profiles = this.providers; - const setup = serverSetup(profiles, process.env); + const placed = this.placed; + const setup = serverSetup(placed, this.catalog, process.env); const start = this.options.startServer ?? startOpenCodeServer; let server: OpenCodeServerHandle; try { @@ -1150,9 +1153,9 @@ export class OpenCodeDriver implements Driver { throw new Error('the agent host is shutting down'); } this.server = server; - this.served = providersFingerprint(profiles); - this.servedIds = new Set(profiles.map(profileProviderId)); - const added = profiles.length > 0 ? `, with ${profiles.length} provider profile(s)` : ''; + this.served = providersFingerprint(placed); + this.serving = servedBy(placed); + const added = placed.length > 0 ? `, with ${placed.length} provider profile(s)` : ''; this.options.log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})${added}`); return this.connect({ baseUrl: server.url, headers: setup.headers }); } @@ -1178,8 +1181,9 @@ export class OpenCodeDriver implements Driver { ); } - /** The provider profiles to add to OpenCode's own providers; those whose - * name one of its providers already has are left out. The server reads + /** The provider profiles to add to OpenCode's own providers, placed by + * its catalog (providers.ts); one that would shadow a provider OpenCode + * has is left out, with the reason. The server reads * its config only when it starts, so a changed list restarts it: * sessions on the old one end with an error and the bridge resumes them * on the new one. */ @@ -1197,13 +1201,15 @@ export class OpenCodeDriver implements Driver { private async applyProviders(providers: ProviderBinding[]): Promise { // A server still being installed or started is waited for: it may have - // read an older list, and it says which names are taken. + // read an older list, and its catalog places the profiles. await this.clientPromise?.catch(() => {}); // A profile the bridge would not let a session use is not added either. const usable = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); - const { admitted, refused } = admitProfiles(usable, await this.ownProviderIds()); - this.providers = admitted; - if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return refused; + const catalog = await this.readCatalog(); + const { placed, refused } = placeProfiles(usable, catalog, this.serving); + this.placed = placed; + this.catalog = catalog; + if (this.stopped || !this.server || !this.bin || providersFingerprint(placed) === this.served) return refused; this.options.log('[opencode] restarting the server: its provider profiles changed'); const old = this.server; this.server = null; @@ -1212,19 +1218,19 @@ export class OpenCodeDriver implements Driver { return refused; } - /** The provider ids OpenCode has of its own — built in, configured by - * the operator, signed in to — without the profiles this driver added. - * Empty when the server cannot say (nothing is refused then). */ - private async ownProviderIds(): Promise> { + /** The running server's catalog (catalog.ts). Empty when it cannot say: + * every profile is then a provider of its own, nothing refused or + * filled in. */ + private async readCatalog(): Promise { try { const client = await this.clientPromise; - if (!client) return new Set(); + if (!client) return EMPTY_CATALOG; const { data, error } = await client.provider.list(); if (error || !data) throw new Error(JSON.stringify(error ?? 'no provider list')); - return new Set(data.all.map((p) => p.id).filter((id) => !this.servedIds.has(id))); + return { providers: new Map(data.all.map((p) => [p.id, p])), connected: new Set(data.connected) }; } catch (err) { - this.options.log(`[opencode] could not list its providers, so profile names are not checked: ${err instanceof Error ? err.message : String(err)}`); - return new Set(); + this.options.log(`[opencode] could not read its provider catalog, so profiles are not checked against it: ${err instanceof Error ? err.message : String(err)}`); + return EMPTY_CATALOG; } } @@ -1290,7 +1296,7 @@ export class OpenCodeDriver implements Driver { if (error || !data) return { models: [] }; const models: ModelEntry[] = []; const contextLimits: Record = {}; - const profiles = new Map(this.providers.map((p) => [profileProviderId(p), p])); + const profiles = new Map(this.placed.map((p) => [p.providerId, p.profile])); for (const provider of data.providers) { const profile = profiles.get(provider.id); for (const model of Object.values(provider.models)) { diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts index 82a3214a..d9498106 100644 --- a/packages/agent-host/src/drivers/opencode/providers.ts +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -2,72 +2,111 @@ * Provider profiles as OpenCode providers. * * A profile adds a provider to OpenCode — beside OpenCode Zen and whatever - * the operator configured, which all stay: an OpenAI-compatible endpoint, - * its token and its models. OpenCode takes a custom provider only from its - * config, so the profiles reach the server this driver starts as config - * handed over in its environment, on top of the operator's files (which are - * never written): + * the operator configured, which all stay. OpenCode takes a provider only + * from its config, so the profiles reach the server this driver starts as + * config handed over in its environment, on top of the operator's files + * (which are never written). Each profile is placed one of two ways, by + * what OpenCode's catalog knows (catalog.ts): * - * - each profile is a provider named as the user named the profile, its - * id that name in lower case (`CCR` is `ccr`, its models `ccr/`); - * a profile whose id one of OpenCode's own providers already has, or an - * earlier profile, is left out with the reason rather than shadow it; - * - each token sits in the server's environment only, referenced from the - * config, never in a file; - * - the server answers only with a password made for it, since its API - * reads its config — the tokens included — back to anyone on loopback - * who asks. + * - an endpoint the catalog knows (DeepSeek's API, OpenRouter's…) signs in + * to that provider, as `/connect` would: the catalog's models, prices + * and limits, under the name the user gave the profile, plus any model + * the endpoint lists that the catalog does not; + * - any other endpoint (a gateway, a local server) is an OpenAI-compatible + * provider of its own, its id the profile's name in lower case (`CCR` is + * `ccr`, its models `ccr/`), its models what the endpoint listed, + * each filled in from the catalog where it knows the model. + * + * A profile is left out, with the reason, rather than shadow a provider + * OpenCode already has: one it is already signed in to, or one whose id the + * profile's name would take, or another profile's. Each token sits in the + * server's environment only, referenced from the config; the server answers + * only with a password made for it, since its API reads its config — the + * tokens included — back to anyone on loopback who asks. * * The server reads its config when it starts, so a changed profile list * means a new server (see the driver). */ import { createHash, randomBytes } from 'node:crypto'; +import type { Provider } from '@opencode-ai/sdk/v2/client'; import { providerApiRoot } from '../../sdk/providerModels'; import type { ProviderBinding, ProviderModel, RefusedProvider } from '../../sdk/types'; +import { type Catalog, catalogModel, EMPTY_CATALOG, knownProviderAt, profileModelConfig, providerIdOf } from './catalog'; /** The user name OpenCode's server expects with its password. */ const SERVER_USERNAME = 'opencode'; -/** `name` as an OpenCode provider id: lower case, each run of anything but - * letters and digits one `-` (a `/` would split its model ids). */ -function providerIdOf(name: string): string { - return name - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); +/** Where a profile sits among OpenCode's providers. */ +export interface Placement { + profile: ProviderBinding; + /** Its OpenCode provider id: the catalog's for a provider OpenCode knows, + * else its name's. */ + providerId: string; + /** The catalog provider it signs in to, when OpenCode knows its endpoint. */ + known?: Provider; +} + +/** What profiles already are in a running server, so they are not taken + * for OpenCode's own providers. */ +export interface Served { + /** Every provider id profiles took. */ + ids: ReadonlySet; + /** Those that are a profile's own provider, not one of the catalog's. */ + own: ReadonlySet; } -/** The OpenCode provider id of a profile: its name's, or its id's when the - * name has no letter or digit. */ +export const NOTHING_SERVED: Served = { ids: new Set(), own: new Set() }; + +/** The provider id a profile of its own takes: its name's, or its id's + * when the name has no letter or digit. */ export function profileProviderId(profile: ProviderBinding): string { return providerIdOf(profile.label) || providerIdOf(profile.id) || 'provider'; } -/** Which of `profiles` OpenCode can take, in order: one whose provider id - * is `taken` by a provider OpenCode already has, or by an earlier profile, - * is refused with the reason. */ -export function admitProfiles( +/** + * Where each of `profiles` goes, in order, against what OpenCode has + * (`catalog`) and what earlier profiles took (`served`, not OpenCode's own); + * a profile that would shadow a provider is refused with the reason. + */ +export function placeProfiles( profiles: ProviderBinding[], - taken: ReadonlySet, -): { admitted: ProviderBinding[]; refused: RefusedProvider[] } { - const admitted: ProviderBinding[] = []; + catalog: Catalog, + served: Served = NOTHING_SERVED, +): { placed: Placement[]; refused: RefusedProvider[] } { + const placed: Placement[] = []; const refused: RefusedProvider[] = []; - const used = new Map(); + const taken = new Map(); for (const profile of profiles) { - const id = profileProviderId(profile); - const earlier = used.get(id); - if (taken.has(id)) { - refused.push({ id: profile.id, reason: `OpenCode already has a provider called '${id}'. Give this profile another name.` }); - } else if (earlier) { - refused.push({ id: profile.id, reason: `The provider profile '${earlier.label}' already goes by '${id}' in OpenCode. Give this one another name.` }); - } else { - used.set(id, profile); - admitted.push(profile); + const known = knownProviderAt(catalog, profile.baseUrl, served.own); + const providerId = known?.id ?? profileProviderId(profile); + const earlier = taken.get(providerId); + const theirs = !served.ids.has(providerId); + let reason: string | undefined; + if (earlier) { + reason = known + ? `The provider profile '${earlier.label}' already signs OpenCode in to ${known.name}.` + : `The provider profile '${earlier.label}' already goes by '${providerId}' in OpenCode. Give this one another name.`; + } else if (known && theirs && catalog.connected.has(providerId)) { + reason = `OpenCode already uses ${known.name} with a key of its own; this profile would replace it.`; + } else if (!known && theirs && catalog.providers.has(providerId)) { + reason = `OpenCode already has a provider called '${providerId}'. Give this profile another name.`; + } + if (reason) { + refused.push({ id: profile.id, reason }); + continue; } + taken.set(providerId, profile); + placed.push({ profile, providerId, ...(known ? { known } : {}) }); } - return { admitted, refused }; + return { placed, refused }; +} + +/** What `placed` makes of a running server's providers. */ +export function servedBy(placed: Placement[]): Served { + return { + ids: new Set(placed.map((p) => p.providerId)), + own: new Set(placed.filter((p) => !p.known).map((p) => p.providerId)), + }; } /** The variable profile `index`'s token reaches the server in. */ @@ -75,52 +114,56 @@ function tokenVariable(index: number): string { return `CODEDECK_PROVIDER_KEY_${index}`; } -/** The provider entries for `profiles`, on top of `base` (an operator's own +/** One placed profile's provider entry. */ +function providerEntry(placement: Placement, index: number, catalog: Catalog, served: Served): Record { + const { profile, known } = placement; + const apiKey = `{env:${tokenVariable(index)}}`; + if (known) { + // The catalog's models stand; the endpoint's others join them. + const extra = profile.models.filter((m) => !known.models[m.id]); + return { + name: profile.label || known.name, + options: { apiKey }, + ...(extra.length > 0 ? { models: Object.fromEntries(extra.map((m) => [m.id, profileModelConfig(m, undefined)])) } : {}), + }; + } + return { + npm: '@ai-sdk/openai-compatible', + name: profile.label || profile.id, + options: { baseURL: providerApiRoot(profile.baseUrl), apiKey }, + models: Object.fromEntries(profile.models.map((m) => [m.id, profileModelConfig(m, catalogModel(catalog, m, served.own))])), + }; +} + +/** The provider entries for `placed`, on top of `base` (an operator's own * `OPENCODE_CONFIG_CONTENT`, whose providers stay). */ -export function providersConfig(profiles: ProviderBinding[], base: Record = {}): Record { +export function providersConfig( + placed: Placement[], + catalog: Catalog = EMPTY_CATALOG, + base: Record = {}, +): Record { const own = (base.provider ?? {}) as Record; - const added = Object.fromEntries( - profiles.map((profile, index) => [ - profileProviderId(profile), - { - npm: '@ai-sdk/openai-compatible', - name: profile.label || profile.id, - options: { baseURL: providerApiRoot(profile.baseUrl), apiKey: `{env:${tokenVariable(index)}}` }, - models: Object.fromEntries(profile.models.map((m) => [m.id, modelConfig(m)])), - }, - ]), - ); + const served = servedBy(placed); + const added = Object.fromEntries(placed.map((p, index) => [p.providerId, providerEntry(p, index, catalog, served)])); return { ...base, provider: { ...own, ...added } }; } -/** A profile model as OpenCode's config holds it: named without the - * upstream provider its group already shows. A known context window - * lets OpenCode compact before the model overflows; the output size is - * never listed by endpoints, so it stays 0, OpenCode's own "unknown". */ -function modelConfig(model: ProviderModel): Record { - const name = model.label ?? (model.provider && model.id.startsWith(`${model.provider}/`) ? model.id.slice(model.provider.length + 1) : undefined); - return { - ...(name ? { name } : {}), - ...(model.contextWindow ? { limit: { context: model.contextWindow, output: 0 } } : {}), - }; -} - /** The group a profile model is listed under: the profile, and the * provider a gateway routes the model to when the endpoint names one. */ export function profileModelGroup(profile: string, model: ProviderModel | undefined): string { return model?.provider ? `${profile} · ${model.provider}` : profile; } -/** What the server is started with to serve `profiles`, and how its client - * signs in. */ +/** What the server is started with to serve the profiles, and how its + * client signs in. */ export interface ServerSetup { env: Record; headers: Record; } -/** The environment and credentials of a server for `profiles` — and, for +/** The environment and credentials of a server for `placed` — and, for * every model, web search. `baseEnv` is the host's own environment. */ -export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.ProcessEnv): ServerSetup { +export function serverSetup(placed: Placement[], catalog: Catalog, baseEnv: NodeJS.ProcessEnv): ServerSetup { const password = randomBytes(24).toString('hex'); const env: Record = { OPENCODE_SERVER_USERNAME: SERVER_USERNAME, @@ -130,9 +173,9 @@ export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.Process // still behind the `websearch` permission). OPENCODE_ENABLE_EXA: '1', }; - if (profiles.length > 0) { - env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(profiles, operatorConfig(baseEnv))); - profiles.forEach((profile, index) => { + if (placed.length > 0) { + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(placed, catalog, operatorConfig(baseEnv))); + placed.forEach(({ profile }, index) => { env[tokenVariable(index)] = profile.authToken; }); } @@ -152,9 +195,9 @@ function operatorConfig(baseEnv: NodeJS.ProcessEnv): Record { } } -/** What makes two profile lists need different servers: everything their +/** What makes two placements need different servers: everything their * config is built from. Hashed, so the tokens are not kept around in it. */ -export function providersFingerprint(profiles: ProviderBinding[]): string { - const parts = profiles.map((p) => [p.id, p.label, p.baseUrl, p.authToken, p.models]); +export function providersFingerprint(placed: Placement[]): string { + const parts = placed.map(({ profile: p, providerId }) => [providerId, p.id, p.label, p.baseUrl, p.authToken, p.models]); return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); } From 10899696d26eb58cfa078b5dacbac75c35a6ac37 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:29:53 +0200 Subject: [PATCH 5/5] feat: reasoning levels for each OpenCode model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode gives a model reasoning levels ("variants": low, high, max…) that differ from model to model, so the agent-wide effort list could not hold them and OpenCode sessions had no effort control at all. A model entry now carries its own levels (`efforts`, with a corpus fixture); an agent that lists no levels of its own has them per model, and the bridge leaves checking one to the agent. The OpenCode driver lists each model's variants with its default first, checks a chosen level against the session's model, drops it when the model changes to one without it, and asks for it on every prompt and command. The phone offers the session model's levels in the controls bar and the new-session form. Co-Authored-By: Claude Code --- .../plus/ui/screens/NewSessionScreen.kt | 17 ++++- .../codedeck/plus/ui/session/SessionScreen.kt | 6 +- .../main/java/uniffi/client_ffi/client_ffi.kt | 11 ++- .../com/codedeck/plus/ui/DesignFixtures.kt | 30 +++++--- .../codedeck/plus/ui/DesignSnapshotTest.kt | 4 ++ .../ui/components/ModelPickerOptionsTest.kt | 6 +- .../plus/ui/session/ModelLabelTest.kt | 6 +- ...nFullPageSnapshotTest_settings_machine.png | Bin 50098 -> 51024 bytes ...esignSnapshotTest_new_session_opencode.png | Bin 0 -> 47974 bytes crates/bridge-core/src/catalog.rs | 5 +- crates/bridge-core/tests/cards.rs | 8 ++- crates/client-core/src/stores/machines.rs | 4 +- crates/client-ffi/src/views.rs | 12 +++- crates/client-runtime/src/dispatch.rs | 2 +- crates/protocol/fixtures/corpus.json | 1 + crates/protocol/src/events.rs | 9 ++- docs/PROTOCOL.md | 7 +- .../opencode/__tests__/opencodeDriver.test.ts | 68 +++++++++++++++++- .../agent-host/src/drivers/opencode/driver.ts | 52 ++++++++++++-- packages/agent-host/src/generated/protocol.ts | 12 ++++ 20 files changed, 223 insertions(+), 37 deletions(-) create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt index b2bdd07a..da2f9e33 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt @@ -328,8 +328,23 @@ internal fun NewSessionBody( } // --- Effort --- + // An agent whose levels differ by model lists none of its own: + // the chosen model's (or its default model's) are offered, its + // own default among them. + val chosenModel = modelOptions.firstOrNull { it.id == model.ifEmpty { agentModels?.defaultModel.orEmpty() } } + val modelEfforts = chosenModel?.efforts.orEmpty() val efforts = agent?.efforts.orEmpty() - if (efforts.isNotEmpty()) { + if (modelEfforts.isNotEmpty()) { + if (shown++ > 0) Divider() + ValueRow("Effort") { + SelectField( + options = modelEfforts.map { PickerOption(it.id, it.label) }, + // A level the model lacks reads as its default. + selected = effort.takeIf { pick -> modelEfforts.any { it.id == pick } } ?: modelEfforts.first().id, + onSelect = { effort = it }, + ) + } + } else if (efforts.isNotEmpty()) { if (shown++ > 0) Divider() ValueRow("Effort") { SelectField( diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt index 7943a73b..6de1d218 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt @@ -675,7 +675,11 @@ fun SessionScreen( SessionControlsBar( effort = session?.effort, - efforts = agent?.efforts.orEmpty(), + // The session model's own levels, for an agent whose levels + // differ by model; else the agent's. + efforts = machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models + ?.firstOrNull { it.id == session?.model }?.efforts?.takeIf { it.isNotEmpty() } + ?: agent?.efforts.orEmpty(), // The button shows only when the agent has modes to switch between. modeLabel = modeLabel?.takeIf { modes.size >= 2 }, modePending = modeCycle.pending != null, diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index a5ac35d5..1ebaa56f 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -6119,6 +6119,12 @@ data class UniffiModelEntry ( * How many tokens the model takes in, when known. */ var `contextWindow`: kotlin.UInt? + , + /** + * The model's own reasoning levels, for an agent whose levels differ by + * model; empty: the agent's. + */ + var `efforts`: List ){ @@ -6139,6 +6145,7 @@ public object FfiConverterTypeUniffiModelEntry: FfiConverterRustBuffer UniffiOptionChoice(id, label, null) }, + ), + UniffiModelEntry("opencode/big-pickle", "Big Pickle", "OpenCode Zen", null, emptyList()), + ), + "home-gateway/OpenCode Go/deepseek-v4.1-flash", + null, )), providerProfiles = providerProfiles, plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), directAdvertised = listOf("wss://192.168.1.20:7447"), directPinned = true, diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 7fe766dd..ac56338c 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -167,6 +167,9 @@ private val pages: Map Unit> = linkedMapOf( "new_session" to { NewSessionBody(machine = workstation, events = MutableSharedFlow(), dispatch = {}, onClose = {}, onCreated = {}) }, + "new_session_opencode" to { + NewSessionBody(machine = workstation.copy(defaultAgent = "opencode"), events = MutableSharedFlow(), dispatch = {}, onClose = {}, onCreated = {}) + }, "pairing" to { CompositionLocalProvider(LocalActivityResultRegistryOwner provides DesignFixtures.noResults) { PairingBody( @@ -294,6 +297,7 @@ class DesignSnapshotTest { @Test fun transcript_cards() = paparazzi.page("transcript_cards") @Test fun transcript_long() = paparazzi.page("transcript_long") @Test fun new_session() = paparazzi.page("new_session") + @Test fun new_session_opencode() = paparazzi.page("new_session_opencode") @Test fun pairing() = paparazzi.page("pairing") @Test fun settings() = paparazzi.page("settings") @Test fun plugins() = paparazzi.page("plugins") diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt index 63bb178f..400a01ef 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt @@ -9,9 +9,9 @@ class ModelPickerOptionsTest { fun `models are grouped by provider, in the order providers first appear`() { val options = modelPickerOptions( listOf( - UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null), - UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null), - UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null), + UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null, emptyList()), + UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null, emptyList()), + UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null, emptyList()), ), ) assertEquals( diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt index f480e15d..323422ea 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt @@ -50,12 +50,12 @@ class ModelLabelTest { @Test fun aListedModelReadsByItsListName() { - val opencode = listOf(UniffiModelEntry("ccr/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "CCR · OpenCode Go", null)) - val profile = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Moonshot", null)) + val opencode = listOf(UniffiModelEntry("ccr/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "CCR · OpenCode Go", null, emptyList())) + val profile = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Moonshot", null, emptyList())) assertEquals("deepseek-v4.1-flash", listedModelName("ccr/OpenCode Go/deepseek-v4.1-flash", listOf(opencode, profile))) assertEquals("Kimi K3", listedModelName("kimi-k3", listOf(opencode, profile))) // Claude's compact tags stay; an unlisted model has no name. - assertNull(listedModelName("claude-opus-5", listOf(listOf(UniffiModelEntry("claude-opus-5", "Opus 5", null, null))))) + assertNull(listedModelName("claude-opus-5", listOf(listOf(UniffiModelEntry("claude-opus-5", "Opus 5", null, null, emptyList()))))) assertNull(listedModelName("unlisted/model", listOf(opencode))) } } diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png index 352c8fb1aeb12cd3674d46c8319ae0633ac86051..e4c350cf66dbcd732363ecc8a03e9a5e82133ecb 100644 GIT binary patch literal 51024 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�ipyMs<;o z3(jv$U$a+-3E9=_$5R*7xhC%D+Q_zh>Jt*CM>!cyZvC8e#-iub*002vpRT`#xL?_Be5G;uMdq{hdXAWc#Q#|Gf}&alVy%tF=%L4mDl6mm zew=lBN^19h@j-E+dDpSm*Dt2IPK!?_g`gktX(3O#Q$8GgJh3#IdjQQIEfnXZ{CJbn z@SGpng{s1^b&9&M4xOFzQi{hmg?+h5SfFroDr zwmc*-QZOeM7KjhWi;EFQe$Qq|U;qarErtsV^6z+C24IEw)OpZ_{^cRcy8tvk5~O;=db_yza0r|hFUUQT$-|nE(G9IfW+Ky%oJ77y_25=csCT70XE>?uDw^Q; z@&l9+v^7IBPkEApE3E1$Nm+Yv=?`IL)C3yO@+^cdTJJPT!~i~$(4Lp7gHR_>%raz1@ZML(wNcvxdl zNKeG1){I0&U^=AzJRxo>e)q--Z^OauCSoN-rWrC-yZMnVax0;#2|g)t1S2i$84$Ky zA?_EV4?J)}<1fwdhN@Th@r5rY_l502MLl>aCDE@^u-kr}er<+fJVFwB;$S%2w|-u6 z+ZlD~l|Fgx)W_`X-@E3~dH556JF6vr|*Y zA(7LJa789=QFW>$OpCnyLBe8=f?0Yj7b(_N?w+)?!1$Z}%{7Dc9;(->IMY6>0%=Z2 z7z;HSwgaR~U56ENdXDuqA=-&lYm3PQh2A+52w#;wp=3mayD>mf>MM6(taCv}hv^xM zWnC*iG}044S7m=at683g17{%(E(@Q1o0bB%VkFCmM=d(!g0-*DznJM&9=F~ZFR-r%d-Aj}S# z4j!-veq4=F7&ET-I&x*1JE$+ldVB{)`v1d?Lc>DK_+5G@4vc{Mw7tgFJv`M|?ux-? zzEH1xudZNBN5j;Kbc$wFey%Gg_e~(=!wtY-(VhqKt9lr>oDIOTpc;9}V>6mjj^ET! z-K^ET{2!kt?Q4}!Ob2>wepJi*kL78J&%T+^Rm5L`3HNrXnMnrC&B-Um|JBZDwIe1b zwr*ab1|oBIn<%ZrV9PIdRvzw#w^zD`f4@ElgWJ$c96)t*J8#>TlFAvqAk474@F}KZ z&jU$8n^9Otht(DgS|Bd%wBYjPp=$-VZn42c*_sK}3%VDj7VmkuysbecuTqC4i6*0`Z@ zW+IpinSR>-)qDH)t=arH1oSn+cEY7{baZ3PVI*-91d2PX`uWI z3k&8;?M^_ce<;~Y1*>u!`ysiNjrhQ@Hs=t8)!>Dnf|r6fD7z)?b4sa5 zO^)PzMl9~L^>R#(lXfEIq9F>97NB|$IVXxLfArq*ePS~TIMACuNpTi-_Hh=&vgUb~ zG)V@U$!9|$0>CTl3~X0(JrNQr9nsnuFUc1bp&c{k7#Jw2dAJWi*GMsxN?80W69mPm zoH_gb`TdYPgC}rkK`LU`!0(KVTmp+7rR%{iJ6E9gKD{D@E8?bvc<*%Y`Q{HXN@jyA ztD%3ob|;qNCg2RgC9&|{0Zs1((?W`9T0kP*O5y~CBc-rqa4V(P(RxwnTj7r>X8O{~ zc<*D60r^Ry4VRqztnW}ilGoF=?nmjfF}hXgtHQMM1SA= zNdEE;KA}c1xRDK;18XYjWn$zeN3j)>h^VIU>6r*G8jqYQ1`-m^!Mvpf=nYx zhVRC+3F)~Sh+_=#K*{DGCdP5(u+#7yi=GLup{jXcwRIL&&d954K0qA9YXg0!-Sq9s zD-|)IRh;*yUAq1z<^Q3OS102hr`jZN(@{|l8BG1qA%n`9DBsvyRvY#t-y}T77O!JO zLISKu^70q(zUAUEKl_4B!=0g^Rzor%&jex56GW%|m{{}-fmj^N{Qks;PUj3Wi7M_o zfI-Wok+D@r+VVFXdHcE9n6T8rR%}JLUx4^Ep%@=)4WgJfOMfw;@tp7>kF|PB;KhBo zA&^kPhl#Lm$9H)j0)4#_%WJ0?9gjxFesZAVHV|S37rHqrSJt^+)FW-Hjr)fM(Y%{w zzZgv#a7>u86}j+v6z(O>VpjPvs2TOx9z@85$jrs-TeDZ|e*QiF3<*_-Q6lMXvZ-!S>a#dU!7^NhIw&QMC4Q9t_NTuZC@3DP$knj|oxh_djH7lm# zH_M!!2R)VMC)<+rQeL@S0s>CtR99It7{+vaF53s~=f_ejc4CB+=^186K5dR2SYX`J z{;hRS?b;g-vBVwb{b_P>f!$f!C&ibqQxy08c}^EF>l40y)vnnA_Gxr+F>6+6Q?|1H zaPE6&+m&9!9t-$fL9?KN=RoXdBJKm zl4n?gMdB#}=RfG^h{?(HJm5ZCc+YpVoah@t9nx{JGyuq$H6VrKoMN&u(+rKCpp1LkW^B zKTvp7B&FlJ6&d-OkNs%dytz1{VF!kZYXHRhuifPKr>l$9k8erX^mqQMM%Z_JGWRlA zY`yc-a)9`Xj+mI$ebzTh;`)>xkGJnH5*=1z)+Sd&XWoA$W#qMH-qB-M*cg3%eWj|X zsaXKH9ayk5OW$({+smwkTo@ud!0M6oKJGMhpJoOi@7oE6W%t(y`ccXE9Rhp{*dEKb z0#Q)%=yQ9!?uL8R%jS%#3>+>Nct2e1&63uY@6FYuh+I#Dh|Orn@s@b68;i(Ca1h#o z6)Tbp7jxP2s!eW9a%jk!%u!8cQiy8sbK7*Kd*8~S1hkr?s;4omT^UhQDslzz)G~6d zT;Q(rIsfZEsOm$R9IiSE`kbZy%A?O2&rVBO{ZovV8DF43+(MwwV2 z-eDk7wHNKtV+H#Ie*|Y}mB+61a#a1dN_-nU59n}NC?C{yeUnqjJ%L44*i90C`tpTE z`ua3xyRF=9(?i!E!}jW&fc=D0HiEjmjslj(YUBo#^26;4p>GoyHr}a)QdWF4W}4t+tkH~7K9>6u>!)w zEnn2SC7YXwkI>jo*VqLh#k>wbANkiQIWux{$`g=<;>^42>Uu;YQ9Kcf*r?_k;N-+6 zi!)RtraHxApIbEigB;UIU4AlJ1cL4mD^`rpWCD>w9G!uWsOVV1Wdz}KNc zT8?rLSW7iT6TyHd!~Xp{6)zI<2&72NFtK}Ej8BfGIMacdKiS&&x=%{PFEi=u*X+rG z;YYtT292pl^I6Xm%$L$EgJ@eQiqlvm{JdY|pb zkxMd+{E#c#7|6zkBRAzF()|4ud>{c*O)x5TN_cBTQd$Z&!QL-_4qeS}#|L;HS5l?# ziZ8@HTJF;6sGRlV+nRLOWNa#CQ%+3(=y5cX;nh1{LbCMb zb;?j#F#{53cDJ2x&bg6kmVXZmTyHZz}h@ zlDFf$ygcB;4LII95SACQ=oYivudqqbnRC8xmuTsEU;iEx)1R7uq*Nl-vLgiezm~B@ z)RV*=R23EDfy0{d!x!k*b^D!n9=NVMM-YM-1qCjYHcXV(h*j_e1QteC3!zFf$S2Cm zIte?`3T^|Ge7lbG3&ZxSku9}J{HC9_I_3E9qF`7=r$C@=6Z~bnMexgSxJnE=1^UX` ziE^>nl)3QO3-9e)E9;h47rD?-uWhF%&z`A!9Ig!+N8Mi&aY!j#XrbXCuh- z&v%(KT#TKD4i9YDWn`FyTNU{)c(5_a3QkX#UJ_AqcF@urVF!#BPFlLpz9Zujfn5wL z6~(sCaqMifX1-%iB~}()h?^w@oTUT`+!gktl^D%sT9I}>MBmLl=1X+=ehq|4OBYqV zWF)|Z()uNU;cC^N_0_$&$_xX@Ta3AZ5Ppnx@TY)Z)?)rBD+!1!djHcNM!8rYimBY+ zvtVBtp=KoX8G%V(FEdh({mn>HVU9@c5v071oRJC6UKAqhs`JOKAd3JlQy-5-uKj7U<_yF#yf zKY{V6*q2hj6+%At!;p>*;V**$ zsX`DoQQVl3IIHXWuvtI`B47asERJXUT}4ZAI+5+iTST2-grvI$v$|Yofe`ueb&6-j zGoqvwfzdFmNEgzi*?j6OoBx&1>~pPMhYIimm^ad zNA8vOl1qDSC+&G!`O7D>{*_F?cw^D8u#E#_tTRnUZlC8!*rbW^tk2(XY;o5=jL;J| zkX89ESi7q6+%H%!jwP9ZB}-v!>_6$O+u)PsT0gztFGnXN+g_%e0S}uYPc?y(oGMMi9dBpvh*ql_UT#;iCTjHb>cY9OK`r!gV0{WemAGO=@ zBy2MY0~u}W#vZui4UY47Zx!5foIUWSk0U=naIUDoYLC|GnkxI2%ZUTRgpBOCu#ysG z^X)0mH*Iv2!}SdJ?PUXyloB4{V5BsTAd{0Yo-Bfu5e*)y6MSsSsRFBl4n%YcbXih; z_P@7Tz&>~nvxntCqZR?8P$T-6m0(n{ZDZ5$uL*?l;@oFEb&6c)nq z8093}xWx8O{}O>yH5vc&Xykr_WF5<%Mm>9nVe|RRLyEC@-{UL|2?fNvGT< z26Ojr6A{!DO=sYx2UJuKJVWn6VYz!Z;60g8Nfj`QpQeS7I$jQ`N;4;myKA{i@$j@P z`@vv=ONVPkA}Rcib0)5fv%V))qjQ!!EycC&JKz1CQX{KctTNE8lE zQQq9@tbZDCY(tl^T zaax2P)1_JY6!Bz{4i^dTW zfr-J8pwR@%Xw;TZ6UHDnX8IWo0-N_ z(@ect9f-LnB*Zp*+#11bKA8FCQ*<;7umnbKt}bDR_f6E2QEK_xhG6iITA=35AbJ(H zY`}3msv_d`cXP8@n{@v;VxY^?(Xlo{4pV!YgbAK3M;-bXuB)zYaCd6sGhh$wS25Uw z#~~@aYQ}xHP8jg1AQ3E%I9W$iJg#99HkHb$E2_cL*V0;UZd}tdIem;c2*iL`Q=O+$ zrhetjPq!_)Pbo9$dUB8+PVhW9$;j}+-@S_gBD*a}d~6(#k9%~r(+%9PDyIWjpd~BU z!XhT786nNdSux>8lc-fwPYh#X`X=~hiQQ^SOk%b{yHIaZ>0QF3Cw;!()K$kM{Vsi} z%~@=4)mBKUsPs2^Re$T&k;AI13tghs3WQc?e1lnjsV1M-{l>IAKm1Z-@WQkW@=zBe zI@FUX6lku8cPfw8hd*u@K$!|7$^5) z2XuFn*X;9+GP*>*!@|BlxcsXmoP89-B^07fNiX;+_XqA&=2I~ywYT1w>%NjR*cfXqx%Jp9?3uXrsxBl%I(SEGgQULSCp^ij@>fRkw?KRgQIHmZw7 z?#dznV>0M>z@WY#*`I>&$w7N_bX4aKFv+Gt5I$$iR#)|}Is~ghzH150vY6oDdmX*K z<^NfOH`gb8#~xN5__vA7GW%)m>}Ov%qa=QIF~dder?|nE)KdM0utLHpaA8?dIGfY8 z#u^%fN=zLmm6Axz%y>Ry2hVDZSAX6ek^ipI*=l06_CZcuMqT?- z3i-9Qv2_AeQ+(1KzEiscJ%RxJ%BBrFZF}0g69`HiMRjj}cUEa>hxS#R9Buwpy5+jJ zk=O!Xu5wXXJ%rRCKi617keY!(9j)YfO+y!eM=BQGpO;|)Ph+yz9e#hj1z!cmjROyl zwdhWw6BDEP>b-aBjR6_*yIhS;)C*;X+YAefs@e8iKw_mV}}(rhCIlGPAR7 z_(+fYlEsZj3tr^Vw*jh8YK?|$V_YQg8vdAkKcu8~xr4oa2NrvRDAzW{JYUNp%A4HI zwR-It4EcaNiM$bLYy+IEA3f<0U`^TD%2(b{@|IHc9dE&^&!4l`)qyGZDUz<%lDbM; z28r@Q_b*26XcLi@aJLn&=yqXvOzmSuT7Jbd3Uzii87-(5^gfF;^yIL@Vb;{&*zt5%sX~B;zX{FEY>)BmxH~ zhDiES*XDA3++w0Oy( zg13I4$|>J>?yFBjId&`b5897K(MxJi9wLx0`xA4vrB#tAf*X(swZ33DIR=cHT@yJZ z>n^;_;KW(B!$k~3gBYsT_rA9o8D`~l;8zZ_gJz{U8~cR>%6}37G^h`g!E@5sMmuyvtEqMmW0{d}JI0{LUACXPEQNb@O_` zr(VuyfDTiAzxDyj9lgn);vL;5~OR#r?6)A~dbLC8#Q-0-i4m zsYkEaSN{cdbg_g9pj2^sy1~J%#={y*u0X1^#@*(NsH=J(Jv#P}7`*39kl`GsgqP~X z;LH%qUZlz!PGoi5p4v)xoso2b1a+43`{1861C^BIcrO(H7!g3L0)aT^Y2Y*h%!t4s znz$+x;D#H8;!`OyXHd+I>>t_bGR>f}f>8GMTxTn(H!%Q8t!!{f0Wk~E_XA>zAD}1D z2_$tZORan45VO_kT8FnE7JN1$#VdeX6-|spasxcZ?d;Xu{7umRTF`2`#<#i+3#`hY zrOu!>;TH$=uO~f;E4@l8DkEELs(vYbz8@m&IDx@(mUf4po&%r*dL|_Ggf0jZa{81g zms&UmnyE>81^u35Avu?k>4%j~&!d7zK2J)GCT$e?>&ZKU(a?-a+R+X>Z zL0AMFWDs&*111n2iw6eL(|ql%cOswLZJ0jB#WC+RT%^>U%s5#MWf#`_Zj9Rfv>D6b zO+nSV(Jd`TNk@YUMc3=psS=TFJ4sGhR}-bmT8&smT_YGM@>4~-dtlWzhz5_ z1^eN904Ao$EgI0hafrK>Fo89aT-dV{>wj~hGri(R1nCFUd^rG{KDND`aymXfo&zk2 zNlxPv{pJ1VA*d%$J{RbgbSjo+)PV*2=5SS@D|~XbzB^GwKgDYuEv3{FDuXG-H@GuV zo+E5I_xdU_j@QtOlq>a}cw(`JDE6(6^+m!nPXb6mZrpRVI}MdndE26J>#-lqARH4C z$5c6)@+(UEI#VDBNI%>VA8Yf(qV3v^AKtbIpslDz(s|1%q&E+FoGmjCRyz1ozhpt9 zoNz@YYJn#CJmO^9J!-?)HdWLv+ji0|p=ORBR3)(Uv8!A+l8-B(7+6XN|6XX9?u!5m zE~eUfB_AqkPs;?fNjuMPJS=QEaoW@n4EzH?KF_wHZ7t80Ra9b_#vC}MSOFL@sTbNr%l@p1pxb<>{o%r~+(9|xFj9*Mn z)Y=Bw5b`+!&ALGE)XH^0Nwazf7038=FrfSvkXDK%m2=^`c;4CGte!OUNU1%NkU z=uMJ+!UN}j9iWp39C1L66XN!wR-NRZybsl-O2i1EupX|!CX&kEA)cX2bj=+p*=fdh z@7$^spIXXRCIM*u@561TjVm|zzgR>h^m3&ZZ}W0N|JLZbW;cIHzg(k?4U8Dn}4MUvChX zgWN1)Heda^dHR%NovJAaHHQ!gY-%#;L6DMTKwZdgV|8xNp-v%ySc0>prY-=bq^%QM z(K{H?iBgo8cU~JWd0#UI()&Ypb`xdpe?#A8x^B1;gOxKg8_O~D3dkXXP79I8Q>QZV#i+vOSF5|FA7WFV48 z{HMQvlk#L(N__x3@OE`}wNR=5N9km@8MQ58jDP7V^_;uULw1A^|Dhbj&aLk7i_q)8 zYL0~X3;tCmmRL=aa+T3I_&gaq&k-S)oR}6&a4gR>U&Qhk-ko{gHj$ zw(~y;nyUb|Jg4EkJl!MVVgD-rZ>r+ou_)!g@sh>z4Z>D?i2#-4nsyLQEXT2qe9SV> zksgJf-JQ?K1ah^T8j=646#}-VCNnm7e@p)l!<20zeEgDa^G_qO%7Zt+W99BMdFNFP zQ8DdHiH3&AwLB11|ZY}r{DIbwIHPl9exO;Yi?IF?ESM=>@zs1TY?#XI( zHPZKdTOgn_?^fZQW#0eP63S8Z^}(uaHy!YMRr|PqiXPLS#ajNn1D1?SOt@qC?pgAy zV6dd!mKc{g7{}9opWSMbl3pL*Y~5ks9=}yA-|^w%1b%|1mCZIIVVYUT2(d=g-ajJt z2yC$KzB*Om4quxmTYUz1VoNJyceOwwV(c0W3)N!Y4GTcEzu(tG-#$RacQv{{=I#IU zKEZ#{L&y_bux$avna8F@PovgNGI^{~u*eGS-BN?0`!QsLPz_kG?>#&=INKqzTNAng zxvU^um~}^6D37x%I>Ajq1H2m*c76_pS9tNN-?u&>)AEw@wiVm5^i#fqKgacl{#fTC zBY}}>JrPQKSYTu@xZDXyv2x-g(3fbulAcOyZ(qgTP{^W09E!&MCI_n%Y(l^Sw0IK% z4z6(RzhC3NBXuiuzS z1?{IaMP4~Sv$Q0^!NaTkhW3?`x>jkV0o=~3Cuybg_uC;nkxd|MygFL%A*M)R1pa?% z=TYVx0;IMgBCSH9v81( zpMKLkbmX3+GR?zh+5id;ll#vubeB1rU`?biYFr|gWy*%kKv2~rSx9-LXcjpw3i9wG zFE8mutOJ*Ovd59(A3lr-0}{#h<;w%i8H%Pm@^ntNMb=XdMCFed*o1>m z^8SpYu~IHfWHDf$knJiQ!@w)6KUQ!G4Dk5F4v`MEdmvJT@1Iwit&%UJ?L27z~jN zcnLZ)Nhve35&j4Nyt#?qWLpD$Y`vjY&ZIx>&V7zLG3!@M&8Gu<&nHgIjO-A6D|xHVwB!V46~zB|BHpUtuodmoB20 z`;qzxDLe}k^y#!NvI9=q5I&uk4zZ?Wvbnw#3QQ z!7bn~EH9DJeDWmsnt&*(yOK~Jj8bqh|K7C3APDUS$>$+Xy+Xi?@(2F?Ni(~9%>66jX(G>fH^ZZq{;y z1`G9x$hqGk+40~hKKp1O{`FlmhzR0?uD(P_)2#nZhJQx-%i*~&d@9ykgM_)Sy*(C) zx%-VbS1e$M;Rf6m3INz*vYV_JbDlqzKInG->3uTeGlm5Rjqi`3InNvN=lqCy6RD14G$l@S?M!QvlpFM#DY0F^&I+BUV!@ok-|Ickw6xF2Ub$o-1Ct%yuhaJFQrSs zHyb{MS>J%Dls@Y0nawT$#|q#)6^x|-!_IYzXkzJfvouCqxlyxH`gBf(2IhUdIWh~@ z^lc|{M$WTv^B367PizQ+#=J!gFmcwEoGR}ti0K%kWS;O^N$#G!cN->l^vkqDpl z((@dtgj)u!z~Ec(ksSfcJ&U6AHnh3A_mVbYik9PC>Y$_B+`dS{iuf3@~+_;aDm7}ROkY;7oE=vCTb%B`A za^SB1{{BP7SFd7$5;%D=hndy}JqhuC-3-h-Txx+;dXhBuyhIs@h&_-4|1zkmU%LIz zzMe#pshdvy1^D6)&5)7M&w~vqe%VGvDN(~kJNg_U-ub|#Qb~Vqq{ay5Bu_WjElpLl zIeZLlE{T^{22~kF=I+;P5v{2VSOlHIg>+yjc8NAtj$UJS27^nGGCt!{OLgS-!M;xcKkEf zx%J94Ks;6RWsp&oV+b5GL=lB5*4q0+>Iq7uHh*i5r1H7@VnPXm-bv6OFO(yjG>q9) zQ#69w-ppEvkCOfFYy=hh_glcxv>h*22g>EfPxhyv=nMS$&d$w0KP`InC=e!ALfp&^ z)+5$dGZOESx#ZC1^dwm2?2HnK&pO1_{$G%g%y>3brQH2H{jk?B@s7KKpW4Dd9(n zM7W#Rvmzsm0_W`SV7jGGwto1M`+uLxXe*5`uK6gw)VV zNr{A%l7fWNIUs^kQUW5~-6;s?=6BZmzV)v4z3ZIstn;73EasV+XYOlX``UY7%JK7{ zgeOHtl2XHxlWVeij#jt9n6U(P1$)`m#dIQud=~Qdv045dC-m<`$b9A)DDozz>A11K zCe@G^ghB9V`;)ClbjQq)a@GaIVE+HTGven|rU2-OsbL`a#@poK#m!YiKj6`^!nkBb z^W|PK3OEdV0Fhu@{a34<1DH-=)ss5i1VP?UP4P7U`>3!#kP%|7Y>B2G{PjT0Wy+>4 z(w2`%RZHs)nC1CCq?nV>lL@ZFhVXalYb*dI!W;UK3tc&DVIYH)N9H1Auuu+TW2U8t z?sGw}HFBl&MI1*pp3l$TT^p7w-N~-x6vnrxD1@eaG|KUhKYiei=P3-ZdU#MCb^>kG z&!07T5AzQ}I)X^CfZGiGcZ**~&KfvUU9i9~VglBk!}s*HARB5=&K@ftBc|s4+4(u4 zo;F0J<<&;-w{P2r;8s1Wb8c`DVw944WK=nmPel39E4=ydg|u}~E68!Ug{WAalaY~? z)Zmygi;7yjIN2}_W*9Ej&A1as@J7s)5U$`sHx^_3fOwGb<03Pb|H^k~95l)kFukmQ zDXj!fF~|5$1O%$!PjrXMD=KILK&VMdYN|Sxfu|KjQ&)KmxRFJM?hCCsAK<(Cej$vO z3Or*f-xQw|?dt9}DBqQxk|}ndm%F2CKm7xBc@Nr&O5X|D*UXIps*y+1!LSZ3LRVLiJnZeCTV`=AX0a;F za7TgCZ+m;gE<8$4N$IlRKj6YhD1Gws*IFn7^cb8Vf#S=#t&Q*`A8?G$yY5mKcwzz% zwn;|_Gk!77PYNBQb1HChv{zdP1*083r(mjH|9`M2hFtY87jt7C2x0?&@ugg>*F7$L zcRKW`{IJlP*SxO=ftXjsiDbNtTp@boUY9#2$45N#sJY6NX=+APSnt{*KUSt;lXT4(S-8Sj{xEo=HM#=uA1bNjgf}$*5Tpuwg(Hue$B1NO=>3 zPFe|{G>vT0$I9`P?6?$_N%8R&@~_=Tm>z?X0$&1^2nc8~0#5hP)Ra*`Pbc(R_ax@< zjErcWo`<9N+m4rF13|91NqbNZAYI5ae8D*6PDt@j8mK?|r_A^Hxw!5otR)?uW^QiT zOi%&fh?l+-6--E%kXc_N(Du*ZKmy$u^mo(Vi1B-ELzP+6lRGgcI_%Y}A6^^7R)FRp zT%D?!EYi&OJv$h?V>f~H`fi+@41y(oM?=mAGljIJV?ir#To2mqwN7$_5m;#)8q_)o zTz`6z`s7p{Uuz^FX`JxsC@GolhBS_&cz0Ht!a|#=o?i|7mi^U#P3v4U+?>aXBXX4c z(|0SLJnW7}EcYZE?Mj$~0$W0F`ZgbB06BYfWlS@kqbssi7UgkY6ZsmeY&XWr2`1mlW ziiLkHcOo!f8!#-~{z$cdBrpG&Y7MwZcBy6{R%bg(^^9mRq(XyR+o$4nO7ytnwp#aj z6QHbvXufW3tu$i&Jk_ifE9^qa6Gu^{w+zPuuKLp6_df)lZ@ExlL39q>Jm?U%3=k{H z+=A9+7!VO1Szw`tzkX%@mGti)T7||oKYy#o6c^k?6#mRQyH8s-$FuC4?)KO3_Jdi> zKmj>xtzij{^sEWMYa4(Gd&&!Z;qg99XpC+N2i9FJalO6y7Xw6FbVvwM#tQ7F9%#BQ zKwgc-c2r`reO~a~3(kMS7M2Xi;yVg`--~YvOZvr0t;H1VPQxKB|DPgTk}F|JR$W%_ zp-JVWB!)?q1|a_Lx4fq17E2&y(NbMn!0W_ z@O%xDFAU64xG*`OH2{UlbntbC{Be)4wFm8Siv!m9i5=FviTRBQg_fI@AE{K`-q8>k zJ!l2mfW~0*mRgM9EvWJ%FLa5$u6AdID=q)31JS6vg5^@T8t87->oG@a9a~>e3y;Sd zdbX3m;Q$6bYg?aiQY`(YY3VZZ{!KG&r@Xbs5D`L>hED!?t?2B+y`Q#m8T&t+S1|C%LfS4YrH=|~~7icI!D_eze?z`E3ahGcdRO*~K^>{sW@f0EjqWzZ}ogfBzyXyo7arKK~RDA!6y! z%moxR<&Hj^qY9&=S$~!>fsf7XfMeJ|U=r?gK%I*(g>l`}wsbQ(_JxJJ{Eb^&LIMza zvXf?tAqUn5{mShlD*Km$oObGf7=WIP%=&g7z?3(O?Y_w2fV|)Hz(MdTG4VOK&+`h3 zBxhUy;!_gXsrb6gc5N-*_P4rU3k(Tq^LYLw2q^#i_oFc}5pJp{L8R;cKCI-J5Y~Uu z$gk05kH{!WT|S`4QI+eEGa4<0c-az0>`9p9?m$)fKx()F3qk0e$p84)?> zKNK$?daho^gEEa3&Q&-{5L)GdHRFZLhzP)t^K56&kDY{b#3d}6O*sPJ5HRhpVoDek zEMsnvrSW1JPdN5Mq5t_!^R^quuU315(<~2o>gz5c2BO%OHN>o!X(ZgS^UhZE*v!-R zl`_9BDUKM-EUmmRaFucWkh*#dCjtf3v_NWLP(;_g`oZ^!6_LjY(KwIcB)XC=ne032 zGZVdRK0)|hB+v^qQbV=%0A#R{IGjPl163&>`_b%cqt)A@nsvSZq)GDrN>Yw}&*g?9 z-#KcPgEWH-L3Lp$6ZlJGthkbq(&-cFw}#R8R#$bTUnAf1C1FA!D1!cHy|-M; zKk8Lblu)IdTkWR)4QLu6JRdMf2JU@=dzrH_q=w0}$ZDbZzR1}(G5>DL-;va)kajVn zG$+c&5GCl#8v?Oh*_JBR?D{{_pD8X}Mj(&B%Qtd@%$iiCDynH;xpVM}Samh)=%4;14-hYF7ILi2%H<%3NI`~_59_((Pq-&I+Zl=7*FcQrCW>5M4RCe58 zIA4*izMwrZd@IAx|ImGZ12{Jt`hF*{a#iKtumi(z#e=wC5r@>ypREd%uFnn+$H=VQ zK}3<`1W%K(qt)tEKVQprxGfg)SFNL7Q}L@Pld9IEkIyLFc$%NQv##k5^!KmruLsr~ z+Aj0lb1;M`9#UL5cVE zx=ORZpDM*ZrE*?wF7Kp8V9uI#P*Chz$FYc%91AnE=`rzT_%ZFVjoJHE^q-D)8*-VQ zf{8}~&h3Q09@-#q0JJB@s~LcF}{xbPv+$$geC85l6TqN#9*>+&7b zBp}KP55JNRBgA)o-*3TO1&-6I^$!@wa402g?~83eLDsE+#|#=C{iI?DPzi2&Liq6{ zg@jb*m7YAoZ5gJnbIU0?Pq2rr)bI)l8iply(bLWvG%QjHAZTI&yG2*Xa;YbkB{>i;!iijyoWm(MuCaUZWZwm!XbQJGiQHKnjj#?TgS-x*GJb?&x&;Q zW4gmi9cV%P;~yx7c);o)%uQnCM8`D*Mcz6Rph^$qlT8HTOG}HNSjMZrO&K+_TF`Q% z6i@u7?&~+}Vcx2N*~vfo19z;}8Sjx}{1k|gWpN0q( zVlPIC3Kx9XN{8tHRHC)c63mp)Txtg7W29?iF{cAW+rgo6&J@_dT(Ky%;dLLwh=%sZ z%VOwot8kRV_6H zj+~#S4#$bgh9JI>0Sh_G!1e%bo$7q@9$e=@m&k)BzLORvAVA82fMnt;IQ`<%7NQNu zjq%pauWUPep^1x5i$aBrW#V2K zF&1oYt#JJGwz<@p2;zS*bWo{F?(wcA26OWMLWJjsYe?nyyrXc)5xQ;9eb`#?@{>lf zl^V|8Tn6N|z#wC*!m)*J;sG6`@0!Tru?Qn_K)zEzL~JH>IWZs{;$%pg1%%x|f>qf( zQ1_u;tJW!+^6dtHJSjg`?|X~~QIkyzU#l0nsE*zzSyPX8iO}w%Xt)zppAQb(DHbR+ za|m$MBHwy@L+J41$)g6wDV5|Pb2yc_nadu87xZ2KmZF8f54M;%Ylphd^KQr5J_Y4q2mR{`qB4fK2*Z_>cuk=S@=PP zgEAR9wD-2)iPmBJ0nHlSfy_Z`aoOTl6voz7cc1_n*%8(4*K&C}Lu#yikN5w!rIwQf zZFYp9tgR0EWBF{T{)m337w}>7Nau}QHC1Ynhd6bCnyaO&?xXUnL3D|rHKyKm6_6*T zqHOV&LPIA#e>UoMrno=?dj{gIyx1U-SH5{uonvkJ#R${+@nV4oW*~97vECa_8(_aT z;x8cHTNtyBN$E+yHybu(=ft)QZOU5?dDo<(9mbzZu;R~8(c+`BG&j`rb#lv_NmjFZ zaD_GZ=Sq$9{l-UCro#rm*Mia**Y`Ja=82D}*O#f*D6d~T=i`wJ|4Uz%;rluHZR@&y zu_b810m^J+%4ph2qODJkU zQ~%H4?)>4yy+q5b%$VxZWm;%a`N#P39m+qUwoOHkwe+?0BN5+%7Oei>fbdq$-Mt4s zUl^XnLG*Mt8MsX6tS|Y~hF4$mNeJ1Y5CpS&YYOEl@Nvk!dr?&t^v^OM=+G2+St3T_ z7?D&2J+94W!;cddtELje-IOE2XGR(R+noU^V;cVuah~hlfQJ#Av23X*z~@7;I6Y); z&GAxv=u%y|FbcNL`Eu1i{e^D=zBYW_y3%hfCb3ds>{FDR>N;apaU9N1tLDE+D{cR+tX3Fjtg0o=4%%Z9_Y%2Z7*&kNPXkw{BF}bX$bkD z(NXi1CTlWP;h>cGtr?&H^t3AUXQSkCl0^(PJw&<^6W6@By}%*<8GRit2!XAArC*Vr z3%e>AqFjt_?Ts-U`kJiSPKFQd^+tWyr2|7o-Zf^f4?c1d>@v_j(sf zNFDt_)C*A*`ebAnO7jE`CAIX%*UzwGaVSR3igB|v%_%6*zKFe2(2S7ZCx9-?d@c11 zrcAif(L;0#Q|syVVLs1j-_Lb^Ik_K21!54~|UoVa>w|#iEga<8*TZa!oaQrz~!;tq~G2OB5u1jV* zyw@@}M>G5w3rbmc=|e$;>c?yn457i$H^a);`5>^g`iwrljR9*x{4NM8?u`ZQYa)U9 zgb#KoKlEZ{1zt=AMA&osKtikq6CIrvpUtAD(PVI_6lS}T5B(#jo~^G5J(#+7tpU0q zF-w4NJY(;2v`F2ZcLPG_^g=3|9V=s>zpJb3v|X^Wg99t0i9TM%8joB6CC!{S&;S*% z5LO!=_ojcc?e8b^LK{ig2SLW2M+q-(E$2co($IiaJmBDZ&l?xD;OW{*ef+A59SgD6 zE|^HK^G)pjMS5Oz2pufKf5c-w=Z}>?H0l1A{nGpH;*xIvQeYA4{!KkZ?@HK&o{nas zC}ozF!UU~X235qxduz*y`4K|sj#=`F*0kl#fBk8$UKeBFQJiDC!#A_nyV^1q*z3uJ ztx1ODLc~Lv;sU)08J1oQJylw2m(omLy$(zMp+26-mK0O@_+Gcr(@DnAq@R_&=8l+K zGxjkL?!{*;d)%%y7qg=UL|=Q(?MdyUwLV-wkM&1}6@S$~kiL@c(d647xV+4HPdvwdj@VN+g_M zCB!rM^OvDHXa1Hd(=(E&6r_BC4v7v2=_43Ul)M_EK!*dHgA|7q8YN%AlCQ&&1}DY& zuMb+vWq{VtRa!_ZqZ|=YuKPJa=|Cl>HQe)JlA?*(uW@2`x(0cZ2|74bkbMBd> zAv+OZ4Fv#UJ%0Bfc9N;~;0_w%Z^^nKTu2Bgt&XUBO`h+~{5skC^|&GuK?33tRW!kv zU(?Mk^x^wHKhdYrG~bjgK<2a<8JFS@T_Zq?Jpd#d-$-gCDUK3$GAfbwY7nT<`PNrH z8Q09!I;~2cyMxK2-O0uw8}oGIBcnjQ8;RJM2R)9iWNa}=wi&h-jpdn}q zy%iL)L=mN?rgAIlez9|vB|-H2ZKklEd+^&|$uWS0qX&+Tv;)9MyxIl!-Qkl5c})?YRfuC##sqOnh=i9|icntxU=Ja?s{nB-LA%TIh?Y7t@Z-g z%LW=fQ)m684!iHMahJ!yFIaqg^i}Ihyp*_YVYg`W&QIi@M45E2U{~hu&5!d`ZmLE8=34Xl^dk zE6OS}dpLdhv_OXx^E;teq!i=h?~gf&x_0fmrf+)bxYtRAm2oWEgVF-F%5ZR^-owMp z7@rcsj*P+son|aB2&E1b-qzKAAdQtoKJ~z-u*E zn2g(Kgg}Rb2@cjz1gNMY5sJS)3Qiw@TLIdTpApt|KPbq2pDf57z-8G857IXC3RmoYU=nw__PM&;zzeCEy1p46&}0F`2Dkg7^GP;H?H ztS3;@$5lU@2HC>wb|5ig4Ls=sN#7k|kOY^$qTQsG8>E%n;kg`LX3?hH&Vn!*$_X^t zllDJI`rYL17e?KPFne5S_I%nSM}MMU9-~k}3&a7udKh$J^5wF9P&ho{?OQQpc&feX(^mgiX3XyziRPqWBOq%? z1u2{ErGQ={Q6r`~Nw##B&$2$+X7;MdR%}avV2vvR z*M!J^t9K_Jt%L^Z7Rw-n1(^>LlR0}&w%DnHK#*UGMMsFjF?hRxX7wr)R5S8t^`}B0 z45R&O2-tOElMYN>TzE%zjIoTE?~H|!39Ta+@N)*}(ZYEg#e9nz&SP_RWKiQA$bQQ+ zBvFN-VXkH3Qp}x}%cg6F*oI!N9v9p8Pk1Gmc7I8S&xKsce{Z&EIV-3&1Y>4H9rgQg z==sh=7chY1AeRg2>+q=aZbwl!$A_D0|+i(q)>b(8(LtEpChWC&g0FK;UGqd^YEfdrOL&3cba#VS=8fn z>-j$^lSdA}Qy3P$$UTMf5QD>K`-Uk@b@r7lxapUc=XXCN6wxnF{czyIuUv;k@ibmS zNc0PM0|qh#`R5rJI^|~~!e^vd!~VdZ9DpnkQJ@~+^i_Dv;t`00qcPW%{oV|M`V82Z9rSR7Db^2fEUHvY038=$}@q5@Nba_h-Dfboz4b%Dpy5ctdy?K zXf>RixWzsJY@zi;Wvoyw9LF!2*cPPS+ki9po+6wG?Ceq)1P;r>wtZ@nQp55}$C3AS zAfQ3;=^3{s2>EvdyO<1i)`67G8JyLDk1;fql^=UwU%b-m2X_ko+ICgw+KsGjpTU0) z0&oa?f2t0D^-686C*kz~@Nf){myNL`vxd-=Ggxg zWV9wQB0&&-)*a;LGyl9#ubvMwOzjR9M)>YTZqpyC*O!*JvqAb#HrV`;0g}5j)6&xS z+#TKn3v1@(F+~PI=q>meZ^gQ$jt4})0R5jnP{OJ0|7qxW7SZYjOs<*x>#C!@Hl-udmBJAf#Ljd9Lz$EMUJJllgaO~;x*Ho@vTTF6N-_EQAB zPyrwz5C#MD!!*z8->m`VKrtwvj3r2ljYpHgZfo(`1^|Y*4$i{Cfh5MS?`n(m=&J0L zKj{2K432^`-1}gG@p!W&uf>sF>z&W2q-3z!2pHbZG9E~{y{NKii+0q8VveSJ(A*j7yxp=NV&QVHp2rjpM&CX?8%8mZ#D}|i0ioy^<%Z?NijaKU ze%~mW?39!(hkabHL58c1584_U8k+YP@EpobWP$T_#7M-cJC;qtVBj3HeaXo8G^Osf)Zo0(zNe zKmi-<4MeTphwt~@Jyr9y^Fr+Y)bFkh7ck2QrNdv+8$*GLo*$=H7@YsBL^~c$H(-!A zdjkASnc!AZ%gzx$d^b+<>}r|*mL(m(+i!E!b}V0xxrzRn`17sW-!;}HN#2$;a=HSP z?7%$zyr;+8ZgW(Gb%{@f6kEXG%Nr9T84(opl_?no=DkaQbFn1X&R!CK=q7Dfg5_mQ zO+DEQ#u|*m-IpkKu8e_`N3lTRj58UKh-=Vg5|bd44$9hCbUr#{shu^`d z3aUM*!-j_%L=2;|7ayh6?0+cz7S4c9y`$j0E|$-61@u%D$6zY@aOJG{@@oEe5$h)8 za`!8tJ65viznw3=G;e>DWLCRXP^*B=Yccta{lCBPf3%Fj4o5aZ0Kf$&H$B09p4N41 z82J7^7k-?JbqikfHyn=G`3!n&A~ehVB{mQPA`g$e9vvP1CXF(X_Fhwxy(JU6o@sik z0ni@{%8(Kpk4o5hV6ICQf*bkS@(4(#`oK4X`OO z$q~VHuC#X)+=0+NDJAe8Ns2+^S44;$3Mjl9JG+*iz$LU11&x@)B|i~kP2P6xPw{~k z^FLM0rzih^-Ru5WqUFDqSmGKM6b8+XP^k{b9?LW4eiBO+swjESbxSd3T|YqE_#y7d z8?1r5T%o) z(?kVx%mNqo3>u40om2dnr{>Db&#`3X8SJ?n?sTEo*vC`WtE92jol{^@#1UAL=sf97 z`e?aYt3{8bF@j}560IuChcvtj%~+Spvck%IqLQ|Xg-DGB0?q#iVlI60x%RNc`(!{e zWMl3l7%>9ogTay71grJv*G zZzU{WXlI08(=vULfc)pTy6DX;ku{CTppOP+A)!e~cZ|oa1PGW`a-Mx3 z08k=Lh41)Xsb>9qT&DM?nnHmSI}&P?E10}8(Un?S%`U>qTJ(MW!^V8u)% zD3Tn!;ca$Ff~3Z_-ivE}B5@#AD^4Y@<9&>`x!NbaYT|xllT=@<3y9`DnWrUMt&X!2 zv*vkPGRWq+zLwUExH&(J6iA0j>ZmE(zEIe0pcQ-E%*S(mIoG9Na@lnIR>v9boF6gL z8}6&-Klt5q8E;Kp&uqe27P8r&mJNrT{F3D=TqT>buYY^iSp_d~jxmKOdad*+<(ZgQ*ElJ{`zemHjZB;xAq{vHw(4z%84o&SJkitqDD+BTQr z$1IuPqj!EMA^>Tp2=B2HzWfk)E>kJsuLhY|E)q?8wNql(~9Wl}#V+?lj({ zw2cjrZ>D1{=Y88K-EdG6}$I4kd`FCffGBr{VQ(3{fcqZ*p7+l8lI{UZem zk5hAV8Fxop&KUsK6l!P#Af5O8NNT_C^e6?gAI|XQOYk{Z3&Xp@qXVxn728_aep^^b zzUO|D=_-3vLvF06_i_B^MD~L*i|fHb+LaUiK3b-*scI2nNvhS=ga?O*`0_WPUDS8H zMWi@P*JjoRitpo;1f2_GAK*o#L6u`p_j1_L(&fIRYSfbf`rWY*b4+2$=@f~Ol4&n6 zJDyuvF$#I|vpkq`z-UZB;9`DGVGbZFKXfvDbk3`wCQ~A<)zLj^oPIp{_{Fd>>GYp60FB#Pr$RV$`f$D82i13R}@BakOFxWD;N&L zc?&o8F)&bH&3%n6fFk0;kby--F9c&H(;gU&jeMVz#&KA9_fF~t(?y`5O@kCA_F=kz4H2iF)}S;{Rf4d+&y^utG@*`yOInN z&Y3!)xiRq8=SzN(^_tlP#gD7Pj@-E8CsGzdFy8-amGSEHR?7zxZMP6CNkuAESgqLf zbUOZ$B?IO}m`|0njXA~NsGE~!^dufW++f>Rm_%=D$Z1aZ=0lCkQXy`7jl}+YB`)zr zP_*c#dw~q5qnn`>^6Ic8dB88^oFr;J>8J+l4Y{|viUM=tl&ubmkuy#%8fvw<=Q32% zjEp9=34Xya+SvMT#rh|lL_S10e$ET4%}==QKoSS$lNlHexB~OCt+%Rlz)X5Ait# zA(#KzL5KUqfd+%Oll1C7VL=@%eEwBOpQVbV>EcQo9!Gh>r-ehBpQZ`1M8t##>5NJ6 zQp~L1&6rq7vLJqJ280pOD+Ob9R95}<>#A-1e3Js@>^~CvqVUlh<+cm67}2PUpg1e4 zrZCO9Fjid}5{6>S`H1w-e6QZZ8()il5yTXICFfC5Zj(pogUO&ZJ+MwN>m} zjY2U@57s|~nq2(~TX&IUUcnLzkN=UKF*Q^5Z|C@$Ku{3}JBfVAz$y<{V5Usp+EHOk z)MLSeQ|u7`8N8MmXCMXQxVf3Ln)NrD{@zPYNx?7=-#F}RVY|Ba$CBt67$XJ^@MAT` zu=iR-OxhS4a_8RKrP`*2SM6K|c=sz#vidB#xPx+nzCGKYwwZaWzc;wn6|VaHEM6y z+}&Aim~fX)Z51aTJ8jkRE334r!1#&v{F%+0xTa_ZWwiy1yzsbC%14rOnwEkl4aF6y z5I&>APt*sME_n7PHGoLa9jHV5mdWkGzL5RKSQo$~j$K1giGR2-J@I0IQ1d-hnmT)C zXfs_7Dzr^Ef{KjN%HB*RO9V+q#3!gkitRx!*}4TWnBfk@Q&xE#myw!a@>TC0Zx54O zzo)dnA~7ds5(1|5kPsR0m$l~feAkUl&#atfNfkL8y}R=uYRN6^2V!6e&)&4E<+KhW z)-0}`y-$fPkjzR$f~tMSmIq+REG(VF2sx|G_^u7MSL=+tq#ugyyjF!gS>QJGgvZ+NcRxmJX)|fW&6E$-Js8XY5A_FOI9+xssu5sLFlI#- z3)uGoKL$7P`QHlIWP5TxJ_DNQ#t&MEKkWYaL#Icz=QxjUGXLGNEEz=@dSO{}X4^Zk zNXfwp`<5dWnzY)=`aF>Vwk??~ZUmpCBkBsuc>iH*aCy)zM}wx))|_-3{qFKE=FZz6 z@1*LMN{!k7646N$4YKAf7$+b(bv%5OBAXmk>%LgZbbf5+nCprRVk#+8hBbgJg*a)n zSV}oFGqitB`5abKjPh&LPfwZM&gOG6tu{zrEF~S2zbSQ$r#p5 zHQmz!EQ)yA_qXw^cBRT&6N`SZ@2p2ATPzUE>=Y-L3bX;%dB+MIC4bAEwA!b^{NcVF z#bi2C;5)TY#7Z_|z(Gpp=IjPJGLAP!9I8P;`T&~MZg8;<$0Pehxi?(Dl<4BP`)_@y z3FoeDj5P-Zqn7x1Mfld(J5!jU)Eu=feNMd`q<(I(f?nql^im!zaE~h%OKbRRsiTYx zR*l$~X+m>ku~*pN$=YS7=>u94C=owWQM?Z>+5^Bx<33awo7@#GTmGm($KU=wlS;c| z{~Oy!2j?BP!TKp)SK6%aGI4SK0>?^L-t|h!T8V71F;0~ZM=Ze##-&{1Be?_hJb>)K zNu#;K|NCJrqH!T`4UO`u2VqWYDzblTo=(3GIr*@( zQ^$5N=+8qhnA81vx0jy(G0Ny*wP-*oZ|fhjOrO0yX?1CJMy4*G|2H{z3a0SbRNSBa zhB)@z1J`cTag%!BkM0OnWsqqPV}2>JDj`_YZ=G%AwNCe!MGL|18u&`V4EC&xi9 zI9y*2UlAUaXne6N^Q+?p|2Jk}lsTJPE;cMbY0(q?8_9bXC(%-`UovnN1E-|<@q>3& zLb~mXlahyXggE)>!uqH}P&2f3pvRf!A z)~`1WSg!{FTCkmN_~}s*Pmha@kOBBHRccp`)3E$6922_OmO#ypvD*sMJq+pV)16I+ ze~$}t{fz)=zlBmRPi@CIOo1at>L;W&EAs`TfHBM5xfPjidRp4V1K#nupMj-f0fLh8 za`JP);Q#!LIn4BQpXpK#)|wYs_JJ29iX;@Zg6<4G838t?Y65@m_?VNH@0#lbk5XS$ z5hC>BZ(03}3cV=_az^dLjA&?|QX6!vw{5@hS~H79E!#`nAp%3QaNyU+Z;jk~n^!=D z(zHUG5nY#>`CGl`asC@UB5cb`Vc_OGs<93aDR_5+&TSPeZ>B#@U!*+i7&@Qf`er`^ z9pJ(HOmNigqJ%|xAi#HN`3b()-i5FenTQ~iVae%2MZkFu8IWmFa@rv) z3HkX^O+y1aoZ+hM?Cd3vs7}|~W)#*haTP>5yGS z;6ZEE3?bHtT)WxW{!FiU}-}dC^29geq6&u{8yH z!HJ4DnW{*BPaH|^r~-!b4EVfX6Y~H_5C}O zQRfj(oV5RpP_PR58`NMkS;fjvMc_@?d64<0EQPT6K?MIWZS_7X##7j=pU+c^~VC8u81@Vrk#vLqij=4uu^LLPgdKE^(-wG#-~Au!mxvWt+8d&J-LN| zEp3w#d0&Px@V)rbc;6y#V%_;xhlkk|BhZ)j_Rm#mTwT(}y}qt}W5k?rUR~;oV{YYI zEdTy=*V&^Pocf+0`$im$_C1L02}iN>5B>(ZQQZ|Cv|RY|?vs_NMECcr|Jig3nYXMp zPN%i!|J3EEMI(=A<^O|0^so@$^9eVz|A5gs^_O@G^~!kcoAux%B@XWUZy`%1JEG~9 zvjTsIn-e(Xw+1AG$iJT3X#BZ{g3b0{J z47yO=gzv2LIcSW%8{HaNzs+o06bNCE{TGFl6&k(F3<|qbmaU7_mp<8>>0)ukeCoc3 z;tZOsC&@K-yo<`{lPJ{DoaWT4$LCANtK3*mX<;RG+}Ja07jD#hOvHA$YYmnW+R|r| zl6lS-J7ijOK}Ufb1=ms0`!~LQrGDu}Io2KxC^)@j0oO&`kJF^A3>&FBStYEyZ9bYD@=D+8x4>kl!5{DBU%8D-rK4^*x zxCE80Db*#TwzI+HsSIIbsxu`=YlMBqnDz?;1Eou?y&NJ%((LurlqXo&0`sU!{%Wes zA9P?OA`HeHNk@X_sSnY>0Ok4okA}dbwLrgxbw*WWXMfIQqvq1Zq_ZbOZ@&p3%@mZv)xY?+pj-e3b|^_cI>O?Wn@~DzA%D? zXm9npJZy>XREMpo=gx692ay*_6_DErs>Os@>m|7gZD|Ex)YCc*Ns^PUF3L=&`3ZW) zoWJ0e=Eol1Ps_fknZW5dj_2Q%YrrQV$!dDazahIuc%UikF~M=n;--23roAO3^7hAM zJ^eD-r=doIqR487rg<%U`)FHVU-wLiZ+35_k_`5+F@(u-;y#%j2~I^ngUwQeyQtY` zZoCTOgRFP$oT*KFtpW4bTl+s&2&ibhs=sVy%;7$qdL@Pjn_JCJJG>V_zi(w(T;i8J zpV8irL?-XX3s)7}En%!Gt8R+V=WC@wAGMVd9ygRJ<6(7-nnJ%e36pAo{+jMLxY%hb8Q^U?86H$eI@O=b_e0*p>Q0e$saqDA&-qJ{8Tb zN)fRE_l%FKt}2R;4QiGeU2j=EjPfOD0jDb&SU2EGj0j0mU*M|%wu zFU`S?{T2%*M4Gna4)%L)imdKkNMnCyfV){b*}*fyczbcO7EI(IAh>8N<&A| z`llGibYt)mtKCs`Us#Qr$&SBey=5jA-snR6FKm8@|lxMZ=yLaD|F6%l4jT)ynE`|Xlf`ix*%@p;MD_SX1n&EEByTO zk8)Rf#k<07HY;BfMW`X$>*8Ad30X*0VZJY_+_TP@-KgPOarHPcenJ!dG}sqwK7Ky8 zDt7{?9}Hh;@u0(v!cOOV!QrY9A)17)1-b0Ywe*tye=F8?nj9pSe6r_^G#T+3eRy~o zIdX#xk#NeHi?lFevu7|-J7QH>+E=IhkuWP97AOX1){h}ELM51QwLk_(s(F{w*mn>r znU*RXnIRFS080`GNTtU$Vt@wOPQQ8Kv$5b!O~kcD>!eHOz3Yg%w?6CZ$Q`c!f`Rz` zNI+tJwO|CeBF1Mt2Y9yC-13an!tM(<#1ph&c%OK}|C2bh*>KKBt-(g|%2KVs1+c$6 z7_^(r`<(6L2_dT?dquNge%!tVo~A+RcU(jt!{443Al-YhEE_vBO3RY<#Q(!4T7!O`LQVIGmZjYy{X z2*t;p#UXb>vUqt&>n>Wr+9ZA0K3|1Y!QU2+K;4THvbn^99-UG_c@wn$)TfsUv(#EG zYhpKj15(ic(b`)_MFGC;pD>KTAWBP1NQcshNQ2TLN(j;^-5?DjB}jLPilTHkgVHIY zfV8AY3exqv=X>^?-FNrh-S?bb|M0J&CZ6Yx&vjiwY!gSG*CY-@f_RmiKgW+N=`q?! z&F)OQ9!y|8_-(=TUqRlrN#9l`Zt^GrX@q>*#<_1xc6g)h7cO$kNaSI`P-ng>R{8An z^bcQ7*9_RnB;4HytY0=3lg%C-$M84Df@_=M{ls|uK!bv8HHKYYzm$%3x+vQoO#faAAo)k!Tl z4(2WFBLV3SmPB@CmEG&tw>|fC<|4%i!sf5DW2^cjb2#gRINv}=bbI9wCwWu^KH>2- z&$)$gX6z~K<*dpNAsx&uH(nF4VefFhlNbf8*4B@Cahtg6F2YIFbna8|6Ne@7#Imf$ z%IE`0FpIfS>3P?th|sgEsBm4Z&B`)l3I-l0ZKnAo`d$ejT=@22 zj?ll@BiR19%!ieYEfX>h`uyoUW~%LKh%vbD+4HGfWB?ZuX$o+%0PtPHZ|-Zv-Q}Uy zC!OQ?2UyCTRjJ`YT@K|? z?WgJn+S}VRe*b=IwKh5pG&ERM$Hm9iOU>~AdNUA9kEbdtn?S6}K?VSoqn=$cp|d+saTylf6G`37w{I0bvmVGMhA} z*>=Pep66Z!pU5ZddBGSS+IEsH>oB4Ug|NYAR*m{L+6EO zgRE8*3p#$G(ux;Di9%-pFtjMWxK$dELyUZ+=4hd(0^g zxR>{fpXf_9lVnYZcjNC@pd!svf+CWqZqfLE$4@l>ssr~S|f%~!(>FDc7DD0&AoB; zYlDY4ie~0`>afhu@#QNg#%nmXT3TB3Lk3Eh?Z6|{lf@i!Jx^|CG3z30N??<07oFbif3 z&`o|^_@0Y{Jn8|~PFNO`Q}+}3I4qTCrXQjx9vxCdi7VlD)9-lcDh08&4-fNKPzHIv zly$KVo?;60+=>-fNAPL>`1l_}n+>tJEy5eJ&0rQ7cuL9&boK+DkUzC#7&iNTPSiK7 zJXsP`e#GKVY#bJC;O9FSEZYLR{`40S1CKxH+=zS_L2}hFAlNvrk$x#$C_eK@rOOj@ z@Su>P7aeK(RI%LKaZU2H;zA!EMaSzKUApA_QMXjS}w(@NsY1OC@t)v4*FLV>+LnWuSVHUiWEx+pY4?f-atyXfQ*8)qCVSIJ?r+*q9=_rXBWT+ zr|i-{WbFN;8=@SWCHd$X%l2X-g)%Qkw{2VN$@JD))x?V{v}%tZ`_>Qk(f)_!*@gJ* zv|nHDE7LqaXpda!-yLc-!K9W7di-%$+WOAumX;&B@Pf?>)OU|{urM^U>c zE@EPl|Gt0MS*{*45{2VIkXyGT1xNL!k>Bhousx6F?5_= zrz_HY#$sapfF=xVjTBUuG+SMm|E}Z)a%g)N$Cmb?UXSvZxT9l2&YxOVVag{j?hF3Y%@rp7RO{8svl#LY%S#1PU+UkJpy4mOD-ot5`>Kp(3_rdEjmF!4L^Y4t{1a#Vq#Jb;?NL_vx>U}CX~s{VX!+Ur(^_Iq^|qjvhi+`u0c_h?n+zL>|rLUgDFSZAO8{&Y$O40)^ljldO; z@l{83DzY9Yn57IN@apkokJgMY*bx1Vk|*nK@6X@7Yk@P{bvG=?TSSUEq)WjuVghPc=S-95g)4Q zY+pQ^vM=s00SID{mV0QszmkU?~v?fB26zg#6a4xL`j77;QvD#oj zuHHWjq-HuWMP`2%5YJT*kPIi{)Vx=N&y|s^hm8p1Z1Mj4fGG>IgnLey5YLl*>#czR z-YKbZn2el@0knHYVFq|-L4ewX15YNU@ZzN*lXU5N?|}HlQSQY%X;SGj&IQP`S$cw0 zDZdI?*6VtgjMK8Ra)9z=dZ0c}{vCVm1&%(Bl1Y1n)^h9lTI*#(9WG#AqmW-a#L4x6;AKa&%S_Xy1W`ozHoPMI zBuy9uu67TQp>)tpWlT*;Zi<7U6bC;NfP9kMIq&R6_!RZ|rX%^K$!7!%q_IF^l!RV7 zJ!+}YG(s|j^bBq~Hh2!WZ{3fGJk-99cs2Tn>1LVJEOvQZ8B#GcqKvrg!C~NFmd^w0 z5~dem09*C2^*4}9Y-wU@DC!Z%yFU})tFs>wIwE{<-(l_Edqs~jKLo`w9hOw$b2ogc@297wQTQXs=ekQ zW6^wV`truV&@&~p%)cs~34HGeQWB^0-$WDz+(CthKhsV`;h zl;3fKuN61OgWp71f;RJa*oJCyWMP3{vCRfYnCuFQT++V|-hmUZ55&?=-)-cv5WC(W zvKsZV9!g%mDGiK-t2OOaYJvW<82xbI^m~r6w~VkrL?!@`N?aj&MxYWM$=VA}>HS{1 z44dZ5jF;ab#sUwvq`p2Ouph2>qtquulUw#C8cU=-wofGtM>y2mj*M7AK4d^&RXRv_$Azu0ARA^5T`Vr5KdAj9;%71AdSVj&VT6VK})7@fTvnWS9U^GC|r`V zyv1GG#6{CX;N9riq99T+cM>+6Rl&?*oZ0%AR8-!cfXf}HX4rEkY z%(SK=kM}p6KxC8D|7SO6shcJY5`-IQa|~P0O*M0PUYb4K1_8}8YBR*RSL3ENLQI*v=VOYh)g}!LpJuaU2`w3VnWS0KB1GR>v+xbqj0T?1S z$8uC9`~3TWdJ^)k@0+uo)K*W*#Q87KHv z9{jQxjDn0dqUmoZZGO3ahvJ>LpKmG?GjY>(#nR_G+*h$J=x!&oK2Cyrfw=aIUvO=*abMB zv=5ze9EZsCDdQ%(7A&g4b2*|N_X|`qL$|Pi&Xs1v*ABl*|Bh!}e{T9H;z<-}53cET zNy9#Goz%pW!0eSQ_uD$@(SKIC$f?}-U(b4{N zW@e6;$jeK{3x7i}Lek3k%VckS+oo`L!}p*B)ff4Pa-{XWd59-G@_g!%cQVqD=>!7% zPMOE$r>Jf4*U`bD{j(S6M4|#i83DLb=l?1!`vy{Yho;E?9AwL+uP%};*lc>S-cid* zPuJfOZ{w@Vyn=?TS!N3#xJQFuGMWk-)@_&A`IP-+pCG)*x^)#zV4TQ?f)}~u%cs&7 zHX535%p#_jXlq}%CQ2dhoe+O1b4dW0VXdoEqb}FFJj4Dc@&3z~H!DoJ7V&&4aN^I* z%qCGL!P}RZS|u;L<27B29V11b<0a@*_&9EF%=Ee3qg z1CLh+nBz_k7~H)^f?39s7U7x{h@k#wFChDzUWSd-DH1u7!~0n_IZRD)!%;%VocS&O zr!jZ@|b(lE~m)OW)k{Nk6~(SfJ`u} zyYY;n*uI!G#Al`gyF1b|4Gg%erh5{H8nGV~6@YM~cPr@%X-#Wbte`5@;0nl{KG*U!{gR`8%nTv*!u5xMu~mkj?l&w)dZm)sdA9 zke0YhLrvjlcOa;yqeHa&Z$wL3FQ@6w=14SQ4%8|NrAg}X-eQ(;*Gj9g50 zWpG-#sjbHin)uC(Ly{Yv>98PvG4)XeWYnrb$!a> z9)F&Ka?t5`ukZ5Xrv#|}%-VJ^Djk3r&L8ca$07r&8e8_D9r(Q{HvF*kx*bNI{P+@N zx(aK*9~6LWY#{19)`V8*cNeK=%=;od1l*SWm_9+HM_{YAeFBm>db@&#bwm}oed5_Y z|5_#u5*90~AtMVKA)EC@{o%#Me<{`tUI!cZ^=cjcM_MmTfIv_i$na=)r4n_FN*8g6 zRZ{*&=0k27hy17$!w;HN5pWx2mWQE0Tou^*Wt2R@OdKGDvZ9#E@luG~@z>XH`afuq z$>+__{}Ge_HTl?Xd+t{O$Ry3^VU(k(7-X$mI>Uk(|MKCe7j((r>JNr#jV>B{6|SMWdONLx?_>woJB#UTFi^rj{`iQtqq^EZtN{W)mMZu!KCfZeeV#(K-Z7G6+3^{eYdfS$cRtG^{e}Bw3Owx*5Qz~9ClBgI!h)Ln&pxJd~i7ltJM8cFuvXJ7=-<5E=KkrE<5 zz;3(mw(HodnI?`luDUDFXVuT*bDt4q!n$o4TzWn3u?$`+2l>IjyTR@4%K_r}Z?IAG zE-^JV2HrR9+s~Va9SqUI6a0jx;AE>X{5+OaQ|JP{m#@!8UO{yIl{3kvjNJl~o@cE%5) zNI~ra)nxh@`)6Rso^{M4iV1b#ognBn|NZrs6+kmL!FiZMbBqTj9xT7>A-5wPB(<|s zdhhCuuUE;;m8cQ029shV?mqpVmiG_L9nwHb*DGv|#KeItCu&Fv`yc;v+gcsv9ccn5 zW;W5Va$^`%%Z zo~68mp5C!DmfpI?(Z3T!ROQ6Xwrf>N3~dm2GcZxTc9*v7yD&q5_m{%8N*Vb*h&oAw zi@4$7=iL`q>De5Nsg5`{Z;;x2&5I`#dA{=PU7x`Aa4&Qm3BgTZP)l+L?R0kp+{nee zHZ3u-qf9s;53d}mXAAUWitU+DXQ;Mc=XUOHARo%j*x}L30;pmDr+em^q6x?)t;YFs ziTBsj*ND4D^SmINc&wthb&Y%;ee*HqK4owE7t?kADSWXzugPKsdJ~pP9)xl~vKgJ0BT1h6*cYn8f&D5LFh}f^6w?c0y z=rn_nB4s>-;vCwWqnjviewTTRO2`zU_+BB8AS?qb#V1t^pKtr@kni>jPO5#L@zbmYht#$kCYafo}9dKJ%9d^!#T*+>MjmeM|3AZIYh|bYJE0^$5WA&bsctd z=Xy$d1Y931`{{4{?kfn|ek1?;0k_rcE!}eWyg5y+o6A&gk42GBV@7!h2+D7Ucf?41Ugq-Jzm!NVw(| z#Xd0b<5t~5hNES8l1`v|2z!j^Uv?8zA~s*k(5EmsvWKzK^`zL+=D>@#xFKYMbl&p%*bwFS{C-fj2WS!xrwArF zL2Yg?^z84vud5Wo3%XIJ=b*$@>2GTg1Ap}GKA1#5?WU3kIwudO54^z@cRyc#f4cdO z2QH!JdThIVxg5&NF>Z6;p0xON41^yK-@@Wvz7=@%DebUMOXO_QsZ}uWcqDfK8n0!cYGeKatmQ3eJ2=N|?jjzERQN&l5J_#qW+sx@V$#OE((=V>tYn%Le>dYjA@jH9 zqc@SD>_9eCM&s1J;b58D?W;}0OuEkZ>Ds_Y`^!|_-}to8SZYsm)9-jR7?i?Jaogk9 z>5=XLNMc2yvrnjkOs@NkrRiV6t4QHF&V0mysUCK*C!%AjK0FM~}9{eg~LPEm3l7{SO)U4S3 za#g6YYCAXl`wqHm34`xNRaKoVXn7Urm^2%Iac!dpGdqKb_Ih4wgdQbF25(;3(Moyy z)+(Q~87H~vsNK@ER^i}mbX>#P$#(mxS;zC^ru#?Dw)kuw9cuV5H&>RfT($kET`FzL zd_`*Nq^WkQs@&nmxohBb!(W+{E4h1!$t=lyqTRL0!^63T2e+J_*w&&Oxc`y8{{N zcK%>H!q%*KL_{kx0oss{?KA>x1U>$x6lhHrmCQYc7mtP-P4)5PF6L%)jBSNxH7^tI26yT7ZF8vOpy;^An8Fbi z8h@ip44GOGs-q>M$jDn9+S5ntxIFm84AgTA1w=GL{UE>Wg!J^;BK_T}IBB?``gfM> z2AX_S`v3gMG$h3Y@#t4rBtsfqwe4(65fSG~xBT z4@yWRk@0rOj}8X8nb_Dr?>42t-!fE}0tbyjhvlz{#~2HBe(0i&cb>n zH3#}d5eOyu!phq{kbousc@L8CI)s0JW9@^O_|7e(KPZ0KohGGOL}YI#c~RsmO&J6V)gy{BZNPVuVKJG;=82XjEu*34jp4)Fq{K%-O*9kZ zuUFdzX_tdA-Bs}~F4M*>x?xAp!v4OlJ;EbcxXNWg#FnU=SViHIG)X*5&{6-~JxFM} zG7x6#Sj`*~TKLi#EE|S|9R7%ZwEO-6C5uSwt-}>jHg0Zk`l8Wq&qK)Fk68swYa^5F zgxC)ZRPq=}rRSoR5qd(!2Jd^ee*SEJ{u`l$F9FX zoO6Y0nj23`JwjBDG&fz){QRXLVZq7aGI?dyAIr)L zlH3uZZ8tGTfNHJX9}UE~8^pve8L3)SSwS_-HIz{;jie8pK3)im$C{h=@wovtXA#xT zeh1+woZJ*=!wvZV1;+aydYb(o+Ua0L1@L{){-l#Fer~i+J2xHw6_>~ODvt`( zpV0F%j1;JeQ`}<>7JpUaFan=xyO3{!0u32(i6#GT1e*{OD006%1D)55LQ5?aB^aHu z%xbk=SGr}NPLlFTxuDj)d#TK`43v~kDLneisiR;mpGq>|ZPLFM3iKVREA6;+o|UiK+gY?h6V|FGM70jyNAgrnNBqu*yx$u^jn9n3 z!gp4`k|PS$nk65<^M(m)XFa8l zYz?8Q)`u5xMHT2*E*F@7>2y2??aoYgs!cAy0vNlAp0gUiB-U-8%QN78Z|>!GZASmS zdR7(166bmx*^HP~zaFNJTcV%wTscb`ZfqksaLc7Iyfsm~MJ10A_u3R_^xSYc-`rp8 z16v%WbGU^?Gew=6>n~(KDycjEYAS7j^yZ>f2nd+#u4?Hd2buMF{D<-uj*ZD>lmX0j9ygWLUJGQWFI))T_oxH$S`5 z4-Pi(_|Ff!q$1&uWjTOBIUeorXqwdprfY8;pt7iTdOc!A^g z@3+g!7Hcg}Qlh}!i&IW(liY;hEmrJJ-<9mkX1IwhkOWh>1>QMuZcDC({-;+s-Si41 zZp*pjk)d)OQz&X*yjg}KZBrbNkji4ldqHib_Oh$g5=(Y)lVzXa^5-zGvp=_?mR5S> zn3UW$ny(NA#tgUKJ|W3~ge}T(jVkQWRL_-cxLzB!-kxzRfM-he=%U5gB(Ve=TtJP}|%Im-b`n^cMHo z@o57m9fJPw1FaX}V3CQ5OSQ1{@ap;jAD3+YXXAzq?msbOm+=CjE;Ii9yUI*=neyf}?2t%mO+p^=L(G!H5vK8Jgdx(eUv$VhysLI! z6%Y(KZO{7$QL<^T<-+SNO}~O*wuiHiIvi^`axX|kPUQ;2=41vP_MF!(6f!0B4r4zA zFut%`#d>Z7!H}|sGuDi9NQop|#~&Z7&AFUXYRXr^N=q1SRKkW_Pjk(Qi}J_Bp5o)M z++er7mkBY9srhHbXbs6Dp+!#zBh_?)A3Vm_*PrICJ_gelh)Cbk<4Bx#KPIZ+B0vRg zPRk`?_0GY_8YXQ9zJfRPE52j;Bmt|Uf-pj&>vvf;jv*M~n(e7Sq}j;O(3!$^oyS3Y z{F-B2kcSWMRl3EOWX4Ci;Q6(%U*t|MlY;RfVdybxex`OT&Ub4@m!|9IG#Nkksl@3x zbh#1Exe_P&+}+@H=qWUQc^Moa+F8p&B%l4$qh3#o2urV6^w^l#%ae%}$@|j$Hdw?u z==G7wU(9$Ox!*xKB-Ub+(YK?=k%Iiat;vtgL-ZV-DK+5_C5@Tdb}SUV#UXqAf#-Q=_qH#yugcKvsLLd{I|MpDM*yG zrXDwV!{F<)^;UY9Z#h#l` zV|3UtrvWepj5p#|B%3%*bvXSA-M z+_mpvRCuxx#wvG0e4$Na@`^}8bFUI}>d=p(kO;A#rz8*l_^jEfi|@kjBO(tmbNQ!q z(N>GE)|BFLdqODXOQNj{Jty4q!4aBRCG>vK-NN z>#A)qQ8-@uV(7l%4A>(h_9&7smA*_d;%IK>Xrf>D0wT-Lbg(PBL&GGCj&~hLO?OR; zCHf4if#dV-rk9S>2jday%uYv<^-JidGAf9vu5T0N=3QZiF`bS#d7OR>>TqoGlWrGh z`2Ki8(OqGn<9)P`bNH1tI2dQQ-EuCVu|Ax#=n{i~#RtzCS(nmyEV~~4Ovj6^QtM(7 zgGc2@II=_6?d!1Db^4Y`;63xA4tM5}5sz_mt`3@$+%g0SjSuUjf=^hg$M5xNymMGi z_Ks@Vcbbmq37!Mu(Fqmpi2sW#oIk(Hh(CT^*QFR#T+t-Hf}B6zBMPm-`yzmTyuHba z9#sn#p9kaFZ1(@wboPI42zEe>D%0e+bEmVt{ksrkNV#enkeEh9{?hZgDqPIfC|5Yg ziJB4tTMozhc^ZrbO4{M!JwP%WWU+{YnMt+!z9Q&Hgmlvpc zE*oDC)Z!;$J|=3O3DX01Mo*rmr$5Z9C6bUKsGv42@LBQHp>`&Tpdgn1zOPB@`~(YE z0rn?VZ+eMIh}T*7-)y{QVZ?ruA-qtP*h>W`P3FBA(mic6efD7G=E zO&p>HR1tnBr4JO|bXZa&L$z=xuEBaDf?N=*%B@!f>W@A@Vt9}uq8(dEwuq6h{}el0 z@gc4f7B}Nm9vHsxTa6Zp4OU)}JD+(i$1XO^;heAK=@|?0X?<*5AJ^54v-!<%r@0buE1!VYApgvC9q zFOMA3S%N_@k^zXD`1%SdS!TLgW{&3sPhft)X8p<%dEq4Q8z-$aby9B7Jn@l|6OP&Y zf&F4XP)L@*Agz>0lRbP~WfLg|L$pedHNl%GVrN4roiQ~a-7~0Ac;euE%m9m?r3U!A? z3ELob(_e{7m82qP4|2bg`++~W#Zc#)?M}VYeS>L-I(%g5tZao`S)_ie}XZ6=C7fbMK zuJ7h0k}Ax=%2oPL8o&9OVG72~%4&C+SC%92eAgKKRWyQmao9PG8{GA~BN`wz>xYZ( z&2-@TdL#Q8{Qs3Vbs1b>;`cZQlK#$j94w{nS5@sV2VNXp&>{41EM4SY92qm=PW*xb zN!>_pQOH`6QKTlNY8Jdy`XQ|Ots#_ zrV|X>22f4+@Yd;crFH2DQQ< zhfVK4A5NpZ)xs_dTEc)~#kb({_PFZ$*mT3RD_$ARXvven5&GW@@`xu5?t+15YqP50 zj?y1^+7$>UN;DA#Q>_-65JaiI0Iua$uMg`lk}Z?M2U zgeQrT*5y|<#|MEIM8l&Z1won`*>|3tn)aW=sGV6a1$ldiZlK+y-RUX?)f7^H-@-XCJjs&|)cxOyzhTWK?yJ6Da&Liy41uuW~QUi(jy1n>82RJH;E$8 z%hBRVi{)!xN-|GGj+vP~y`Ay}^|uYDZ?fb`1M*K9BMcGOTT-QOEWNRh zyL>ao|2j4epGh2;>K-1>+%04;5Ro15jhxx#8N(NtApfPItn69!RH9tE?2dCJe%xbH zv_!q@Qbs#@(f<1!e4)L9^Q2UvonZYkApZ+0hUIMJ(1t>btiSVwV1s{37f>B4sT-DVW+At)gx5RvWM#UI{28 zJc*M!R+|Wtq?^|76**{idR=2yHYzo`@dMY=8O7hIY-ccaAA>M=%-SU3aR2!}EWBiS zsfel_@m)@7^Dc@J<4FO?yN7`lJkNg{>gB1iqQmg<*Vx+{dU9b9=SW^1W5Eqh3#=7kLM_Y%%(*c_>dqHw7jfc!VbvO2B@{2R zQfN?q7bml~%KaCqIaFCf2&rxphdQeH-(pfOU=gIJ`o+HeNgPiBfqF>!AIA@WrU))A z(=fu@v2@HIj9UxS685VMW{njrUYFBx$x4+AQQ4{cUv#IZNBX_s9x+WsgI50G$M?O` z1P||#)N8s)xk~jg=e|=%L3i*&{Pf5V`q6-m(6>vNf?i7yY z_zKx{Ew~dNE>4TXfqgm8Lh(Ol8ltr)-<-}si4%w`KbRIDIhFkU^jsTdIX}qJ&YTwq zw`Ag0*Rhh?>pBU>``w9;Dmv0gyjK*_;+4dLOXXd?V(X8L{3s=jf%0J`%>4k+(%Bib!B`y~HQ&LcuFOf9~`Cr{@_|^ab diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..e410cf70d136a0c48958247df3410664b6e7827e GIT binary patch literal 47974 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�n#u`volTGxSs9}RQfgJF<;8<;k%~+ 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 R*dsz1P!1X~ bool { agent.modes.iter().any(|m| m.id == mode) } +/// Whether `effort` may be asked of `agent`: one of its levels, or — for an +/// agent that lists none of its own, its levels differing by model +/// (`ModelEntry.efforts`) — any, which the agent itself checks. pub(crate) fn is_effort(agent: &AgentInfo, effort: &str) -> bool { - agent.efforts.iter().any(|e| e.id == effort) + agent.efforts.is_empty() || agent.efforts.iter().any(|e| e.id == effort) } diff --git a/crates/bridge-core/tests/cards.rs b/crates/bridge-core/tests/cards.rs index 82705036..cbc5c74c 100644 --- a/crates/bridge-core/tests/cards.rs +++ b/crates/bridge-core/tests/cards.rs @@ -300,8 +300,12 @@ fn set_option_is_checked_against_the_catalog_then_confirmed() { rig.host_reply(&id, HostMessage::Error { message: "no".into() }); assert_eq!(confirmed(&mut rig), ["low"], "a refused change confirms the effort in force"); + // Beta lists no levels of its own: its levels are per model, so the + // agent judges one — and its refusal confirms nothing new. let b = rig.ready_session("beta"); - assert_eq!(set_option(&mut rig, &b, "effort", "low"), None, "beta has no efforts"); + let id = set_option(&mut rig, &b, "effort", "max").expect("left to the agent"); + rig.host_reply(&id, HostMessage::Error { message: "not a level of this model".into() }); + assert!(!confirmed(&mut rig).contains(&"max".to_string())); } #[test] @@ -499,7 +503,7 @@ fn models_are_listed_or_the_phone_is_told_why_not() { rig.host_up(); rig.send(json!({"type":"models-request","agent":"alpha"})); let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::ListModels { .. })); - rig.host_reply(&id, HostMessage::Models { models: vec![protocol::events::ModelEntry { id: "m1".into(), label: None, provider: None }], default_model: Some("m1".into()) }); + rig.host_reply(&id, HostMessage::Models { models: vec![protocol::events::ModelEntry { id: "m1".into(), ..Default::default() }], default_model: Some("m1".into()) }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::Models(x) if x.models.len() == 1 && x.error.is_none() && x.default_model.as_deref() == Some("m1")))); diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index 8a45bedc..43de3e5e 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1367,7 +1367,7 @@ mod tests { .map(|id| ModelEntry { id: (*id).to_string(), label: Some(id.to_uppercase()), - provider: None, + ..Default::default() }) .collect(), default_model: default.map(str::to_string), @@ -1607,7 +1607,7 @@ mod tests { "pk", &ModelsMsg { agent: "opencode".into(), - models: vec![ModelEntry { id: "gpt".into(), label: None, provider: None }], + models: vec![ModelEntry { id: "gpt".into(), ..Default::default() }], default_model: None, error: None, }, diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 255c5f03..f0e671e2 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -330,12 +330,21 @@ pub struct UniffiModelEntry { pub provider: Option, /// How many tokens the model takes in, when known. pub context_window: Option, + /// The model's own reasoning levels, for an agent whose levels differ by + /// model; empty: the agent's. + pub efforts: Vec, } fn to_uniffi_model_entries(models: &[protocol::events::ModelEntry]) -> Vec { models .iter() - .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: m.provider.clone(), context_window: None }) + .map(|m| UniffiModelEntry { + id: m.id.clone(), + label: m.label.clone(), + provider: m.provider.clone(), + context_window: None, + efforts: m.efforts.iter().map(to_uniffi_option_choice).collect(), + }) .collect() } @@ -356,6 +365,7 @@ fn to_uniffi_profile_model(profile: &str, m: &protocol::common::ProviderModel) - None => profile.to_string(), }), context_window: m.context_window, + efforts: Vec::new(), } } diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index a88508d4..6839dc77 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -1310,7 +1310,7 @@ mod tests { models: vec![protocol::events::ModelEntry { id: "sonnet".into(), label: Some("Sonnet".into()), - provider: None, + ..Default::default() }], default_model: Some("sonnet".into()), error: None, diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 5438f6eb..a6c74f3b 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -129,6 +129,7 @@ { "type": "usage", "sessionId": "s", "usage": { "available": false, "fetchedAt": "t" } }, { "type": "gsd-state", "sessionId": "s", "gsd": { "installed": true, "available": true, "hasGit": true, "situation": "x", "summary": "y", "milestone": null, "currentPhase": "2", "totalPhases": 5, "percent": 40.0, "phases": [{ "number": "1", "name": "plan", "diskStatus": "ok", "plans": 2, "summaries": 1, "recentlyTouched": true, "action": null, "command": null, "planCount": 2, "needsYou": null }], "actions": [{ "id": "a", "label": "go", "command": "gsd go", "recommended": true }], "recommended": "a", "paused": false, "blockers": [], "verifyFailed": false, "execution": { "phase": "2", "plansTotal": 3, "plansDone": 1, "currentPlan": "p2", "tasksDone": 4, "tasksTotal": null, "lastTask": "t" } } }, { "type": "models", "agent": "claude-code", "models": [{ "id": "m1", "label": "M1" }, { "id": "m2" }, { "id": "Z.ai/glm-5.3-flash", "label": "glm-5.3-flash", "provider": "Z.ai" }], "defaultModel": "m1" }, + { "type": "models", "agent": "opencode", "models": [{ "id": "ccr/OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "CCR · OpenCode Go", "efforts": [{ "id": "default", "label": "Default" }, { "id": "high", "label": "High" }, { "id": "max", "label": "Max" }] }], "defaultModel": "ccr/OpenCode Go/deepseek-v4.1-flash" }, { "type": "models", "agent": "opencode", "models": [], "error": "sdk offline" }, { "type": "commands", "sessionId": "s", "commands": [{ "name": "compact", "description": "Clear history but keep a summary", "argumentHint": "" }, { "name": "commit-commands:commit" }] }, { "type": "commands", "sessionId": "s", "commands": [], "error": "The session is not running." }, diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 80ba44e5..b7d0bedc 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use super::capabilities::BridgeHostKind; use super::common::{ - AgentDescriptor, AvailablePlugin, CredentialStatus, GsdState, InstalledPlugin, McpAction, McpServerInfo, OutputEntry, + AgentDescriptor, AvailablePlugin, CredentialStatus, GsdState, InstalledPlugin, McpAction, McpServerInfo, OptionChoice, OutputEntry, SessionMcpServer, PluginAction, PluginMarketplace, ProviderProfileInfo, RemoteSessionInfo, SessionOption, UsageData, @@ -208,7 +208,7 @@ pub struct GsdStateMsg { pub gsd: GsdState, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] pub struct ModelEntry { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -217,6 +217,11 @@ pub struct ModelEntry { /// says: the same model can be offered by more than one provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, + /// The reasoning levels of this model, for an agent whose levels differ + /// by model (its catalog entry lists none of its own). Empty: the + /// agent's own levels, if any. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub efforts: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 2d90538d..1a017f62 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -269,9 +269,12 @@ wrong provider). ### `models` `models-request {agent}` → `models {agent, models[], defaultModel?, error?}`. -Each model is `{id, label?, provider?}`: `provider` names who serves it (an +Each model is `{id, label?, provider?, efforts?}`: `provider` names who serves it (an OpenCode provider, a router's channel such as `OpenCode Go`), since the same -model can be offered by more than one. An empty list always comes with an `error` saying why, so the phone can tell +model can be offered by more than one. `efforts` are the model's own +reasoning levels, from an agent whose levels differ by model: its catalog +entry lists no `efforts`, the phone offers the session model's instead, and +the bridge leaves checking a level to the agent. An empty list always comes with an `error` saying why, so the phone can tell "no answer yet" from a lost message. Models are correlated by the machine that sent them (the event author), never by a payload field. diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts index 5941dfab..d0218688 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import type { Event, OpencodeClient, Provider, Session } from '@opencode-ai/sdk/v2/client'; -import { OpenCodeDriver, pickDefaultModel, toQuestionAnswers } from '../driver'; +import { OpenCodeDriver, reasoningLevels, pickDefaultModel, toQuestionAnswers } from '../driver'; import type { StartSession } from '../../../sdk/types'; import { recordingContext, type Handlers } from '../../../sdk/__tests__/context'; @@ -409,7 +409,7 @@ describe('OpenCode resume', () => { }); describe('OpenCode options', () => { - it('accepts its modes and provider/model ids, refuses effort', async () => { + it('accepts its modes and provider/model ids', async () => { const ctx = recordingContext(); const session = OpenCodeDriver.withClient(clientWith([])).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp' }, ctx); await session.setOption('mode', 'default'); @@ -417,7 +417,69 @@ describe('OpenCode options', () => { await session.setOption('model', 'anthropic/claude-sonnet-5'); await expect(session.setOption('mode', 'acceptEdits')).rejects.toThrow(/no mode/); await expect(session.setOption('model', 'sonnet')).rejects.toThrow(/provider\/model/); - await expect(session.setOption('effort', 'high')).rejects.toThrow(/no effort/); + await session.end(); + }); +}); + +describe('OpenCode reasoning levels', () => { + const catalog = { + providers: vi.fn().mockResolvedValue({ + data: { + providers: [{ + id: 'ccr', name: 'CCR', + models: { + 'OpenCode Go/deepseek-v4.1-flash': { id: 'OpenCode Go/deepseek-v4.1-flash', name: 'deepseek-v4.1-flash', variants: { low: {}, high: {}, max: {} }, cost: { input: 0, output: 0 }, status: 'active' }, + plain: { id: 'plain', name: 'plain', variants: {}, cost: { input: 0, output: 0 }, status: 'active' }, + }, + }], + default: {}, + }, + error: undefined, + }), + get: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + }; + + it("are the model's own, default first", () => { + expect(reasoningLevels({ low: {}, xhigh: {}, max: {} })).toEqual([ + { id: 'default', label: 'Default' }, + { id: 'low', label: 'Low' }, + { id: 'xhigh', label: 'Extra high' }, + { id: 'max', label: 'Max' }, + ]); + expect(reasoningLevels({})).toEqual([]); + expect(reasoningLevels(undefined)).toEqual([]); + }); + + it('are listed with each model, asked of it on every prompt, and checked against it', async () => { + const client = Object.assign(clientWith([]), { config: catalog }); + const promptAsync = vi.fn().mockResolvedValue({ data: undefined, error: undefined }); + (client.session as unknown as { promptAsync: unknown }).promptAsync = promptAsync; + const driver = OpenCodeDriver.withClient(client); + const listed = (await driver.listModels()).models; + expect(listed.find((m) => m.id === 'ccr/OpenCode Go/deepseek-v4.1-flash')?.efforts?.map((e) => e.id)).toEqual(['default', 'low', 'high', 'max']); + expect(listed.find((m) => m.id === 'ccr/plain')).not.toHaveProperty('efforts'); + + const ctx = recordingContext(); + const session = driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', model: 'ccr/OpenCode Go/deepseek-v4.1-flash' }, ctx); + await ctx.waitFor((e) => e.type === 'ready'); + await session.setOption('effort', 'max'); + session.prompt('think hard'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(1); + expect(promptAsync.mock.calls[0]![0].variant).toBe('max'); + await expect(session.setOption('effort', 'ultra')).rejects.toThrow(/not a reasoning level/); + + // A model without levels drops the level, and refuses one. + await session.setOption('model', 'ccr/plain'); + session.prompt('quick'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(2); + expect(promptAsync.mock.calls[1]![0]).not.toHaveProperty('variant'); + await expect(session.setOption('effort', 'high')).rejects.toThrow(/has no reasoning levels/); + + await session.setOption('model', 'ccr/OpenCode Go/deepseek-v4.1-flash'); + await session.setOption('effort', 'default'); + session.prompt('as usual'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(3); + expect(promptAsync.mock.calls[2]![0]).not.toHaveProperty('variant'); await session.end(); }); }); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 47046cff..12531a25 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -42,6 +42,7 @@ import { newTranslateContext } from '../../sdk/transcript'; import type { AgentInfo, ModelEntry, + OptionChoice, PermissionOption, ProviderBinding, QuestionSpec, @@ -205,6 +206,22 @@ export function pickDefaultModel( /** Why OpenCode cannot run `model`: none of its providers offers it. When * the model list could not be fetched, nothing is refused. */ +/** The variant OpenCode reads as "none": the model's own settings. */ +const DEFAULT_VARIANT = 'default'; + +/** How a variant reads in the effort picker. */ +const VARIANT_LABELS: Record = { xhigh: 'Extra high', none: 'None' }; + +/** A model's reasoning levels, from its OpenCode variants (`low`, `high`, + * `max`…, set by OpenCode from what the model is): its default first. A + * model without variants has none. */ +export function reasoningLevels(variants: Record | undefined): OptionChoice[] { + const ids = Object.keys(variants ?? {}).filter((id) => id !== DEFAULT_VARIANT); + if (ids.length === 0) return []; + const label = (id: string) => VARIANT_LABELS[id] ?? id.charAt(0).toUpperCase() + id.slice(1); + return [{ id: DEFAULT_VARIANT, label: 'Default' }, ...ids.map((id) => ({ id, label: label(id) }))]; +} + /** The name OpenCode gives a session it has not titled yet * ("New session - "; "Child session - …" for a sub-agent's). */ const PLACEHOLDER_TITLE = /^(New|Child) session - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -308,6 +325,9 @@ export class OpenCodeSession implements DriverSession { /** The title last reported for the session. */ private title: string | undefined; private model?: { providerID: string; modelID: string }; + /** The reasoning level ("variant") prompts ask of the model; none = the + * model's default. */ + private variant: string | undefined; /** messageID -> role, seeded from message.updated events, so a later * message.part.updated for the same messageID can be tagged — Part itself @@ -373,6 +393,9 @@ export class OpenCodeSession implements DriverSession { this.mode = params.mode ?? DEFAULT_MODE; this.catalog = options.catalog ?? (async () => ({ models: [] })); this.model = splitModelId(params.model ?? undefined); + // A level the model lacks is ignored by OpenCode: the session starts on + // the model's default rather than failing. + this.variant = params.effort && params.effort !== DEFAULT_VARIANT ? params.effort : undefined; this.ready = this.init(clientPromise, params); // init() reports its own failure as `ended`; nothing else awaits this // rejection except prompt/interrupt, which catch it themselves. @@ -875,6 +898,7 @@ export class OpenCodeSession implements DriverSession { parts: [{ type: 'text', text }], ...(this.model ? { model: this.model } : {}), ...this.agentField(), + ...this.variantField(), }); if (error) this.deliver({ type: 'error', content: `OpenCode prompt failed: ${JSON.stringify(error)}` }); }) @@ -918,7 +942,7 @@ export class OpenCodeSession implements DriverSession { private runCommand(client: OpencodeClient, sessionID: string, name: string, args: string): void { const model = this.model ? `${this.model.providerID}/${this.model.modelID}` : undefined; client.session - .command({ sessionID, directory: this.cwd, command: name, arguments: args, ...(model ? { model } : {}), ...this.agentField() }) + .command({ sessionID, directory: this.cwd, command: name, arguments: args, ...(model ? { model } : {}), ...this.agentField(), ...this.variantField() }) .then(({ error }) => { if (error) this.deliver({ type: 'error', content: `OpenCode /${name} failed: ${JSON.stringify(error)}` }); }) @@ -970,16 +994,33 @@ export class OpenCodeSession implements DriverSession { // Model selection is per prompt in OpenCode; the next prompt uses it. const split = splitModelId(value); if (!split) throw new Error(`'${value}' is not an OpenCode provider/model id`); - const refused = unsupportedModelReason(value, (await this.catalog()).models); + const models = (await this.catalog()).models; + const refused = unsupportedModelReason(value, models); if (refused) throw new Error(refused); this.model = split; + // A level the new model lacks would be ignored; drop it. + if (this.variant && !models.find((m) => m.id === value)?.efforts?.some((e) => e.id === this.variant)) this.variant = undefined; + return; + } + case 'effort': { + // Levels are the model's own (its OpenCode variants). + const { models, defaultModel } = await this.catalog(); + const id = this.model ? `${this.model.providerID}/${this.model.modelID}` : defaultModel; + const levels = models.find((m) => m.id === id)?.efforts ?? []; + if (!levels.some((e) => e.id === value)) { + throw new Error(levels.length > 0 ? `'${value}' is not a reasoning level of ${id}` : `${id ?? 'This model'} has no reasoning levels`); + } + this.variant = value === DEFAULT_VARIANT ? undefined : value; return; } - case 'effort': - throw new Error('OpenCode has no effort levels'); } } + /** The reasoning level a prompt or command asks for, if any. */ + private variantField(): { variant?: string } { + return this.variant ? { variant: this.variant } : {}; + } + async interrupt(): Promise { try { const { client, session } = await this.ready; @@ -1303,7 +1344,8 @@ export class OpenCodeDriver implements Driver { const id = `${provider.id}/${model.id}`; const name = provider.name || provider.id; const group = profile ? profileModelGroup(name, profile.models.find((m) => m.id === model.id)) : name; - models.push({ id, label: model.name, provider: group }); + const efforts = reasoningLevels(model.variants); + models.push({ id, label: model.name, provider: group, ...(efforts.length > 0 ? { efforts } : {}) }); const window = model.limit?.context ?? 0; if (window > 0) contextLimits[id] = window; } diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index 84c075c4..663ea62d 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -981,6 +981,12 @@ export type ModelEntry_Deserialize = { * says: the same model can be offered by more than one provider. */ provider?: string | null, + /** + * The reasoning levels of this model, for an agent whose levels differ + * by model (its catalog entry lists none of its own). Empty: the + * agent's own levels, if any. + */ + efforts?: OptionChoice_Deserialize[], }; export type ModelEntry_Serialize = { @@ -991,6 +997,12 @@ export type ModelEntry_Serialize = { * says: the same model can be offered by more than one provider. */ provider?: string | null, + /** + * The reasoning levels of this model, for an agent whose levels differ + * by model (its catalog entry lists none of its own). Empty: the + * agent's own levels, if any. + */ + efforts?: OptionChoice_Serialize[], }; /** Session lifecycle notices a client shows as a marker line. */