diff --git a/apps/cli/src/commands/ask.test.ts b/apps/cli/src/commands/ask.test.ts index 3167837b..9a1725f0 100644 --- a/apps/cli/src/commands/ask.test.ts +++ b/apps/cli/src/commands/ask.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { registerSignalCleanup, streamErrorToBtcaError } from './ask.ts'; +import { runEffectCli } from '../effect/cli-app.ts'; type SignalEvent = 'SIGINT' | 'SIGTERM' | 'exit'; type ForwardedSignal = 'SIGINT' | 'SIGTERM'; @@ -39,6 +43,86 @@ const createMockProcess = ({ throwOnKill = false } = {}) => { return { mock, emit, listeners, killCalls, exitCalls, offCalls }; }; +const withTempHome = async (run: (tempHome: string) => Promise): Promise => { + const tempHome = mkdtempSync(path.join(tmpdir(), 'btca-ask-test-')); + const originalHome = process.env.HOME; + process.env.HOME = tempHome; + try { + return await run(tempHome); + } finally { + process.env.HOME = originalHome; + rmSync(tempHome, { recursive: true, force: true }); + } +}; + +const createAskStubServer = () => { + const encoder = new TextEncoder(); + const requestPaths: string[] = []; + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url); + requestPaths.push(url.pathname); + + if (url.pathname === '/') return Response.json({ ok: true }); + if (url.pathname === '/config') { + return Response.json({ + provider: 'opencode', + model: 'claude-haiku-4-5', + providerTimeoutMs: 300000, + maxSteps: 40, + resourcesDirectory: '/tmp/resources', + resourceCount: 1 + }); + } + if (url.pathname === '/resources') { + return Response.json({ + resources: [ + { + type: 'git', + name: 'chipwhisperer', + url: 'https://github.com/newaetech/chipwhisperer', + branch: 'develop' + } + ] + }); + } + if (url.pathname === '/question/stream') { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'meta' })}\n\n`)); + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: 'error', + message: 'Provider "opencode" is not authenticated.', + tag: 'ProviderNotAuthenticatedError' + })}\n\n` + ) + ); + controller.close(); + } + }), + { + headers: { + 'Content-Type': 'text/event-stream' + } + } + ); + } + + return Response.json({ error: 'not found' }, { status: 404 }); + } + }); + + return { + server, + url: `http://127.0.0.1:${server.port}`, + requestPaths + }; +}; + describe('registerSignalCleanup', () => { test('stops server and re-signals on SIGINT', () => { let stopCalls = 0; @@ -108,3 +192,49 @@ describe('streamErrorToBtcaError', () => { expect(error.tag).toBe('ProviderNotAuthenticatedError'); }); }); + +describe('ask command streaming errors', () => { + test('surfaces provider auth errors from SSE responses', async () => { + const stub = createAskStubServer(); + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + console.log = (...args) => { + output.push(args.map((arg) => String(arg)).join(' ')); + }; + console.error = (...args) => { + output.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + const exitCode = await withTempHome(() => + runEffectCli( + [ + 'bun', + 'src/index.ts', + 'ask', + '--server', + stub.url, + '--question', + 'What is this repo?', + '--resource', + 'chipwhisperer' + ], + 'test' + ) + ); + + expect(exitCode).toBe(1); + expect(stub.requestPaths).toContain('/question/stream'); + expect(output.join('\n')).toContain('Provider "opencode" is not authenticated.'); + expect(output.join('\n')).toContain( + 'Hint: run btca connect to authenticate and pick a model.' + ); + expect(output.join('\n')).not.toContain('An error occurred in Effect.tryPromise'); + } finally { + console.log = originalLog; + console.error = originalError; + stub.server.stop(); + } + }); +}); diff --git a/apps/cli/src/commands/ask.ts b/apps/cli/src/commands/ask.ts index c8ee7ede..098b307e 100644 --- a/apps/cli/src/commands/ask.ts +++ b/apps/cli/src/commands/ask.ts @@ -214,58 +214,62 @@ export const runAskCommand = (args: { quiet: true }); - yield* Effect.tryPromise(async () => { - let receivedMeta = false; - let inReasoning = false; - let hasText = false; + yield* Effect.tryPromise({ + try: async () => { + let receivedMeta = false; + let inReasoning = false; + let hasText = false; - for await (const event of parseSSEStream(response)) { - handleStreamEvent(event, { - onMeta: () => { - if (!receivedMeta) { - console.log('creating collection...\n'); - receivedMeta = true; + for await (const event of parseSSEStream(response)) { + handleStreamEvent(event, { + onMeta: () => { + if (!receivedMeta) { + console.log('creating collection...\n'); + receivedMeta = true; + } + }, + onReasoningDelta: (delta) => { + if (!showThinking) return; + if (!inReasoning) { + process.stdout.write('\n'); + inReasoning = true; + } + process.stdout.write(delta); + }, + onTextDelta: (delta) => { + if (inReasoning) { + process.stdout.write('\n\n\n'); + inReasoning = false; + } + hasText = true; + outputChars += delta.length; + process.stdout.write(delta); + }, + onToolCall: (tool) => { + if (inReasoning) { + process.stdout.write('\n\n\n'); + inReasoning = false; + } + if (!showTools) return; + if (hasText) { + process.stdout.write('\n'); + } + console.log(`[${tool}]`); + }, + onError: (message, tag, hint) => { + throw streamErrorToBtcaError(message, tag, hint); } - }, - onReasoningDelta: (delta) => { - if (!showThinking) return; - if (!inReasoning) { - process.stdout.write('\n'); - inReasoning = true; - } - process.stdout.write(delta); - }, - onTextDelta: (delta) => { - if (inReasoning) { - process.stdout.write('\n\n\n'); - inReasoning = false; - } - hasText = true; - outputChars += delta.length; - process.stdout.write(delta); - }, - onToolCall: (tool) => { - if (inReasoning) { - process.stdout.write('\n\n\n'); - inReasoning = false; - } - if (!showTools) return; - if (hasText) { - process.stdout.write('\n'); - } - console.log(`[${tool}]`); - }, - onError: (message, tag, hint) => { - throw streamErrorToBtcaError(message, tag, hint); - } - }); - } + }); + } - if (inReasoning) { - process.stdout.write('\n\n'); - } + if (inReasoning) { + process.stdout.write('\n\n'); + } - console.log('\n'); + console.log('\n'); + }, + catch: (cause) => + cause instanceof BtcaError ? cause : new BtcaError(String(cause)) }); } finally { teardownSignalCleanup(); diff --git a/apps/server/src/resources/impls/git.test.ts b/apps/server/src/resources/impls/git.test.ts index 9b6c9f5c..32110063 100644 --- a/apps/server/src/resources/impls/git.test.ts +++ b/apps/server/src/resources/impls/git.test.ts @@ -3,9 +3,37 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { loadGitResource } from './git.ts'; +import { loadGitResource, syncSparseCheckoutPaths } from './git.ts'; import type { BtcaGitResourceArgs } from '../types.ts'; +const runGit = async ( + args: string[], + options: { cwd?: string; env?: Record } = {} +) => { + const proc = Bun.spawn(['git', ...args], { + cwd: options.cwd, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'btca-test', + GIT_AUTHOR_EMAIL: 'btca-test@example.com', + GIT_COMMITTER_NAME: 'btca-test', + GIT_COMMITTER_EMAIL: 'btca-test@example.com', + ...(options.env ?? {}) + }, + stdout: 'pipe', + stderr: 'pipe' + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error(`git ${args.join(' ')} failed (${exitCode}): ${stderr}`); + } + + return { stdout, stderr }; +}; + describe('Git Resource', () => { let testDir: string; @@ -115,5 +143,60 @@ describe('Git Resource', () => { expect(loadGitResource(args)).rejects.toThrow('path traversal'); }); + + it('supports sparse checkout updates for submodule-backed search paths', async () => { + const childRepo = path.join(testDir, 'child-repo'); + const childBareRepo = path.join(testDir, 'child-repo.git'); + const parentRepo = path.join(testDir, 'parent-repo'); + const cloneRepo = path.join(testDir, 'clone-repo'); + + await fs.mkdir(childRepo, { recursive: true }); + await runGit(['init', '-b', 'main'], { cwd: childRepo }); + await fs.writeFile(path.join(childRepo, 'README.md'), '# child\n'); + await runGit(['add', 'README.md'], { cwd: childRepo }); + await runGit(['commit', '-m', 'init child'], { cwd: childRepo }); + await runGit(['clone', '--bare', childRepo, childBareRepo]); + + await fs.mkdir(parentRepo, { recursive: true }); + await runGit(['init', '-b', 'main'], { cwd: parentRepo }); + await fs.writeFile(path.join(parentRepo, 'README.md'), '# parent\n'); + await runGit(['add', 'README.md'], { cwd: parentRepo }); + await runGit(['commit', '-m', 'init parent'], { cwd: parentRepo }); + await runGit( + [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + childBareRepo, + 'chipwhisperer-minimal' + ], + { cwd: parentRepo } + ); + await runGit(['commit', '-am', 'add submodule'], { cwd: parentRepo }); + + await runGit(['clone', '--no-checkout', '--sparse', '-b', 'main', parentRepo, cloneRepo]); + + await syncSparseCheckoutPaths({ + localAbsolutePath: cloneRepo, + repoSubPaths: ['chipwhisperer-minimal'], + quiet: true + }); + + const firstStat = await fs.stat(path.join(cloneRepo, 'chipwhisperer-minimal')); + expect(firstStat.isDirectory()).toBe(true); + + await runGit(['fetch', '--depth', '1', 'origin', 'main'], { cwd: cloneRepo }); + await runGit(['reset', '--hard', 'origin/main'], { cwd: cloneRepo }); + + await syncSparseCheckoutPaths({ + localAbsolutePath: cloneRepo, + repoSubPaths: ['chipwhisperer-minimal'], + quiet: true + }); + + const secondStat = await fs.stat(path.join(cloneRepo, 'chipwhisperer-minimal')); + expect(secondStat.isDirectory()).toBe(true); + }); }); }); diff --git a/apps/server/src/resources/impls/git.ts b/apps/server/src/resources/impls/git.ts index b6821e20..b1be7c0b 100644 --- a/apps/server/src/resources/impls/git.ts +++ b/apps/server/src/resources/impls/git.ts @@ -224,6 +224,38 @@ const runGit = async ( return { exitCode, stderr }; }; +export const syncSparseCheckoutPaths = async (args: { + localAbsolutePath: string; + repoSubPaths: readonly string[]; + quiet: boolean; +}) => { + await runGitChecked( + ['sparse-checkout', 'set', '--skip-checks', ...args.repoSubPaths], + { cwd: args.localAbsolutePath, quiet: args.quiet }, + (sparseResult) => + new ResourceError({ + message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`, + hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.', + cause: new Error( + `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}` + ) + }) + ); + + await runGitChecked( + ['checkout'], + { cwd: args.localAbsolutePath, quiet: args.quiet }, + (checkoutResult) => + new ResourceError({ + message: 'Failed to checkout repository', + hint: CommonHints.CLEAR_CACHE, + cause: new Error( + `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}` + ) + }) + ); +}; + const gitClone = async (args: { repoUrl: string; repoBranch: string; @@ -290,31 +322,11 @@ const gitClone = async (args: { }); if (needsSparseCheckout) { - await runGitChecked( - ['sparse-checkout', 'set', ...args.repoSubPaths], - { cwd: args.localAbsolutePath, quiet: args.quiet }, - (sparseResult) => - new ResourceError({ - message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`, - hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.', - cause: new Error( - `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}` - ) - }) - ); - - await runGitChecked( - ['checkout'], - { cwd: args.localAbsolutePath, quiet: args.quiet }, - (checkout) => - new ResourceError({ - message: 'Failed to checkout repository', - hint: CommonHints.CLEAR_CACHE, - cause: new Error( - `git checkout failed with exit code ${checkout.exitCode}: ${checkout.stderr}` - ) - }) - ); + await syncSparseCheckoutPaths({ + localAbsolutePath: args.localAbsolutePath, + repoSubPaths: args.repoSubPaths, + quiet: args.quiet + }); } }; @@ -358,31 +370,11 @@ const gitUpdate = async (args: { ); if (args.repoSubPaths.length > 0) { - await runGitChecked( - ['sparse-checkout', 'set', ...args.repoSubPaths], - { cwd: args.localAbsolutePath, quiet: args.quiet }, - (sparseResult) => - new ResourceError({ - message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`, - hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.', - cause: new Error( - `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}` - ) - }) - ); - - await runGitChecked( - ['checkout'], - { cwd: args.localAbsolutePath, quiet: args.quiet }, - (checkoutResult) => - new ResourceError({ - message: 'Failed to checkout repository', - hint: CommonHints.CLEAR_CACHE, - cause: new Error( - `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}` - ) - }) - ); + await syncSparseCheckoutPaths({ + localAbsolutePath: args.localAbsolutePath, + repoSubPaths: args.repoSubPaths, + quiet: args.quiet + }); } };