From 760b6e188f8445cc14694e4237b95bb176fabca2 Mon Sep 17 00:00:00 2001 From: moose-lab Date: Tue, 2 Jun 2026 21:49:04 +0800 Subject: [PATCH 1/4] feat: wire execution model runtime through tasks Propagate the Settings-derived runtime payload through task launch, retry, and session continuation so Tasks and Sessions use the selected execution mode/model instead of falling back implicitly. Preserve stored session runtime metadata while injecting only transient browser API keys or local CLI env at send time, and add E2E/runtime helpers for local CLI, BYOK provider, and legacy backend env modes. Harden Codex connection testing for slow auth refresh and JSON event parsing, with focused tests for task retry, runtime persistence, UI readiness gates, and E2E config. --- scripts/task-launch-e2e-config.mjs | 123 ++++++++++++++++++ scripts/task-launch-e2e.mjs | 17 ++- src/app/api/sessions/route.ts | 5 +- src/app/api/tasks/[id]/execute/route.ts | 5 +- src/app/api/tasks/[id]/retry/route.ts | 88 +++++++++---- src/app/sessions/[id]/page.tsx | 6 +- src/components/dashboard/task-quick-view.tsx | 14 +- src/components/kanban/board.tsx | 8 +- src/components/kanban/task-detail-dialog.tsx | 15 ++- src/components/kanban/task-review-panel.tsx | 23 +++- src/components/sessions/launch-dialog.tsx | 14 +- src/components/sessions/session-chat.test.ts | 15 +++ src/components/sessions/session-chat.tsx | 26 +++- .../__tests__/agent-connection-test.test.ts | 41 ++++++ src/core/__tests__/agent-settings.test.ts | 79 +++++++++++ .../__tests__/api-session-runtime.test.ts | 53 ++++++++ src/core/__tests__/quality-gates.test.ts | 91 +++++++++++++ .../__tests__/session-runtime-auth.test.ts | 11 ++ .../__tests__/task-launch-e2e-config.test.ts | 69 ++++++++++ src/core/__tests__/task-retry-request.test.ts | 23 ++++ src/core/__tests__/task-retry.test.ts | 62 +++++++++ src/core/agent-connection-test.ts | 9 +- src/core/agent-settings.ts | 57 ++++++++ src/core/api-session-runtime.ts | 25 +--- src/core/process-manager.ts | 45 +------ src/core/session-runtime-auth.ts | 23 ++++ src/core/task-retry-request.ts | 11 ++ src/core/task-retry.ts | 48 +++++++ src/hooks/use-agent-settings.ts | 52 +++++++- src/hooks/use-session-chat.ts | 36 ++++- 30 files changed, 965 insertions(+), 129 deletions(-) create mode 100644 scripts/task-launch-e2e-config.mjs create mode 100644 src/core/__tests__/task-launch-e2e-config.test.ts create mode 100644 src/core/__tests__/task-retry-request.test.ts create mode 100644 src/core/__tests__/task-retry.test.ts create mode 100644 src/core/task-retry-request.ts create mode 100644 src/core/task-retry.ts diff --git a/scripts/task-launch-e2e-config.mjs b/scripts/task-launch-e2e-config.mjs new file mode 100644 index 0000000..24ae204 --- /dev/null +++ b/scripts/task-launch-e2e-config.mjs @@ -0,0 +1,123 @@ +const DEFAULT_API_BASE_URL_BY_PROTOCOL = { + anthropic: "https://api.anthropic.com", + openai: "https://api.openai.com/v1", + azure: "", + google: "https://generativelanguage.googleapis.com", + ollama: "https://ollama.com", + senseaudio: "https://api.senseaudio.cn", +}; + +const DEFAULT_API_MODEL_BY_PROTOCOL = { + anthropic: "claude-sonnet-4-5", + openai: "gpt-4o", + azure: "gpt-4o", + google: "gemini-2.0-flash", + ollama: "gemma3:4b", + senseaudio: "senseaudio-s2", +}; + +function nonEmpty(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseMaxTokens(value) { + const parsed = Number(nonEmpty(value)); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 8192; +} + +function parseJsonObject(value) { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function readByokApiKey(env) { + const direct = nonEmpty(env.DEVLOG_E2E_API_KEY); + if (direct) return direct; + + const envVarName = + nonEmpty(env.DEVLOG_E2E_API_KEY_ENV_VAR) ?? + nonEmpty(env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR) ?? + "ANTHROPIC_API_KEY"; + return nonEmpty(env[envVarName]); +} + +export function buildTaskLaunchRuntimePayload(env = process.env) { + const mode = nonEmpty(env.DEVLOG_E2E_AUTH_MODE) ?? "local-cli"; + const model = nonEmpty(env.DEVLOG_E2E_AGENT_MODEL); + + if (mode === "agent-api-key") { + return { + session_auth_mode: "agent-api-key", + agent_api_key_env_var: + nonEmpty(env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR) ?? "ANTHROPIC_API_KEY", + agent_model: model ?? "claude-sonnet-4-6", + }; + } + + if (mode === "anthropic-api-key") { + const protocol = nonEmpty(env.DEVLOG_E2E_API_PROTOCOL) ?? "anthropic"; + const apiKey = readByokApiKey(env); + return { + session_auth_mode: "anthropic-api-key", + agent_api_protocol: protocol, + agent_model: + model ?? + DEFAULT_API_MODEL_BY_PROTOCOL[protocol] ?? + "claude-sonnet-4-5", + agent_base_url: + nonEmpty(env.DEVLOG_E2E_AGENT_BASE_URL) ?? + DEFAULT_API_BASE_URL_BY_PROTOCOL[protocol] ?? + "", + agent_api_version: nonEmpty(env.DEVLOG_E2E_AGENT_API_VERSION) ?? "", + agent_max_tokens: parseMaxTokens(env.DEVLOG_E2E_AGENT_MAX_TOKENS), + ...(apiKey ? { anthropic_api_key: apiKey } : {}), + }; + } + + const localCliEnv = parseJsonObject(env.DEVLOG_E2E_LOCAL_CLI_ENV_JSON); + return { + session_auth_mode: "local-cli", + local_cli_agent_id: + nonEmpty(env.DEVLOG_E2E_LOCAL_CLI_AGENT_ID) ?? + nonEmpty(env.DEVLOG_E2E_LOCAL_CLI_AGENT) ?? + "claude", + agent_model: model ?? "default", + agent_reasoning: nonEmpty(env.DEVLOG_E2E_AGENT_REASONING) ?? "medium", + ...(localCliEnv ? { local_cli_agent_env: localCliEnv } : {}), + }; +} + +export function describeTaskLaunchRuntimePayload(payload) { + if (payload.session_auth_mode === "anthropic-api-key") { + return [ + "mode=anthropic-api-key", + `protocol=${payload.agent_api_protocol}`, + `model=${payload.agent_model}`, + `baseUrl=${payload.agent_base_url}`, + `key=${payload.anthropic_api_key ? "provided" : "missing"}`, + ].join(" "); + } + + if (payload.session_auth_mode === "agent-api-key") { + return [ + "mode=agent-api-key", + `env=${payload.agent_api_key_env_var}`, + `model=${payload.agent_model}`, + ].join(" "); + } + + return [ + "mode=local-cli", + `agent=${payload.local_cli_agent_id}`, + `model=${payload.agent_model}`, + `reasoning=${payload.agent_reasoning}`, + ].join(" "); +} diff --git a/scripts/task-launch-e2e.mjs b/scripts/task-launch-e2e.mjs index 7784870..839f0dd 100644 --- a/scripts/task-launch-e2e.mjs +++ b/scripts/task-launch-e2e.mjs @@ -1,5 +1,10 @@ #!/usr/bin/env node +import { + buildTaskLaunchRuntimePayload, + describeTaskLaunchRuntimePayload, +} from "./task-launch-e2e-config.mjs"; + const baseUrl = (process.env.DEVLOG_E2E_BASE_URL ?? "http://localhost:3000") .replace(/\/$/, ""); const projectId = process.env.DEVLOG_E2E_PROJECT_ID ?? "devlog"; @@ -8,10 +13,7 @@ const codingAgentId = process.env.DEVLOG_E2E_CODING_AGENT_ID ?? "general-coding-agent"; const agentTeamId = process.env.DEVLOG_E2E_AGENT_TEAM_ID ?? "implementation-review-team"; -const sessionAuthMode = - process.env.DEVLOG_E2E_AUTH_MODE ?? "backend-oauth"; -const agentApiKeyEnvVar = - process.env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR ?? "ANTHROPIC_API_KEY"; +const runtimePayload = buildTaskLaunchRuntimePayload(process.env); const startedAt = Date.now(); const marker = `DEVLOG_E2E_${Date.now()}`; @@ -92,6 +94,7 @@ async function watchSse(sessionId, evidence, signal) { async function main() { console.log(`DevLog task launch E2E against ${baseUrl} project=${projectId}`); + console.log(`runtime ${describeTaskLaunchRuntimePayload(runtimePayload)}`); const task = await request("/api/tasks", { method: "POST", @@ -114,8 +117,7 @@ async function main() { body: JSON.stringify({ coding_agent_id: codingAgentId, agent_team_id: agentTeamId, - session_auth_mode: sessionAuthMode, - agent_api_key_env_var: agentApiKeyEnvVar, + ...runtimePayload, }), }); const session = launched.session; @@ -147,6 +149,7 @@ async function main() { "Human follow-up E2E instruction.", `Reply with exactly ${followupAck}.`, ].join(" "), + ...runtimePayload, }), }); if (!("queue_length" in patched) || !("is_processing" in patched)) { @@ -164,7 +167,7 @@ async function main() { .join("\n"); if (/Failed to authenticate|API Error/i.test(assistantText)) { throw new Error( - "Claude session authentication failed. Use backend OAuth on a logged-in Claude Code runtime or set DEVLOG_E2E_AUTH_MODE=agent-api-key with a backend env var such as ANTHROPIC_API_KEY.", + "Session authentication failed. Use DEVLOG_E2E_AUTH_MODE=local-cli with a usable local coding agent, DEVLOG_E2E_AUTH_MODE=anthropic-api-key with DEVLOG_E2E_API_KEY, or explicitly use DEVLOG_E2E_AUTH_MODE=agent-api-key with a backend env var such as ANTHROPIC_API_KEY.", ); } if (assistantText.includes(followupAck)) return rows; diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 94370d8..e3f3c0e 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -14,6 +14,7 @@ import { import { buildSessionRuntimeAuthInstructions, getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, resolveSessionRuntimeAuthConfig, } from "@/core/session-runtime-auth"; import type { Session } from "@/core/types-dashboard"; @@ -81,7 +82,7 @@ export async function POST(req: NextRequest) { agent_reasoning, agent_api_protocol, agent_api_version, agent_base_url, agent_max_tokens, prompt ) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( @@ -100,7 +101,7 @@ export async function POST(req: NextRequest) { runtimeAuthConfig.reasoning, runtimeAuthConfig.apiProtocol, runtimeAuthConfig.apiVersion, - runtimeAuthConfig.baseUrl, + getPersistedSessionBaseUrl(runtimeAuthConfig), runtimeAuthConfig.maxTokens, sessionPrompt ) as Session; diff --git a/src/app/api/tasks/[id]/execute/route.ts b/src/app/api/tasks/[id]/execute/route.ts index 7b06c87..fa64a52 100644 --- a/src/app/api/tasks/[id]/execute/route.ts +++ b/src/app/api/tasks/[id]/execute/route.ts @@ -17,6 +17,7 @@ import { } from "@/core/agent-presets"; import { getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, resolveSessionRuntimeAuthConfig, } from "@/core/session-runtime-auth"; import type { Task, Session } from "@/core/types-dashboard"; @@ -122,7 +123,7 @@ export async function POST( agent_reasoning, agent_api_protocol, agent_api_version, agent_base_url, agent_max_tokens, prompt ) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( @@ -141,7 +142,7 @@ export async function POST( runtimeAuthConfig.reasoning, runtimeAuthConfig.apiProtocol, runtimeAuthConfig.apiVersion, - runtimeAuthConfig.baseUrl, + getPersistedSessionBaseUrl(runtimeAuthConfig), runtimeAuthConfig.maxTokens, prompt ) as Session; diff --git a/src/app/api/tasks/[id]/retry/route.ts b/src/app/api/tasks/[id]/retry/route.ts index d8ab488..94e91bd 100644 --- a/src/app/api/tasks/[id]/retry/route.ts +++ b/src/app/api/tasks/[id]/retry/route.ts @@ -4,9 +4,21 @@ import { getDb } from "@/core/db"; import { resolveProjectId } from "@/lib/api-utils"; import { getProject } from "@/core/project-adapter"; import { listWorktrees } from "@/core/worktree-manager"; -import { processManager } from "@/core/process-manager"; +import { + processManager, + validateSessionRuntimeProcessLaunch, +} from "@/core/process-manager"; import { compileSession } from "@/core/vcc"; -import { buildPromptTemplate } from "@/core/task-lifecycle"; +import { buildTaskRetryPrompt } from "@/core/task-retry"; +import { + getAgentExecutionInputFromPayload, + resolveAgentExecutionConfig, +} from "@/core/agent-presets"; +import { + getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, + resolveSessionRuntimeAuthConfig, +} from "@/core/session-runtime-auth"; import type { Task, Session } from "@/core/types-dashboard"; export async function POST( @@ -16,7 +28,17 @@ export async function POST( const { id: taskId } = await params; const db = getDb(); const projectId = resolveProjectId(req); - const { feedback } = (await req.json()) as { feedback: string }; + let payload: unknown; + try { + payload = await req.json(); + } catch { + payload = {}; + } + const record = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; + const feedback = typeof record.feedback === "string" ? record.feedback : ""; if (!feedback?.trim()) { return NextResponse.json({ error: "Feedback is required" }, { status: 400 }); @@ -48,6 +70,18 @@ export async function POST( } const project = getProject(projectId); + const agentConfig = resolveAgentExecutionConfig( + getAgentExecutionInputFromPayload(payload), + ); + const runtimeAuthInput = getSessionRuntimeAuthInputFromPayload(payload); + const runtimeAuthConfig = resolveSessionRuntimeAuthConfig(runtimeAuthInput); + const preflight = validateSessionRuntimeProcessLaunch( + runtimeAuthConfig, + wt.path, + ); + if (!preflight.ok) { + return NextResponse.json({ error: preflight.error }, { status: 400 }); + } // 3. Get previous session brief for context let previousBrief = ""; @@ -70,32 +104,29 @@ export async function POST( } // 4. Build retry prompt - const basePrompt = buildPromptTemplate( + const retryPrompt = buildTaskRetryPrompt({ task, project, - wt.path, - wt.branch - ); - const retryPrompt = [ - basePrompt, - "", - "## Previous Attempt Feedback", - feedback, - previousBrief - ? `\n## Previous Session Summary\n\`\`\`\n${previousBrief}\n\`\`\`` - : "", - "", - "Address the feedback above and complete the task.", - ] - .filter(Boolean) - .join("\n"); + worktreePath: wt.path, + branchName: wt.branch, + feedback: feedback.trim(), + previousBrief, + agentConfig, + runtimeAuthConfig, + }); // 5. Create new session const sessionId = randomBytes(8).toString("hex"); const session = db .prepare( - `INSERT INTO sessions (id, project_id, task_id, worktree_name, worktree_path, branch_name, status, prompt) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?) + `INSERT INTO sessions ( + id, project_id, task_id, worktree_name, worktree_path, branch_name, + status, coding_agent_id, agent_team_id, session_auth_mode, + agent_api_key_env_var, local_cli_agent_id, agent_model, + agent_reasoning, agent_api_protocol, agent_api_version, + agent_base_url, agent_max_tokens, prompt + ) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( @@ -105,6 +136,17 @@ export async function POST( task.worktree_name, wt.path, wt.branch, + agentConfig.codingAgent.id, + agentConfig.agentTeam.id, + runtimeAuthConfig.mode, + runtimeAuthConfig.agentApiKeyEnvVar, + runtimeAuthConfig.localCliAgentId, + runtimeAuthConfig.model, + runtimeAuthConfig.reasoning, + runtimeAuthConfig.apiProtocol, + runtimeAuthConfig.apiVersion, + getPersistedSessionBaseUrl(runtimeAuthConfig), + runtimeAuthConfig.maxTokens, retryPrompt ) as Session; @@ -115,7 +157,7 @@ export async function POST( // 7. Spawn agent try { - processManager.sendMessage(sessionId, retryPrompt); + processManager.sendMessage(sessionId, retryPrompt, runtimeAuthInput); } catch (err) { db.prepare( "UPDATE sessions SET status = 'failed', ended_at = datetime('now') WHERE id = ?" diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index 9eaf3fc..70e9688 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -167,7 +167,11 @@ export default function SessionDetailPage() { {/* Tab content */}
- +
diff --git a/src/components/dashboard/task-quick-view.tsx b/src/components/dashboard/task-quick-view.tsx index 1a77617..a05df26 100644 --- a/src/components/dashboard/task-quick-view.tsx +++ b/src/components/dashboard/task-quick-view.tsx @@ -10,6 +10,7 @@ import { CreateTaskDialog } from "@/components/kanban/create-task-dialog"; import { TaskDetailDialog } from "@/components/kanban/task-detail-dialog"; import { useTasks } from "@/hooks/use-tasks"; import type { Task, TaskStatus } from "@/core/types-dashboard"; +import type { SessionRuntimeAuthInput } from "@/core/session-runtime-auth"; import { cn } from "@/core/dashboard-utils"; const QUICK_COLUMNS: TaskStatus[] = ["todo", "in_progress"]; @@ -125,9 +126,15 @@ export function TaskQuickView() { task: Task, worktreePath: string, worktreeName?: string, - branchName?: string + branchName?: string, + agentConfig?: { + coding_agent_id: string; + agent_team_id: string; + } & SessionRuntimeAuthInput, + promptOverride?: string, ): Promise<{ id: string } | null> => { - if (!task.prompt) return null; + const prompt = promptOverride?.trim() || task.prompt; + if (!prompt) return null; const res = await fetch("/api/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -136,7 +143,8 @@ export function TaskQuickView() { worktree_name: worktreeName, worktree_path: worktreePath, branch_name: branchName, - prompt: task.prompt, + prompt, + ...agentConfig, }), }); if (res.ok) { diff --git a/src/components/kanban/board.tsx b/src/components/kanban/board.tsx index 02f74ac..68550db 100644 --- a/src/components/kanban/board.tsx +++ b/src/components/kanban/board.tsx @@ -21,16 +21,22 @@ const COLUMNS: TaskStatus[] = ["todo", "in_progress", "review", "blocked", "done export function KanbanBoard() { const { loading, tasksByStatus, createTask, updateTask, deleteTask, reorder, executeTask } = useTasks(); const taskSessions = useTaskSessions(); - const { runtimePayload, byokReady } = useAgentSettings(); + const { runtimePayload, byokReady, loaded, settingsReady } = useAgentSettings(); const router = useRouter(); const [selectedTask, setSelectedTask] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const handleExecuteTask = async (taskId: string) => { + if (!loaded) { + return; + } if (!byokReady) { router.push("/settings"); return; } + if (!settingsReady) { + return; + } const result = await executeTask(taskId, runtimePayload); if (result?.session) { router.push(`/sessions/${result.session.id}`); diff --git a/src/components/kanban/task-detail-dialog.tsx b/src/components/kanban/task-detail-dialog.tsx index 44357b0..f694f8d 100644 --- a/src/components/kanban/task-detail-dialog.tsx +++ b/src/components/kanban/task-detail-dialog.tsx @@ -94,7 +94,8 @@ export function TaskDetailDialog({ const [selectedWorktree, setSelectedWorktree] = useState(""); const [codingAgentId, setCodingAgentId] = useState(DEFAULT_CODING_AGENT_ID); const [agentTeamId, setAgentTeamId] = useState(DEFAULT_AGENT_TEAM_ID); - const { settings, runtimePayload, byokReady } = useAgentSettings(); + const { settings, runtimePayload, byokReady, loaded, settingsReady } = + useAgentSettings(); // Reset form when task changes useEffect(() => { @@ -139,6 +140,8 @@ export function TaskDetailDialog({ }; const handleLaunch = async () => { + if (!settingsReady) return; + const taskPrompt = prompt.trim() || task.prompt; if (!taskPrompt) return; @@ -263,11 +266,11 @@ export function TaskDetailDialog({ {/* Prompt */} {editing ? (
- +