Compare commits

...
14 Commits
Author SHA1 Message Date
ginnoir 4ea9b86f8e chore: release v0.6.10
CI / checks (push) Skipped
Release Image / build-and-push (push) Successful in 7m45s
2026-07-18 17:25:59 -05:00
ginnoir 9b3bd02b0b feat: open notes in view mode with optional editor
default to reading the note; edit opens the form alone without a
side preview since the rich text editor already shows live content.
2026-07-18 17:25:50 -05:00
ginnoir 0be93d088b chore: release v0.6.9
CI / checks (push) Skipped
Release Image / build-and-push (push) Successful in 8m21s
2026-07-18 17:23:06 -05:00
ginnoir 832e7265c9 fix: build share urls from auth_url instead of localhost
docker build sets next_public_app_url to localhost; preferring it over
runtime auth_url made prod share links point at localhost:3000.
2026-07-18 17:22:58 -05:00
ginnoir d1e295165a chore: release v0.6.8
CI / checks (push) Skipped
Release Image / build-and-push (push) Successful in 9m8s
2026-07-18 17:19:56 -05:00
ginnoir 969521ac3f fix: restore share and note scrolling on ios pwa
bare routes lacked a scrollport under overflow:hidden html/body.
wide rich-text tables used overflow-x:auto alone, which computes
overflow-y:auto and traps vertical touch on iphone. force ink palette
on /s/* for a consistent public share look.
2026-07-18 17:19:41 -05:00
ginnoir 908764873f chore: release v0.6.7
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m37s
2026-07-12 22:28:20 -05:00
ginnoir 043d28539e fix(garden): add ikea cabinet and acrylic case container types 2026-07-12 22:28:10 -05:00
ginnoir 3efbf75179 chore: release v0.6.6
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m7s
2026-07-12 22:18:50 -05:00
ginnoir b8229eb9b5 fix(garden): add carnivore plant category 2026-07-12 22:18:26 -05:00
ginnoir 7bf4a801b3 chore: release v0.6.5
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m19s
2026-07-09 00:38:50 -05:00
ginnoir 3e8fe2d06d fix(agent): call api v1 via loopback instead of public url
CI / checks (push) Has been cancelled
Tool self-fetches were hairpinning to fam.ginnoir.com and failing,
so list_calendars/create_event returned 500 and the model gave up.
2026-07-09 00:38:33 -05:00
ginnoir 60eb101015 chore: release v0.6.4
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m30s
2026-07-08 22:51:47 -05:00
ginnoir 72d123b9a0 fix(agent): stop tool thrash after writes and log rounds
CI / checks (push) Has been cancelled
Cap get_api_docs, break duplicate tool rounds, force a reply after
successful writes, and log each tool call so limit hits are diagnosable.
2026-07-08 22:51:31 -05:00
22 changed files with 704 additions and 63 deletions
+2
View File
@@ -57,3 +57,5 @@ LLM_API_KEY=
LLM_MODEL=llama3.2 LLM_MODEL=llama3.2
# IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago. # IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago.
HOUSEHOLD_TIMEZONE=America/Chicago HOUSEHOLD_TIMEZONE=America/Chicago
# Optional override for agent tool → /api/v1 self-calls (defaults to http://127.0.0.1:$PORT).
# INTERNAL_API_BASE_URL=http://127.0.0.1:3000
+42
View File
@@ -1,5 +1,47 @@
# Changelog # Changelog
## [0.6.10](https://github.com/ginnoir/famapp/compare/v0.6.9...v0.6.10) (2026-07-18)
### Features
- open notes in view mode with optional editor ([9b3bd02](https://github.com/ginnoir/famapp/commit/9b3bd02b0bafc89a514ca8fd4434f9659b9ee4c2))
## [0.6.9](https://github.com/ginnoir/famapp/compare/v0.6.8...v0.6.9) (2026-07-18)
### Bug Fixes
- build share urls from auth_url instead of localhost ([832e726](https://github.com/ginnoir/famapp/commit/832e7265c9354edf86f28f72f129ee34da8c3d04))
## [0.6.8](https://github.com/ginnoir/famapp/compare/v0.6.7...v0.6.8) (2026-07-18)
### Bug Fixes
- restore share and note scrolling on ios pwa ([969521a](https://github.com/ginnoir/famapp/commit/969521ac3f527a939988053b453881ed3560eb61))
## [0.6.7](https://github.com/ginnoir/famapp/compare/v0.6.6...v0.6.7) (2026-07-13)
### Bug Fixes
- **garden:** add ikea cabinet and acrylic case container types ([043d285](https://github.com/ginnoir/famapp/commit/043d28539ec08dd46a097970b4e876bfb1ba7aa1))
## [0.6.6](https://github.com/ginnoir/famapp/compare/v0.6.5...v0.6.6) (2026-07-13)
### Bug Fixes
- **garden:** add carnivore plant category ([b8229eb](https://github.com/ginnoir/famapp/commit/b8229eb9b5550cde98e5446615873e727ead05a8))
## [0.6.5](https://github.com/ginnoir/famapp/compare/v0.6.4...v0.6.5) (2026-07-09)
### Bug Fixes
- **agent:** call api v1 via loopback instead of public url ([3e8fe2d](https://github.com/ginnoir/famapp/commit/3e8fe2d06dc31d3a1058b4b8942c46b1220a6b4c))
## [0.6.4](https://github.com/ginnoir/famapp/compare/v0.6.3...v0.6.4) (2026-07-09)
### Bug Fixes
- **agent:** stop tool thrash after writes and log rounds ([72d123b](https://github.com/ginnoir/famapp/commit/72d123b9a0f67589d92614a8d2138912585b0d79))
## [0.6.3](https://github.com/ginnoir/famapp/compare/v0.6.2...v0.6.3) (2026-07-09) ## [0.6.3](https://github.com/ginnoir/famapp/compare/v0.6.2...v0.6.3) (2026-07-09)
### Bug Fixes ### Bug Fixes
+2
View File
@@ -16,7 +16,9 @@ ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL=postgres://build:build@localhost:5432/build ENV DATABASE_URL=postgres://build:build@localhost:5432/build
ENV AUTH_SECRET=build-time-placeholder ENV AUTH_SECRET=build-time-placeholder
# Build-only placeholder. Runtime public URL is AUTH_URL from stack.env — never prefer this.
ENV NEXT_PUBLIC_APP_URL=http://localhost:3000 ENV NEXT_PUBLIC_APP_URL=http://localhost:3000
ENV AUTH_URL=http://localhost:3000
COPY . . COPY . .
RUN pnpm install --offline --frozen-lockfile RUN pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "famapp", "name": "famapp",
"version": "0.6.3", "version": "0.6.10",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.3", "packageManager": "pnpm@10.33.3",
+13 -2
View File
@@ -725,9 +725,19 @@
.scroll-area { .scroll-area {
flex: 1; flex: 1;
overflow: auto; overflow-x: hidden;
overflow-y: auto;
padding: 20px 24px 60px; padding: 20px 24px 60px;
} }
/* Bare routes (/s/*, /login, signed-out) — same scrollport as .scroll-area
without app chrome padding. Needed because html/body are overflow:hidden. */
.bare-scroll {
height: 100%;
height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
}
:where(html[data-nav="bottom"]) .scroll-area, :where(html[data-nav="bottom"]) .scroll-area,
:where(html[data-nav="fab"]) .scroll-area { :where(html[data-nav="fab"]) .scroll-area {
padding: 12px 14px 74px; padding: 12px 14px 74px;
@@ -1617,7 +1627,8 @@ select {
} }
/* Momentum scrolling + prevent page bounce fighting in-app scroll */ /* Momentum scrolling + prevent page bounce fighting in-app scroll */
.scroll-area { .scroll-area,
.bare-scroll {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
} }
+14 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { headers } from "next/headers";
import "./globals.css"; import "./globals.css";
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google"; import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -71,9 +72,13 @@ export const metadata: Metadata = {
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark. // Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored. // Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
// Public share pages always use ink so guests get a neutral, consistent look.
const prePaintScript = `(function(){ const prePaintScript = `(function(){
try { try {
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay'; var isShare = location.pathname.indexOf('/s/') === 0;
var palette = isShare
? 'ink'
: (localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay');
var mode = localStorage.getItem('themeMode') || 'system'; var mode = localStorage.getItem('themeMode') || 'system';
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans'; var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
var density = localStorage.getItem('themeDensity') || 'regular'; var density = localStorage.getItem('themeDensity') || 'regular';
@@ -106,6 +111,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let assistantName = DEFAULT_ASSISTANT_NAME; let assistantName = DEFAULT_ASSISTANT_NAME;
let assistantModel: string | null = null; let assistantModel: string | null = null;
const headersList = await headers();
const pathname = headersList.get("x-pathname") ?? "";
const isSharePage = pathname === "/s" || pathname.startsWith("/s/");
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
signedIn = true; signedIn = true;
@@ -146,6 +155,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
.orderBy(asc(dashboards.position), asc(dashboards.createdAt)); .orderBy(asc(dashboards.position), asc(dashboards.createdAt));
} }
if (isSharePage) {
palette = "ink";
}
// Server-side initial dark guess: only for `dark` mode (system mode is corrected // Server-side initial dark guess: only for `dark` mode (system mode is corrected
// before paint by the inline script). Avoids a flash on signed-in users. // before paint by the inline script). Avoids a flash on signed-in users.
const isDark = mode === "dark"; const isDark = mode === "dark";
+2 -1
View File
@@ -24,7 +24,8 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
if (bare || !signedIn) { if (bare || !signedIn) {
// No shell — share viewer and signed-out pages render bare. // No shell — share viewer and signed-out pages render bare.
return <>{children}</>; // html/body are overflow:hidden for the app grid; bare pages need their own scroller.
return <div className="bare-scroll">{children}</div>;
} }
const useTopVariant = navStyle === "top-nav"; const useTopVariant = navStyle === "top-nav";
+8
View File
@@ -64,6 +64,9 @@
margin: 0.8em 0; margin: 0.8em 0;
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
padding: 0.75rem 0.9rem; padding: 0.75rem 0.9rem;
} }
@@ -91,7 +94,12 @@
display: block; display: block;
margin: 0.8em 0; margin: 0.8em 0;
max-width: 100%; max-width: 100%;
/* overflow-x alone computes overflow-y to auto, which traps vertical
touch scrolling on iOS when a wide table fills the viewport. */
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
width: max-content; width: max-content;
} }
+8
View File
@@ -0,0 +1,8 @@
/** Public origin for absolute links. AUTH_URL wins — Docker build sets NEXT_PUBLIC to localhost. */
export function getAppPublicUrl(): string {
const raw =
process.env.AUTH_URL?.trim() ||
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
"http://localhost:3000";
return raw.replace(/\/$/, "");
}
+2 -3
View File
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "crypto"; import { createHash, randomBytes } from "crypto";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import type { ApiAuthContext } from "@/lib/api-auth"; import type { ApiAuthContext } from "@/lib/api-auth";
import { getAppPublicUrl } from "@/lib/app-url";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getEntityType, getRegistry } from "./registry"; import { getEntityType, getRegistry } from "./registry";
import { shareLinks } from "./schema"; import { shareLinks } from "./schema";
@@ -27,9 +28,7 @@ function hashToken(raw: string): string {
} }
function buildUrl(token: string): string { function buildUrl(token: string): string {
const base = return `${getAppPublicUrl()}/s/${token}`;
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
return `${base}/s/${token}`;
} }
export function listShareableEntityTypes() { export function listShareableEntityTypes() {
+2 -3
View File
@@ -2,6 +2,7 @@
import { createHash, randomBytes } from "crypto"; import { createHash, randomBytes } from "crypto";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { getAppPublicUrl } from "@/lib/app-url";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { getEntityType } from "./registry"; import { getEntityType } from "./registry";
@@ -21,9 +22,7 @@ function hashToken(raw: string): string {
} }
function buildUrl(token: string): string { function buildUrl(token: string): string {
const base = return `${getAppPublicUrl()}/s/${token}`;
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
return `${base}/s/${token}`;
} }
export async function createShareLink( export async function createShareLink(
@@ -4,6 +4,7 @@ export type AssistantChatMessage = {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
imageUrl?: string; imageUrl?: string;
toolCalls?: Array<{ name: string; status: number }>;
}; };
const STORAGE_VERSION = "v2"; const STORAGE_VERSION = "v2";
@@ -19,6 +20,14 @@ function isValidMessage(value: unknown): value is AssistantChatMessage {
if (row.role !== "user" && row.role !== "assistant") return false; if (row.role !== "user" && row.role !== "assistant") return false;
if (typeof row.content !== "string" || row.content.trim().length === 0) return false; if (typeof row.content !== "string" || row.content.trim().length === 0) return false;
if (row.imageUrl !== undefined && typeof row.imageUrl !== "string") return false; if (row.imageUrl !== undefined && typeof row.imageUrl !== "string") return false;
if (row.toolCalls !== undefined) {
if (!Array.isArray(row.toolCalls)) return false;
for (const call of row.toolCalls) {
if (!call || typeof call !== "object") return false;
const entry = call as Record<string, unknown>;
if (typeof entry.name !== "string" || typeof entry.status !== "number") return false;
}
}
return true; return true;
} }
@@ -229,7 +229,11 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
setMessages((current) => [ setMessages((current) => [
...current, ...current,
{ role: "assistant", content: result.message.content }, {
role: "assistant",
content: result.message.content,
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
},
]); ]);
scrollToBottom(); scrollToBottom();
} catch (err) { } catch (err) {
@@ -347,6 +351,13 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
/> />
) : null} ) : null}
{message.content} {message.content}
{message.role === "assistant" &&
message.toolCalls &&
message.toolCalls.length > 0 ? (
<p className="mt-1 text-[10px] text-muted-foreground">
{message.toolCalls.map((call) => `${call.name}${call.status}`).join(" · ")}
</p>
) : null}
</div> </div>
))} ))}
+85
View File
@@ -0,0 +1,85 @@
const MUTATION_TOOLS = new Set([
"add_list_item",
"update_list_item",
"delete_list_item",
"create_list",
"update_list",
"delete_list",
"create_event",
"update_event",
"delete_event",
"create_calendar",
"create_note",
"update_note",
"delete_note",
"create_journal_entry",
"update_journal_entry",
"delete_journal_entry",
"create_garden_plant",
"update_garden_plant",
"delete_garden_plant",
"create_garden_container",
"update_garden_container",
"delete_garden_container",
"log_garden_care",
"create_garden_care_schedule",
"update_garden_care_schedule",
"delete_garden_care_schedule",
"schedule_garden_care_on_calendar",
"create_bang",
"update_bang",
"delete_bang",
"create_share_link",
"revoke_share_link",
]);
export function isMutationTool(name: string): boolean {
return MUTATION_TOOLS.has(name);
}
export function isSuccessfulWrite(name: string, status: number, argsJson = ""): boolean {
if (!isSuccessfulStatus(status)) return false;
if (isMutationTool(name)) return true;
if (name !== "call_api") return false;
try {
const args = JSON.parse(argsJson || "{}") as { method?: string };
const method = typeof args.method === "string" ? args.method.toUpperCase() : "";
return method === "POST" || method === "PATCH" || method === "DELETE";
} catch {
return false;
}
}
export function isSuccessfulStatus(status: number): boolean {
return status >= 200 && status < 300;
}
export function fingerprintToolCalls(calls: Array<{ name: string; arguments: string }>): string {
return calls.map((call) => `${call.name}:${normalizeArgs(call.arguments)}`).join("|");
}
export function truncateToolResult(result: string, maxChars = 6000): string {
if (result.length <= maxChars) return result;
return `${result.slice(0, maxChars)}\n…[truncated ${result.length - maxChars} chars]`;
}
export function summarizeToolTrace(toolCalls: Array<{ name: string; status: number }>): string {
if (toolCalls.length === 0) return "No tools were called.";
return toolCalls.map((call) => `${call.name}${call.status}`).join(", ");
}
function normalizeArgs(argsJson: string): string {
try {
const parsed: unknown = JSON.parse(argsJson || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return JSON.stringify(parsed);
}
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(parsed as Record<string, unknown>).sort()) {
sorted[key] = (parsed as Record<string, unknown>)[key];
}
return JSON.stringify(sorted);
} catch {
return argsJson.trim();
}
}
+128 -4
View File
@@ -1,9 +1,16 @@
import logger from "@/lib/logger";
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm"; import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content"; import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS, appendAgentRuntimeContext } from "../tools"; import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS, appendAgentRuntimeContext } from "../tools";
import type { ClientChatMessage } from "../messages"; import type { ClientChatMessage } from "../messages";
import { describeToolActivity } from "../tool-labels"; import { describeToolActivity } from "../tool-labels";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor"; import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
import {
fingerprintToolCalls,
isSuccessfulWrite,
summarizeToolTrace,
truncateToolResult,
} from "./loop-guards";
import { resolveAssistantImageDataUrls } from "./resolve-images"; import { resolveAssistantImageDataUrls } from "./resolve-images";
import { thinkingLabel, type AgentProgressEvent } from "./progress"; import { thinkingLabel, type AgentProgressEvent } from "./progress";
@@ -41,6 +48,21 @@ async function toLlmUserMessage(message: ClientChatMessage): Promise<ChatMessage
}; };
} }
function forceReplyAfterWrite(writeNames: string[]): ChatMessage {
return {
role: "user",
content: `The write already succeeded (${writeNames.join(", ")}). Stop calling tools and reply to the user in one short sentence confirming what you did.`,
};
}
function forceReplyAfterRepeat(): ChatMessage {
return {
role: "user",
content:
"You repeated the same tool call. Stop calling tools and reply with what you already know, or ask one clarifying question.",
};
}
export async function runAgentChat(options: { export async function runAgentChat(options: {
messages: ClientChatMessage[]; messages: ClientChatMessage[];
request: Request; request: Request;
@@ -62,13 +84,16 @@ export async function runAgentChat(options: {
const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages]; const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages];
const toolCalls: AgentToolCallSummary[] = []; const toolCalls: AgentToolCallSummary[] = [];
const seenFingerprints = new Set<string>();
const successfulWriteNames: string[] = [];
let forceReply = false;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
onProgress?.({ type: "thinking", label: thinkingLabel(round), round }); onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
const completion = await llm.chatCompletion({ const completion = await llm.chatCompletion({
messages: transcript, messages: transcript,
tools: AGENT_TOOLS, tools: forceReply ? undefined : AGENT_TOOLS,
}); });
const assistantMessage = completion.message; const assistantMessage = completion.message;
@@ -76,6 +101,15 @@ export async function runAgentChat(options: {
if (!assistantMessage.tool_calls?.length) { if (!assistantMessage.tool_calls?.length) {
onProgress?.({ type: "responding", label: "Writing a reply…" }); onProgress?.({ type: "responding", label: "Writing a reply…" });
logger.info(
{
msg: "agent.chat.done",
rounds: round + 1,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
forcedReply: forceReply,
},
"agent chat completed",
);
return { return {
message: { message: {
role: "assistant", role: "assistant",
@@ -87,6 +121,36 @@ export async function runAgentChat(options: {
}; };
} }
if (forceReply) {
logger.warn(
{
msg: "agent.chat.forced_tools_ignored",
round,
names: assistantMessage.tool_calls.map((c) => c.function.name),
},
"model kept calling tools after force-reply",
);
break;
}
const fingerprint = fingerprintToolCalls(
assistantMessage.tool_calls.map((call) => ({
name: call.function.name,
arguments: call.function.arguments,
})),
);
if (seenFingerprints.has(fingerprint)) {
logger.warn(
{ msg: "agent.chat.duplicate_tools", round, fingerprint, toolCalls: fingerprint },
"duplicate tool round detected",
);
transcript.push(forceReplyAfterRepeat());
forceReply = true;
continue;
}
seenFingerprints.add(fingerprint);
for (const toolCall of assistantMessage.tool_calls) { for (const toolCall of assistantMessage.tool_calls) {
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments); const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
onProgress?.({ type: "tool", name: toolCall.function.name, label }); onProgress?.({ type: "tool", name: toolCall.function.name, label });
@@ -96,8 +160,20 @@ export async function runAgentChat(options: {
try { try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments); result = await executeTool(toolCall.function.name, toolCall.function.arguments);
const parsed = JSON.parse(result) as { status?: number }; const parsed = JSON.parse(result) as { status?: number; body?: unknown };
status = typeof parsed.status === "number" ? parsed.status : 200; status = typeof parsed.status === "number" ? parsed.status : 200;
if (status >= 400) {
logger.warn(
{
msg: "agent.chat.tool_error",
round,
name: toolCall.function.name,
status,
body: parsed.body,
},
"agent tool returned error",
);
}
} catch (err) { } catch (err) {
status = 500; status = 500;
result = JSON.stringify({ result = JSON.stringify({
@@ -107,21 +183,69 @@ export async function runAgentChat(options: {
} }
toolCalls.push({ name: toolCall.function.name, status }); toolCalls.push({ name: toolCall.function.name, status });
logger.info(
{
msg: "agent.chat.tool",
round,
name: toolCall.function.name,
status,
args: toolCall.function.arguments.slice(0, 300),
},
"agent tool call",
);
if (isSuccessfulWrite(toolCall.function.name, status, toolCall.function.arguments)) {
successfulWriteNames.push(toolCall.function.name);
}
transcript.push({ transcript.push({
role: "tool", role: "tool",
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
name: toolCall.function.name, name: toolCall.function.name,
content: result, content: truncateToolResult(result),
}); });
} }
if (successfulWriteNames.length > 0) {
transcript.push(forceReplyAfterWrite(successfulWriteNames));
forceReply = true;
}
} }
onProgress?.({ type: "responding", label: "Wrapping up…" }); onProgress?.({ type: "responding", label: "Wrapping up…" });
const trace = summarizeToolTrace(toolCalls);
if (successfulWriteNames.length > 0) {
logger.warn(
{
msg: "agent.chat.forced_summary",
rounds: MAX_TOOL_ROUNDS,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
},
"agent summarizing after write without clean stop",
);
return {
message: {
role: "assistant",
content: `Done. Completed: ${[...new Set(successfulWriteNames)].join(", ")}.`,
},
toolCalls,
};
}
logger.warn(
{
msg: "agent.chat.tool_limit",
rounds: MAX_TOOL_ROUNDS,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
forcedReply: forceReply,
},
"agent hit tool-call limit",
);
return { return {
message: { message: {
role: "assistant", role: "assistant",
content: "I hit the tool-call limit for this request. Please try a simpler question.", content: `I hit the tool-call limit for this request. Tools used: ${trace}.`,
}, },
toolCalls, toolCalls,
}; };
+57 -13
View File
@@ -1,3 +1,5 @@
import logger from "@/lib/logger";
type ApiCallResult = { type ApiCallResult = {
status: number; status: number;
body: unknown; body: unknown;
@@ -5,8 +7,22 @@ type ApiCallResult = {
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>; export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
/** Loopback base for in-process tool → /api/v1 calls. Avoids hairpinning to the public URL. */
export function resolveInternalApiBase(request: Request): string {
const configured = process.env.INTERNAL_API_BASE_URL?.trim();
if (configured) return configured.replace(/\/$/, "");
const port = process.env.PORT?.trim() || "3000";
const requestUrl = new URL(request.url);
if (requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1") {
return requestUrl.origin;
}
return `http://127.0.0.1:${port}`;
}
export function createApiToolExecutor(request: Request): ToolExecutor { export function createApiToolExecutor(request: Request): ToolExecutor {
const origin = new URL(request.url).origin; const origin = resolveInternalApiBase(request);
return async (name: string, argsJson: string) => { return async (name: string, argsJson: string) => {
const args = parseArgs(argsJson); const args = parseArgs(argsJson);
@@ -451,13 +467,18 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml"); const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8"); const spec = await readFile(specPath, "utf8");
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : ""; const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
const pathLines = spec
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("/api/v1/"));
if (!search) { if (!search) {
return { return {
status: 200, status: 200,
body: { body: {
spec, paths: pathLines.slice(0, 80),
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.", pathCount: pathLines.length,
hint: "Pass search (e.g. calendar, events, lists) to get matching lines. Do not request the full OpenAPI dump.",
}, },
}; };
} }
@@ -469,8 +490,8 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
body: { body: {
search, search,
matchCount: matches.length, matchCount: matches.length,
matches: matches.slice(0, 100), matches: matches.slice(0, 40),
hint: "Use call_api with method and path from the matches above.", hint: "Use a dedicated tool when one exists; otherwise call_api with method and path from the matches above.",
}, },
}; };
} }
@@ -517,14 +538,30 @@ async function callApi(
path: string, path: string,
body?: Record<string, unknown>, body?: Record<string, unknown>,
): Promise<ApiCallResult> { ): Promise<ApiCallResult> {
const response = await fetch(`${origin}${path}`, { const url = `${origin}${path}`;
method, const headers: Record<string, string> = {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", };
cookie: request.headers.get("cookie") ?? "", const cookie = request.headers.get("cookie");
}, if (cookie) headers.cookie = cookie;
body: body !== undefined ? JSON.stringify(body) : undefined, const authorization = request.headers.get("authorization");
}); if (authorization) headers.authorization = authorization;
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : "fetch failed";
logger.error(
{ msg: "agent.tool.fetch_failed", method, url, error: message },
"agent tool internal fetch failed",
);
return { status: 502, body: { error: `Internal API unreachable: ${message}`, url } };
}
const text = await response.text(); const text = await response.text();
let parsed: unknown = null; let parsed: unknown = null;
@@ -536,5 +573,12 @@ async function callApi(
} }
} }
if (response.status >= 400) {
logger.warn(
{ msg: "agent.tool.api_error", method, url, status: response.status, body: parsed },
"agent tool API error",
);
}
return { status: response.status, body: parsed }; return { status: response.status, body: parsed };
} }
+7 -5
View File
@@ -718,15 +718,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
function: { function: {
name: "get_api_docs", name: "get_api_docs",
description: description:
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.", "Search famapp REST API docs for /api/v1 paths. Always pass search. Returns matching lines only — not the full OpenAPI file.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
search: { search: {
type: "string", type: "string",
description: "Optional keyword to filter paths (e.g. garden, share, journal)", description: "Keyword to filter paths (e.g. garden, share, journal, events)",
}, },
}, },
required: ["search"],
}, },
}, },
}, },
@@ -767,10 +768,11 @@ export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly. Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
Prefer a dedicated tool when one exists (create_event, add_list_item, create_note, etc.). When no dedicated tool fits, or you need an endpoint that is not wrapped yet: Prefer a dedicated tool when one exists (create_event, add_list_item, create_note, etc.). After a successful write (2xx), stop calling tools and confirm in one short sentence — do not re-list or re-create.
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint. When no dedicated tool fits, or you need an endpoint that is not wrapped yet:
1. Call get_api_docs with a relevant search term (always pass search; never request the full spec).
2. Call call_api with the documented method, path, query, and body. 2. Call call_api with the documented method, path, query, and body.
If a tool call fails, read the error body and fix the arguments before trying a different approach. If a tool call fails, read the error body and fix the arguments before trying a different approach. Do not repeat the exact same tool call.
When the user sends a photo, read dates, times, locations, and action items from it, then use tools to act. When the user sends a photo, read dates, times, locations, and action items from it, then use tools to act.
@@ -10,6 +10,8 @@ const CONTAINER_TYPES = [
{ value: "raised-bed", label: "Raised bed" }, { value: "raised-bed", label: "Raised bed" },
{ value: "window-box", label: "Window box" }, { value: "window-box", label: "Window box" },
{ value: "single-pot", label: "Single pot" }, { value: "single-pot", label: "Single pot" },
{ value: "ikea-cabinet", label: "Ikea Cabinet" },
{ value: "acrylic-case", label: "Acrylic Case" },
{ value: "outdoor", label: "Outdoor" }, { value: "outdoor", label: "Outdoor" },
{ value: "other", label: "Other" }, { value: "other", label: "Other" },
]; ];
@@ -14,6 +14,7 @@ const CATEGORIES = [
{ value: "flower", label: "Flower" }, { value: "flower", label: "Flower" },
{ value: "succulent", label: "Succulent" }, { value: "succulent", label: "Succulent" },
{ value: "cactus", label: "Cactus" }, { value: "cactus", label: "Cactus" },
{ value: "carnivore", label: "Carnivore" },
{ value: "tropical", label: "Tropical" }, { value: "tropical", label: "Tropical" },
{ value: "tree", label: "Tree" }, { value: "tree", label: "Tree" },
{ value: "shrub", label: "Shrub" }, { value: "shrub", label: "Shrub" },
+95 -23
View File
@@ -2,10 +2,9 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react"; import { Bell, Pencil, Pin, PinOff, Save, Trash2, X } from "lucide-react";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { EntityComments } from "@/components/comments/entity-comments"; import { EntityComments } from "@/components/comments/entity-comments";
import { RichTextContent } from "@/components/rich-text";
import { DetailBackLink } from "@/components/detail-back-link"; import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,6 +12,7 @@ import { Label } from "@/components/ui/label";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
import type { NoteDto } from "../server/queries"; import type { NoteDto } from "../server/queries";
import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions"; import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions";
import { NoteRichTextBody } from "./note-rich-text-body";
const RichTextEditor = dynamic( const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor), () => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
@@ -29,6 +29,7 @@ const RichTextEditor = dynamic(
export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUserId?: string }) { export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUserId?: string }) {
const router = useRouter(); const router = useRouter();
const [currentNote, setCurrentNote] = useState(note); const [currentNote, setCurrentNote] = useState(note);
const [editing, setEditing] = useState(!note);
const [title, setTitle] = useState(note?.title ?? ""); const [title, setTitle] = useState(note?.title ?? "");
const [body, setBody] = useState(note?.body ?? ""); const [body, setBody] = useState(note?.body ?? "");
const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null)); const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null));
@@ -36,6 +37,25 @@ export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUse
const pinned = currentNote?.pinned ?? false; const pinned = currentNote?.pinned ?? false;
function beginEdit() {
if (!currentNote) return;
setTitle(currentNote.title);
setBody(currentNote.body);
setRemindAt(toLocalDateTimeValue(currentNote.remindAt));
setEditing(true);
}
function cancelEdit() {
if (!currentNote) {
router.push("/notes");
return;
}
setTitle(currentNote.title);
setBody(currentNote.body);
setRemindAt(toLocalDateTimeValue(currentNote.remindAt));
setEditing(false);
}
function saveNote() { function saveNote() {
startTransition(async () => { startTransition(async () => {
if (currentNote) { if (currentNote) {
@@ -46,6 +66,7 @@ export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUse
remindAt: remindAt ? new Date(remindAt) : null, remindAt: remindAt ? new Date(remindAt) : null,
}); });
setCurrentNote(updated); setCurrentNote(updated);
setEditing(false);
return; return;
} }
@@ -73,38 +94,87 @@ export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUse
}); });
} }
const displayTitle = currentNote?.title || title || "New note";
const updatedLabel = currentNote
? new Date(currentNote.updatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
const reminderLabel = currentNote?.remindAt
? new Date(currentNote.remindAt).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
: null;
return ( return (
<div className="mx-auto grid w-full max-w-6xl gap-4 min-w-0"> <div className="mx-auto grid w-full max-w-3xl gap-4 min-w-0">
<DetailBackLink href="/notes" label="Notes" /> <DetailBackLink href="/notes" label="Notes" />
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="min-w-0 flex-1">
{pinned && <Pin className="size-3.5 text-[var(--accent)]" />} <div className="flex items-center gap-2 min-w-0">
<h2 className="serif text-[24px] font-medium tracking-tight truncate"> {pinned ? <Pin className="size-3.5 shrink-0 text-[var(--accent)]" /> : null}
{currentNote ? currentNote.title : "New note"} <h2 className="serif text-[24px] font-medium tracking-tight truncate">
</h2> {displayTitle}
</h2>
</div>
{!editing && currentNote ? (
<p className="muted text-[13px] mt-1 inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{pinned ? <span>Pinned</span> : null}
{pinned && updatedLabel ? <span>·</span> : null}
{updatedLabel ? <span>Updated {updatedLabel}</span> : null}
{reminderLabel ? (
<>
<span>·</span>
<span className="inline-flex items-center gap-1">
<Bell className="size-3" />
{reminderLabel}
</span>
</>
) : null}
</p>
) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{currentNote ? ( {currentNote ? (
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}> <Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />} {pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
{pinned ? "Unpin note" : "Pin note"} {pinned ? "Unpin" : "Pin"}
</Button> </Button>
) : null} ) : null}
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null} {currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
{currentNote ? ( {currentNote ? (
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}> <Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
Delete note Delete
</Button> </Button>
) : null} ) : null}
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}> {editing ? (
<Save className="size-3.5" /> <>
Save note <Button variant="outline" size="sm" onClick={cancelEdit} disabled={isPending}>
</Button> <X className="size-3.5" />
Cancel
</Button>
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
<Save className="size-3.5" />
Save
</Button>
</>
) : (
<Button size="sm" onClick={beginEdit}>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div> </div>
</header> </header>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)] min-w-0"> {editing ? (
<section <section
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0" className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0"
style={{ borderColor: "var(--hair)" }} style={{ borderColor: "var(--hair)" }}
@@ -138,16 +208,18 @@ export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUse
/> />
</div> </div>
</section> </section>
) : currentNote ? (
<aside <section
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)] min-w-0" className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0 text-[var(--ink)]"
style={{ borderColor: "var(--hair)" }} style={{ borderColor: "var(--hair)" }}
aria-label="Preview"
> >
<div className="eyebrow mb-3">Preview</div> {currentNote.body ? (
<RichTextContent html={body} /> <NoteRichTextBody noteId={currentNote.id} body={currentNote.body} />
</aside> ) : (
</div> <p className="text-sm muted">No body text.</p>
)}
</section>
) : null}
{currentNote && currentUserId ? ( {currentNote && currentUserId ? (
<EntityComments <EntityComments
+152
View File
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
fingerprintToolCalls,
isSuccessfulWrite,
summarizeToolTrace,
truncateToolResult,
} from "../../src/modules/agent/server/loop-guards";
import { runAgentChat } from "../../src/modules/agent/server/run";
import type {
ChatCompletionRequest,
ChatCompletionResult,
LlmClient,
} from "../../src/lib/llm/types";
describe("loop-guards", () => {
it("fingerprints tool calls stably regardless of key order", () => {
const a = fingerprintToolCalls([
{ name: "create_event", arguments: '{"title":"Dentist","calendarName":"Family"}' },
]);
const b = fingerprintToolCalls([
{ name: "create_event", arguments: '{"calendarName":"Family","title":"Dentist"}' },
]);
assert.equal(a, b);
});
it("treats create_event 201 as a successful write", () => {
assert.equal(isSuccessfulWrite("create_event", 201), true);
assert.equal(isSuccessfulWrite("list_calendars", 200), false);
assert.equal(
isSuccessfulWrite("call_api", 201, '{"method":"POST","path":"/api/v1/events"}'),
true,
);
assert.equal(
isSuccessfulWrite("call_api", 200, '{"method":"GET","path":"/api/v1/events"}'),
false,
);
});
it("truncates oversized tool results", () => {
const result = truncateToolResult("x".repeat(7000), 100);
assert.ok(result.length < 200);
assert.match(result, /truncated/);
});
it("summarizes tool traces", () => {
assert.equal(
summarizeToolTrace([
{ name: "list_calendars", status: 200 },
{ name: "create_event", status: 201 },
]),
"list_calendars→200, create_event→201",
);
});
});
describe("runAgentChat loop guards", () => {
it("stops after a successful write instead of looping", async () => {
let calls = 0;
const llm: LlmClient = {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
calls += 1;
if (calls === 1) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "1",
type: "function",
function: {
name: "create_event",
arguments: JSON.stringify({
title: "Dentist",
startAt: "2026-07-10T15:00:00.000Z",
endAt: "2026-07-10T16:00:00.000Z",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
assert.equal(request.tools, undefined);
return {
message: { role: "assistant", content: "Added Dentist to your calendar." },
finishReason: "stop",
};
},
};
const result = await runAgentChat({
messages: [{ role: "user", content: "add dentist tomorrow at 10" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm,
executeTool: async () =>
JSON.stringify({ status: 201, body: { id: "evt-1", title: "Dentist" } }),
});
assert.equal(calls, 2);
assert.equal(result.toolCalls.length, 1);
assert.equal(result.toolCalls[0]?.name, "create_event");
assert.match(result.message.content, /Dentist/);
});
it("breaks duplicate identical tool rounds", async () => {
let calls = 0;
const llm: LlmClient = {
async chatCompletion(): Promise<ChatCompletionResult> {
calls += 1;
if (calls <= 2) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: String(calls),
type: "function",
function: {
name: "list_calendars",
arguments: "{}",
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: { role: "assistant", content: "You have one Family calendar." },
finishReason: "stop",
};
},
};
const result = await runAgentChat({
messages: [{ role: "user", content: "what calendars do I have?" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm,
executeTool: async () =>
JSON.stringify({ status: 200, body: [{ id: "cal-1", name: "Family" }] }),
});
assert.equal(calls, 3);
assert.equal(result.toolCalls.length, 1);
assert.match(result.message.content, /Family/);
});
});
+60 -6
View File
@@ -1,7 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { appendAgentRuntimeContext, resolveHouseholdTimezone } from "../../src/modules/agent/tools"; import { appendAgentRuntimeContext, resolveHouseholdTimezone } from "../../src/modules/agent/tools";
import { createApiToolExecutor } from "../../src/modules/agent/tool-executor"; import {
createApiToolExecutor,
resolveInternalApiBase,
} from "../../src/modules/agent/tool-executor";
describe("appendAgentRuntimeContext", () => { describe("appendAgentRuntimeContext", () => {
it("appends current time and timezone to the prompt", () => { it("appends current time and timezone to the prompt", () => {
@@ -38,6 +41,34 @@ describe("resolveHouseholdTimezone", () => {
}); });
}); });
describe("resolveInternalApiBase", () => {
it("uses loopback instead of the public request origin", () => {
const original = process.env.INTERNAL_API_BASE_URL;
const originalPort = process.env.PORT;
delete process.env.INTERNAL_API_BASE_URL;
process.env.PORT = "3000";
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
assert.equal(base, "http://127.0.0.1:3000");
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
else process.env.INTERNAL_API_BASE_URL = original;
if (originalPort === undefined) delete process.env.PORT;
else process.env.PORT = originalPort;
});
it("honors INTERNAL_API_BASE_URL when set", () => {
const original = process.env.INTERNAL_API_BASE_URL;
process.env.INTERNAL_API_BASE_URL = "http://127.0.0.1:3010/";
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
assert.equal(base, "http://127.0.0.1:3010");
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
else process.env.INTERNAL_API_BASE_URL = original;
});
});
describe("create_event calendar resolution", () => { describe("create_event calendar resolution", () => {
it("uses the first calendar when calendarId and calendarName are omitted", async () => { it("uses the first calendar when calendarId and calendarName are omitted", async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -45,13 +76,13 @@ describe("create_event calendar resolution", () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.endsWith("/api/v1/calendars") && (!init?.method || init.method === "GET")) { if (url.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([ return Response.json([
{ id: "cal-1", name: "Family" }, { id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" }, { id: "cal-2", name: "Work" },
]); ]);
} }
if (url.endsWith("/api/v1/events") && init?.method === "POST") { if (url.includes("/api/v1/events") && init?.method === "POST") {
const body = JSON.parse(String(init.body)); const body = JSON.parse(String(init.body));
posts.push({ path: url, body }); posts.push({ path: url, body });
return Response.json({ id: "evt-1", ...body }, { status: 201 }); return Response.json({ id: "evt-1", ...body }, { status: 201 });
@@ -59,7 +90,11 @@ describe("create_event calendar resolution", () => {
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}) as typeof fetch; }) as typeof fetch;
const execute = createApiToolExecutor(new Request("http://localhost:3000/api/agent/chat")); const execute = createApiToolExecutor(
new Request("https://fam.ginnoir.com/api/agent/chat", {
headers: { cookie: "authjs.session-token=test" },
}),
);
const result = JSON.parse( const result = JSON.parse(
await execute( await execute(
"create_event", "create_event",
@@ -74,6 +109,7 @@ describe("create_event calendar resolution", () => {
assert.equal(result.status, 201); assert.equal(result.status, 201);
assert.equal(result.body.calendarId, "cal-1"); assert.equal(result.body.calendarId, "cal-1");
assert.equal(posts.length, 1); assert.equal(posts.length, 1);
assert.match(posts[0]!.path, /^http:\/\/127\.0\.0\.1:3000\/api\/v1\/events/);
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
@@ -83,13 +119,13 @@ describe("create_event calendar resolution", () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.endsWith("/api/v1/calendars") && (!init?.method || init.method === "GET")) { if (url.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([ return Response.json([
{ id: "cal-1", name: "Family" }, { id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" }, { id: "cal-2", name: "Work" },
]); ]);
} }
if (url.endsWith("/api/v1/events") && init?.method === "POST") { if (url.includes("/api/v1/events") && init?.method === "POST") {
const body = JSON.parse(String(init.body)); const body = JSON.parse(String(init.body));
return Response.json({ id: "evt-1", ...body }, { status: 201 }); return Response.json({ id: "evt-1", ...body }, { status: 201 });
} }
@@ -114,4 +150,22 @@ describe("create_event calendar resolution", () => {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
it("returns a clear error when the internal API is unreachable", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError("fetch failed");
}) as typeof fetch;
const execute = createApiToolExecutor(new Request("https://fam.ginnoir.com/api/agent/chat"));
const result = JSON.parse(await execute("list_calendars", "{}")) as {
status: number;
body: { error?: string };
};
assert.equal(result.status, 502);
assert.match(String(result.body.error), /unreachable/i);
globalThis.fetch = originalFetch;
});
}); });