Unified · Split
| @@ -5,16 +5,16 @@ · 4 unmodified lines | |||
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "with-env": "bun run --env-file=../../.env --", | 6 | "with-env": "bun run --env-file=../../.env --", |
| 7 | "with-env:prod": "bun run --env-file=../../.env.production --", | 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 | "start": "bun with-env bun run src/index.ts", | 9 | "start": "bun with-env bun run src/index.ts", |
| 10 | "start:prod": "bun with-env:prod bun run src/index.ts", | 10 | "start:prod": "bun with-env:prod bun run src/index.ts", |
| 11 | "typecheck": "tsgo --noEmit --incremental false -p tsconfig.json", | 11 | "typecheck": "tsgo --noEmit --incremental false -p tsconfig.json", |
| 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});\"" | 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 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 15 | "@openassistant/core": "workspace:*", | 16 | "@openassistant/core": "workspace:*", |
| 16 | "ai": "^6.0.73", | 17 | "ai": "^6.0.73", |
| 17 | "ai-sdk-provider-claude-code": "^3.3.6", | ||
| 18 | "discord.js": "^14.17.3", | 18 | "discord.js": "^14.17.3", |
| 19 | "effect": "latest", | 19 | "effect": "latest", |
| 20 | "zod": "^4.3.6" | 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 | export function formatDiscordResponse(params: { | 4 | export function formatDiscordResponse(params: { |
| 4 | prompt: string; | 5 | prompt: string; |
| 5 | generatedCode: string; | 6 | text: string; |
| 6 | rationale: string; | 7 | planner: string; |
| 7 | provider: "claude" | "heuristic"; | 8 | provider: "claude"; |
| 8 | result: CodeModeRunResult; | 9 | runs: AgentCodeRun[]; |
| 9 | }): string { | 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 | const sections = [ | 14 | const sections = [ |
| 12 | `Prompt: ${prompt}`, | 15 | `Request: ${prompt}`, |
| 13 | `Planner (${provider}): ${rationale}`, | 16 | `Reply: ${text || "(no text returned)"}`, |
| 14 | "Code:", | 17 | `Planner: ${provider} - ${planner}`, |
| 15 | fenced("ts", generatedCode), | 18 | `Code runs: ${runs.length}`, |
| 16 | result.ok ? `Result: ${safeJson(result.value)}` : `Error: ${result.error}`, | 19 | `Tool activity:\n${receiptSummary}`, |
| 17 | "Receipts:", | ||
| 18 | formatReceipts(result.receipts), | ||
| 19 | ]; | 20 | ]; |
| 20 | 21 | ||
| 22 | const failures = formatFailures(runs); | ||
| 23 | if (failures) { | ||
| 24 | sections.push(`Failures:\n${failures}`); | ||
| 25 | } | ||
| 26 | |||
| 21 | return truncateDiscord(sections.join("\n\n")); | 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 | if (receipts.length === 0) { | 31 | if (receipts.length === 0) { |
| 26 | return "- none"; | 32 | return "- none"; |
| 27 | } | 33 | } |
| 28 | return receipts | 34 | |
| 29 | .map((receipt) => { | 35 | const byTool = new Map< |
| 30 | const parts = [ | 36 | string, |
| 31 | `- \`${receipt.toolPath}\``, | 37 | { |
| 32 | `decision=${receipt.decision}`, | 38 | calls: number; |
| 33 | `status=${receipt.status}`, | 39 | approved: number; |
| 34 | ]; | 40 | auto: number; |
| 35 | if (receipt.inputPreview) { | 41 | denied: number; |
| 36 | parts.push(`input="${receipt.inputPreview}"`); | 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 | .join("\n"); | 100 | .join("\n"); |
| 41 | } | 101 | } |
| 42 | 102 | ||
| 43 | function safeJson(value: unknown): string { | 103 | function formatFailures(runs: AgentCodeRun[]): string | null { |
| 44 | try { | 104 | const failures: string[] = []; |
| 45 | return JSON.stringify(value); | 105 | |
| 46 | } catch { | 106 | for (const run of runs) { |
| 47 | return String(value); | 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 { | 121 | return failures.length > 0 ? failures.join("\n") : null; |
| 52 | return `\`\`\`${lang}\n${content}\n\`\`\``; | ||
| 53 | } | 122 | } |
| 54 | 123 | ||
| 55 | function truncateDiscord(value: string): string { | 124 | function truncateDiscord(value: string): string { |
| @@ -10,8 +10,8 @@ import { · 9 unmodified lines | |||
| 10 | } from "discord.js"; | 10 | } from "discord.js"; |
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | import { DiscordApprovalBridge } from "./approval-bridge.js"; | 12 | import { DiscordApprovalBridge } from "./approval-bridge.js"; |
| 13 | import { runAgentLoop } from "./agent-loop.js"; | ||
| 13 | import { InMemoryCalendarStore } from "./calendar-store.js"; | 14 | import { InMemoryCalendarStore } from "./calendar-store.js"; |
| 14 | import { generateCodeFromPrompt } from "./codegen.js"; | ||
| 15 | import { formatDiscordResponse } from "./format-response.js"; | 15 | import { formatDiscordResponse } from "./format-response.js"; |
| 16 | import { createToolTree } from "./tools.js"; | 16 | import { createToolTree } from "./tools.js"; |
| 17 | 17 | ||
| @@ -73,8 +73,6 @@ async function handleMessage(message: Message): Promise<void> { · 55 unmodified lines | |||
| 73 | return; | 73 | return; |
| 74 | } | 74 | } |
| 75 | 75 | ||
| 76 | const generated = await generateCodeFromPrompt(message.content); | ||
| 77 | |||
| 78 | const runner = createCodeModeRunner({ | 76 | const runner = createCodeModeRunner({ |
| 79 | tools, | 77 | tools, |
| 80 | requestApproval: (request) => | 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 | const response = formatDiscordResponse({ | 93 | const response = formatDiscordResponse({ |
| 94 | prompt: message.content, | 94 | prompt: message.content, |
| 95 | generatedCode: generated.code, | 95 | text: generated.text, |
| 96 | rationale: generated.rationale, | 96 | planner: generated.planner, |
| 97 | provider: generated.provider, | 97 | provider: generated.provider, |
| 98 | result, | 98 | runs: generated.runs, |
| 99 | }); | 99 | }); |
| 100 | 100 | ||
| 101 | await ack.edit(response); | 101 | await ack.edit(response); |
| @@ -16,9 +16,9 @@ · 15 unmodified lines | |||
| 16 | "apps/discord-bot": { | 16 | "apps/discord-bot": { |
| 17 | "name": "@openassistant/discord-bot", | 17 | "name": "@openassistant/discord-bot", |
| 18 | "dependencies": { | 18 | "dependencies": { |
| 19 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 19 | "@openassistant/core": "workspace:*", | 20 | "@openassistant/core": "workspace:*", |
| 20 | "ai": "^6.0.73", | 21 | "ai": "^6.0.73", |
| 21 | "ai-sdk-provider-claude-code": "^3.3.6", | ||
| 22 | "discord.js": "^14.17.3", | 22 | "discord.js": "^14.17.3", |
| 23 | "effect": "latest", | 23 | "effect": "latest", |
| 24 | "zod": "^4.3.6", | 24 | "zod": "^4.3.6", |
| @@ -39,13 +39,13 @@ · 14 unmodified lines | |||
| 39 | }, | 39 | }, |
| 40 | }, | 40 | }, |
| 41 | "packages": { | 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 | "@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=="], | 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 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], | 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 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], | 118 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], |
| 149 | 119 | ||
| 150 | "@openassistant/core": ["@openassistant/core@workspace:packages/core"], | 120 | "@openassistant/core": ["@openassistant/core@workspace:packages/core"], |
| @@ -261,8 +231,6 @@ · 110 unmodified lines | |||
| 261 | 231 | ||
| 262 | "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=="], | 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 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | 234 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| 267 | 235 | ||
| 268 | "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], | 236 | "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], |
| @@ -377,8 +345,16 @@ · 108 unmodified lines | |||
| 377 | 345 | ||
| 378 | "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], | 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 | "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], | 352 | "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], |
| 381 | 353 | ||
| 382 | "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], | 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 | Effect.gen(function* () { | 83 | Effect.gen(function* () { |
| 84 | let ranMutation = false; | 84 | let ranMutations = 0; |
| 85 | const approvalCalls: Array<{ toolPath: string }> = []; | ||
| 85 | const runner = createCodeModeRunner({ | 86 | const runner = createCodeModeRunner({ |
| 86 | tools: { | 87 | tools: { |
| 87 | calendar: { | 88 | calendar: { |
| @@ -90,28 +91,45 @@ describe("codemode runner", () => { · 2 unmodified lines | |||
| 90 | approval: "required", | 91 | approval: "required", |
| 91 | run: (input: { id: string; title: string }) => | 92 | run: (input: { id: string; title: string }) => |
| 92 | Effect.sync(() => { | 93 | Effect.sync(() => { |
| 93 | ranMutation = true; | 94 | ranMutations += 1; |
| 94 | return { id: input.id, title: input.title }; | 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 | const result = yield* runner.run({ | 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 | expect(result.ok).toBe(true); | 116 | expect(result.ok).toBe(true); |
| 107 | if (result.ok) { | 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 | }); |