Merge pull request #141 from aaaaron/integrate-opencode-pr62
Integrate opencode pr62 & pr104
This commit is contained in:
@@ -17,7 +17,7 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
};
|
||||
|
||||
const opencodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "opencode_local",
|
||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
@@ -52,7 +54,7 @@ Three registries consume these modules:
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local` or `codex_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
||||
|
||||
Returns the full organizational tree for the company.
|
||||
|
||||
## List Adapter Models
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/adapters/{adapterType}/models
|
||||
```
|
||||
|
||||
Returns selectable models for an adapter type.
|
||||
|
||||
- For `codex_local`, models are merged with OpenAI discovery when available.
|
||||
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
|
||||
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
|
||||
|
||||
## Config Revisions
|
||||
|
||||
```
|
||||
|
||||
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||
- **Capabilities** — short description of what this agent does
|
||||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
Paperclip validates the selected model against live `opencode models` output.
|
||||
|
||||
## Agent Hiring via Governance
|
||||
|
||||
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Added initial `opencode_local` adapter package for local OpenCode execution
|
||||
- Add local OpenCode adapter package with server/UI/CLI modules.
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function printToolEvent(part: Record<string, unknown>): void {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
const state = asRecord(part.state);
|
||||
const status = asString(state?.status);
|
||||
const input = state?.input;
|
||||
const output = asString(state?.output).replace(/\s+$/, "");
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const exit = asNumber(metadata?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exit) && exit !== 0);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||
if (input !== undefined) {
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const message =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
|
||||
if (status || output) {
|
||||
const summary = [
|
||||
"tool_result",
|
||||
status ? `status=${status}` : "",
|
||||
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
@@ -74,18 +59,36 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
if (part) {
|
||||
printToolEvent(part);
|
||||
} else {
|
||||
console.log(pc.yellow("tool_use"));
|
||||
const tool = asString(part?.tool, "tool");
|
||||
const state = asRecord(part?.state);
|
||||
const status = asString(state?.status);
|
||||
const summary = `tool_${status || "event"}: ${tool}`;
|
||||
const isError = status === "error";
|
||||
console.log((isError ? pc.red : pc.yellow)(summary));
|
||||
const input = state?.input;
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
const output = asString(state?.output) || asString(state?.error);
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,20 +96,18 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step_finish");
|
||||
const input = asNumber(tokens?.input);
|
||||
const output = asNumber(tokens?.output);
|
||||
const cached = asNumber(cache?.read);
|
||||
const cost = asNumber(part?.cost);
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
const input = asNumber(tokens?.input, 0);
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
const cached = asNumber(cache?.read, 0);
|
||||
const cost = asNumber(part?.cost, 0);
|
||||
const reason = asString(part?.reason, "step");
|
||||
console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const part = asRecord(parsed.part);
|
||||
const message = asString(parsed.message) || asString(part?.message) || line;
|
||||
console.log(pc.red(`error: ${message}`));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
export const type = "opencode_local";
|
||||
export const label = "OpenCode (local)";
|
||||
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
|
||||
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
|
||||
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
|
||||
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
|
||||
];
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const agentConfigurationDoc = `# opencode_local agent configuration
|
||||
|
||||
@@ -26,8 +20,8 @@ Don't use when:
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex)
|
||||
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant
|
||||
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- command (string, optional): defaults to "opencode"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
@@ -38,7 +32,9 @@ Operational fields:
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- OpenCode supports multiple providers and models. Use \
|
||||
\`opencode models\` to list available options in provider/model format.
|
||||
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
|
||||
- Runs are executed with: opencode run --format json ...
|
||||
- Prompts are passed as the final positional message argument.
|
||||
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||
`;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
@@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||
const raw = envOverrides[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
const raw = process.env[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function hasEffectiveEnvValue(envOverrides: Record<string, string>, key: string): boolean {
|
||||
return getEffectiveEnvValue(envOverrides, key).trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
function parseModelProvider(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return trimmed.slice(0, slash).toLowerCase();
|
||||
}
|
||||
|
||||
function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`;
|
||||
return /ProviderModelNotFoundError|provider model not found/i.test(haystack);
|
||||
}
|
||||
|
||||
type ProviderModelNotFoundDetails = {
|
||||
providerId: string | null;
|
||||
modelId: string | null;
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
function parseProviderModelNotFoundDetails(
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): ProviderModelNotFoundDetails | null {
|
||||
if (!isProviderModelNotFoundFailure(stdout, stderr)) return null;
|
||||
const haystack = `${stdout}\n${stderr}`;
|
||||
|
||||
const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i);
|
||||
const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i);
|
||||
const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i);
|
||||
const suggestions = suggestionsMatch
|
||||
? Array.from(
|
||||
suggestionsMatch[1].matchAll(/"([^"]+)"/g),
|
||||
(match) => match[1].trim(),
|
||||
).filter((value) => value.length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
providerId: providerMatch?.[1]?.trim().toLowerCase() || null,
|
||||
modelId: modelMatch?.[1]?.trim() || null,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
function formatModelNotFoundError(
|
||||
model: string,
|
||||
providerFromModel: string | null,
|
||||
details: ProviderModelNotFoundDetails | null,
|
||||
): string {
|
||||
const provider = details?.providerId || providerFromModel || "unknown";
|
||||
const missingModel = details?.modelId || model;
|
||||
const suggestions = details?.suggestions ?? [];
|
||||
const suggestionText =
|
||||
suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : "";
|
||||
return (
|
||||
`OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` +
|
||||
` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` +
|
||||
suggestionText
|
||||
);
|
||||
if (!trimmed.includes("/")) return null;
|
||||
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
@@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
const variant = asString(config.variant, asString(config.effort, ""));
|
||||
const model = asString(config.model, "").trim();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
@@ -209,52 +139,39 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveOpenCodeBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
@@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const resolvedInstructionsFilePath = instructionsFilePath
|
||||
? path.resolve(cwd, instructionsFilePath)
|
||||
: "";
|
||||
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
if (resolvedInstructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) return [] as string[];
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push(prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
@@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
@@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
env: runtimeEnv,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
return {
|
||||
proc,
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||
rawStderr: string;
|
||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
@@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
@@ -400,50 +313,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
|
||||
const fallbackErrorMessage = modelNotFound
|
||||
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
|
||||
: parsedError ||
|
||||
const rawExitCode = attempt.proc.exitCode;
|
||||
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
||||
const modelId = model || null;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
exitCode: synthesizedExitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
usage: {
|
||||
inputTokens: attempt.parsed.usage.inputTokens,
|
||||
outputTokens: attempt.parsed.usage.outputTokens,
|
||||
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||
},
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
provider: parseModelProvider(modelId),
|
||||
model: modelId,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.usage.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
const initialFailed =
|
||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
initialFailed &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
@@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listOpenCodeModels,
|
||||
discoverOpenCodeModels,
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
|
||||
33
packages/adapters/opencode-local/src/server/models.test.ts
Normal file
33
packages/adapters/opencode-local/src/server/models.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
listOpenCodeModels,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
|
||||
describe("openCode models", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
await expect(listOpenCodeModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects when model is missing", async () => {
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
|
||||
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
|
||||
});
|
||||
|
||||
it("rejects when discovery cannot run for configured model", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: "openai/gpt-5",
|
||||
}),
|
||||
).rejects.toThrow("Failed to start command");
|
||||
});
|
||||
});
|
||||
195
packages/adapters/opencode-local/src/server/models.ts
Normal file
195
packages/adapters/opencode-local/src/server/models.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
function resolveOpenCodeCommand(input: unknown): string {
|
||||
const envOverride =
|
||||
typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
|
||||
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
|
||||
: "opencode";
|
||||
return asString(input, envOverride);
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortModels(models: AdapterModel[]): AdapterModel[] {
|
||||
return [...models].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelsOutput(stdout: string): AdapterModel[] {
|
||||
const parsed: AdapterModel[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
|
||||
if (!firstToken.includes("/")) continue;
|
||||
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
|
||||
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
|
||||
if (!provider || !model) continue;
|
||||
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
|
||||
}
|
||||
return dedupeModels(parsed);
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envInput)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 20,
|
||||
graceSec: 3,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error("`opencode models` timed out.");
|
||||
}
|
||||
if ((result.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
|
||||
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(parseModelsOutput(result.stdout));
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModelsCached(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
const models = await discoverOpenCodeModels({ command, cwd, env });
|
||||
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
|
||||
model?: unknown;
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
}): Promise<AdapterModel[]> {
|
||||
const model = asString(input.model, "").trim();
|
||||
if (!model) {
|
||||
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
|
||||
const models = await discoverOpenCodeModelsCached({
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
|
||||
}
|
||||
|
||||
if (!models.some((entry) => entry.id === model)) {
|
||||
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
||||
throw new Error(
|
||||
`Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
|
||||
try {
|
||||
return await discoverOpenCodeModelsCached();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetOpenCodeModelsCacheForTests() {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
50
packages/adapters/opencode-local/src/server/parse.test.ts
Normal file
50
packages/adapters/opencode-local/src/server/parse.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
|
||||
describe("parseOpenCodeJsonl", () => {
|
||||
it("parses assistant text, usage, cost, and errors", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "text",
|
||||
sessionID: "session_123",
|
||||
part: { text: "Hello from OpenCode" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
sessionID: "session_123",
|
||||
part: {
|
||||
reason: "done",
|
||||
cost: 0.0025,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 40,
|
||||
reasoning: 10,
|
||||
cache: { read: 20, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
sessionID: "session_123",
|
||||
error: { message: "model unavailable" },
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseOpenCodeJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("session_123");
|
||||
expect(parsed.summary).toBe("Hello from OpenCode");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 20,
|
||||
outputTokens: 50,
|
||||
costUsd: 0.0025,
|
||||
});
|
||||
expect(parsed.errorMessage).toContain("model unavailable");
|
||||
});
|
||||
|
||||
it("detects unknown session errors", () => {
|
||||
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, "");
|
||||
const message = asString(rec.message, "").trim();
|
||||
if (message) return message;
|
||||
const data = parseObject(rec.data);
|
||||
const nestedMessage = asString(data.message, "").trim();
|
||||
if (nestedMessage) return nestedMessage;
|
||||
const name = asString(rec.name, "").trim();
|
||||
if (name) return name;
|
||||
const code = asString(rec.code, "").trim();
|
||||
if (code) return code;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
@@ -15,12 +22,12 @@ function asErrorText(value: unknown): string {
|
||||
export function parseOpenCodeJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let totalCostUsd = 0;
|
||||
const errors: string[] = [];
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
@@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSession = asString(event.sessionID, "").trim();
|
||||
if (foundSession) sessionId = foundSession;
|
||||
const currentSessionId = asString(event.sessionID, "").trim();
|
||||
if (currentSessionId) sessionId = currentSessionId;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
|
||||
@@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0);
|
||||
totalCostUsd += asNumber(part.cost, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
|
||||
usage.costUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = parseObject(event.part);
|
||||
const state = parseObject(part.state);
|
||||
if (asString(state.status, "") === "error") {
|
||||
const text = asString(state.error, "").trim();
|
||||
if (text) errors.push(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const part = parseObject(event.part);
|
||||
const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim();
|
||||
if (msg) errorMessage = msg;
|
||||
const text = errorText(event.error ?? event.message).trim();
|
||||
if (text) errors.push(text);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||
errorMessage,
|
||||
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +92,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test(
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
@@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||
const raw = envOverrides[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
const raw = process.env[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
@@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const OPENCODE_AUTH_REQUIRED_RE =
|
||||
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i;
|
||||
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
|
||||
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
@@ -70,7 +59,7 @@ export async function testEnvironment(
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
@@ -90,7 +79,17 @@ export async function testEnvironment(
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
checks.push({
|
||||
code: "opencode_command_skipped",
|
||||
level: "warn",
|
||||
message: "Skipped command check because working directory validation failed.",
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -106,84 +105,112 @@ export async function testEnvironment(
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY");
|
||||
const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY");
|
||||
if (isNonEmpty(effectiveOpenAiKey)) {
|
||||
const source = configDefinesOpenAiKey ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for OpenCode authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
|
||||
hint: configDefinesOpenAiKey
|
||||
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
|
||||
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||
|
||||
let modelValidationPassed = false;
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "opencode")) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_skipped_custom_command",
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `opencode`.",
|
||||
detail: command,
|
||||
hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.",
|
||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim();
|
||||
const variant = asString(config.variant, asString(config.effort, "")).trim();
|
||||
checks.push({
|
||||
code: "opencode_models_empty",
|
||||
level: "error",
|
||||
message: "OpenCode returned no models.",
|
||||
hint: "Run `opencode models` and verify provider authentication.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovery_failed",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "OpenCode model discovery failed.",
|
||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
if (!configuredModel) {
|
||||
checks.push({
|
||||
code: "opencode_model_required",
|
||||
level: "error",
|
||||
message: "OpenCode requires a configured model in provider/model format.",
|
||||
hint: "Set adapterConfig.model using an ID from `opencode models`.",
|
||||
});
|
||||
} else if (canRunProbe) {
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: configuredModel,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
checks.push({
|
||||
code: "opencode_model_configured",
|
||||
level: "info",
|
||||
message: `Configured model: ${configuredModel}`,
|
||||
});
|
||||
modelValidationPassed = true;
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_model_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
||||
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && modelValidationPassed) {
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
const probeModel = configuredModel;
|
||||
|
||||
const args = ["run", "--format", "json"];
|
||||
if (model) args.push("--model", model);
|
||||
args.push("--model", probeModel);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence);
|
||||
const modelProvider = (() => {
|
||||
const slash = model.indexOf("/");
|
||||
if (slash <= 0) return "openai";
|
||||
return model.slice(0, slash).toLowerCase();
|
||||
})();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "OpenCode hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify `opencode run --format json \"Respond with hello\"` manually.",
|
||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
@@ -196,24 +223,16 @@ export async function testEnvironment(
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
|
||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||
}),
|
||||
});
|
||||
} else if (modelNotFound) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: `OpenCode could not run model \`${model}\`.`,
|
||||
...(detail ? { detail } : {}),
|
||||
hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`,
|
||||
});
|
||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "OpenCode CLI is installed, but authentication is not ready.",
|
||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.",
|
||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
@@ -221,9 +240,17 @@ export async function testEnvironment(
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.",
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
@@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
ac.graceSec = 20;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
|
||||
@@ -21,26 +21,57 @@ function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const msg =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return String(value);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonLike(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const part = asRecord(parsed.part);
|
||||
if (!part) return [{ kind: "system", ts, text: "tool event" }];
|
||||
|
||||
const toolName = asString(part.tool, "tool");
|
||||
const state = asRecord(part.state);
|
||||
const input = state?.input ?? {};
|
||||
const callEntry: TranscriptEntry = {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
};
|
||||
|
||||
const status = asString(state?.status);
|
||||
if (status !== "completed" && status !== "error") return [callEntry];
|
||||
|
||||
const output =
|
||||
asString(state?.output) ||
|
||||
asString(state?.error) ||
|
||||
asString(part.title) ||
|
||||
`${toolName} ${status}`;
|
||||
|
||||
return [
|
||||
callEntry,
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: asString(part.id, toolName),
|
||||
content: output,
|
||||
isError: status === "error",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
@@ -51,6 +82,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "thinking", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
return parseToolUse(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [
|
||||
@@ -62,93 +111,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
||||
const toolName = asString(part?.tool, "tool");
|
||||
const state = asRecord(part?.state);
|
||||
const input = state?.input ?? {};
|
||||
const output = asString(state?.output).trim();
|
||||
const status = asString(state?.status).trim();
|
||||
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exitCode) && exitCode !== 0);
|
||||
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
},
|
||||
];
|
||||
|
||||
if (status || output) {
|
||||
const lines: string[] = [];
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
if (isJsonLike(output)) {
|
||||
try {
|
||||
lines.push(JSON.stringify(JSON.parse(output), null, 2));
|
||||
} catch {
|
||||
lines.push(output);
|
||||
}
|
||||
} else {
|
||||
lines.push(output);
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: lines.join("\n").trim() || "tool completed",
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason);
|
||||
const reason = asString(part?.reason, "step");
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
return [
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: reason,
|
||||
inputTokens: asNumber(tokens?.input),
|
||||
outputTokens: asNumber(tokens?.output),
|
||||
cachedTokens: asNumber(cache?.read),
|
||||
costUsd: asNumber(part?.cost),
|
||||
subtype: reason || "step_finish",
|
||||
isError: reason === "error" || reason === "failed",
|
||||
inputTokens: asNumber(tokens?.input, 0),
|
||||
outputTokens: output,
|
||||
cachedTokens: asNumber(cache?.read, 0),
|
||||
costUsd: asNumber(part?.cost, 0),
|
||||
subtype: reason,
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message =
|
||||
asString(parsed.message) ||
|
||||
asString(asRecord(parsed.part)?.message) ||
|
||||
stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) ||
|
||||
line;
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
const text = errorText(parsed.error ?? parsed.message);
|
||||
return [{ kind: "stderr", ts, text: text || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
7
packages/adapters/opencode-local/vitest.config.ts
Normal file
7
packages/adapters/opencode-local/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -21,7 +21,15 @@ export const AGENT_STATUSES = [
|
||||
] as const;
|
||||
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
||||
|
||||
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const;
|
||||
export const AGENT_ADAPTER_TYPES = [
|
||||
"process",
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
export const AGENT_ROLES = [
|
||||
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -80,7 +80,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -96,7 +96,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -112,7 +112,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -141,7 +141,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -155,6 +155,9 @@ importers:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.12.0
|
||||
version: 22.19.11
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -173,7 +176,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.9
|
||||
version: 0.31.9
|
||||
@@ -185,7 +188,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
@@ -228,7 +231,7 @@ importers:
|
||||
version: link:../packages/shared
|
||||
better-auth:
|
||||
specifier: 1.4.18
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
detect-port:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
@@ -277,7 +280,7 @@ importers:
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
@@ -295,10 +298,10 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.1.0
|
||||
version: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
@@ -2837,8 +2840,8 @@ packages:
|
||||
'@types/node@22.19.11':
|
||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||
|
||||
'@types/node@24.11.0':
|
||||
resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
|
||||
'@types/node@24.12.0':
|
||||
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
|
||||
|
||||
'@types/node@25.2.3':
|
||||
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
||||
@@ -8159,7 +8162,7 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
@@ -8168,7 +8171,7 @@ snapshots:
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
@@ -8186,7 +8189,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -8221,7 +8224,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.11.0':
|
||||
'@types/node@24.12.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -8243,18 +8246,18 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
@@ -8268,7 +8271,7 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
@@ -8292,13 +8295,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
@@ -8383,7 +8386,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.9.19: {}
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
@@ -8403,7 +8406,7 @@ snapshots:
|
||||
pg: 8.18.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
better-call@1.1.8(zod@4.3.6):
|
||||
dependencies:
|
||||
@@ -10644,13 +10647,13 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-node@3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite-node@3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -10686,7 +10689,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10695,7 +10698,7 @@ snapshots:
|
||||
rollup: 4.57.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
@@ -10716,7 +10719,7 @@ snapshots:
|
||||
lightningcss: 1.30.2
|
||||
tsx: 4.21.0
|
||||
|
||||
vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10725,7 +10728,7 @@ snapshots:
|
||||
rollup: 4.57.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
@@ -10746,11 +10749,11 @@ snapshots:
|
||||
lightningcss: 1.30.2
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -10768,12 +10771,12 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -32,6 +32,7 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw",
|
||||
];
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -245,6 +245,7 @@ pnpm --filter @paperclipai/adapter-utils build
|
||||
pnpm --filter @paperclipai/db build
|
||||
pnpm --filter @paperclipai/adapter-claude-local build
|
||||
pnpm --filter @paperclipai/adapter-codex-local build
|
||||
pnpm --filter @paperclipai/adapter-opencode-local build
|
||||
pnpm --filter @paperclipai/adapter-openclaw build
|
||||
pnpm --filter @paperclipai/server build
|
||||
|
||||
@@ -280,7 +281,7 @@ if [ "$dry_run" = true ]; then
|
||||
echo ""
|
||||
echo " Preview what would be published:"
|
||||
for dir in packages/shared packages/adapter-utils packages/db \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \
|
||||
server cli; do
|
||||
echo " --- $dir ---"
|
||||
cd "$REPO_ROOT/$dir"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
@@ -8,9 +9,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
|
||||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetCodexModelsCacheForTests();
|
||||
resetCursorModelsCacheForTests();
|
||||
setCursorModelsRunnerForTests(null);
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -60,6 +63,7 @@ describe("adapter model listing", () => {
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
|
||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||
setCursorModelsRunnerForTests(() => ({
|
||||
status: null,
|
||||
@@ -90,4 +94,11 @@ describe("adapter model listing", () => {
|
||||
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
|
||||
expect(first.some((model) => model.id === "composer-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no opencode models when opencode command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as opencodeExecute,
|
||||
testEnvironment as opencodeTestEnvironment,
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
execute as openCodeExecute,
|
||||
testEnvironment as openCodeTestEnvironment,
|
||||
sessionCodec as openCodeSessionCodec,
|
||||
listOpenCodeModels,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
execute as openclawExecute,
|
||||
testEnvironment as openclawTestEnvironment,
|
||||
@@ -58,16 +61,6 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const opencodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: opencodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
@@ -89,8 +82,19 @@ const openclawAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
@@ -198,15 +198,34 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
||||
const trimmed = candidatePath.trim();
|
||||
if (path.isAbsolute(trimmed)) return trimmed;
|
||||
@@ -338,7 +357,9 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/adapters/:type/models", async (req, res) => {
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
@@ -592,6 +613,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
@@ -727,6 +753,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
req.body.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const agent = await svc.create(companyId, {
|
||||
...req.body,
|
||||
@@ -906,6 +937,27 @@ export function agentRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
rawEffectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
effectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
recordRevision: {
|
||||
|
||||
18
ui/public/brands/opencode-logo-dark-square.svg
Normal file
18
ui/public/brands/opencode-logo-dark-square.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86283)">
|
||||
<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86283)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86283">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
18
ui/public/brands/opencode-logo-light-square.svg
Normal file
18
ui/public/brands/opencode-logo-light-square.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86274)">
|
||||
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86274)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86274">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -8,7 +8,7 @@ import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the prompt at runtime.";
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function OpenCodeLocalConfigFields({
|
||||
isCreate,
|
||||
|
||||
@@ -117,7 +117,10 @@ export const agentsApi = {
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
||||
adapterModels: (companyId: string, type: string) =>
|
||||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractModelName, extractProviderId } from "../lib/model-utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
@@ -42,6 +42,7 @@ import { getUIAdapter } from "../adapters";
|
||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
@@ -132,7 +133,7 @@ const codexThinkingEffortOptions = [
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
const opencodeVariantOptions = [
|
||||
const openCodeThinkingEffortOptions = [
|
||||
{ id: "", label: "Auto" },
|
||||
{ id: "minimal", label: "Minimal" },
|
||||
{ id: "low", label: "Low" },
|
||||
@@ -279,9 +280,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
const { data: fetchedModels } = useQuery({
|
||||
queryKey: ["adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
||||
const {
|
||||
data: fetchedModels,
|
||||
error: fetchedModelsError,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
|
||||
@@ -348,7 +355,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? opencodeVariantOptions
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
@@ -483,7 +490,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
} else if (t === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (t === "opencode_local") {
|
||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
nextValues.model = "";
|
||||
}
|
||||
set!(nextValues);
|
||||
} else {
|
||||
@@ -498,8 +505,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? DEFAULT_CODEX_LOCAL_MODEL
|
||||
: t === "cursor"
|
||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: t === "opencode_local"
|
||||
? DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
@@ -622,7 +627,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{fetchedModelsError instanceof Error
|
||||
? fetchedModelsError.message
|
||||
: "Failed to load adapter models."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ThinkingEffortDropdown
|
||||
value={currentThinkingEffort}
|
||||
@@ -898,7 +913,10 @@ function AdapterTypeDropdown({
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{adapterLabels[value] ?? value}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -918,7 +936,10 @@ function AdapterTypeDropdown({
|
||||
if (!item.comingSoon) onChange(item.value);
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
{item.comingSoon && (
|
||||
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
||||
)}
|
||||
@@ -1184,20 +1205,56 @@ function ModelDropdown({
|
||||
onChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const filteredModels = models.filter((m) => {
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
|
||||
const provider = extractProviderId(m.id) ?? "";
|
||||
return (
|
||||
m.id.toLowerCase().includes(q) ||
|
||||
m.label.toLowerCase().includes(q) ||
|
||||
provider.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [models, modelSearch]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return [
|
||||
{
|
||||
provider: "models",
|
||||
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
},
|
||||
];
|
||||
}
|
||||
const map = new Map<string, AdapterModel[]>();
|
||||
for (const model of filteredModels) {
|
||||
const provider = extractProviderId(model.id) ?? "other";
|
||||
const group = map.get(provider) ?? [];
|
||||
group.push(model);
|
||||
map.set(provider, group);
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, entries]) => ({
|
||||
provider,
|
||||
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}));
|
||||
}, [filteredModels, groupByProvider]);
|
||||
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
@@ -1211,7 +1268,9 @@ function ModelDropdown({
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!value && "text-muted-foreground")}>
|
||||
{selected ? selected.label : value || "Default"}
|
||||
{selected
|
||||
? selected.label
|
||||
: value || (allowDefault ? "Default" : required ? "Select model (required)" : "Select model")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -1225,6 +1284,7 @@ function ModelDropdown({
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
@@ -1237,11 +1297,19 @@ function ModelDropdown({
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{filteredModels.map((m) => (
|
||||
)}
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mb-1 last:mb-0">
|
||||
{groupByProvider && (
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{group.provider} ({group.entries.length})
|
||||
</div>
|
||||
)}
|
||||
{group.entries.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === value && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -1249,10 +1317,13 @@ function ModelDropdown({
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
<span className="block w-full text-left truncate" title={m.id}>
|
||||
{groupByProvider ? extractModelName(m.id) : m.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
)}
|
||||
|
||||
@@ -55,14 +55,23 @@ export function NewAgentDialog() {
|
||||
enabled: !!selectedCompanyId && newAgentOpen,
|
||||
});
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", configValues.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
|
||||
enabled: newAgentOpen,
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
} = useQuery({
|
||||
queryKey:
|
||||
selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
|
||||
: ["agents", "none", "adapter-models", configValues.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
|
||||
enabled: Boolean(selectedCompanyId) && newAgentOpen,
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Auto-fill for CEO
|
||||
useEffect(() => {
|
||||
@@ -82,6 +91,9 @@ export function NewAgentDialog() {
|
||||
closeNewAgent();
|
||||
navigate(agentUrl(result.agent));
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
@@ -91,6 +103,7 @@ export function NewAgentDialog() {
|
||||
setReportsTo("");
|
||||
setConfigValues(defaultCreateValues);
|
||||
setExpanded(true);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function buildAdapterConfig() {
|
||||
@@ -100,6 +113,35 @@ export function NewAgentDialog() {
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
setFormError(null);
|
||||
if (configValues.adapterType === "opencode_local") {
|
||||
const selectedModel = configValues.model.trim();
|
||||
if (!selectedModel) {
|
||||
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||
return;
|
||||
}
|
||||
if (adapterModelsError) {
|
||||
setFormError(
|
||||
adapterModelsError instanceof Error
|
||||
? adapterModelsError.message
|
||||
: "Failed to load OpenCode models.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setFormError("OpenCode models are still loading. Please wait and try again.");
|
||||
return;
|
||||
}
|
||||
const discovered = adapterModels ?? [];
|
||||
if (!discovered.some((entry) => entry.id === selectedModel)) {
|
||||
setFormError(
|
||||
discovered.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModel}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
createAgent.mutate({
|
||||
name: name.trim(),
|
||||
role: effectiveRole,
|
||||
@@ -281,6 +323,11 @@ export function NewAgentDialog() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isFirstAgent ? "This will be the CEO" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{formError && (
|
||||
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end px-4 pb-3">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createAgent.isPending}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
@@ -115,6 +116,8 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "claude_local") {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
if (adapterType === "claude_local" && input.chrome) {
|
||||
@@ -248,9 +251,12 @@ export function NewIssueDialog() {
|
||||
}, [agents, orderedProjects]);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
queryKey:
|
||||
effectiveCompanyId && assigneeAdapterType
|
||||
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
|
||||
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
||||
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
@@ -496,12 +502,21 @@ export function NewIssueDialog() {
|
||||
[orderedProjects],
|
||||
);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
(assigneeAdapterModels ?? []).map((model) => ({
|
||||
() => {
|
||||
return [...(assigneeAdapterModels ?? [])]
|
||||
.sort((a, b) => {
|
||||
const providerA = extractProviderIdWithFallback(a.id);
|
||||
const providerB = extractProviderIdWithFallback(b.id);
|
||||
const byProvider = providerA.localeCompare(providerB);
|
||||
if (byProvider !== 0) return byProvider;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
searchText: model.id,
|
||||
})),
|
||||
searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
|
||||
}));
|
||||
},
|
||||
[assigneeAdapterModels],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import {
|
||||
@@ -24,10 +25,10 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { HintIcon } from "./agent-config-primitives";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
@@ -76,6 +77,7 @@ export function OnboardingWizard() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
|
||||
// Step 1
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
@@ -149,10 +151,18 @@ export function OnboardingWizard() {
|
||||
if (step === 3) autoResizeTextarea();
|
||||
}, [step, taskDescription, autoResizeTextarea]);
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
||||
enabled: onboardingOpen && step === 2
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
} = useQuery({
|
||||
queryKey:
|
||||
createdCompanyId
|
||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
||||
});
|
||||
const isLocalAdapter =
|
||||
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
||||
@@ -182,6 +192,41 @@ export function OnboardingWizard() {
|
||||
adapterType === "claude_local" &&
|
||||
adapterEnvResult?.status === "fail" &&
|
||||
hasAnthropicApiKeyOverrideCheck;
|
||||
const filteredModels = useMemo(() => {
|
||||
const query = modelSearch.trim().toLowerCase();
|
||||
return (adapterModels ?? []).filter((entry) => {
|
||||
if (!query) return true;
|
||||
const provider = extractProviderIdWithFallback(entry.id, "");
|
||||
return (
|
||||
entry.id.toLowerCase().includes(query) ||
|
||||
entry.label.toLowerCase().includes(query) ||
|
||||
provider.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [adapterModels, modelSearch]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (adapterType !== "opencode_local") {
|
||||
return [
|
||||
{
|
||||
provider: "models",
|
||||
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
},
|
||||
];
|
||||
}
|
||||
const groups = new Map<string, Array<{ id: string; label: string }>>();
|
||||
for (const entry of filteredModels) {
|
||||
const provider = extractProviderIdWithFallback(entry.id);
|
||||
const bucket = groups.get(provider) ?? [];
|
||||
bucket.push(entry);
|
||||
groups.set(provider, bucket);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, entries]) => ({
|
||||
provider,
|
||||
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}));
|
||||
}, [filteredModels, adapterType]);
|
||||
|
||||
function reset() {
|
||||
setStep(1);
|
||||
@@ -225,8 +270,6 @@ export function OnboardingWizard() {
|
||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||
: adapterType === "cursor"
|
||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: adapterType === "opencode_local"
|
||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: model,
|
||||
command,
|
||||
args,
|
||||
@@ -315,6 +358,35 @@ export function OnboardingWizard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (adapterType === "opencode_local") {
|
||||
const selectedModelId = model.trim();
|
||||
if (!selectedModelId) {
|
||||
setError("OpenCode requires an explicit model in provider/model format.");
|
||||
return;
|
||||
}
|
||||
if (adapterModelsError) {
|
||||
setError(
|
||||
adapterModelsError instanceof Error
|
||||
? adapterModelsError.message
|
||||
: "Failed to load OpenCode models.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setError("OpenCode models are still loading. Please wait and try again.");
|
||||
return;
|
||||
}
|
||||
const discoveredModels = adapterModels ?? [];
|
||||
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
|
||||
setError(
|
||||
discoveredModels.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalAdapter) {
|
||||
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
||||
if (!result) return;
|
||||
@@ -590,8 +662,8 @@ export function OnboardingWizard() {
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
icon: Code,
|
||||
desc: "Local OpenCode agent"
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
@@ -640,9 +712,14 @@ export function OnboardingWizard() {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
} else if (nextType === "cursor" && !model) {
|
||||
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||
} else if (nextType === "opencode_local" && !model) {
|
||||
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
}
|
||||
if (nextType === "opencode_local") {
|
||||
if (!model.includes("/")) {
|
||||
setModel("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
@@ -688,7 +765,13 @@ export function OnboardingWizard() {
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Model
|
||||
</label>
|
||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
||||
<Popover
|
||||
open={modelOpen}
|
||||
onOpenChange={(next) => {
|
||||
setModelOpen(next);
|
||||
if (!next) setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span
|
||||
@@ -698,7 +781,10 @@ export function OnboardingWizard() {
|
||||
>
|
||||
{selectedModel
|
||||
? selectedModel.label
|
||||
: model || "Default"}
|
||||
: model ||
|
||||
(adapterType === "opencode_local"
|
||||
? "Select model (required)"
|
||||
: "Default")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -707,6 +793,14 @@ export function OnboardingWizard() {
|
||||
className="w-[var(--radix-popover-trigger-width)] p-1"
|
||||
align="start"
|
||||
>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{adapterType !== "opencode_local" && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
@@ -719,11 +813,20 @@ export function OnboardingWizard() {
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{(adapterModels ?? []).map((m) => (
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mb-1 last:mb-0">
|
||||
{adapterType === "opencode_local" && (
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{group.provider} ({group.entries.length})
|
||||
</div>
|
||||
)}
|
||||
{group.entries.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === model && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -731,12 +834,19 @@ export function OnboardingWizard() {
|
||||
setModelOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{m.id}
|
||||
<span className="block w-full text-left truncate" title={m.id}>
|
||||
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No models discovered.
|
||||
</p>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -802,7 +912,7 @@ export function OnboardingWizard() {
|
||||
: adapterType === "codex_local"
|
||||
? `${effectiveAdapterCommand} exec --json -`
|
||||
: adapterType === "opencode_local"
|
||||
? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"`
|
||||
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface OpenCodeLogoIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src="/brands/opencode-logo-light-square.svg"
|
||||
alt="OpenCode"
|
||||
className={cn("dark:hidden", className)}
|
||||
/>
|
||||
<img
|
||||
src="/brands/opencode-logo-dark-square.svg"
|
||||
alt="OpenCode"
|
||||
className={cn("hidden dark:block", className)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
||||
role: "Organizational role. Determines position and capabilities.",
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
@@ -34,7 +34,7 @@ export const help: Record<string, string> = {
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||
command: "The command to execute (e.g. node, python).",
|
||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
|
||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
|
||||
16
ui/src/lib/model-utils.ts
Normal file
16
ui/src/lib/model-utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function extractProviderId(modelId: string): string | null {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed.includes("/")) return null;
|
||||
const provider = trimmed.slice(0, trimmed.indexOf("/")).trim();
|
||||
return provider || null;
|
||||
}
|
||||
|
||||
export function extractProviderIdWithFallback(modelId: string, fallback = "other"): string {
|
||||
return extractProviderId(modelId) ?? fallback;
|
||||
}
|
||||
|
||||
export function extractModelName(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed.includes("/")) return trimmed;
|
||||
return trimmed.slice(trimmed.indexOf("/") + 1).trim();
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export const queryKeys = {
|
||||
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "adapter-models", adapterType] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
||||
@@ -1155,8 +1155,12 @@ function ConfigurationTab({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", agent.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(agent.adapterType),
|
||||
queryKey:
|
||||
companyId
|
||||
? queryKeys.agents.adapterModels(companyId, agent.adapterType)
|
||||
: ["agents", "none", "adapter-models", agent.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
const updateAgent = useMutation({
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: ["packages/db", "server", "ui", "cli"],
|
||||
projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user