Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/resumable-petrinaut-workpiece.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

Report duplicate AI mutations as no-ops so hosts can distinguish an applied document change from
an already-present state when resuming a correlated browser tool call.
6 changes: 5 additions & 1 deletion apps/brunch-agent/src/conversation/client-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** Flue-side client-tool signal contract: awaiting sentinel, result signal, tool names. */

import { READ_PETRINAUT_DOC_TOOL_NAME } from "@hashintel/brunch-agent-plugin-sdcpn/flue";
import {
petrinautFixtureToolNames,
READ_PETRINAUT_DOC_TOOL_NAME,
} from "@hashintel/brunch-agent-plugin-sdcpn/flue";
import { CLIENT_TOOL_RESULT_SIGNAL } from "@hashintel/brunch-agent-transport-aisdk";
import { AWAITING_CLIENT } from "@hashintel/brunch-agent/client-tools";

Expand All @@ -9,6 +12,7 @@ export { CLIENT_TOOL_RESULT_SIGNAL };

export const clientToolNames: ReadonlySet<string> = new Set([
READ_PETRINAUT_DOC_TOOL_NAME,
...petrinautFixtureToolNames,
]);

const isRecord = (value: unknown): value is Record<string, unknown> =>
Expand Down
47 changes: 47 additions & 0 deletions apps/brunch-agent/src/conversation/workpiece.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** Recover the current Markdown workpiece from canonical Flue history. */

import { createHash } from "node:crypto";

import { selectRunbookWorkpiece } from "@hashintel/brunch-agent/workpiece";

import type { FlueConversationSnapshot } from "@flue/sdk";

const sha256 = (value: string): string =>
createHash("sha256").update(value).digest("hex");

export interface RecoveredRunbookWorkpiece {
readonly authorship: "model-produced" | "test-authored";
readonly content: string;
readonly fixtureId?: string;
readonly sha256: string;
readonly sourceKind: "assistant" | "prepared-signal";
readonly sourceMessageId: string;
readonly sourceMessageSha256: string;
readonly sourceSubmissionId?: string;
}

/**
* Add content and source hashes to the substrate-neutral current-workpiece
* selection used by both evaluations and the browser fixture.
*/
export const recoverRunbookWorkpiece = (
snapshot: FlueConversationSnapshot,
): RecoveredRunbookWorkpiece | undefined => {
const selected = selectRunbookWorkpiece(snapshot);
if (selected === undefined) return undefined;

return {
authorship: selected.authorship,
content: selected.content,
...(selected.fixtureId === undefined
? {}
: { fixtureId: selected.fixtureId }),
sha256: sha256(selected.content),
sourceKind: selected.sourceKind,
sourceMessageId: selected.sourceMessageId,
sourceMessageSha256: sha256(JSON.stringify(selected.sourceMessage)),
...(selected.sourceSubmissionId === undefined
? {}
: { sourceSubmissionId: selected.sourceSubmissionId }),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

import { isAwaitingClient } from "../../conversation/client-tools.ts";
import { formatFlueTranscript } from "../../conversation/transcript.ts";
import { recoverRunbookWorkpiece } from "../runbook/artifacts.ts";
import { recoverRunbookWorkpiece } from "../../conversation/workpiece.ts";

interface ProofEventBase {
readonly sequence: number;
Expand Down
48 changes: 4 additions & 44 deletions apps/brunch-agent/src/evaluations/runbook/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,16 @@

import { basename } from "node:path";

import { sha256 } from "./campaign-integrity.ts";
import { runbookIrFence } from "@hashintel/brunch-agent/workpiece";

import type { FlueConversationPart, FlueConversationSnapshot } from "@flue/sdk";
import { recoverRunbookWorkpiece } from "../../conversation/workpiece.ts";

export const RUNBOOK_IR_FENCE = "runbook-ir";

const runbookIrFencePattern = /```runbook-ir\s*\n([\s\S]*?)```/g;

export const latestRunbookIrBlock = (text: string): string | undefined => {
const matches = [...text.matchAll(runbookIrFencePattern)];
const last = matches.at(-1)?.[1];
return last === undefined ? undefined : last.trim();
};
import type { FlueConversationSnapshot } from "@flue/sdk";

export const recoverRunbookIr = (
snapshot: FlueConversationSnapshot,
): string | undefined => recoverRunbookWorkpiece(snapshot)?.content;

export interface RecoveredRunbookWorkpiece {
readonly content: string;
readonly sha256: string;
readonly sourceMessageId: string;
readonly sourceMessageSha256: string;
}

export const recoverRunbookWorkpiece = (
snapshot: FlueConversationSnapshot,
): RecoveredRunbookWorkpiece | undefined => {
let recovered: RecoveredRunbookWorkpiece | undefined;
for (const message of snapshot.messages) {
if (message.purpose !== "assistant") continue;
const text = message.parts
.filter(
(part): part is Extract<FlueConversationPart, { type: "text" }> =>
part.type === "text",
)
.map((part) => part.text)
.join("\n");
const content = latestRunbookIrBlock(text);
if (content === undefined) continue;
recovered = {
content,
sha256: sha256(content),
sourceMessageId: message.id,
sourceMessageSha256: sha256(JSON.stringify(message)),
};
}
return recovered;
};

export const interviewerToolNamesFrom = (
snapshot: FlueConversationSnapshot,
): readonly string[] => [
Expand Down Expand Up @@ -153,7 +113,7 @@ export const ordinaryElicitationViolationsFrom = (
}
if (
firstWorkpiecePosition === undefined &&
part.text.includes(`\`\`\`${RUNBOOK_IR_FENCE}`)
part.text.includes(`\`\`\`${runbookIrFence}`)
) {
firstWorkpiecePosition = position;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
getLatestNetDefinitionToolName,
} from "@hashintel/petrinaut-core/ai";

import type { Petrinaut } from "@hashintel/petrinaut-core";
import type { Petrinaut, SDCPN } from "@hashintel/petrinaut-core";

export interface HeadlessPetrinautToolCall {
readonly toolCallId: string;
Expand Down Expand Up @@ -45,15 +45,18 @@ const constructionToolNames = new Set<string>(
const errorMessageFrom = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

export const createHeadlessPetrinautClient = (title: string) => {
export const createHeadlessPetrinautClient = (
title: string,
initial: SDCPN = {
places: [],
transitions: [],
types: [],
parameters: [],
differentialEquations: [],
},
) => {
const handle = createJsonDocHandle({
initial: {
places: [],
transitions: [],
types: [],
parameters: [],
differentialEquations: [],
},
initial,
});
const instance = createPetrinaut({ document: handle });
const writableCallbacks = createPetrinautAiWritableCallbacks(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,14 @@ describe("recorded Flue constraints hold by construction (spec Β§10)", () => {
});

describe("core auxiliary subpaths stay in their assigned lanes", () => {
test("core exposes Flue composition, browser contracts, and storage support as explicit subpaths", () => {
test("core exposes Flue composition, browser, storage, and workpiece contracts as explicit subpaths", () => {
const core = PACKAGES.find((pkg) => pkg.name === CORE)!;
expect(Object.keys(core.manifest.exports ?? {})).toEqual([
".",
"./client-tools",
"./flue",
"./storage",
"./workpiece",
]);
});

Expand Down Expand Up @@ -429,6 +430,8 @@ describe("the HASH smoke is runnable without a model key or a network (spec Β§12
"Types Flue's public conversation snapshot so the transcript projector can be unit-tested; the import is type-only β€” no provider key, no socket, no model call, no runtime boot.",
"apps/brunch-agent/test/petrinaut-chat.integration.ts":
"Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the browser ChatTransport against the mounted Flue route over app.fetch, and proves streamed reasoning/text, server tools, client-tool resume, SDK history ownership, SQLite restart, and harness-side idempotent apply-sweep β€” no provider key, no socket, no extraction model call. Run as a child process by petrinaut-chat.test.ts.",
"apps/brunch-agent/test/prepared-workpiece.integration.ts":
"Boots the built Flue ChatAgent with pi-ai's faux provider, creates a prepared fixture through one tagged public signal with fixture-scoped initial data, retries its deterministic idempotency key, and proves prepared/model workpiece selection from canonical history β€” no provider key, socket, or network model call.",
"apps/brunch-agent/test/proof-artifacts.test.ts":
"Types Flue's public conversation snapshot so canonical trace derivation, workpiece binding, and atomic evidence retention can be unit-tested against an in-memory fixture β€” no provider key, no socket, no model call, no runtime boot.",
"apps/brunch-agent/test/runbook-artifacts.test.ts":
Expand All @@ -437,6 +440,8 @@ describe("the HASH smoke is runnable without a model key or a network (spec Β§12
"Defines the scripted pi-ai faux provider loaded only by the hermetic prospective-runner test β€” no provider key, no socket, and no network model call.",
"apps/brunch-agent/test/runbook-headless.integration.ts":
"Boots the built Flue ChatAgent with pi-ai's faux provider and a headless Petrinaut client to prove validated construct-only tool flow without a provider key, socket, or network model call.",
"apps/brunch-agent/test/workpiece.test.ts":
"Types Flue's public conversation snapshot so the substrate-neutral workpiece selector and app-owned SHA-256 projection can be unit-tested against in-memory messages β€” no provider key, no socket, no model call, no runtime boot.",
"libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts":
"Types a stubbed public Flue client and stream chunks to prove finite AI SDK projection and client-tool signal admission β€” no runtime boot, provider key, socket, or model call.",
"libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts":
Expand Down
5 changes: 5 additions & 0 deletions apps/brunch-agent/test/architecture/boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Keep the filesystem-wide architecture suite runnable by Vitest while its
* implementation remains a non-test entry point for boundary self-inspection.
*/
import "./boundaries.integration.ts";
70 changes: 70 additions & 0 deletions apps/brunch-agent/test/prepared-workpiece.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { expect, test } from "vitest";

import { runNodeScript } from "./run-node-script";

test("the built ChatAgent preserves prepared and model workpiece provenance", async () => {
const databaseDirectory = await mkdtemp(
join(tmpdir(), "brunch-prepared-workpiece-"),
);
try {
const { exitCode, stdout, stderr } = await runNodeScript(
join(import.meta.dirname, "prepared-workpiece.integration.ts"),
join(import.meta.dirname, "../../.."),
{
BRUNCH_CHAT_DB_PATH: join(databaseDirectory, "conversations.db"),
},
);
expect(exitCode, stderr || stdout).toBe(0);
const resultLine = stdout
.split("\n")
.find((line) => line.startsWith("PREPARED_WORKPIECE_HERMETIC "));
expect(resultLine, stdout).toBeDefined();
const result = JSON.parse(
resultLine!.slice("PREPARED_WORKPIECE_HERMETIC ".length),
) as {
readonly clientToolCallIds: string[];
readonly messageCountStableAcrossRetry: boolean;
readonly prepared: {
readonly authorship: string;
readonly content: string;
readonly sourceKind: string;
};
readonly preparedDispatchCount: number;
readonly preparationSubmissionId: string;
readonly retryDeduplicated: boolean;
readonly retrySubmissionId: string;
readonly targetArcAdded: boolean;
readonly revision: {
readonly authorship: string;
readonly content: string;
readonly sourceKind: string;
};
};

expect(result.retryDeduplicated).toBe(true);
expect(result.retrySubmissionId).toBe(result.preparationSubmissionId);
expect(result.messageCountStableAcrossRetry).toBe(true);
expect(result.preparedDispatchCount).toBe(1);
expect(result.clientToolCallIds).toEqual([
"fixture-read-before-mutation",
"fixture-add-reservation-arc",
]);
expect(result.targetArcAdded).toBe(true);
expect(result.prepared).toMatchObject({
authorship: "test-authored",
content: "# Prepared revision\n\nTiming and recovery remain unresolved.",
sourceKind: "prepared-signal",
});
expect(result.revision).toMatchObject({
authorship: "model-produced",
sourceKind: "assistant",
});
expect(result.revision.content).toContain("# Model revision one");
} finally {
await rm(databaseDirectory, { recursive: true, force: true });
}
});
Loading
Loading