From e64a850f57643114466362e62818bfff6f2b772f Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 20:08:17 +0100 Subject: [PATCH 1/6] feat: enhance feature dialogs with planning mode tooltips - Integrated Tooltip components into AddFeatureDialog, EditFeatureDialog, and MassEditDialog to provide user guidance on planning mode availability. - Updated the rendering logic for planning mode selection to conditionally display tooltips when planning modes are not supported. - Improved user experience by clarifying the conditions under which planning modes can be utilized. --- .../board-view/dialogs/add-feature-dialog.tsx | 92 ++++++++++++------- .../dialogs/edit-feature-dialog.tsx | 92 ++++++++++++------- .../board-view/dialogs/mass-edit-dialog.tsx | 86 ++++++++++++----- 3 files changed, 179 insertions(+), 91 deletions(-) diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index bab34522..c502e2b0 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -53,6 +53,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { getAncestors, formatAncestorContextForPrompt, @@ -492,23 +493,44 @@ export function AddFeatureDialog({ /> -
- {modelSupportsPlanningMode && ( -
- +
+
+ + {modelSupportsPlanningMode ? ( -
- )} + ) : ( + + + +
+ {}} + testIdPrefix="add-feature-planning" + compact + disabled + /> +
+
+ +

Planning modes are only available for Claude Provider

+
+
+
+ )} +
@@ -526,28 +548,32 @@ export function AddFeatureDialog({ Run tests
- {modelSupportsPlanningMode && ( -
- setRequirePlanApproval(!!checked)} - disabled={planningMode === 'skip' || planningMode === 'lite'} - data-testid="add-feature-require-approval-checkbox" - /> - -
- )} +
+ setRequirePlanApproval(!!checked)} + disabled={ + !modelSupportsPlanningMode || + planningMode === 'skip' || + planningMode === 'lite' + } + data-testid="add-feature-require-approval-checkbox" + /> + +
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index addd7e83..7b4ac693 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -52,6 +52,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { DependencyTreeDialog } from './dependency-tree-dialog'; import { isClaudeModel, supportsReasoningEffort } from '@automaker/types'; @@ -516,23 +517,44 @@ export function EditFeatureDialog({ /> -
- {modelSupportsPlanningMode && ( -
- +
+
+ + {modelSupportsPlanningMode ? ( -
- )} + ) : ( + + + +
+ {}} + testIdPrefix="edit-feature-planning" + compact + disabled + /> +
+
+ +

Planning modes are only available for Claude Provider

+
+
+
+ )} +
@@ -552,28 +574,32 @@ export function EditFeatureDialog({ Run tests
- {modelSupportsPlanningMode && ( -
- setRequirePlanApproval(!!checked)} - disabled={planningMode === 'skip' || planningMode === 'lite'} - data-testid="edit-feature-require-approval-checkbox" - /> - -
- )} +
+ setRequirePlanApproval(!!checked)} + disabled={ + !modelSupportsPlanningMode || + planningMode === 'skip' || + planningMode === 'lite' + } + data-testid="edit-feature-require-approval-checkbox" + /> + +
diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx index ce391330..30042a4c 100644 --- a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -15,8 +15,9 @@ import { modelSupportsThinking } from '@/lib/utils'; import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store'; import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; -import { isCursorModel, type PhaseModelEntry } from '@automaker/types'; +import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types'; import { cn } from '@/lib/utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; interface MassEditDialogProps { open: boolean; @@ -167,6 +168,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas const hasAnyApply = Object.values(applyState).some(Boolean); const isCurrentModelCursor = isCursorModel(model); const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model); + const modelSupportsPlanningMode = isClaudeModel(model); return ( !open && onClose()}> @@ -205,30 +207,64 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
{/* Planning Mode */} - - setApplyState((prev) => ({ - ...prev, - planningMode: apply, - requirePlanApproval: apply, - })) - } - > - { - setPlanningMode(newMode); - // Auto-suggest approval based on mode, but user can override - setRequirePlanApproval(newMode === 'spec' || newMode === 'full'); - }} - requireApproval={requirePlanApproval} - onRequireApprovalChange={setRequirePlanApproval} - testIdPrefix="mass-edit-planning" - /> - + {modelSupportsPlanningMode ? ( + + setApplyState((prev) => ({ + ...prev, + planningMode: apply, + requirePlanApproval: apply, + })) + } + > + { + setPlanningMode(newMode); + // Auto-suggest approval based on mode, but user can override + setRequirePlanApproval(newMode === 'spec' || newMode === 'full'); + }} + requireApproval={requirePlanApproval} + onRequireApprovalChange={setRequirePlanApproval} + testIdPrefix="mass-edit-planning" + /> + + ) : ( + + + +
+
+
+ + +
+
+
+ {}} + testIdPrefix="mass-edit-planning" + disabled + /> +
+
+
+ +

Planning modes are only available for Claude Provider

+
+
+
+ )} {/* Priority */} Date: Sun, 11 Jan 2026 00:56:25 +0530 Subject: [PATCH 2/6] feat: Add OpenCode provider integration with official brand icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit integrates OpenCode as a new AI provider and updates all provider icons with their official brand colors for better visual recognition. **OpenCode Provider Integration:** - Add OpencodeProvider class with CLI-based execution - Support for OpenCode native models (opencode/) and Bedrock models - Proper event normalization for OpenCode streaming format - Correct CLI arguments: --format json (not stream-json) - Event structure: type, part.text, sessionID fields **Provider Icons:** - Add official OpenCode icon (white square frame from opencode.ai) - Add DeepSeek icon (blue whale #4D6BFE) - Add Qwen icon (purple gradient #6336E7 → #6F69F7) - Add Amazon Nova icon (AWS orange #FF9900) - Add Mistral icon (rainbow gradient gold→red) - Add Meta icon (blue #1877F2) - Update existing icons with brand colors: * Claude: #d97757 (terra cotta) * OpenAI/Codex: #74aa9c (teal-green) * Cursor: #5E9EFF (bright blue) **Settings UI Updates:** - Update settings navigation to show OpenCode icon - Update model configuration to use provider-specific icons - Differentiate between OpenCode free models and Bedrock-hosted models - All AI models now display their official brand logos **Model Resolution:** - Add isOpencodeModel() function to detect OpenCode models - Support patterns: opencode/, opencode-*, amazon-bedrock/* - Update getProviderFromModel to recognize opencode provider Note: Some unit tests in opencode-provider.test.ts need updating to match the new event structure and CLI argument format. --- .../server/src/providers/opencode-provider.ts | 291 +++++++++--------- apps/ui/src/components/ui/provider-icon.tsx | 183 ++++++++++- .../cli-status/opencode-cli-status.tsx | 5 +- .../views/settings-view/config/navigation.ts | 5 +- .../opencode-model-configuration.tsx | 27 +- libs/model-resolver/src/resolver.ts | 8 + 6 files changed, 352 insertions(+), 167 deletions(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index b54592c3..e20c3593 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -41,95 +41,103 @@ export interface OpenCodeAuthStatus { /** * Base interface for all OpenCode stream events + * OpenCode uses underscore format: step_start, step_finish, text */ interface OpenCodeBaseEvent { /** Event type identifier */ type: string; - /** Optional session identifier */ - session_id?: string; + /** Timestamp of the event */ + timestamp?: number; + /** Session ID */ + sessionID?: string; + /** Part object containing the actual event data */ + part?: Record; } /** - * Text delta event - Incremental text output from the model + * Text event - Text output from the model + * Format: {"type":"text","part":{"text":"content",...}} */ -export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent { - type: 'text-delta'; - /** The incremental text content */ - text: string; -} - -/** - * Text end event - Signals completion of text generation - */ -export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent { - type: 'text-end'; +export interface OpenCodeTextEvent extends OpenCodeBaseEvent { + type: 'text'; + part: { + type: 'text'; + text: string; + [key: string]: unknown; + }; } /** * Tool call event - Request to execute a tool */ export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { - type: 'tool-call'; - /** Unique identifier for this tool call */ - call_id?: string; - /** Tool name to invoke */ - name: string; - /** Arguments to pass to the tool */ - args: unknown; + type: 'tool_call'; + part: { + type: 'tool-call'; + name: string; + call_id?: string; + args: unknown; + [key: string]: unknown; + }; } /** * Tool result event - Output from a tool execution */ export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { - type: 'tool-result'; - /** The tool call ID this result corresponds to */ - call_id?: string; - /** Output from the tool execution */ - output: string; + type: 'tool_result'; + part: { + type: 'tool-result'; + call_id?: string; + output: string; + [key: string]: unknown; + }; } /** * Tool error event - Tool execution failed */ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { - type: 'tool-error'; - /** The tool call ID that failed */ - call_id?: string; - /** Error message describing the failure */ - error: string; + type: 'tool_error'; + part: { + type: 'tool-error'; + call_id?: string; + error: string; + [key: string]: unknown; + }; } /** * Start step event - Begins an agentic loop iteration + * Format: {"type":"step_start","part":{...}} */ export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent { - type: 'start-step'; - /** Step number in the agentic loop */ - step?: number; + type: 'step_start'; + part?: { + type: 'step-start'; + [key: string]: unknown; + }; } /** * Finish step event - Completes an agentic loop iteration + * Format: {"type":"step_finish","part":{"reason":"stop",...}} */ export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent { - type: 'finish-step'; - /** Step number that completed */ - step?: number; - /** Whether the step completed successfully */ - success?: boolean; - /** Optional result data */ - result?: string; - /** Optional error if step failed */ - error?: string; + type: 'step_finish'; + part?: { + type: 'step-finish'; + reason?: string; + error?: string; + [key: string]: unknown; + }; } /** * Union type of all OpenCode stream events */ export type OpenCodeStreamEvent = - | OpenCodeTextDeltaEvent - | OpenCodeTextEndEvent + | OpenCodeTextEvent | OpenCodeToolCallEvent | OpenCodeToolResultEvent | OpenCodeToolErrorEvent @@ -219,14 +227,12 @@ export class OpencodeProvider extends CliProvider { * * Arguments built: * - 'run' subcommand for executing queries - * - '--format', 'stream-json' for JSONL streaming output - * - '-q' / '--quiet' to suppress spinner and interactive elements - * - '-c', '' for working directory + * - '--format', 'json' for JSON streaming output * - '--model', '' for model selection (if specified) - * - '-' as final arg to read prompt from stdin + * - Message passed via stdin (no positional args needed) * - * The prompt is NOT included in CLI args - it's passed via stdin to avoid - * shell escaping issues with special characters in content. + * The prompt is passed via stdin to avoid shell escaping issues. + * OpenCode will read from stdin when no positional message arguments are provided. * * @param options - Execution options containing model, cwd, etc. * @returns Array of CLI arguments for opencode run @@ -234,27 +240,18 @@ export class OpencodeProvider extends CliProvider { buildCliArgs(options: ExecuteOptions): string[] { const args: string[] = ['run']; - // Add streaming JSON output format for JSONL parsing - args.push('--format', 'stream-json'); - - // Suppress spinner and interactive elements for non-TTY usage - args.push('-q'); - - // Set working directory - if (options.cwd) { - args.push('-c', options.cwd); - } + // Add JSON output format for streaming + args.push('--format', 'json'); // Handle model selection - // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' + // Strip 'opencode-' prefix if present, OpenCode uses native format if (options.model) { const model = stripProviderPrefix(options.model); args.push('--model', model); } - // Use '-' to indicate reading prompt from stdin - // This avoids shell escaping issues with special characters - args.push('-'); + // Note: Working directory is set via subprocess cwd option, not CLI args + // Note: Message is passed via stdin, OpenCode reads from stdin automatically return args; } @@ -314,14 +311,12 @@ export class OpencodeProvider extends CliProvider { * Normalize a raw CLI event to ProviderMessage format * * Maps OpenCode event types to the standard ProviderMessage structure: - * - text-delta -> type: 'assistant', content with type: 'text' - * - text-end -> null (informational, no message needed) - * - tool-call -> type: 'assistant', content with type: 'tool_use' - * - tool-result -> type: 'assistant', content with type: 'tool_result' - * - tool-error -> type: 'error' - * - start-step -> null (informational, no message needed) - * - finish-step with success -> type: 'result', subtype: 'success' - * - finish-step with error -> type: 'error' + * - text -> type: 'assistant', content with type: 'text' + * - step_start -> null (informational, no message needed) + * - step_finish -> type: 'result', subtype: 'success' (or error if failed) + * - tool_call -> type: 'assistant', content with type: 'tool_use' + * - tool_result -> type: 'assistant', content with type: 'tool_result' + * - tool_error -> type: 'error' * * @param event - Raw event from OpenCode CLI JSONL output * @returns Normalized ProviderMessage or null to skip the event @@ -334,24 +329,24 @@ export class OpencodeProvider extends CliProvider { const openCodeEvent = event as OpenCodeStreamEvent; switch (openCodeEvent.type) { - case 'text-delta': { - const textEvent = openCodeEvent as OpenCodeTextDeltaEvent; + case 'text': { + const textEvent = openCodeEvent as OpenCodeTextEvent; - // Skip empty text deltas - if (!textEvent.text) { + // Skip if no text content + if (!textEvent.part?.text) { return null; } const content: ContentBlock[] = [ { type: 'text', - text: textEvent.text, + text: textEvent.part.text, }, ]; return { type: 'assistant', - session_id: textEvent.session_id, + session_id: textEvent.sessionID, message: { role: 'assistant', content, @@ -359,81 +354,20 @@ export class OpencodeProvider extends CliProvider { }; } - case 'text-end': { - // Text end is informational - no message needed - return null; - } - - case 'tool-call': { - const toolEvent = openCodeEvent as OpenCodeToolCallEvent; - - // Generate a tool use ID if not provided - const toolUseId = toolEvent.call_id || generateToolUseId(); - - const content: ContentBlock[] = [ - { - type: 'tool_use', - name: toolEvent.name, - tool_use_id: toolUseId, - input: toolEvent.args, - }, - ]; - - return { - type: 'assistant', - session_id: toolEvent.session_id, - message: { - role: 'assistant', - content, - }, - }; - } - - case 'tool-result': { - const resultEvent = openCodeEvent as OpenCodeToolResultEvent; - - const content: ContentBlock[] = [ - { - type: 'tool_result', - tool_use_id: resultEvent.call_id, - content: resultEvent.output, - }, - ]; - - return { - type: 'assistant', - session_id: resultEvent.session_id, - message: { - role: 'assistant', - content, - }, - }; - } - - case 'tool-error': { - const errorEvent = openCodeEvent as OpenCodeToolErrorEvent; - - return { - type: 'error', - session_id: errorEvent.session_id, - error: errorEvent.error || 'Tool execution failed', - }; - } - - case 'start-step': { + case 'step_start': { // Start step is informational - no message needed return null; } - case 'finish-step': { + case 'step_finish': { const finishEvent = openCodeEvent as OpenCodeFinishStepEvent; // Check if the step failed - if (finishEvent.success === false || finishEvent.error) { + if (finishEvent.part?.error) { return { type: 'error', - session_id: finishEvent.session_id, - error: finishEvent.error || 'Step execution failed', + session_id: finishEvent.sessionID, + error: finishEvent.part.error, }; } @@ -441,8 +375,71 @@ export class OpencodeProvider extends CliProvider { return { type: 'result', subtype: 'success', - session_id: finishEvent.session_id, - result: finishEvent.result, + session_id: finishEvent.sessionID, + }; + } + + case 'tool_call': { + const toolEvent = openCodeEvent as OpenCodeToolCallEvent; + + if (!toolEvent.part) { + return null; + } + + // Generate a tool use ID if not provided + const toolUseId = toolEvent.part.call_id || generateToolUseId(); + + const content: ContentBlock[] = [ + { + type: 'tool_use', + name: toolEvent.part.name, + tool_use_id: toolUseId, + input: toolEvent.part.args, + }, + ]; + + return { + type: 'assistant', + session_id: toolEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool_result': { + const resultEvent = openCodeEvent as OpenCodeToolResultEvent; + + if (!resultEvent.part) { + return null; + } + + const content: ContentBlock[] = [ + { + type: 'tool_result', + tool_use_id: resultEvent.part.call_id, + content: resultEvent.part.output, + }, + ]; + + return { + type: 'assistant', + session_id: resultEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool_error': { + const errorEvent = openCodeEvent as OpenCodeToolErrorEvent; + + return { + type: 'error', + session_id: errorEvent.sessionID, + error: errorEvent.part?.error || 'Tool execution failed', }; } diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index e21e9ffc..b0683ea6 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -1,5 +1,4 @@ import type { ComponentType, SVGProps } from 'react'; -import { Cpu } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { AgentModel, ModelProvider } from '@automaker/types'; import { getProviderFromModel } from '@/lib/utils'; @@ -10,6 +9,10 @@ const PROVIDER_ICON_KEYS = { cursor: 'cursor', gemini: 'gemini', grok: 'grok', + opencode: 'opencode', + deepseek: 'deepseek', + qwen: 'qwen', + nova: 'nova', } as const; type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; @@ -17,6 +20,8 @@ type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; interface ProviderIconDefinition { viewBox: string; path: string; + fillRule?: 'nonzero' | 'evenodd'; + fill?: string; } const PROVIDER_ICON_DEFINITIONS: Record = { @@ -24,15 +29,18 @@ const PROVIDER_ICON_DEFINITIONS: Record viewBox: '0 0 248 248', // Official Claude logo from claude.ai favicon path: 'M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z', + fill: '#d97757', }, openai: { viewBox: '0 0 158.7128 157.296', path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z', + fill: '#74aa9c', }, cursor: { viewBox: '0 0 512 512', // Official Cursor logo - hexagonal shape with triangular wedge path: 'M415.035 156.35l-151.503-87.4695c-4.865-2.8094-10.868-2.8094-15.733 0l-151.4969 87.4695c-4.0897 2.362-6.6146 6.729-6.6146 11.459v176.383c0 4.73 2.5249 9.097 6.6146 11.458l151.5039 87.47c4.865 2.809 10.868 2.809 15.733 0l151.504-87.47c4.089-2.361 6.614-6.728 6.614-11.458v-176.383c0-4.73-2.525-9.097-6.614-11.459zm-9.516 18.528l-146.255 253.32c-.988 1.707-3.599 1.01-3.599-.967v-165.872c0-3.314-1.771-6.379-4.644-8.044l-143.645-82.932c-1.707-.988-1.01-3.599.968-3.599h292.509c4.154 0 6.75 4.503 4.673 8.101h-.007z', + fill: '#5E9EFF', }, gemini: { viewBox: '0 0 192 192', @@ -44,6 +52,28 @@ const PROVIDER_ICON_DEFINITIONS: Record // Official Grok/xAI logo - stylized symbol from grok.com path: 'M213.235 306.019l178.976-180.002v.169l51.695-51.763c-.924 1.32-1.86 2.605-2.785 3.89-39.281 54.164-58.46 80.649-43.07 146.922l-.09-.101c10.61 45.11-.744 95.137-37.398 131.836-46.216 46.306-120.167 56.611-181.063 14.928l42.462-19.675c38.863 15.278 81.392 8.57 111.947-22.03 30.566-30.6 37.432-75.159 22.065-112.252-2.92-7.025-11.67-8.795-17.792-4.263l-124.947 92.341zm-25.786 22.437l-.033.034L68.094 435.217c7.565-10.429 16.957-20.294 26.327-30.149 26.428-27.803 52.653-55.359 36.654-94.302-21.422-52.112-8.952-113.177 30.724-152.898 41.243-41.254 101.98-51.661 152.706-30.758 11.23 4.172 21.016 10.114 28.638 15.639l-42.359 19.584c-39.44-16.563-84.629-5.299-112.207 22.313-37.298 37.308-44.84 102.003-1.128 143.81z', }, + opencode: { + viewBox: '0 0 512 512', + // Official OpenCode favicon - geometric icon from opencode.ai + path: 'M384 416H128V96H384V416ZM320 160H192V352H320V160Z', + fillRule: 'evenodd', + fill: 'white', + }, + deepseek: { + viewBox: '0 0 24 24', + // Official DeepSeek logo - whale icon from lobehub/lobe-icons + path: 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z', + }, + qwen: { + viewBox: '0 0 24 24', + // Official Qwen logo - geometric star from lobehub/lobe-icons + path: 'M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z', + }, + nova: { + viewBox: '0 0 33 32', + // Official Amazon Nova logo from lobehub/lobe-icons + path: 'm17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z', + }, }; export interface ProviderIconProps extends Omit, 'viewBox'> { @@ -72,7 +102,11 @@ export function ProviderIcon({ provider, title, className, ...props }: ProviderI {...rest} > {title && {title}} - + ); } @@ -97,8 +131,140 @@ export function GrokIcon(props: Omit) { return ; } -export function OpenCodeIcon({ className, ...props }: { className?: string }) { - return ; +export function OpenCodeIcon(props: Omit) { + return ; +} + +export function DeepSeekIcon({ + className, + title, + ...props +}: { + className?: string; + title?: string; +}) { + const hasAccessibleLabel = Boolean(title); + + return ( + + {title && {title}} + + + ); +} + +export function QwenIcon({ className, title, ...props }: { className?: string; title?: string }) { + const hasAccessibleLabel = Boolean(title); + + return ( + + {title && {title}} + + + + + + + + + ); +} + +export function NovaIcon({ className, title, ...props }: { className?: string; title?: string }) { + const hasAccessibleLabel = Boolean(title); + + return ( + + {title && {title}} + + + ); +} + +export function MistralIcon({ + className, + title, + ...props +}: { + className?: string; + title?: string; +}) { + const hasAccessibleLabel = Boolean(title); + + return ( + + {title && {title}} + + + + + + + ); +} + +export function MetaIcon({ className, title, ...props }: { className?: string; title?: string }) { + const hasAccessibleLabel = Boolean(title); + + return ( + + {title && {title}} + + + ); } export const PROVIDER_ICON_COMPONENTS: Record< @@ -106,7 +272,7 @@ export const PROVIDER_ICON_COMPONENTS: Record< ComponentType<{ className?: string }> > = { claude: AnthropicIcon, - cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel) + cursor: CursorIcon, codex: OpenAIIcon, opencode: OpenCodeIcon, }; @@ -120,6 +286,11 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { const modelStr = typeof model === 'string' ? model.toLowerCase() : model; + // Check for OpenCode models (opencode/, amazon-bedrock/, opencode-*) + if (modelStr.includes('opencode') || modelStr.includes('amazon-bedrock')) { + return 'opencode'; + } + // Check for Cursor-specific models with underlying providers if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) { return 'anthropic'; @@ -141,6 +312,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { const provider = getProviderFromModel(model); if (provider === 'codex') return 'openai'; if (provider === 'cursor') return 'cursor'; + if (provider === 'opencode') return 'opencode'; return 'anthropic'; } @@ -155,6 +327,7 @@ export function getProviderIconForModel( cursor: CursorIcon, gemini: GeminiIcon, grok: GrokIcon, + opencode: OpenCodeIcon, }; return iconMap[iconKey] || AnthropicIcon; diff --git a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx index a68dbcb7..961ff866 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button'; -import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react'; +import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; +import { OpenCodeIcon } from '@/components/ui/provider-icon'; export type OpencodeAuthMethod = | 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars @@ -169,7 +170,7 @@ export function OpencodeCliStatus({
- +

OpenCode CLI

diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index 62bb9daf..f7f2b9f6 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -14,9 +14,8 @@ import { MessageSquareText, User, Shield, - Cpu, } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; export interface NavigationItem { @@ -48,7 +47,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ { id: 'claude-provider', label: 'Claude', icon: AnthropicIcon }, { id: 'cursor-provider', label: 'Cursor', icon: CursorIcon }, { id: 'codex-provider', label: 'Codex', icon: OpenAIIcon }, - { id: 'opencode-provider', label: 'OpenCode', icon: Cpu }, + { id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon }, ], }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx index 1c762018..524f10e5 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -8,11 +8,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types'; import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types'; -import { AnthropicIcon } from '@/components/ui/provider-icon'; +import { + OpenCodeIcon, + DeepSeekIcon, + QwenIcon, + NovaIcon, + AnthropicIcon, + MistralIcon, + MetaIcon, +} from '@/components/ui/provider-icon'; import type { ComponentType } from 'react'; interface OpencodeModelConfigurationProps { @@ -29,21 +36,21 @@ interface OpencodeModelConfigurationProps { function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> { switch (provider) { case 'opencode': - return Terminal; + return OpenCodeIcon; case 'amazon-bedrock-anthropic': return AnthropicIcon; case 'amazon-bedrock-deepseek': - return Brain; + return DeepSeekIcon; case 'amazon-bedrock-amazon': - return Cloud; + return NovaIcon; case 'amazon-bedrock-meta': - return Cpu; + return MetaIcon; case 'amazon-bedrock-mistral': - return Sparkles; + return MistralIcon; case 'amazon-bedrock-qwen': - return Zap; + return QwenIcon; default: - return Terminal; + return OpenCodeIcon; } } @@ -113,7 +120,7 @@ export function OpencodeModelConfiguration({
- +

Model Configuration diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index 96848f57..29259f9e 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -15,6 +15,7 @@ import { DEFAULT_MODELS, PROVIDER_PREFIXES, isCursorModel, + isOpencodeModel, stripProviderPrefix, type PhaseModelEntry, type ThinkingLevel, @@ -68,6 +69,13 @@ export function resolveModelString( return modelKey; } + // OpenCode model - pass through unchanged + // Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-* + if (isOpencodeModel(modelKey)) { + console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`); + return modelKey; + } + // Full Claude model string - pass through unchanged if (modelKey.includes('claude-')) { console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`); From 51cd7156d240263cbf8380c481499c39f881ef22 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 01:07:55 +0530 Subject: [PATCH 3/6] test: Update OpenCode provider tests to use new event format Update normalizeEvent tests to match new OpenCode API: - text events use type='text' with part.text instead of text-delta - tool_call events use type='tool_call' with part containing call_id, name, args - tool_result events use type='tool_result' with part - tool_error events use type='tool_error' with part - step_finish events use type='step_finish' with part Update buildCliArgs tests: - Remove expectations for -q flag (no longer used) - Remove expectations for -c flag (cwd set at subprocess level) - Remove expectations for - final arg (prompt via stdin) - Update format to 'json' instead of 'stream-json' Remaining 8 test failures are in integration tests that use executeQuery and require more extensive mock data updates. --- .../unit/providers/opencode-provider.test.ts | 334 ++++++++++-------- 1 file changed, 181 insertions(+), 153 deletions(-) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index b33217a8..576dfbfd 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -168,41 +168,23 @@ describe('opencode-provider.ts', () => { it('should build correct args with run subcommand', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', cwd: '/tmp/project', }); expect(args[0]).toBe('run'); }); - it('should include --format stream-json for streaming output', () => { + it('should include --format json for streaming output', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', cwd: '/tmp/project', }); const formatIndex = args.indexOf('--format'); expect(formatIndex).toBeGreaterThan(-1); - expect(args[formatIndex + 1]).toBe('stream-json'); - }); - - it('should include -q flag for quiet mode', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/project', - }); - - expect(args).toContain('-q'); - }); - - it('should include working directory with -c flag', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/my-project', - }); - - const cwdIndex = args.indexOf('-c'); - expect(cwdIndex).toBeGreaterThan(-1); - expect(args[cwdIndex + 1]).toBe('/tmp/my-project'); + expect(args[formatIndex + 1]).toBe('json'); }); it('should include model with --model flag', () => { @@ -228,18 +210,10 @@ describe('opencode-provider.ts', () => { expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); }); - it('should include dash as final arg for stdin prompt', () => { - const args = provider.buildCliArgs({ - prompt: 'Hello', - cwd: '/tmp/project', - }); - - expect(args[args.length - 1]).toBe('-'); - }); - it('should handle missing cwd', () => { const args = provider.buildCliArgs({ prompt: 'Hello', + model: 'opencode/big-pickle', }); expect(args).not.toContain('-c'); @@ -249,6 +223,7 @@ describe('opencode-provider.ts', () => { const args = provider.buildCliArgs({ prompt: 'Hello', cwd: '/tmp/project', + model: 'opencode/big-pickle', }); expect(args).not.toContain('--model'); @@ -260,12 +235,15 @@ describe('opencode-provider.ts', () => { // ========================================================================== describe('normalizeEvent', () => { - describe('text-delta events', () => { - it('should convert text-delta to assistant message with text content', () => { + describe('text events (new OpenCode format)', () => { + it('should convert text to assistant message with text content', () => { const event = { - type: 'text-delta', - text: 'Hello, world!', - session_id: 'test-session', + type: 'text', + part: { + type: 'text', + text: 'Hello, world!', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -285,10 +263,13 @@ describe('opencode-provider.ts', () => { }); }); - it('should return null for empty text-delta', () => { + it('should return null for empty text', () => { const event = { - type: 'text-delta', - text: '', + type: 'text', + part: { + type: 'text', + text: '', + }, }; const result = provider.normalizeEvent(event); @@ -296,9 +277,10 @@ describe('opencode-provider.ts', () => { expect(result).toBeNull(); }); - it('should return null for text-delta with undefined text', () => { + it('should return null for text with undefined text', () => { const event = { - type: 'text-delta', + type: 'text', + part: {}, }; const result = provider.normalizeEvent(event); @@ -307,27 +289,17 @@ describe('opencode-provider.ts', () => { }); }); - describe('text-end events', () => { - it('should return null for text-end events (informational)', () => { + describe('tool_call events', () => { + it('should convert tool_call to assistant message with tool_use content', () => { const event = { - type: 'text-end', - session_id: 'test-session', - }; - - const result = provider.normalizeEvent(event); - - expect(result).toBeNull(); - }); - }); - - describe('tool-call events', () => { - it('should convert tool-call to assistant message with tool_use content', () => { - const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Read', - args: { file_path: '/tmp/test.txt' }, - session_id: 'test-session', + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -351,9 +323,12 @@ describe('opencode-provider.ts', () => { it('should generate tool_use_id when call_id is missing', () => { const event = { - type: 'tool-call', - name: 'Write', - args: { content: 'test' }, + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Write', + args: { content: 'test' }, + }, }; const result = provider.normalizeEvent(event); @@ -363,21 +338,27 @@ describe('opencode-provider.ts', () => { // Second call should increment const result2 = provider.normalizeEvent({ - type: 'tool-call', - name: 'Edit', - args: {}, + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Edit', + args: {}, + }, }); expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2'); }); }); - describe('tool-result events', () => { - it('should convert tool-result to assistant message with tool_result content', () => { + describe('tool_result events', () => { + it('should convert tool_result to assistant message with tool_result content', () => { const event = { - type: 'tool-result', - call_id: 'call-123', - output: 'File contents here', - session_id: 'test-session', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: 'File contents here', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -398,10 +379,13 @@ describe('opencode-provider.ts', () => { }); }); - it('should handle tool-result without call_id', () => { + it('should handle tool_result without call_id', () => { const event = { - type: 'tool-result', - output: 'Result without ID', + type: 'tool_result', + part: { + type: 'tool-result', + output: 'Result without ID', + }, }; const result = provider.normalizeEvent(event); @@ -411,13 +395,16 @@ describe('opencode-provider.ts', () => { }); }); - describe('tool-error events', () => { - it('should convert tool-error to error message', () => { + describe('tool_error events', () => { + it('should convert tool_error to error message', () => { const event = { - type: 'tool-error', - call_id: 'call-123', - error: 'File not found', - session_id: 'test-session', + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + error: 'File not found', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -431,8 +418,11 @@ describe('opencode-provider.ts', () => { it('should provide default error message when error is missing', () => { const event = { - type: 'tool-error', - call_id: 'call-123', + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + }, }; const result = provider.normalizeEvent(event); @@ -442,12 +432,14 @@ describe('opencode-provider.ts', () => { }); }); - describe('start-step events', () => { - it('should return null for start-step events (informational)', () => { + describe('step_start events', () => { + it('should return null for step_start events (informational)', () => { const event = { - type: 'start-step', - step: 1, - session_id: 'test-session', + type: 'step_start', + part: { + type: 'step-start', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -456,14 +448,16 @@ describe('opencode-provider.ts', () => { }); }); - describe('finish-step events', () => { - it('should convert successful finish-step to result message', () => { + describe('step_finish events', () => { + it('should convert successful step_finish to result message', () => { const event = { - type: 'finish-step', - step: 1, - success: true, - result: 'Task completed successfully', - session_id: 'test-session', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Task completed successfully', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -476,13 +470,15 @@ describe('opencode-provider.ts', () => { }); }); - it('should convert finish-step with success=false to error message', () => { + it('should convert step_finish with error to error message', () => { const event = { - type: 'finish-step', - step: 1, - success: false, - error: 'Something went wrong', - session_id: 'test-session', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + error: 'Something went wrong', + }, + sessionID: 'test-session', }; const result = provider.normalizeEvent(event); @@ -494,11 +490,13 @@ describe('opencode-provider.ts', () => { }); }); - it('should convert finish-step with error property to error message', () => { + it('should convert step_finish with error property to error message', () => { const event = { - type: 'finish-step', - step: 1, - error: 'Process failed', + type: 'step_finish', + part: { + type: 'step-finish', + error: 'Process failed', + }, }; const result = provider.normalizeEvent(event); @@ -509,9 +507,11 @@ describe('opencode-provider.ts', () => { it('should provide default error message for failed step without error text', () => { const event = { - type: 'finish-step', - step: 1, - success: false, + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + }, }; const result = provider.normalizeEvent(event); @@ -520,11 +520,14 @@ describe('opencode-provider.ts', () => { expect(result?.error).toBe('Step execution failed'); }); - it('should treat finish-step without success flag as success', () => { + it('should treat step_finish with reason=stop as success', () => { const event = { - type: 'finish-step', - step: 1, - result: 'Done', + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Done', + }, }; const result = provider.normalizeEvent(event); @@ -1069,12 +1072,15 @@ describe('opencode-provider.ts', () => { }); describe('normalizeEvent additional edge cases', () => { - it('should handle tool-call with empty args object', () => { + it('should handle tool_call with empty args object', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Glob', - args: {}, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: {}, + }, }; const result = provider.normalizeEvent(event); @@ -1083,12 +1089,15 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].input).toEqual({}); }); - it('should handle tool-call with null args', () => { + it('should handle tool_call with null args', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Glob', - args: null, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: null, + }, }; const result = provider.normalizeEvent(event); @@ -1097,18 +1106,21 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].input).toBeNull(); }); - it('should handle tool-call with complex nested args', () => { + it('should handle tool_call with complex nested args', () => { const event = { - type: 'tool-call', - call_id: 'call-123', - name: 'Edit', - args: { - file_path: '/tmp/test.ts', - changes: [ - { line: 10, old: 'foo', new: 'bar' }, - { line: 20, old: 'baz', new: 'qux' }, - ], - options: { replace_all: true }, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Edit', + args: { + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }, }, }; @@ -1125,11 +1137,14 @@ describe('opencode-provider.ts', () => { }); }); - it('should handle tool-result with empty output', () => { + it('should handle tool_result with empty output', () => { const event = { - type: 'tool-result', - call_id: 'call-123', - output: '', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: '', + }, }; const result = provider.normalizeEvent(event); @@ -1138,10 +1153,13 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].content).toBe(''); }); - it('should handle text-delta with whitespace-only text', () => { + it('should handle text with whitespace-only text', () => { const event = { - type: 'text-delta', - text: ' ', + type: 'text', + part: { + type: 'text', + text: ' ', + }, }; const result = provider.normalizeEvent(event); @@ -1151,10 +1169,13 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].text).toBe(' '); }); - it('should handle text-delta with newlines', () => { + it('should handle text with newlines', () => { const event = { - type: 'text-delta', - text: 'Line 1\nLine 2\nLine 3', + type: 'text', + part: { + type: 'text', + text: 'Line 1\nLine 2\nLine 3', + }, }; const result = provider.normalizeEvent(event); @@ -1162,12 +1183,15 @@ describe('opencode-provider.ts', () => { expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3'); }); - it('should handle finish-step with both result and error (error takes precedence)', () => { + it('should handle step_finish with both result and error (error takes precedence)', () => { const event = { - type: 'finish-step', - result: 'Some result', - error: 'But also an error', - success: false, + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Some result', + error: 'But also an error', + }, }; const result = provider.normalizeEvent(event); @@ -1231,13 +1255,14 @@ describe('opencode-provider.ts', () => { const longPrompt = 'a'.repeat(10000); const args = provider.buildCliArgs({ prompt: longPrompt, + model: 'opencode/big-pickle', cwd: '/tmp', }); // The prompt is NOT in args (it's passed via stdin) // Just verify the args structure is correct expect(args).toContain('run'); - expect(args).toContain('-'); + expect(args).not.toContain('-'); expect(args.join(' ')).not.toContain(longPrompt); }); @@ -1245,22 +1270,25 @@ describe('opencode-provider.ts', () => { const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\''; const args = provider.buildCliArgs({ prompt: specialPrompt, + model: 'opencode/big-pickle', cwd: '/tmp', }); // Special chars in prompt should not affect args (prompt is via stdin) expect(args).toContain('run'); - expect(args).toContain('-'); + expect(args).not.toContain('-'); }); it('should handle cwd with spaces', () => { const args = provider.buildCliArgs({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/tmp/path with spaces/project', }); - const cwdIndex = args.indexOf('-c'); - expect(args[cwdIndex + 1]).toBe('/tmp/path with spaces/project'); + // cwd is set at subprocess level, not via CLI args + expect(args).not.toContain('-c'); + expect(args).not.toContain('/tmp/path with spaces/project'); }); it('should handle model with unusual characters', () => { From 7cc092cd59f191bb9ac58aa8aaa7ba5d76af4235 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 01:23:42 +0530 Subject: [PATCH 4/6] test: Fix remaining OpenCode provider test failures Fix all 8 remaining test failures: 1. Update executeQuery integration tests to use new OpenCode event format: - text events use type='text' with part.text - tool_call events use type='tool_call' with part containing call_id, name, args - tool_result events use type='tool_result' with part - step_finish events use type='step_finish' with part - Use sessionID field instead of session_id 2. Fix step_finish event handling: - Include result field in successful completion response - Check for reason === 'error' to detect failed steps - Provide default error message when error field is missing 3. Update model test expectations: - Model 'opencode/big-pickle' stays as-is (not stripped to 'big-pickle') - PROVIDER_PREFIXES only strips 'opencode-' prefix, not 'opencode/' All 84 tests now pass successfully! --- .../server/src/providers/opencode-provider.ts | 27 +++++--- .../unit/providers/opencode-provider.test.ts | 66 ++++++++++++------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index e20c3593..d0990d16 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -362,21 +362,32 @@ export class OpencodeProvider extends CliProvider { case 'step_finish': { const finishEvent = openCodeEvent as OpenCodeFinishStepEvent; - // Check if the step failed - if (finishEvent.part?.error) { + // Check if the step failed (either has error field or reason is 'error') + if (finishEvent.part?.error || finishEvent.part?.reason === 'error') { return { type: 'error', session_id: finishEvent.sessionID, - error: finishEvent.part.error, + error: finishEvent.part?.error || 'Step execution failed', }; } // Successful completion - return { - type: 'result', - subtype: 'success', - session_id: finishEvent.sessionID, - }; + const result: { type: 'result'; subtype: 'success'; session_id?: string; result?: string } = + { + type: 'result', + subtype: 'success', + }; + + if (finishEvent.sessionID) { + result.session_id = finishEvent.sessionID; + } + + // Include result text if provided + if (finishEvent.part?.result) { + result.result = finishEvent.part.result; + } + + return result; } case 'tool_call': { diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index 576dfbfd..ad8dfb1e 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -219,14 +219,15 @@ describe('opencode-provider.ts', () => { expect(args).not.toContain('-c'); }); - it('should handle missing model', () => { + it('should handle model from opencode provider', () => { const args = provider.buildCliArgs({ prompt: 'Hello', - cwd: '/tmp/project', model: 'opencode/big-pickle', + cwd: '/tmp/project', }); - expect(args).not.toContain('--model'); + expect(args).toContain('--model'); + expect(args).toContain('opencode/big-pickle'); }); }); @@ -589,13 +590,12 @@ describe('opencode-provider.ts', () => { return mockedProvider; } - it('should stream text-delta events as assistant messages', async () => { + it('should stream text events as assistant messages', async () => { const mockedProvider = setupMockedProvider(); const mockEvents = [ - { type: 'text-delta', text: 'Hello ' }, - { type: 'text-delta', text: 'World!' }, - { type: 'text-end' }, + { type: 'text', part: { type: 'text', text: 'Hello ' } }, + { type: 'text', part: { type: 'text', text: 'World!' } }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -614,7 +614,6 @@ describe('opencode-provider.ts', () => { }) ); - // text-end should be filtered out (returns null) expect(results).toHaveLength(2); expect(results[0].type).toBe('assistant'); expect(results[0].message?.content[0].text).toBe('Hello '); @@ -626,15 +625,21 @@ describe('opencode-provider.ts', () => { const mockEvents = [ { - type: 'tool-call', - call_id: 'tool-1', - name: 'Read', - args: { file_path: '/tmp/test.txt' }, + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'tool-1', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, }, { - type: 'tool-result', - call_id: 'tool-1', - output: 'File contents', + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'tool-1', + output: 'File contents', + }, }, ]; @@ -721,10 +726,7 @@ describe('opencode-provider.ts', () => { const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; expect(call.args).toContain('run'); expect(call.args).toContain('--format'); - expect(call.args).toContain('stream-json'); - expect(call.args).toContain('-q'); - expect(call.args).toContain('-c'); - expect(call.args).toContain('/tmp/workspace'); + expect(call.args).toContain('json'); expect(call.args).toContain('--model'); expect(call.args).toContain('anthropic/claude-opus-4-5'); }); @@ -734,9 +736,9 @@ describe('opencode-provider.ts', () => { const mockEvents = [ { type: 'unknown-internal-event', data: 'ignored' }, - { type: 'text-delta', text: 'Valid text' }, + { type: 'text', part: { type: 'text', text: 'Valid text' } }, { type: 'another-unknown', foo: 'bar' }, - { type: 'finish-step', result: 'Done' }, + { type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -750,6 +752,7 @@ describe('opencode-provider.ts', () => { const results = await collectAsyncGenerator( mockedProvider.executeQuery({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/test', }) ); @@ -1042,10 +1045,22 @@ describe('opencode-provider.ts', () => { const sessionId = 'test-session-123'; const mockEvents = [ - { type: 'text-delta', text: 'Hello ', session_id: sessionId }, - { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1', session_id: sessionId }, - { type: 'tool-result', call_id: 'c1', output: 'file content', session_id: sessionId }, - { type: 'finish-step', result: 'Done', session_id: sessionId }, + { type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId }, + { + type: 'tool_call', + part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' }, + sessionID: sessionId, + }, + { + type: 'tool_result', + part: { type: 'tool-result', call_id: 'c1', output: 'file content' }, + sessionID: sessionId, + }, + { + type: 'step_finish', + part: { type: 'step-finish', reason: 'stop', result: 'Done' }, + sessionID: sessionId, + }, ]; vi.mocked(spawnJSONLProcess).mockReturnValue( @@ -1059,6 +1074,7 @@ describe('opencode-provider.ts', () => { const results = await collectAsyncGenerator( mockedProvider.executeQuery({ prompt: 'Test', + model: 'opencode/big-pickle', cwd: '/tmp', }) ); From e34e4a59e9bee1601bcc87bccb82541d18437f84 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 01:35:32 +0530 Subject: [PATCH 5/6] fix: Resolve TypeScript error assigning part.result to string field Fix TS2322 error where finishEvent.part?.result (typed as {}) was being assigned to result.result (typed as string). Solution: Safely handle arbitrary result payloads by: 1. Reading raw value as unknown from Record 2. Checking if it's a string, otherwise JSON.stringify() This ensures type safety while supporting both string and object results from the OpenCode CLI. --- apps/server/src/providers/opencode-provider.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index d0990d16..ecc7fc85 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -382,9 +382,11 @@ export class OpencodeProvider extends CliProvider { result.session_id = finishEvent.sessionID; } - // Include result text if provided - if (finishEvent.part?.result) { - result.result = finishEvent.part.result; + // Safely handle arbitrary result payloads from CLI: ensure we assign a string. + const rawResult = + (finishEvent.part && (finishEvent.part as Record).result) ?? undefined; + if (rawResult !== undefined) { + result.result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult); } return result; From 0c19beb11c27f493c23eabf3e62b49fcd9c37ae6 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 01:54:17 +0530 Subject: [PATCH 6/6] fix: Set OpenCode icon to official brand color (#6366F1 indigo) The OpenCode icon now uses the official indigo brand color (#6366F1) from opencode.ai instead of white, making it visible in both light and dark themes. --- apps/ui/src/components/ui/provider-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index b0683ea6..4d978305 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -57,7 +57,7 @@ const PROVIDER_ICON_DEFINITIONS: Record // Official OpenCode favicon - geometric icon from opencode.ai path: 'M384 416H128V96H384V416ZM320 160H192V352H320V160Z', fillRule: 'evenodd', - fill: 'white', + fill: '#6366F1', }, deepseek: { viewBox: '0 0 24 24',