Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .archon/workflows/test-workflows/e2e-opencode-smoke.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# E2E smoke test — opencode community provider
# Verifies: opencode server starts, event bridge yields assistant chunks,
# session.idle triggers result chunk.
name: e2e-opencode-smoke
description: 'Smoke test for opencode community provider. Verifies prompt response via sendQuery.'
provider: opencode
model: requesty/google/gemini-3-flash-preview

nodes:
- id: simple
prompt: 'What is 2+2? Reply with just the number, nothing else.'
idle_timeout: 60000

- id: assert
bash: |
output="$simple.output"
if [ -z "$output" ]; then
echo "FAIL: simple node returned empty output"
exit 1
fi
echo "PASS: got response: $output"
depends_on: [simple]
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/providers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
"./codex/config": "./src/codex/config.ts",
"./codex/binary-resolver": "./src/codex/binary-resolver.ts",
"./community/pi": "./src/community/pi/index.ts",
"./community/opencode": "./src/community/opencode/index.ts",
"./errors": "./src/errors.ts",
"./registry": "./src/registry.ts"
},
"scripts": {
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts",
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts && bun test src/community/opencode/config.test.ts && bun test src/community/opencode/event-bridge.test.ts && bun test src/community/opencode/provider.test.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
Expand All @@ -27,6 +28,7 @@
"@mariozechner/pi-ai": "^0.67.5",
"@mariozechner/pi-coding-agent": "^0.67.5",
"@openai/codex-sdk": "^0.125.0",
"@opencode-ai/sdk": "^1.14.0",
"@sinclair/typebox": "^0.34.41"
},
"devDependencies": {
Expand Down
32 changes: 32 additions & 0 deletions packages/providers/src/community/opencode/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ProviderCapabilities } from '../../types';

/**
* Opencode capabilities — conservative v1 declaration. Flags must reflect
* wired-up behavior; the dag-executor uses them to warn when a workflow node
* specifies a feature the provider silently ignores.
*
* structuredOutput is best-effort (prompt-engineering only — opencode has no
* SDK-level JSON mode). The provider appends a "respond with JSON matching
* this schema" instruction and parses the accumulated assistant text on
* session.idle. Reliable on instruction-following models; parse failures
* surface via the dag-executor's existing dag.structured_output_missing path.
*
* mcp/hooks/skills/agents/toolRestrictions: opencode manages its own tool
* ecosystem independently of Archon's layered tool configuration. These flags
* remain false until a mapping layer is implemented.
*/
export const OPENCODE_CAPABILITIES: ProviderCapabilities = {
sessionResume: true,
mcp: false,
hooks: false,
skills: false,
agents: false,
toolRestrictions: false,
structuredOutput: true,
envInjection: false,
costControl: false,
effortControl: false,
thinkingControl: false,
fallbackModel: false,
sandbox: false,
};
87 changes: 87 additions & 0 deletions packages/providers/src/community/opencode/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, test } from 'bun:test';

import { parseOpencodeConfig, parseOpencodeModel } from './config';

describe('parseOpencodeConfig', () => {
test('returns empty object for empty input', () => {
expect(parseOpencodeConfig({})).toEqual({});
});

test('parses valid model string', () => {
expect(parseOpencodeConfig({ model: 'ollama/qwen3:8b' })).toEqual({
model: 'ollama/qwen3:8b',
});
});

test('drops non-string model silently', () => {
expect(parseOpencodeConfig({ model: 123 })).toEqual({});
expect(parseOpencodeConfig({ model: null })).toEqual({});
expect(parseOpencodeConfig({ model: [] })).toEqual({});
});

test('parses opencodeBinaryDir', () => {
expect(parseOpencodeConfig({ opencodeBinaryDir: '/usr/local/bin' })).toEqual({
opencodeBinaryDir: '/usr/local/bin',
});
});

test('drops non-string opencodeBinaryDir silently', () => {
expect(parseOpencodeConfig({ opencodeBinaryDir: 42 })).toEqual({});
});

test('parses model and opencodeBinaryDir together', () => {
expect(
parseOpencodeConfig({ model: 'anthropic/claude-sonnet-4-5', opencodeBinaryDir: '/opt/bin' })
).toEqual({ model: 'anthropic/claude-sonnet-4-5', opencodeBinaryDir: '/opt/bin' });
});

test('ignores unknown keys', () => {
expect(parseOpencodeConfig({ model: 'ollama/qwen3:8b', futureField: 'x' })).toEqual({
model: 'ollama/qwen3:8b',
});
});

test('does not throw on malformed input', () => {
expect(() => parseOpencodeConfig({ model: undefined })).not.toThrow();
expect(() => parseOpencodeConfig({ model: {} })).not.toThrow();
});
});

describe('parseOpencodeModel', () => {
test('parses simple providerID/modelID', () => {
expect(parseOpencodeModel('ollama/qwen3:8b')).toEqual({
providerID: 'ollama',
modelID: 'qwen3:8b',
});
});

test('parses model with extra slashes in modelID', () => {
expect(parseOpencodeModel('openrouter/meta-llama/llama-3')).toEqual({
providerID: 'openrouter',
modelID: 'meta-llama/llama-3',
});
});

test('parses anthropic model', () => {
expect(parseOpencodeModel('anthropic/claude-sonnet-4-5')).toEqual({
providerID: 'anthropic',
modelID: 'claude-sonnet-4-5',
});
});

test('returns undefined for missing slash', () => {
expect(parseOpencodeModel('qwen3')).toBeUndefined();
});

test('returns undefined for leading slash', () => {
expect(parseOpencodeModel('/model')).toBeUndefined();
});

test('returns undefined for trailing slash', () => {
expect(parseOpencodeModel('ollama/')).toBeUndefined();
});

test('returns undefined for empty string', () => {
expect(parseOpencodeModel('')).toBeUndefined();
});
});
39 changes: 39 additions & 0 deletions packages/providers/src/community/opencode/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { OpencodeProviderDefaults } from '../../types';

export type { OpencodeProviderDefaults };

/**
* Parse raw YAML-derived config into typed opencode defaults.
* Defensive: invalid fields are dropped silently (matches parsePiConfig and
* parseCodexConfig — never throws, so broken user config can't prevent
* provider registration or workflow discovery).
*/
export function parseOpencodeConfig(raw: Record<string, unknown>): OpencodeProviderDefaults {
const result: OpencodeProviderDefaults = {};

if (typeof raw.model === 'string') {
result.model = raw.model;
}

if (typeof raw.opencodeBinaryDir === 'string') {
result.opencodeBinaryDir = raw.opencodeBinaryDir;
}

return result;
}

/**
* Parse an opencode model string into providerID and modelID.
* opencode models use '<providerID>/<modelID>' format (e.g. 'ollama/qwen3:8b').
* Returns undefined when the format is invalid.
*/
export function parseOpencodeModel(
model: string
): { providerID: string; modelID: string } | undefined {
const idx = model.indexOf('/');
if (idx <= 0 || idx === model.length - 1) return undefined;
return {
providerID: model.slice(0, idx),
modelID: model.slice(idx + 1),
};
}
Loading
Loading