@circuitwall/jarela 2.3.0 → 2.4.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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +4 -4
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
- package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +0 -1
- package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/page.js +322 -275
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +8 -0
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +4 -4
- package/.next/standalone/.next/server/chunks/1388.js +106 -36
- package/.next/standalone/.next/server/chunks/2068.js +47 -10
- package/.next/standalone/.next/server/chunks/210.js +6 -6
- package/.next/standalone/.next/server/chunks/2447.js +10 -5
- package/.next/standalone/.next/server/chunks/2471.js +17 -17
- package/.next/standalone/.next/server/chunks/3042.js +10 -8
- package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
- package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
- package/.next/standalone/.next/server/chunks/3399.js +525 -525
- package/.next/standalone/.next/server/chunks/3524.js +150 -67
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/46.js +73 -28
- package/.next/standalone/.next/server/chunks/5908.js +63 -42
- package/.next/standalone/.next/server/chunks/6142.js +10 -2
- package/.next/standalone/.next/server/chunks/6649.js +1 -1
- package/.next/standalone/.next/server/chunks/8461.js +10 -8
- package/.next/standalone/.next/server/chunks/9032.js +5 -5
- package/.next/standalone/.next/server/chunks/9164.js +435 -176
- package/.next/standalone/.next/server/chunks/9304.js +51 -12
- package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +66 -21
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +71 -18
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-04748847c6ff866f.js} +171 -132
- package/.next/standalone/.next/static/css/{312542a5725ba0d5.css → 2669e8744cc8c212.css} +2 -2
- package/.next/standalone/docs/adoption/changesets.json +49 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +19 -0
- package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
- package/components/chat/MessageBubble.tsx +12 -0
- package/components/integrations/NetworkSection.tsx +2 -2
- package/lib/agents/citation-checker.test.ts +40 -2
- package/lib/agents/citation-checker.ts +56 -11
- package/lib/agents/context-boundary.ts +5 -6
- package/lib/agents/instruction-edits.test.ts +120 -0
- package/lib/agents/instruction-edits.ts +65 -42
- package/lib/agents/prepare/request.ts +0 -2
- package/lib/agents/prepare/system-prompt.test.ts +10 -0
- package/lib/agents/prepare/system-prompt.ts +8 -2
- package/lib/agents/proposals.test.ts +76 -0
- package/lib/agents/proposals.ts +13 -5
- package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
- package/lib/agents/run-thread.ts +24 -9
- package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
- package/lib/agents/warm-summary-background.ts +22 -2
- package/lib/agents/warm-topics.test.ts +155 -1
- package/lib/agents/warm-topics.ts +27 -7
- package/lib/bridges/dispatcher.ts +1 -0
- package/lib/bridges/message-role.test.ts +81 -0
- package/lib/bridges/message-role.ts +23 -10
- package/lib/bridges/types.ts +6 -0
- package/lib/bridges/whatsapp-link.test.ts +32 -0
- package/lib/bridges/whatsapp-link.ts +21 -0
- package/lib/bridges/whatsapp.inbound.test.ts +239 -0
- package/lib/bridges/whatsapp.sender.test.ts +80 -1
- package/lib/bridges/whatsapp.ts +76 -38
- package/lib/pricing/snapshot.test.ts +70 -0
- package/lib/pricing/snapshot.ts +10 -2
- package/lib/proxy/dispatcher.test.ts +119 -1
- package/lib/proxy/dispatcher.ts +22 -9
- package/lib/stores/agent-configs.ts +10 -5
- package/lib/stores/bridges.test.ts +81 -0
- package/lib/stores/bridges.ts +9 -8
- package/lib/stores/proxy-config.test.ts +120 -0
- package/lib/stores/proxy-config.ts +22 -4
- package/lib/stores/threads.ts +12 -8
- package/lib/tools/communications/calendar.test.ts +43 -2
- package/lib/tools/communications/calendar.ts +2 -0
- package/lib/tools/communications/gmail.test.ts +51 -1
- package/lib/tools/communications/gmail.ts +44 -2
- package/lib/tools/communications/outlook-calendar.ts +2 -0
- package/lib/tools/communications/outlook.test.ts +33 -2
- package/lib/tools/communications/outlook.ts +2 -0
- package/lib/tools/filesystem/edit-match.test.ts +133 -0
- package/lib/tools/filesystem/edit-match.ts +7 -2
- package/lib/tools/support/result-refs.test.ts +131 -0
- package/lib/tools/support/result-refs.ts +41 -9
- package/lib/utils/source-link.test.ts +51 -0
- package/lib/utils/source-link.ts +45 -0
- package/package.json +1 -1
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_ssgManifest.js +0 -0
|
@@ -395,3 +395,79 @@ describe("applyAction(update_agent) instructions_append", () => {
|
|
|
395
395
|
expect(String(result.detail)).toMatch(/one instructions mode only/);
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
|
+
|
|
399
|
+
describe("applyAction(update_agent) edge cases", () => {
|
|
400
|
+
function seedTunedAgent(id: string) {
|
|
401
|
+
upsertAgentConfig({
|
|
402
|
+
id,
|
|
403
|
+
name: id,
|
|
404
|
+
identity: "test agent",
|
|
405
|
+
instructions: "be helpful",
|
|
406
|
+
tools: ["web_search"],
|
|
407
|
+
hot_turn_limit: 12,
|
|
408
|
+
adaptive_persona_enabled: true,
|
|
409
|
+
adaptive_mbti: "ENFP",
|
|
410
|
+
adaptive_persona_strength: 91,
|
|
411
|
+
adaptive_empathy: 7,
|
|
412
|
+
adaptive_expressiveness: 88,
|
|
413
|
+
adaptive_verbosity: 3,
|
|
414
|
+
never_reply: true,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it("applies hot_turn_limit, which the propose tool advertises", async () => {
|
|
419
|
+
seedTunedAgent("agent-hot-turns");
|
|
420
|
+
const result = await applyAction("update_agent", { agent_id: "agent-hot-turns", hot_turn_limit: 4 });
|
|
421
|
+
expect(result.ok).toBe(true);
|
|
422
|
+
expect(getAgentConfig("agent-hot-turns")!.hot_turn_limit).toBe(4);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it.each([
|
|
426
|
+
["hot_turn_limit", -1],
|
|
427
|
+
["hot_turn_limit", 2.5],
|
|
428
|
+
["history_limit", 1.5],
|
|
429
|
+
["history_limit", -5],
|
|
430
|
+
["history_window_hours", -1],
|
|
431
|
+
])("rejects %s=%s without writing", async (field, value) => {
|
|
432
|
+
seedTunedAgent(`agent-bad-${field}`);
|
|
433
|
+
const before = getAgentConfig(`agent-bad-${field}`)!;
|
|
434
|
+
const result = await applyAction("update_agent", { agent_id: `agent-bad-${field}`, [field]: value });
|
|
435
|
+
expect(result.ok).toBe(false);
|
|
436
|
+
expect(String(result.detail)).toContain(field);
|
|
437
|
+
expect(getAgentConfig(`agent-bad-${field}`)).toEqual(before);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("keeps settings the proposal did not mention, including tuned persona sliders", async () => {
|
|
441
|
+
seedTunedAgent("agent-keep");
|
|
442
|
+
const before = getAgentConfig("agent-keep")!;
|
|
443
|
+
expect((await applyAction("update_agent", { agent_id: "agent-keep", identity: "renamed" })).ok).toBe(true);
|
|
444
|
+
const after = getAgentConfig("agent-keep")!;
|
|
445
|
+
expect(after.identity).toBe("renamed");
|
|
446
|
+
expect(after).toMatchObject({
|
|
447
|
+
adaptive_mbti: before.adaptive_mbti,
|
|
448
|
+
adaptive_persona_strength: 91,
|
|
449
|
+
adaptive_empathy: 7,
|
|
450
|
+
adaptive_expressiveness: 88,
|
|
451
|
+
adaptive_verbosity: 3,
|
|
452
|
+
never_reply: before.never_reply,
|
|
453
|
+
hot_turn_limit: 12,
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("keeps tuned persona sliders when only tools change", async () => {
|
|
458
|
+
seedTunedAgent("agent-keep-tools");
|
|
459
|
+
expect((await applyAction("update_agent_tools", { agent_id: "agent-keep-tools", tools: ["memory_write"] })).ok).toBe(true);
|
|
460
|
+
expect(getAgentConfig("agent-keep-tools")).toMatchObject({ adaptive_persona_strength: 91, adaptive_verbosity: 3 });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("drops non-string tool names instead of storing them", async () => {
|
|
464
|
+
seedTunedAgent("agent-tool-junk");
|
|
465
|
+
const result = await applyAction("update_agent_tools", {
|
|
466
|
+
agent_id: "agent-tool-junk",
|
|
467
|
+
tools: ["memory_write", 42, null, "", " memory_write "],
|
|
468
|
+
mode: "replace",
|
|
469
|
+
});
|
|
470
|
+
expect(result.ok).toBe(true);
|
|
471
|
+
expect(JSON.parse(getAgentConfig("agent-tool-junk")!.tools)).toEqual(["memory_write"]);
|
|
472
|
+
});
|
|
473
|
+
});
|
package/lib/agents/proposals.ts
CHANGED
|
@@ -130,9 +130,13 @@ function applyUpdateAgentTools(payload: unknown): ApplyResult {
|
|
|
130
130
|
const before = getAgentTools(existing);
|
|
131
131
|
const mode = p.mode ?? "add";
|
|
132
132
|
if (mode !== "add" && mode !== "replace") return { ok: false, detail: "mode must be add or replace" };
|
|
133
|
+
const requested = (p.tools as unknown[])
|
|
134
|
+
.filter((tool): tool is string => typeof tool === "string")
|
|
135
|
+
.map((tool) => tool.trim())
|
|
136
|
+
.filter(Boolean);
|
|
133
137
|
const nextTools = mode === "replace"
|
|
134
|
-
? [...new Set(
|
|
135
|
-
: [...new Set([...before, ...
|
|
138
|
+
? [...new Set(requested)]
|
|
139
|
+
: [...new Set([...before, ...requested])];
|
|
136
140
|
upsertAgentConfig({
|
|
137
141
|
id: existing.id,
|
|
138
142
|
name: existing.name,
|
|
@@ -154,13 +158,16 @@ function applyUpdateAgent(payload: unknown): ApplyResult {
|
|
|
154
158
|
instructions_edits?: unknown;
|
|
155
159
|
history_limit?: number;
|
|
156
160
|
history_window_hours?: number;
|
|
161
|
+
hot_turn_limit?: number;
|
|
157
162
|
warm_summary_limit?: number;
|
|
158
163
|
harness_id?: string | null;
|
|
159
164
|
};
|
|
160
165
|
if (!p.agent_id) return { ok: false, detail: "agent_id required" };
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
for (const field of ["warm_summary_limit", "hot_turn_limit", "history_limit", "history_window_hours"] as const) {
|
|
167
|
+
const value = p[field];
|
|
168
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
|
169
|
+
return { ok: false, detail: `${field} must be a nonnegative integer` };
|
|
170
|
+
}
|
|
164
171
|
}
|
|
165
172
|
const editModes = [
|
|
166
173
|
p.instructions !== undefined,
|
|
@@ -209,6 +216,7 @@ function applyUpdateAgent(payload: unknown): ApplyResult {
|
|
|
209
216
|
model_config_name: existing.model_config_name,
|
|
210
217
|
history_limit: p.history_limit ?? existing.history_limit,
|
|
211
218
|
history_window_hours: p.history_window_hours ?? existing.history_window_hours,
|
|
219
|
+
hot_turn_limit: p.hot_turn_limit,
|
|
212
220
|
warm_summary_limit: p.warm_summary_limit ?? existing.warm_summary_limit,
|
|
213
221
|
harness_id: p.harness_id,
|
|
214
222
|
});
|
|
@@ -10,6 +10,7 @@ process.env.JARELA_MAX_THREAD_MESSAGES = "4";
|
|
|
10
10
|
|
|
11
11
|
const streamWithConfigMock = vi.fn();
|
|
12
12
|
let providerSummary = "AUTO-COMPACT-RECAP";
|
|
13
|
+
let providerCalls = 0;
|
|
13
14
|
|
|
14
15
|
vi.mock("@/lib/agents/llm", () => ({
|
|
15
16
|
streamWithConfig: (...args: unknown[]) => streamWithConfigMock(...args),
|
|
@@ -21,7 +22,7 @@ vi.mock("@/lib/scheduler", () => ({
|
|
|
21
22
|
|
|
22
23
|
vi.mock("@/lib/providers", () => ({
|
|
23
24
|
getProvider: () => ({
|
|
24
|
-
chat: async () => ({
|
|
25
|
+
chat: async () => (providerCalls++, {
|
|
25
26
|
stream: (async function* () { yield providerSummary; })(),
|
|
26
27
|
}),
|
|
27
28
|
}),
|
|
@@ -164,7 +165,6 @@ describe("prepareThreadRun auto context boundary", () => {
|
|
|
164
165
|
thread_id: thread.thread_id,
|
|
165
166
|
message: "Persist this before the assistant draft.",
|
|
166
167
|
_user_message_persisted: true,
|
|
167
|
-
_message_count_before_user: persisted.messageCountBefore,
|
|
168
168
|
_idle_ms_before_user: persisted.idleMsBefore,
|
|
169
169
|
context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
|
|
170
170
|
});
|
|
@@ -271,7 +271,6 @@ describe("prepareThreadRun auto context boundary", () => {
|
|
|
271
271
|
.run(new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), message.msg_id);
|
|
272
272
|
});
|
|
273
273
|
const orderedSeeded = getMessages(thread.thread_id);
|
|
274
|
-
const messageCountBefore = getThread(thread.thread_id)!.message_count;
|
|
275
274
|
const activePrompt = addMessage(thread.thread_id, "user", "topic 3 question");
|
|
276
275
|
providerSummary = [
|
|
277
276
|
"AUTO-COMPACT-RECAP",
|
|
@@ -292,7 +291,6 @@ describe("prepareThreadRun auto context boundary", () => {
|
|
|
292
291
|
thread_id: thread.thread_id,
|
|
293
292
|
message: "topic 3 question",
|
|
294
293
|
_user_message_persisted: true,
|
|
295
|
-
_message_count_before_user: messageCountBefore,
|
|
296
294
|
_idle_ms_before_user: null,
|
|
297
295
|
context_profile: {
|
|
298
296
|
include_hot: true,
|
|
@@ -374,4 +372,126 @@ describe("prepareThreadRun auto context boundary", () => {
|
|
|
374
372
|
"continue after retention guard",
|
|
375
373
|
]);
|
|
376
374
|
});
|
|
377
|
-
|
|
375
|
+
|
|
376
|
+
async function seedBridgeHeavyAgent(agentId: string, foregroundTurns: number) {
|
|
377
|
+
upsertModelConfig("default", "openai", "gpt-4o-mini", { api_key: "sk-test" }, true);
|
|
378
|
+
upsertAgentConfig({
|
|
379
|
+
id: agentId,
|
|
380
|
+
name: agentId,
|
|
381
|
+
identity: "helper",
|
|
382
|
+
instructions: "Be helpful.",
|
|
383
|
+
tools: [],
|
|
384
|
+
model_config_name: null,
|
|
385
|
+
});
|
|
386
|
+
const thread = createThread(agentId);
|
|
387
|
+
const metadata = { bridge_conversation: { key: "wa:chat-1" } };
|
|
388
|
+
for (let i = 0; i < 20; i++) {
|
|
389
|
+
addMessage(thread.thread_id, i % 2 === 0 ? "user" : "assistant", `bridge turn ${i}`, undefined, "bridge", metadata);
|
|
390
|
+
}
|
|
391
|
+
for (let i = 0; i < foregroundTurns; i++) {
|
|
392
|
+
addMessage(thread.thread_id, "user", `chat question ${i}`);
|
|
393
|
+
addMessage(thread.thread_id, "assistant", `chat answer ${i}`);
|
|
394
|
+
}
|
|
395
|
+
return { thread, metadata };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
it("does not start size compaction for bridge rows that compaction can never prune", async () => {
|
|
399
|
+
const { thread, metadata } = await seedBridgeHeavyAgent("agent-bridge-only", 0);
|
|
400
|
+
const onCompacting = vi.fn();
|
|
401
|
+
await prepareThreadRun({
|
|
402
|
+
thread_id: thread.thread_id,
|
|
403
|
+
message: "new bridge message",
|
|
404
|
+
user_category: "bridge",
|
|
405
|
+
message_metadata: metadata,
|
|
406
|
+
history_bridge_key: "wa:chat-1",
|
|
407
|
+
context_profile: { history_scope: "bridge", include_hot: true, include_warm: false, include_facts: false, include_recall: false },
|
|
408
|
+
_onCompacting: onCompacting,
|
|
409
|
+
});
|
|
410
|
+
expect(onCompacting).not.toHaveBeenCalled();
|
|
411
|
+
expect(getMessages(thread.thread_id).filter((m) => m.category === "bridge")).toHaveLength(21);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("does not report compacting on a short chat just because the bridge history is long", async () => {
|
|
415
|
+
const { thread } = await seedBridgeHeavyAgent("agent-bridge-mixed", 1);
|
|
416
|
+
const onCompacting = vi.fn();
|
|
417
|
+
await prepareThreadRun({
|
|
418
|
+
thread_id: thread.thread_id,
|
|
419
|
+
message: "quick chat question",
|
|
420
|
+
context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
|
|
421
|
+
_onCompacting: onCompacting,
|
|
422
|
+
});
|
|
423
|
+
expect(onCompacting).not.toHaveBeenCalled();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("still size-compacts once the chat itself outgrows the cap alongside bridge rows", async () => {
|
|
427
|
+
const { thread } = await seedBridgeHeavyAgent("agent-bridge-busy-chat", 6);
|
|
428
|
+
const onCompacting = vi.fn();
|
|
429
|
+
await prepareThreadRun({
|
|
430
|
+
thread_id: thread.thread_id,
|
|
431
|
+
message: "long chat question",
|
|
432
|
+
context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
|
|
433
|
+
_onCompacting: onCompacting,
|
|
434
|
+
});
|
|
435
|
+
expect(onCompacting).toHaveBeenCalledTimes(1);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("does not re-run hot-turn compaction on the very next turn after a commit", async () => {
|
|
439
|
+
upsertModelConfig("default", "openai", "gpt-4o-mini", { api_key: "sk-test" }, true);
|
|
440
|
+
upsertAgentConfig({
|
|
441
|
+
id: "agent-hot-turn-rolling",
|
|
442
|
+
name: "Rolling Hot Turn Agent",
|
|
443
|
+
identity: "helper",
|
|
444
|
+
instructions: "Be helpful.",
|
|
445
|
+
tools: [],
|
|
446
|
+
model_config_name: null,
|
|
447
|
+
history_window_hours: 0,
|
|
448
|
+
hot_turn_limit: 2,
|
|
449
|
+
});
|
|
450
|
+
const thread = createThread("agent-hot-turn-rolling");
|
|
451
|
+
for (let i = 0; i < 4; i += 1) {
|
|
452
|
+
addMessage(thread.thread_id, "user", `topic ${i} question`);
|
|
453
|
+
addMessage(thread.thread_id, "assistant", `topic ${i} answer`);
|
|
454
|
+
}
|
|
455
|
+
const seeded = getMessages(thread.thread_id);
|
|
456
|
+
seeded.forEach((message, index) => {
|
|
457
|
+
getDb().prepare("UPDATE messages SET created_at=? WHERE msg_id=?")
|
|
458
|
+
.run(new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), message.msg_id);
|
|
459
|
+
});
|
|
460
|
+
const rows = getMessages(thread.thread_id);
|
|
461
|
+
providerSummary = [
|
|
462
|
+
"AUTO-COMPACT-RECAP",
|
|
463
|
+
"```jarela-topics",
|
|
464
|
+
JSON.stringify([{
|
|
465
|
+
title: "later topic",
|
|
466
|
+
start_seq: rows[4].seq,
|
|
467
|
+
end_seq: rows[7].seq,
|
|
468
|
+
start_at: rows[4].created_at,
|
|
469
|
+
end_at: rows[7].created_at,
|
|
470
|
+
recap: "Topics 2 and 3.",
|
|
471
|
+
facts: [],
|
|
472
|
+
}]),
|
|
473
|
+
"```",
|
|
474
|
+
].join("\n");
|
|
475
|
+
const turn = (message: string) => prepareThreadRun({
|
|
476
|
+
thread_id: thread.thread_id,
|
|
477
|
+
message,
|
|
478
|
+
context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await turn("topic 4 question");
|
|
482
|
+
await waitForCondition(() => getThread(thread.thread_id)?.hot_since_seq === rows[4].seq);
|
|
483
|
+
addMessage(thread.thread_id, "assistant", "topic 4 answer");
|
|
484
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
485
|
+
|
|
486
|
+
providerCalls = 0;
|
|
487
|
+
await turn("topic 5 question");
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
489
|
+
expect(providerCalls).toBe(0);
|
|
490
|
+
addMessage(thread.thread_id, "assistant", "topic 5 answer");
|
|
491
|
+
|
|
492
|
+
// The cooldown is half the turn limit; the turn after it re-evaluates.
|
|
493
|
+
await turn("topic 6 question");
|
|
494
|
+
await waitForCondition(() => providerCalls > 0);
|
|
495
|
+
expect(getThread(thread.thread_id)?.hot_since_seq).toBe(rows[4].seq);
|
|
496
|
+
});
|
|
497
|
+
});
|
package/lib/agents/run-thread.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { autoCompactionKeepLast, compactAgentThread } from "@/lib/agents/thread-
|
|
|
13
13
|
import { kickBoundaryCompaction, kickWarmSummaryRefresh, pendingCompactionBoundarySeq } from "@/lib/agents/warm-summary-background";
|
|
14
14
|
import { moveThreadContextBoundary } from "@/lib/agents/context-boundary";
|
|
15
15
|
import { getForegroundTabPresence } from "@/lib/api/foreground-presence";
|
|
16
|
-
import { addMessage, capToolEventPayload,
|
|
16
|
+
import { addMessage, capToolEventPayload, countForegroundThreadMessages, getMessagesPage, isValidThreadContextCursor, pruneThreadMessages, getRecentlyUsedToolNames, getRecentMessagesWindow, getThread, getThreadMessageBySeq, mergeMessageMetadata, touchThread, updateMessageContent, type MessageTranscriptStatus, type PersistedToolEvent } from "@/lib/stores/threads";
|
|
17
17
|
import { transcriptText } from "@/lib/agents/conversation-summary";
|
|
18
18
|
import { getMaskRunContext } from "@/lib/redaction/context";
|
|
19
19
|
import { recordToolUsage } from "@/lib/stores/tool-stats";
|
|
@@ -170,10 +170,19 @@ function hotTurnBoundary(
|
|
|
170
170
|
return boundary?.seq ?? null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// The topic-aware boundary keeps the whole active topic hot, so user turns
|
|
174
|
+
// since the pin usually still exceed the limit right after a commit. Without a
|
|
175
|
+
// cooldown every following turn re-runs the topic LLM pass only to land on the
|
|
176
|
+
// same pin. Skip the next half-limit turns (2 rows per turn) before re-checking.
|
|
177
|
+
export function hotTurnCooldownUntil(messageCount: number, hotTurnLimit: number | null | undefined): number {
|
|
178
|
+
const turnLimit = hotTurnLimit ?? DEFAULT_HOT_TURN_LIMIT;
|
|
179
|
+
const skipTurns = Math.max(1, Math.ceil(Math.max(1, turnLimit) / 2));
|
|
180
|
+
return messageCount + skipTurns * 2 + 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
async function maybeAutoCompactOversizedThread(
|
|
174
184
|
agentId: string,
|
|
175
185
|
threadId: string,
|
|
176
|
-
messageCount: number,
|
|
177
186
|
onCompacting?: () => void,
|
|
178
187
|
preserveNewestMessages = 0,
|
|
179
188
|
): Promise<void> {
|
|
@@ -181,12 +190,17 @@ async function maybeAutoCompactOversizedThread(
|
|
|
181
190
|
const keepLast = autoCompactionKeepLast(cap);
|
|
182
191
|
const retainedMessages = keepLast + preserveNewestMessages;
|
|
183
192
|
const triggerAt = cap + (cap - keepLast);
|
|
184
|
-
if (!Number.isFinite(cap) || cap <= 0
|
|
193
|
+
if (!Number.isFinite(cap) || cap <= 0) return;
|
|
194
|
+
// Only foreground rows are ever pruned; counting bridge/watcher/scheduled
|
|
195
|
+
// rows would re-trigger an LLM compaction on every turn of a busy bridge
|
|
196
|
+
// agent without ever bringing the count back under the cap.
|
|
197
|
+
const messageCount = countForegroundThreadMessages(threadId);
|
|
198
|
+
if (messageCount < triggerAt) return;
|
|
185
199
|
try {
|
|
186
200
|
// A pin that already keeps the hot tier within the retained range must not
|
|
187
201
|
// be pulled back to the cutoff, nor re-summarised by an LLM on every turn.
|
|
188
202
|
const pinSeq = getThread(threadId)?.hot_since_seq ?? null;
|
|
189
|
-
if (pinSeq !== null &&
|
|
203
|
+
if (pinSeq !== null && countForegroundThreadMessages(threadId, pinSeq) <= retainedMessages) {
|
|
190
204
|
pruneThreadMessages(threadId, retainedMessages, pinSeq);
|
|
191
205
|
return;
|
|
192
206
|
}
|
|
@@ -237,7 +251,6 @@ export class RunThreadError extends Error {
|
|
|
237
251
|
|
|
238
252
|
export interface PersistedThreadRunUserMessage {
|
|
239
253
|
attachments?: ContentPart[];
|
|
240
|
-
messageCountBefore: number;
|
|
241
254
|
idleMsBefore: number | null;
|
|
242
255
|
userMessageId: string;
|
|
243
256
|
storedContent: string;
|
|
@@ -256,7 +269,6 @@ export async function persistThreadRunUserMessage(
|
|
|
256
269
|
throw new RunThreadError(400, "message required", "message_required");
|
|
257
270
|
}
|
|
258
271
|
|
|
259
|
-
const messageCountBefore = thread.message_count;
|
|
260
272
|
const autoBoundaryScope = req.context_profile?.history_scope
|
|
261
273
|
?? (req.user_category === "bridge" ? "bridge" : "foreground");
|
|
262
274
|
const idleMsBefore = isAutoBoundaryEligibleCategory(req.user_category)
|
|
@@ -287,7 +299,6 @@ export async function persistThreadRunUserMessage(
|
|
|
287
299
|
|
|
288
300
|
return {
|
|
289
301
|
attachments: spilledAttachments,
|
|
290
|
-
messageCountBefore,
|
|
291
302
|
idleMsBefore,
|
|
292
303
|
userMessageId: userMessage.msg_id,
|
|
293
304
|
storedContent: stored,
|
|
@@ -486,8 +497,8 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
|
|
|
486
497
|
await maybeAutoCompactOversizedThread(
|
|
487
498
|
agentCfg.id,
|
|
488
499
|
req.thread_id,
|
|
489
|
-
req._message_count_before_user ?? thread.message_count,
|
|
490
500
|
req._onCompacting,
|
|
501
|
+
|
|
491
502
|
req._user_message_persisted ? 2 : 0,
|
|
492
503
|
);
|
|
493
504
|
}
|
|
@@ -552,7 +563,11 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
|
|
|
552
563
|
&& latestThread.message_count < (latestThread.auto_boundary_locked_until_msg_count ?? 0);
|
|
553
564
|
if (!locked) {
|
|
554
565
|
const boundary = hotTurnBoundary(req.thread_id, agentCfg, autoBoundaryScope);
|
|
555
|
-
if (boundary !== null)
|
|
566
|
+
if (boundary !== null) {
|
|
567
|
+
kickBoundaryCompaction(req.thread_id, boundary, {
|
|
568
|
+
autoBoundaryLockedUntilMessageCount: hotTurnCooldownUntil(latestThread?.message_count ?? 0, agentCfg.hot_turn_limit),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
556
571
|
}
|
|
557
572
|
}
|
|
558
573
|
}
|
|
@@ -165,3 +165,94 @@ describe("NodeSqliteSaver", () => {
|
|
|
165
165
|
expect(tuple).toBeUndefined();
|
|
166
166
|
});
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("NodeSqliteSaver edge cases", () => {
|
|
170
|
+
async function chain(saver: NodeSqliteSaver, thread: string, count: number, ns = ""): Promise<string[]> {
|
|
171
|
+
const ids: string[] = [];
|
|
172
|
+
let parent: string | undefined;
|
|
173
|
+
for (let i = 0; i < count; i++) {
|
|
174
|
+
const ck = newCheckpoint();
|
|
175
|
+
await saver.put(
|
|
176
|
+
{ configurable: { thread_id: thread, checkpoint_ns: ns, ...(parent ? { checkpoint_id: parent } : {}) } },
|
|
177
|
+
ck,
|
|
178
|
+
{ ...META_BASE, step: i },
|
|
179
|
+
);
|
|
180
|
+
ids.push(ck.id);
|
|
181
|
+
parent = ck.id;
|
|
182
|
+
}
|
|
183
|
+
return ids;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it("links each checkpoint to its parent and returns no parent for the first one", async () => {
|
|
187
|
+
const saver = newSaver();
|
|
188
|
+
const [first, second] = await chain(saver, "t-parent", 2);
|
|
189
|
+
const latest = await saver.getTuple(cfg("t-parent"));
|
|
190
|
+
expect(latest?.checkpoint.id).toBe(second);
|
|
191
|
+
expect(latest?.parentConfig?.configurable?.checkpoint_id).toBe(first);
|
|
192
|
+
expect((await saver.getTuple(cfg("t-parent", first)))?.parentConfig).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("keeps checkpoint namespaces (subgraphs) isolated within one thread", async () => {
|
|
196
|
+
const saver = newSaver();
|
|
197
|
+
const [root] = await chain(saver, "t-ns", 1, "");
|
|
198
|
+
const [child] = await chain(saver, "t-ns", 1, "sub:1");
|
|
199
|
+
const rootLatest = await saver.getTuple({ configurable: { thread_id: "t-ns", checkpoint_ns: "" } });
|
|
200
|
+
const childLatest = await saver.getTuple({ configurable: { thread_id: "t-ns", checkpoint_ns: "sub:1" } });
|
|
201
|
+
expect(rootLatest?.checkpoint.id).toBe(root);
|
|
202
|
+
expect(childLatest?.checkpoint.id).toBe(child);
|
|
203
|
+
expect(childLatest?.config.configurable?.checkpoint_ns).toBe("sub:1");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("pages list() with before, newest first, without repeating the cursor", async () => {
|
|
207
|
+
const saver = newSaver();
|
|
208
|
+
const ids = await chain(saver, "t-before", 4);
|
|
209
|
+
const page = [];
|
|
210
|
+
for await (const tuple of saver.list(cfg("t-before"), { before: cfg("t-before", ids[2]), limit: 10 })) page.push(tuple.checkpoint.id);
|
|
211
|
+
expect(page).toEqual([ids[1], ids[0]]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns pending writes for the checkpoint they belong to, and replaces a retried task's writes", async () => {
|
|
215
|
+
const saver = newSaver();
|
|
216
|
+
const [first, second] = await chain(saver, "t-writes", 2);
|
|
217
|
+
await saver.putWrites(cfg("t-writes", second), [["messages", { text: "draft" }], ["status", "running"]], "task-1");
|
|
218
|
+
await saver.putWrites(cfg("t-writes", second), [["messages", { text: "final" }], ["status", "done"]], "task-1");
|
|
219
|
+
await saver.putWrites(cfg("t-writes", first), [["messages", { text: "old" }]], "task-0");
|
|
220
|
+
|
|
221
|
+
const latest = await saver.getTuple(cfg("t-writes"));
|
|
222
|
+
expect(latest?.pendingWrites).toEqual([
|
|
223
|
+
["task-1", "messages", { text: "final" }],
|
|
224
|
+
["task-1", "status", "done"],
|
|
225
|
+
]);
|
|
226
|
+
const older = await saver.getTuple(cfg("t-writes", first));
|
|
227
|
+
expect(older?.pendingWrites).toEqual([["task-0", "messages", { text: "old" }]]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("round-trips non-ASCII and large channel values", async () => {
|
|
231
|
+
const saver = newSaver();
|
|
232
|
+
const ck = newCheckpoint();
|
|
233
|
+
const text = `你好 👋 ${"x".repeat(200_000)}`;
|
|
234
|
+
(ck.channel_values as Record<string, unknown>).messages = [{ text }];
|
|
235
|
+
await saver.put(cfg("t-big"), ck, META_BASE);
|
|
236
|
+
await saver.putWrites(cfg("t-big", ck.id), [["note", text]], "task");
|
|
237
|
+
const tuple = await saver.getTuple(cfg("t-big"));
|
|
238
|
+
expect((tuple?.checkpoint.channel_values as Record<string, unknown>).messages).toEqual([{ text }]);
|
|
239
|
+
expect(tuple?.pendingWrites?.[0][2]).toBe(text);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("rejects writes without a checkpoint_id and puts without a thread_id", async () => {
|
|
243
|
+
const saver = newSaver();
|
|
244
|
+
await expect(saver.putWrites(cfg("t-x"), [["c", 1]], "task")).rejects.toThrow(/checkpoint_id/);
|
|
245
|
+
await expect(saver.put({ configurable: {} }, newCheckpoint(), META_BASE)).rejects.toThrow(/thread_id/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("deleteThread leaves other threads untouched", async () => {
|
|
249
|
+
const saver = newSaver();
|
|
250
|
+
const [keep] = await chain(saver, "t-keep", 1);
|
|
251
|
+
const [drop] = await chain(saver, "t-drop", 1);
|
|
252
|
+
await saver.putWrites(cfg("t-keep", keep), [["c", 1]], "task");
|
|
253
|
+
await saver.putWrites(cfg("t-drop", drop), [["c", 2]], "task");
|
|
254
|
+
await saver.deleteThread("t-drop");
|
|
255
|
+
expect(await saver.getTuple(cfg("t-drop"))).toBeUndefined();
|
|
256
|
+
expect((await saver.getTuple(cfg("t-keep")))?.pendingWrites).toEqual([["task", "c", 1]]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getThreadMessageBySeq,
|
|
17
17
|
getThreadChannelSummary,
|
|
18
18
|
listThreadChannelSummaries,
|
|
19
|
+
setAutoBoundaryLock,
|
|
19
20
|
} from "@/lib/stores/threads";
|
|
20
21
|
import { getOrCreateGlobal } from "@/lib/utils/global-state";
|
|
21
22
|
import {
|
|
@@ -163,9 +164,10 @@ function runBoundaryCompaction(
|
|
|
163
164
|
): void {
|
|
164
165
|
const baseThread = getThread(threadId);
|
|
165
166
|
const basePinSeq = baseThread?.hot_since_seq ?? null;
|
|
167
|
+
const baseLock = baseThread?.auto_boundary_locked_until_msg_count ?? 0;
|
|
166
168
|
void runTrackedCompaction(threadId, "auto", boundarySeq, async (attempt) => {
|
|
167
169
|
await Promise.resolve();
|
|
168
|
-
await commitBoundaryCompaction(threadId, boundarySeq, basePinSeq, options, attempt);
|
|
170
|
+
await commitBoundaryCompaction(threadId, boundarySeq, basePinSeq, baseLock, options, attempt);
|
|
169
171
|
})
|
|
170
172
|
.catch((err) => console.warn(`[context-boundary:auto] compaction failed thread=${threadId}: ${String(err)}`))
|
|
171
173
|
.finally(() => drainQueuedBoundary(threadId));
|
|
@@ -174,6 +176,7 @@ async function commitBoundaryCompaction(
|
|
|
174
176
|
threadId: string,
|
|
175
177
|
boundarySeq: number,
|
|
176
178
|
basePinSeq: number | null,
|
|
179
|
+
baseLock: number,
|
|
177
180
|
options: Pick<WarmContextCompactionOptions, "autoBoundaryLockedUntilMessageCount">,
|
|
178
181
|
attempt?: CompactionAttempt,
|
|
179
182
|
): Promise<void> {
|
|
@@ -184,7 +187,24 @@ async function commitBoundaryCompaction(
|
|
|
184
187
|
...options,
|
|
185
188
|
attempt,
|
|
186
189
|
});
|
|
187
|
-
if (!committed)
|
|
190
|
+
if (!committed) {
|
|
191
|
+
// A pass that resolved to no change (the active topic still starts at the
|
|
192
|
+
// current pin) must not be retried on every following turn either. Skip
|
|
193
|
+
// it when the boundary or lock changed mid-pass (e.g. the user cleared or
|
|
194
|
+
// moved the boundary): that newer state wins over this stale pass.
|
|
195
|
+
const lockUntil = options.autoBoundaryLockedUntilMessageCount;
|
|
196
|
+
const current = getThread(threadId);
|
|
197
|
+
if (
|
|
198
|
+
lockUntil !== undefined &&
|
|
199
|
+
current &&
|
|
200
|
+
(current.hot_since_seq ?? null) === basePinSeq &&
|
|
201
|
+
(current.auto_boundary_locked_until_msg_count ?? 0) === baseLock &&
|
|
202
|
+
lockUntil > baseLock
|
|
203
|
+
) {
|
|
204
|
+
setAutoBoundaryLock(threadId, lockUntil);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
188
208
|
console.info(
|
|
189
209
|
`[context-boundary:auto] thread=${threadId} committed boundary_seq=${committed.boundarySeq} warm_msgs=${committed.sourceMessages}`,
|
|
190
210
|
);
|