Address PR feedback for OpenCode integration
This commit is contained in:
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
Reference in New Issue
Block a user