Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
11 changes: 10 additions & 1 deletion apps/desktop/scripts/after-pack-prune.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ function archName(arch) {
}

function rm(target) {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3 });
if (!fs.existsSync(target)) return;
const stat = fs.lstatSync(target);
if (!stat.isDirectory()) {
fs.unlinkSync(target);
return;
}
for (const entry of fs.readdirSync(target)) {
rm(path.join(target, entry));
}
fs.rmdirSync(target);
}

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
38 changes: 35 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,53 @@ 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: '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();
}
Expand Down
55 changes: 39 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 Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
logIpc.info('agent.tool_end', {
generationId: id,
tool: event.toolName,
isError: event.isError,
isError: event.toolName === 'set_todos' ? false : event.isError,
});
} else if (event.type === 'turn_end') {
logIpc.info('agent.turn_end', {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/snapshots-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
recordDiagnosticEvent,
updateDesignWorkspace,
} from './snapshots-db';
import { normalizeWorkspacePath } from './workspace-path';

describe('json design store', () => {
it('persists designs and snapshots without a native database binding', async () => {
Expand All @@ -33,7 +34,7 @@ describe('json design store', () => {
});

const reopened = initSnapshotsDb(storePath);
expect(getDesign(reopened, design.id)?.workspacePath).toBe(root);
expect(getDesign(reopened, design.id)?.workspacePath).toBe(normalizeWorkspacePath(root));
expect(listDesigns(reopened).map((row) => row.id)).toEqual([design.id]);
expect(listSnapshots(reopened, design.id).map((row) => row.id)).toEqual([snapshot.id]);
await expect(readFile(storePath, 'utf8')).resolves.toContain('Workspace-first design');
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/snapshots-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow |
if (design === null) {
throw new CodesignError('Design not found', 'IPC_NOT_FOUND');
}
if (design.workspacePath === null) return [];
const workspacePath = requireBoundWorkspacePath(design, 'Design is not bound to a workspace');
try {
return await listWorkspaceFilesAt(workspacePath);
Expand Down Expand Up @@ -793,6 +794,16 @@ export function registerWorkspaceIpc(db: Database, getWin: () => BrowserWindow |
if (design === null) {
throw new CodesignError('Design not found', 'IPC_NOT_FOUND');
}
if (design.workspacePath === null) {
const requestedPath = r['path'] as string;
return {
path: requestedPath,
kind: classifyWorkspaceFileKind(requestedPath),
size: 0,
updatedAt: new Date(0).toISOString(),
content: '',
};
}
const workspacePath = requireBoundWorkspacePath(design, 'Design is not bound to a workspace');
try {
return await readWorkspaceFileAt(workspacePath, r['path'] as string);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/workspace-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('readWorkspaceFilesAt', () => {
);
const result = await readWorkspaceFilesAt(root);
expect(result.length).toBe(200);
});
}, 20_000);

it('caps total bytes at 2 MB', async () => {
// 20 × 150KB = 3 MB total. We expect the reader to stop before pulling
Expand Down
26 changes: 20 additions & 6 deletions apps/desktop/src/main/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';

const handlers = new Map<string, (e: unknown, raw: unknown) => unknown>();
Expand Down Expand Up @@ -80,6 +82,10 @@ function captureError(fn: () => unknown): unknown {
}
}

function tempWorkspace(name: string): string {
return path.join(tmpdir(), name).replaceAll('\\', '/');
}

describe('files-watcher subscribe / unsubscribe', () => {
it('rejects when no design row found', () => {
reset();
Expand Down Expand Up @@ -122,7 +128,10 @@ describe('files-watcher subscribe / unsubscribe', () => {
watchMock.mockImplementation(() => {
throw new Error('watch denied');
});
getDesignMock.mockReturnValue({ id: 'd1', workspacePath: '/tmp/ws' });
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-denied'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');

Expand All @@ -137,7 +146,10 @@ describe('files-watcher subscribe / unsubscribe', () => {
vi.useFakeTimers();
const closeSpy = vi.fn();
watchMock.mockImplementation(() => ({ on: vi.fn(), close: closeSpy }) as never);
getDesignMock.mockReturnValue({ id: 'd1', workspacePath: '/tmp/ws' });
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-refcount'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');
const unsub = getHandler('codesign:files:v1:unsubscribe');
Expand Down Expand Up @@ -165,9 +177,11 @@ describe('files-watcher subscribe / unsubscribe', () => {
watchMock
.mockImplementationOnce(() => ({ on: vi.fn(), close: closeFirst }) as never)
.mockImplementationOnce(() => ({ on: vi.fn(), close: closeSecond }) as never);
const firstWorkspace = tempWorkspace('codesign-watch-one');
const secondWorkspace = tempWorkspace('codesign-watch-two');
getDesignMock
.mockReturnValueOnce({ id: 'd1', workspacePath: '/tmp/ws-one/' })
.mockReturnValueOnce({ id: 'd1', workspacePath: '/tmp/ws-two' });
.mockReturnValueOnce({ id: 'd1', workspacePath: `${firstWorkspace}/` })
.mockReturnValueOnce({ id: 'd1', workspacePath: secondWorkspace });
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');

Expand All @@ -178,13 +192,13 @@ describe('files-watcher subscribe / unsubscribe', () => {
expect(closeSecond).not.toHaveBeenCalled();
expect(watchMock).toHaveBeenNthCalledWith(
1,
'/tmp/ws-one',
firstWorkspace,
{ recursive: true },
expect.any(Function),
);
expect(watchMock).toHaveBeenNthCalledWith(
2,
'/tmp/ws-two',
secondWorkspace,
{ recursive: true },
expect.any(Function),
);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function PreviewSlot({
);
} else {
body = (
<div className="h-full w-full relative">
<div className="h-full w-full min-w-0 relative overflow-auto">
{showCommentUi && active ? (
<div className={COMMENT_HINT_CLASS}>{commentHintLabel}</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export function StorageTab() {
<p className="text-[var(--text-xs)] text-[var(--color-text-muted)] leading-[var(--leading-body)]">
{t('settings.storage.restartHint')}
</p>
<p className="text-[var(--text-xs)] text-[var(--color-text-muted)] leading-[var(--leading-body)]">
{t('settings.storage.workspaceHint')}
</p>

{paths === null ? (
<div className="flex items-center gap-2 py-4 text-[var(--text-sm)] text-[var(--color-text-muted)]">
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/renderer/src/preview/workspace-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,21 @@ describe('workspace preview source resolution', () => {
}),
).rejects.toThrow(/Cannot resolve referenced preview source/);
});

it('falls back to original source when referenced workspace read returns empty content', async () => {
const source = '<!doctype html><body><!-- artifact source lives in index.jsx --></body>';
const read = vi.fn<WorkspacePreviewRead>(async (_designId, path) => ({
path,
content: '',
}));

await expect(
resolveWorkspacePreviewSource({
designId: 'd1',
source,
read,
requireReferencedSource: true,
}),
).resolves.toEqual({ path: 'index.html', content: source });
});
});
9 changes: 8 additions & 1 deletion apps/desktop/src/renderer/src/preview/workspace-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export async function resolveWorkspacePreviewSource(input: {
}
return { content: input.source, path };
}
const referenced = await input.read(input.designId, referencedPath);
let referenced: WorkspacePreviewReadResult;
try {
referenced = await input.read(input.designId, referencedPath);
} catch (err) {
if (input.requireReferencedSource) throw err;
return { content: input.source, path };
}
if (referenced.content.trim().length === 0) return { content: input.source, path };
return { content: referenced.content, path: referenced.path };
}
Loading
Loading