From da7558ab493109af76101c7abd7d9831649a7d5f Mon Sep 17 00:00:00 2001 From: Wenxin Duan Date: Tue, 8 Sep 2026 10:47:42 +0800 Subject: [PATCH] fix: align runtime startup and model events --- .env.example | 6 +++ apps/server/src/index.ts | 6 +-- apps/server/src/routes/models.ts | 45 ++++++++++++++++--- apps/server/tests/model-api.test.ts | 23 +++++++++- apps/server/tests/test-app.ts | 7 ++- apps/web/vite.config.ts | 4 +- package.json | 2 +- .../harness/src/sessions/harness-session.ts | 4 +- 8 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 772698f..0f5ed18 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,14 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= AGENT_PROVIDER= AGENT_MODEL= +AGENT_MODEL_SUPPORTS_IMAGES=true +AGENT_SKILLS_DIRS= LLM_BASE_URL= LLM_API=openai-responses +HOST=127.0.0.1 +PORT=3001 +SESSIONS_ROOT=.agent-studio-sessions +AGENT_DIR=.agent-studio-runtime ALLOW_PRIVATE_LLM_BASE_URLS=false SESSION_RUN_TIMEOUT_MS=120000 APPROVAL_TIMEOUT_MS=60000 diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 1744ee4..bea94e0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,6 +1,6 @@ import { mkdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { FileAttachmentStore, HarnessSession, @@ -22,7 +22,7 @@ import { loadServerConfig } from "./config.js"; export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.env) { const config = loadServerConfig(environment); - const projectRoot = process.cwd(); + const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const sessionsRoot = resolve(projectRoot, config.sessionsRoot); const agentDir = resolve(projectRoot, config.agentDir); await mkdir(sessionsRoot, { recursive: true, mode: 0o700 }); diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts index 944ca0a..0b7724a 100644 --- a/apps/server/src/routes/models.ts +++ b/apps/server/src/routes/models.ts @@ -21,9 +21,32 @@ export function registerModelRoutes(app: FastifyInstance, dependencies: AppDepen if (session.isBusy === true) { throw new HarnessError("SESSION_BUSY", "Model configuration cannot change during a run"); } - const input = ModelConnectionInputSchema.parse(request.body); - await dependencies.modelStore.configure(sessionId, input); - return { modelConfig: dependencies.modelStore.getRedacted(sessionId), requestId: request.id }; + try { + const input = ModelConnectionInputSchema.parse(request.body); + await dependencies.modelStore.configure(sessionId, input); + const modelConfig = dependencies.modelStore.getRedacted(sessionId); + if (modelConfig !== undefined) { + dependencies.eventStore?.append(sessionId, { + type: "model.configured", + payload: { + providerId: modelConfig.providerId, + modelId: modelConfig.modelId, + supportsImages: modelConfig.supportsImages, + source: modelConfig.source, + }, + }); + } + return { modelConfig, requestId: request.id }; + } catch (error) { + dependencies.eventStore?.append(sessionId, { + type: "model.validation_failed", + payload: { + code: error instanceof HarnessError ? error.code : "MODEL_VALIDATION_FAILED", + message: "Model configuration validation failed", + }, + }); + throw error; + } }); app.delete("/api/sessions/:sessionId/model-config/credentials", (request) => { @@ -37,9 +60,17 @@ export function registerModelRoutes(app: FastifyInstance, dependencies: AppDepen const { sessionId } = Params.parse(request.params); requireSession(dependencies, sessionId); const resolved = dependencies.modelStore.resolveConnection(sessionId); - const result = dependencies.testModelConnection === undefined - ? { status: "deferred" as const, message: "Saved; validation will occur on first run." } - : await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000)); - return { ...result, requestId: request.id }; + try { + const result = dependencies.testModelConnection === undefined + ? { status: "deferred" as const, message: "Saved; validation will occur on first run." } + : await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000)); + return { ...result, requestId: request.id }; + } catch { + dependencies.eventStore?.append(sessionId, { + type: "model.validation_failed", + payload: { code: "MODEL_CONNECTION_FAILED", message: "Model connection validation failed" }, + }); + throw new HarnessError("MODEL_CONNECTION_FAILED", "Model connection validation failed"); + } }); } diff --git a/apps/server/tests/model-api.test.ts b/apps/server/tests/model-api.test.ts index fae06f3..6ac4a92 100644 --- a/apps/server/tests/model-api.test.ts +++ b/apps/server/tests/model-api.test.ts @@ -6,7 +6,7 @@ afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))); describe("model API", () => { it("configures and returns only redacted model metadata", async () => { - const { app, modelStore } = createTestApp(); + const { app, modelStore, eventStore } = createTestApp(); apps.push(app); const configured = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: { providerId: "training", api: "openai-responses", apiKey: "sk-super-secret", @@ -21,6 +21,10 @@ describe("model API", () => { const body = response.json<{ modelConfig: { keyHint: string }; requestId: string }>(); expect(body.modelConfig.keyHint).toBe("••••cret"); expect(body.requestId).toMatch(/^req_/); + expect(eventStore.listAfter("ses_1").events.at(-1)).toMatchObject({ + type: "model.configured", + payload: { providerId: "training", modelId: "training-model", supportsImages: true, source: "session" }, + }); }); it("clears credentials idempotently and returns an honest deferred connection test", async () => { @@ -36,4 +40,21 @@ describe("model API", () => { expect(body.requestId).toMatch(/^req_/); expect(tested.body).not.toContain("server-only-secret"); }); + + it("emits a safe validation event when model configuration fails", async () => { + const { app, modelStore, eventStore } = createTestApp(); + apps.push(app); + modelStore.configure.mockRejectedValueOnce(new Error("provider leaked sk-super-secret")); + + const response = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: { + providerId: "training", api: "openai-responses", apiKey: "sk-super-secret", + modelId: "training-model", supportsImages: true, + } }); + + expect(response.statusCode).toBe(500); + const serialized = JSON.stringify(eventStore.listAfter("ses_1").events); + expect(serialized).toContain("model.validation_failed"); + expect(serialized).not.toContain("sk-super-secret"); + expect(serialized).not.toContain("provider leaked"); + }); }); diff --git a/apps/server/tests/test-app.ts b/apps/server/tests/test-app.ts index 1d7127d..0b7e09c 100644 --- a/apps/server/tests/test-app.ts +++ b/apps/server/tests/test-app.ts @@ -1,4 +1,4 @@ -import { HarnessError } from "@agent-studio/harness"; +import { HarnessError, InMemoryEventStore } from "@agent-studio/harness"; import type { FastifyInstance } from "fastify"; import { vi } from "vitest"; import { buildApp } from "../src/app.js"; @@ -14,6 +14,7 @@ export function createTestApp(): { }; attachmentStore: { add: ReturnType; remove: ReturnType }; approvalBroker: { approve: ReturnType; deny: ReturnType }; + eventStore: InMemoryEventStore; } { const session = { sessionId: "ses_1", @@ -57,15 +58,17 @@ export function createTestApp(): { approve: vi.fn((approvalId: string) => ({ approvalId, status: "approved" as const })), deny: vi.fn((approvalId: string) => ({ approvalId, status: "denied" as const })), }; + const eventStore = new InMemoryEventStore(); const app = buildApp({ sessionRegistry: registry, skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] }, modelStore, attachmentStore, approvalBroker, + eventStore, testModelConnection: () => Promise.resolve({ status: "deferred", message: "Saved; validation will occur on first run." }), }); - return { app, session, modelStore, attachmentStore, approvalBroker }; + return { app, session, modelStore, attachmentStore, approvalBroker, eventStore }; } export function makeSessionBusy(session: { startRun: ReturnType }): void { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d818f88..9be95d9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": "http://127.0.0.1:3000", - "/health": "http://127.0.0.1:3000" + "/api": "http://127.0.0.1:3001", + "/health": "http://127.0.0.1:3001" } } }); diff --git a/package.json b/package.json index 2243706..3117c0a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@11.7.0", "engines": { - "node": ">=20" + "node": ">=22.19.0" }, "scripts": { "dev": "pnpm --parallel --filter @agent-studio/server --filter @agent-studio/web dev", diff --git a/packages/harness/src/sessions/harness-session.ts b/packages/harness/src/sessions/harness-session.ts index 890b2b2..906d4ea 100644 --- a/packages/harness/src/sessions/harness-session.ts +++ b/packages/harness/src/sessions/harness-session.ts @@ -83,14 +83,14 @@ export class HarnessSession { ...(skillInference === undefined ? {} : { skillInference }), }); let eventQueue = Promise.resolve(); - let eventError: unknown; + let eventError: Error | undefined; const unsubscribe = port.subscribe((event) => { eventQueue = eventQueue.then(async () => { if (eventError !== undefined) return; try { await adapter.observe(event); } catch (error) { - eventError = error; + eventError = error instanceof Error ? error : new Error("Agent event mapping failed"); } }); });