Address PR feedback for OpenCode integration

This commit is contained in:
Konan69
2026-03-05 15:52:59 +01:00
parent 6a101e0da1
commit 69c453b274
12 changed files with 87 additions and 55 deletions

View File

@@ -115,7 +115,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
model, model,
command, command,
cwd, cwd,
env, env: runtimeEnv,
}); });
const timeoutSec = asNumber(config.timeoutSec, 0); const timeoutSec = asNumber(config.timeoutSec, 0);
@@ -244,7 +244,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 const resolvedSessionParams = resolvedSessionId
? ({ ? ({
sessionId: resolvedSessionId, sessionId: resolvedSessionId,
@@ -287,7 +289,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stderr: attempt.proc.stderr, stderr: attempt.proc.stderr,
}, },
summary: attempt.parsed.summary, summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
}; };
}; };

View File

@@ -1,12 +1,16 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils"; import type { AdapterModel } from "@paperclipai/adapter-utils";
import { import {
asString, asString,
ensurePathInEnv,
runChildProcess, runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000; const MODELS_CACHE_TTL_MS = 60_000;
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>(); 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[] { function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>(); const seen = new Set<string>();
@@ -61,14 +65,30 @@ function normalizeEnv(input: unknown): Record<string, string> {
return env; 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>) { function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env) const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`) .map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n"); .join("\n");
return `${command}\n${cwd}\n${envKey}`; 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: { export async function discoverOpenCodeModels(input: {
command?: unknown; command?: unknown;
cwd?: unknown; cwd?: unknown;
@@ -83,6 +103,7 @@ export async function discoverOpenCodeModels(input: {
); );
const cwd = asString(input.cwd, process.cwd()); const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env); const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const result = await runChildProcess( const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -90,7 +111,7 @@ export async function discoverOpenCodeModels(input: {
["models"], ["models"],
{ {
cwd, cwd,
env, env: runtimeEnv,
timeoutSec: 20, timeoutSec: 20,
graceSec: 3, graceSec: 3,
onLog: async () => {}, onLog: async () => {},
@@ -124,6 +145,7 @@ export async function discoverOpenCodeModelsCached(input: {
const env = normalizeEnv(input.env); const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env); const key = discoveryCacheKey(command, cwd, env);
const now = Date.now(); const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key); const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models; if (cached && cached.expiresAt > now) return cached.models;

View File

@@ -38,6 +38,15 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
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 = const OPENCODE_AUTH_REQUIRED_RE =
/(?: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; /(?: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;
@@ -50,7 +59,7 @@ export async function testEnvironment(
const cwd = asString(config.cwd, process.cwd()); const cwd = asString(config.cwd, process.cwd());
try { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({ checks.push({
code: "opencode_cwd_valid", code: "opencode_cwd_valid",
level: "info", level: "info",
@@ -70,7 +79,7 @@ export async function testEnvironment(
for (const [key, value] of Object.entries(envConfig)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureCommandResolvable(command, cwd, runtimeEnv);
@@ -91,17 +100,15 @@ export async function testEnvironment(
const canRunProbe = const canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let discoveredModels: string[] = [];
let modelValidationPassed = false; let modelValidationPassed = false;
if (canRunProbe) { if (canRunProbe) {
try { try {
const discovered = await discoverOpenCodeModels({ command, cwd, env }); const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
discoveredModels = discovered.map((item) => item.id); if (discovered.length > 0) {
if (discoveredModels.length > 0) {
checks.push({ checks.push({
code: "opencode_models_discovered", code: "opencode_models_discovered",
level: "info", level: "info",
message: `Discovered ${discoveredModels.length} model(s) from OpenCode providers.`, message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
}); });
} else { } else {
checks.push({ checks.push({
@@ -135,7 +142,7 @@ export async function testEnvironment(
model: configuredModel, model: configuredModel,
command, command,
cwd, cwd,
env, env: runtimeEnv,
}); });
checks.push({ checks.push({
code: "opencode_model_configured", code: "opencode_model_configured",
@@ -173,7 +180,7 @@ export async function testEnvironment(
args, args,
{ {
cwd, cwd,
env, env: runtimeEnv,
timeoutSec: 60, timeoutSec: 60,
graceSec: 5, graceSec: 5,
stdin: "Respond with hello.", stdin: "Respond with hello.",

View File

@@ -57,6 +57,8 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.model) ac.model = v.model; if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort; 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.timeoutSec = 0;
ac.graceSec = 20; ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings); const env = parseEnvBindings(v.envBindings);

View File

@@ -27,7 +27,6 @@ import {
} from "@paperclipai/adapter-opencode-local/server"; } from "@paperclipai/adapter-opencode-local/server";
import { import {
agentConfigurationDoc as openCodeAgentConfigurationDoc, agentConfigurationDoc as openCodeAgentConfigurationDoc,
models as openCodeModels,
} from "@paperclipai/adapter-opencode-local"; } from "@paperclipai/adapter-opencode-local";
import { listCodexModels } from "./codex-models.js"; import { listCodexModels } from "./codex-models.js";
import { processAdapter } from "./process/index.js"; import { processAdapter } from "./process/index.js";
@@ -68,7 +67,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
execute: openCodeExecute, execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment, testEnvironment: openCodeTestEnvironment,
sessionCodec: openCodeSessionCodec, sessionCodec: openCodeSessionCodec,
models: openCodeModels, models: [],
listModels: listOpenCodeModels, listModels: listOpenCodeModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
agentConfigurationDoc: openCodeAgentConfigurationDoc, agentConfigurationDoc: openCodeAgentConfigurationDoc,

View File

@@ -933,9 +933,14 @@ export function agentRoutes(db: Db) {
Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {}) ? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {}); : (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
rawEffectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints( await assertAdapterConfigConstraints(
existing.companyId, existing.companyId,
requestedAdapterType, requestedAdapterType,

View File

@@ -118,7 +118,9 @@ export const agentsApi = {
resetSession: (id: string, taskKey?: string | null, companyId?: string) => resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
adapterModels: (companyId: string, type: string) => adapterModels: (companyId: string, type: string) =>
api.get<AdapterModel[]>(`/companies/${companyId}/adapters/${type}/models`), api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
),
testEnvironment: ( testEnvironment: (
companyId: string, companyId: string,
type: string, type: string,

View File

@@ -23,6 +23,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { extractModelName, extractProviderId } from "../lib/model-utils";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { import {
@@ -123,19 +124,6 @@ function formatArgList(value: unknown): string {
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
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;
}
function extractModelName(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return trimmed;
return trimmed.slice(trimmed.indexOf("/") + 1);
}
const codexThinkingEffortOptions = [ const codexThinkingEffortOptions = [
{ id: "", label: "Auto" }, { id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" }, { id: "minimal", label: "Minimal" },

View File

@@ -58,6 +58,8 @@ export function NewAgentDialog() {
const { const {
data: adapterModels, data: adapterModels,
error: adapterModelsError, error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
} = useQuery({ } = useQuery({
queryKey: queryKey:
selectedCompanyId selectedCompanyId
@@ -126,6 +128,10 @@ export function NewAgentDialog() {
); );
return; return;
} }
if (adapterModelsLoading || adapterModelsFetching) {
setFormError("OpenCode models are still loading. Please wait and try again.");
return;
}
const discovered = adapterModels ?? []; const discovered = adapterModels ?? [];
if (!discovered.some((entry) => entry.id === selectedModel)) { if (!discovered.some((entry) => entry.id === selectedModel)) {
setFormError( setFormError(

View File

@@ -36,6 +36,7 @@ import {
Paperclip, Paperclip,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils";
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
@@ -54,12 +55,6 @@ function getContrastTextColor(hexColor: string): string {
return luminance > 0.5 ? "#000000" : "#ffffff"; return luminance > 0.5 ? "#000000" : "#ffffff";
} }
function extractProviderId(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return "other";
return trimmed.slice(0, trimmed.indexOf("/")).trim() || "other";
}
interface IssueDraft { interface IssueDraft {
title: string; title: string;
description: string; description: string;
@@ -505,8 +500,8 @@ export function NewIssueDialog() {
() => { () => {
return [...(assigneeAdapterModels ?? [])] return [...(assigneeAdapterModels ?? [])]
.sort((a, b) => { .sort((a, b) => {
const providerA = extractProviderId(a.id); const providerA = extractProviderIdWithFallback(a.id);
const providerB = extractProviderId(b.id); const providerB = extractProviderIdWithFallback(b.id);
const byProvider = providerA.localeCompare(providerB); const byProvider = providerA.localeCompare(providerB);
if (byProvider !== 0) return byProvider; if (byProvider !== 0) return byProvider;
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
@@ -514,7 +509,7 @@ export function NewIssueDialog() {
.map((model) => ({ .map((model) => ({
id: model.id, id: model.id,
label: model.label, label: model.label,
searchText: `${model.id} ${extractProviderId(model.id)}`, searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
})); }));
}, },
[assigneeAdapterModels], [assigneeAdapterModels],

View File

@@ -17,6 +17,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
import { getUIAdapter } from "../adapters"; import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults"; import { defaultCreateValues } from "./agent-config-defaults";
import { import {
@@ -61,19 +62,6 @@ Ensure you have a folder agents/ceo and then download this AGENTS.md as well as
And after you've finished that, hire yourself a Founding Engineer agent`; And after you've finished that, hire yourself a Founding Engineer agent`;
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;
}
function extractModelName(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return trimmed;
return trimmed.slice(trimmed.indexOf("/") + 1);
}
export function OnboardingWizard() { export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
@@ -185,7 +173,7 @@ export function OnboardingWizard() {
const query = modelSearch.trim().toLowerCase(); const query = modelSearch.trim().toLowerCase();
return (adapterModels ?? []).filter((entry) => { return (adapterModels ?? []).filter((entry) => {
if (!query) return true; if (!query) return true;
const provider = extractProviderId(entry.id) ?? ""; const provider = extractProviderIdWithFallback(entry.id, "");
return ( return (
entry.id.toLowerCase().includes(query) || entry.id.toLowerCase().includes(query) ||
entry.label.toLowerCase().includes(query) || entry.label.toLowerCase().includes(query) ||
@@ -204,7 +192,7 @@ export function OnboardingWizard() {
} }
const groups = new Map<string, Array<{ id: string; label: string }>>(); const groups = new Map<string, Array<{ id: string; label: string }>>();
for (const entry of filteredModels) { for (const entry of filteredModels) {
const provider = extractProviderId(entry.id) ?? "other"; const provider = extractProviderIdWithFallback(entry.id);
const bucket = groups.get(provider) ?? []; const bucket = groups.get(provider) ?? [];
bucket.push(entry); bucket.push(entry);
groups.set(provider, bucket); groups.set(provider, bucket);

16
ui/src/lib/model-utils.ts Normal file
View 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);
}