Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
afbd2b7
Cut Mission 6 resumable fixture authority
lunelson Sep 3, 2026
4230703
Accept tagged prepared workpiece history
lunelson Sep 3, 2026
eae0d9f
Reconcile Mission 6 with browser Flue transport
lunelson Sep 3, 2026
60603a3
Record Mission 5 as the Mission 6 transport prerequisite
lunelson Sep 3, 2026
621a96a
Implement the Mission 6 resumable workpiece fixture over the Flue tra…
lunelson Sep 3, 2026
da1b62f
Correct Mission 6 bundle recovery and preparation
lunelson Sep 4, 2026
cd67285
Normalize provider inputs for Petrinaut client tools
lunelson Sep 4, 2026
30e7749
Retain Mission 6 outer browser witness
lunelson Sep 4, 2026
8b2206e
Simplify Mission 6 settlement coordination
lunelson Sep 4, 2026
98a0ded
Normalize Mission 6 witness workpiece files
lunelson Sep 4, 2026
eb39fa4
Validate Mission 6 coherent bundle inputs
lunelson Sep 4, 2026
5ddd9bf
Replace the Mission 6 outer witness with honest authorship evidence
lunelson Sep 4, 2026
eb76be8
M7+ resteering planning
lunelson Sep 4, 2026
fe35f7c
M7+ resteering draft 2
lunelson Sep 4, 2026
dacef19
Incorporate the follow-up provenance review into the replanning record
lunelson Sep 4, 2026
f0cec21
Recut the Brunch mission spine around provenance by lineage with decl…
lunelson Sep 4, 2026
56e6164
Amend Mission 6 deferred items for the recut planning record
lunelson Sep 4, 2026
21b4e3b
Record Mission 6 cold-reader acceptance
lunelson Sep 4, 2026
6db95ca
Reposition the prepared fixture status banner
lunelson Sep 4, 2026
ba25541
Resume hydrated Petrinaut tool calls
lunelson Sep 4, 2026
51e6c95
Tighten the Mission 7 draft after the readiness review
lunelson Sep 4, 2026
c118fbf
Resume Petrinaut browser tool continuations
lunelson Sep 4, 2026
9c9290a
Avoid deep Immer typing in prepared snapshot persistence
lunelson Sep 7, 2026
ad31475
Close Mission 6 with deferred presentation checks
lunelson Sep 7, 2026
9a390b6
Fix Mission 6 review findings for fixture routing, tool catalog, and …
lunelson Sep 7, 2026
f6ebda7
Reconcile fixture routing and history gaps
lunelson Sep 7, 2026
f124c70
Refuse settlement while a coherent snapshot is missing
lunelson Sep 7, 2026
7ee6f35
Wait for every browser tool before continuing
lunelson Sep 7, 2026
7bdf802
Preserve step boundaries when folding client-tool continuations
lunelson Sep 8, 2026
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
6 changes: 6 additions & 0 deletions .changeset/resumable-petrinaut-workpiece.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

Report an AI mutation that leaves the document unchanged as not applied, without claiming the requested state was already present, so hosts can distinguish an applied document change from an unchanged one, and reliably continue consecutive browser tool calls from live or rehydrated assistant messages.
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 @@ -439,6 +442,8 @@ describe("the HASH smoke is runnable without a model key or a network (spec Β§12
"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/telemetry.test.ts":
"Constructs Flue's content-free OpenTelemetry instrumentation with an injected exporter setup to prove disposal order; it registers no global instrumentation, opens no socket, and makes no provider 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