mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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>
162 lines
5.2 KiB
TypeScript
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();
|
|
});
|
|
});
|