Skip to content
Merged
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
68 changes: 44 additions & 24 deletions assistant/src/__tests__/clarification-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,53 @@ let llmResolution: 'keep_existing' | 'keep_candidate' | 'merge' | 'still_unclear
let llmResolvedStatement = '';
let llmExplanation = 'Unclear response from user.';

mock.module('@anthropic-ai/sdk', () => ({
default: class MockAnthropic {
messages = {
create: async (_body: unknown, opts?: { signal?: AbortSignal }) => {
llmCallCount += 1;
if (llmDelayMs > 0) {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, llmDelayMs);
opts?.signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new Error('Request was aborted.'));
});
mock.module('../providers/anthropic-send-message.js', () => ({
getAnthropicProvider: () => ({
sendMessage: async (
_messages: unknown,
_tools: unknown,
_system: unknown,
opts?: { signal?: AbortSignal },
) => {
llmCallCount += 1;
if (llmDelayMs > 0) {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, llmDelayMs);
opts?.signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new Error('Request was aborted.'));
});
}
return {
content: [{
type: 'tool_use',
input: {
resolution: llmResolution,
resolved_statement: llmResolvedStatement,
explanation: llmExplanation,
},
}],
};
},
});
}
return {
content: [{
type: 'tool_use' as const,
id: 'test-tool-use-id',
name: 'resolve_conflict',
input: {
resolution: llmResolution,
resolved_statement: llmResolvedStatement,
explanation: llmExplanation,
},
}],
model: 'claude-haiku-4-5-20251001',
stopReason: 'tool_use',
usage: { inputTokens: 0, outputTokens: 0 },
};
},
}),
createTimeout: (ms: number) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
},
extractToolUse: (response: { content: Array<{ type: string }> }) => {
return response.content.find((b: { type: string }) => b.type === 'tool_use');
},
userMessage: (text: string) => ({ role: 'user', content: [{ type: 'text', text }] }),
}));

mock.module('../config/loader.js', () => ({
Expand Down
25 changes: 20 additions & 5 deletions assistant/src/__tests__/contradiction-checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,37 @@ const classifyRelationshipMock = mock(async () => {
return {
content: [
{
type: 'tool_use',
type: 'tool_use' as const,
id: 'test-tool-use-id',
name: 'classify_relationship',
input: {
relationship: nextRelationship,
explanation: nextExplanation,
},
},
],
model: 'claude-haiku-4-5-20251001',
stopReason: 'tool_use',
usage: { inputTokens: 0, outputTokens: 0 },
};
});

mock.module('@anthropic-ai/sdk', () => ({
default: class MockAnthropic {
messages = {
create: classifyRelationshipMock,
mock.module('../providers/anthropic-send-message.js', () => ({
getAnthropicProvider: () => ({
sendMessage: classifyRelationshipMock,
}),
createTimeout: (ms: number) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
},
extractToolUse: (response: { content: Array<{ type: string }> }) => {
return response.content.find((b: { type: string }) => b.type === 'tool_use');
},
userMessage: (text: string) => ({ role: 'user', content: [{ type: 'text', text }] }),
}));

mock.module('../util/platform.js', () => ({
Expand Down
42 changes: 42 additions & 0 deletions assistant/src/__tests__/no-direct-anthropic-sdk-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { execSync } from 'node:child_process';
import { describe, expect, test } from 'bun:test';

/**
* Guard test: production files under assistant/src must not import
* `@anthropic-ai/sdk` directly. Only the canonical provider adapter is
* allowed to use the SDK.
*
* Allowlist entries should be kept minimal — add a path here only if it
* genuinely needs to talk to the Anthropic SDK without going through the
* provider abstraction.
*/
const ALLOWED_FILES = new Set([
'assistant/src/providers/anthropic/client.ts',
]);

describe('no direct @anthropic-ai/sdk imports', () => {
test('production files do not import @anthropic-ai/sdk outside allowlist', () => {
let grepOutput = '';
try {
grepOutput = execSync(
`git grep -l "@anthropic-ai/sdk" -- 'assistant/src/**/*.ts'`,
{ encoding: 'utf-8', cwd: process.cwd() + '/../..' },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve git grep from repository root

The new guard test computes cwd as process.cwd() + '/../..', which points outside this repo under the normal workflow (cd assistant && bun test from AGENTS.md), so git grep errors with fatal: not a git repository and the test fails even when there are no violations. I verified this by running cd assistant && bun test src/__tests__/no-direct-anthropic-sdk-imports.test.ts, which fails before checking imports.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Guard test cwd navigates one directory too far above the repo root

The no-direct-anthropic-sdk-imports.test.ts guard test sets the cwd for git grep to process.cwd() + '/../..', which navigates two directories up from process.cwd(). Per the project conventions (cd assistant && bun test), process.cwd() is {repo}/assistant/. Going up two levels lands at {repo}/.., which is outside the git repository.

Root Cause and Impact

When git grep is executed from outside the git repository, it exits with code 128 ("fatal: not a git repository"). The catch block only handles exit code 1 (no matches — the happy path). Since 128 !== 1, the error is re-thrown, causing the test to always fail regardless of whether violations exist:

// assistant/src/__tests__/no-direct-anthropic-sdk-imports.test.ts:23
cwd: process.cwd() + '/../..'
// From {repo}/assistant/ this resolves to {repo}/../ — outside the repo

The fix should use '/..' (one level up) instead of '/../..' (two levels up) to navigate from {repo}/assistant/ to {repo}/.

Impact: The guard test cannot work as intended — it will always throw an error instead of detecting direct @anthropic-ai/sdk imports in production files.

Suggested change
{ encoding: 'utf-8', cwd: process.cwd() + '/../..' },
{ encoding: 'utf-8', cwd: process.cwd() + '/..' },
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

).trim();
} catch (err) {
// Exit code 1 means no matches — that's the happy path
if ((err as { status?: number }).status === 1) {
return;
}
throw err;
}

const files = grepOutput
.split('\n')
.filter((f) => f.length > 0)
// Exclude test files — they legitimately mock the SDK
.filter((f) => !f.includes('/__tests__/'));
const violations = files.filter((f) => !ALLOWED_FILES.has(f));

expect(violations).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { readFile } from 'node:fs/promises';
import Anthropic from '@anthropic-ai/sdk';
import { getConfig } from '../../../../config/loader.js';
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
import { getAnthropicProvider, extractText, userMessageWithImage } from '../../../../providers/anthropic-send-message.js';
import {
getMediaAssetById,
getKeyframesForAsset,
Expand Down Expand Up @@ -70,17 +69,15 @@ export async function analyzeKeyframesForAsset(

updateProcessingStage(stage.id, { status: 'running', startedAt: Date.now() });

const config = getConfig();
const apiKey = config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
const provider = getAnthropicProvider();
if (!provider) {
updateProcessingStage(stage.id, {
status: 'failed',
lastError: 'Anthropic API key not configured',
});
throw new Error('No Anthropic API key available. Configure it in settings or set ANTHROPIC_API_KEY.');
}

const client = new Anthropic({ apiKey });
let analyzedCount = analyzedKeyframeIds.size;
const totalKeyframes = keyframes.length;

Expand Down Expand Up @@ -114,7 +111,7 @@ export async function analyzeKeyframesForAsset(
}

try {
const result = await analyzeKeyframe(client, keyframe);
const result = await analyzeKeyframe(provider, keyframe);
batchResults.push({
assetId,
keyframeId: keyframe.id,
Expand Down Expand Up @@ -232,7 +229,7 @@ export async function run(
}

async function analyzeKeyframe(
client: Anthropic,
provider: import('../../../../providers/types.js').Provider,
keyframe: MediaKeyframe,
): Promise<{ output: Record<string, unknown>; confidence: number }> {
// Read the image file and encode as base64
Expand All @@ -250,33 +247,20 @@ async function analyzeKeyframe(
};
const mediaType = mediaTypeMap[ext] ?? 'image/jpeg';

const response = await client.messages.create({
model: 'claude-sonnet-4-6-20250514',
max_tokens: 1024,
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
data: base64,
},
},
{
type: 'text',
text: VLM_PROMPT,
},
],
const response = await provider.sendMessage(
[userMessageWithImage(base64, mediaType, VLM_PROMPT)],
undefined,
undefined,
{
config: {
model: 'claude-sonnet-4-6-20250514',
max_tokens: 1024,
},
],
});
},
);

// Extract text from response
const textBlock = response.content.find((block) => block.type === 'text');
const responseText = textBlock && 'text' in textBlock ? textBlock.text : '';
const responseText = extractText(response);

// Parse JSON from response
let output: Record<string, unknown>;
Expand Down
33 changes: 15 additions & 18 deletions assistant/src/config/skills.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
import Anthropic from '@anthropic-ai/sdk';
import { getConfig } from './loader.js';
import { getWorkspaceSkillsDir } from '../util/platform.js';
import { getLogger } from '../util/logger.js';
import { stripCommentLines } from './system-prompt.js';
import { parseFrontmatterFields } from '../skills/frontmatter.js';
import { parseToolManifestFile } from '../skills/tool-manifest.js';
import { computeSkillVersionHash } from '../skills/version-hash.js';
import { getAnthropicProvider, extractAllText, userMessage } from '../providers/anthropic-send-message.js';

const log = getLogger('skills');

Expand Down Expand Up @@ -887,27 +887,24 @@ export function loadSkillBySelector(selector: string, workspaceSkillsDir?: strin
// ─── Icon generation ─────────────────────────────────────────────────────────

async function generateSkillIcon(name: string, description: string): Promise<string> {
const config = getConfig();
const apiKey = config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
const provider = getAnthropicProvider();
if (!provider) {
throw new Error('No Anthropic API key available for icon generation');
}

const client = new Anthropic({ apiKey });
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
system: 'You are a pixel art icon designer. When asked, return ONLY a single <svg> element — no explanation, no markdown, no code fences. The SVG must be a 16x16 grid pixel art icon using <rect> elements. Use a limited palette (3-5 colors). Keep it under 2KB. The viewBox should be "0 0 16 16" with each pixel being a 1x1 rect.',
messages: [{
role: 'user',
content: `Create a 16x16 pixel art SVG icon representing this skill:\nName: ${name}\nDescription: ${description}`,
}],
});
const response = await provider.sendMessage(
[userMessage(`Create a 16x16 pixel art SVG icon representing this skill:\nName: ${name}\nDescription: ${description}`)],
undefined,
'You are a pixel art icon designer. When asked, return ONLY a single <svg> element — no explanation, no markdown, no code fences. The SVG must be a 16x16 grid pixel art icon using <rect> elements. Use a limited palette (3-5 colors). Keep it under 2KB. The viewBox should be "0 0 16 16" with each pixel being a 1x1 rect.',
{
config: {
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
},
},
);

const text = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
const text = extractAllText(response);

const svgMatch = text.match(/<svg[\s\S]*<\/svg>/i);
if (!svgMatch) {
Expand Down
59 changes: 31 additions & 28 deletions assistant/src/daemon/classifier.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Anthropic from '@anthropic-ai/sdk';
import { getConfig } from '../config/loader.js';
import { getAnthropicProvider, createTimeout, extractToolUse, userMessage } from '../providers/anthropic-send-message.js';
import { getLogger } from '../util/logger.js';

const log = getLogger('classifier');
Expand All @@ -18,21 +17,18 @@ export async function classifyInteraction(task: string, source?: 'voice' | 'text
return 'text_qa';
}

const config = getConfig();
const apiKey = config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
const provider = getAnthropicProvider();
if (!provider) {
log.warn('No API key available, falling back to heuristic classification');
return classifyHeuristic(task);
}

try {
const client = new Anthropic({ apiKey });
const response = await Promise.race([
client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 128,
system: 'You are a classifier. Determine whether the user\'s request requires computer use (controlling the GUI — clicking, scrolling, typing into app windows, navigating between apps) or can be handled with local tools (answering questions, running terminal commands, creating/editing/reading files, web searches, writing code). GUI tasks → computer_use. Everything else → text_qa.',
tools: [{
const { signal, cleanup } = createTimeout(CLASSIFICATION_TIMEOUT_MS);
try {
const response = await provider.sendMessage(
[userMessage(task)],
[{
name: 'classify_interaction',
description: 'Classify the user interaction type',
input_schema: {
Expand All @@ -51,24 +47,31 @@ export async function classifyInteraction(task: string, source?: 'voice' | 'text
required: ['interaction_type', 'reasoning'],
},
}],
tool_choice: { type: 'tool' as const, name: 'classify_interaction' },
messages: [{ role: 'user' as const, content: task }],
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Classification timeout')), CLASSIFICATION_TIMEOUT_MS),
),
]);
'You are a classifier. Determine whether the user\'s request requires computer use (controlling the GUI — clicking, scrolling, typing into app windows, navigating between apps) or can be handled with local tools (answering questions, running terminal commands, creating/editing/reading files, web searches, writing code). GUI tasks → computer_use. Everything else → text_qa.',
{
config: {
model: 'claude-haiku-4-5-20251001',
max_tokens: 128,
tool_choice: { type: 'tool' as const, name: 'classify_interaction' },
},
signal,
},
);
cleanup();

const toolBlock = response.content.find((b) => b.type === 'tool_use');
if (toolBlock && toolBlock.type === 'tool_use') {
const input = toolBlock.input as { interaction_type?: string; reasoning?: string };
const result = input.interaction_type === 'text_qa' ? 'text_qa' : 'computer_use';
log.info({ result, reasoning: input.reasoning }, 'Haiku classification');
return result;
}
const toolBlock = extractToolUse(response);
if (toolBlock) {
const input = toolBlock.input as { interaction_type?: string; reasoning?: string };
const result = input.interaction_type === 'text_qa' ? 'text_qa' : 'computer_use';
log.info({ result, reasoning: input.reasoning }, 'Haiku classification');
return result;
}

log.warn('No tool_use block in classification response, falling back to heuristic');
return classifyHeuristic(task);
log.warn('No tool_use block in classification response, falling back to heuristic');
return classifyHeuristic(task);
} finally {
cleanup();
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn({ err: message }, 'Haiku classification failed, falling back to heuristic');
Expand Down
Loading
Loading