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
89 changes: 89 additions & 0 deletions app/lib/workflows/__tests__/runAgentWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream";
import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId";
import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage";
import { handleChatCredits } from "@/lib/credits/handleChatCredits";
import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn";

vi.mock("@/app/lib/workflows/runAgentStep", () => ({
runAgentStep: vi.fn(),
Expand All @@ -25,6 +26,9 @@ vi.mock("@/lib/chat/persistAssistantMessage", () => ({
vi.mock("@/lib/credits/handleChatCredits", () => ({
handleChatCredits: vi.fn(),
}));
vi.mock("@/lib/chat/auto-commit/autoCommitChatTurn", () => ({
autoCommitChatTurn: vi.fn(),
}));
// Captured writable stub so tests can assert closeChatStream got the
// same instance the workflow body holds.
const writableStub = new WritableStream();
Expand All @@ -49,11 +53,24 @@ const baseInput = {
sessionId: "session-1",
accountId: "acc-1",
modelId: "anthropic/claude-haiku-4.5",
sessionTitle: "test session",
repoOwner: "recoupable",
repoName: "api",
agentContext: {
sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" },
} as never,
};

const responseMessageWithMetadata = {
id: "asst-msg-1",
role: "assistant",
parts: [{ type: "text", text: "Hello!" }],
metadata: {
totalMessageCost: 0.07,
totalMessageUsage: { inputTokens: 100, cachedInputTokens: 10, outputTokens: 20 },
},
} as never;

describe("runAgentWorkflow", () => {
it("clears active_stream_id after a successful run, using the workflow's own runId", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
Expand Down Expand Up @@ -253,4 +270,76 @@ describe("runAgentWorkflow", () => {

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

describe("auto-commit", () => {
// The auto-commit flow itself is exhaustively covered in
// `lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts`.
// These tests only verify the workflow body wires the orchestrator
// up with the right inputs.

it("calls autoCommitChatTurn with workflow context after persistAssistantMessage", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
responseMessage: responseMessageWithMetadata,
});

await runAgentWorkflow(baseInput);

expect(autoCommitChatTurn).toHaveBeenCalledTimes(1);
// Workflow spreads `...input, ...result` into the call so any
// future fields are forwarded automatically. Only assert on the
// fields autoCommitChatTurn actually consumes — extra fields
// from input/result are fine to pass through.
expect(autoCommitChatTurn).toHaveBeenCalledWith(
expect.objectContaining({
writable: writableStub,
responseMessage: responseMessageWithMetadata,
finishReason: "stop",
sessionId: "session-1",
sessionTitle: "test session",
repoOwner: "recoupable",
repoName: "api",
sandboxState: { type: "vercel", ...baseInput.agentContext.sandbox.state },
}),
);
});

it("wraps the raw VercelState with `type: 'vercel'` before forwarding", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
responseMessage: responseMessageWithMetadata,
});

await runAgentWorkflow(baseInput);

const call = vi.mocked(autoCommitChatTurn).mock.calls[0]?.[0];
expect(call?.sandboxState).toMatchObject({ type: "vercel" });
});

it("forwards undefined sandboxState when agentContext.sandbox is missing", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
responseMessage: responseMessageWithMetadata,
});

await runAgentWorkflow({
...baseInput,
agentContext: {} as never,
});

const call = vi.mocked(autoCommitChatTurn).mock.calls[0]?.[0];
expect(call?.sandboxState).toBeUndefined();
});

it("does NOT call autoCommitChatTurn when runAgentStep returns no responseMessage", async () => {
vi.mocked(runAgentStep).mockResolvedValue({
finishReason: "stop",
responseMessage: undefined,
});

await runAgentWorkflow(baseInput);

expect(autoCommitChatTurn).not.toHaveBeenCalled();
});
});
});
32 changes: 32 additions & 0 deletions app/lib/workflows/runAgentWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runAgentStep } from "@/app/lib/workflows/runAgentStep";
import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream";
import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage";
import { handleChatCredits } from "@/lib/credits/handleChatCredits";
import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn";
import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata";
import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext";

Expand All @@ -27,6 +28,20 @@ export type RunAgentWorkflowInput = {
*/
accountId: string;
modelId: string;
/**
* Optional chat title — used as context for the auto-commit
* message-generation LLM call.
*/
sessionTitle?: string;
/**
* Repo identifiers from `sessions.repo_owner` / `sessions.repo_name`.
* When BOTH are present and the sandbox is reachable, the workflow
* runs auto-commit after a successful turn (git add → LLM-generated
* commit message → git commit → git push). Either being absent
* skips auto-commit silently.
*/
repoOwner?: string;
repoName?: string;
/**
* JSON-serializable subset of AgentContext that survives the durable
* workflow input. `runAgentStep` attaches the constructed `model`
Expand Down Expand Up @@ -115,6 +130,23 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise<vo
gatewayCostUsd: metadata?.totalMessageCost,
usage: metadata?.totalMessageUsage ?? ZERO_USAGE,
});

// Auto-commit + push after a natural finish. DurableAgentContext
// carries the raw VercelState; the auto-commit helpers operate on
// the discriminated SandboxState union so they can fan out to
// other sandbox backends in the future. Wrap with the
// `type: "vercel"` tag here. All gating + chunk emission +
// persistence lives in `autoCommitChatTurn` so the workflow body
// stays a thin orchestrator.
const sandboxState = input.agentContext.sandbox?.state
? ({ type: "vercel", ...input.agentContext.sandbox.state } as const)
: undefined;
await autoCommitChatTurn({
...input,
...result,
writable,
sandboxState,
});
}
} finally {
// Run two cleanup steps in parallel:
Expand Down
77 changes: 77 additions & 0 deletions lib/chat/__tests__/persistAssistantDataPart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { persistAssistantDataPart } from "@/lib/chat/persistAssistantDataPart";
import { updateChatMessage } from "@/lib/supabase/chat_messages/updateChatMessage";

vi.mock("@/lib/supabase/chat_messages/updateChatMessage", () => ({
updateChatMessage: vi.fn(),
}));

const baseMessage = {
id: "msg_1",
role: "assistant" as const,
parts: [{ type: "text", text: "Hello" }] as never[],
};

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(updateChatMessage).mockResolvedValue({ ok: true });
});

describe("persistAssistantDataPart", () => {
it("merges the part into the message and writes the WHOLE merged message via updateChatMessage", async () => {
const part = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "success" as const },
};

await persistAssistantDataPart(baseMessage, part);

expect(updateChatMessage).toHaveBeenCalledTimes(1);
const [id, written] = vi.mocked(updateChatMessage).mock.calls[0]!;
expect(id).toBe("msg_1");
const message = written as typeof baseMessage;
expect(message.id).toBe("msg_1");
expect(message.role).toBe("assistant");
expect(message.parts).toHaveLength(2);
expect(message.parts[1]).toEqual(part);
});

it("replaces an existing data-part with matching {type, id} (pending → success transition)", async () => {
const messageWithPending = {
...baseMessage,
parts: [
...baseMessage.parts,
{
type: "data-commit",
id: "msg_1:commit",
data: { status: "pending" },
},
],
};
const success = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "success" as const, commitSha: "abc" },
};

await persistAssistantDataPart(messageWithPending, success);

const [, written] = vi.mocked(updateChatMessage).mock.calls[0]!;
const message = written as typeof messageWithPending;
// Still 2 parts (text + commit), not 3
expect(message.parts).toHaveLength(2);
expect(message.parts[1]).toEqual(success);
});

it("does not throw when updateChatMessage rejects (caller decides what to do)", async () => {
vi.mocked(updateChatMessage).mockResolvedValue({ ok: false, error: "boom" });
await expect(
persistAssistantDataPart(baseMessage, {
type: "data-commit",
id: "msg_1:commit",
data: { status: "success" },
}),
).resolves.toBeUndefined();
});
});
81 changes: 81 additions & 0 deletions lib/chat/__tests__/upsertAssistantDataPart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { upsertAssistantDataPart } from "@/lib/chat/upsertAssistantDataPart";

const baseMessage = {
id: "msg_1",
role: "assistant" as const,
parts: [{ type: "text", text: "Hello" }, { type: "step-finish" }] as never[],
};

describe("upsertAssistantDataPart", () => {
it("appends a new part when no part with the same {type, id} exists", () => {
const part = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "pending" as const, committed: false, pushed: false },
};
const result = upsertAssistantDataPart(baseMessage, part);
expect(result.parts).toHaveLength(3);
expect(result.parts[2]).toEqual(part);
});

it("replaces the existing part when {type, id} matches (pending → success)", () => {
const pendingPart = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "pending" as const, committed: false, pushed: false },
};
const successPart = {
type: "data-commit" as const,
id: "msg_1:commit",
data: {
status: "success" as const,
committed: true,
pushed: true,
commitSha: "abc123",
url: "https://github.com/owner/repo/commit/abc123",
},
};
const afterPending = upsertAssistantDataPart(baseMessage, pendingPart);
const afterSuccess = upsertAssistantDataPart(afterPending, successPart);
expect(afterSuccess.parts).toHaveLength(3);
expect(afterSuccess.parts[2]).toEqual(successPart);
});

it("does NOT mutate the input message", () => {
const part = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "pending" as const, committed: false, pushed: false },
};
const result = upsertAssistantDataPart(baseMessage, part);
expect(baseMessage.parts).toHaveLength(2);
expect(result.parts).not.toBe(baseMessage.parts);
});

it("preserves the other message fields (id, role, etc.)", () => {
const part = {
type: "data-commit" as const,
id: "msg_1:commit",
data: { status: "pending" as const, committed: false, pushed: false },
};
const result = upsertAssistantDataPart(baseMessage, part);
expect(result.id).toBe(baseMessage.id);
expect(result.role).toBe(baseMessage.role);
});

it("treats different ids as different parts (appends a second one)", () => {
const partA = {
type: "data-commit" as const,
id: "msg_1:commit-a",
data: { status: "pending" as const, committed: false, pushed: false },
};
const partB = {
type: "data-commit" as const,
id: "msg_1:commit-b",
data: { status: "pending" as const, committed: false, pushed: false },
};
const result = upsertAssistantDataPart(upsertAssistantDataPart(baseMessage, partA), partB);
expect(result.parts).toHaveLength(4);
});
});
Loading
Loading