Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/cli/src/commands/ask.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,6 +43,86 @@ const createMockProcess = ({ throwOnKill = false } = {}) => {
return { mock, emit, listeners, killCalls, exitCalls, offCalls };
};

const withTempHome = async <T>(run: (tempHome: string) => Promise<T>): Promise<T> => {
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;
Expand Down Expand Up @@ -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();
}
});
});
100 changes: 52 additions & 48 deletions apps/cli/src/commands/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<thinking>\n');
inReasoning = true;
}
process.stdout.write(delta);
},
onTextDelta: (delta) => {
if (inReasoning) {
process.stdout.write('\n</thinking>\n\n');
inReasoning = false;
}
hasText = true;
outputChars += delta.length;
process.stdout.write(delta);
},
onToolCall: (tool) => {
if (inReasoning) {
process.stdout.write('\n</thinking>\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('<thinking>\n');
inReasoning = true;
}
process.stdout.write(delta);
},
onTextDelta: (delta) => {
if (inReasoning) {
process.stdout.write('\n</thinking>\n\n');
inReasoning = false;
}
hasText = true;
outputChars += delta.length;
process.stdout.write(delta);
},
onToolCall: (tool) => {
if (inReasoning) {
process.stdout.write('\n</thinking>\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</thinking>\n');
}
if (inReasoning) {
process.stdout.write('\n</thinking>\n');
}

console.log('\n');
console.log('\n');
},
catch: (cause) =>
cause instanceof BtcaError ? cause : new BtcaError(String(cause))
});
} finally {
teardownSignalCleanup();
Expand Down
85 changes: 84 additions & 1 deletion apps/server/src/resources/impls/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } = {}
) => {
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;

Expand Down Expand Up @@ -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);
});
});
});
Loading