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,
command,
cwd,
env,
env: runtimeEnv,
});
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
? ({
sessionId: resolvedSessionId,
@@ -287,7 +289,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stderr: attempt.proc.stderr,
},
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 {
asString,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
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>();
@@ -61,14 +65,30 @@ function normalizeEnv(input: unknown): Record<string, string> {
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}=${value}`)
.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;
@@ -83,6 +103,7 @@ export async function discoverOpenCodeModels(input: {
);
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)}`,
@@ -90,7 +111,7 @@ export async function discoverOpenCodeModels(input: {
["models"],
{
cwd,
env,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
@@ -124,6 +145,7 @@ export async function discoverOpenCodeModelsCached(input: {
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;

View File

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

View File

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

View File

@@ -933,9 +933,14 @@ export function agentRoutes(db: Db) {
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
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(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
rawEffectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,

View File

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

View File

@@ -23,6 +23,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 {
@@ -123,19 +124,6 @@ function formatArgList(value: unknown): string {
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 = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },

View File

@@ -58,6 +58,8 @@ export function NewAgentDialog() {
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
} = useQuery({
queryKey:
selectedCompanyId
@@ -126,6 +128,10 @@ export function NewAgentDialog() {
);
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(

View File

@@ -36,6 +36,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";
@@ -54,12 +55,6 @@ function getContrastTextColor(hexColor: string): string {
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 {
title: string;
description: string;
@@ -505,8 +500,8 @@ export function NewIssueDialog() {
() => {
return [...(assigneeAdapterModels ?? [])]
.sort((a, b) => {
const providerA = extractProviderId(a.id);
const providerB = extractProviderId(b.id);
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);
@@ -514,7 +509,7 @@ export function NewIssueDialog() {
.map((model) => ({
id: model.id,
label: model.label,
searchText: `${model.id} ${extractProviderId(model.id)}`,
searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
}));
},
[assigneeAdapterModels],

View File

@@ -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 {
@@ -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`;
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() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
@@ -185,7 +173,7 @@ export function OnboardingWizard() {
const query = modelSearch.trim().toLowerCase();
return (adapterModels ?? []).filter((entry) => {
if (!query) return true;
const provider = extractProviderId(entry.id) ?? "";
const provider = extractProviderIdWithFallback(entry.id, "");
return (
entry.id.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 }>>();
for (const entry of filteredModels) {
const provider = extractProviderId(entry.id) ?? "other";
const provider = extractProviderIdWithFallback(entry.id);
const bucket = groups.get(provider) ?? [];
bucket.push(entry);
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);
}