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
392 changes: 87 additions & 305 deletions app/lib/workflows/__tests__/runAgentStep.test.ts

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions app/lib/workflows/__tests__/runAgentStepStreaming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { streamText } from "ai";
import { runAgentStep } from "@/app/lib/workflows/runAgentStep";
import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage";

vi.mock("ai", async () => {
const actual = await vi.importActual<typeof import("ai")>("ai");
return { ...actual, streamText: vi.fn() };
});
vi.mock("@ai-sdk/gateway", () => ({
gateway: vi.fn((modelId: string) => ({ modelId, __mock: "gateway" })),
}));
vi.mock("@/lib/chat/persistAssistantMessage", () => ({
persistAssistantMessage: vi.fn(),
}));
vi.mock("workflow", () => ({
getWorkflowMetadata: vi.fn(() => ({
workflowRunId: "test-run-id",
workflowName: "test",
workflowStartedAt: new Date(0),
url: "https://example.test",
})),
}));
vi.mock("@/lib/chat/pollWorkflowCancellation", () => ({
pollWorkflowCancellation: vi.fn(() => ({ stop: vi.fn(), done: Promise.resolve() })),
}));
vi.mock("workflow/api", () => ({
getRun: vi.fn(() => ({
get status() {
return Promise.resolve("running");
},
})),
}));

const baseInput = {
modelMessages: [{ role: "user" as const, content: "hi" }],
originalMessages: [
{ id: "m1", role: "user" as const, parts: [{ type: "text" as const, text: "hi" }] },
],
modelId: "anthropic/claude-haiku-4.5",
agentContext: {
sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" },
},
assistantMessageId: "asst-test-id",
};

function makeWritable() {
const written: unknown[] = [];
const stream = new WritableStream({
write(chunk) {
written.push(chunk);
},
});
return { stream, written };
}

beforeEach(() => vi.clearAllMocks());

describe("runAgentStep — streaming shape (mirrors upstream open-agents)", () => {
it("writes every stream part straight to the shared writable", async () => {
const parts = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "hello" },
{ type: "text-end", id: "t1" },
];
vi.mocked(streamText).mockReturnValue({
toUIMessageStream: vi.fn(() =>
(async function* () {
for (const p of parts) yield p;
})(),
),
finishReason: Promise.resolve("stop"),
response: Promise.resolve({ messages: [] }),
} as never);
const { stream, written } = makeWritable();

await runAgentStep({ ...baseInput, writable: stream } as never);

expect(written).toEqual(parts);
});

// Persistence moved to the workflow body. Keeping it in the step is what
// required the outer createUIMessageStream wrapper, and that wrapper is
// what dropped every tool call from the transcript (chat#1918).
it("does not persist from inside the step", async () => {
vi.mocked(streamText).mockReturnValue({
toUIMessageStream: vi.fn(() => (async function* () {})()),
finishReason: Promise.resolve("stop"),
response: Promise.resolve({ messages: [] }),
} as never);
const { stream } = makeWritable();

await runAgentStep({ ...baseInput, writable: stream } as never);

expect(persistAssistantMessage).not.toHaveBeenCalled();
});

it("reports aborted and does not rethrow when the stream aborts", async () => {
const abortError = Object.assign(new Error("aborted"), { name: "AbortError" });
vi.mocked(streamText).mockReturnValue({
toUIMessageStream: vi.fn(() =>
(async function* () {
yield { type: "text-start", id: "t1" };
throw abortError;
})(),
),
finishReason: Promise.reject(abortError),
response: Promise.reject(abortError),
} as never);
const { stream } = makeWritable();

const result = await runAgentStep({ ...baseInput, writable: stream } as never);

expect(result.aborted).toBe(true);
expect(result.finishReason).toBe("stop");
expect(result.responseMessages).toEqual([]);
});
});
35 changes: 33 additions & 2 deletions app/lib/workflows/__tests__/runAgentWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ vi.mock("@/app/lib/workflows/closeChatStream", () => ({
vi.mock("@/app/lib/workflows/generateAssistantMessageId", () => ({
generateAssistantMessageId: vi.fn(),
}));
// The loop's supporting steps — exercised in runAgentWorkflowLoop.test.ts.
vi.mock("@/app/lib/workflows/convertMessagesStep", () => ({
convertMessagesStep: vi.fn(() => Promise.resolve([])),
}));
vi.mock("@/app/lib/workflows/persistAssistantMessageStep", () => ({
persistAssistantMessageStep: vi.fn(),
}));
vi.mock("@/app/lib/workflows/sendStreamStart", () => ({ sendStreamStart: vi.fn() }));
vi.mock("@/app/lib/workflows/sendStreamFinish", () => ({ sendStreamFinish: vi.fn() }));
vi.mock("@/lib/credits/handleChatCredits", () => ({
handleChatCredits: vi.fn(),
}));
Expand Down Expand Up @@ -76,6 +85,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -98,6 +108,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -117,6 +128,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -129,6 +141,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -147,22 +160,28 @@ describe("runAgentWorkflow", () => {
expect(closeChatStream).toHaveBeenCalledWith(writableStub);
});

it("forwards chatId to runAgentStep so it can persist the assistant message per step", async () => {
// Persistence moved to the workflow body, so the step has no business
// knowing the chat id. Guards against re-coupling them.
it("does NOT pass chatId to runAgentStep — persistence is the workflow body's job", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

await runAgentWorkflow(baseInput);

expect(runAgentStep).toHaveBeenCalledWith(expect.objectContaining({ chatId: "chat-1" }));
expect(runAgentStep).toHaveBeenCalledWith(
expect.not.objectContaining({ chatId: expect.anything() }),
);
});

it("generates a fresh assistantMessageId via the step and forwards it to runAgentStep", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -178,6 +197,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand Down Expand Up @@ -218,6 +238,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: responseMessage as never,
});

Expand Down Expand Up @@ -247,6 +268,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: responseMessage as never,
});

Expand All @@ -266,6 +288,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand All @@ -292,6 +315,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: responseMessageWithMetadata,
});

Expand All @@ -308,6 +332,7 @@ describe("runAgentWorkflow", () => {
responseMessage: responseMessageWithMetadata,
finishReason: "stop",
aborted: false,
responseMessages: [],
sessionId: "session-1",
sessionTitle: "test session",
repoOwner: "recoupable",
Expand All @@ -321,6 +346,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: responseMessageWithMetadata,
});

Expand All @@ -334,6 +360,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: responseMessageWithMetadata,
});

Expand All @@ -350,6 +377,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: false,
responseMessages: [],
responseMessage: undefined,
});

Expand Down Expand Up @@ -378,6 +406,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: true,
responseMessages: [],
responseMessage: abortedResponseMessage as never,
});

Expand All @@ -398,6 +427,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: true,
responseMessages: [],
responseMessage: abortedResponseMessage as never,
});

Expand All @@ -410,6 +440,7 @@ describe("runAgentWorkflow", () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
aborted: true,
responseMessages: [],
responseMessage: abortedResponseMessage as never,
});

Expand Down
Loading
Loading