@circuitwall/jarela 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
  19. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
  20. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
  21. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  33. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  35. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  36. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  37. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  38. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
  40. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
  42. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  47. package/.next/standalone/.next/server/app/page.js +31202 -30960
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  51. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  52. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  53. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  54. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  55. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  56. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  57. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  58. package/.next/standalone/.next/server/chunks/46.js +18 -3
  59. package/.next/standalone/.next/server/chunks/466.js +53 -7
  60. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  61. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  62. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9164.js +65 -14
  64. package/.next/standalone/.next/server/chunks/9590.js +18 -3
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  66. package/.next/standalone/.next/server/middleware.js +18 -3
  67. package/.next/standalone/.next/server/pages/404.html +14 -2
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/preview-props.json +3 -3
  70. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  72. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  73. package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
  74. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  75. package/.next/standalone/docs/adoption/changesets.json +2756 -0
  76. package/.next/standalone/package.json +1 -1
  77. package/CHANGELOG.md +33 -0
  78. package/api/client.models.test.ts +10 -0
  79. package/api/client.startup.test.ts +129 -0
  80. package/api/client.ts +51 -9
  81. package/api/thread-metadata.test.ts +72 -0
  82. package/api/types.ts +6 -3
  83. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  84. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  85. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  86. package/app/globals.css +119 -29
  87. package/app/layout.tsx +12 -0
  88. package/components/agents/AgentsPanel.tsx +5 -13
  89. package/components/chat/ChatView.tsx +9 -18
  90. package/components/chat/InputBar.tsx +1 -1
  91. package/components/chat/MessageBubble.tsx +1 -1
  92. package/components/chat/MessageList.test.tsx +45 -4
  93. package/components/chat/MessageList.tsx +123 -89
  94. package/components/chat/ToolList.tsx +88 -84
  95. package/components/chat/useThreadActivity.test.tsx +2 -2
  96. package/components/chat/useThreadActivity.ts +2 -2
  97. package/components/chat/useThreadData.ts +1 -30
  98. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  99. package/components/chat/useThreadMetadataPolling.ts +50 -0
  100. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  101. package/components/chat/useUserProfileAndAgent.ts +13 -2
  102. package/components/dashboard/DashboardPanel.tsx +1 -1
  103. package/components/dashboard/Series.tsx +34 -21
  104. package/components/dashboard/SummaryCards.tsx +1 -1
  105. package/components/layout/AppShell.tsx +22 -7
  106. package/components/layout/MenuPanel.tsx +14 -24
  107. package/components/profile/ProfilePanel.tsx +2 -2
  108. package/components/settings/AppearancePanel.tsx +70 -3
  109. package/components/settings/SettingsPanel.tsx +2 -2
  110. package/components/tools/ToolsPanel.tsx +2 -7
  111. package/components/ui/AppearancePreferences.test.tsx +68 -0
  112. package/components/ui/BootScreen.test.tsx +44 -0
  113. package/components/ui/BootScreen.tsx +24 -11
  114. package/components/ui/NotificationStatus.test.tsx +2 -2
  115. package/components/ui/NotificationStatus.tsx +5 -5
  116. package/components/ui/SubTabBar.tsx +2 -3
  117. package/contexts/ThemeContext.tsx +84 -1
  118. package/hooks/useAgentSession.test.ts +12 -1
  119. package/hooks/useAgentSession.ts +6 -4
  120. package/hooks/useUrlSync.test.ts +13 -0
  121. package/hooks/useUrlSync.ts +0 -4
  122. package/lib/db/migrations.test.ts +61 -0
  123. package/lib/db/migrations.ts +13 -0
  124. package/lib/embeddings/performance.test.ts +114 -0
  125. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  126. package/lib/stores/dashboard-metrics.ts +30 -4
  127. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  128. package/lib/stores/message-embedding-jobs.ts +5 -3
  129. package/lib/stores/message-usage.test.ts +21 -1
  130. package/lib/stores/pricing-cache.test.ts +31 -0
  131. package/lib/stores/pricing.ts +3 -0
  132. package/lib/tools/packages/default-packages.test.ts +21 -0
  133. package/lib/tools/packages/optional-package.test.ts +72 -0
  134. package/lib/tools/packages/optional-package.ts +33 -4
  135. package/lib/ui/navigate.test.ts +13 -0
  136. package/lib/ui/navigate.ts +10 -2
  137. package/package.json +1 -1
  138. package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
  139. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  140. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.5.0] - 2026-10-18
11
+
12
+ ### Added
13
+
14
+ - Add appearance presets and accent-color choices, and reorganize configuration
15
+ panels for a more consistent workspace.
16
+
17
+ ### Fixed
18
+
19
+ - Let assistant replies use the available chat width and keep long tool names,
20
+ status indicators, expanded tool details, and the composer within narrow screens.
21
+ - Align configuration panel widths on desktop and mobile.
22
+ - Refresh provider model catalogs and capability metadata.
23
+ - Resolve optional tool packages from the workspace when their package entry
24
+ point is unavailable.
25
+
26
+ ### Changed
27
+
28
+ - Reuse short-lived startup reads between the agent picker and chat so the
29
+ first agent load warms profile, configuration, and conversation data without
30
+ duplicate requests. Writes and run submissions invalidate the handoff.
31
+ - Speed up chat pagination, dashboard date windows, and thread lists with
32
+ automatically created SQLite indexes. The first startup after upgrading a
33
+ large database may take longer while indexes build.
34
+ - Index latest usage, memory ordering, signal cleanup, background retention,
35
+ and expired leases; find the next embedding wakeup with indexed lookups
36
+ instead of scanning the active queue.
37
+ - Reduce chat refresh work with metadata-only, visibility-aware polling and
38
+ reuse historical message rows during streaming updates.
39
+ - Reuse unchanged dashboard computations briefly, avoid loading snapshotted
40
+ message bodies for token estimates, and skip rereading unchanged pricing
41
+ snapshots. Database writes and pricing changes invalidate server-side metrics.
42
+
10
43
  ## [2.4.0] - 2026-10-16
11
44
 
12
45
  ### Added
@@ -77,4 +77,14 @@ describe("api request HTTP cache mode", () => {
77
77
 
78
78
  expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe("default");
79
79
  });
80
+
81
+ it("fetches live thread metadata without loading a message page", async () => {
82
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ thread_id: "t1" }));
83
+ vi.stubGlobal("fetch", fetchMock);
84
+
85
+ await api.threads.metadata("t1");
86
+
87
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/threads/t1?view=metadata");
88
+ expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe("no-store");
89
+ });
80
90
  });
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("./runtime-config", () => ({
5
+ runtimeConfig: () => ({ httpMaxAttempts: 1, httpRequestTimeoutMs: 30_000 }),
6
+ }));
7
+
8
+ function json(body: unknown): Response {
9
+ return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } });
10
+ }
11
+
12
+ describe("agent startup handoff", () => {
13
+ beforeEach(() => {
14
+ vi.resetModules();
15
+ });
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ vi.unstubAllGlobals();
19
+ });
20
+
21
+ it("shares all four startup reads between boot and chat without sharing mutable objects", async () => {
22
+ const { api } = await import("./client");
23
+ const fetch = vi.fn().mockImplementation(() => Promise.resolve(json({ nested: { value: "original" } })));
24
+ vi.stubGlobal("fetch", fetch);
25
+ const load = () => Promise.all([
26
+ api.profile.get({ startup: true }),
27
+ api.agents.get("agent", { startup: true }),
28
+ api.agents.getThread("agent", { startup: true }),
29
+ api.threads.get("thread", { startup: true }),
30
+ ]);
31
+ const [boot, chat] = await Promise.all([load(), load()]);
32
+ expect(fetch).toHaveBeenCalledTimes(4);
33
+ expect(chat).toEqual(boot);
34
+ expect(chat[0]).not.toBe(boot[0]);
35
+ await load();
36
+ expect(fetch).toHaveBeenCalledTimes(4);
37
+ for (const [, init] of fetch.mock.calls) {
38
+ expect(init.cache).toBe("no-store");
39
+ expect(init).not.toHaveProperty("startup");
40
+ }
41
+ });
42
+
43
+ it("expires startup reads after five seconds and keeps ordinary reads fresh", async () => {
44
+ const { api } = await import("./client");
45
+ let now = 1_000;
46
+ vi.spyOn(Date, "now").mockImplementation(() => now);
47
+ const fetch = vi.fn().mockImplementation(() => Promise.resolve(json({ name: "profile" })));
48
+ vi.stubGlobal("fetch", fetch);
49
+ await api.profile.get({ startup: true });
50
+ now += 4_999;
51
+ await api.profile.get({ startup: true });
52
+ expect(fetch).toHaveBeenCalledTimes(1);
53
+ now += 1;
54
+ await api.profile.get({ startup: true });
55
+ expect(fetch).toHaveBeenCalledTimes(2);
56
+ await api.profile.get();
57
+ await api.profile.get({ startup: true });
58
+ expect(fetch).toHaveBeenCalledTimes(4);
59
+ });
60
+
61
+ it("isolates nested transcript data between consumers", async () => {
62
+ const { api } = await import("./client");
63
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(json({ messages: [{ id: "message" }] })));
64
+ const first = await api.threads.get("thread", { startup: true });
65
+ first.messages.length = 0;
66
+ const second = await api.threads.get("thread", { startup: true });
67
+ expect(second.messages).toEqual([{ id: "message" }]);
68
+ });
69
+
70
+ it("invalidates pending warmups before writes and never caches failed reads", async () => {
71
+ const { api } = await import("./client");
72
+ let resolveOld!: (response: Response) => void;
73
+ const fetch = vi.fn()
74
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveOld = resolve; }))
75
+ .mockResolvedValueOnce(json({ name: "updated" }))
76
+ .mockResolvedValueOnce(json({ name: "updated" }))
77
+ .mockResolvedValueOnce(new Response("failed", { status: 400 }))
78
+ .mockResolvedValueOnce(json({ name: "recovered" }));
79
+ vi.stubGlobal("fetch", fetch);
80
+ const old = api.profile.get({ startup: true });
81
+ await api.profile.update({ name: "updated" });
82
+ resolveOld(json({ name: "old" }));
83
+ await old;
84
+ expect(await api.profile.get({ startup: true })).toEqual({ name: "updated" });
85
+ await expect(api.agents.get("retry", { startup: true })).rejects.toThrow("400");
86
+ await expect(api.agents.get("retry", { startup: true })).resolves.toEqual({ name: "recovered" });
87
+ expect(fetch).toHaveBeenCalledTimes(5);
88
+ });
89
+
90
+ it("clears the startup page before submitting a run", async () => {
91
+ const { api, submitRun } = await import("./client");
92
+ const fetch = vi.fn()
93
+ .mockResolvedValueOnce(json({ messages: [] }))
94
+ .mockResolvedValueOnce(new Response(null, { status: 202 }))
95
+ .mockResolvedValueOnce(json({ messages: [{ id: "new" }] }));
96
+ vi.stubGlobal("fetch", fetch);
97
+ await api.threads.get("thread", { startup: true });
98
+ await submitRun("thread", "hello", new AbortController().signal);
99
+ await expect(api.threads.get("thread", { startup: true })).resolves.toEqual({ messages: [{ id: "new" }] });
100
+ expect(fetch).toHaveBeenCalledTimes(3);
101
+ });
102
+
103
+ it("discards startup data read while a write was still in flight", async () => {
104
+ const { api } = await import("./client");
105
+ let finishWrite!: (response: Response) => void;
106
+ const fetch = vi.fn()
107
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { finishWrite = resolve; }))
108
+ .mockResolvedValueOnce(json({ name: "old" }))
109
+ .mockResolvedValueOnce(json({ name: "new" }));
110
+ vi.stubGlobal("fetch", fetch);
111
+ const write = api.profile.update({ name: "new" });
112
+ await expect(api.profile.get({ startup: true })).resolves.toEqual({ name: "old" });
113
+ finishWrite(json({ name: "new" }));
114
+ await write;
115
+ await expect(api.profile.get({ startup: true })).resolves.toEqual({ name: "new" });
116
+ expect(fetch).toHaveBeenCalledTimes(3);
117
+ });
118
+
119
+ it("bounds retained data and separates thread cursor requests", async () => {
120
+ const { api } = await import("./client");
121
+ const fetch = vi.fn().mockImplementation(() => Promise.resolve(json({ messages: [] })));
122
+ vi.stubGlobal("fetch", fetch);
123
+ for (let i = 0; i < 17; i++) await api.threads.get(`thread-${i}`, { startup: true });
124
+ await api.threads.get("thread-0", { startup: true });
125
+ expect(fetch).toHaveBeenCalledTimes(18);
126
+ await api.threads.get("thread-0", { after: 10 });
127
+ expect(fetch.mock.calls[18][0]).toBe("/api/v1/threads/thread-0?after=10");
128
+ });
129
+ });
package/api/client.ts CHANGED
@@ -61,6 +61,7 @@ import type {
61
61
  ToolConfigSlotInfo,
62
62
  TaskAssignment,
63
63
  ThreadDetail,
64
+ ThreadMetadata,
64
65
  ThreadSummary,
65
66
  UserProfile,
66
67
  BuiltinToolCategoryInfo,
@@ -369,11 +370,41 @@ async function parseJsonResponse<T>(res: Response): Promise<T> {
369
370
  return JSON.parse(await new Response(stream).text()) as T;
370
371
  }
371
372
 
372
- async function request<T>(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
373
+ const STARTUP_TTL_MS = 5_000;
374
+ const startupReads = new Map<string, { promise: Promise<unknown>; settledAt: number | null }>();
375
+
376
+ function startupRead<T>(path: string): Promise<T> {
377
+ const now = Date.now();
378
+ for (const [key, entry] of startupReads) {
379
+ if (entry.settledAt !== null && (now < entry.settledAt || now - entry.settledAt >= STARTUP_TTL_MS)) {
380
+ startupReads.delete(key);
381
+ }
382
+ }
383
+ const cached = startupReads.get(path);
384
+ if (cached) return cached.promise.then((value) => structuredClone(value as T));
385
+ // Bound retained startup data when agents are switched rapidly.
386
+ const oldestKey = startupReads.keys().next().value;
387
+ if (startupReads.size >= 16 && oldestKey !== undefined) startupReads.delete(oldestKey);
388
+ const entry = { promise: Promise.resolve<unknown>(undefined), settledAt: null as number | null };
389
+ entry.promise = request<T>(path, { startup: true }).then((value) => {
390
+ entry.settledAt = Date.now();
391
+ return value;
392
+ }).catch((error: unknown) => {
393
+ if (startupReads.get(path) === entry) startupReads.delete(path);
394
+ throw error;
395
+ });
396
+ startupReads.set(path, entry);
397
+ return entry.promise.then((value) => structuredClone(value as T));
398
+ }
399
+
400
+ async function request<T>(path: string, init?: RequestInit & { timeoutMs?: number; startup?: boolean }): Promise<T> {
401
+ const mutation = !!init?.method && init.method !== "GET";
402
+ if (mutation) startupReads.clear();
403
+ else if (!init?.startup) startupReads.delete(path);
373
404
  const cfg = runtimeConfig();
374
405
  const maxAttempts = Math.max(1, cfg.httpMaxAttempts);
375
406
  const timeoutMs = init?.timeoutMs ?? cfg.httpRequestTimeoutMs;
376
- const { timeoutMs: _ignoreTimeoutMs, ...fetchInit } = init ?? {};
407
+ const { timeoutMs: _ignoreTimeoutMs, startup: _ignoreStartup, ...fetchInit } = init ?? {};
377
408
  const callerSignal = fetchInit.signal ?? null;
378
409
 
379
410
  let lastErr: unknown = null;
@@ -434,6 +465,7 @@ async function request<T>(path: string, init?: RequestInit & { timeoutMs?: numbe
434
465
  if (attempt < maxAttempts && isRetryable(err)) continue;
435
466
  throw err;
436
467
  } finally {
468
+ if (mutation) startupReads.clear();
437
469
  clearTimeout(timer);
438
470
  callerSignal?.removeEventListener("abort", onCallerAbort);
439
471
  }
@@ -446,7 +478,10 @@ export const api = {
446
478
  agents: {
447
479
  list: (opts?: { force?: boolean }) =>
448
480
  cachedList(agentListCache, () => request<AgentConfig[]>("/agents"), setAgentListCache, opts?.force === true),
449
- get: (id: string) => request<AgentConfig>(`/agents/${encodeURIComponent(id)}`),
481
+ get: (id: string, opts?: { startup?: boolean }) => {
482
+ const path = `/agents/${encodeURIComponent(id)}`;
483
+ return opts?.startup ? startupRead<AgentConfig>(path) : request<AgentConfig>(path);
484
+ },
450
485
  create: async (data: AgentConfigIn) => {
451
486
  const created = await request<AgentConfig>("/agents", { method: "POST", body: JSON.stringify(data) });
452
487
  if (agentListCache.data) setAgentListCache([...agentListCache.data, created]);
@@ -467,8 +502,10 @@ export const api = {
467
502
  }
468
503
  return res;
469
504
  },
470
- getThread: (id: string) =>
471
- request<ThreadSummary>(`/agents/${encodeURIComponent(id)}/thread`),
505
+ getThread: (id: string, opts?: { startup?: boolean }) => {
506
+ const path = `/agents/${encodeURIComponent(id)}/thread`;
507
+ return opts?.startup ? startupRead<ThreadSummary>(path) : request<ThreadSummary>(path);
508
+ },
472
509
  compact: (id: string, options?: { resetContext?: boolean }) =>
473
510
  request<{
474
511
  compacted: boolean;
@@ -705,7 +742,7 @@ export const api = {
705
742
  list: (limit = 50, offset = 0) => request<ThreadSummary[]>(`/threads?limit=${limit}&offset=${offset}`),
706
743
  create: (agent_id: string, title?: string) =>
707
744
  request<ThreadSummary>("/threads", { method: "POST", body: JSON.stringify({ agent_id, title }) }),
708
- get: (thread_id: string, opts?: { limit?: number; before?: number; after?: number }) => {
745
+ get: (thread_id: string, opts?: { limit?: number; before?: number; after?: number; startup?: boolean }) => {
709
746
  const p = new URLSearchParams();
710
747
  if (opts?.limit) p.set("limit", String(opts.limit));
711
748
  if (opts?.before !== undefined) p.set("before", String(opts.before));
@@ -714,8 +751,11 @@ export const api = {
714
751
  // user+assistant pair instead of re-pulling the whole most-recent page.
715
752
  if (opts?.after !== undefined) p.set("after", String(opts.after));
716
753
  const qs = p.toString() ? `?${p}` : "";
717
- return request<ThreadDetail>(`/threads/${thread_id}${qs}`);
754
+ const path = `/threads/${thread_id}${qs}`;
755
+ return opts?.startup ? startupRead<ThreadDetail>(path) : request<ThreadDetail>(path);
718
756
  },
757
+ metadata: (thread_id: string, signal?: AbortSignal) =>
758
+ request<ThreadMetadata>(`/threads/${thread_id}?view=metadata`, { signal }),
719
759
  delete: (thread_id: string) =>
720
760
  request<{ deleted: boolean }>(`/threads/${thread_id}`, { method: "DELETE" }),
721
761
  // What the thread is doing now (run phase, queued turns, compaction).
@@ -893,7 +933,8 @@ export const api = {
893
933
  },
894
934
 
895
935
  profile: {
896
- get: () => request<UserProfile>("/profile"),
936
+ get: (opts?: { startup?: boolean }) =>
937
+ opts?.startup ? startupRead<UserProfile>("/profile") : request<UserProfile>("/profile"),
897
938
  update: (data: Partial<Pick<UserProfile, "name" | "icon" | "about" | "preset">>) =>
898
939
  request<UserProfile>("/profile", { method: "PUT", body: JSON.stringify(data) }),
899
940
  setLocationConsent: (consent: boolean) =>
@@ -1397,6 +1438,7 @@ export async function submitRun(
1397
1438
  channels?: string[],
1398
1439
  hot_since_seq?: number | null,
1399
1440
  ): Promise<SubmitResult> {
1441
+ startupReads.clear();
1400
1442
  const payload: {
1401
1443
  message: string;
1402
1444
  stream_options?: StreamOptions;
@@ -1413,7 +1455,7 @@ export async function submitRun(
1413
1455
  headers: { "Content-Type": "application/json" },
1414
1456
  body: JSON.stringify(runPayload),
1415
1457
  signal,
1416
- });
1458
+ }).finally(() => startupReads.clear());
1417
1459
  // 2xx = accepted (currently always 202); 409 = already running. We treat
1418
1460
  // every other status as a hard error so the consumer's catch fires.
1419
1461
  if (res.status === 202) {
@@ -0,0 +1,72 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ getThread: vi.fn(), getMessagesPage: vi.fn(), getMessagesAfter: vi.fn(),
6
+ getMessageUsageByIds: vi.fn(), getAgentConfig: vi.fn(),
7
+ }));
8
+ vi.mock("@/lib/stores/threads", () => ({ ...mocks, deleteThread: vi.fn() }));
9
+ vi.mock("@/lib/stores/message-usage", () => ({ getMessageUsageByIds: mocks.getMessageUsageByIds }));
10
+ vi.mock("@/lib/stores/agent-configs", () => ({ getAgentConfig: mocks.getAgentConfig }));
11
+ vi.mock("@/lib/stores/model-config", () => ({ getModelConfig: vi.fn(), getModelParams: vi.fn() }));
12
+ vi.mock("@/lib/agents/warm-summary-background", () => ({
13
+ currentChannelSummaries: () => [], isManualCompactionRunning: () => true,
14
+ pendingCompactionBoundary: () => null, pendingCompactionBoundarySeq: () => 10,
15
+ }));
16
+ vi.mock("@/lib/agents/conversation-summary", () => ({ parseStoredTopics: () => null }));
17
+ vi.mock("@/lib/agents/thread-activity", () => ({ getThreadActivity: () => ({ busy: true }) }));
18
+ vi.mock("@/lib/agents/checkpointer", () => ({ getCheckpointer: vi.fn() }));
19
+
20
+ const { GET } = await import("@/app/api/v1/threads/[thread_id]/route");
21
+ function get(query = "") {
22
+ return GET(new NextRequest(`http://localhost/api/v1/threads/t1${query}`),
23
+ { params: Promise.resolve({ thread_id: "t1" }) });
24
+ }
25
+
26
+ describe("thread metadata view", () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mocks.getThread.mockReturnValue({ thread_id: "t1", agent_id: "a1", hot_since_seq: 4 });
30
+ mocks.getMessagesPage.mockReturnValue({ messages: [], has_more: true });
31
+ mocks.getMessagesAfter.mockReturnValue([]);
32
+ mocks.getMessageUsageByIds.mockReturnValue(new Map());
33
+ mocks.getAgentConfig.mockReturnValue(null);
34
+ });
35
+
36
+ it("returns live metadata without reading messages or usage", async () => {
37
+ const response = await get("?view=metadata");
38
+ const data = await response.json();
39
+ expect(data).toMatchObject({
40
+ thread_id: "t1", hot_since_seq: 4, pending_hot_since_seq: 10,
41
+ manual_compaction_pending: true, activity: { busy: true }, context_window_tokens: 8192,
42
+ });
43
+ expect(data).not.toHaveProperty("messages");
44
+ expect(data).not.toHaveProperty("has_more");
45
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
46
+ expect(mocks.getMessagesPage).not.toHaveBeenCalled();
47
+ expect(mocks.getMessagesAfter).not.toHaveBeenCalled();
48
+ expect(mocks.getMessageUsageByIds).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("preserves the default paged response and before cursor", async () => {
52
+ const response = await get("?before=42&limit=10");
53
+ expect(await response.json()).toMatchObject({ messages: [], has_more: true });
54
+ expect(mocks.getMessagesPage).toHaveBeenCalledWith("t1", 10, 42);
55
+ expect(mocks.getMessageUsageByIds).toHaveBeenCalledWith([]);
56
+ });
57
+
58
+ it("preserves forward message reads", async () => {
59
+ expect(await (await get("?after=42&limit=10")).json()).toMatchObject({ messages: [], has_more: false });
60
+ expect(mocks.getMessagesAfter).toHaveBeenCalledWith("t1", 42, 10);
61
+ });
62
+
63
+ it("rejects unknown views", async () => {
64
+ expect((await get("?view=unknown")).status).toBe(400);
65
+ expect(mocks.getMessagesPage).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("preserves missing-thread responses", async () => {
69
+ mocks.getThread.mockReturnValue(null);
70
+ expect((await get("?view=metadata")).status).toBe(404);
71
+ });
72
+ });
package/api/types.ts CHANGED
@@ -378,9 +378,7 @@ export interface MessageUsage {
378
378
  cost_usd: number | null;
379
379
  }
380
380
 
381
- export interface ThreadDetail extends ThreadSummary {
382
- messages: Message[];
383
- has_more: boolean;
381
+ export interface ThreadMetadata extends ThreadSummary {
384
382
  // Effective context-window size (in tokens) for this thread's current
385
383
  // agent + model config. Used by the chat UI to scale the per-turn
386
384
  // context-usage bar against the same cap the agent applies at run time.
@@ -416,6 +414,11 @@ export interface ThreadDetail extends ThreadSummary {
416
414
  warm_channel_summaries?: WarmChannelSummary[];
417
415
  }
418
416
 
417
+ export interface ThreadDetail extends ThreadMetadata {
418
+ messages: Message[];
419
+ has_more: boolean;
420
+ }
421
+
419
422
  /** What a thread is doing right now — GET /threads/:id/activity. */
420
423
  export interface ThreadActivity {
421
424
  run: { phase: string; label: string; started_at: number; agent_id: string | null } | null;
@@ -8,13 +8,16 @@ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-provider-models-"));
8
8
  process.env.JARELA_DB_DIR = tmpRoot;
9
9
 
10
10
  const { upsertModelConfig, deleteModelConfig } = await import("@/lib/stores/model-config");
11
- const { GET } = await import("./route");
11
+ const { GET, POST } = await import("./route");
12
12
 
13
- const params = { params: Promise.resolve({ provider: "github-copilot" }) };
14
- const request = () => new NextRequest("http://localhost/api/v1/providers/github-copilot/models?fresh=1");
13
+ const routeParams = (provider: string) => ({ params: Promise.resolve({ provider }) });
14
+ const request = (provider: string, fresh = true) =>
15
+ new NextRequest(`http://localhost/api/v1/providers/${provider}/models${fresh ? "?fresh=1" : ""}`);
15
16
 
16
17
  afterEach(() => {
17
- deleteModelConfig("copilot-config");
18
+ for (const name of ["copilot-config", "anthropic-config", "gemini-config", "deepseek-config"]) {
19
+ deleteModelConfig(name);
20
+ }
18
21
  vi.unstubAllEnvs();
19
22
  vi.unstubAllGlobals();
20
23
  });
@@ -29,7 +32,7 @@ describe("provider model catalogs", () => {
29
32
  api_key: "github_pat_synthetic",
30
33
  }, true);
31
34
 
32
- const response = await GET(request(), params);
35
+ const response = await GET(request("github-copilot"), routeParams("github-copilot"));
33
36
 
34
37
  expect(response.status).toBe(400);
35
38
  await expect(response.json()).resolves.toMatchObject({
@@ -43,11 +46,218 @@ describe("provider model catalogs", () => {
43
46
  const fetch = vi.fn();
44
47
  vi.stubGlobal("fetch", fetch);
45
48
 
46
- const response = await GET(request(), params);
49
+ const response = await GET(request("github-copilot"), routeParams("github-copilot"));
47
50
 
48
51
  expect(response.status).toBe(200);
49
52
  const models = await response.json() as Array<{ id: string }>;
50
53
  expect(models.length).toBeGreaterThan(0);
51
54
  expect(fetch).not.toHaveBeenCalled();
52
55
  });
56
+
57
+ it("loads the Anthropic catalog and follows its pagination cursor", async () => {
58
+ upsertModelConfig("anthropic-config", "anthropic", "claude-sonnet-4-6", {
59
+ api_key: "synthetic-anthropic-key",
60
+ }, true);
61
+ const fetch = vi.fn()
62
+ .mockResolvedValueOnce(new Response(JSON.stringify({
63
+ data: [{
64
+ id: "claude-first",
65
+ max_input_tokens: 500000,
66
+ max_tokens: 64000,
67
+ capabilities: {
68
+ image_input: { supported: true },
69
+ structured_outputs: { supported: true },
70
+ },
71
+ }],
72
+ has_more: true,
73
+ last_id: "claude-first",
74
+ })))
75
+ .mockResolvedValueOnce(new Response(JSON.stringify({
76
+ data: [{ id: "claude-second" }],
77
+ has_more: false,
78
+ last_id: "claude-second",
79
+ })));
80
+ vi.stubGlobal("fetch", fetch);
81
+
82
+ const response = await GET(request("anthropic"), routeParams("anthropic"));
83
+
84
+ expect(response.status).toBe(200);
85
+ await expect(response.json()).resolves.toMatchObject([
86
+ {
87
+ id: "claude-first",
88
+ hosted_on: "anthropic",
89
+ context_length: 500000,
90
+ max_output_tokens: 64000,
91
+ capabilities: { vision: true, files: false, json_mode: true, web_search: false },
92
+ },
93
+ { id: "claude-second", hosted_on: "anthropic", capabilities: { vision: false, files: false, web_search: false } },
94
+ ]);
95
+ expect(fetch).toHaveBeenCalledTimes(2);
96
+ expect(String(fetch.mock.calls[0][0])).toBe("https://api.anthropic.com/v1/models?limit=1000");
97
+ expect(String(fetch.mock.calls[1][0])).toContain("after_id=claude-first");
98
+ expect(fetch.mock.calls[0][1]?.headers).toMatchObject({
99
+ "x-api-key": "synthetic-anthropic-key",
100
+ "anthropic-version": "2023-06-01",
101
+ });
102
+ });
103
+
104
+ it("loads Gemini generation models with live limits and skips embedding-only models", async () => {
105
+ upsertModelConfig("gemini-config", "gemini", "gemini-2.5-flash", {
106
+ api_key: "synthetic-gemini-key",
107
+ }, true);
108
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
109
+ models: [
110
+ {
111
+ name: "models/gemini-current",
112
+ inputTokenLimit: 131072,
113
+ outputTokenLimit: 16384,
114
+ supportedGenerationMethods: ["generateContent"],
115
+ },
116
+ {
117
+ name: "models/text-embedding-current",
118
+ supportedGenerationMethods: ["embedContent"],
119
+ },
120
+ ],
121
+ })));
122
+ vi.stubGlobal("fetch", fetch);
123
+
124
+ const response = await GET(request("gemini"), routeParams("gemini"));
125
+
126
+ expect(response.status).toBe(200);
127
+ await expect(response.json()).resolves.toMatchObject([
128
+ {
129
+ id: "gemini-current",
130
+ context_length: 131072,
131
+ max_output_tokens: 16384,
132
+ capabilities: { vision: false, files: false, audio: false, web_search: false },
133
+ },
134
+ ]);
135
+ expect(fetch.mock.calls[0][1]?.headers).toMatchObject({
136
+ "x-goog-api-key": "synthetic-gemini-key",
137
+ });
138
+ });
139
+
140
+ it("uses a configured Gemini native base URL for catalog discovery", async () => {
141
+ upsertModelConfig("gemini-config", "gemini", "gemini-2.5-flash", {
142
+ api_key: "synthetic-gemini-key",
143
+ gemini_native_base_url: "https://gemini-proxy.example/v1beta",
144
+ }, true);
145
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ models: [] })));
146
+ vi.stubGlobal("fetch", fetch);
147
+
148
+ const response = await GET(request("gemini"), routeParams("gemini"));
149
+
150
+ expect(response.status).toBe(200);
151
+ expect(String(fetch.mock.calls[0][0])).toBe("https://gemini-proxy.example/v1beta/models?pageSize=1000");
152
+ expect(fetch.mock.calls[0][1]?.headers).toMatchObject({
153
+ "x-goog-api-key": "synthetic-gemini-key",
154
+ });
155
+ });
156
+
157
+ it("loads DeepSeek models with live limits and modality capabilities", async () => {
158
+ upsertModelConfig("deepseek-config", "deepseek", "deepseek-flash", {
159
+ api_key: "synthetic-deepseek-key",
160
+ }, true);
161
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
162
+ data: [{
163
+ id: "deepseek-new",
164
+ context_window: 900000,
165
+ max_output_tokens: 120000,
166
+ input_modalities: ["text", "image"],
167
+ }],
168
+ })));
169
+ vi.stubGlobal("fetch", fetch);
170
+
171
+ const response = await GET(request("deepseek"), routeParams("deepseek"));
172
+
173
+ expect(response.status).toBe(200);
174
+ await expect(response.json()).resolves.toMatchObject([
175
+ {
176
+ id: "deepseek-new",
177
+ context_length: 900000,
178
+ max_output_tokens: 120000,
179
+ capabilities: { vision: true, files: false },
180
+ },
181
+ ]);
182
+ expect(String(fetch.mock.calls[0][0])).toBe("https://api.deepseek.com/models");
183
+ expect(fetch.mock.calls[0][1]?.headers).toMatchObject({
184
+ Authorization: "Bearer synthetic-deepseek-key",
185
+ });
186
+ });
187
+
188
+ it("uses unsaved credentials and base URLs for OpenAI catalog discovery", async () => {
189
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
190
+ data: [{ id: "gpt-live-catalog-model" }],
191
+ })));
192
+ vi.stubGlobal("fetch", fetch);
193
+ const req = new NextRequest("http://localhost/api/v1/providers/openai/models", {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({
197
+ params: {
198
+ api_key: "synthetic-openai-key",
199
+ base_url: "https://openai.example/v1",
200
+ },
201
+ }),
202
+ });
203
+
204
+ const response = await POST(req, routeParams("openai"));
205
+
206
+ expect(response.status).toBe(200);
207
+ await expect(response.json()).resolves.toMatchObject([
208
+ { id: "gpt-live-catalog-model" },
209
+ ]);
210
+ expect(String(fetch.mock.calls[0][0])).toBe("https://openai.example/v1/models");
211
+ expect(fetch.mock.calls[0][1]?.headers).toMatchObject({
212
+ Authorization: "Bearer synthetic-openai-key",
213
+ });
214
+ });
215
+
216
+ it("rejects malformed catalog request bodies", async () => {
217
+ const fetch = vi.fn();
218
+ vi.stubGlobal("fetch", fetch);
219
+ const req = new NextRequest("http://localhost/api/v1/providers/openai/models", {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: "{",
223
+ });
224
+
225
+ const response = await POST(req, routeParams("openai"));
226
+
227
+ expect(response.status).toBe(400);
228
+ expect(fetch).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it("invalidates the cached fallback after credentialed catalog discovery", async () => {
232
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
233
+ const fetch = vi.fn().mockImplementation(() => new Response(JSON.stringify({
234
+ data: [{ id: "claude-live-after-save" }],
235
+ has_more: false,
236
+ last_id: "claude-live-after-save",
237
+ })));
238
+ vi.stubGlobal("fetch", fetch);
239
+
240
+ const fallbackResponse = await GET(request("anthropic"), routeParams("anthropic"));
241
+ expect(fallbackResponse.status).toBe(200);
242
+ expect(fetch).not.toHaveBeenCalled();
243
+
244
+ const postRequest = new NextRequest("http://localhost/api/v1/providers/anthropic/models", {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify({ params: { api_key: "synthetic-anthropic-key" } }),
248
+ });
249
+ const liveResponse = await POST(postRequest, routeParams("anthropic"));
250
+ expect(liveResponse.status).toBe(200);
251
+
252
+ upsertModelConfig("anthropic-config", "anthropic", "claude-live-after-save", {
253
+ api_key: "synthetic-anthropic-key",
254
+ }, true);
255
+ const nextResponse = await GET(request("anthropic", false), routeParams("anthropic"));
256
+
257
+ expect(nextResponse.status).toBe(200);
258
+ await expect(nextResponse.json()).resolves.toMatchObject([
259
+ { id: "claude-live-after-save" },
260
+ ]);
261
+ expect(fetch).toHaveBeenCalledTimes(2);
262
+ });
53
263
  });