Unified · Split
| @@ -5,16 +5,16 @@ · 4 unmodified lines | ||
| 5 | 5 | "scripts": { |
| 6 | 6 | "with-env": "bun run --env-file=../../.env --", |
| 7 | 7 | "with-env:prod": "bun run --env-file=../../.env.production --", |
| 8 | − "dev": "bun with-env bun run src/index.ts", | |
| 8 | + "dev": "bun with-env bun run --hot src/index.ts", | |
| 9 | 9 | "start": "bun with-env bun run src/index.ts", |
| 10 | 10 | "start:prod": "bun with-env:prod bun run src/index.ts", |
| 11 | 11 | "typecheck": "tsgo --noEmit --incremental false -p tsconfig.json", |
| 12 | 12 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | + "@ai-sdk/anthropic": "^2.0.0", | |
| 15 | 16 | "@openassistant/core": "workspace:*", |
| 16 | 17 | "ai": "^6.0.73", |
| 17 | − "ai-sdk-provider-claude-code": "^3.3.6", | |
| 18 | 18 | "discord.js": "^14.17.3", |
| 19 | 19 | "effect": "latest", |
| 20 | 20 | "zod": "^4.3.6" |
| @@ -0,0 +1,105 @@ | ||
| 1 | +import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { runAgentLoop } from "./agent-loop.js"; | |
| 4 | + | |
| 5 | +describe("runAgentLoop", () => { | |
| 6 | + it("tracks three code runs when claude tool loop executes run_code three times", async () => { | |
| 7 | + let runCount = 0; | |
| 8 | + | |
| 9 | + const result = await runAgentLoop( | |
| 10 | + "please schedule dinner, lunch, and breakfast", | |
| 11 | + async (_code) => { | |
| 12 | + runCount += 1; | |
| 13 | + return succeededRun(runCount); | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + runWithClaude: async ({ executeCode }) => { | |
| 17 | + await executeCode({ code: "await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });" }); | |
| 18 | + await executeCode({ code: "await tools.calendar.update({ title: 'Lunch', startsAt: '2026-02-08T14:00:00.000Z' });" }); | |
| 19 | + await executeCode({ code: "await tools.calendar.update({ title: 'Breakfast', startsAt: '2026-02-09T03:00:00.000Z' });" }); | |
| 20 | + return { | |
| 21 | + text: "Added 3 calendar events.", | |
| 22 | + modelID: "claude-3-7-sonnet-latest", | |
| 23 | + authSource: "test", | |
| 24 | + }; | |
| 25 | + }, | |
| 26 | + }, | |
| 27 | + ); | |
| 28 | + | |
| 29 | + expect(result.provider).toBe("claude"); | |
| 30 | + expect(result.text).toContain("3 calendar events"); | |
| 31 | + expect(result.runs).toHaveLength(3); | |
| 32 | + expect(runCount).toBe(3); | |
| 33 | + expect(result.runs.flatMap((run) => run.result.receipts)).toHaveLength(3); | |
| 34 | + expect(result.planner).toContain("3 code runs"); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it("returns a failure response when claude generation fails", async () => { | |
| 38 | + const codeRuns: string[] = []; | |
| 39 | + | |
| 40 | + const result = await runAgentLoop( | |
| 41 | + "update my calendar to put dinner with ella at 5 pm", | |
| 42 | + async (code) => { | |
| 43 | + codeRuns.push(code); | |
| 44 | + return succeededRun(1); | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + runWithClaude: async () => { | |
| 48 | + throw new Error("claude unavailable"); | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | + ); | |
| 52 | + | |
| 53 | + expect(result.provider).toBe("claude"); | |
| 54 | + expect(result.planner).toContain("failed before completion"); | |
| 55 | + expect(result.text).toContain("claude unavailable"); | |
| 56 | + expect(result.runs).toHaveLength(0); | |
| 57 | + expect(codeRuns).toHaveLength(0); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("allows a zero-tool response when claude does not call run_code", async () => { | |
| 61 | + const codeRuns: string[] = []; | |
| 62 | + | |
| 63 | + const result = await runAgentLoop( | |
| 64 | + "Please add dinner at 5pm tomorrow to my calendar", | |
| 65 | + async (code) => { | |
| 66 | + codeRuns.push(code); | |
| 67 | + return succeededRun(1); | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + runWithClaude: async () => ({ | |
| 71 | + text: "I added it.", | |
| 72 | + modelID: "claude-3-7-sonnet-latest", | |
| 73 | + authSource: "test", | |
| 74 | + }), | |
| 75 | + }, | |
| 76 | + ); | |
| 77 | + | |
| 78 | + expect(result.provider).toBe("claude"); | |
| 79 | + expect(result.runs).toHaveLength(0); | |
| 80 | + expect(codeRuns).toHaveLength(0); | |
| 81 | + expect(result.planner).toContain("0 code runs"); | |
| 82 | + expect(result.text).toBe("I added it."); | |
| 83 | + }); | |
| 84 | +}); | |
| 85 | + | |
| 86 | +function succeededRun(index: number): CodeModeRunResult { | |
| 87 | + const receipts: ToolCallReceipt[] = [ | |
| 88 | + { | |
| 89 | + callId: `call_${index}`, | |
| 90 | + toolPath: "calendar.update", | |
| 91 | + kind: "write", | |
| 92 | + approval: "required", | |
| 93 | + decision: "approved", | |
| 94 | + status: "succeeded", | |
| 95 | + timestamp: `2026-02-06T0${index}:00:00.000Z`, | |
| 96 | + inputPreview: `Event ${index}`, | |
| 97 | + }, | |
| 98 | + ]; | |
| 99 | + | |
| 100 | + return { | |
| 101 | + ok: true, | |
| 102 | + value: { id: `evt_${index}` }, | |
| 103 | + receipts, | |
| 104 | + }; | |
| 105 | +} | |
| @@ -0,0 +1,179 @@ | ||
| 1 | +import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; | |
| 2 | +import { generateText, stepCountIs, tool } from "ai"; | |
| 3 | +import { getAnthropicModel } from "./anthropic-provider.js"; | |
| 4 | +import { z } from "zod"; | |
| 5 | + | |
| 6 | +export interface AgentCodeRun { | |
| 7 | + code: string; | |
| 8 | + result: CodeModeRunResult; | |
| 9 | + reason?: string | undefined; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface AgentLoopResult { | |
| 13 | + provider: "claude"; | |
| 14 | + planner: string; | |
| 15 | + text: string; | |
| 16 | + runs: AgentCodeRun[]; | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface RunCodeToolInput { | |
| 20 | + code: string; | |
| 21 | + reason?: string | undefined; | |
| 22 | +} | |
| 23 | + | |
| 24 | +interface RunCodeToolOutput { | |
| 25 | + ok: boolean; | |
| 26 | + receipts: ToolCallReceipt[]; | |
| 27 | + value?: unknown; | |
| 28 | + error?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +interface RunWithClaudeInput { | |
| 32 | + prompt: string; | |
| 33 | + now: Date; | |
| 34 | + executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +interface RunWithClaudeOutput { | |
| 38 | + text: string; | |
| 39 | + modelID: string; | |
| 40 | + authSource: string; | |
| 41 | +} | |
| 42 | + | |
| 43 | +interface AgentLoopOptions { | |
| 44 | + now?: Date; | |
| 45 | + runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const DEFAULT_ANTHROPIC_MODEL = | |
| 49 | + readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ?? | |
| 50 | + readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ?? | |
| 51 | + "claude-3-7-sonnet-latest"; | |
| 52 | +const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000); | |
| 53 | +const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8); | |
| 54 | + | |
| 55 | +const RUN_CODE_TOOL_SCHEMA = z.object({ | |
| 56 | + code: z.string().min(1), | |
| 57 | + reason: z.string().min(1).optional(), | |
| 58 | +}); | |
| 59 | + | |
| 60 | +export async function runAgentLoop( | |
| 61 | + prompt: string, | |
| 62 | + runCode: (code: string) => Promise<CodeModeRunResult>, | |
| 63 | + options: AgentLoopOptions = {}, | |
| 64 | +): Promise<AgentLoopResult> { | |
| 65 | + const now = options.now ?? new Date(); | |
| 66 | + const runs: AgentCodeRun[] = []; | |
| 67 | + | |
| 68 | + const executeCode = async (input: RunCodeToolInput): Promise<RunCodeToolOutput> => { | |
| 69 | + const result = await runCode(input.code); | |
| 70 | + runs.push({ | |
| 71 | + code: input.code, | |
| 72 | + result, | |
| 73 | + ...(input.reason ? { reason: input.reason } : {}), | |
| 74 | + }); | |
| 75 | + | |
| 76 | + if (result.ok) { | |
| 77 | + return { | |
| 78 | + ok: true, | |
| 79 | + value: result.value, | |
| 80 | + receipts: result.receipts, | |
| 81 | + }; | |
| 82 | + } | |
| 83 | + | |
| 84 | + return { | |
| 85 | + ok: false, | |
| 86 | + error: result.error, | |
| 87 | + receipts: result.receipts, | |
| 88 | + }; | |
| 89 | + }; | |
| 90 | + | |
| 91 | + try { | |
| 92 | + const generated = await (options.runWithClaude ?? runWithClaude)({ | |
| 93 | + prompt, | |
| 94 | + now, | |
| 95 | + executeCode, | |
| 96 | + }); | |
| 97 | + | |
| 98 | + return { | |
| 99 | + provider: "claude", | |
| 100 | + planner: `Claude tool-loop (${runs.length} code run${runs.length === 1 ? "" : "s"}, model=${generated.modelID}, auth=${generated.authSource}).`, | |
| 101 | + text: generated.text, | |
| 102 | + runs, | |
| 103 | + }; | |
| 104 | + } catch (error) { | |
| 105 | + return { | |
| 106 | + provider: "claude", | |
| 107 | + planner: `Claude tool-loop failed before completion (model=${DEFAULT_ANTHROPIC_MODEL}).`, | |
| 108 | + text: `Agent loop failed: ${describeUnknown(error)}. No actions were executed.`, | |
| 109 | + runs, | |
| 110 | + }; | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> { | |
| 115 | + const abortController = new AbortController(); | |
| 116 | + const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS); | |
| 117 | + | |
| 118 | + try { | |
| 119 | + const { model, authSource, authMode } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | |
| 120 | + const result = await generateText({ | |
| 121 | + model, | |
| 122 | + temperature: 0, | |
| 123 | + stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | |
| 124 | + prompt: buildAgentPrompt(input.prompt, input.now), | |
| 125 | + system: buildSystemPrompt(authMode), | |
| 126 | + tools: { | |
| 127 | + run_code: tool({ | |
| 128 | + description: | |
| 129 | + "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | |
| 130 | + inputSchema: RUN_CODE_TOOL_SCHEMA, | |
| 131 | + execute: (args) => input.executeCode(args), | |
| 132 | + }), | |
| 133 | + }, | |
| 134 | + abortSignal: abortController.signal, | |
| 135 | + }); | |
| 136 | + | |
| 137 | + return { | |
| 138 | + text: result.text.trim() || "Done.", | |
| 139 | + modelID: DEFAULT_ANTHROPIC_MODEL, | |
| 140 | + authSource, | |
| 141 | + }; | |
| 142 | + } finally { | |
| 143 | + clearTimeout(timeout); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +function buildSystemPrompt(authMode: "api" | "oauth"): string { | |
| 148 | + if (authMode === "oauth") { | |
| 149 | + return "You are Claude Code, Anthropic's official CLI for Claude."; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return [ | |
| 153 | + "You are OpenAssistant. Execute work via the run_code tool and then report what happened.", | |
| 154 | + "In your final response, summarize relevant tool activity and mention failures/denials clearly.", | |
| 155 | + ].join("\n"); | |
| 156 | +} | |
| 157 | + | |
| 158 | +function buildAgentPrompt(userPrompt: string, now: Date): string { | |
| 159 | + return [ | |
| 160 | + "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", | |
| 161 | + "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).", | |
| 162 | + "For multiple events, produce multiple tool calls in the same code block.", | |
| 163 | + "Never claim an action succeeded unless run_code returned ok=true and receipts show succeeded calls.", | |
| 164 | + `Current timestamp: ${now.toISOString()}`, | |
| 165 | + `User request: ${userPrompt}`, | |
| 166 | + ].join("\n"); | |
| 167 | +} | |
| 168 | + | |
| 169 | +function describeUnknown(error: unknown): string { | |
| 170 | + if (error instanceof Error) { | |
| 171 | + return error.message; | |
| 172 | + } | |
| 173 | + return String(error); | |
| 174 | +} | |
| 175 | + | |
| 176 | +function readEnv(key: string): string | undefined { | |
| 177 | + const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 178 | + return bun?.env?.[key] ?? process.env[key]; | |
| 179 | +} | |
| @@ -0,0 +1,133 @@ | ||
| 1 | +import { homedir } from "node:os"; | |
| 2 | +import { join } from "node:path"; | |
| 3 | + | |
| 4 | +const DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; | |
| 5 | + | |
| 6 | +export type ResolvedAnthropicAuth = | |
| 7 | + | { | |
| 8 | + mode: "api"; | |
| 9 | + apiKey: string; | |
| 10 | + source: string; | |
| 11 | + } | |
| 12 | + | { | |
| 13 | + mode: "oauth"; | |
| 14 | + accessToken: string; | |
| 15 | + refreshToken: string | undefined; | |
| 16 | + expiresAt: number | undefined; | |
| 17 | + clientId: string; | |
| 18 | + source: string; | |
| 19 | + }; | |
| 20 | + | |
| 21 | +export async function resolveAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> { | |
| 22 | + const explicitApiKey = env("OPENASSISTANT_ANTHROPIC_API_KEY") ?? env("ANTHROPIC_API_KEY"); | |
| 23 | + if (explicitApiKey) { | |
| 24 | + return { | |
| 25 | + mode: "api", | |
| 26 | + apiKey: explicitApiKey, | |
| 27 | + source: env("OPENASSISTANT_ANTHROPIC_API_KEY") ? "env:OPENASSISTANT_ANTHROPIC_API_KEY" : "env:ANTHROPIC_API_KEY", | |
| 28 | + }; | |
| 29 | + } | |
| 30 | + | |
| 31 | + const explicitAccess = env("OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN"); | |
| 32 | + if (explicitAccess) { | |
| 33 | + const refreshToken = env("OPENASSISTANT_ANTHROPIC_REFRESH_TOKEN"); | |
| 34 | + const expiresAt = parseMaybeNumber(env("OPENASSISTANT_ANTHROPIC_EXPIRES_AT")); | |
| 35 | + return { | |
| 36 | + mode: "oauth", | |
| 37 | + accessToken: explicitAccess, | |
| 38 | + refreshToken, | |
| 39 | + expiresAt, | |
| 40 | + clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID, | |
| 41 | + source: "env:OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN", | |
| 42 | + }; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const opencodeAuth = await loadOpencodeAnthropicAuth(); | |
| 46 | + if (opencodeAuth) { | |
| 47 | + return opencodeAuth; | |
| 48 | + } | |
| 49 | + | |
| 50 | + return null; | |
| 51 | +} | |
| 52 | + | |
| 53 | +async function loadOpencodeAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> { | |
| 54 | + const filepath = join(opencodeDataDir(), "auth.json"); | |
| 55 | + const file = Bun.file(filepath); | |
| 56 | + const exists = await file.exists(); | |
| 57 | + if (!exists) { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | + | |
| 61 | + const raw = (await file.json().catch(() => null)) as Record<string, unknown> | null; | |
| 62 | + if (!raw || typeof raw !== "object") { | |
| 63 | + return null; | |
| 64 | + } | |
| 65 | + | |
| 66 | + const anthropic = raw["anthropic"]; | |
| 67 | + if (!anthropic || typeof anthropic !== "object") { | |
| 68 | + return null; | |
| 69 | + } | |
| 70 | + | |
| 71 | + const record = anthropic as Record<string, unknown>; | |
| 72 | + const type = asString(record["type"]); | |
| 73 | + if (type === "api") { | |
| 74 | + const key = asString(record["key"]); | |
| 75 | + if (!key) { | |
| 76 | + return null; | |
| 77 | + } | |
| 78 | + return { | |
| 79 | + mode: "api", | |
| 80 | + apiKey: key, | |
| 81 | + source: "opencode:auth.json(api)", | |
| 82 | + }; | |
| 83 | + } | |
| 84 | + | |
| 85 | + if (type === "oauth") { | |
| 86 | + const accessToken = asString(record["access"]); | |
| 87 | + if (!accessToken) { | |
| 88 | + return null; | |
| 89 | + } | |
| 90 | + const refreshToken = asString(record["refresh"]); | |
| 91 | + const expiresAt = asNumber(record["expires"]); | |
| 92 | + | |
| 93 | + return { | |
| 94 | + mode: "oauth", | |
| 95 | + accessToken, | |
| 96 | + refreshToken, | |
| 97 | + expiresAt, | |
| 98 | + clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID, | |
| 99 | + source: "opencode:auth.json(oauth)", | |
| 100 | + }; | |
| 101 | + } | |
| 102 | + | |
| 103 | + return null; | |
| 104 | +} | |
| 105 | + | |
| 106 | +function opencodeDataDir(): string { | |
| 107 | + const xdg = env("XDG_DATA_HOME"); | |
| 108 | + if (xdg) { | |
| 109 | + return join(xdg, "opencode"); | |
| 110 | + } | |
| 111 | + return join(homedir(), ".local", "share", "opencode"); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function asString(value: unknown): string | undefined { | |
| 115 | + return typeof value === "string" && value.length > 0 ? value : undefined; | |
| 116 | +} | |
| 117 | + | |
| 118 | +function asNumber(value: unknown): number | undefined { | |
| 119 | + return typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 120 | +} | |
| 121 | + | |
| 122 | +function parseMaybeNumber(value: string | undefined): number | undefined { | |
| 123 | + if (!value) { | |
| 124 | + return undefined; | |
| 125 | + } | |
| 126 | + const parsed = Number(value); | |
| 127 | + return Number.isFinite(parsed) ? parsed : undefined; | |
| 128 | +} | |
| 129 | + | |
| 130 | +function env(key: string): string | undefined { | |
| 131 | + const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 132 | + return bun?.env?.[key] ?? process.env[key]; | |
| 133 | +} | |
| @@ -0,0 +1,284 @@ | ||
| 1 | +import { createAnthropic } from "@ai-sdk/anthropic"; | |
| 2 | +import type { LanguageModel } from "ai"; | |
| 3 | +import { resolveAnthropicAuth, type ResolvedAnthropicAuth } from "./anthropic-auth.js"; | |
| 4 | + | |
| 5 | +const OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; | |
| 6 | +const REQUIRED_OAUTH_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"]; | |
| 7 | +const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)"; | |
| 8 | +const TOOL_PREFIX = "mcp_"; | |
| 9 | + | |
| 10 | +let cachedAuthPromise: Promise<ResolvedAnthropicAuth | null> | undefined; | |
| 11 | +let refreshInFlight: Promise<void> | undefined; | |
| 12 | + | |
| 13 | +export interface AnthropicModelSelection { | |
| 14 | + model: LanguageModel; | |
| 15 | + authSource: string; | |
| 16 | + authMode: "api" | "oauth"; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export async function getAnthropicModel(modelID: string): Promise<AnthropicModelSelection> { | |
| 20 | + const auth = await getResolvedAuth(); | |
| 21 | + if (!auth) { | |
| 22 | + throw new Error( | |
| 23 | + "No Anthropic credentials found. Set OPENASSISTANT_ANTHROPIC_API_KEY or OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN (or configure opencode auth).", | |
| 24 | + ); | |
| 25 | + } | |
| 26 | + | |
| 27 | + if (auth.mode === "api") { | |
| 28 | + const anthropic = createAnthropic({ | |
| 29 | + apiKey: auth.apiKey, | |
| 30 | + }); | |
| 31 | + return { | |
| 32 | + model: anthropic(modelID), | |
| 33 | + authSource: auth.source, | |
| 34 | + authMode: "api", | |
| 35 | + }; | |
| 36 | + } | |
| 37 | + | |
| 38 | + const oauthFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { | |
| 39 | + await ensureFreshToken(auth); | |
| 40 | + | |
| 41 | + const headers = mergeHeaders(input, init?.headers); | |
| 42 | + headers.set("authorization", `Bearer ${auth.accessToken}`); | |
| 43 | + headers.set("anthropic-beta", mergeBetas(headers.get("anthropic-beta"))); | |
| 44 | + headers.set("user-agent", CLAUDE_CLI_USER_AGENT); | |
| 45 | + headers.delete("x-api-key"); | |
| 46 | + | |
| 47 | + const transformedBody = transformRequestBody(init?.body); | |
| 48 | + const withBetaURL = ensureBetaMessagesURL(input); | |
| 49 | + | |
| 50 | + const response = await fetch(withBetaURL, { | |
| 51 | + ...init, | |
| 52 | + headers, | |
| 53 | + ...(transformedBody !== undefined ? { body: transformedBody } : {}), | |
| 54 | + }); | |
| 55 | + | |
| 56 | + return transformResponseBody(response); | |
| 57 | + }) as typeof fetch; | |
| 58 | + | |
| 59 | + const anthropic = createAnthropic({ | |
| 60 | + apiKey: "opencode-oauth-dummy-key", | |
| 61 | + fetch: oauthFetch, | |
| 62 | + }); | |
| 63 | + | |
| 64 | + return { | |
| 65 | + model: anthropic(modelID), | |
| 66 | + authSource: auth.source, | |
| 67 | + authMode: "oauth", | |
| 68 | + }; | |
| 69 | +} | |
| 70 | + | |
| 71 | +async function getResolvedAuth(): Promise<ResolvedAnthropicAuth | null> { | |
| 72 | + if (!cachedAuthPromise) { | |
| 73 | + cachedAuthPromise = resolveAnthropicAuth(); | |
| 74 | + } | |
| 75 | + return cachedAuthPromise; | |
| 76 | +} | |
| 77 | + | |
| 78 | +async function ensureFreshToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> { | |
| 79 | + const now = Date.now(); | |
| 80 | + const expiresAt = auth.expiresAt; | |
| 81 | + const mustRefresh = !auth.accessToken || (typeof expiresAt === "number" && now + 30_000 >= expiresAt); | |
| 82 | + if (!mustRefresh) { | |
| 83 | + return; | |
| 84 | + } | |
| 85 | + | |
| 86 | + if (!auth.refreshToken) { | |
| 87 | + throw new Error("Anthropic OAuth access token expired and no refresh token is available."); | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (!refreshInFlight) { | |
| 91 | + refreshInFlight = refreshAccessToken(auth).finally(() => { | |
| 92 | + refreshInFlight = undefined; | |
| 93 | + }); | |
| 94 | + } | |
| 95 | + | |
| 96 | + await refreshInFlight; | |
| 97 | +} | |
| 98 | + | |
| 99 | +async function refreshAccessToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> { | |
| 100 | + const response = await fetch(OAUTH_TOKEN_URL, { | |
| 101 | + method: "POST", | |
| 102 | + headers: { | |
| 103 | + "Content-Type": "application/json", | |
| 104 | + }, | |
| 105 | + body: JSON.stringify({ | |
| 106 | + grant_type: "refresh_token", | |
| 107 | + refresh_token: auth.refreshToken, | |
| 108 | + client_id: auth.clientId, | |
| 109 | + }), | |
| 110 | + }); | |
| 111 | + | |
| 112 | + if (!response.ok) { | |
| 113 | + throw new Error(`Anthropic OAuth token refresh failed (${response.status}).`); | |
| 114 | + } | |
| 115 | + | |
| 116 | + const json = (await response.json()) as { | |
| 117 | + access_token?: unknown; | |
| 118 | + refresh_token?: unknown; | |
| 119 | + expires_in?: unknown; | |
| 120 | + }; | |
| 121 | + | |
| 122 | + if (typeof json.access_token !== "string" || json.access_token.length === 0) { | |
| 123 | + throw new Error("Anthropic OAuth token refresh returned no access token."); | |
| 124 | + } | |
| 125 | + | |
| 126 | + auth.accessToken = json.access_token; | |
| 127 | + if (typeof json.refresh_token === "string" && json.refresh_token.length > 0) { | |
| 128 | + auth.refreshToken = json.refresh_token; | |
| 129 | + } | |
| 130 | + if (typeof json.expires_in === "number" && Number.isFinite(json.expires_in)) { | |
| 131 | + auth.expiresAt = Date.now() + json.expires_in * 1_000; | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +function mergeBetas(existing: string | null): string { | |
| 136 | + const current = (existing ?? "") | |
| 137 | + .split(",") | |
| 138 | + .map((item) => item.trim()) | |
| 139 | + .filter((item) => item.length > 0); | |
| 140 | + | |
| 141 | + return [...new Set([...REQUIRED_OAUTH_BETAS, ...current])].join(","); | |
| 142 | +} | |
| 143 | + | |
| 144 | +function mergeHeaders(input: RequestInfo | URL, initHeaders: HeadersInit | undefined): Headers { | |
| 145 | + const headers = new Headers(); | |
| 146 | + | |
| 147 | + if (input instanceof Request) { | |
| 148 | + input.headers.forEach((value, key) => { | |
| 149 | + headers.set(key, value); | |
| 150 | + }); | |
| 151 | + } | |
| 152 | + | |
| 153 | + if (initHeaders) { | |
| 154 | + const candidate = new Headers(initHeaders); | |
| 155 | + candidate.forEach((value, key) => { | |
| 156 | + headers.set(key, value); | |
| 157 | + }); | |
| 158 | + } | |
| 159 | + | |
| 160 | + return headers; | |
| 161 | +} | |
| 162 | + | |
| 163 | +function transformRequestBody(body: RequestInit["body"]): RequestInit["body"] { | |
| 164 | + if (typeof body !== "string") { | |
| 165 | + return body; | |
| 166 | + } | |
| 167 | + | |
| 168 | + try { | |
| 169 | + const parsed = JSON.parse(body) as Record<string, unknown>; | |
| 170 | + const tools = parsed["tools"]; | |
| 171 | + if (Array.isArray(tools)) { | |
| 172 | + parsed["tools"] = tools.map((tool) => { | |
| 173 | + if (!tool || typeof tool !== "object") { | |
| 174 | + return tool; | |
| 175 | + } | |
| 176 | + const candidate = tool as Record<string, unknown>; | |
| 177 | + const name = candidate["name"]; | |
| 178 | + if (typeof name === "string" && name.length > 0 && !name.startsWith(TOOL_PREFIX)) { | |
| 179 | + return { | |
| 180 | + ...candidate, | |
| 181 | + name: `${TOOL_PREFIX}${name}`, | |
| 182 | + }; | |
| 183 | + } | |
| 184 | + return tool; | |
| 185 | + }); | |
| 186 | + } | |
| 187 | + | |
| 188 | + const messages = parsed["messages"]; | |
| 189 | + if (Array.isArray(messages)) { | |
| 190 | + parsed["messages"] = messages.map((message) => { | |
| 191 | + if (!message || typeof message !== "object") { | |
| 192 | + return message; | |
| 193 | + } | |
| 194 | + const candidate = message as Record<string, unknown>; | |
| 195 | + const content = candidate["content"]; | |
| 196 | + if (!Array.isArray(content)) { | |
| 197 | + return message; | |
| 198 | + } | |
| 199 | + return { | |
| 200 | + ...candidate, | |
| 201 | + content: content.map((block) => { | |
| 202 | + if (!block || typeof block !== "object") { | |
| 203 | + return block; | |
| 204 | + } | |
| 205 | + const value = block as Record<string, unknown>; | |
| 206 | + if (value["type"] !== "tool_use") { | |
| 207 | + return block; | |
| 208 | + } | |
| 209 | + const name = value["name"]; | |
| 210 | + if (typeof name !== "string" || name.length === 0 || name.startsWith(TOOL_PREFIX)) { | |
| 211 | + return block; | |
| 212 | + } | |
| 213 | + return { | |
| 214 | + ...value, | |
| 215 | + name: `${TOOL_PREFIX}${name}`, | |
| 216 | + }; | |
| 217 | + }), | |
| 218 | + }; | |
| 219 | + }); | |
| 220 | + } | |
| 221 | + | |
| 222 | + return JSON.stringify(parsed); | |
| 223 | + } catch { | |
| 224 | + return body; | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +function ensureBetaMessagesURL(input: RequestInfo | URL): RequestInfo | URL { | |
| 229 | + let inputIsRequest = false; | |
| 230 | + let parsed: URL | null = null; | |
| 231 | + try { | |
| 232 | + if (input instanceof Request) { | |
| 233 | + inputIsRequest = true; | |
| 234 | + parsed = new URL(input.url); | |
| 235 | + } else if (input instanceof URL) { | |
| 236 | + parsed = new URL(input.toString()); | |
| 237 | + } else if (typeof input === "string") { | |
| 238 | + parsed = new URL(input); | |
| 239 | + } | |
| 240 | + } catch { | |
| 241 | + parsed = null; | |
| 242 | + } | |
| 243 | + | |
| 244 | + if (!parsed || parsed.pathname !== "/v1/messages" || parsed.searchParams.has("beta")) { | |
| 245 | + return input; | |
| 246 | + } | |
| 247 | + | |
| 248 | + parsed.searchParams.set("beta", "true"); | |
| 249 | + if (inputIsRequest && input instanceof Request) { | |
| 250 | + return new Request(parsed.toString(), input); | |
| 251 | + } | |
| 252 | + return parsed; | |
| 253 | +} | |
| 254 | + | |
| 255 | +async function transformResponseBody(response: Response): Promise<Response> { | |
| 256 | + if (!response.body) { | |
| 257 | + return response; | |
| 258 | + } | |
| 259 | + | |
| 260 | + const reader = response.body.getReader(); | |
| 261 | + const decoder = new TextDecoder(); | |
| 262 | + const encoder = new TextEncoder(); | |
| 263 | + | |
| 264 | + const stream = new ReadableStream({ | |
| 265 | + async pull(controller) { | |
| 266 | + const { done, value } = await reader.read(); | |
| 267 | + if (done) { | |
| 268 | + controller.close(); | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + | |
| 272 | + const text = decoder | |
| 273 | + .decode(value, { stream: true }) | |
| 274 | + .replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'); | |
| 275 | + controller.enqueue(encoder.encode(text)); | |
| 276 | + }, | |
| 277 | + }); | |
| 278 | + | |
| 279 | + return new Response(stream, { | |
| 280 | + status: response.status, | |
| 281 | + statusText: response.statusText, | |
| 282 | + headers: response.headers, | |
| 283 | + }); | |
| 284 | +} | |
| @@ -1,77 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | −import { generateCodeFromPrompt } from "./codegen.js"; | |
| 3 | − | |
| 4 | −describe("generateCodeFromPrompt", () => { | |
| 5 | − it("maps calendar prompts with heuristic provider", async () => { | |
| 6 | − const now = new Date("2026-02-06T10:00:00.000Z"); | |
| 7 | − const generated = await generateCodeFromPrompt( | |
| 8 | − "update my calendar to put dinner with ella at 5 pm", | |
| 9 | − { now, provider: "heuristic" }, | |
| 10 | − ); | |
| 11 | − | |
| 12 | − expect(generated.provider).toBe("heuristic"); | |
| 13 | − expect(generated.code).toContain("tools.calendar.update"); | |
| 14 | − expect(generated.code.toLowerCase()).toContain("dinner with ella"); | |
| 15 | − expect(generated.rationale).toContain("calendar.update"); | |
| 16 | − }); | |
| 17 | − | |
| 18 | − it("supports direct code mode via code prefix", async () => { | |
| 19 | − const generated = await generateCodeFromPrompt("code: return 42;", { | |
| 20 | − provider: "heuristic", | |
| 21 | − }); | |
| 22 | − expect(generated.code).toBe("return 42;"); | |
| 23 | − expect(generated.rationale).toContain("direct code"); | |
| 24 | − }); | |
| 25 | − | |
| 26 | − it("parses structured output from claude provider", async () => { | |
| 27 | − const generated = await generateCodeFromPrompt("make an event", { | |
| 28 | − provider: "claude", | |
| 29 | − generateWithClaude: async () => ({ | |
| 30 | − code: "const event = await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-06T17:00:00.000Z' }); return { event };", | |
| 31 | − rationale: "Mapped user prompt to calendar.update", | |
| 32 | − }), | |
| 33 | − }); | |
| 34 | − | |
| 35 | − expect(generated.provider).toBe("claude"); | |
| 36 | − expect(generated.code).toContain("tools.calendar.update"); | |
| 37 | − expect(generated.rationale).toContain("Mapped"); | |
| 38 | − }); | |
| 39 | − | |
| 40 | − it("handles multi-line calendar lists with relative days", async () => { | |
| 41 | − const now = new Date("2026-02-06T10:00:00.000Z"); // Friday | |
| 42 | − const prompt = [ | |
| 43 | − "Please add the following to my calendar:", | |
| 44 | − "", | |
| 45 | − "Dinner 5pm tomorrow", | |
| 46 | − "", | |
| 47 | − "Lunch 2 pm sunday", | |
| 48 | − "", | |
| 49 | − "Breakfast 3 am Monday", | |
| 50 | − ].join("\n"); | |
| 51 | − | |
| 52 | − const generated = await generateCodeFromPrompt(prompt, { | |
| 53 | − now, | |
| 54 | − provider: "heuristic", | |
| 55 | − }); | |
| 56 | − | |
| 57 | − expect(generated.code.match(/tools\.calendar\.update\(/g)?.length).toBe(3); | |
| 58 | − expect(generated.code).toContain('"title":"Dinner"'); | |
| 59 | − expect(generated.code).toContain('"title":"Lunch"'); | |
| 60 | − expect(generated.code).toContain('"title":"Breakfast"'); | |
| 61 | − expect(generated.code).toContain('return { message: "Calendar updated", events };'); | |
| 62 | − | |
| 63 | − const startsAt = [...generated.code.matchAll(/"startsAt":"([^"]+)"/g)] | |
| 64 | − .map((match) => match[1]) | |
| 65 | − .filter((value): value is string => typeof value === "string"); | |
| 66 | − expect(startsAt).toHaveLength(3); | |
| 67 | − if (startsAt.length !== 3) { | |
| 68 | − throw new Error("expected 3 startsAt values"); | |
| 69 | − } | |
| 70 | − const dinnerAt = new Date(startsAt[0]!); | |
| 71 | − const lunchAt = new Date(startsAt[1]!); | |
| 72 | − const breakfastAt = new Date(startsAt[2]!); | |
| 73 | − expect(dinnerAt.getDay()).toBe(6); // Saturday | |
| 74 | − expect(lunchAt.getDay()).toBe(0); // Sunday | |
| 75 | − expect(breakfastAt.getDay()).toBe(1); // Monday | |
| 76 | − }); | |
| 77 | −}); | |
| @@ -1,322 +0,0 @@ | ||
| 1 | −import { generateObject } from "ai"; | |
| 2 | −import { createClaudeCode } from "ai-sdk-provider-claude-code"; | |
| 3 | −import { z } from "zod"; | |
| 4 | − | |
| 5 | −export interface GeneratedCode { | |
| 6 | − code: string; | |
| 7 | − rationale: string; | |
| 8 | − provider: "claude" | "heuristic"; | |
| 9 | −} | |
| 10 | − | |
| 11 | −interface CodegenOptions { | |
| 12 | − now?: Date; | |
| 13 | − provider?: "claude" | "heuristic"; | |
| 14 | − generateWithClaude?: (params: { prompt: string; now: Date }) => Promise<{ code: string; rationale: string }>; | |
| 15 | −} | |
| 16 | − | |
| 17 | −const DEFAULT_PROVIDER = resolveDefaultProvider(); | |
| 18 | −const DEFAULT_CLAUDE_MODEL = readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() || "sonnet"; | |
| 19 | −const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 30_000); | |
| 20 | −const CLAUDE_PATH = readEnv("OPENASSISTANT_CLAUDE_PATH")?.trim(); | |
| 21 | −const CODEGEN_SCHEMA = z.object({ | |
| 22 | − code: z.string().min(1), | |
| 23 | − rationale: z.string().min(1), | |
| 24 | −}); | |
| 25 | − | |
| 26 | −const claudeCode = createClaudeCode({ | |
| 27 | − defaultSettings: { | |
| 28 | − permissionMode: "dontAsk", | |
| 29 | − tools: [], | |
| 30 | − maxTurns: 1, | |
| 31 | − ...(CLAUDE_PATH ? { pathToClaudeCodeExecutable: CLAUDE_PATH } : {}), | |
| 32 | − }, | |
| 33 | −}); | |
| 34 | − | |
| 35 | −export async function generateCodeFromPrompt( | |
| 36 | − prompt: string, | |
| 37 | − options: CodegenOptions = {}, | |
| 38 | −): Promise<GeneratedCode> { | |
| 39 | − const now = options.now ?? new Date(); | |
| 40 | − const provider = options.provider ?? DEFAULT_PROVIDER; | |
| 41 | − | |
| 42 | − if (provider === "claude") { | |
| 43 | − try { | |
| 44 | − const generated = await (options.generateWithClaude ?? generateWithClaude)({ | |
| 45 | − prompt, | |
| 46 | − now, | |
| 47 | − }); | |
| 48 | − return { ...generated, provider: "claude" }; | |
| 49 | − } catch (error) { | |
| 50 | − const fallback = generateHeuristic(prompt, now); | |
| 51 | − return { | |
| 52 | − ...fallback, | |
| 53 | − provider: "heuristic", | |
| 54 | − rationale: `${fallback.rationale} Claude fallback: ${describeUnknown(error)}`, | |
| 55 | − }; | |
| 56 | − } | |
| 57 | − } | |
| 58 | − | |
| 59 | − const generated = generateHeuristic(prompt, now); | |
| 60 | − return { ...generated, provider: "heuristic" }; | |
| 61 | −} | |
| 62 | − | |
| 63 | −async function generateWithClaude(params: { | |
| 64 | − prompt: string; | |
| 65 | − now: Date; | |
| 66 | −}): Promise<Omit<GeneratedCode, "provider">> { | |
| 67 | − const { prompt, now } = params; | |
| 68 | − const abortController = new AbortController(); | |
| 69 | − const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS); | |
| 70 | − try { | |
| 71 | − const result = await generateObject({ | |
| 72 | − model: claudeCode(DEFAULT_CLAUDE_MODEL), | |
| 73 | − schema: CODEGEN_SCHEMA, | |
| 74 | − prompt: buildPlannerPrompt(prompt, now), | |
| 75 | − temperature: 0, | |
| 76 | − abortSignal: abortController.signal, | |
| 77 | − }); | |
| 78 | − | |
| 79 | − return result.object; | |
| 80 | − } finally { | |
| 81 | − clearTimeout(timeout); | |
| 82 | − } | |
| 83 | −} | |
| 84 | − | |
| 85 | −function buildPlannerPrompt(userPrompt: string, now: Date): string { | |
| 86 | − return [ | |
| 87 | − "You are a code generator for a Bun TypeScript codemode runner.", | |
| 88 | − "Return a function body only (no markdown, no fences, no explanations in code).", | |
| 89 | − "The runtime invokes: new AsyncFunction('tools', code).", | |
| 90 | − "Available tools:", | |
| 91 | − "- await tools.calendar.update({ title: string, startsAt: string, notes?: string })", | |
| 92 | − "- await tools.calendar.list()", | |
| 93 | − "Rules:", | |
| 94 | − "- Use await for tool calls.", | |
| 95 | − "- If request implies creating/updating a calendar event, call tools.calendar.update.", | |
| 96 | − "- startsAt must be an ISO-8601 string.", | |
| 97 | − "- If time/date missing, use current timestamp.", | |
| 98 | − "- Always return a final object (e.g. { message, event }).", | |
| 99 | − `Current timestamp: ${now.toISOString()}`, | |
| 100 | − `User request: ${userPrompt}`, | |
| 101 | − ].join("\n"); | |
| 102 | −} | |
| 103 | − | |
| 104 | −function resolveDefaultProvider(): "claude" | "heuristic" { | |
| 105 | − const configured = readEnv("OPENASSISTANT_CODEGEN_PROVIDER")?.trim().toLowerCase(); | |
| 106 | − if (configured === "heuristic") { | |
| 107 | − return "heuristic"; | |
| 108 | − } | |
| 109 | − return "claude"; | |
| 110 | −} | |
| 111 | − | |
| 112 | −function generateHeuristic(prompt: string, now: Date): Omit<GeneratedCode, "provider"> { | |
| 113 | − const trimmed = prompt.trim(); | |
| 114 | − if (!trimmed) { | |
| 115 | − return { | |
| 116 | − code: "return { message: 'Empty prompt.' };", | |
| 117 | − rationale: "Empty prompt fallback.", | |
| 118 | − }; | |
| 119 | − } | |
| 120 | − | |
| 121 | − if (trimmed.toLowerCase().startsWith("code:")) { | |
| 122 | − return { | |
| 123 | − code: trimmed.slice(5).trim(), | |
| 124 | − rationale: "User provided direct code.", | |
| 125 | − }; | |
| 126 | − } | |
| 127 | − | |
| 128 | − const lower = trimmed.toLowerCase(); | |
| 129 | − if (lower.includes("calendar") || lower.includes("event") || lower.includes("schedule")) { | |
| 130 | − const updates = extractCalendarUpdates(trimmed, now); | |
| 131 | − if (updates.length > 0) { | |
| 132 | − const code = buildCalendarCode(updates); | |
| 133 | − return { | |
| 134 | − code, | |
| 135 | − rationale: | |
| 136 | − updates.length === 1 | |
| 137 | − ? "Mapped prompt to calendar.update tool call." | |
| 138 | − : `Mapped prompt to ${updates.length} calendar.update tool calls.`, | |
| 139 | − }; | |
| 140 | − } | |
| 141 | − } | |
| 142 | − | |
| 143 | − return { | |
| 144 | − code: `return { message: "No tool mapping yet for prompt", prompt: ${JSON.stringify(trimmed)} };`, | |
| 145 | − rationale: "Fallback response when no tool intent is detected.", | |
| 146 | − }; | |
| 147 | −} | |
| 148 | − | |
| 149 | −function inferTitle(prompt: string): string { | |
| 150 | − const match = prompt.match( | |
| 151 | − /(?:put|add|create)\s+(?:an?\s+)?(?:calendar\s+)?(?:event\s+)?(?:to\s+)?(.+?)(?:\s+at\s+|$)/i, | |
| 152 | − ); | |
| 153 | − if (match?.[1]) { | |
| 154 | − return cleanTitle(match[1]); | |
| 155 | − } | |
| 156 | − return "Untitled event"; | |
| 157 | −} | |
| 158 | − | |
| 159 | −function cleanTitle(input: string): string { | |
| 160 | − return input.replace(/^my\s+/i, "").replace(/^calendar\s+/i, "").trim(); | |
| 161 | −} | |
| 162 | − | |
| 163 | −function inferStartTime(prompt: string, now: Date): string { | |
| 164 | − const time = prompt.match(/(?:\bat\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i); | |
| 165 | − if (!time) { | |
| 166 | − return now.toISOString(); | |
| 167 | − } | |
| 168 | − | |
| 169 | − const dayToken = prompt.match( | |
| 170 | − /\b(today|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i, | |
| 171 | − )?.[1]; | |
| 172 | − return resolveStartsAt(now, Number(time[1]), time[2] ? Number(time[2]) : 0, time[3], dayToken); | |
| 173 | −} | |
| 174 | − | |
| 175 | −function describeUnknown(error: unknown): string { | |
| 176 | − if (error instanceof Error) { | |
| 177 | − return error.message; | |
| 178 | − } | |
| 179 | − return String(error); | |
| 180 | −} | |
| 181 | − | |
| 182 | −function readEnv(key: string): string | undefined { | |
| 183 | − const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 184 | − return bun?.env?.[key] ?? process.env[key]; | |
| 185 | −} | |
| 186 | − | |
| 187 | −type CalendarUpdateInput = { | |
| 188 | − title: string; | |
| 189 | − startsAt: string; | |
| 190 | − notes: string; | |
| 191 | −}; | |
| 192 | − | |
| 193 | −const WEEKDAY_INDEX: Record<string, number> = { | |
| 194 | − sunday: 0, | |
| 195 | − monday: 1, | |
| 196 | − tuesday: 2, | |
| 197 | − wednesday: 3, | |
| 198 | − thursday: 4, | |
| 199 | − friday: 5, | |
| 200 | − saturday: 6, | |
| 201 | −}; | |
| 202 | − | |
| 203 | −function buildCalendarCode(updates: CalendarUpdateInput[]): string { | |
| 204 | − const lines: string[] = ["const events = [];"]; | |
| 205 | − for (const update of updates) { | |
| 206 | − lines.push(`events.push(await tools.calendar.update(${JSON.stringify(update)}));`); | |
| 207 | − } | |
| 208 | − lines.push('return { message: "Calendar updated", events };'); | |
| 209 | − return lines.join(" "); | |
| 210 | −} | |
| 211 | − | |
| 212 | −function extractCalendarUpdates(prompt: string, now: Date): CalendarUpdateInput[] { | |
| 213 | − const lines = prompt | |
| 214 | − .split(/\r?\n/) | |
| 215 | − .map((line) => line.trim()) | |
| 216 | − .filter((line) => line.length > 0); | |
| 217 | − | |
| 218 | − const updates: CalendarUpdateInput[] = []; | |
| 219 | − for (const line of lines) { | |
| 220 | − if (isInstructionLine(line)) { | |
| 221 | − continue; | |
| 222 | − } | |
| 223 | − const parsed = parseCalendarLine(line, now, prompt); | |
| 224 | − if (parsed) { | |
| 225 | − updates.push(parsed); | |
| 226 | − } | |
| 227 | − } | |
| 228 | − | |
| 229 | − if (updates.length > 0) { | |
| 230 | − return updates; | |
| 231 | − } | |
| 232 | − | |
| 233 | − return [ | |
| 234 | − { | |
| 235 | − title: inferTitle(prompt), | |
| 236 | − startsAt: inferStartTime(prompt, now), | |
| 237 | − notes: `Requested via Discord prompt: ${prompt}`, | |
| 238 | − }, | |
| 239 | − ]; | |
| 240 | −} | |
| 241 | − | |
| 242 | −function isInstructionLine(line: string): boolean { | |
| 243 | − const value = line.toLowerCase(); | |
| 244 | − if (value.includes("calendar") && (value.includes("add") || value.includes("following"))) { | |
| 245 | − return true; | |
| 246 | − } | |
| 247 | − return value.startsWith("please "); | |
| 248 | −} | |
| 249 | − | |
| 250 | −function parseCalendarLine( | |
| 251 | − rawLine: string, | |
| 252 | − now: Date, | |
| 253 | − fullPrompt: string, | |
| 254 | −): CalendarUpdateInput | null { | |
| 255 | − const line = rawLine.replace(/^[-*]\s*/, "").replace(/^\d+[.)]\s*/, "").trim(); | |
| 256 | − const match = line.match( | |
| 257 | − /^(?<title>.+?)\s+(?:at\s+)?(?<hour>\d{1,2})(?::(?<minute>\d{2}))?\s*(?<meridiem>am|pm)\b(?:\s+(?<day>today|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday))?$/i, | |
| 258 | − ); | |
| 259 | − | |
| 260 | − if (!match?.groups) { | |
| 261 | − return null; | |
| 262 | − } | |
| 263 | − | |
| 264 | − const titleRaw = match.groups.title; | |
| 265 | − const hourRaw = match.groups.hour; | |
| 266 | − const meridiemRaw = match.groups.meridiem; | |
| 267 | − if (!titleRaw || !hourRaw || !meridiemRaw) { | |
| 268 | − return null; | |
| 269 | − } | |
| 270 | − | |
| 271 | − const title = cleanTitle(titleRaw); | |
| 272 | − if (!title) { | |
| 273 | − return null; | |
| 274 | − } | |
| 275 | − | |
| 276 | − const hour = Number(hourRaw); | |
| 277 | − const minute = match.groups.minute ? Number(match.groups.minute) : 0; | |
| 278 | − const meridiem = meridiemRaw; | |
| 279 | − const dayToken = match.groups.day; | |
| 280 | − const startsAt = resolveStartsAt(now, hour, minute, meridiem, dayToken); | |
| 281 | − | |
| 282 | − return { | |
| 283 | − title, | |
| 284 | − startsAt, | |
| 285 | − notes: `Requested via Discord prompt: ${fullPrompt}`, | |
| 286 | − }; | |
| 287 | −} | |
| 288 | − | |
| 289 | −function resolveStartsAt( | |
| 290 | − now: Date, | |
| 291 | − hour12: number, | |
| 292 | − minute: number, | |
| 293 | − meridiemRaw: string | undefined, | |
| 294 | − dayTokenRaw: string | undefined, | |
| 295 | −): string { | |
| 296 | − const meridiem = meridiemRaw?.toLowerCase(); | |
| 297 | − const dayToken = dayTokenRaw?.toLowerCase(); | |
| 298 | − let hour = hour12; | |
| 299 | − | |
| 300 | − if (meridiem === "pm" && hour < 12) { | |
| 301 | − hour += 12; | |
| 302 | − } else if (meridiem === "am" && hour === 12) { | |
| 303 | − hour = 0; | |
| 304 | − } | |
| 305 | − | |
| 306 | − const date = new Date(now); | |
| 307 | − if (dayToken === "tomorrow") { | |
| 308 | − date.setDate(date.getDate() + 1); | |
| 309 | − } else if (dayToken) { | |
| 310 | − const target = WEEKDAY_INDEX[dayToken]; | |
| 311 | − if (target === undefined) { | |
| 312 | − date.setHours(hour, minute, 0, 0); | |
| 313 | − return date.toISOString(); | |
| 314 | − } | |
| 315 | − const current = date.getDay(); | |
| 316 | − const delta = (target - current + 7) % 7; | |
| 317 | − date.setDate(date.getDate() + delta); | |
| 318 | − } | |
| 319 | − | |
| 320 | − date.setHours(hour, minute, 0, 0); | |
| 321 | − return date.toISOString(); | |
| 322 | −} | |
| @@ -0,0 +1,76 @@ | ||
| 1 | +import type { CodeModeRunResult } from "@openassistant/core"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { formatDiscordResponse } from "./format-response.js"; | |
| 4 | + | |
| 5 | +describe("formatDiscordResponse", () => { | |
| 6 | + it("summarizes tool activity across multiple runs", () => { | |
| 7 | + const output = formatDiscordResponse({ | |
| 8 | + prompt: "Add 3 calendar events", | |
| 9 | + text: "Added three events.", | |
| 10 | + planner: "Claude tool-loop (3 code runs).", | |
| 11 | + provider: "claude", | |
| 12 | + runs: [ | |
| 13 | + { code: "run1", result: runResult("a", "succeeded") }, | |
| 14 | + { code: "run2", result: runResult("b", "succeeded") }, | |
| 15 | + { code: "run3", result: runResult("c", "succeeded") }, | |
| 16 | + ], | |
| 17 | + }); | |
| 18 | + | |
| 19 | + expect(output).toContain("Code runs: 3"); | |
| 20 | + expect(output).toContain("`calendar.update`"); | |
| 21 | + expect(output).toContain("3 calls"); | |
| 22 | + expect(output).toContain("3 approved"); | |
| 23 | + expect(output).toContain("3 succeeded"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("includes failure details for denied/failed receipts", () => { | |
| 27 | + const output = formatDiscordResponse({ | |
| 28 | + prompt: "Delete event", | |
| 29 | + text: "Could not complete request.", | |
| 30 | + planner: "Claude tool-loop (1 code run).", | |
| 31 | + provider: "claude", | |
| 32 | + runs: [ | |
| 33 | + { | |
| 34 | + code: "run", | |
| 35 | + result: { | |
| 36 | + ok: false, | |
| 37 | + error: "Tool call denied", | |
| 38 | + receipts: [ | |
| 39 | + { | |
| 40 | + callId: "call_denied", | |
| 41 | + toolPath: "calendar.update", | |
| 42 | + kind: "write", | |
| 43 | + approval: "required", | |
| 44 | + decision: "denied", | |
| 45 | + status: "denied", | |
| 46 | + timestamp: "2026-02-06T01:00:00.000Z", | |
| 47 | + }, | |
| 48 | + ], | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | + ], | |
| 52 | + }); | |
| 53 | + | |
| 54 | + expect(output).toContain("Failures:"); | |
| 55 | + expect(output).toContain("code run failed"); | |
| 56 | + expect(output).toContain("denied"); | |
| 57 | + }); | |
| 58 | +}); | |
| 59 | + | |
| 60 | +function runResult(callId: string, status: "succeeded" | "failed"): CodeModeRunResult { | |
| 61 | + return { | |
| 62 | + ok: true, | |
| 63 | + value: { ok: true }, | |
| 64 | + receipts: [ | |
| 65 | + { | |
| 66 | + callId, | |
| 67 | + toolPath: "calendar.update", | |
| 68 | + kind: "write", | |
| 69 | + approval: "required", | |
| 70 | + decision: "approved", | |
| 71 | + status, | |
| 72 | + timestamp: "2026-02-06T01:00:00.000Z", | |
| 73 | + }, | |
| 74 | + ], | |
| 75 | + }; | |
| 76 | +} | |
| @@ -1,55 +1,124 @@ | ||
| 1 | −import type { CodeModeRunResult } from "@openassistant/core"; | |
| 1 | +import type { ToolCallReceipt } from "@openassistant/core"; | |
| 2 | +import type { AgentCodeRun } from "./agent-loop.js"; | |
| 2 | 3 | |
| 3 | 4 | export function formatDiscordResponse(params: { |
| 4 | 5 | prompt: string; |
| 5 | − generatedCode: string; | |
| 6 | − rationale: string; | |
| 7 | − provider: "claude" | "heuristic"; | |
| 8 | − result: CodeModeRunResult; | |
| 6 | + text: string; | |
| 7 | + planner: string; | |
| 8 | + provider: "claude"; | |
| 9 | + runs: AgentCodeRun[]; | |
| 9 | 10 | }): string { |
| 10 | − const { prompt, generatedCode, rationale, provider, result } = params; | |
| 11 | + const { prompt, text, planner, provider, runs } = params; | |
| 12 | + const receiptSummary = summarizeReceipts(runs.flatMap((run) => run.result.receipts)); | |
| 13 | + | |
| 11 | 14 | const sections = [ |
| 12 | − `Prompt: ${prompt}`, | |
| 13 | − `Planner (${provider}): ${rationale}`, | |
| 14 | − "Code:", | |
| 15 | − fenced("ts", generatedCode), | |
| 16 | − result.ok ? `Result: ${safeJson(result.value)}` : `Error: ${result.error}`, | |
| 17 | − "Receipts:", | |
| 18 | − formatReceipts(result.receipts), | |
| 15 | + `Request: ${prompt}`, | |
| 16 | + `Reply: ${text || "(no text returned)"}`, | |
| 17 | + `Planner: ${provider} - ${planner}`, | |
| 18 | + `Code runs: ${runs.length}`, | |
| 19 | + `Tool activity:\n${receiptSummary}`, | |
| 19 | 20 | ]; |
| 20 | 21 | |
| 22 | + const failures = formatFailures(runs); | |
| 23 | + if (failures) { | |
| 24 | + sections.push(`Failures:\n${failures}`); | |
| 25 | + } | |
| 26 | + | |
| 21 | 27 | return truncateDiscord(sections.join("\n\n")); |
| 22 | 28 | } |
| 23 | 29 | |
| 24 | −function formatReceipts(receipts: CodeModeRunResult["receipts"]): string { | |
| 30 | +function summarizeReceipts(receipts: ToolCallReceipt[]): string { | |
| 25 | 31 | if (receipts.length === 0) { |
| 26 | 32 | return "- none"; |
| 27 | 33 | } |
| 28 | − return receipts | |
| 29 | − .map((receipt) => { | |
| 30 | − const parts = [ | |
| 31 | − `- \`${receipt.toolPath}\``, | |
| 32 | − `decision=${receipt.decision}`, | |
| 33 | − `status=${receipt.status}`, | |
| 34 | − ]; | |
| 35 | − if (receipt.inputPreview) { | |
| 36 | − parts.push(`input="${receipt.inputPreview}"`); | |
| 34 | + | |
| 35 | + const byTool = new Map< | |
| 36 | + string, | |
| 37 | + { | |
| 38 | + calls: number; | |
| 39 | + approved: number; | |
| 40 | + auto: number; | |
| 41 | + denied: number; | |
| 42 | + deniedDecision: number; | |
| 43 | + succeeded: number; | |
| 44 | + failed: number; | |
| 45 | + } | |
| 46 | + >(); | |
| 47 | + | |
| 48 | + for (const receipt of receipts) { | |
| 49 | + const current = byTool.get(receipt.toolPath) ?? { | |
| 50 | + calls: 0, | |
| 51 | + approved: 0, | |
| 52 | + auto: 0, | |
| 53 | + denied: 0, | |
| 54 | + deniedDecision: 0, | |
| 55 | + succeeded: 0, | |
| 56 | + failed: 0, | |
| 57 | + }; | |
| 58 | + | |
| 59 | + current.calls += 1; | |
| 60 | + if (receipt.decision === "approved") { | |
| 61 | + current.approved += 1; | |
| 62 | + } else if (receipt.decision === "auto") { | |
| 63 | + current.auto += 1; | |
| 64 | + } else { | |
| 65 | + current.deniedDecision += 1; | |
| 66 | + } | |
| 67 | + | |
| 68 | + if (receipt.status === "succeeded") { | |
| 69 | + current.succeeded += 1; | |
| 70 | + } else if (receipt.status === "failed") { | |
| 71 | + current.failed += 1; | |
| 72 | + } else { | |
| 73 | + current.denied += 1; | |
| 74 | + } | |
| 75 | + | |
| 76 | + byTool.set(receipt.toolPath, current); | |
| 77 | + } | |
| 78 | + | |
| 79 | + return Array.from(byTool.entries()) | |
| 80 | + .map(([toolPath, stats]) => { | |
| 81 | + const parts = [`${stats.calls} call${stats.calls === 1 ? "" : "s"}`]; | |
| 82 | + parts.push(`${stats.succeeded} succeeded`); | |
| 83 | + if (stats.approved > 0) { | |
| 84 | + parts.push(`${stats.approved} approved`); | |
| 37 | 85 | } |
| 38 | − return parts.join(" "); | |
| 86 | + if (stats.auto > 0) { | |
| 87 | + parts.push(`${stats.auto} auto`); | |
| 88 | + } | |
| 89 | + if (stats.denied > 0) { | |
| 90 | + parts.push(`${stats.denied} denied`); | |
| 91 | + } | |
| 92 | + if (stats.deniedDecision > 0) { | |
| 93 | + parts.push(`${stats.deniedDecision} denied-decision`); | |
| 94 | + } | |
| 95 | + if (stats.failed > 0) { | |
| 96 | + parts.push(`${stats.failed} failed`); | |
| 97 | + } | |
| 98 | + return `- \`${toolPath}\`: ${parts.join(", ")}`; | |
| 39 | 99 | }) |
| 40 | 100 | .join("\n"); |
| 41 | 101 | } |
| 42 | 102 | |
| 43 | −function safeJson(value: unknown): string { | |
| 44 | − try { | |
| 45 | − return JSON.stringify(value); | |
| 46 | − } catch { | |
| 47 | − return String(value); | |
| 103 | +function formatFailures(runs: AgentCodeRun[]): string | null { | |
| 104 | + const failures: string[] = []; | |
| 105 | + | |
| 106 | + for (const run of runs) { | |
| 107 | + if (!run.result.ok) { | |
| 108 | + failures.push(`- code run failed: ${run.result.error}`); | |
| 109 | + } | |
| 110 | + | |
| 111 | + for (const receipt of run.result.receipts) { | |
| 112 | + if (receipt.status === "failed") { | |
| 113 | + failures.push(`- \`${receipt.toolPath}\` failed (${receipt.callId})${receipt.error ? `: ${receipt.error}` : ""}`); | |
| 114 | + } | |
| 115 | + if (receipt.status === "denied") { | |
| 116 | + failures.push(`- \`${receipt.toolPath}\` denied (${receipt.callId})`); | |
| 117 | + } | |
| 118 | + } | |
| 48 | 119 | } |
| 49 | −} | |
| 50 | 120 | |
| 51 | −function fenced(lang: string, content: string): string { | |
| 52 | − return `\`\`\`${lang}\n${content}\n\`\`\``; | |
| 121 | + return failures.length > 0 ? failures.join("\n") : null; | |
| 53 | 122 | } |
| 54 | 123 | |
| 55 | 124 | function truncateDiscord(value: string): string { |
| @@ -10,8 +10,8 @@ import { · 9 unmodified lines | ||
| 10 | 10 | } from "discord.js"; |
| 11 | 11 | import { Effect } from "effect"; |
| 12 | 12 | import { DiscordApprovalBridge } from "./approval-bridge.js"; |
| 13 | +import { runAgentLoop } from "./agent-loop.js"; | |
| 13 | 14 | import { InMemoryCalendarStore } from "./calendar-store.js"; |
| 14 | −import { generateCodeFromPrompt } from "./codegen.js"; | |
| 15 | 15 | import { formatDiscordResponse } from "./format-response.js"; |
| 16 | 16 | import { createToolTree } from "./tools.js"; |
| 17 | 17 | |
| @@ -73,8 +73,6 @@ async function handleMessage(message: Message): Promise<void> { · 55 unmodified lines | ||
| 73 | 73 | return; |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | − const generated = await generateCodeFromPrompt(message.content); | |
| 77 | − | |
| 78 | 76 | const runner = createCodeModeRunner({ |
| 79 | 77 | tools, |
| 80 | 78 | requestApproval: (request) => |
| @@ -89,13 +87,15 @@ async function handleMessage(message: Message): Promise<void> { · 8 unmodified lines | ||
| 89 | 87 | }), |
| 90 | 88 | }); |
| 91 | 89 | |
| 92 | − const result = await Effect.runPromise(runner.run({ code: generated.code })); | |
| 90 | + const generated = await runAgentLoop(message.content, (code) => | |
| 91 | + Effect.runPromise(runner.run({ code })), | |
| 92 | + ); | |
| 93 | 93 | const response = formatDiscordResponse({ |
| 94 | 94 | prompt: message.content, |
| 95 | − generatedCode: generated.code, | |
| 96 | − rationale: generated.rationale, | |
| 95 | + text: generated.text, | |
| 96 | + planner: generated.planner, | |
| 97 | 97 | provider: generated.provider, |
| 98 | − result, | |
| 98 | + runs: generated.runs, | |
| 99 | 99 | }); |
| 100 | 100 | |
| 101 | 101 | await ack.edit(response); |
| @@ -16,9 +16,9 @@ · 15 unmodified lines | ||
| 16 | 16 | "apps/discord-bot": { |
| 17 | 17 | "name": "@openassistant/discord-bot", |
| 18 | 18 | "dependencies": { |
| 19 | + "@ai-sdk/anthropic": "^2.0.0", | |
| 19 | 20 | "@openassistant/core": "workspace:*", |
| 20 | 21 | "ai": "^6.0.73", |
| 21 | − "ai-sdk-provider-claude-code": "^3.3.6", | |
| 22 | 22 | "discord.js": "^14.17.3", |
| 23 | 23 | "effect": "latest", |
| 24 | 24 | "zod": "^4.3.6", |
| @@ -39,13 +39,13 @@ · 14 unmodified lines | ||
| 39 | 39 | }, |
| 40 | 40 | }, |
| 41 | 41 | "packages": { |
| 42 | − "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw=="], | |
| 42 | + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s7T5etVW7k4y3p1TDpTGa7xBddvSPWTvRa+EHAI0MPCrVh2dVFmAk+Dm6I8zlr1vz45SBpHvKR3SP29aGXdrUQ=="], | |
| 43 | 43 | |
| 44 | − "@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="], | |
| 44 | + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw=="], | |
| 45 | 45 | |
| 46 | − "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="], | |
| 46 | + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], | |
| 47 | 47 | |
| 48 | − "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.32", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ=="], | |
| 48 | + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], | |
| 49 | 49 | |
| 50 | 50 | "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], |
| 51 | 51 | |
| @@ -115,36 +115,6 @@ · 63 unmodified lines | ||
| 115 | 115 | |
| 116 | 116 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 117 | 117 | |
| 118 | − "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], | |
| 119 | − | |
| 120 | − "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], | |
| 121 | − | |
| 122 | − "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], | |
| 123 | − | |
| 124 | − "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], | |
| 125 | − | |
| 126 | − "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], | |
| 127 | − | |
| 128 | − "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], | |
| 129 | − | |
| 130 | − "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], | |
| 131 | − | |
| 132 | − "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], | |
| 133 | − | |
| 134 | − "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], | |
| 135 | − | |
| 136 | − "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], | |
| 137 | − | |
| 138 | − "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], | |
| 139 | − | |
| 140 | − "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], | |
| 141 | − | |
| 142 | − "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], | |
| 143 | − | |
| 144 | − "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], | |
| 145 | − | |
| 146 | − "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], | |
| 147 | − | |
| 148 | 118 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], |
| 149 | 119 | |
| 150 | 120 | "@openassistant/core": ["@openassistant/core@workspace:packages/core"], |
| @@ -261,8 +231,6 @@ · 110 unmodified lines | ||
| 261 | 231 | |
| 262 | 232 | "ai": ["ai@6.0.73", "", { "dependencies": { "@ai-sdk/gateway": "3.0.36", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p2/ICXIjAM4+bIFHEkAB+l58zq+aTmxAkotsb6doNt/CEms72zt6gxv2ky1fQDwU4ecMOcmMh78VJUSEKECzlg=="], |
| 263 | 233 | |
| 264 | − "ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.3.6", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.2.23" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lDJJrQqe2A6tAH96ZLsZW39blkBh3jF1A4rs8B6CJyjNhLNeq46KmxkKDPYDoZw4xbILvnAknjXQNhMSZLnEYg=="], | |
| 265 | − | |
| 266 | 234 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| 267 | 235 | |
| 268 | 236 | "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], |
| @@ -377,8 +345,16 @@ · 108 unmodified lines | ||
| 377 | 345 | |
| 378 | 346 | "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], |
| 379 | 347 | |
| 348 | + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="], | |
| 349 | + | |
| 350 | + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="], | |
| 351 | + | |
| 380 | 352 | "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], |
| 381 | 353 | |
| 382 | 354 | "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], |
| 355 | + | |
| 356 | + "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="], | |
| 357 | + | |
| 358 | + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="], | |
| 383 | 359 | } |
| 384 | 360 | } |
| @@ -79,9 +79,10 @@ describe("codemode runner", () => { · 78 unmodified lines | ||
| 79 | 79 | }), |
| 80 | 80 | ); |
| 81 | 81 | |
| 82 | − it.effect("runs writes when approved and records success receipt", () => | |
| 82 | + it.effect("runs three writes with three approval requests and three success receipts", () => | |
| 83 | 83 | Effect.gen(function* () { |
| 84 | − let ranMutation = false; | |
| 84 | + let ranMutations = 0; | |
| 85 | + const approvalCalls: Array<{ toolPath: string }> = []; | |
| 85 | 86 | const runner = createCodeModeRunner({ |
| 86 | 87 | tools: { |
| 87 | 88 | calendar: { |
| @@ -90,28 +91,45 @@ describe("codemode runner", () => { · 2 unmodified lines | ||
| 90 | 91 | approval: "required", |
| 91 | 92 | run: (input: { id: string; title: string }) => |
| 92 | 93 | Effect.sync(() => { |
| 93 | − ranMutation = true; | |
| 94 | + ranMutations += 1; | |
| 94 | 95 | return { id: input.id, title: input.title }; |
| 95 | 96 | }), |
| 96 | 97 | }), |
| 97 | 98 | }, |
| 98 | 99 | }, |
| 99 | − requestApproval: () => Effect.succeed("approved" as const), | |
| 100 | + requestApproval: (request) => | |
| 101 | + Effect.sync(() => { | |
| 102 | + approvalCalls.push({ toolPath: request.toolPath }); | |
| 103 | + return "approved" as const; | |
| 104 | + }), | |
| 100 | 105 | }); |
| 101 | 106 | |
| 102 | 107 | const result = yield* runner.run({ |
| 103 | − code: "const out = await tools.calendar.update({ id: 'evt_3', title: 'Dinner' }); return out.title;", | |
| 108 | + code: ` | |
| 109 | +const first = await tools.calendar.update({ id: 'evt_3', title: 'Dinner' }); | |
| 110 | +const second = await tools.calendar.update({ id: 'evt_4', title: 'Lunch' }); | |
| 111 | +const third = await tools.calendar.update({ id: 'evt_5', title: 'Breakfast' }); | |
| 112 | +return [first.title, second.title, third.title]; | |
| 113 | +`, | |
| 104 | 114 | }); |
| 105 | 115 | |
| 106 | 116 | expect(result.ok).toBe(true); |
| 107 | 117 | if (result.ok) { |
| 108 | − expect(result.value).toBe("Dinner"); | |
| 118 | + expect(result.value).toEqual(["Dinner", "Lunch", "Breakfast"]); | |
| 119 | + } | |
| 120 | + expect(ranMutations).toBe(3); | |
| 121 | + expect(approvalCalls).toHaveLength(3); | |
| 122 | + expect(approvalCalls.map((call) => call.toolPath)).toEqual([ | |
| 123 | + "calendar.update", | |
| 124 | + "calendar.update", | |
| 125 | + "calendar.update", | |
| 126 | + ]); | |
| 127 | + expect(result.receipts).toHaveLength(3); | |
| 128 | + for (const receipt of result.receipts) { | |
| 129 | + expect(receipt.decision).toBe("approved"); | |
| 130 | + expect(receipt.status).toBe("succeeded"); | |
| 131 | + expect(receipt.toolPath).toBe("calendar.update"); | |
| 109 | 132 | } |
| 110 | − expect(ranMutation).toBe(true); | |
| 111 | − expect(result.receipts).toHaveLength(1); | |
| 112 | − expect(result.receipts[0]?.decision).toBe("approved"); | |
| 113 | − expect(result.receipts[0]?.status).toBe("succeeded"); | |
| 114 | − expect(result.receipts[0]?.toolPath).toBe("calendar.update"); | |
| 115 | 133 | }), |
| 116 | 134 | ); |
| 117 | 135 | }); |