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
5 changes: 5 additions & 0 deletions .changeset/calm-moles-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": patch
---

Preserve orphaned durable execution outcomes as framework-authored notes, then project them to user context for inference so provider transcript validation cannot reject their arbitrary position. Existing outcome notes receive the same projection without rewriting stored history.
50 changes: 50 additions & 0 deletions packages/think/src/tests/actions-durable-pause.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,56 @@ describe("durable-pause actions (turn-driven, connection-less)", () => {

expect(await agent.listActionPendingForTest()).toHaveLength(0);
});

it("labels orphaned durable-pause outcomes without re-invoking the action", async () => {
const agent = await freshPauseAgent(`dp-orphan-${crypto.randomUUID()}`);
await agent.useDurablePauseActionForTest();

const first = await agent.testChat("call pauseAction");
expect(first.done).toBe(true);

const pending = await agent.listActionPendingForTest();
expect(pending).toHaveLength(1);
const executionId = pending[0].execution_id;
await agent.stripDurablePausePartsForTest();

const messagesBefore = (await agent.getStoredMessages()) as UIMessage[];
const textPartsBefore = messagesBefore
.filter((message) => message.role === "assistant")
.flatMap((message) => message.parts)
.filter((part) => part.type === "text").length;

const rejected = (await agent.rejectExecutionForTest(
executionId,
"not now"
)) as PausedOutput;
expect(rejected.status).toBe("rejected");

await vi.waitFor(
async () => {
const messages = (await agent.getStoredMessages()) as UIMessage[];
const note = messages.find((message) =>
message.id.startsWith(`exec-outcome-${executionId}-`)
);
const noteText = note?.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
expect(note?.role).toBe("system");
expect(noteText).toContain("[durable action]");
expect(noteText).not.toContain("[execute tool]");
expect(noteText).toContain('"action":"pauseAction"');

const textPartsAfter = messages
.filter((message) => message.role === "assistant")
.flatMap((message) => message.parts)
.filter((part) => part.type === "text").length;
expect(textPartsAfter).toBeGreaterThan(textPartsBefore);
expect(await agent.listActionPendingForTest()).toHaveLength(0);
},
{ timeout: 5000, interval: 50 }
);
});
});

describe("paused-output descriptor derivation", () => {
Expand Down
107 changes: 80 additions & 27 deletions packages/think/src/tests/agents/execute-hitl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,51 @@
* The mock model:
* - with no execute result in the prompt, calls the `execute` tool with the
* code the test configured (`setExecuteCode`);
* - otherwise emits text reporting every execution status it can see in the
* prompt (`seen:<status,...>`), so tests can assert what the model
* observed on each (auto-)continuation.
* - otherwise emits text reporting every execution status and orphaned
* execution-outcome role it can see in the prompt, so tests can assert what
* the model observed on each (auto-)continuation.
*/
import type { LanguageModel, ToolSet, UIMessage } from "ai";
import { tool } from "ai";
import { z } from "zod";
import { Think } from "../../think";
import { createExecuteTool } from "../../tools/execute";

function promptHasExecuteResult(options: Record<string, unknown>): boolean {
const messages = (options as { prompt?: unknown[] }).prompt ?? [];
return messages.some(
(m: unknown) =>
typeof m === "object" &&
m !== null &&
(m as Record<string, unknown>).role === "tool"
);
type HitlLanguageModelCallOptions = Parameters<
Extract<LanguageModel, { specificationVersion: "v3" }>["doStream"]
>[0];

function promptHasExecuteResult(
options: HitlLanguageModelCallOptions
): boolean {
return options.prompt.some((message) => message.role === "tool");
}

function statusesInPrompt(options: Record<string, unknown>): string[] {
const serialized = JSON.stringify(
(options as { prompt?: unknown[] }).prompt ?? []
);
function statusesInPrompt(options: HitlLanguageModelCallOptions): string[] {
const serialized = JSON.stringify(options.prompt);
const seen: string[] = [];
const re = /"status"\s*:\s*"(completed|paused|rejected|error)"/g;
const re = /\\?"status\\?"\s*:\s*\\?"(completed|paused|rejected|error)\\?"/g;
for (const match of serialized.matchAll(re)) {
if (!seen.includes(match[1])) seen.push(match[1]);
}
return seen;
}

function executionOutcomeRolesInPrompt(
options: HitlLanguageModelCallOptions
): string[] {
const roles: string[] = [];
for (const message of options.prompt) {
if (
JSON.stringify(message.content).includes("[execute tool]") &&
!roles.includes(message.role)
) {
roles.push(message.role);
}
}
return roles;
}

function enqueueExecuteCall(
controller: ReadableStreamDefaultController,
id: string,
Expand Down Expand Up @@ -70,7 +83,7 @@ function createHitlMockModel(agent: ThinkExecuteHitlAgent): LanguageModel {
doGenerate() {
throw new Error("doGenerate not implemented");
},
doStream(options: Record<string, unknown>) {
doStream(options: HitlLanguageModelCallOptions) {
callCount++;
const step = callCount;
const stream = new ReadableStream({
Expand All @@ -92,7 +105,9 @@ function createHitlMockModel(agent: ThinkExecuteHitlAgent): LanguageModel {
controller.enqueue({
type: "text-delta",
id,
delta: `seen:${statusesInPrompt(options).join(",")}`
delta:
`seen:${statusesInPrompt(options).join(",")};` +
`execution-outcome-roles:${executionOutcomeRolesInPrompt(options).join(",")}`
});
controller.enqueue({ type: "text-end", id });
controller.enqueue({
Expand Down Expand Up @@ -244,16 +259,54 @@ export class ThinkExecuteHitlAgent extends Think {
}
}

/** Text of system messages (the orphaned-outcome fallback notes). */
async systemNoteTexts(): Promise<string[]> {
/** Orphaned execution outcome notes, including their transcript role. */
async executionOutcomeNotes(): Promise<
Array<{
id: string;
role: string;
text: string;
metadataOrigin?: string;
}>
> {
return this.messages
.filter((m) => m.role === "system")
.map((m) =>
(m as UIMessage).parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join("")
);
.filter((message) => message.id.startsWith("exec-outcome-"))
.map((message) => {
const metadataOrigin =
message.metadata &&
typeof message.metadata === "object" &&
"origin" in message.metadata &&
typeof message.metadata.origin === "string"
? message.metadata.origin
: undefined;
return {
id: message.id,
role: message.role,
text: (message as UIMessage).parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join(""),
...(metadataOrigin ? { metadataOrigin } : {})
};
});
}

/** Seed the system-role execution outcome produced by older Think releases. */
async appendLegacyExecutionOutcomeForTest(
executionId: string
): Promise<void> {
await this.appendMessageToHistory({
id: `exec-outcome-${executionId}-legacy`,
role: "system",
parts: [
{
type: "text",
text:
`[execute tool] The paused execution "${executionId}" was ` +
`resolved, but its tool call is no longer in the transcript ` +
`(it may have been compacted). Outcome: {"status":"completed"}`
}
]
} as UIMessage);
}

/** Expire all paused runs immediately (stage 1 `expirePaused`). */
Expand Down
38 changes: 34 additions & 4 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3670,14 +3670,28 @@ function createDurablePauseMockModel(): LanguageModel {
(m as Record<string, unknown>).role === "tool"
);
// Only park when a user explicitly asked for it on this turn — so a
// post-resolution continuation (driven by a system note, no fresh user
// ask) responds with text instead of re-parking.
const userAskedToPause = messages.some((m: unknown) => {
// post-resolution continuation (driven by provider-projected framework
// context, not a fresh user ask) responds with text instead of re-parking.
const hasExecutionOutcomeContext = messages.some((m: unknown) => {
if (typeof m !== "object" || m === null) return false;
const mm = m as Record<string, unknown>;
if (mm.role !== "user") return false;
return JSON.stringify(mm.content ?? "").includes("pauseAction");
const content = JSON.stringify(mm.content ?? "");
return (
content.includes("[execute tool]") ||
content.includes("[durable action]")
);
});
const userAskedToPause =
!hasExecutionOutcomeContext &&
messages.some((m: unknown) => {
if (typeof m !== "object" || m === null) return false;
const mm = m as Record<string, unknown>;
return (
mm.role === "user" &&
JSON.stringify(mm.content ?? "").includes("pauseAction")
);
});
const stream = new ReadableStream({
start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
Expand Down Expand Up @@ -4425,6 +4439,22 @@ export class ThinkToolsTestAgent extends Think {
return this._durablePauseExecCount;
}

/** Simulate compaction removing a durable-pause action's tool part. */
async stripDurablePausePartsForTest(): Promise<void> {
for (const message of this.messages) {
if (message.role !== "assistant") continue;
const remaining = message.parts.filter(
(part) => part.type !== "tool-pauseAction"
);
if (remaining.length === message.parts.length) continue;
const parts: UIMessage["parts"] =
remaining.length > 0
? remaining
: [{ type: "text", text: "(summarized)" }];
await this.updateMessageInHistory({ ...message, parts });
}
}

/** Compile tools and directly invoke the durable-pause action to park it. */
async parkDurablePauseForTest(
message = "hello",
Expand Down
69 changes: 60 additions & 9 deletions packages/think/src/tests/execute-hitl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,17 @@ async function connectWS(room: string) {
return ws;
}

function sendChatRequest(ws: WebSocket, text: string) {
function sendChatRequest(
ws: WebSocket,
text: string,
metadata?: Record<string, unknown>
) {
const id = crypto.randomUUID();
const message: UIMessage = {
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text }]
parts: [{ type: "text", text }],
...(metadata ? { metadata } : {})
};
ws.send(
JSON.stringify({
Expand Down Expand Up @@ -125,10 +130,13 @@ type PausedOutput = {
};

/** Run one chat turn that pauses, returning the paused execute output. */
async function runTurnToPause(room: string) {
async function runTurnToPause(
room: string,
metadata?: Record<string, unknown>
) {
const agent = await freshAgent(room);
const ws = await connectWS(room);
sendChatRequest(ws, "deploy please");
sendChatRequest(ws, "deploy please", metadata);
await waitForDone(ws);

const parts = await agent.executeParts();
Expand Down Expand Up @@ -336,9 +344,11 @@ describe("Think HITL — approve/reject paused executions", () => {
ws.close();
});

it("an approval whose paused part was compacted away records a system note", async () => {
it("continues after an approval whose paused part was compacted away", async () => {
const room = crypto.randomUUID();
const { agent, ws, executionId } = await runTurnToPause(room);
const { agent, ws, executionId } = await runTurnToPause(room, {
origin: "original-user-turn"
});

// Simulate compaction summarizing the paused tool part away.
await agent.stripExecutePartsForTest();
Expand All @@ -351,12 +361,53 @@ describe("Think HITL — approve/reject paused executions", () => {
// The runtime still applied the approval — the gated tool ran…
expect(await agent.gatedCallCount()).toBe(1);

// …and the outcome was not dropped: it landed as a system note.
// …and the outcome was not dropped: it remained a framework-authored
// system note in durable history.
await waitUntil(async () =>
(await agent.systemNoteTexts()).some(
(text) => text.includes(executionId) && text.includes("completed")
(await agent.executionOutcomeNotes()).some(
(note) =>
note.id.includes(executionId) && note.text.includes("completed")
)
);
const note = (await agent.executionOutcomeNotes()).find((candidate) =>
candidate.id.includes(executionId)
);
expect(note?.id).toMatch(new RegExp(`^exec-outcome-${executionId}-`));
expect(note?.role).toBe("system");
expect(note?.text).toContain(
`[execute tool] The paused execution "${executionId}" was resolved, ` +
`but its tool call is no longer in the transcript (it may have been ` +
`compacted). Outcome: `
);
expect(note?.text).toContain("completed");
expect(note?.metadataOrigin).toBeUndefined();

// The auto-continuation consumed the fallback successfully after the
// provider boundary projected it to ordinary user context.
await waitUntil(async () =>
(await agent.lastAssistantText()).includes("completed")
);
expect(await agent.lastAssistantText()).toContain(
"execution-outcome-roles:user"
);

ws.close();
});

it("continues a session containing a legacy system-role execution outcome", async () => {
const room = crypto.randomUUID();
const { agent, ws, executionId } = await runTurnToPause(room);

await agent.appendLegacyExecutionOutcomeForTest(executionId);
sendChatRequest(ws, "what happened?");
await waitForDone(ws);

await waitUntil(async () =>
(await agent.lastAssistantText()).includes("completed")
);
expect(await agent.lastAssistantText()).toContain(
"execution-outcome-roles:user"
);

ws.close();
});
Expand Down
Loading
Loading