open-design/apps/daemon/tests/plugins-diff-review-genui-bridge.test.ts
Cursor Agent a1bfac34f6
feat(plugins): wire diff-review GenUI response \u2192 review/decision.json (Phase 8)
Plan R1 / spec §10.3 / §21.5.

When the user (or agent) responds to the auto-derived
`__auto_diff_review_<stageId>` choice surface, the daemon now
immediately persists the decision into the run's project cwd as
`<cwd>/review/decision.json` so the next pipeline stage (handoff,
typically) sees the user's choice without an extra agent turn.

apps/daemon/src/plugins/atoms/diff-review-genui-bridge.ts

  - isDiffReviewSurfaceId(id)            — owns the
    '__auto_diff_review_' prefix; future renames flow from one
    constant.
  - parseDiffReviewGenuiResponse(value)  — strict JSON validator
    coercing the surface payload into runDiffReview's
    { decision, accepted_files?, rejected_files?, reason? }
    shape. Rejects non-object payloads + unknown decisions.
  - applyDiffReviewDecisionToCwd({ cwd, value, reviewer })
    — end-to-end glue: parses, calls runDiffReview(), returns
    structured ok/error so the caller can decide what to surface.

apps/daemon/src/server.ts POST /api/runs/:runId/genui/:surfaceId/respond
now becomes async and, when isDiffReviewSurfaceId() matches, looks
up the run's projectId via design.runs.get(), resolves the project
cwd via resolveProjectDir(), and calls
applyDiffReviewDecisionToCwd() with reviewer='user' (or 'agent' /
'auto' when respondedBy says so).

Best-effort wiring:
  - Failures are caught and surfaced on the response payload as
    `diffReviewBridge: { ok: false, error }` so the agent can
    retry without the GenUI respond contract regressing.
  - No-project runs return diffReviewBridge: { ok: false, error:
    'run is not linked to a project' }.

Daemon tests: 1618 → 1629 (+11 cases on
plugins-diff-review-genui-bridge: prefix detection, payload
parsing happy + sad paths (non-object, malformed decision,
non-string file lists, optional reason forwarding), end-to-end
applyDiffReviewDecisionToCwd writes review/decision.json on
accept, agent reviewer + reason forwarding, partial-missing-file
rejection, malformed value does NOT touch disk).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:48:21 +00:00

162 lines
5.2 KiB
TypeScript

// Phase 8 entry slice — diff-review GenUI bridge.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
applyDiffReviewDecisionToCwd,
isDiffReviewSurfaceId,
parseDiffReviewGenuiResponse,
} from '../src/plugins/atoms/diff-review-genui-bridge.js';
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-diff-review-bridge-'));
await mkdir(path.join(cwd, 'plan', 'receipts'), { recursive: true });
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
async function seedReceipts() {
await writeFile(
path.join(cwd, 'plan', 'steps.json'),
JSON.stringify([
{ id: 'rewrite-button', files: [], rationale: '', risk: 'low', status: 'completed' },
], null, 2),
);
await writeFile(
path.join(cwd, 'plan', 'receipts', 'step-rewrite-button.json'),
JSON.stringify({
step: 'rewrite-button',
files: ['Button.tsx', 'Button.css'],
added: 1,
removed: 1,
rationale: '',
completedAt: new Date().toISOString(),
}, null, 2),
);
}
describe('isDiffReviewSurfaceId', () => {
it('matches the auto-derived prefix', () => {
expect(isDiffReviewSurfaceId('__auto_diff_review_review')).toBe(true);
expect(isDiffReviewSurfaceId('__auto_diff_review_verify')).toBe(true);
});
it('rejects unrelated ids', () => {
expect(isDiffReviewSurfaceId('__auto_connector_slack')).toBe(false);
expect(isDiffReviewSurfaceId('plugin-declared-surface')).toBe(false);
expect(isDiffReviewSurfaceId('')).toBe(false);
});
});
describe('parseDiffReviewGenuiResponse', () => {
it('accepts a well-shaped accept payload', () => {
const out = parseDiffReviewGenuiResponse({
decision: 'accept',
accepted_files: ['x.ts'],
rejected_files: [],
});
expect('error' in out).toBe(false);
if (!('error' in out)) {
expect(out.decision).toBe('accept');
expect(out.accepted_files).toEqual(['x.ts']);
}
});
it("forwards the optional 'reason' field when non-empty", () => {
const out = parseDiffReviewGenuiResponse({
decision: 'reject',
reason: 'looks wrong',
});
if ('error' in out) throw new Error(out.error);
expect(out.reason).toBe('looks wrong');
});
it('strips non-string entries from the file lists', () => {
const out = parseDiffReviewGenuiResponse({
decision: 'partial',
accepted_files: ['x.ts', 42, null],
rejected_files: ['y.ts'],
});
if ('error' in out) throw new Error(out.error);
expect(out.accepted_files).toEqual(['x.ts']);
});
it('rejects non-object payloads', () => {
const a = parseDiffReviewGenuiResponse(null);
expect('error' in a).toBe(true);
const b = parseDiffReviewGenuiResponse('accept');
expect('error' in b).toBe(true);
});
it('rejects unknown decision values', () => {
const out = parseDiffReviewGenuiResponse({ decision: 'maybe' });
expect('error' in out).toBe(true);
});
});
describe('applyDiffReviewDecisionToCwd', () => {
it('writes review/decision.json end-to-end on accept', async () => {
await seedReceipts();
const result = await applyDiffReviewDecisionToCwd({
cwd,
reviewer: 'user',
value: { decision: 'accept' },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.report.decision?.decision).toBe('accept');
expect(result.report.decision?.reviewer).toBe('user');
expect(result.report.decision?.accepted_files).toEqual(['Button.css', 'Button.tsx']);
}
const decision = JSON.parse(await readFile(path.join(cwd, 'review', 'decision.json'), 'utf8'));
expect(decision.decision).toBe('accept');
expect(decision.accepted_files).toEqual(['Button.css', 'Button.tsx']);
});
it('forwards reason + reviewer=agent when respondedBy=agent', async () => {
await seedReceipts();
const result = await applyDiffReviewDecisionToCwd({
cwd,
reviewer: 'agent',
value: { decision: 'reject', reason: 'auto-revert' },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.report.decision?.decision).toBe('reject');
expect(result.report.decision?.reviewer).toBe('agent');
expect(result.report.decision?.reason).toBe('auto-revert');
}
});
it('returns ok=false on a partial decision missing some files', async () => {
await seedReceipts();
const result = await applyDiffReviewDecisionToCwd({
cwd,
reviewer: 'user',
value: {
decision: 'partial',
accepted_files: ['Button.tsx'],
rejected_files: [],
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/missing Button\.css/);
});
it('returns ok=false on malformed value WITHOUT touching disk', async () => {
await seedReceipts();
const result = await applyDiffReviewDecisionToCwd({
cwd,
reviewer: 'user',
value: { decision: 'maybe' },
});
expect(result.ok).toBe(false);
await expect(readFile(path.join(cwd, 'review', 'decision.json'), 'utf8'))
.rejects.toThrow();
});
});