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
90 changes: 90 additions & 0 deletions app/desktop/src/__tests__/edgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
fetchRunDiff,
fetchArtifacts,
fetchPreviews,
applyRunDiff,
applyAllRunDiffs,
} from '../api/edgeClient';
import { createDesktopPlatform } from '../platform/desktopPlatform';
import { mapEdgeAgentsToWorkbenchAgents } from '../platform/edgeCapabilityMapper';
Expand Down Expand Up @@ -709,4 +711,92 @@ describe('edgeClient', () => {
);
});
});

describe('diff apply write-back (#1817)', () => {
it('posts a single hunk decision to Edge /v1/runs/{runId}/apply with snake_case fields', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
code: 'OK',
data: {
runId: 'run_abc123',
filePath: 'src/app.ts',
hunkIndex: 2,
accepted: true,
applied: true,
},
}),
} as Response);

const result = await applyRunDiff('run_abc123', {
filePath: 'src/app.ts',
hunkIndex: 2,
accepted: true,
workDir: '/work/project',
});

expect(result.applied).toBe(true);
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/runs\/run_abc123\/apply$/),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
file_path: 'src/app.ts',
hunk_index: 2,
accepted: true,
workDir: '/work/project',
}),
}),
);
});

it('posts batch decisions to Edge /v1/runs/{runId}/apply-all', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
code: 'OK',
data: { runId: 'run_abc123', applied: 2 },
}),
} as Response);

const result = await applyAllRunDiffs('run_abc123', {
decisions: [
{ filePath: 'src/app.ts', hunkIndex: 0, accepted: true },
{ filePath: 'src/app.ts', hunkIndex: 1, accepted: false },
],
workDir: '/work/project',
});

expect(result.applied).toBe(2);
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/runs\/run_abc123\/apply-all$/),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
decisions: [
{ file_path: 'src/app.ts', hunk_index: 0, accepted: true },
{ file_path: 'src/app.ts', hunk_index: 1, accepted: false },
],
workDir: '/work/project',
}),
}),
);
});

it('surfaces Edge apply failures as errors for the UI toast path', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ error: { code: 'workspace_not_allowed', message: 'workdir not allowed' } }),
} as Response);

await expect(applyRunDiff('run_abc123', {
filePath: 'src/app.ts',
hunkIndex: 0,
accepted: true,
workDir: '/forbidden',
})).rejects.toThrow('workdir not allowed');
});
});
});
61 changes: 61 additions & 0 deletions app/desktop/src/api/edgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
AgentInfoSchema,
RunInfoSchema,
RunDiffSchema,
ApplyRunDiffResponseSchema,
ApplyAllRunDiffsResponseSchema,
ArtifactSchema,
PreviewSchema,
ThreadInfoSchema,
Expand Down Expand Up @@ -307,6 +309,65 @@ export async function fetchRunDiff(runId: string): Promise<RunDiff> {
return safeParse<RunDiff>(RunDiffSchema, unwrapEdgeResponse(await res.json()), 'runDiff');
}

export interface ApplyRunDiffRequest {
filePath: string;
hunkIndex: number;
accepted: boolean;
workDir: string;
}

export interface ApplyRunDiffResponse {
runId: string;
filePath: string;
hunkIndex: number;
accepted: boolean;
applied: boolean;
}

export interface ApplyAllRunDiffsRequest {
decisions: Array<Pick<ApplyRunDiffRequest, 'filePath' | 'hunkIndex' | 'accepted'>>;
workDir: string;
}

export interface ApplyAllRunDiffsResponse {
runId: string;
applied: number;
}

/** Write one hunk accept/reject decision back into the run workdir. */
export async function applyRunDiff(runId: string, request: ApplyRunDiffRequest): Promise<ApplyRunDiffResponse> {
const res = await edgeFetch(`${BASE}/v1/runs/${encodeURIComponent(runId)}/apply`, {
method: 'POST',
...edgeDevRequestInit({}, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
file_path: request.filePath,
hunk_index: request.hunkIndex,
accepted: request.accepted,
workDir: request.workDir,
}),
});
if (!res.ok) throw await parseError(res);
return safeParse<ApplyRunDiffResponse>(ApplyRunDiffResponseSchema, unwrapEdgeResponse(await res.json()), 'applyRunDiff');
}

/** Batch variant of applyRunDiff for accept-all / reject-all decisions. */
export async function applyAllRunDiffs(runId: string, request: ApplyAllRunDiffsRequest): Promise<ApplyAllRunDiffsResponse> {
const res = await edgeFetch(`${BASE}/v1/runs/${encodeURIComponent(runId)}/apply-all`, {
method: 'POST',
...edgeDevRequestInit({}, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
decisions: request.decisions.map((decision) => ({
file_path: decision.filePath,
hunk_index: decision.hunkIndex,
accepted: decision.accepted,
})),
workDir: request.workDir,
}),
});
if (!res.ok) throw await parseError(res);
return safeParse<ApplyAllRunDiffsResponse>(ApplyAllRunDiffsResponseSchema, unwrapEdgeResponse(await res.json()), 'applyAllRunDiffs');
Comment thread
DeliciousBuding marked this conversation as resolved.
}

export async function fetchArtifacts(): Promise<ListResponse<Artifact>> {
const res = await edgeFetch(`${BASE}/v1/artifacts`, edgeDevRequestInit());
if (!res.ok) throw await parseError(res);
Expand Down
15 changes: 15 additions & 0 deletions app/desktop/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,21 @@ export const RunDiffSchema = z.object({
files: z.array(RunDiffFileSchema),
});

// POST /v1/runs/{runId}/apply — single hunk decision write-back.
export const ApplyRunDiffResponseSchema = z.object({
runId: z.string(),
filePath: z.string(),
hunkIndex: z.number(),
accepted: z.boolean(),
applied: z.boolean(),
});

// POST /v1/runs/{runId}/apply-all — batch hunk decision write-back.
export const ApplyAllRunDiffsResponseSchema = z.object({
runId: z.string(),
applied: z.number(),
});

export const ArtifactSchema = z.object({
id: z.string(),
runId: z.string(),
Expand Down
50 changes: 43 additions & 7 deletions app/desktop/src/platform/desktopPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,28 @@ import type { EvidenceRef } from '@shared/transcript';
import type { TranscriptBlock } from '@shared/transcript';
import type { RunInfo, StartRunRequest } from '@shared/types';
import { createHubClient } from '@/api/hubClient';
import { applyAllRunDiffs, applyRunDiff } from '@/api/edgeClient';
import { edgeAuthHeaders } from '@/api/edgeAuth';
import { getAccessToken } from '@/hooks/useAuth';
import { getEdgeBaseUrl } from '@/config';
import { fetchRuntimeSessions } from '@shared/workbench';
import { fetchDesktopRuntimeSessions } from './desktopRuntimeSessions';
import { pickDesktopComposerAttachments } from './desktopAttachments';
import { canOpenDesktopEvidencePreview, openDesktopEvidencePreview } from './desktopPreview';
import {
canOpenDesktopEvidencePreview,
openDesktopEvidencePreview,
resolveDesktopEvidenceContentUrl,
resolveDesktopRuntimeEvidenceContent,
} from './desktopPreview';
import { resolveDesktopTargetPreference, type DesktopTargetPreference } from './targetPreference';
import { createDesktopSettingsAdapter } from './desktopSettingsAdapter';

export const DESKTOP_FALLBACK_CONVERSATION_ID = WORKBENCH_DEMO_FALLBACK_CONVERSATION_ID;
export const desktopConversations: WorkbenchConversation[] = workbenchDemoRuntimeStore.getSnapshot().conversations;
export const desktopConversations: WorkbenchConversation[] =
workbenchDemoRuntimeStore.getSnapshot().conversations;
export const desktopAgents: WorkbenchAgent[] = demoWorkbenchAgents;
export const desktopTranscript: TranscriptBlock[] = resolveDemoWorkbenchTranscript(DESKTOP_FALLBACK_CONVERSATION_ID);
export const desktopTranscript: TranscriptBlock[] = resolveDemoWorkbenchTranscript(
DESKTOP_FALLBACK_CONVERSATION_ID,
);

export function resolveDesktopPreviewTranscript(conversationId: string): TranscriptBlock[] {
return workbenchDemoRuntimeStore.resolveTranscript(conversationId);
Expand Down Expand Up @@ -176,6 +185,28 @@ export function createDesktopPlatform(options: DesktopPlatformOptions = {}): Des
preview: {
canOpenEvidence: canOpenDesktopEvidencePreview,
openEvidence: options.openPreview ?? openDesktopEvidencePreview,
// Interactive diff write-back goes through the Local Edge apply endpoints
// (#1817). Desktop owns the Edge connection, so it owns this port leg.
async applyRunDiff(input) {
await applyRunDiff(input.runId, {
filePath: input.decision.filePath,
hunkIndex: input.decision.hunkIndex,
accepted: input.decision.accepted,
workDir: input.workDir,
});
},
async applyAllRunDiffs(input) {
await applyAllRunDiffs(input.runId, {
decisions: input.decisions.map((decision) => ({
filePath: decision.filePath,
hunkIndex: decision.hunkIndex,
accepted: decision.accepted,
})),
workDir: input.workDir,
});
},
resolveContentUrl: resolveDesktopEvidenceContentUrl,
resolveRuntimeEvidenceContent: resolveDesktopRuntimeEvidenceContent,
},
settings: createDesktopSettingsAdapter(),
// exactOptionalPropertyTypes: omit key when undefined rather than assign undefined.
Expand All @@ -190,7 +221,11 @@ export function createDesktopPlatform(options: DesktopPlatformOptions = {}): Des
const run = await options.submitRun({
projectId: options.activeProjectId,
threadId: options.activeThreadId,
prompt: formatComposerPromptWithContext(intent.text, intent.attachments, intent.mentions),
prompt: formatComposerPromptWithContext(
intent.text,
intent.attachments,
intent.mentions,
),
...edgeSelectedAgent(intent),
...edgePermissionMode(intent),
...edgeWorkDir(intent),
Expand Down Expand Up @@ -224,7 +259,7 @@ export function readLocalCliDiscovery(): Promise<LocalCliDiscoveryManifest> {

/** Desktop host: Edge GET /v1/runtime-sessions via typed fetch (no foreign store). */
export async function readRuntimeSessions(limit = 50): Promise<RuntimeSessionSummary[]> {
return fetchRuntimeSessions({
return fetchDesktopRuntimeSessions({
edgeBaseUrl: getEdgeBaseUrl(),
limit,
fetchImpl: async (input, init) => {
Expand All @@ -238,7 +273,8 @@ export async function readRuntimeSessions(limit = 50): Promise<RuntimeSessionSum
}

function edgeSelectedAgent(intent: ComposerIntent): Pick<StartRunRequest, 'agentId' | 'model'> {
const mention = intent.mentions.find((item) => item.status !== 'unavailable') ?? intent.mentions[0];
const mention =
intent.mentions.find((item) => item.status !== 'unavailable') ?? intent.mentions[0];
if (!mention) return {};
return {
agentId: mention.runtimeId?.trim() || mention.id,
Expand Down
58 changes: 58 additions & 0 deletions app/desktop/src/platform/desktopPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Desktop PreviewPort content-URL resolution (#1817): absolute evidence URLs
// pass through; host-relative API paths resolve against the Local Edge base
// URL because Desktop owns the Edge connection.
import { describe, expect, it } from 'vitest';
import {
resolveDesktopEvidenceContentUrl,
resolveDesktopRuntimeEvidenceContent,
} from './desktopPreview';

describe('resolveDesktopEvidenceContentUrl', () => {
it('returns absolute evidence URLs unchanged', () => {
expect(resolveDesktopEvidenceContentUrl('http://127.0.0.1:4173/preview')).toBe(
'http://127.0.0.1:4173/preview',
);
expect(resolveDesktopEvidenceContentUrl('https://preview.example.com/app')).toBe(
'https://preview.example.com/app',
);
});

it('resolves Edge-relative content paths against the Local Edge base URL', () => {
// Default test-env Edge base URL (no override configured).
expect(resolveDesktopEvidenceContentUrl('/v1/runs/run-1/artifacts/artifact-1/content')).toBe(
'http://127.0.0.1:3210/v1/runs/run-1/artifacts/artifact-1/content',
);
expect(resolveDesktopEvidenceContentUrl('/v1/runs/run-1/previews/preview-1/content')).toBe(
'http://127.0.0.1:3210/v1/runs/run-1/previews/preview-1/content',
);
});

it('yields undefined for empty or non-URL references', () => {
expect(resolveDesktopEvidenceContentUrl('')).toBeUndefined();
expect(resolveDesktopEvidenceContentUrl(' ')).toBeUndefined();
expect(resolveDesktopEvidenceContentUrl('# reports/runtime.patch')).toBeUndefined();
expect(resolveDesktopEvidenceContentUrl('data:text/plain;base64,abc')).toBeUndefined();
});
});

describe('resolveDesktopRuntimeEvidenceContent', () => {
it('maps artifact refs onto the Edge artifact content endpoint', () => {
expect(
resolveDesktopRuntimeEvidenceContent({
kind: 'artifact',
runId: 'run-1',
id: 'artifact-1',
}),
).toBe('http://127.0.0.1:3210/v1/runs/run-1/artifacts/artifact-1/content');
});

it('maps preview refs onto the Edge preview content endpoint', () => {
expect(
resolveDesktopRuntimeEvidenceContent({
kind: 'preview',
runId: 'run-2',
id: 'preview-2',
}),
).toBe('http://127.0.0.1:3210/v1/runs/run-2/previews/preview-2/content');
});
});
36 changes: 36 additions & 0 deletions app/desktop/src/platform/desktopPreview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { open } from '@tauri-apps/plugin-shell';
import { resolveEvidencePreviewTarget } from '@shared/platform';
import type { RuntimeEvidenceContentRef } from '@shared/platform';
import type { EvidenceRef } from '@shared/transcript';
import { getEdgeBaseUrl } from '@/config';

export function canOpenDesktopEvidencePreview(evidence: EvidenceRef): boolean {
return Boolean(resolveEvidencePreviewTarget(evidence));
Expand All @@ -14,3 +16,37 @@ export async function openDesktopEvidencePreview(evidence: EvidenceRef): Promise

await open(target);
}

/**
* PreviewPort.resolveContentUrl for Desktop (#1817).
* Absolute http(s) URLs (e.g. a runtime preview server) are displayed as-is;
* host-relative API paths (`/v1/runs/…/content`) are owned by the Local Edge,
* so they are resolved against the Edge base URL. Anything else has no
* displayable source and yields undefined (UI shows an honest notice).
*/
export function resolveDesktopEvidenceContentUrl(contentRef: string): string | undefined {
const trimmed = contentRef.trim();
if (!trimmed) return undefined;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith('/')) {
const edgeBase = getEdgeBaseUrl().replace(/\/+$/, '');
if (!edgeBase) return undefined;
return `${edgeBase}${trimmed}`;
}
return undefined;
}

/**
* PreviewPort.resolveRuntimeEvidenceContent for Desktop (#1817).
* Desktop owns the Local Edge connection, so it maps the shared structured
* ref onto the Edge run content endpoint. This is the only place where the
* Edge content path shape lives — shared code only carries the neutral ref.
*/
export function resolveDesktopRuntimeEvidenceContent(
ref: RuntimeEvidenceContentRef,
): string | undefined {
const edgeBase = getEdgeBaseUrl().replace(/\/+$/, '');
if (!edgeBase) return undefined;
const collection = ref.kind === 'artifact' ? 'artifacts' : 'previews';
return `${edgeBase}/v1/runs/${ref.runId}/${collection}/${ref.id}/content`;
}
Loading
Loading