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
70 changes: 70 additions & 0 deletions apps/agent/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,73 @@ describe("accepting a build result (/project/save-example, fail-closed)", () =>
expect(JSON.stringify(surfaceOfRun(next.events).surface)).toBe(JSON.stringify(refined));
});
});


describe("safe worked-example persistence (#42) and honest scripted absence (#43)", () => {
const contractOf = () => JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
const surfaceOf = () => structuredClone(contractOf().examples[0].surface);

it("mints a collision-free id from the contract on disk when the client supplies none", async () => {
const before = contractOf().examples.map((e: any) => e.id);
const a = await call("save-example", { path: root, example: { intent: "status-report", prompt: "first ask", surface: surfaceOf() } });
expect(a.status).toBe(200);
expect(a.payload.example.id).toMatch(/^ex\.chat-\d+$/);
expect(before).not.toContain(a.payload.example.id);

const b = await call("save-example", { path: root, example: { intent: "status-report", prompt: "second ask", surface: surfaceOf() } });
expect(b.status).toBe(200);
expect(b.payload.example.id).not.toBe(a.payload.example.id); // distinct across accepts

// Both survive; every pre-existing example is byte-identical.
const doc = contractOf();
expect(doc.examples.map((e: any) => e.id)).toEqual(expect.arrayContaining([a.payload.example.id, b.payload.example.id, ...before]));
for (const id of before) {
expect(JSON.stringify(doc.examples.find((e: any) => e.id === id))).toBe(
JSON.stringify(JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")).examples.find((e: any) => e.id === id)),
);
}
});

it("REFUSES an explicit id that already exists rather than overwriting it", async () => {
const existing = contractOf().examples[0];
const before = JSON.stringify(existing);
const { status, payload } = await call("save-example", {
path: root,
example: { id: existing.id, intent: "status-report", prompt: "hostile overwrite", surface: surfaceOf() },
});
expect(status).toBe(409);
expect(payload.findings[0].code).toBe("example-exists");
expect(payload.findings[0].message).toContain(existing.id);
// Untouched, byte for byte.
expect(JSON.stringify(contractOf().examples.find((e: any) => e.id === existing.id))).toBe(before);
});

it("the newly accepted example is consumable as few-shot and scripted plays the latest without replacing older ones", async () => {
const refined = surfaceOf();
refined.root.children[0].children[0].text = "Rollout status";
const saved = await call("save-example", {
path: root,
example: { intent: "status-report", prompt: "a status screen — refined: say Rollout status", surface: refined },
});
expect(saved.status).toBe(200);
const doc = contractOf();
const { compileContext } = await import("@aestheticfunction/dspack-gen/core");
const context = compileContext(doc, "status-report");
expect(context.fewshot.some((m: any) => m.role === "assistant" && m.content.includes("Rollout status"))).toBe(true);
// Older examples still present and still served.
expect(doc.examples.length).toBeGreaterThan(1);
expect(context.fewshot.length).toBeGreaterThanOrEqual(doc.examples.filter((e: any) => e.intent === "status-report").length);
});

it("an intent with no matching example never borrows another intent's: scripted refuses honestly", async () => {
const doc = contractOf();
doc.intents = [...doc.intents, { id: "onboarding", description: "Welcome a new operator." }];
const { writeFileSync } = await import("node:fs");
writeFileSync(join(root, "acme-ui.dspack.json"), JSON.stringify(doc, null, 2) + "\n");

const { status, payload } = await call("run", { path: root, prompt: "an onboarding screen", intent: "onboarding", modelRef: "scripted" });
expect(status).toBe(400);
expect(String(payload.error)).toMatch(/onboarding/);
expect(String(payload.error)).toMatch(/worked example/i);
});
});
75 changes: 60 additions & 15 deletions apps/agent/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,22 @@ function scriptedRunAdapter(example: { surface: unknown }, conversation: Convers
try {
const refined = JSON.parse(priorRaw) as Record<string, unknown>;
const textNode = firstTextNode(refined);
if (textNode && !textNode.text.endsWith(" (refined)")) textNode.text = `${textNode.text} (refined)`;
else if (!textNode) (refined as { id?: string }).id = "refined";
return new ScriptedAdapter([{ output: refined }]);
if (textNode) {
// MONOTONIC, never idempotent: successive refinements must each
// produce a genuinely different surface, or the twin would report
// a byte-identical no-op as a successful refinement (#43).
const existing = /^(.*?)(?: \(refined(?: (\d+))?\))$/.exec(textNode.text);
const base = existing ? existing[1] : textNode.text;
const next = existing ? Number(existing[2] ?? 1) + 1 : 1;
textNode.text = next === 1 ? `${base} (refined)` : `${base} (refined ${next})`;
} else {
const previous = /^refined(?: (\d+))?$/.exec(String((refined as { id?: string }).id ?? ""));
const next = previous ? Number(previous[1] ?? 1) + 1 : 1;
(refined as { id?: string }).id = next === 1 ? "refined" : `refined ${next}`;
}
// Three entries so a refinement can survive bounded repair too — a
// refinement run must never die with a script-exhaustion error.
return new ScriptedAdapter([{ output: refined }, { output: refined }, { output: refined }]);
} catch {
// Fall through: an unparseable prior surface behaves like a fresh run.
}
Expand Down Expand Up @@ -512,12 +525,18 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, re
const conversation = parseConversation(props.conversation);

const examples = (contract.examples as Array<{ intent: string; surface: unknown }> | undefined) ?? [];
// LAST match: accepted chat results join the corpus at the end, and the
// deterministic twin plays the owner's latest accepted example.
const matching = examples.filter((e) => e.intent === intent);
const example = matching.at(-1) ?? examples.at(-1);
// LAST match FOR THIS INTENT: accepted chat results join the corpus at the
// end, and the deterministic twin plays the owner's latest accepted
// example. Never borrow another intent's example — a screen built for a
// different intent is not a deterministic stand-in, it is a wrong answer
// reported as a right one (#43).
const example = examples.filter((e) => e.intent === intent).at(-1);
if (modelRef === "scripted" && !example) {
throw new ProjectError(400, "scripted mode needs at least one worked example in the contract");
throw new ProjectError(
400,
`scripted mode replays this intent's own worked example, and '${intent}' has none yet. ` +
`Author one in Scenarios, or run with a model — generation works from the scoped contract without few-shot context.`,
);
}
const adapter =
modelRef === "scripted"
Expand Down Expand Up @@ -553,6 +572,16 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, re
}


/** The next free `ex.chat-N` for this contract (monotonic, gap-tolerant). */
function nextExampleId(existing: string[]): string {
let n = 0;
for (const id of existing) {
const match = /^ex\.chat-(\d+)$/.exec(id);
if (match) n = Math.max(n, Number(match[1]));
}
return `ex.chat-${n + 1}`;
}

/**
* Accept a build result as a governed worked example — the ONLY save format
* for chat-accepted surfaces, and fail-closed SERVER-SIDE: a disabled
Expand All @@ -565,15 +594,33 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, re
async function saveExample(ctx: ProjectContext, body: Record<string, unknown>) {
const raw = body.example as Record<string, unknown> | undefined;
if (!raw || typeof raw !== "object") throw new ProjectError(400, "example is required");
const id = String(raw.id ?? "");
if (!/^ex\.[a-z0-9][a-z0-9-]*$/.test(id)) {
throw new ProjectError(400, "example.id must be kebab-case with the 'ex.' prefix");
}
if (!raw.surface || typeof raw.surface !== "object") throw new ProjectError(400, "example.surface must be a surface document");
const prompt = String(raw.prompt ?? "");
if (!prompt) throw new ProjectError(400, "example.prompt is required (the ask that produced this surface)");

const contract = readJson(ctx.contractPath) as Record<string, unknown>;
const existing = ((contract.examples as Array<{ id?: unknown }> | undefined) ?? []).map((e) => String(e?.id ?? ""));

// Identity is derived from the contract ON DISK, never from a page-local
// counter: a browser that reloaded (or a second tab) cannot mint an id
// that collides with work already saved. An EXPLICIT id that already
// exists is refused outright — accepting a build result never overwrites
// an existing worked example, least of all an owner-authored one (#42).
const requested = raw.id === undefined ? "" : String(raw.id);
if (requested && !/^ex\.[a-z0-9][a-z0-9-]*$/.test(requested)) {
throw new ProjectError(400, "example.id must be kebab-case with the 'ex.' prefix");
}
if (requested && existing.includes(requested)) {
return {
status: 409,
payload: {
ok: false,
findings: [finding("document", "example-exists", "error", "example.id", `'${requested}' already exists in this contract; accepting would overwrite it. Choose another id, or leave it blank to mint the next free one.`)],
},
};
}
const id = requested || nextExampleId(existing);

const intents = ((contract.intents as Array<{ id: string }> | undefined) ?? []).map((i) => i.id);
const intent = String(raw.intent ?? "");
if (!intents.includes(intent)) {
Expand Down Expand Up @@ -606,9 +653,7 @@ async function saveExample(ctx: ProjectContext, body: Record<string, unknown>) {
};
const document = structuredClone(contract);
const examples = ((document.examples as unknown[] | undefined) ?? []) as Array<{ id: string }>;
const at = examples.findIndex((e) => e.id === id);
if (at >= 0) examples[at] = entry as never;
else examples.push(entry as never);
examples.push(entry as never); // append-only: the id was proven free above
document.examples = examples;

// The same guarded write as /project/save: ledger preserved, harness clean.
Expand Down
22 changes: 19 additions & 3 deletions apps/composer/app/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ export interface ConnectPayload {
extraSurfaces: Array<{ name: string; surface: unknown }>;
}

export type AgentResult<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* A refusal keeps its STRUCTURED evidence: routes that answer 4xx with a
* findings array (the fail-closed accept gate) must not be flattened to
* "agent replied 422" — the gate reasons are the whole point (#41).
*/
export type AgentResult<T> =
| { ok: true; value: T }
| { ok: false; error: string; findings?: ComposerFinding[]; status?: number };

const DEFAULT_AGENT = process.env.NEXT_PUBLIC_AGENT_URL ?? "http://localhost:8787";

Expand All @@ -45,7 +52,15 @@ async function post<T>(route: string, body: unknown): Promise<AgentResult<T>> {
signal: AbortSignal.timeout(180_000),
});
const payload = await res.json();
if (!res.ok) return { ok: false, error: String(payload.error ?? `agent replied ${res.status}`) };
if (!res.ok) {
const findings = Array.isArray(payload?.findings) ? (payload.findings as ComposerFinding[]) : undefined;
return {
ok: false,
status: res.status,
...(findings ? { findings } : {}),
error: String(payload?.error ?? (findings?.length ? findings.map((f) => f.message).join("; ") : `agent replied ${res.status}`)),
};
}
return { ok: true, value: payload as T };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
Expand Down Expand Up @@ -148,7 +163,8 @@ export function streamProjectRun(
}

export interface AcceptedExample {
id: string;
/** Omit to let the agent mint a collision-free id from the contract (#42). */
id?: string;
intent: string;
name?: string;
prompt: string;
Expand Down
47 changes: 36 additions & 11 deletions apps/composer/app/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
addTombstone,
applyFreshFact,
buildReadiness,
canRefineTurn,
examplePromptFor,
foldBuildEvents,
ledgerStatus,
removeTombstone,
Expand Down Expand Up @@ -52,10 +54,14 @@ export interface BuildTurn {
modelRef: string;
/** True when this turn refined the previous surface (seed supplied). */
refinement: boolean;
/** The turn this one refined, for truthful example provenance (#42). */
parentId?: number;
progress: BuildTurnProgress;
/** Component ids the ask needed but the owner has not approved (S2 evidence). */
gaps: string[];
accepted?: string; // the saved example id
/** Structured findings from a refused Accept, rendered in place (#41). */
acceptFindings?: ComposerFinding[];
}

export interface ComposerState {
Expand Down Expand Up @@ -96,7 +102,8 @@ export interface ComposerState {
/** Setup completeness for building; reason names the exact remaining work. */
readiness: BuildReadiness;
runBuild: (input: { prompt: string; intent: string; modelRef: string; refine?: boolean }) => Promise<void>;
acceptBuildTurn: (turnId: number, exampleId: string) => Promise<void>;
/** Accept a turn as a worked example; the agent mints the id (#42). */
acceptBuildTurn: (turnId: number, exampleId?: string) => Promise<void>;
clearBuildThread: () => void;
}

Expand Down Expand Up @@ -514,9 +521,12 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
return;
}
if (buildBusy) return;
const prior = input.refine ? [...buildTurns].reverse().find((t) => t.progress.surface) : undefined;
// Only a completed, passing turn can seed a refinement (#43): a failed
// turn still carries its last attempt's surface, and seeding that
// regenerates from something the contract already rejected.
const prior = input.refine ? [...buildTurns].reverse().find((t) => canRefineTurn(t.progress)) : undefined;
if (input.refine && !prior) {
setNotice("Nothing to refine yet — run a build first.");
setNotice("Nothing to refine yet — refinement starts from a completed build that passed its gates.");
return;
}
setBuildBusy(true);
Expand All @@ -527,6 +537,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
intent: input.intent,
modelRef: input.modelRef,
refinement: !!prior,
...(prior ? { parentId: prior.id } : {}),
progress: { status: "streaming", attempts: [] },
gaps: [],
};
Expand Down Expand Up @@ -577,7 +588,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
* format — and immediately joins that intent's few-shot corpus.
*/
const acceptBuildTurn = useCallback(
async (turnId: number, exampleId: string) => {
async (turnId: number, exampleId?: string) => {
if (!contract || decisionLock.current) return;
const turn = buildTurns.find((t) => t.id === turnId);
if (!turn?.progress.surface || turn.progress.outcome !== "passed") {
Expand All @@ -587,19 +598,32 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
decisionLock.current = true;
setBusy("accepting build result");
try {
// Truthful provenance: walk back to the ORIGINAL ask and record the
// refinements that shaped this surface (#42).
const chain: string[] = [];
for (let t: BuildTurn | undefined = turn; t; t = t.parentId ? buildTurns.find((x) => x.id === t!.parentId) : undefined) {
chain.unshift(t.prompt);
}
const prompt = examplePromptFor(chain);
const result = await agentSaveExample(projectPath, {
id: exampleId,
...(exampleId ? { id: exampleId } : {}), // omitted ⇒ the agent mints a collision-free id
intent: turn.intent,
name: `Chat: ${turn.prompt.slice(0, 60)}`,
prompt: turn.prompt,
name: `Chat: ${chain[0].slice(0, 60)}`,
prompt,
surface: turn.progress.surface,
});
if (!result.ok) {
setNotice(`Accept failed: ${result.error}`);
// Structured gate reasons, never a bare HTTP status (#41).
const detail = result.findings?.length
? result.findings.map((f) => `${f.gate} ${f.code}: ${f.message}`).join(" · ")
: result.error;
setNotice(`Accept refused: ${detail}`);
setBuildTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, acceptFindings: result.findings ?? [] } : t)));
return;
}
if (!result.value.ok) {
setNotice(`Accept refused by the gates: ${result.value.findings.map((f) => f.message).join("; ").slice(0, 300)}`);
setNotice(`Accept refused: ${result.value.findings.map((f) => `${f.gate} ${f.code}: ${f.message}`).join(" · ").slice(0, 400)}`);
setBuildTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, acceptFindings: result.value.findings } : t)));
return;
}
if (result.value.ledger) setLedger(result.value.ledger);
Expand All @@ -611,8 +635,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
doc.examples = examples;
setContract(doc);
recomputeEmit(doc, profile);
setBuildTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, accepted: exampleId } : t)));
setNotice(`Accepted as worked example '${exampleId}' — it now seeds generation for '${turn.intent}'.`);
const savedId = result.value.example?.id ?? exampleId ?? "";
setBuildTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, accepted: savedId, acceptFindings: undefined } : t)));
setNotice(`Accepted as worked example '${savedId}' — it now seeds generation for '${turn.intent}'.`);
} finally {
decisionLock.current = false;
setBusy(null);
Expand Down
Loading
Loading