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
14 changes: 7 additions & 7 deletions apps/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@
"record:catch": "tsx src/record-catch.ts"
},
"dependencies": {
"@aestheticfunction/dspack-gen": "^0.1.3",
"@dspack-studio/agui-bridge": "workspace:*",
"@dspack-studio/contracts": "workspace:*",
"@dspack-studio/replay": "workspace:*",
"@aestheticfunction/dspack-emit": "^0.4.1",
"@aestheticfunction/dspack-export": "^0.5.0",
"@aestheticfunction/dspack-gen": "^0.2.0",
"@aestheticfunction/dspack-spec": "^0.4.2",
"@dspack-studio/composer-core": "workspace:*"
"@dspack-studio/agui-bridge": "workspace:*",
"@dspack-studio/composer-core": "workspace:*",
"@dspack-studio/contracts": "workspace:*",
"@dspack-studio/replay": "workspace:*"
},
"devDependencies": {
"@dspack-studio/scenarios": "workspace:*",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^3.0.0",
"@dspack-studio/scenarios": "workspace:*"
"vitest": "^3.0.0"
}
}
151 changes: 151 additions & 0 deletions apps/agent/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,154 @@ describe("save", () => {
expect(payload.findings[0].path ?? payload.findings[0].target).toContain("propMap");
});
});

/**
* Phase 3 (Build): /project/run streaming + conversation refinement +
* server-side fail-closed example acceptance. SSE runs are captured through
* a minimal ServerResponse mock; every pipeline event line is parsed back.
*/
function sseCall(route: string, body: Record<string, unknown>): Promise<{ status: number; events: any[] }> {
return new Promise((resolve, reject) => {
let status = 0;
const chunks: string[] = [];
const res = {
writeHead(code: number) {
status = code;
return res;
},
write(chunk: string) {
chunks.push(String(chunk));
return true;
},
end() {
const events = chunks
.join("")
.split("\n\n")
.map((block) => block.split("\n").find((l) => l.startsWith("data:")))
.filter((l): l is string => !!l)
.map((l) => JSON.parse(l.slice(5)));
resolve({ status, events });
},
} as unknown as ServerResponse;
handleProjectRoute(`/project/${route}`, body, res, {}, "text/event-stream", ((r: unknown, code: number, payload: unknown) => {
// JSON reply instead of a stream (a refusal): surface it for asserts.
resolve({ status: code, events: [payload] });
}) as never).catch(reject);
});
}

const surfaceOfRun = (events: any[]) => {
const audit = events.find((e) => e.type === "CUSTOM" && e.name === "dspack.audit");
return { audit: audit?.value, surface: audit?.value?.report?.attempts?.at(-1)?.surface };
};

describe("build runs (/project/run, scripted)", () => {
it("streams a deterministic fail->repair->pass run scoped to the project", async () => {
const { status, events } = await sseCall("run", { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" });
expect(status).toBe(200);
const names = events.map((e) => e.type + (e.name ? `:${e.name}` : ""));
expect(names[0]).toBe("RUN_STARTED");
expect(names).toContain("CUSTOM:dspack.gates"); // per-attempt gate results
expect(names).toContain("CUSTOM:dspack.repair"); // the visible repair turn
expect(names.at(-1)).toBe("RUN_FINISHED");
const { audit, surface } = surfaceOfRun(events);
expect(audit.outcome).toBe("passed");
expect(audit.report.attempts.length).toBe(2); // violation, then the worked example
expect(surface.root.component).toBe("info-card");
});

it("accepts HttpAgent-shaped bodies (RunAgentInput.forwardedProps)", async () => {
const { status, events } = await sseCall("run", {
threadId: "t",
runId: "r",
forwardedProps: { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" },
});
expect(status).toBe(200);
expect(surfaceOfRun(events).audit.outcome).toBe("passed");
});

it("refinement is non-vacuous under scripted: the refined surface differs ONLY when the prior surface is supplied", async () => {
const fresh = await sseCall("run", { path: root, prompt: "a status screen", intent: "status-report", modelRef: "scripted" });
const freshSurface = surfaceOfRun(fresh.events).surface;

const again = await sseCall("run", { path: root, prompt: "make the title clearer", intent: "status-report", modelRef: "scripted" });
expect(JSON.stringify(surfaceOfRun(again.events).surface)).toBe(JSON.stringify(freshSurface)); // no seed -> same

const refined = await sseCall("run", {
path: root,
prompt: "make the title clearer",
intent: "status-report",
modelRef: "scripted",
conversation: [
{ role: "user", content: "a status screen" },
{ role: "assistant", content: JSON.stringify(freshSurface) },
],
});
const refinedSurface = surfaceOfRun(refined.events).surface;
expect(surfaceOfRun(refined.events).audit.outcome).toBe("passed");
expect(JSON.stringify(refinedSurface)).not.toBe(JSON.stringify(freshSurface)); // seed -> visibly different
expect(JSON.stringify(refinedSurface)).toContain("(refined)"); // the deterministic transform marker
});

it("refuses a malformed conversation with 400 before running anything", async () => {
const bad = await sseCall("run", { path: root, prompt: "x", intent: "status-report", modelRef: "scripted", conversation: [{ role: "narrator", content: 1 }] });
expect(bad.status).toBe(400);
expect(String((bad.events[0] as any).error)).toContain("conversation");
});
});

describe("accepting a build result (/project/save-example, fail-closed)", () => {
const freshExample = () => JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")).examples[0];

it("rejects lint-invalid surfaces server-side with the gate findings", async () => {
const surface = structuredClone(freshExample().surface);
surface.root.children[0].component = "not-a-component"; // S2 violation
const { status, payload } = await call("save-example", {
path: root,
example: { id: "ex.chat-bad", intent: "status-report", prompt: "bad", surface },
});
expect(status).toBe(422);
expect(payload.findings.some((f: any) => f.gate === "S2")).toBe(true);
// Nothing was written.
const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
expect(doc.examples.some((e: any) => e.id === "ex.chat-bad")).toBe(false);
});

it("rejects unknown intents and malformed ids", async () => {
const surface = freshExample().surface;
expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "not-an-intent", prompt: "p", surface } })).status).toBe(422);
expect((await call("save-example", { path: root, example: { id: "chat", intent: "status-report", prompt: "p", surface } })).status).toBe(400);
expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "status-report", prompt: "p", surface: "nope" } })).status).toBe(400);
});

it("accepts a governed surface, preserves the ledger, and feeds the next run's few-shot + scripted playback", async () => {
// The accepted surface: the deterministic refinement of the worked example.
const refined = structuredClone(freshExample().surface);
const title = refined.root.children[0].children[0];
title.text = `${title.text} (refined)`;
const { status, payload } = await call("save-example", {
path: root,
example: { id: "ex.chat-refined", intent: "status-report", name: "Chat: refined status", prompt: "make the title clearer", surface: refined },
});
expect(status).toBe(200);
expect(payload.ok).toBe(true);
expect(payload.ledger.hasLedger).toBe(true);

const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
const saved = doc.examples.find((e: any) => e.id === "ex.chat-refined");
expect(saved.intent).toBe("status-report");
expect(JSON.stringify(saved.surface)).toBe(JSON.stringify(refined));
expect(doc.metadata["x-bootstrap"]).toBeDefined(); // ledger intact

// Few-shot proof against the REAL saved file: the compiler now includes it.
const { compileContext } = await import("@aestheticfunction/dspack-gen/core");
const context = compileContext(doc, "status-report");
const pair = context.fewshot.find((m: any) => m.role === "assistant" && m.content.includes("(refined)"));
expect(pair).toBeDefined();

// Scripted playback proof: a fresh scripted run now converges on the
// LATEST accepted example — the accept loop visibly compounds.
const next = await sseCall("run", { path: root, prompt: "again", intent: "status-report", modelRef: "scripted" });
expect(JSON.stringify(surfaceOfRun(next.events).surface)).toBe(JSON.stringify(refined));
});
});
165 changes: 159 additions & 6 deletions apps/agent/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,23 +429,99 @@ function ollamaAdapterWithWindow(modelRef: string) {
});
}

/** A conversation seed: prior chat turns for a refinement run (gen 0.2.0). */
type ConversationTurn = { role: "user" | "assistant"; content: string };

function parseConversation(raw: unknown): ConversationTurn[] | undefined {
if (raw === undefined) return undefined;
if (
!Array.isArray(raw) ||
raw.some((m) => !m || typeof m !== "object" || !["user", "assistant"].includes((m as { role?: unknown }).role as string) || typeof (m as { content?: unknown }).content !== "string")
) {
throw new ProjectError(400, "conversation must be an array of { role: 'user' | 'assistant', content: string } turns");
}
return raw as ConversationTurn[];
}

/** Deep-walk a surface and return the first node carrying visible text. */
function firstTextNode(node: unknown): { text: string } | null {
if (!node || typeof node !== "object") return null;
const record = node as Record<string, unknown>;
if (typeof record.text === "string") return record as { text: string };
for (const value of Object.values(record)) {
if (Array.isArray(value)) {
for (const child of value) {
const found = firstTextNode(child);
if (found) return found;
}
} else if (value && typeof value === "object") {
const found = firstTextNode(value);
if (found) return found;
}
}
return null;
}

/**
* Scripted mode is the deterministic zero-model twin of a real chat run:
* - a FRESH run scripts a contract-derived S2 violation first, then the
* intent's LATEST worked example — so every scripted run demonstrates the
* governed fail -> repair -> pass loop honestly, and accepting a chat
* result visibly changes what scripted plays next (the example corpus is
* the product's memory);
* - a REFINEMENT run (conversation present) replays the prior surface from
* the seed with a deterministic, gate-neutral textual change — different
* output exists ONLY when the prior surface was supplied, which is the
* ratified non-vacuous-refinement proof, executable with zero models.
*/
function scriptedRunAdapter(example: { surface: unknown }, conversation: ConversationTurn[] | undefined): ScriptedAdapter {
if (conversation && conversation.length > 0) {
const priorRaw = [...conversation].reverse().find((m) => m.role === "assistant")?.content;
if (priorRaw) {
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 }]);
} catch {
// Fall through: an unparseable prior surface behaves like a fresh run.
}
}
}
const violating = structuredClone(example.surface) as { root?: { children?: Array<Record<string, unknown>> } };
if (violating.root?.children?.[0]) violating.root.children[0] = { ...violating.root.children[0], component: "not-a-component" };
// Three entries cover maxRepairs=2 (≤3 generations): the run always ends
// in a real outcome — passed when the example is clean, or an honest
// failed-lint-exhausted when the corpus itself violates — never a script
// exhaustion error.
return new ScriptedAdapter([{ output: violating }, { output: example.surface }, { output: example.surface }]);
}

/** AG-UI SSE generation under the PROJECT contract + profile. */
async function runProject(ctx: ProjectContext, body: Record<string, unknown>, res: ServerResponse, cors: Record<string, string>, accept: string | undefined) {
const contract = readJson(ctx.contractPath) as Record<string, unknown>;
const profile = loadProfile(readJson(ctx.profilePath));
const prompt = String(body.prompt ?? "");
// HttpAgent posts RunAgentInput with the run parameters in forwardedProps;
// plain JSON bodies keep working (the test surface and curl).
const props = ((body.forwardedProps as Record<string, unknown> | undefined) ?? body) as Record<string, unknown>;
const prompt = String(props.prompt ?? "");
const intents = (contract.intents as Array<{ id: string }> | undefined) ?? [];
const intent = String(body.intent ?? intents[0]?.id ?? "");
const modelRef = String(body.modelRef ?? "scripted");
const intent = String(props.intent ?? intents[0]?.id ?? "");
const modelRef = String(props.modelRef ?? "scripted");
const conversation = parseConversation(props.conversation);

const examples = (contract.examples as Array<{ intent: string; surface: unknown }> | undefined) ?? [];
const example = examples.find((e) => e.intent === intent) ?? examples[0];
// 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);
if (modelRef === "scripted" && !example) {
throw new ProjectError(400, "scripted mode needs at least one worked example in the contract");
}
const adapter =
modelRef === "scripted"
? new ScriptedAdapter([{ output: example!.surface }])
? scriptedRunAdapter(example!, conversation)
: modelRef.startsWith("ollama:")
? ollamaAdapterWithWindow(modelRef)
: adapterFor(modelRef);
Expand All @@ -463,6 +539,7 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, re
adapter,
maxRepairs: 2,
emitProfile: profile,
...(conversation && conversation.length > 0 ? { conversation } : {}),
onEvent: (event) => {
// The bridge's PipelineEvent is a structural mirror of dspack-gen's
// union (retired once dspack-gen#48 re-exports the type).
Expand All @@ -475,6 +552,77 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, re
res.end();
}


/**
* Accept a build result as a governed worked example — the ONLY save format
* for chat-accepted surfaces, and fail-closed SERVER-SIDE: a disabled
* client button is a courtesy, this gate is the contract. Refuses unless
* the surface passes S1-S3 for the project contract and the intent is one
* the owner authored. Never touches intents, rules, mappings, casualty
* declarations, or any other governance; writes through the same
* ledger-preserving, harness-gated path as every contract save.
*/
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 intents = ((contract.intents as Array<{ id: string }> | undefined) ?? []).map((i) => i.id);
const intent = String(raw.intent ?? "");
if (!intents.includes(intent)) {
return {
status: 422,
payload: { ok: false, findings: [finding("document", "unknown-intent", "error", "example.intent", `'${intent}' is not an intent this contract's owner authored (${intents.join(", ") || "none"})`)] },
};
}

// The server-side gate: S1-S3 over the project contract, zero errors.
const lint = lintSurface(raw.surface as Parameters<typeof lintSurface>[0], contract as Parameters<typeof lintSurface>[1]);
const findings: ComposerFinding[] = [];
for (const gate of lint.gates) {
if (gate.status === "FAIL") {
findings.push(finding(gate.gate as "S1", gate.name, "error", id, (gate.errors ?? []).join("; ") || gate.name));
}
}
for (const f of lint.findings ?? []) {
if (f.level === "error") findings.push(finding("S3", f.ruleId, "error", `${id} ${f.location.path}`, `${f.message} — ${f.rationale}`));
}
if (findings.length > 0) return { status: 422, payload: { ok: false, findings } };

const entry = {
id,
intent,
...(raw.name ? { name: String(raw.name) } : {}),
prompt,
...(raw.description ? { description: String(raw.description) } : {}),
surface: raw.surface,
};
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);
document.examples = examples;

// The same guarded write as /project/save: ledger preserved, harness clean.
if (!preservesLedger(contract, document)) {
return { status: 200, payload: { ok: false, findings: [finding("ledger", "ledger-dropped", "error", 'metadata["x-bootstrap"]', "a save may not remove the bootstrap ledger")] } };
}
const report = documentReport(document, specValidators());
if (!report.valid) {
return { status: 422, payload: { ok: false, findings: report.errors.map((e) => finding("document", "harness", "error", "", e)) } };
}
atomicWriteJson(ctx.contractPath, document);
return { status: 200, payload: { ok: true, findings: [], example: entry, ledger: await ledgerStatus(document) } };
}

// ---------------------------------------------------------------------------

/**
Expand All @@ -492,7 +640,7 @@ export async function handleProjectRoute(
if (!path.startsWith("/project/")) return false;
const route = path.slice("/project/".length);
try {
const ctx = openProject(body.path);
const ctx = openProject(body.path ?? (body.forwardedProps as Record<string, unknown> | undefined)?.path);
switch (route) {
case "connect":
json(res, 200, await connect(ctx), cors);
Expand All @@ -512,6 +660,11 @@ export async function handleProjectRoute(
case "save":
json(res, 200, await save(ctx, body), cors);
return true;
case "save-example": {
const result = await saveExample(ctx, body);
json(res, result.status, result.payload, cors);
return true;
}
case "run":
await runProject(ctx, body, res, cors, accept);
return true;
Expand Down
Loading
Loading