diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06f060cb7..273f0d596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,14 @@ concurrency: jobs: packaged_changes: - name: Detect packaged smoke changes + name: Detect PR change scopes runs-on: ubuntu-latest outputs: required: ${{ steps.detect.outputs.required }} + daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }} + web_tests_required: ${{ steps.detect.outputs.web_tests_required }} + tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }} + tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }} steps: - name: Checkout @@ -37,12 +41,16 @@ jobs: with: fetch-depth: 0 - - name: Detect desktop/sidecar/packaging changes + - name: Detect desktop, packaging, and app test scopes id: detect shell: bash run: | set -euo pipefail required=false + daemon_tests_required=false + web_tests_required=false + tools_dev_tests_required=false + tools_pack_tests_required=false if [ "${{ github.event_name }}" = "pull_request" ]; then git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/changed-files.txt" patterns=( @@ -62,20 +70,53 @@ jobs: required=true fi done + if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + daemon_tests_required=true + fi + if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + web_tests_required=true + fi + if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then + daemon_tests_required=true + web_tests_required=true + fi + if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + tools_dev_tests_required=true + fi + if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then + tools_pack_tests_required=true + fi if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then required=true + daemon_tests_required=true + web_tests_required=true + tools_dev_tests_required=true + tools_pack_tests_required=true fi - if [ "$required" = "true" ]; then + if [ "$required" = "true" ] \ + && [ "$daemon_tests_required" = "true" ] \ + && [ "$web_tests_required" = "true" ] \ + && [ "$tools_dev_tests_required" = "true" ] \ + && [ "$tools_pack_tests_required" = "true" ]; then break fi done < "$RUNNER_TEMP/changed-files.txt" else required=true + daemon_tests_required=true + web_tests_required=true + tools_dev_tests_required=true + tools_pack_tests_required=true fi echo "required=$required" >> "$GITHUB_OUTPUT" + echo "daemon_tests_required=$daemon_tests_required" >> "$GITHUB_OUTPUT" + echo "web_tests_required=$web_tests_required" >> "$GITHUB_OUTPUT" + echo "tools_dev_tests_required=$tools_dev_tests_required" >> "$GITHUB_OUTPUT" + echo "tools_pack_tests_required=$tools_pack_tests_required" >> "$GITHUB_OUTPUT" validate: name: Validate workspace + needs: [packaged_changes] runs-on: ubuntu-latest timeout-minutes: 45 @@ -137,12 +178,25 @@ jobs: pnpm --filter @open-design/sidecar test pnpm --filter @open-design/sidecar-proto test - - name: App workspace tests + - name: App workspace smoke tests + if: ${{ needs.packaged_changes.outputs.tools_dev_tests_required == 'true' || needs.packaged_changes.outputs.tools_pack_tests_required == 'true' }} + run: | + if [ "${{ needs.packaged_changes.outputs.tools_dev_tests_required }}" = "true" ]; then + pnpm --filter @open-design/tools-dev test + fi + if [ "${{ needs.packaged_changes.outputs.tools_pack_tests_required }}" = "true" ]; then + pnpm --filter @open-design/tools-pack test + fi + + - name: App workspace daemon tests + if: ${{ needs.packaged_changes.outputs.daemon_tests_required == 'true' }} run: | pnpm --filter @open-design/daemon test + + - name: App workspace web tests + if: ${{ needs.packaged_changes.outputs.web_tests_required == 'true' }} + run: | pnpm --filter @open-design/web test - pnpm --filter @open-design/tools-dev test - pnpm --filter @open-design/tools-pack test - name: E2E vitest run: pnpm --filter @open-design/e2e test @@ -247,8 +301,6 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Compute Windows tools-pack cache key id: win_tools_pack_cache_key diff --git a/apps/daemon/src/runtimes/detection.ts b/apps/daemon/src/runtimes/detection.ts index 95413b8ba..ae33e525e 100644 --- a/apps/daemon/src/runtimes/detection.ts +++ b/apps/daemon/src/runtimes/detection.ts @@ -1,7 +1,7 @@ import { execAgentFile } from './invocation.js'; import { AGENT_DEFS } from './registry.js'; import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js'; -import { resolveAgentExecutable } from './executables.js'; +import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js'; import { spawnEnvForAgent } from './env.js'; import { probeAgentAuthStatus } from './auth.js'; import { agentCapabilities } from './capabilities.js'; @@ -118,27 +118,30 @@ async function probe( def: RuntimeAgentDef, configuredEnv: Record = {}, ): Promise { - // Resolution returns whichever path the rest of the daemon will spawn - // (configured override wins, PATH fallback otherwise). Detection must - // probe THAT path and report `available` accordingly, so the Settings - // UI never advertises an executable that `resolveAgentBin` won't pick - // at run time. Surfacing a different PATH candidate as `available: true` - // while a stale configured override survives in chat/run resolution - // breaks the invariant flagged on PR #1301 review and would only swap - // the ghost in Settings for a ghost in chat (Siri-Ray, #1301 round 3). - const resolved = resolveAgentExecutable(def, configuredEnv); - if (!resolved) { + // Detection must probe the exact path the runtime will spawn, not just the + // PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise: + // the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper + // that is not invocable from a GUI-launched app's stripped PATH, while the + // launch resolver can still upgrade it to the packaged native Codex binary. + // If detection probes the shim but chat/run spawns the native binary, the + // UI incorrectly reports "not installed" until the user pins CODEX_BIN by + // hand even though the real launch path is healthy. + const launch = resolveAgentLaunch(def, configuredEnv); + if (!launch.selectedPath || !launch.launchPath) { return unavailableAgent(def); } - const probeEnv = spawnEnvForAgent( - def.id, - { - ...process.env, - ...(def.env || {}), - }, - configuredEnv, + const probeEnv = applyAgentLaunchEnv( + spawnEnvForAgent( + def.id, + { + ...process.env, + ...(def.env || {}), + }, + configuredEnv, + ), + launch, ); - const outcome = await probeVersionAtPath(def, resolved, probeEnv); + const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv); if (outcome.kind === 'not-invocable') { return unavailableAgent(def); } @@ -147,7 +150,7 @@ async function probe( if (def.helpArgs && def.capabilityFlags) { const caps: RuntimeCapabilityMap = {}; try { - const { stdout } = await execAgentFile(resolved, def.helpArgs, { + const { stdout } = await execAgentFile(launch.launchPath, def.helpArgs, { env: probeEnv, timeout: 5000, maxBuffer: 4 * 1024 * 1024, @@ -161,13 +164,13 @@ async function probe( } agentCapabilities.set(def.id, caps); } - const models = await fetchModels(def, resolved, probeEnv); - const auth = await probeAgentAuthStatus(def.id, resolved, probeEnv); + const models = await fetchModels(def, launch.launchPath, probeEnv); + const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv); return { ...stripFns(def), models, available: true, - path: resolved, + path: launch.selectedPath, version: outcome.version, ...(auth ? { diff --git a/apps/daemon/tests/memory-config-route.test.ts b/apps/daemon/tests/memory-config-route.test.ts index e75684f27..cea9df848 100644 --- a/apps/daemon/tests/memory-config-route.test.ts +++ b/apps/daemon/tests/memory-config-route.test.ts @@ -154,4 +154,138 @@ describe('PATCH /api/memory/config apiKey three-state handling', () => { expect(extraction?.provider).toBe('anthropic'); expect(extraction?.apiKey ?? '').toBe(''); }); + + it('clears the extraction override when the patch sends extraction: null', async () => { + await writeMemoryConfig(dataDir, { + extraction: { + provider: 'openai', + model: 'gpt-4o-mini', + apiKey: 'sk-stored-secret', + baseUrl: 'https://api.openai.com', + }, + }); + + const res = await patchConfig({ + extraction: null, + }); + expect(res.status).toBe(200); + + const extraction = await readStoredExtraction(); + expect(extraction).toBeNull(); + }); + + it('preserves the stored azure apiVersion when the patch omits the field', async () => { + await writeMemoryConfig(dataDir, { + extraction: { + provider: 'azure', + model: 'gpt-4.1-mini', + apiKey: 'azure-secret', + baseUrl: 'https://example.openai.azure.com', + apiVersion: '2025-01-01-preview', + }, + }); + + const res = await patchConfig({ + extraction: { + provider: 'azure', + model: 'gpt-4.1-mini', + baseUrl: 'https://example.openai.azure.com', + }, + }); + expect(res.status).toBe(200); + + const extraction = await readStoredExtraction(); + expect(extraction?.provider).toBe('azure'); + expect(extraction?.apiVersion).toBe('2025-01-01-preview'); + }); + + it('clears the stored azure apiVersion when the patch sends an explicit empty string', async () => { + await writeMemoryConfig(dataDir, { + extraction: { + provider: 'azure', + model: 'gpt-4.1-mini', + apiKey: 'azure-secret', + baseUrl: 'https://example.openai.azure.com', + apiVersion: '2025-01-01-preview', + }, + }); + + const res = await patchConfig({ + extraction: { + provider: 'azure', + model: 'gpt-4.1-mini', + baseUrl: 'https://example.openai.azure.com', + apiVersion: '', + }, + }); + expect(res.status).toBe(200); + + const extraction = await readStoredExtraction(); + expect(extraction?.provider).toBe('azure'); + expect(extraction?.apiVersion ?? '').toBe(''); + }); + + it('updates the enabled flag independently of extraction settings', async () => { + await writeMemoryConfig(dataDir, { + enabled: true, + extraction: { + provider: 'openai', + model: 'gpt-4o-mini', + apiKey: 'sk-stored-secret', + baseUrl: 'https://api.openai.com', + }, + }); + + const res = await patchConfig({ enabled: false }); + expect(res.status).toBe(200); + + const json = await res.json() as { + enabled: boolean; + extraction: { provider: string; apiKeyConfigured: boolean } | null; + }; + expect(json.enabled).toBe(false); + expect(json.extraction).toMatchObject({ + provider: 'openai', + apiKeyConfigured: true, + }); + + const extraction = await readStoredExtraction(); + expect(extraction?.provider).toBe('openai'); + }); + + it('returns a masked extraction config without leaking the apiKey on GET /api/memory', async () => { + await writeMemoryConfig(dataDir, { + extraction: { + provider: 'azure', + model: 'gpt-4.1-mini', + apiKey: 'azure-secret-1234', + baseUrl: 'https://example.openai.azure.com', + apiVersion: '2025-01-01-preview', + }, + }); + + const res = await fetch(`${baseUrl}/api/memory`); + expect(res.status).toBe(200); + + const json = await res.json() as { + extraction: { + provider: string; + model: string; + baseUrl: string; + apiVersion: string; + apiKeyTail: string; + apiKeyConfigured: boolean; + apiKey?: string; + } | null; + }; + expect(json.extraction).toMatchObject({ + provider: 'azure', + model: 'gpt-4.1-mini', + baseUrl: 'https://example.openai.azure.com', + apiVersion: '2025-01-01-preview', + apiKeyTail: '1234', + apiKeyConfigured: true, + }); + expect(json.extraction && 'apiKey' in json.extraction).toBe(false); + }); }); diff --git a/apps/daemon/tests/memory-routes.test.ts b/apps/daemon/tests/memory-routes.test.ts index e0b04d697..07d984f52 100644 --- a/apps/daemon/tests/memory-routes.test.ts +++ b/apps/daemon/tests/memory-routes.test.ts @@ -23,6 +23,12 @@ const dataDir = process.env.OD_DATA_DIR as string; let baseUrl: string; let server: http.Server; +const originalFetch = globalThis.fetch; + +interface SseEvent { + event: string; + data: unknown; +} async function closeServer(nextServer: http.Server | undefined): Promise { if (!nextServer) return; @@ -36,15 +42,81 @@ beforeAll(async () => { })) as StartedServer; baseUrl = started.url; server = started.server; + globalThis.fetch = async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.startsWith(baseUrl)) return originalFetch(input, init); + return new Response( + JSON.stringify({ + choices: [{ message: { content: '[]' } }], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + }; }); -afterAll(() => closeServer(server)); +afterAll(async () => { + globalThis.fetch = originalFetch; + await closeServer(server); +}); beforeEach(async () => { await fsp.rm(memoryDir(dataDir), { recursive: true, force: true }); __resetExtractionsForTests(); }); +async function readNextSseEvent( + reader: ReadableStreamDefaultReader, + decoder: InstanceType, + state: { buffer: string }, +): Promise { + while (true) { + const boundaryIndex = state.buffer.indexOf('\n\n'); + if (boundaryIndex !== -1) { + const rawEvent = state.buffer.slice(0, boundaryIndex); + state.buffer = state.buffer.slice(boundaryIndex + 2); + const eventLine = rawEvent + .split('\n') + .find((line) => line.startsWith('event: ')); + const dataLine = rawEvent + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!eventLine || !dataLine) continue; + return { + event: eventLine.slice('event: '.length), + data: JSON.parse(dataLine.slice('data: '.length)), + }; + } + + const chunk = await reader.read(); + if (chunk.done) { + throw new Error('memory SSE stream ended before the next event arrived'); + } + state.buffer += decoder.decode(chunk.value, { stream: true }); + } +} + +async function readSseEventByType( + reader: ReadableStreamDefaultReader, + decoder: InstanceType, + state: { buffer: string }, + eventType: string, +): Promise { + while (true) { + const event = await readNextSseEvent(reader, decoder, state); + if (event.event === eventType) return event; + } +} + describe('memory routes', () => { it('lists the default memory state when the store is empty', async () => { const res = await fetch(`${baseUrl}/api/memory`); @@ -118,6 +190,23 @@ describe('memory routes', () => { expect(listJson.entries).toEqual([]); }); + it('rejects invalid memory entry payloads during creation', async () => { + const res = await fetch(`${baseUrl}/api/memory`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: '', + description: 'Missing required values', + type: 'unknown', + body: '- Invalid entry', + }), + }); + + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toContain('memory entry requires'); + }); + it('saves the memory index and returns it from the list payload', async () => { const nextIndex = '# Memory\n\n- user_ui_preferences.md\n'; const putRes = await fetch(`${baseUrl}/api/memory/index`, { @@ -227,6 +316,30 @@ describe('memory routes', () => { ]); }); + it('reports attemptedLLM for post-turn extraction requests without triggering a real provider call', async () => { + const res = await fetch(`${baseUrl}/api/memory/extract`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + userMessage: 'Remember that I prefer dark mode for demos.', + assistantMessage: 'I will keep future demos darker and quieter.', + chatProvider: { + provider: 'openai', + apiKey: 'sk-test', + model: 'gpt-5-mini', + }, + }), + }); + expect(res.status).toBe(200); + + const json = await res.json() as { + changed: Array; + attemptedLLM: boolean; + }; + expect(json.attemptedLLM).toBe(true); + expect(json.changed).toEqual([]); + }); + it('returns the composed system prompt body from indexed memory entries', async () => { await fetch(`${baseUrl}/api/memory`, { method: 'POST', @@ -257,4 +370,82 @@ describe('memory routes', () => { expect(json.body).toContain('### Project'); expect(json.body).toContain('**Project goal** — Ship a cleaner onboarding flow'); }); + + it('streams memory change events over SSE when entries are created', async () => { + const response = await fetch(`${baseUrl}/api/memory/events`); + expect(response.status).toBe(200); + expect(response.body).toBeTruthy(); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + const state = { buffer: '' }; + + try { + const connected = await readNextSseEvent(reader, decoder, state); + expect(connected.event).toBe('connected'); + + const createRes = await fetch(`${baseUrl}/api/memory`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Shipping priority', + description: 'Protect onboarding polish in examples', + type: 'project', + body: '- Keep onboarding examples polished', + }), + }); + expect(createRes.status).toBe(200); + + const change = await readSseEventByType(reader, decoder, state, 'change'); + expect(change.event).toBe('change'); + expect(change.data).toMatchObject({ + kind: 'upsert', + id: 'project_shipping_priority', + name: 'Shipping priority', + description: 'Protect onboarding polish in examples', + type: 'project', + source: 'manual', + }); + } finally { + await reader.cancel(); + } + }); + + it('streams extraction events over SSE when the extraction buffer changes', async () => { + const response = await fetch(`${baseUrl}/api/memory/events`); + expect(response.status).toBe(200); + expect(response.body).toBeTruthy(); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + const state = { buffer: '' }; + + try { + const connected = await readNextSseEvent(reader, decoder, state); + expect(connected.event).toBe('connected'); + + recordHeuristic({ + userMessage: 'Remember that I prefer editorial chart labels.', + writtenCount: 1, + writtenIds: ['feedback_editorial_chart_labels'], + }); + + const extraction = await readNextSseEvent(reader, decoder, state); + expect(extraction.event).toBe('extraction'); + expect(extraction.data).toMatchObject({ + kind: 'heuristic', + phase: 'success', + writtenCount: 1, + writtenIds: ['feedback_editorial_chart_labels'], + }); + } finally { + await reader.cancel(); + } + }); + + it('returns 404 when reading a missing memory entry', async () => { + const res = await fetch(`${baseUrl}/api/memory/user_missing_note`); + + expect(res.status).toBe(404); + const json = await res.json() as { error: string }; + expect(json.error).toBe('memory not found'); + }); }); diff --git a/apps/daemon/tests/routine-routes.test.ts b/apps/daemon/tests/routine-routes.test.ts index cdfa96610..14a35bd28 100644 --- a/apps/daemon/tests/routine-routes.test.ts +++ b/apps/daemon/tests/routine-routes.test.ts @@ -179,6 +179,39 @@ describe('routine routes', () => { } }); + it('rejects patching to a missing reuse-mode target project', async () => { + const { app } = buildApp(); + const { server, port } = await listen(app); + try { + const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Daily digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + const created = await createRes.json() as { routine: { id: string } }; + + const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + target: { mode: 'reuse', projectId: 'missing-project' }, + }), + }); + + expect(patchRes.status).toBe(400); + const json = await patchRes.json() as { error: string }; + expect(json.error).toContain('target project missing-project not found'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('runs a routine now and exposes its run history', async () => { const { app, runNow } = buildApp(); const { server, port } = await listen(app); @@ -222,6 +255,100 @@ describe('routine routes', () => { } }); + it('maps the latest persisted run into the routine contract', async () => { + const { app, db } = buildApp(); + const { server, port } = await listen(app); + try { + const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Daily digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + const created = await createRes.json() as { routine: { id: string } }; + + insertRoutineRun(db, { + id: 'run-failed-1', + routineId: created.routine.id, + trigger: 'manual', + status: 'failed', + projectId: 'proj-failed', + conversationId: 'conv-failed', + agentRunId: 'agent-run-failed', + startedAt: Date.now() - 1000, + completedAt: Date.now(), + summary: 'Connector auth failed', + error: 'provider rejected credentials', + }); + + const getRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`); + expect(getRes.status).toBe(200); + const json = await getRes.json() as { + routine: { + lastRun: { + runId: string; + status: string; + trigger: string; + projectId: string; + conversationId: string; + agentRunId: string; + summary: string; + completedAt: number; + } | null; + }; + }; + expect(json.routine.lastRun).toMatchObject({ + runId: 'run-failed-1', + status: 'failed', + trigger: 'manual', + projectId: 'proj-failed', + conversationId: 'conv-failed', + agentRunId: 'agent-run-failed', + summary: 'Connector auth failed', + }); + expect(json.routine.lastRun?.completedAt).toBeTypeOf('number'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('returns 500 when running a routine now throws', async () => { + const { app, runNow } = buildApp(); + runNow.mockImplementationOnce(async () => { + throw new Error('agent unavailable'); + }); + + const { server, port } = await listen(app); + try { + const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Daily digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + const created = await createRes.json() as { routine: { id: string } }; + + const runRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}/run`, { + method: 'POST', + }); + expect(runRes.status).toBe(500); + const json = await runRes.json() as { error: string }; + expect(json.error).toContain('agent unavailable'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('rejects reuse-mode creation when the target project does not exist', async () => { const { app } = buildApp(); const { server, port } = await listen(app); @@ -251,6 +378,30 @@ describe('routine routes', () => { } }); + it('rejects unsupported target modes during creation', async () => { + const { app } = buildApp(); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Weird target digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'teleport' }, + enabled: true, + }), + }); + + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toContain('Unsupported routine target mode'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('deletes a routine and unschedules it', async () => { const { app, unschedule } = buildApp(); const { server, port } = await listen(app); @@ -317,4 +468,90 @@ describe('routine routes', () => { await new Promise((resolve) => server.close(() => resolve())); } }); + + it('rejects invalid timezone values during creation', async () => { + const { app } = buildApp(); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Bad timezone digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'Mars/Olympus' }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toContain('Invalid timezone'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects invalid weekly weekday values during creation', async () => { + const { app } = buildApp(); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Bad weekday digest', + prompt: 'Summarize activity.', + schedule: { + kind: 'weekly', + weekday: 8, + time: '09:00', + timezone: 'UTC', + }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toContain('weekly.weekday'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects invalid schedule input during routine patch updates', async () => { + const { app } = buildApp(); + const { server, port } = await listen(app); + try { + const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Daily digest', + prompt: 'Summarize activity.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + enabled: true, + }), + }); + const created = await createRes.json() as { routine: { id: string } }; + + const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + schedule: { kind: 'daily', time: '25:99', timezone: 'UTC' }, + }), + }); + + expect(patchRes.status).toBe(400); + const json = await patchRes.json() as { error: string }; + expect(json.error).toContain('Invalid time'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); diff --git a/apps/daemon/tests/routines.test.ts b/apps/daemon/tests/routines.test.ts index 09fae340c..cbe50f768 100644 --- a/apps/daemon/tests/routines.test.ts +++ b/apps/daemon/tests/routines.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { nextRunAtForSchedule } from '../src/routines.js'; +import { + nextRunAtForSchedule, + validateSchedule, + validateTarget, +} from '../src/routines.js'; function partsIn(timezone: string, at: Date): Record { const dtf = new Intl.DateTimeFormat('en-US', { @@ -114,4 +118,80 @@ describe('nextRunAtForSchedule DST handling', () => { expect(parts.hour).toBe('02'); expect(parts.minute).toBe('30'); }); + + it('returns the next hourly slot strictly after now', () => { + const now = new Date('2026-05-13T10:45:30Z'); + const next = nextRunAtForSchedule({ kind: 'hourly', minute: 15 }, now); + expect(next).not.toBeNull(); + if (!next) return; + expect(next.toISOString()).toBe('2026-05-13T11:15:00.000Z'); + }); + + it('returns the next weekday occurrence for weekday schedules', () => { + const now = new Date('2026-05-16T00:00:00Z'); // Saturday + const next = nextRunAtForSchedule( + { kind: 'weekdays', time: '09:00', timezone: 'UTC' }, + now, + ); + expect(next).not.toBeNull(); + if (!next) return; + + const parts = partsIn('UTC', next); + expect(parts.year).toBe('2026'); + expect(parts.month).toBe('05'); + expect(parts.day).toBe('18'); + expect(parts.hour).toBe('09'); + expect(parts.minute).toBe('00'); + }); + + it('returns the next requested weekday for weekly schedules', () => { + const now = new Date('2026-05-13T10:00:00Z'); // Wednesday + const next = nextRunAtForSchedule( + { kind: 'weekly', weekday: 5, time: '08:30', timezone: 'UTC' }, + now, + ); + expect(next).not.toBeNull(); + if (!next) return; + + const parts = partsIn('UTC', next); + expect(parts.year).toBe('2026'); + expect(parts.month).toBe('05'); + expect(parts.day).toBe('15'); + expect(parts.hour).toBe('08'); + expect(parts.minute).toBe('30'); + }); +}); + +describe('routine validation', () => { + it('accepts valid schedule and target shapes', () => { + expect(() => + validateSchedule({ kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' }), + ).not.toThrow(); + expect(() => validateTarget({ mode: 'create_each_run' })).not.toThrow(); + expect(() => validateTarget({ mode: 'reuse', projectId: 'proj-1' })).not.toThrow(); + }); + + it('rejects invalid wall times and timezones', () => { + expect(() => + validateSchedule({ kind: 'daily', time: '25:00', timezone: 'UTC' }), + ).toThrow(/Invalid time/); + expect(() => + validateSchedule({ kind: 'daily', time: '09:00', timezone: 'Mars\/Olympus' }), + ).toThrow(/Invalid timezone/); + }); + + it('rejects invalid weekday and unsupported target mode', () => { + expect(() => + validateSchedule({ kind: 'weekly', weekday: 9 as 0, time: '09:00', timezone: 'UTC' }), + ).toThrow(/weekly\.weekday/); + expect(() => + validateTarget({ mode: 'teleport' } as unknown as Parameters[0]), + ).toThrow(/Unsupported routine target mode/); + }); + + it('rejects reuse targets without a project id', () => { + expect(() => + validateTarget({ mode: 'reuse', projectId: '' }), + ).toThrow(/projectId/); + }); }); diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index 59cf4a03f..0770b06c1 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -1,3 +1,4 @@ +import { symlinkSync } from 'node:fs'; import { test } from 'vitest'; import { homedir } from 'node:os'; import { @@ -5,6 +6,8 @@ import { } from './helpers/test-helpers.js'; import { isCursorAuthFailureText } from '../../src/runtimes/auth.js'; +const fsTest = process.platform === 'win32' ? test.skip : test; + // Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login` // credentials, silently billing API usage. Strip it for the claude // adapter so the user's subscription wins. @@ -197,6 +200,64 @@ test('detectAgents includes sanitized install and docs metadata from split runti } }); +fsTest('detectAgents marks Codex available when nvm exposes a node shim but launch resolution upgrades it to the native binary', async () => { + const home = mkdtempSync(join(tmpdir(), 'od-detect-codex-nvm-native-')); + try { + return await withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], async () => { + const wrapperBinDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'bin'); + const wrapperPkgDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'lib', 'node_modules', '@openai', 'codex'); + const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js'); + const wrapperLinkPath = join(wrapperBinDir, 'codex'); + const nativePkgDir = join( + wrapperPkgDir, + 'node_modules', + '@openai', + `codex-${process.platform}-${process.arch}`, + ); + const nativeTargetTriple = codexNativeTargetTriple(); + const nativePathDir = join(nativePkgDir, 'vendor', nativeTargetTriple, 'path'); + const nativeBin = join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex', 'codex'); + + mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true }); + mkdirSync(wrapperBinDir, { recursive: true }); + mkdirSync(join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex'), { recursive: true }); + mkdirSync(nativePathDir, { recursive: true }); + writeFileSync( + wrapperRealPath, + '#!/usr/bin/env node\nconsole.log("wrapper should not be probed");\n', + ); + writeFileSync(nativeBin, '#!/bin/sh\necho "codex 9.9.9"\n'); + chmodSync(wrapperRealPath, 0o755); + chmodSync(nativeBin, 0o755); + symlinkSync(wrapperRealPath, wrapperLinkPath); + + process.env.HOME = home; + process.env.PATH = '/usr/bin:/bin'; + process.env.OD_AGENT_HOME = home; + + const agents = await detectAgents(); + const codexAgent = agents.find((agent) => agent.id === 'codex'); + + assert.ok(codexAgent); + assert.equal(codexAgent.available, true); + assert.equal(codexAgent.path, wrapperLinkPath); + assert.equal(codexAgent.version, 'codex 9.9.9'); + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +function codexNativeTargetTriple(): string { + if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin'; + if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin'; + if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl'; + if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl'; + if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc'; + if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc'; + return `${process.platform}-${process.arch}`; +} + test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => { const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-rel-')); const oldCwd = process.cwd(); diff --git a/apps/daemon/tests/runtimes/probe-ghost-cli.test.ts b/apps/daemon/tests/runtimes/probe-ghost-cli.test.ts index b98c21f20..04c3383a7 100644 --- a/apps/daemon/tests/runtimes/probe-ghost-cli.test.ts +++ b/apps/daemon/tests/runtimes/probe-ghost-cli.test.ts @@ -22,39 +22,51 @@ * so adapters whose `--version` flag is unsupported are not * regressed. * - * Detection always probes the same path `resolveAgentExecutable` - * picks for chat/run resolution, so a stale configured override that - * shadows a working PATH binary is reported as unavailable rather - * than swapped for the PATH candidate; advertising a different path - * would break the invariant that Settings and the chat spawn path - * agree on what the agent runs (PR #1301 review, Siri-Ray). + * Detection always probes the same launch path chat/run resolution + * picks, so a stale configured override that shadows a working PATH + * binary is reported as unavailable rather than swapped for the PATH + * candidate; advertising a different path would break the invariant + * that Settings and the chat spawn path agree on what the agent runs + * (PR #1301 review, Siri-Ray). */ import { beforeEach, describe, expect, it, vi } from 'vitest'; const execAgentFileMock = vi.fn(); -const resolveAgentExecutableMock = vi.fn(); +const resolveAgentLaunchMock = vi.fn(); vi.mock('../../src/runtimes/invocation.js', () => ({ execAgentFile: (...args: unknown[]) => (execAgentFileMock as unknown as (...args: unknown[]) => unknown)(...args), })); -vi.mock('../../src/runtimes/executables.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../src/runtimes/launch.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - resolveAgentExecutable: ( - ...args: Parameters + resolveAgentLaunch: ( + ...args: Parameters ) => ( - resolveAgentExecutableMock as unknown as ( - ...a: Parameters - ) => ReturnType + resolveAgentLaunchMock as unknown as ( + ...a: Parameters + ) => ReturnType )(...args), }; }); +function fakeCodexLaunch() { + return { + configuredOverridePath: null, + pathResolvedPath: '/fake/bin/codex', + selectedPath: '/fake/bin/codex', + launchPath: '/fake/bin/codex', + launchKind: 'selected' as const, + childPathPrepend: ['/fake/bin'], + diagnostic: null, + }; +} + function spawnError(code: 'ENOENT' | 'EACCES' | 'ENOTDIR' | 'ETIMEDOUT'): NodeJS.ErrnoException { const error = new Error(`spawn failed (${code})`) as NodeJS.ErrnoException; error.code = code; @@ -74,10 +86,10 @@ function exitCodeError(code: number): NodeJS.ErrnoException { describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () => { beforeEach(() => { execAgentFileMock.mockReset(); - resolveAgentExecutableMock.mockReset(); + resolveAgentLaunchMock.mockReset(); // Default: pretend every agent definition resolves to a fake bin so // we exercise the spawn path uniformly. - resolveAgentExecutableMock.mockImplementation(() => '/fake/bin/codex'); + resolveAgentLaunchMock.mockImplementation(fakeCodexLaunch); }); for (const failingCode of ['ENOENT', 'EACCES', 'ENOTDIR'] as const) { @@ -156,25 +168,29 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () // to fall back to a PATH candidate when the configured override // failed to spawn, but that broke the invariant that detection and // chat-run resolution agree on the executable. resolveAgentBin - // still resolves via resolveAgentExecutable (configured override + // still resolves via resolveAgentLaunch (configured override // wins when present and executable), so if detection adopted a // different PATH binary, Settings would show "available at // /usr/local/bin/codex" while every actual run would spawn the // stale /stale/custom/codex and fail. The fix is to keep detection - // honest: probe whichever path resolveAgentExecutable picks, and + // honest: probe whichever path resolveAgentLaunch picks, and // report exactly that path's availability. The Settings repair // flow (PR #1205) needs to derive its adopt-or-clear affordance // from the resolution diagnostic — not from `available`. const { - resolveAgentExecutable: realResolveAgentExecutable, + resolveAgentLaunch: realResolveAgentLaunch, + } = await vi.importActual( + '../../src/runtimes/launch.js', + ); + const { inspectAgentExecutableResolution, } = await vi.importActual( '../../src/runtimes/executables.js', ); // Drive the resolver through its real path so a future refactor // that diverges resolution from detection trips this assertion. - resolveAgentExecutableMock.mockImplementation( - (def, env) => realResolveAgentExecutable(def, env), + resolveAgentLaunchMock.mockImplementation( + (def, env) => realResolveAgentLaunch(def, env), ); // Force a stale configured override + a working PATH candidate. execAgentFileMock.mockImplementation((cmd: string) => { @@ -194,9 +210,9 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () expect(codex).toBeDefined(); // Detection must report unavailable rather than swap to a hypothetical - // PATH candidate, because resolveAgentExecutable (which chat-run + // PATH candidate, because resolveAgentLaunch (which chat-run // resolution uses) will pick whatever the same call returns. - const resolvedForRun = realResolveAgentExecutable( + const resolvedForRun = realResolveAgentLaunch( // re-run AGENT_DEFS's codex entry through the real resolver to // get the executable resolveAgentBin would pick at chat time. // The detection side already validated this path. @@ -204,11 +220,11 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () { id: 'codex', bin: 'codex' } as any, configuredEnv.codex, ); - if (resolvedForRun) { + if (resolvedForRun.selectedPath && resolvedForRun.launchPath) { // If the resolver found a working PATH binary, detection must // have reported available=true with the SAME path. expect(codex?.available).toBe(true); - expect(codex?.path).toBe(resolvedForRun); + expect(codex?.path).toBe(resolvedForRun.selectedPath); } else { // Otherwise detection must report unavailable rather than invent // a different path. diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index efb25b0de..67a852190 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -5007,6 +5007,12 @@ function HtmlViewer({ setZoom((z) => Math.max(25, Math.min(200, z + delta))); } + function activateBoard(nextTool?: BoardTool) { + setMode('preview'); + setBoardMode(true); + if (nextTool) setBoardTool(nextTool); + } + function clearBoardComposer() { setActiveCommentTarget(null); setHoveredCommentTarget(null); @@ -5015,12 +5021,6 @@ function HtmlViewer({ setStrokePoints([]); } - function activateBoard(tool: BoardTool) { - setBoardTool(tool); - setDrawOverlayOpen(false); - setBoardMode(true); - } - function queueCurrentDraft() { const note = commentDraft.trim(); if (!note) return; @@ -5308,6 +5308,78 @@ function HtmlViewer({ /> ) : null} + + {boardMode ? ( + <> + + + + ) : null} +