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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pnpm-debug.log*
# VitePress
website/.vitepress/dist/
website/.vitepress/cache/
website/.vitepress/.temp/

# Playwright MCP screenshots
.playwright-mcp/
Expand Down
47 changes: 46 additions & 1 deletion apps/desktop/scripts/after-pack-prune.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,53 @@ function archName(arch) {
return typeof arch === 'number' ? (ARCH_NAMES[arch] ?? String(arch)) : String(arch);
}

function sleepSync(ms) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Busy wait is acceptable here: this is a short build-time retry delay.
}
}

function withRmRetry(fn) {
for (let attempt = 0; attempt <= 3; attempt += 1) {
try {
fn();
return;
} catch (err) {
if (err?.code === 'ENOENT') return;
if (!['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(err?.code) || attempt === 3) {
throw err;
}
sleepSync(50 * (attempt + 1));
}
}
}

function rm(target) {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3 });
const pending = [target];
const directories = [];
while (pending.length > 0) {
const current = pending.pop();
if (!current) continue;
let stat;
try {
stat = fs.lstatSync(current);
} catch (err) {
if (err?.code === 'ENOENT') continue;
throw err;
}
if (!stat.isDirectory()) {
withRmRetry(() => fs.unlinkSync(current));
continue;
}
directories.push(current);
for (const entry of fs.readdirSync(current)) {
pending.push(path.join(current, entry));
}
}
for (const dir of directories.reverse()) {
withRmRetry(() => fs.rmdirSync(dir));
}
}

function existingDirs(paths) {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/scripts/after-pack-prune.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('after-pack-prune', () => {
await afterPackPrune({
appOutDir: root,
electronPlatformName: 'darwin',
arch: 3,
arch: 'arm64',
packager: { appInfo: { productFilename: 'Open CoDesign' } },
});

Expand All @@ -64,7 +64,7 @@ describe('after-pack-prune', () => {
afterPackPrune({
appOutDir: root,
electronPlatformName: 'darwin',
arch: 3,
arch: 'arm64',
packager: { appInfo: { productFilename: 'Open CoDesign' } },
}),
).resolves.toBeUndefined();
Expand Down
66 changes: 63 additions & 3 deletions apps/desktop/src/main/connection-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,21 +1032,81 @@ describe('runProviderTest degrade-probe (issue #179)', () => {
}
});

it('anthropic: /models 404 does NOT degrade (standard endpoint must stay authoritative)', async () => {
it('anthropic: /models 404 + /v1/messages 404 preserves original 404', async () => {
const { calls, restore } = installFakeFetch(() => ({ status: 404 }));
try {
const res = await runProviderTest({
provider: 'anthropic-like',
wire: 'anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
baseUrl: 'https://proxy.example.com/anthropic',
});
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe('404');
if (!res.ok) expect(res.compatibility).toBe('incompatible');
// Only /v1/models should have been probed — no /v1/messages degrade.
expect(calls).toHaveLength(1);
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toMatch(/\/v1\/models$/);
expect(calls[1]?.url).toMatch(/\/v1\/messages$/);
} finally {
restore();
}
});

it('anthropic: /models 404 + /v1/messages 400 degrades because Messages endpoint is alive', async () => {
const { calls, restore } = installFakeFetch((url) => {
if (url.endsWith('/v1/models')) return { status: 404 };
if (url.endsWith('/v1/messages')) {
return {
status: 400,
body: { error: { type: 'invalid_request_error', message: 'model missing' } },
};
}
return { status: 500 };
});
try {
const res = await runProviderTest({
provider: 'anthropic-like',
wire: 'anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://proxy.example.com/anthropic',
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.probeMethod).toBe('anthropic_messages_degraded');
expect(res.compatibility).toBe('degraded');
}
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toMatch(/\/v1\/models$/);
expect(calls[1]?.url).toMatch(/\/v1\/messages$/);
expect(calls[1]?.method).toBe('POST');
const body = JSON.parse(calls[1]?.body ?? '{}');
expect(body.max_tokens).toBe(1);
expect(body.stream).toBe(false);
expect(Array.isArray(body.messages)).toBe(true);
} finally {
restore();
}
});

it('anthropic: /models 404 + generic /v1/messages 400 surfaces the 400', async () => {
const { restore } = installFakeFetch((url) => {
if (url.endsWith('/v1/models')) return { status: 404 };
if (url.endsWith('/v1/messages')) return { status: 400, body: { error: 'bad request' } };
return { status: 500 };
});
try {
const res = await runProviderTest({
provider: 'anthropic-like',
wire: 'anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://proxy.example.com/anthropic',
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.code).toBe('NETWORK');
expect(res.message).toBe('HTTP 400');
}
} finally {
restore();
}
Expand Down
79 changes: 63 additions & 16 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ export interface ConnectionTestResult {
* endpoint so a gateway that only implements /chat/completions can't
* false-positive for a user whose provider is on the Responses API.
*/
probeMethod?: 'models' | 'chat_completion_degraded' | 'responses_degraded';
probeMethod?:
| 'models'
| 'chat_completion_degraded'
| 'responses_degraded'
| 'anthropic_messages_degraded';
compatibility?: 'compatible' | 'degraded';
reasonCategory?: DiagnosticCategory;
}
Expand Down Expand Up @@ -552,7 +556,12 @@ export async function runProviderTest(
// chat request before declaring the endpoint dead. We intentionally do
// not degrade anthropic — its /v1/models is standard, and skipping it
// would mask real path-shape mistakes.
if (res.status === 404 && (creds.wire === 'openai-chat' || creds.wire === 'openai-responses')) {
if (
res.status === 404 &&
(creds.wire === 'openai-chat' ||
creds.wire === 'openai-responses' ||
creds.wire === 'anthropic')
) {
const degraded = await tryDegradeProbe(creds.wire, normalizedBaseUrl, headers);
if (degraded !== null) return degraded;
// Inference endpoint also 404'd (or the network dropped) — fall through
Expand All @@ -572,15 +581,20 @@ export async function runProviderTest(
}

async function tryDegradeProbe(
wire: 'openai-chat' | 'openai-responses',
wire: 'openai-chat' | 'openai-responses' | 'anthropic',
normalizedBaseUrl: string,
headers: Record<string, string>,
): Promise<ConnectionTestResponse | null> {
const probe = await probeInferenceEndpoint(wire, normalizedBaseUrl, headers);
if (probe.kind === 'pass') {
return {
ok: true,
probeMethod: wire === 'openai-responses' ? 'responses_degraded' : 'chat_completion_degraded',
probeMethod:
wire === 'openai-responses'
? 'responses_degraded'
: wire === 'anthropic'
? 'anthropic_messages_degraded'
: 'chat_completion_degraded',
compatibility: 'degraded',
reasonCategory: 'model-discovery-degraded',
};
Expand Down Expand Up @@ -616,28 +630,37 @@ type ProbeResult =
* shape with a 4xx we still know the route exists.
*/
async function probeInferenceEndpoint(
wire: 'openai-chat' | 'openai-responses',
wire: 'openai-chat' | 'openai-responses' | 'anthropic',
normalizedBaseUrl: string,
headers: Record<string, string>,
): Promise<ProbeResult> {
const url =
wire === 'openai-responses'
? `${normalizedBaseUrl}/responses`
: `${normalizedBaseUrl}/chat/completions`;
wire === 'anthropic'
? `${normalizedBaseUrl}/v1/messages`
: wire === 'openai-responses'
? `${normalizedBaseUrl}/responses`
: `${normalizedBaseUrl}/chat/completions`;
const body =
wire === 'openai-responses'
wire === 'anthropic'
? JSON.stringify({
model: 'probe',
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
max_output_tokens: 1,
stream: false,
})
: JSON.stringify({
model: 'probe',
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 1,
stream: false,
});
})
: wire === 'openai-responses'
? JSON.stringify({
model: 'probe',
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
max_output_tokens: 1,
stream: false,
})
: JSON.stringify({
model: 'probe',
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 1,
stream: false,
});
let res: Response;
try {
res = await fetchWithTimeout(url, {
Expand All @@ -653,10 +676,34 @@ async function probeInferenceEndpoint(
// 401/403 — endpoint alive but auth rejected; surface as auth error so the
// diagnostics panel shows the key-invalid hint instead of the 404 one.
if (res.status === 401 || res.status === 403) return { kind: 'http', status: res.status };
if (wire === 'anthropic') {
const body = await responseJson(res);
return hasAnthropicApiErrorShape(body)
? { kind: 'pass' }
: { kind: 'http', status: res.status };
}
// 400/402/422/429 etc. — endpoint alive, request-level rejection.
return { kind: 'pass' };
}

async function responseJson(res: Response): Promise<unknown> {
try {
return await res.json();
} catch {
return null;
}
}

function isJsonRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function hasAnthropicApiErrorShape(value: unknown): boolean {
if (!isJsonRecord(value)) return false;
const error = value['error'];
return isJsonRecord(error) && typeof error['type'] === 'string';
}

export function registerConnectionIpc(): void {
ipcMain.handle('connection:v1:test', (_e, raw: unknown) => handleConnectionV1Test(raw));
ipcMain.handle('models:v1:list', (_e, raw: unknown) => handleModelsV1List(raw));
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { listSessionChatMessages, type SessionChatStoreOptions } from '../sessio
import { type Database, getDesign, recordDiagnosticEvent } from '../snapshots-db';
import { readWorkspaceFilesAt } from '../workspace-reader';
import { allocateAssetPath, createRuntimeTextEditorFs, resolveLocalAssetRefs } from './runtime-fs';
import { toolExecutionIsErrorForLog } from './tool-log';

/**
* Pull an HTTP status code out of a caught provider error. Mirrors
Expand Down Expand Up @@ -334,7 +335,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
logIpc.info('agent.tool_end', {
generationId: id,
tool: event.toolName,
isError: event.isError,
isError: toolExecutionIsErrorForLog(event),
});
} else if (event.type === 'turn_end') {
logIpc.info('agent.turn_end', {
Expand Down
56 changes: 56 additions & 0 deletions apps/desktop/src/main/ipc/tool-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { AgentEvent } from '@open-codesign/core';
import { describe, expect, it } from 'vitest';
import { toolExecutionIsErrorForLog } from './tool-log';

type ToolExecutionEndEvent = Extract<AgentEvent, { type: 'tool_execution_end' }>;

function toolEnd(overrides: Partial<ToolExecutionEndEvent>): ToolExecutionEndEvent {
return {
type: 'tool_execution_end',
toolCallId: 'tool-1',
toolName: 'set_todos',
isError: true,
result: {
content: [{ type: 'text', text: '[ ] Check work' }],
details: { items: [{ text: 'Check work', checked: false }] },
},
...overrides,
} as ToolExecutionEndEvent;
}

describe('toolExecutionIsErrorForLog', () => {
it('suppresses only the known successful set_todos false-positive shape', () => {
expect(toolExecutionIsErrorForLog(toolEnd({}))).toBe(false);
});

it('preserves genuine set_todos errors when the result includes an error signal', () => {
expect(
toolExecutionIsErrorForLog(
toolEnd({
result: {
content: [{ type: 'text', text: '[ ] Check work' }],
details: { items: [{ text: 'Check work', checked: false }] },
errorMessage: 'Failed to persist todos',
},
}),
),
).toBe(true);
});

it('preserves set_todos errors when the result shape is not the tool success payload', () => {
expect(
toolExecutionIsErrorForLog(
toolEnd({
result: {
content: [{ type: 'text', text: '[ ] Check work' }],
details: { items: [{ text: 'Check work', checked: 'no' }] },
},
}),
),
).toBe(true);
});

it('leaves non-set_todos tool errors untouched', () => {
expect(toolExecutionIsErrorForLog(toolEnd({ toolName: 'read' }))).toBe(true);
});
});
Loading
Loading