History

claude max works (sorry dario)

Rhys Sullivan committed 9 months ago

Commit 2fe766c1 parent 29fe12712 files · +929 −488Browse files

Unified · Split

12 files
  1. apps/discord-bot/package.json +2 −2
  2. apps/discord-bot/src/agent-loop.test.ts +105 −0
  3. apps/discord-bot/src/agent-loop.ts +179 −0
  4. apps/discord-bot/src/anthropic-auth.ts +133 −0
  5. apps/discord-bot/src/anthropic-provider.ts +284 −0
  6. apps/discord-bot/src/codegen.test.ts +0 −77
  7. apps/discord-bot/src/codegen.ts +0 −322
  8. apps/discord-bot/src/format-response.test.ts +76 −0
  9. apps/discord-bot/src/format-response.ts +101 −32
  10. apps/discord-bot/src/index.ts +7 −7
  11. bun.lock +13 −37
  12. packages/core/src/codemode/runner.test.ts +29 −11
apps/discord-bot/package.json+2−2
@@ -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"
apps/discord-bot/src/agent-loop.test.ts+105−0
@@ -0,0 +1,105 @@
1import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
2import { describe, expect, it } from "vitest";
3import { runAgentLoop } from "./agent-loop.js";
4
5describe("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
86function 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}
apps/discord-bot/src/agent-loop.ts+179−0
@@ -0,0 +1,179 @@
1import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
2import { generateText, stepCountIs, tool } from "ai";
3import { getAnthropicModel } from "./anthropic-provider.js";
4import { z } from "zod";
5
6export interface AgentCodeRun {
7 code: string;
8 result: CodeModeRunResult;
9 reason?: string | undefined;
10}
11
12export interface AgentLoopResult {
13 provider: "claude";
14 planner: string;
15 text: string;
16 runs: AgentCodeRun[];
17}
18
19interface RunCodeToolInput {
20 code: string;
21 reason?: string | undefined;
22}
23
24interface RunCodeToolOutput {
25 ok: boolean;
26 receipts: ToolCallReceipt[];
27 value?: unknown;
28 error?: string;
29}
30
31interface RunWithClaudeInput {
32 prompt: string;
33 now: Date;
34 executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>;
35}
36
37interface RunWithClaudeOutput {
38 text: string;
39 modelID: string;
40 authSource: string;
41}
42
43interface AgentLoopOptions {
44 now?: Date;
45 runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>;
46}
47
48const DEFAULT_ANTHROPIC_MODEL =
49 readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ??
50 readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ??
51 "claude-3-7-sonnet-latest";
52const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000);
53const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8);
54
55const RUN_CODE_TOOL_SCHEMA = z.object({
56 code: z.string().min(1),
57 reason: z.string().min(1).optional(),
58});
59
60export 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
114async 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
147function 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
158function 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
169function describeUnknown(error: unknown): string {
170 if (error instanceof Error) {
171 return error.message;
172 }
173 return String(error);
174}
175
176function 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}
apps/discord-bot/src/anthropic-auth.ts+133−0
@@ -0,0 +1,133 @@
1import { homedir } from "node:os";
2import { join } from "node:path";
3
4const DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
5
6export 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
21export 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
53async 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
106function 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
114function asString(value: unknown): string | undefined {
115 return typeof value === "string" && value.length > 0 ? value : undefined;
116}
117
118function asNumber(value: unknown): number | undefined {
119 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
120}
121
122function 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
130function 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}
apps/discord-bot/src/anthropic-provider.ts+284−0
@@ -0,0 +1,284 @@
1import { createAnthropic } from "@ai-sdk/anthropic";
2import type { LanguageModel } from "ai";
3import { resolveAnthropicAuth, type ResolvedAnthropicAuth } from "./anthropic-auth.js";
4
5const OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
6const REQUIRED_OAUTH_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
7const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)";
8const TOOL_PREFIX = "mcp_";
9
10let cachedAuthPromise: Promise<ResolvedAnthropicAuth | null> | undefined;
11let refreshInFlight: Promise<void> | undefined;
12
13export interface AnthropicModelSelection {
14 model: LanguageModel;
15 authSource: string;
16 authMode: "api" | "oauth";
17}
18
19export 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
71async function getResolvedAuth(): Promise<ResolvedAnthropicAuth | null> {
72 if (!cachedAuthPromise) {
73 cachedAuthPromise = resolveAnthropicAuth();
74 }
75 return cachedAuthPromise;
76}
77
78async 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
99async 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
135function 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
144function 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
163function 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
228function 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
255async 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}
apps/discord-bot/src/codegen.test.ts+0−77
@@ -1,77 +0,0 @@
1import { describe, expect, it } from "vitest";
2import { generateCodeFromPrompt } from "./codegen.js";
3
4describe("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});
apps/discord-bot/src/codegen.ts+0−322
@@ -1,322 +0,0 @@
1import { generateObject } from "ai";
2import { createClaudeCode } from "ai-sdk-provider-claude-code";
3import { z } from "zod";
4
5export interface GeneratedCode {
6 code: string;
7 rationale: string;
8 provider: "claude" | "heuristic";
9}
10
11interface CodegenOptions {
12 now?: Date;
13 provider?: "claude" | "heuristic";
14 generateWithClaude?: (params: { prompt: string; now: Date }) => Promise<{ code: string; rationale: string }>;
15}
16
17const DEFAULT_PROVIDER = resolveDefaultProvider();
18const DEFAULT_CLAUDE_MODEL = readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() || "sonnet";
19const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 30_000);
20const CLAUDE_PATH = readEnv("OPENASSISTANT_CLAUDE_PATH")?.trim();
21const CODEGEN_SCHEMA = z.object({
22 code: z.string().min(1),
23 rationale: z.string().min(1),
24});
25
26const claudeCode = createClaudeCode({
27 defaultSettings: {
28 permissionMode: "dontAsk",
29 tools: [],
30 maxTurns: 1,
31 ...(CLAUDE_PATH ? { pathToClaudeCodeExecutable: CLAUDE_PATH } : {}),
32 },
33});
34
35export 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
63async 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
85function 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
104function 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
112function 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
149function 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
159function cleanTitle(input: string): string {
160 return input.replace(/^my\s+/i, "").replace(/^calendar\s+/i, "").trim();
161}
162
163function 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
175function describeUnknown(error: unknown): string {
176 if (error instanceof Error) {
177 return error.message;
178 }
179 return String(error);
180}
181
182function 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
187type CalendarUpdateInput = {
188 title: string;
189 startsAt: string;
190 notes: string;
191};
192
193const 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
203function 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
212function 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
242function 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
250function 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
289function 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}
apps/discord-bot/src/format-response.test.ts+76−0
@@ -0,0 +1,76 @@
1import type { CodeModeRunResult } from "@openassistant/core";
2import { describe, expect, it } from "vitest";
3import { formatDiscordResponse } from "./format-response.js";
4
5describe("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
60function 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}
apps/discord-bot/src/format-response.ts+101−32
@@ -1,55 +1,124 @@
1import type { CodeModeRunResult } from "@openassistant/core";1import type { ToolCallReceipt } from "@openassistant/core";
2import type { AgentCodeRun } from "./agent-loop.js";
2 3
3export function formatDiscordResponse(params: {4export 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
24function formatReceipts(receipts: CodeModeRunResult["receipts"]): string {30function summarizeReceipts(receipts: ToolCallReceipt[]): string {
25 if (receipts.length === 0) {31 if (receipts.length === 0) {
26 return "- none";32 return "- none";
27 }33 }
28 return receipts34
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
43function safeJson(value: unknown): string {103function 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
51function 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
55function truncateDiscord(value: string): string {124function truncateDiscord(value: string): string {
apps/discord-bot/src/index.ts+7−7
@@ -10,8 +10,8 @@ import { · 9 unmodified lines
10} from "discord.js";10} from "discord.js";
11import { Effect } from "effect";11import { Effect } from "effect";
12import { DiscordApprovalBridge } from "./approval-bridge.js";12import { DiscordApprovalBridge } from "./approval-bridge.js";
13import { runAgentLoop } from "./agent-loop.js";
13import { InMemoryCalendarStore } from "./calendar-store.js";14import { InMemoryCalendarStore } from "./calendar-store.js";
14import { generateCodeFromPrompt } from "./codegen.js";
15import { formatDiscordResponse } from "./format-response.js";15import { formatDiscordResponse } from "./format-response.js";
16import { createToolTree } from "./tools.js";16import { 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);
bun.lock+13−37
@@ -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}
packages/core/src/codemode/runner.test.ts+29−11
@@ -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: `
109const first = await tools.calendar.update({ id: 'evt_3', title: 'Dinner' });
110const second = await tools.calendar.update({ id: 'evt_4', title: 'Lunch' });
111const third = await tools.calendar.update({ id: 'evt_5', title: 'Breakfast' });
112return [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});