diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index 19cec3b78..3697c84a9 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -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(), @@ -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(); @@ -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({ @@ -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(); + }); + }); }); diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index 67eb94b24..58431094c 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -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"; @@ -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` @@ -115,6 +130,23 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ + 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(); + }); +}); diff --git a/lib/chat/__tests__/upsertAssistantDataPart.test.ts b/lib/chat/__tests__/upsertAssistantDataPart.test.ts new file mode 100644 index 000000000..28a832407 --- /dev/null +++ b/lib/chat/__tests__/upsertAssistantDataPart.test.ts @@ -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); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts b/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts new file mode 100644 index 000000000..d0ab5ec96 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import { persistAssistantDataPart } from "@/lib/chat/persistAssistantDataPart"; +import type { UIMessageChunk } from "ai"; + +vi.mock("@/lib/chat/auto-commit/hasAutoCommitChanges", () => ({ + hasAutoCommitChanges: vi.fn(), +})); +vi.mock("@/lib/chat/auto-commit/runAutoCommit", () => ({ + runAutoCommit: vi.fn(), +})); +vi.mock("@/lib/chat/auto-commit/sendCommitChunk", () => ({ + sendCommitChunk: vi.fn(), +})); +vi.mock("@/lib/chat/persistAssistantDataPart", () => ({ + persistAssistantDataPart: vi.fn(), +})); + +const writable = new WritableStream(); +const baseMessage = { + id: "asst-msg-1", + role: "assistant" as const, + parts: [{ type: "text", text: "ok" }], +}; + +const baseInput = { + writable, + responseMessage: baseMessage as never, + finishReason: "stop", + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: { type: "vercel" as const, sandboxId: "sb_123" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("autoCommitChatTurn", () => { + describe("gating (canAutoCommit)", () => { + it("skips entirely when finishReason is 'tool-calls' (intermediate turn)", async () => { + await autoCommitChatTurn({ ...baseInput, finishReason: "tool-calls" }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + expect(runAutoCommit).not.toHaveBeenCalled(); + expect(sendCommitChunk).not.toHaveBeenCalled(); + expect(persistAssistantDataPart).not.toHaveBeenCalled(); + }); + + it("skips entirely when repoOwner is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, repoOwner: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + + it("skips entirely when repoName is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, repoName: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + + it("skips entirely when sandboxState is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, sandboxState: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + }); + + describe("no-changes path", () => { + it("checks for changes but emits no chunks and does not persist", async () => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(false); + await autoCommitChatTurn(baseInput); + expect(hasAutoCommitChanges).toHaveBeenCalledTimes(1); + expect(runAutoCommit).not.toHaveBeenCalled(); + expect(sendCommitChunk).not.toHaveBeenCalled(); + expect(persistAssistantDataPart).not.toHaveBeenCalled(); + }); + }); + + describe("happy path", () => { + beforeEach(() => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(true); + vi.mocked(runAutoCommit).mockResolvedValue({ + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + }); + }); + + it("emits pending → resolved chunks and persists the resolved data-commit part", async () => { + await autoCommitChatTurn(baseInput); + + expect(sendCommitChunk).toHaveBeenCalledTimes(2); + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 1, + writable, + "asst-msg-1:commit", + expect.objectContaining({ status: "pending" }), + ); + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 2, + writable, + "asst-msg-1:commit", + expect.objectContaining({ + status: "success", + commitSha: "abc123", + url: "https://github.com/recoupable/api/commit/abc123", + }), + ); + + expect(persistAssistantDataPart).toHaveBeenCalledTimes(1); + const [calledMessage, calledPart] = vi.mocked(persistAssistantDataPart).mock.calls[0]!; + expect((calledMessage as { id: string }).id).toBe("asst-msg-1"); + expect(calledPart).toMatchObject({ + type: "data-commit", + id: "asst-msg-1:commit", + data: { status: "success", commitSha: "abc123" }, + }); + }); + + it("forwards sessionId, sessionTitle, repos, sandboxState to runAutoCommit", async () => { + await autoCommitChatTurn(baseInput); + expect(runAutoCommit).toHaveBeenCalledWith({ + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: baseInput.sandboxState, + }); + }); + + it("defaults sessionTitle to empty string when undefined", async () => { + await autoCommitChatTurn({ ...baseInput, sessionTitle: undefined }); + expect(runAutoCommit).toHaveBeenCalledWith(expect.objectContaining({ sessionTitle: "" })); + }); + }); + + describe("error path (commit failed)", () => { + it("emits resolved chunk with status='error' but does NOT skip persistence", async () => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(true); + vi.mocked(runAutoCommit).mockResolvedValue({ + committed: false, + pushed: false, + error: "Failed to stage changes", + }); + + await autoCommitChatTurn(baseInput); + + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 2, + writable, + "asst-msg-1:commit", + expect.objectContaining({ status: "error", error: "Failed to stage changes" }), + ); + // We still persist the error state so the GitDataPartCard + // renders "Commit failed" on refresh. + expect(persistAssistantDataPart).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/buildCommitData.test.ts b/lib/chat/auto-commit/__tests__/buildCommitData.test.ts new file mode 100644 index 000000000..e7577497d --- /dev/null +++ b/lib/chat/auto-commit/__tests__/buildCommitData.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { buildCommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +describe("buildCommitData", () => { + describe("success path", () => { + it("returns status='success' with the commit url when the commit was pushed", () => { + const data = buildCommitData( + { + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + }, + "recoupable", + "api", + ); + expect(data).toEqual({ + status: "success", + committed: true, + pushed: true, + commitMessage: "feat: thing", + commitSha: "abc123", + url: "https://github.com/recoupable/api/commit/abc123", + }); + }); + + it("omits the url when the commit landed locally but wasn't pushed", () => { + const data = buildCommitData( + { + committed: true, + pushed: false, + commitSha: "abc123", + commitMessage: "feat: thing", + }, + "recoupable", + "api", + ); + expect(data.url).toBeUndefined(); + expect(data.status).toBe("success"); + }); + + it("omits the url when commitSha is missing (paranoia)", () => { + const data = buildCommitData( + { committed: true, pushed: true, commitMessage: "feat: thing" }, + "recoupable", + "api", + ); + expect(data.url).toBeUndefined(); + }); + }); + + describe("error path", () => { + it("returns status='error' with the error message", () => { + const data = buildCommitData( + { + committed: false, + pushed: false, + error: "Failed to stage changes", + }, + "recoupable", + "api", + ); + expect(data).toEqual({ + status: "error", + committed: false, + pushed: false, + commitMessage: undefined, + commitSha: undefined, + url: undefined, + error: "Failed to stage changes", + }); + }); + + it("still includes the url when commit pushed but result was marked error (partial success edge)", () => { + // hypothetical: commit pushed, then a later step set error + const data = buildCommitData( + { + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + error: "post-push hook failed", + }, + "recoupable", + "api", + ); + expect(data.status).toBe("error"); + expect(data.url).toBe("https://github.com/recoupable/api/commit/abc123"); + }); + }); + + describe("skipped path", () => { + it("returns status='skipped' when nothing was committed (no changes)", () => { + const data = buildCommitData({ committed: false, pushed: false }, "recoupable", "api"); + expect(data).toEqual({ + status: "skipped", + committed: false, + pushed: false, + }); + }); + }); + + describe("url encoding", () => { + it("encodes owner / repo / sha in the URL path", () => { + const data = buildCommitData( + { committed: true, pushed: true, commitSha: "abc/def" }, + "owner with space", + "repo+name", + ); + expect(data.url).toBe("https://github.com/owner%20with%20space/repo%2Bname/commit/abc%2Fdef"); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts b/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts new file mode 100644 index 000000000..34e9942d9 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateCommitMessage } from "@/lib/chat/auto-commit/generateCommitMessage"; +import generateText from "@/lib/ai/generateText"; +import type { ExecResult, Sandbox } from "@/lib/sandbox/abstraction"; + +vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn() })); + +const ok = (stdout = "", stderr = ""): ExecResult => ({ + success: true, + exitCode: 0, + stdout, + stderr, + truncated: false, +}); + +function makeSandbox(handlers: Record = {}) { + const exec = vi.fn((cmd: string) => { + for (const [pattern, result] of Object.entries(handlers)) { + if (cmd.includes(pattern)) return Promise.resolve(result); + } + return Promise.resolve(ok()); + }); + return { exec } as unknown as Sandbox; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("generateCommitMessage", () => { + it("returns the LLM-generated message when the gateway succeeds", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "feat: add hello world endpoint", + } as never); + const sandbox = makeSandbox({ + "git diff --cached": ok("some diff content"), + }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "test session"); + expect(msg).toBe("feat: add hello world endpoint"); + }); + + it("falls back to default message when staged diff is empty", async () => { + const sandbox = makeSandbox({ "git diff --cached": ok("") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("falls back to default message when generateText rejects", async () => { + vi.mocked(generateText).mockRejectedValue(new Error("gateway down")); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + }); + + it("truncates the LLM-generated message to 72 chars", async () => { + const longMessage = "feat: " + "x".repeat(200); + vi.mocked(generateText).mockResolvedValue({ text: longMessage } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg.length).toBeLessThanOrEqual(72); + }); + + it("takes only the first line of the LLM output (strips trailing prose)", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "feat: clean header\n\nMore details about the change.", + } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("feat: clean header"); + }); + + it("falls back when LLM returns an empty string", async () => { + vi.mocked(generateText).mockResolvedValue({ text: "" } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts b/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts new file mode 100644 index 000000000..d0e075eb8 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { connectSandbox } from "@/lib/sandbox/factory"; + +vi.mock("@/lib/sandbox/factory", () => ({ + connectSandbox: vi.fn(), +})); + +function buildSandbox(execResult: { success: boolean; stdout: string; stderr?: string }) { + return { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec: vi.fn().mockResolvedValue({ + success: execResult.success, + stdout: execResult.stdout, + stderr: execResult.stderr ?? "", + exitCode: execResult.success ? 0 : 1, + truncated: false, + }), + } as never; +} + +const SANDBOX_STATE = { type: "vercel" as const, sandboxId: "sb_123" } as never; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("hasAutoCommitChanges", () => { + it("returns true when git status --porcelain has output (changes present)", async () => { + vi.mocked(connectSandbox).mockResolvedValue( + buildSandbox({ success: true, stdout: "M file.txt\n" }), + ); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); + + it("returns false when git status --porcelain has empty output (no changes)", async () => { + vi.mocked(connectSandbox).mockResolvedValue(buildSandbox({ success: true, stdout: "" })); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(false); + }); + + it("returns false when git status --porcelain has only whitespace", async () => { + vi.mocked(connectSandbox).mockResolvedValue(buildSandbox({ success: true, stdout: " \n\n" })); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(false); + }); + + it("returns true (fail-open) when git status itself fails — lets runAutoCommit surface the real error", async () => { + vi.mocked(connectSandbox).mockResolvedValue( + buildSandbox({ success: false, stdout: "", stderr: "not a git repo" }), + ); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); + + it("returns true (fail-open) when the sandbox connect rejects — same rationale", async () => { + vi.mocked(connectSandbox).mockRejectedValue(new Error("sandbox gone")); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts b/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts new file mode 100644 index 000000000..8290430c0 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { performAutoCommit } from "@/lib/chat/auto-commit/performAutoCommit"; +import generateText from "@/lib/ai/generateText"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import type { ExecResult, Sandbox } from "@/lib/sandbox/abstraction"; + +vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn() })); +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(), +})); + +const ok = (stdout = "", stderr = ""): ExecResult => ({ + success: true, + exitCode: 0, + stdout, + stderr, + truncated: false, +}); + +const fail = (stdout = "", stderr = ""): ExecResult => ({ + success: false, + exitCode: 1, + stdout, + stderr, + truncated: false, +}); + +function makeSandbox(handlers: Record = {}) { + const exec = vi.fn((cmd: string) => { + for (const [pattern, result] of Object.entries(handlers)) { + if (cmd.includes(pattern)) return Promise.resolve(result); + } + return Promise.resolve(ok()); + }); + const sandbox = { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec, + } as unknown as Sandbox; + return { sandbox, exec }; +} + +const baseParams = { + sessionId: "session-1", + sessionTitle: "test session", + repoOwner: "recoupable", + repoName: "api", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + vi.mocked(generateText).mockResolvedValue({ + text: "feat: add example file", + } as never); +}); + +describe("performAutoCommit", () => { + describe("no changes path", () => { + it("returns { committed:false, pushed:false } when git status is empty", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok(""), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result).toEqual({ committed: false, pushed: false }); + // Should not have called add/commit/push when nothing to stage + expect(exec).not.toHaveBeenCalledWith( + expect.stringContaining("git add -A"), + expect.any(String), + expect.any(Number), + ); + }); + + it("returns { committed:false, pushed:false } when git status itself fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": fail("not a git repo"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result).toEqual({ committed: false, pushed: false }); + }); + }); + + describe("happy path", () => { + it("commits AND pushes when changes are present", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff content"), + "git rev-parse HEAD": ok("abc123def456"), + "git symbolic-ref --short HEAD": ok("feat/branch"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + + expect(result.committed).toBe(true); + expect(result.pushed).toBe(true); + expect(result.commitMessage).toBe("feat: add example file"); + expect(result.commitSha).toBe("abc123def456"); + expect(result.error).toBeUndefined(); + + // Verify command sequence by call ordering + const calls = exec.mock.calls.map(c => c[0]); + const addIdx = calls.findIndex(c => c.includes("git add -A")); + const commitIdx = calls.findIndex(c => c.startsWith("git commit")); + const pushIdx = calls.findIndex(c => c.includes("git push")); + expect(addIdx).toBeGreaterThanOrEqual(0); + expect(commitIdx).toBeGreaterThan(addIdx); + expect(pushIdx).toBeGreaterThan(commitIdx); + }); + + it("pushes the current branch via `git push -u origin `", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("custom-branch"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + expect(exec).toHaveBeenCalledWith( + expect.stringContaining("git push -u origin custom-branch"), + expect.any(String), + expect.any(Number), + ); + }); + + it("disables interactive prompts on push (GIT_TERMINAL_PROMPT=0)", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const pushCall = exec.mock.calls.find(c => c[0].includes("git push")); + expect(pushCall?.[0]).toContain("GIT_TERMINAL_PROMPT=0"); + }); + }); + + describe("error paths", () => { + it("returns { committed:false, pushed:false, error } when git add fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git add -A": fail("permission denied"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toMatch(/stage|add/i); + }); + + it("returns { committed:false, pushed:false, error } when git commit fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git commit": fail("nothing to commit"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("returns { committed:true, pushed:false, error } when push fails after commit succeeds", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("commitsha"), + "git symbolic-ref --short HEAD": ok("main"), + "git push": fail("network unreachable"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(true); + expect(result.pushed).toBe(false); + expect(result.commitSha).toBe("commitsha"); + expect(result.error).toMatch(/push/i); + }); + }); + + describe("commit message generation", () => { + it("uses the LLM-generated commit message on success", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "fix: update header logic", + } as never); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage).toBe("fix: update header logic"); + }); + + it("falls back to default message when generateText rejects", async () => { + vi.mocked(generateText).mockRejectedValue(new Error("gateway down")); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(true); + expect(result.commitMessage).toBe("chore: update repository changes"); + }); + + it("falls back to default message when staged diff is empty (rare race)", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok(""), // empty diff + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage).toBe("chore: update repository changes"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("truncates the LLM-generated message to 72 chars", async () => { + const longMessage = "feat: " + "x".repeat(200); + vi.mocked(generateText).mockResolvedValue({ text: longMessage } as never); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage!.length).toBeLessThanOrEqual(72); + }); + }); + + describe("github auth url", () => { + it("sets `git remote set-url origin` with x-access-token URL when GITHUB_TOKEN is set", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue("ghp_test_token_value"); + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const remoteCall = exec.mock.calls.find(c => c[0].includes("git remote set-url")); + expect(remoteCall?.[0]).toContain("x-access-token:ghp_test_token_value"); + expect(remoteCall?.[0]).toContain("github.com/recoupable/api"); + }); + + it("does NOT touch the remote when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const remoteCall = exec.mock.calls.find(c => c[0].includes("git remote set-url")); + expect(remoteCall).toBeUndefined(); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts b/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts new file mode 100644 index 000000000..16a9dac5b --- /dev/null +++ b/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { performAutoCommit } from "@/lib/chat/auto-commit/performAutoCommit"; + +vi.mock("@/lib/sandbox/factory", () => ({ connectSandbox: vi.fn() })); +vi.mock("@/lib/chat/auto-commit/performAutoCommit", () => ({ + performAutoCommit: vi.fn(), +})); + +const SANDBOX_STATE = { type: "vercel", sandboxId: "sb_123" } as never; + +const baseParams = { + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: SANDBOX_STATE, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runAutoCommit", () => { + it("forwards sandbox + caller fields to performAutoCommit", async () => { + const sandboxStub = { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec: vi.fn(), + } as never; + vi.mocked(connectSandbox).mockResolvedValue(sandboxStub); + vi.mocked(performAutoCommit).mockResolvedValue({ + committed: true, + pushed: true, + commitSha: "abc", + commitMessage: "feat: thing", + }); + + const result = await runAutoCommit(baseParams); + + expect(connectSandbox).toHaveBeenCalledWith(SANDBOX_STATE); + expect(performAutoCommit).toHaveBeenCalledWith({ + sandbox: sandboxStub, + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + }); + expect(result).toEqual({ + committed: true, + pushed: true, + commitSha: "abc", + commitMessage: "feat: thing", + }); + }); + + it("returns the AutoCommitResult unchanged on success", async () => { + vi.mocked(connectSandbox).mockResolvedValue({} as never); + vi.mocked(performAutoCommit).mockResolvedValue({ + committed: false, + pushed: false, + }); + expect(await runAutoCommit(baseParams)).toEqual({ + committed: false, + pushed: false, + }); + }); + + it("returns { committed:false, pushed:false, error } when connectSandbox rejects (does NOT throw)", async () => { + vi.mocked(connectSandbox).mockRejectedValue(new Error("sandbox unreachable")); + const result = await runAutoCommit(baseParams); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toMatch(/sandbox unreachable|auto-commit/i); + }); + + it("returns { committed:false, pushed:false, error } when performAutoCommit rejects", async () => { + vi.mocked(connectSandbox).mockResolvedValue({} as never); + vi.mocked(performAutoCommit).mockRejectedValue(new Error("boom")); + const result = await runAutoCommit(baseParams); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts b/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts new file mode 100644 index 000000000..e42b9b59d --- /dev/null +++ b/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import type { CommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +const PENDING: CommitData = { + status: "skipped", + committed: false, + pushed: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("sendCommitChunk", () => { + it("writes the `data-commit` chunk into the writable with id + data", async () => { + const written: unknown[] = []; + const writable = new WritableStream({ + write(chunk) { + written.push(chunk); + }, + }); + + await sendCommitChunk(writable, "msg_abc:commit", PENDING); + + expect(written).toEqual([{ type: "data-commit", id: "msg_abc:commit", data: PENDING }]); + }); + + it("releases the writer lock after writing so subsequent writes can acquire one", async () => { + const writable = new WritableStream({ write() {} }); + await sendCommitChunk(writable, "id1", PENDING); + // If lock weren't released, this getWriter() would throw + const writer = writable.getWriter(); + expect(writer).toBeDefined(); + writer.releaseLock(); + }); + + it("releases the writer lock even when the underlying write rejects", async () => { + const writable = new WritableStream({ + write() { + throw new Error("sink boom"); + }, + }); + await expect(sendCommitChunk(writable, "id1", PENDING)).rejects.toThrow(/sink boom/); + // Lock should still be released + expect(() => { + const writer = writable.getWriter(); + writer.releaseLock(); + }).not.toThrow(); + }); +}); diff --git a/lib/chat/auto-commit/autoCommitChatTurn.ts b/lib/chat/auto-commit/autoCommitChatTurn.ts new file mode 100644 index 000000000..73c22293d --- /dev/null +++ b/lib/chat/auto-commit/autoCommitChatTurn.ts @@ -0,0 +1,95 @@ +import type { UIMessageChunk } from "ai"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import { buildCommitData } from "@/lib/chat/auto-commit/buildCommitData"; +import { persistAssistantDataPart } from "@/lib/chat/persistAssistantDataPart"; +import type { SandboxState } from "@/lib/sandbox/factory"; + +interface AssistantMessage { + id: string; + role: string; + parts: ReadonlyArray; +} + +export interface AutoCommitChatTurnInput { + writable: WritableStream; + responseMessage: AssistantMessage; + finishReason: string | undefined; + sessionId: string; + sessionTitle?: string; + repoOwner?: string; + repoName?: string; + /** + * Discriminated SandboxState (already wrapped with the `type` tag + * by the caller — DurableAgentContext carries the raw VercelState + * so the workflow body composes the SandboxState before calling + * here). + */ + sandboxState?: SandboxState; +} + +/** + * Runs the full auto-commit flow at the tail of a successful chat + * turn: gates on `canAutoCommit`, checks for sandbox changes, emits + * the `data-commit` chunks (pending → resolved), runs the + * sandbox-side commit + push, and persists the resolved part onto + * the assistant message so the `GitDataPartCard` UI renders on + * page refresh. + * + * Extracted from `runAgentWorkflow` so the workflow body stays a + * thin orchestrator. Mirrors open-agents' + * `apps/web/app/workflows/chat.ts:canAutoCommit` block. + * + * Skips silently when: + * - finishReason is `"tool-calls"` (intermediate turn, not natural finish) + * - either `repoOwner` or `repoName` is missing (no GitHub link to make) + * - `sandboxState` is missing (no sandbox to commit in) + * - `hasAutoCommitChanges` returns false (`git status --porcelain` empty) + * + * Never throws — the inner steps swallow their errors and surface + * the result via `AutoCommitResult.error`, which `buildCommitData` + * shapes into a `status: "error"` chunk that's still persisted so + * the UI shows "Commit failed" on refresh. + */ +export async function autoCommitChatTurn(input: AutoCommitChatTurnInput): Promise { + const canAutoCommit = + input.finishReason !== "tool-calls" && + input.repoOwner !== undefined && + input.repoName !== undefined && + input.sandboxState !== undefined; + if (!canAutoCommit) return; + + const hasChanges = await hasAutoCommitChanges({ + sandboxState: input.sandboxState!, + }); + if (!hasChanges) return; + + const commitPartId = `${input.responseMessage.id}:commit`; + + // Emit the pending chunk BEFORE the commit step so the UI can + // show a spinner while git add/commit/push run. + await sendCommitChunk(input.writable, commitPartId, { + status: "pending", + committed: false, + pushed: false, + }); + + const commitResult = await runAutoCommit({ + sessionId: input.sessionId, + sessionTitle: input.sessionTitle ?? "", + repoOwner: input.repoOwner!, + repoName: input.repoName!, + sandboxState: input.sandboxState!, + }); + const resolvedData = buildCommitData(commitResult, input.repoOwner!, input.repoName!); + await sendCommitChunk(input.writable, commitPartId, resolvedData); + + // Persist the resolved data-commit part onto the assistant + // message so the GitDataPartCard renders on page refresh. + await persistAssistantDataPart(input.responseMessage, { + type: "data-commit", + id: commitPartId, + data: resolvedData, + }); +} diff --git a/lib/chat/auto-commit/buildCommitData.ts b/lib/chat/auto-commit/buildCommitData.ts new file mode 100644 index 000000000..982522132 --- /dev/null +++ b/lib/chat/auto-commit/buildCommitData.ts @@ -0,0 +1,85 @@ +import type { AutoCommitResult } from "@/lib/chat/auto-commit/performAutoCommit"; + +/** + * Data shape carried by the `data-commit` UI chunk emitted from + * `runAgentWorkflow` after auto-commit runs. Mirrors open-agents' + * `WebAgentCommitData` byte-for-byte so the shared chat UI can render + * it without conditional logic on the source surface. + */ +export interface CommitData { + /** + * `"pending"` is set when the workflow emits the initial chunk + * before the commit step runs (so the UI can show a spinner). + * `buildCommitData` itself only ever produces the terminal three + * statuses; pending is constructed inline in `runAgentWorkflow`. + */ + status: "pending" | "success" | "error" | "skipped"; + committed: boolean; + pushed: boolean; + commitMessage?: string; + commitSha?: string; + /** + * GitHub commit URL. Set only when the commit was both committed + * AND pushed AND has a SHA — i.e., the link will actually resolve + * on GitHub. + */ + url?: string; + error?: string; +} + +function buildGitHubCommitUrl(repoOwner: string, repoName: string, commitSha: string): string { + return `https://github.com/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(commitSha)}`; +} + +/** + * Shapes an `AutoCommitResult` into the UI chunk payload. + * + * Resolution order: + * - `result.error` set → `status: "error"` (preserves any commit/push + * metadata that landed so the UI can still link to the partial + * result) + * - `result.committed` → `status: "success"` + * - otherwise → `status: "skipped"` + * + * Mirrors open-agents' `apps/web/app/workflows/chat.ts:buildCommitData`. + */ +export function buildCommitData( + result: AutoCommitResult, + repoOwner: string, + repoName: string, +): CommitData { + if (result.error) { + return { + status: "error", + committed: result.committed, + pushed: result.pushed, + commitMessage: result.commitMessage, + commitSha: result.commitSha, + url: + result.pushed && result.commitSha + ? buildGitHubCommitUrl(repoOwner, repoName, result.commitSha) + : undefined, + error: result.error, + }; + } + + if (result.committed) { + return { + status: "success", + committed: result.committed, + pushed: result.pushed, + commitMessage: result.commitMessage, + commitSha: result.commitSha, + url: + result.pushed && result.commitSha + ? buildGitHubCommitUrl(repoOwner, repoName, result.commitSha) + : undefined, + }; + } + + return { + status: "skipped", + committed: false, + pushed: false, + }; +} diff --git a/lib/chat/auto-commit/generateCommitMessage.ts b/lib/chat/auto-commit/generateCommitMessage.ts new file mode 100644 index 000000000..93c3af1d1 --- /dev/null +++ b/lib/chat/auto-commit/generateCommitMessage.ts @@ -0,0 +1,62 @@ +import type { Sandbox } from "@/lib/sandbox/abstraction"; +import generateText from "@/lib/ai/generateText"; + +const FALLBACK_COMMIT_MESSAGE = "chore: update repository changes"; +const COMMIT_MESSAGE_MAX_LENGTH = 72; +const DIFF_PROMPT_TRUNCATE_CHARS = 8000; +const TIMEOUT_DIFF_MS = 30_000; + +/** + * Asks the gateway to produce a conventional-commit-formatted + * message describing the staged diff. Falls back to a sane default + * when: + * - the staged diff is empty (rare race; the caller has already + * verified there's something to commit, but `git diff --cached` + * can race with the actual stage) + * - the gateway call throws + * - the gateway returns an empty/whitespace string + * + * Truncates to 72 chars to fit standard commit-message-line width. + * Only the first line of the LLM output is used — the prompt asks + * for a single line but models sometimes follow with body text. + * + * Ports the inline `generateCommitMessage` helper that previously + * lived inside `performAutoCommit.ts`. Extracting it lets callers + * compose alternative commit message strategies without re-running + * the full performAutoCommit sandbox pipeline. + */ +export async function generateCommitMessage( + sandbox: Sandbox, + cwd: string, + sessionTitle: string, +): Promise { + try { + const stagedDiffResult = await sandbox.exec("git diff --cached", cwd, TIMEOUT_DIFF_MS); + const diffForCommit = stagedDiffResult.stdout; + + if (!diffForCommit.trim()) { + return FALLBACK_COMMIT_MESSAGE; + } + + const result = await generateText({ + model: "openai/gpt-5.4-nano", + prompt: `Generate a concise git commit message for these changes. Use conventional commit format (e.g., "feat:", "fix:", "refactor:"). One line only, max ${COMMIT_MESSAGE_MAX_LENGTH} characters. + +Session context: ${sessionTitle} + +Diff: +${diffForCommit.slice(0, DIFF_PROMPT_TRUNCATE_CHARS)} + +Respond with ONLY the commit message, nothing else.`, + }); + + const generated = result.text.trim().split("\n")[0]?.trim(); + if (generated && generated.length > 0) { + return generated.slice(0, COMMIT_MESSAGE_MAX_LENGTH); + } + } catch (error) { + console.warn("[auto-commit] Failed to generate commit message:", error); + } + + return FALLBACK_COMMIT_MESSAGE; +} diff --git a/lib/chat/auto-commit/hasAutoCommitChanges.ts b/lib/chat/auto-commit/hasAutoCommitChanges.ts new file mode 100644 index 000000000..bc2b86e05 --- /dev/null +++ b/lib/chat/auto-commit/hasAutoCommitChanges.ts @@ -0,0 +1,47 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; + +const STATUS_TIMEOUT_MS = 10_000; + +/** + * Quick pre-flight: does the sandbox have anything staged-or-unstaged + * that auto-commit should pick up? Returns true when `git status + * --porcelain` reports any output. + * + * Failure mode: fail-open. If the sandbox connect or the git command + * itself fails, return `true` and let `runAutoCommit` try anyway — + * it will surface the real error in its own `AutoCommitResult`. The + * alternative (returning false on error) would silently skip + * auto-commit on a transient sandbox blip, which we don't want. + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat-post-finish.ts:hasAutoCommitChangesStep`. + */ +export async function hasAutoCommitChanges(params: { + sandboxState: SandboxState; +}): Promise { + "use step"; + console.log("[hasAutoCommitChanges] enter"); + try { + const sandbox = await connectSandbox(params.sandboxState); + const statusResult = await sandbox.exec( + "git status --porcelain", + sandbox.workingDirectory, + STATUS_TIMEOUT_MS, + ); + + if (!statusResult.success) { + console.warn( + "[hasAutoCommitChanges] git status failed; assuming changes present (fail-open)", + { stderr: statusResult.stderr }, + ); + return true; + } + + const hasChanges = statusResult.stdout.trim().length > 0; + console.log("[hasAutoCommitChanges] done", { hasChanges }); + return hasChanges; + } catch (error) { + console.error("[hasAutoCommitChanges] unexpected error (failing open):", error); + return true; + } +} diff --git a/lib/chat/auto-commit/performAutoCommit.ts b/lib/chat/auto-commit/performAutoCommit.ts new file mode 100644 index 000000000..94c918feb --- /dev/null +++ b/lib/chat/auto-commit/performAutoCommit.ts @@ -0,0 +1,128 @@ +import type { Sandbox } from "@/lib/sandbox/abstraction"; +import { generateCommitMessage } from "@/lib/chat/auto-commit/generateCommitMessage"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +export interface AutoCommitParams { + sandbox: Sandbox; + sessionId: string; + sessionTitle: string; + repoOwner: string; + repoName: string; +} + +export interface AutoCommitResult { + committed: boolean; + pushed: boolean; + commitMessage?: string; + commitSha?: string; + error?: string; +} + +const TIMEOUT_QUICK_MS = 10_000; +const TIMEOUT_PUSH_MS = 60_000; +const TIMEOUT_RESOLVE_MS = 5_000; + +/** + * Stages all changes in the sandbox, generates a commit message via + * the gateway, commits, and pushes. Ports + * `open-agents/apps/web/lib/chat/auto-commit-direct.ts:performAutoCommit`. + * + * Failure modes are deliberately granular so the caller can surface + * the right UI state: + * - `{ committed: false, pushed: false }` when nothing to commit + * (git status empty, or git status itself failed) + * - `{ committed: false, pushed: false, error }` when stage/commit + * fails + * - `{ committed: true, pushed: false, error }` when the commit + * landed locally but the push failed (caller should retry or + * surface the local sha so the user knows the changes aren't + * remote yet) + * + * Auth: if `GITHUB_TOKEN` is set (the service-account token used to + * clone), the function rewrites `origin` to an x-access-token URL so + * the subsequent push authenticates. When the token is absent the + * remote URL is left alone (public repos / pre-authed remotes still + * work). + */ +export async function performAutoCommit(params: AutoCommitParams): Promise { + const { sandbox, sessionTitle, repoOwner, repoName } = params; + const cwd = sandbox.workingDirectory; + + // 1. Check for uncommitted changes + const statusResult = await sandbox.exec("git status --porcelain", cwd, TIMEOUT_QUICK_MS); + if (!statusResult.success || !statusResult.stdout.trim()) { + return { committed: false, pushed: false }; + } + + // 2. Configure auth on origin so the push can authenticate. + const token = getServiceGithubToken(); + if (token) { + const authUrl = `https://x-access-token:${token}@github.com/${repoOwner}/${repoName}.git`; + await sandbox.exec(`git remote set-url origin "${authUrl}"`, cwd, TIMEOUT_QUICK_MS); + } + + // 3. Stage all changes + const addResult = await sandbox.exec("git add -A", cwd, TIMEOUT_QUICK_MS); + if (!addResult.success) { + return { + committed: false, + pushed: false, + error: "Failed to stage changes", + }; + } + + // 4. Generate commit message (LLM-generated from staged diff, with + // a sane fallback when the gateway fails or the diff is empty). + const commitMessage = await generateCommitMessage(sandbox, cwd, sessionTitle); + + // 5. Commit. Single-quote escaping mirrors open-agents' + // auto-commit-direct so messages containing apostrophes don't + // break the shell. + const escapedMessage = commitMessage.replace(/'/g, "'\\''"); + const commitResult = await sandbox.exec( + `git commit -m '${escapedMessage}'`, + cwd, + TIMEOUT_QUICK_MS, + ); + if (!commitResult.success) { + return { + committed: false, + pushed: false, + error: `Failed to commit: ${commitResult.stdout || commitResult.stderr}`, + }; + } + + const headResult = await sandbox.exec("git rev-parse HEAD", cwd, TIMEOUT_RESOLVE_MS); + const commitSha = headResult.stdout.trim() || undefined; + + // 6. Push. GIT_TERMINAL_PROMPT=0 so a missing/expired token fails + // fast instead of hanging on a credential prompt the workflow + // runtime can't answer. + const branchResult = await sandbox.exec("git symbolic-ref --short HEAD", cwd, TIMEOUT_RESOLVE_MS); + const currentBranch = branchResult.stdout.trim() || "HEAD"; + + const pushResult = await sandbox.exec( + `GIT_TERMINAL_PROMPT=0 git push -u origin ${currentBranch}`, + cwd, + TIMEOUT_PUSH_MS, + ); + if (!pushResult.success) { + console.warn( + `[auto-commit] Push failed for session ${params.sessionId}: ${pushResult.stderr || pushResult.stdout}`, + ); + return { + committed: true, + pushed: false, + commitMessage, + commitSha, + error: "Commit succeeded but push failed", + }; + } + + return { + committed: true, + pushed: true, + commitMessage, + commitSha, + }; +} diff --git a/lib/chat/auto-commit/runAutoCommit.ts b/lib/chat/auto-commit/runAutoCommit.ts new file mode 100644 index 000000000..ca2b73975 --- /dev/null +++ b/lib/chat/auto-commit/runAutoCommit.ts @@ -0,0 +1,50 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; +import { performAutoCommit, type AutoCommitResult } from "@/lib/chat/auto-commit/performAutoCommit"; + +/** + * Workflow step that runs auto-commit + push in the sandbox. Wraps + * `performAutoCommit` with sandbox connect + global error handling so + * the chat workflow never aborts on a commit-time hiccup — failures + * land in `AutoCommitResult.error`, which the caller surfaces via the + * `data-commit` UI chunk. + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat-post-finish.ts:runAutoCommitStep`. + */ +export async function runAutoCommit(params: { + sessionId: string; + sessionTitle: string; + repoOwner: string; + repoName: string; + sandboxState: SandboxState; +}): Promise { + "use step"; + console.log("[runAutoCommit] enter", { + sessionId: params.sessionId, + repoOwner: params.repoOwner, + repoName: params.repoName, + }); + try { + const sandbox = await connectSandbox(params.sandboxState); + const result = await performAutoCommit({ + sandbox, + sessionId: params.sessionId, + sessionTitle: params.sessionTitle, + repoOwner: params.repoOwner, + repoName: params.repoName, + }); + console.log("[runAutoCommit] done", { + committed: result.committed, + pushed: result.pushed, + hasError: result.error !== undefined, + }); + return result; + } catch (error) { + console.error("[runAutoCommit] unexpected error:", error); + return { + committed: false, + pushed: false, + error: error instanceof Error ? error.message : "Auto-commit failed", + }; + } +} diff --git a/lib/chat/auto-commit/sendCommitChunk.ts b/lib/chat/auto-commit/sendCommitChunk.ts new file mode 100644 index 000000000..b0b60e954 --- /dev/null +++ b/lib/chat/auto-commit/sendCommitChunk.ts @@ -0,0 +1,25 @@ +import type { UIMessageChunk } from "ai"; +import type { CommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +/** + * Emits a `data-commit` UIMessageChunk into the chat workflow's + * writable. Wrapped as a `"use step"` so the chunk flushes durably to + * the SSE client before the workflow moves on (e.g., the "pending" + * status appears in the UI before `runAutoCommit` starts). + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat.ts:sendDataPart`. + */ +export async function sendCommitChunk( + writable: WritableStream, + id: string, + data: CommitData, +): Promise { + "use step"; + const writer = writable.getWriter(); + try { + await writer.write({ type: "data-commit", id, data } as UIMessageChunk); + } finally { + writer.releaseLock(); + } +} diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 27961b126..0e7af2f3e 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -14,6 +14,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; import { extractOrgId } from "@/lib/recoupable/extractOrgId"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; @@ -117,6 +118,13 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise; +} + +/** + * Workflow-step wrapper that merges a data-part into an assistant + * message and persists the merged message to `chat_messages.parts`. + * + * Carries the `"use step"` directive — the underlying + * `updateChatMessage` Supabase helper is intentionally kept pure so + * the step boundary lives in the chat-domain layer (this file) + * rather than the supabase layer. Mirrors the pattern used by + * `persistAssistantMessage` + `upsertChatMessage`. + * + * Use this when a `data-*` chunk lands AFTER the initial + * `persistAssistantMessage` call already wrote the message — e.g., + * the auto-commit flow's resolved `data-commit` chunk that needs to + * survive page refresh. + * + * Errors from the underlying supabase call are surfaced as logs; + * this function never throws so a transient DB blip can't mark the + * chat workflow run failed. + */ +export async function persistAssistantDataPart( + message: AssistantMessage, + part: DataPart, +): Promise { + "use step"; + console.log("[persistAssistantDataPart] enter", { + messageId: message.id, + partType: part.type, + partId: part.id, + }); + const merged = upsertAssistantDataPart(message, part); + const result = await updateChatMessage(merged.id, merged); + if (!result.ok) { + console.error("[persistAssistantDataPart] update failed:", result.error); + return; + } + console.log("[persistAssistantDataPart] persisted", { + messageId: merged.id, + partCount: merged.parts.length, + }); +} diff --git a/lib/chat/upsertAssistantDataPart.ts b/lib/chat/upsertAssistantDataPart.ts new file mode 100644 index 000000000..4516ba458 --- /dev/null +++ b/lib/chat/upsertAssistantDataPart.ts @@ -0,0 +1,44 @@ +interface DataPart { + type: string; + id: string; + data: unknown; +} + +interface AssistantMessage { + id: string; + role: string; + parts: ReadonlyArray; +} + +/** + * Returns a new assistant message with `part` merged into `parts`: + * - replaces the existing part when one with the same `{type, id}` + * is already present (e.g. pending → success transition for the + * same `data-commit` part) + * - appends otherwise + * + * Pure helper — the input message is not mutated. Mirrors + * open-agents' `upsertAssistantDataPart` in + * `apps/web/app/workflows/chat.ts`. + * + * Used by the auto-commit branch in `runAgentWorkflow` to persist the + * resolved data-commit chunk onto the assistant message so the + * `GitDataPartCard` UI renders on page refresh (not just during the + * live SSE stream). + */ +export function upsertAssistantDataPart( + message: TMessage, + part: DataPart, +): TMessage { + const nextParts = [...message.parts]; + const existingIndex = nextParts.findIndex(p => { + const candidate = p as { type?: string; id?: string }; + return candidate.type === part.type && candidate.id === part.id; + }); + if (existingIndex >= 0) { + nextParts[existingIndex] = part; + } else { + nextParts.push(part); + } + return { ...message, parts: nextParts }; +} diff --git a/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts b/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts new file mode 100644 index 000000000..72d6ded0d --- /dev/null +++ b/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; + +describe("parseGitHubRepoIdentifiers", () => { + it("parses owner + repo from a plain https URL", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/Myco-WTF/Sweetman")).toEqual({ + owner: "Myco-WTF", + repo: "Sweetman", + }); + }); + + it("strips a trailing .git suffix", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/recoupable/api.git")).toEqual({ + owner: "recoupable", + repo: "api", + }); + }); + + it("strips a trailing slash", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/recoupable/api/")).toEqual({ + owner: "recoupable", + repo: "api", + }); + }); + + it("accepts ssh-style URLs (git@github.com:owner/repo.git)", () => { + expect(parseGitHubRepoIdentifiers("git@github.com:Myco-WTF/Sweetman.git")).toEqual({ + owner: "Myco-WTF", + repo: "Sweetman", + }); + }); + + it("returns null for non-github URLs", () => { + expect(parseGitHubRepoIdentifiers("https://gitlab.com/owner/repo")).toBeNull(); + }); + + it("returns null for the empty string", () => { + expect(parseGitHubRepoIdentifiers("")).toBeNull(); + }); + + it("returns null for an https URL missing the repo segment", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/owner")).toBeNull(); + }); + + it("returns null when input is null/undefined", () => { + expect(parseGitHubRepoIdentifiers(null)).toBeNull(); + expect(parseGitHubRepoIdentifiers(undefined)).toBeNull(); + }); +}); diff --git a/lib/github/parseGitHubRepoIdentifiers.ts b/lib/github/parseGitHubRepoIdentifiers.ts new file mode 100644 index 000000000..eb05bf925 --- /dev/null +++ b/lib/github/parseGitHubRepoIdentifiers.ts @@ -0,0 +1,32 @@ +/** + * Parses a clone URL into `{ owner, repo }`. Used as a fallback when + * a session row was created before the api started persisting + * `repo_owner` / `repo_name` directly — auto-commit needs both to + * compose the GitHub commit URL and to set the remote auth URL on + * push. Returns `null` for non-GitHub URLs and for malformed inputs. + * + * Recognized shapes: + * - `https://github.com//` + * - `https://github.com//.git` + * - `git@github.com:/.git` + * - trailing slashes are tolerated + */ +export function parseGitHubRepoIdentifiers( + cloneUrl: string | null | undefined, +): { owner: string; repo: string } | null { + if (!cloneUrl) return null; + + // ssh form: git@github.com:owner/repo[.git] + const sshMatch = cloneUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (sshMatch) { + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; + } + + // https form: https://github.com/owner/repo[.git][/] + const httpsMatch = cloneUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (httpsMatch) { + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; + } + + return null; +} diff --git a/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts b/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts new file mode 100644 index 000000000..95735ec18 --- /dev/null +++ b/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { updateChatMessage } from "@/lib/supabase/chat_messages/updateChatMessage"; + +const { fromMock, updateMock, eqMock } = vi.hoisted(() => { + const updateMock = vi.fn(); + const eqMock = vi.fn(); + const fromMock = vi.fn(); + return { fromMock, updateMock, eqMock }; +}); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { from: fromMock }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + // chained builder: from(...).update(...).eq(...) → Promise<{error}> + eqMock.mockResolvedValue({ error: null }); + updateMock.mockReturnValue({ eq: eqMock }); + fromMock.mockReturnValue({ update: updateMock }); +}); + +describe("updateChatMessage", () => { + it("UPDATEs the `parts` column on chat_messages keyed by id", async () => { + const parts = [ + { type: "text", text: "hi" }, + { type: "data-commit", id: "x", data: { status: "success" } }, + ]; + const result = await updateChatMessage("msg_abc", parts); + + expect(fromMock).toHaveBeenCalledWith("chat_messages"); + expect(updateMock).toHaveBeenCalledWith({ parts }); + expect(eqMock).toHaveBeenCalledWith("id", "msg_abc"); + expect(result).toEqual({ ok: true }); + }); + + it("returns { ok: false, error } when supabase reports an error (does NOT throw)", async () => { + eqMock.mockResolvedValue({ error: { message: "boom" } }); + const result = await updateChatMessage("msg_abc", []); + expect(result).toEqual({ ok: false, error: "boom" }); + }); + + it("returns { ok: false, error } when the supabase client itself rejects", async () => { + eqMock.mockRejectedValue(new Error("network blip")); + const result = await updateChatMessage("msg_abc", []); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("network blip"); + }); +}); diff --git a/lib/supabase/chat_messages/updateChatMessage.ts b/lib/supabase/chat_messages/updateChatMessage.ts new file mode 100644 index 000000000..8f201db10 --- /dev/null +++ b/lib/supabase/chat_messages/updateChatMessage.ts @@ -0,0 +1,58 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Json } from "@/types/database.types"; + +export interface UpdateChatMessageResult { + ok: boolean; + /** + * Present only when `ok === false`. Kept optional rather than as a + * discriminated union so callers can read `result.error` without + * tripping a narrowing edge case in the Next.js 16 type checker + * (same pattern as `DeductCreditsWithAuditResult`). + */ + error?: string; +} + +/** + * Replaces the `parts` jsonb column on an existing `chat_messages` + * row. UPDATE-only — does NOT insert if the row is missing, so the + * caller must have already persisted the message via + * `upsertChatMessage` first. + * + * Pure supabase wrapper — kept free of any workflow concerns + * (no `"use step"` directive here). Callers that need durable step + * semantics wrap this in a `"use step"` function in `lib/chat/` + * (e.g. `persistAssistantDataPart`). Keeping the step boundary out + * of the supabase layer matches the rest of the codebase + * (`upsertChatMessage`, `updateChat`, etc.) — only domain wrappers + * are step-bound, supabase wrappers stay pure. + * + * Use this when a chunk lands AFTER the initial assistant message + * was already persisted (auto-commit's `data-commit`, future PR data + * parts, etc.) and you need the chunk to survive page refresh. The + * existing `upsertChatMessage` uses `onConflict: "id", + * ignoreDuplicates: true` so a second call would be a no-op — this + * helper exists specifically to bypass that. + * + * Mirrors the UPDATE branch in open-agents' + * `apps/web/lib/db/sessions.ts:upsertChatMessageScoped` (which uses + * a single INSERT-then-UPDATE atomic helper; api keeps the two paths + * separate so the first-insert path remains replay-idempotent). + */ +export async function updateChatMessage( + id: string, + parts: unknown, +): Promise { + try { + const { error } = await supabase + .from("chat_messages") + .update({ parts: parts as Json }) + .eq("id", id); + if (error) return { ok: false, error: error.message }; + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +}