stagent 0.10.0 → 0.11.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 (170) hide show
  1. package/README.md +15 -2
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/index.md +1 -1
  18. package/docs/journeys/developer.md +25 -2
  19. package/docs/journeys/personal-use.md +12 -5
  20. package/docs/journeys/power-user.md +45 -14
  21. package/docs/journeys/work-use.md +17 -8
  22. package/docs/manifest.json +15 -15
  23. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  24. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  25. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  27. package/next.config.mjs +1 -0
  28. package/package.json +1 -1
  29. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  30. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  31. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  32. package/src/app/api/chat/export/route.ts +52 -0
  33. package/src/app/api/chat/files/search/route.ts +50 -0
  34. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  35. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  36. package/src/app/api/environment/skills/route.ts +13 -0
  37. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  38. package/src/app/api/settings/chat/pins/route.ts +94 -0
  39. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  40. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  41. package/src/app/api/settings/environment/route.ts +26 -0
  42. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  43. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  44. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  45. package/src/app/documents/page.tsx +4 -1
  46. package/src/app/settings/page.tsx +2 -0
  47. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  48. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  49. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  50. package/src/components/chat/capability-banner.tsx +68 -0
  51. package/src/components/chat/chat-command-popover.tsx +668 -47
  52. package/src/components/chat/chat-input.tsx +103 -8
  53. package/src/components/chat/chat-message.tsx +12 -3
  54. package/src/components/chat/chat-session-provider.tsx +73 -3
  55. package/src/components/chat/chat-shell.tsx +62 -3
  56. package/src/components/chat/command-tab-bar.tsx +68 -0
  57. package/src/components/chat/conversation-template-picker.tsx +421 -0
  58. package/src/components/chat/help-dialog.tsx +39 -0
  59. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  60. package/src/components/chat/skill-row.tsx +147 -0
  61. package/src/components/documents/document-browser.tsx +37 -19
  62. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  63. package/src/components/notifications/permission-response-actions.tsx +155 -1
  64. package/src/components/settings/environment-section.tsx +102 -0
  65. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  66. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  67. package/src/components/shared/command-palette.tsx +262 -2
  68. package/src/components/shared/filter-hint.tsx +70 -0
  69. package/src/components/shared/filter-input.tsx +59 -0
  70. package/src/components/shared/saved-searches-manager.tsx +199 -0
  71. package/src/components/tasks/task-bento-grid.tsx +12 -2
  72. package/src/components/tasks/task-card.tsx +3 -0
  73. package/src/components/tasks/task-chip-bar.tsx +30 -1
  74. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  75. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  76. package/src/hooks/use-active-skills.ts +110 -0
  77. package/src/hooks/use-chat-autocomplete.ts +120 -7
  78. package/src/hooks/use-enriched-skills.ts +19 -0
  79. package/src/hooks/use-pinned-entries.ts +104 -0
  80. package/src/hooks/use-recent-user-messages.ts +19 -0
  81. package/src/hooks/use-saved-searches.ts +142 -0
  82. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  83. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  84. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  85. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  86. package/src/lib/agents/claude-agent.ts +105 -46
  87. package/src/lib/agents/handoff/bus.ts +2 -2
  88. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  89. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  90. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  91. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  92. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  93. package/src/lib/agents/profiles/registry.ts +18 -0
  94. package/src/lib/agents/profiles/types.ts +7 -1
  95. package/src/lib/agents/router.ts +3 -6
  96. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  97. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  98. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  99. package/src/lib/agents/runtime/catalog.ts +121 -0
  100. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  101. package/src/lib/agents/runtime/execution-target.ts +456 -0
  102. package/src/lib/agents/runtime/index.ts +4 -0
  103. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  104. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  105. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  106. package/src/lib/agents/task-dispatch.ts +220 -0
  107. package/src/lib/agents/tool-permissions.ts +16 -1
  108. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  109. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  110. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  111. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  112. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  113. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  114. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  115. package/src/lib/chat/__tests__/types.test.ts +28 -0
  116. package/src/lib/chat/active-skills.ts +31 -0
  117. package/src/lib/chat/clean-filter-input.ts +30 -0
  118. package/src/lib/chat/codex-engine.ts +30 -7
  119. package/src/lib/chat/command-tabs.ts +61 -0
  120. package/src/lib/chat/context-builder.ts +141 -1
  121. package/src/lib/chat/dismissals.ts +73 -0
  122. package/src/lib/chat/engine.ts +109 -15
  123. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  124. package/src/lib/chat/files/expand-mention.ts +76 -0
  125. package/src/lib/chat/files/search.ts +99 -0
  126. package/src/lib/chat/skill-composition.ts +210 -0
  127. package/src/lib/chat/skill-conflict.ts +105 -0
  128. package/src/lib/chat/stagent-tools.ts +6 -19
  129. package/src/lib/chat/stream-telemetry.ts +9 -4
  130. package/src/lib/chat/system-prompt.ts +22 -0
  131. package/src/lib/chat/tool-catalog.ts +33 -3
  132. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  133. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  134. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  135. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  136. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  137. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  138. package/src/lib/chat/tools/helpers.ts +2 -0
  139. package/src/lib/chat/tools/profile-tools.ts +120 -23
  140. package/src/lib/chat/tools/skill-tools.ts +183 -0
  141. package/src/lib/chat/tools/task-tools.ts +6 -2
  142. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  143. package/src/lib/chat/types.ts +15 -0
  144. package/src/lib/constants/settings.ts +2 -0
  145. package/src/lib/data/clear.ts +2 -6
  146. package/src/lib/db/bootstrap.ts +17 -0
  147. package/src/lib/db/schema.ts +26 -0
  148. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  149. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  150. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  151. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  152. package/src/lib/environment/data.ts +9 -0
  153. package/src/lib/environment/list-skills.ts +176 -0
  154. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  155. package/src/lib/environment/parsers/skill.ts +26 -5
  156. package/src/lib/environment/profile-generator.ts +54 -0
  157. package/src/lib/environment/skill-enrichment.ts +106 -0
  158. package/src/lib/environment/skill-recommendations.ts +66 -0
  159. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  160. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  161. package/src/lib/filters/parse.ts +86 -0
  162. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  163. package/src/lib/instance/fingerprint.ts +7 -9
  164. package/src/lib/instance/upgrade-poller.ts +53 -1
  165. package/src/lib/schedules/scheduler.ts +4 -4
  166. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  167. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  168. package/src/lib/workflows/blueprints/types.ts +6 -0
  169. package/src/lib/workflows/engine.ts +5 -3
  170. package/src/test/setup.ts +10 -0
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const { mockFs } = vi.hoisted(() => ({
4
+ mockFs: {
5
+ realpathMap: new Map<string, string>(),
6
+ files: new Map<string, string>(),
7
+ statMap: new Map<string, { size: number }>(),
8
+ },
9
+ }));
10
+
11
+ vi.mock("node:fs", () => {
12
+ const realpathSync = (p: string) => {
13
+ const real = mockFs.realpathMap.get(p);
14
+ if (real === undefined) throw new Error(`ENOENT: realpath ${p}`);
15
+ return real;
16
+ };
17
+ const statSync = (p: string) => {
18
+ const s = mockFs.statMap.get(p);
19
+ if (!s) throw new Error(`ENOENT: stat ${p}`);
20
+ return s;
21
+ };
22
+ const readFileSync = (p: string) => {
23
+ const content = mockFs.files.get(p);
24
+ if (content === undefined) throw new Error(`ENOENT: read ${p}`);
25
+ return content;
26
+ };
27
+ return {
28
+ default: { realpathSync, statSync, readFileSync },
29
+ realpathSync,
30
+ statSync,
31
+ readFileSync,
32
+ };
33
+ });
34
+
35
+ import { expandFileMention } from "../files/expand-mention";
36
+
37
+ const CWD = "/repo";
38
+
39
+ beforeEach(() => {
40
+ mockFs.realpathMap.clear();
41
+ mockFs.files.clear();
42
+ mockFs.statMap.clear();
43
+ mockFs.realpathMap.set(CWD, CWD);
44
+ });
45
+
46
+ function registerFile(relPath: string, content: string) {
47
+ const abs = `${CWD}/${relPath}`;
48
+ mockFs.files.set(abs, content);
49
+ mockFs.statMap.set(abs, { size: Buffer.byteLength(content, "utf8") });
50
+ }
51
+
52
+ describe("expandFileMention", () => {
53
+ it("inlines files under 8 KB with a path header and fenced code block", () => {
54
+ registerFile("src/a.ts", "export const x = 1;\n");
55
+ const out = expandFileMention("src/a.ts", CWD).join("\n");
56
+ expect(out).toContain("### File: src/a.ts");
57
+ expect(out).toContain("```ts");
58
+ expect(out).toContain("export const x = 1;");
59
+ expect(out).toContain("```");
60
+ });
61
+
62
+ it("references files >= 8 KB without inlining their content", () => {
63
+ const big = "A".repeat(10 * 1024);
64
+ registerFile("docs/large.md", big);
65
+ const out = expandFileMention("docs/large.md", CWD).join("\n");
66
+ expect(out).toContain("File (by reference): docs/large.md");
67
+ expect(out).toContain("KB)"); // size hint
68
+ expect(out).toContain("Use the Read tool");
69
+ expect(out).not.toContain(big); // content not inlined
70
+ });
71
+
72
+ it("emits a not-found note when the file no longer exists", () => {
73
+ const out = expandFileMention("src/gone.ts", CWD).join("\n");
74
+ expect(out).toContain("### File: src/gone.ts");
75
+ expect(out).toContain("(file not found at context-build time)");
76
+ });
77
+
78
+ it("rejects paths that resolve outside cwd (security guardrail)", () => {
79
+ const out = expandFileMention("../escape.ts", CWD).join("\n");
80
+ expect(out).toContain("(invalid path — escapes working directory)");
81
+ expect(out).not.toContain("(file not found"); // did not even try to read
82
+ });
83
+
84
+ it("skips pathological files >= 50 MB silently (returns empty)", () => {
85
+ const abs = `${CWD}/huge.bin`;
86
+ mockFs.statMap.set(abs, { size: 60 * 1024 * 1024 });
87
+ // readFileSync is never reached
88
+ const out = expandFileMention("huge.bin", CWD);
89
+ expect(out).toEqual([]);
90
+ });
91
+
92
+ it("picks an 'unknown' code-fence language for files without an extension", () => {
93
+ const abs = `${CWD}/Makefile`;
94
+ mockFs.files.set(abs, "all:\n\techo ok\n");
95
+ mockFs.statMap.set(abs, { size: 16 });
96
+ const out = expandFileMention("Makefile", CWD).join("\n");
97
+ // .split(".").pop() on a name with no dot returns the whole name,
98
+ // which is the best we can do without a language map. We just
99
+ // assert a header + a closing fence are present.
100
+ expect(out).toContain("### File: Makefile");
101
+ expect(out).toMatch(/```[\w]*/);
102
+ expect(out).toContain("all:");
103
+ });
104
+
105
+ it("emits a read-failure note if the file stats OK but reads throw", () => {
106
+ const abs = `${CWD}/src/binary.ico`;
107
+ mockFs.statMap.set(abs, { size: 100 });
108
+ // Do NOT register contents — readFileSync will throw
109
+ const out = expandFileMention("src/binary.ico", CWD).join("\n");
110
+ expect(out).toContain("(file could not be read as UTF-8)");
111
+ });
112
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ loadDismissals,
4
+ saveDismissal,
5
+ activeDismissedIds,
6
+ DISMISSAL_TTL_MS,
7
+ } from "../dismissals";
8
+
9
+ type Store = { read: () => string | null; write: (v: string) => void };
10
+
11
+ function mockStore(initial: string | null = null): Store {
12
+ let v = initial;
13
+ return {
14
+ read: () => v,
15
+ write: (next) => {
16
+ v = next;
17
+ },
18
+ };
19
+ }
20
+
21
+ describe("dismissals", () => {
22
+ const NOW = 1_700_000_000_000;
23
+
24
+ it("returns empty when store is null", () => {
25
+ const store = mockStore();
26
+ const all = loadDismissals(store);
27
+ expect(all).toEqual({});
28
+ });
29
+
30
+ it("saves dismissals keyed by conversation + skill", () => {
31
+ const store = mockStore();
32
+ saveDismissal(store, "conv-1", "skill-a", NOW);
33
+ const all = loadDismissals(store);
34
+ expect(all["conv-1"]["skill-a"]).toBe(NOW);
35
+ });
36
+
37
+ it("activeDismissedIds excludes expired entries", () => {
38
+ const store = mockStore();
39
+ saveDismissal(store, "c1", "fresh", NOW);
40
+ saveDismissal(store, "c1", "old", NOW - DISMISSAL_TTL_MS - 1000);
41
+ const ids = activeDismissedIds(store, "c1", NOW);
42
+ expect(ids.has("fresh")).toBe(true);
43
+ expect(ids.has("old")).toBe(false);
44
+ });
45
+
46
+ it("returns empty set when conversation has no dismissals", () => {
47
+ const store = mockStore();
48
+ expect(activeDismissedIds(store, "never-seen", NOW).size).toBe(0);
49
+ });
50
+
51
+ it("silently tolerates store write errors", () => {
52
+ const store: Store = {
53
+ read: () => null,
54
+ write: () => {
55
+ throw new Error("quota");
56
+ },
57
+ };
58
+ expect(() => saveDismissal(store, "c1", "s1", NOW)).not.toThrow();
59
+ });
60
+
61
+ it("silently tolerates corrupt JSON on read", () => {
62
+ const store = mockStore("not-json");
63
+ expect(loadDismissals(store)).toEqual({});
64
+ });
65
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ CLAUDE_SDK_ALLOWED_TOOLS,
4
+ CLAUDE_SDK_SETTING_SOURCES,
5
+ CLAUDE_SDK_READ_ONLY_FS_TOOLS,
6
+ } from "@/lib/chat/engine";
7
+
8
+ describe("Claude SDK options (Phase 1a)", () => {
9
+ it("declares settingSources loading user and project config", () => {
10
+ expect(CLAUDE_SDK_SETTING_SOURCES).toEqual(["user", "project"]);
11
+ });
12
+
13
+ it("includes Skill, filesystem tools, Bash, and TodoWrite in allowedTools", () => {
14
+ expect(CLAUDE_SDK_ALLOWED_TOOLS).toEqual(
15
+ expect.arrayContaining([
16
+ "Skill",
17
+ "Read",
18
+ "Grep",
19
+ "Glob",
20
+ "Edit",
21
+ "Write",
22
+ "Bash",
23
+ "TodoWrite",
24
+ ])
25
+ );
26
+ });
27
+
28
+ it("does NOT include Task (subagent delegation replaced by Stagent primitives)", () => {
29
+ expect(CLAUDE_SDK_ALLOWED_TOOLS).not.toContain("Task");
30
+ });
31
+
32
+ it("declares Read, Grep, Glob as read-only filesystem tools", () => {
33
+ expect(CLAUDE_SDK_READ_ONLY_FS_TOOLS).toEqual(
34
+ new Set(["Read", "Grep", "Glob"])
35
+ );
36
+ });
37
+
38
+ it("does NOT treat Edit, Write, Bash, or TodoWrite as read-only", () => {
39
+ for (const tool of ["Edit", "Write", "Bash", "TodoWrite"]) {
40
+ expect(CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(tool)).toBe(false);
41
+ }
42
+ });
43
+ });
44
+
45
+ import { canUseToolForTest } from "@/lib/chat/engine";
46
+
47
+ describe("canUseTool auto-allow policy for SDK filesystem tools", () => {
48
+ it("auto-allows Read without a permission request", async () => {
49
+ const result = await canUseToolForTest("Read", { file_path: "/tmp/x" });
50
+ expect(result.behavior).toBe("allow");
51
+ });
52
+
53
+ it("auto-allows Grep without a permission request", async () => {
54
+ const result = await canUseToolForTest("Grep", { pattern: "foo" });
55
+ expect(result.behavior).toBe("allow");
56
+ });
57
+
58
+ it("auto-allows Glob without a permission request", async () => {
59
+ const result = await canUseToolForTest("Glob", { pattern: "**/*.ts" });
60
+ expect(result.behavior).toBe("allow");
61
+ });
62
+
63
+ it("auto-allows Skill tool invocation", async () => {
64
+ const result = await canUseToolForTest("Skill", { skill: "code-reviewer" });
65
+ expect(result.behavior).toBe("allow");
66
+ });
67
+
68
+ it("does NOT auto-allow Edit (must go through permission flow)", async () => {
69
+ const result = await canUseToolForTest("Edit", { file_path: "/tmp/x", content: "y" });
70
+ expect(result.behavior).not.toBe("allow");
71
+ });
72
+
73
+ it("does NOT auto-allow Bash", async () => {
74
+ const result = await canUseToolForTest("Bash", { command: "ls" });
75
+ expect(result.behavior).not.toBe("allow");
76
+ });
77
+ });
78
+
79
+ describe("hooks excluded per Q2", () => {
80
+ it("does not declare a hooks field alongside settingSources", async () => {
81
+ const fs = await import("fs");
82
+ const path = await import("path");
83
+ const enginePath = path.resolve(__dirname, "../engine.ts");
84
+ const source = fs.readFileSync(enginePath, "utf8");
85
+ // Assert that within the query() options block, there is no `hooks:` field.
86
+ // This is a regex-level check because the options object is inline literals.
87
+ const optionsBlock = source.match(/query\(\s*\{[\s\S]*?\}\s*\)/)?.[0] ?? "";
88
+ expect(optionsBlock).toContain("settingSources");
89
+ expect(optionsBlock).not.toMatch(/\bhooks\s*:/);
90
+ });
91
+ });
92
+
93
+ describe("CLAUDE_SDK_* constants source-of-truth", () => {
94
+ it("exports CLAUDE_SDK_ALLOWED_TOOLS from runtime/claude-sdk", async () => {
95
+ const mod = await import("@/lib/agents/runtime/claude-sdk");
96
+ expect(mod.CLAUDE_SDK_ALLOWED_TOOLS).toEqual([
97
+ "Skill",
98
+ "Read",
99
+ "Grep",
100
+ "Glob",
101
+ "Edit",
102
+ "Write",
103
+ "Bash",
104
+ "TodoWrite",
105
+ ]);
106
+ });
107
+
108
+ it("exports CLAUDE_SDK_SETTING_SOURCES from runtime/claude-sdk", async () => {
109
+ const mod = await import("@/lib/agents/runtime/claude-sdk");
110
+ expect(mod.CLAUDE_SDK_SETTING_SOURCES).toEqual(["user", "project"]);
111
+ });
112
+
113
+ it("exports CLAUDE_SDK_READ_ONLY_FS_TOOLS from runtime/claude-sdk", async () => {
114
+ const mod = await import("@/lib/agents/runtime/claude-sdk");
115
+ expect(mod.CLAUDE_SDK_READ_ONLY_FS_TOOLS).toEqual(new Set(["Read", "Grep", "Glob"]));
116
+ });
117
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectSkillConflicts } from "../skill-conflict";
3
+
4
+ describe("detectSkillConflicts", () => {
5
+ it("returns no conflicts for two unrelated skills", () => {
6
+ const a = { id: "a", name: "code-reviewer", content: "Always run ESLint before reviewing code." };
7
+ const b = { id: "b", name: "haiku-poet", content: "Use 5-7-5 syllable structure." };
8
+ expect(detectSkillConflicts(a, b)).toEqual([]);
9
+ });
10
+
11
+ it("flags directive divergence on a shared topic", () => {
12
+ const a = { id: "a", name: "tdd", content: "Always write the test first. Never write production code without a failing test." };
13
+ const b = { id: "b", name: "spike", content: "Never write tests during a spike. Prefer exploratory code." };
14
+ const conflicts = detectSkillConflicts(a, b);
15
+ expect(conflicts.length).toBeGreaterThan(0);
16
+ expect(conflicts[0]).toMatchObject({
17
+ skillA: "tdd",
18
+ skillB: "spike",
19
+ });
20
+ expect(conflicts[0].excerptA).toMatch(/test/i);
21
+ expect(conflicts[0].excerptB).toMatch(/test/i);
22
+ });
23
+
24
+ it("returns no conflicts when both skills agree on a topic", () => {
25
+ const a = { id: "a", name: "tdd", content: "Always write tests first." };
26
+ const b = { id: "b", name: "qa-strict", content: "Always write tests first and add coverage gates." };
27
+ expect(detectSkillConflicts(a, b)).toEqual([]);
28
+ });
29
+
30
+ it("ignores non-directive lines", () => {
31
+ const a = { id: "a", name: "x", content: "This skill is for documentation tasks." };
32
+ const b = { id: "b", name: "y", content: "Documentation is important context." };
33
+ expect(detectSkillConflicts(a, b)).toEqual([]);
34
+ });
35
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getFeaturesForModel, getRuntimeForModel } from "@/lib/chat/types";
3
+
4
+ describe("getFeaturesForModel", () => {
5
+ it("returns Claude features for a Claude model id", () => {
6
+ const features = getFeaturesForModel("sonnet");
7
+ expect(features.hasNativeSkills).toBe(true);
8
+ expect(features.autoLoadsInstructions).toBe("CLAUDE.md");
9
+ });
10
+
11
+ it("returns Ollama features for an ollama-prefixed model id", () => {
12
+ const features = getFeaturesForModel("ollama:llama3");
13
+ expect(features.stagentInjectsSkills).toBe(true);
14
+ expect(features.hasNativeSkills).toBe(false);
15
+ });
16
+
17
+ it("returns Codex features for a GPT model id", () => {
18
+ const features = getFeaturesForModel("gpt-5.4");
19
+ expect(features.autoLoadsInstructions).toBe("AGENTS.md");
20
+ });
21
+
22
+ it("falls back to claude-code features for an unknown model id", () => {
23
+ // getRuntimeForModel's fallback chain lands on claude-code for unknown ids.
24
+ const features = getFeaturesForModel("totally-made-up-model");
25
+ expect(features.hasNativeSkills).toBe(true);
26
+ expect(getRuntimeForModel("totally-made-up-model")).toBe("claude-code");
27
+ });
28
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pure helper for combining the legacy `conversations.active_skill_id`
3
+ * column with the new `conversations.active_skill_ids` JSON array
4
+ * (`features/chat-skill-composition.md`).
5
+ *
6
+ * Lives in its own module (no DB imports) so client components can use
7
+ * it without pulling server-only code into the bundle. The original
8
+ * lived alongside the chat-tool definition in `tools/skill-tools.ts`,
9
+ * which can only run server-side.
10
+ */
11
+
12
+ export function mergeActiveSkillIds(
13
+ legacyId: string | null | undefined,
14
+ composed: string[] | null | undefined
15
+ ): string[] {
16
+ const out: string[] = [];
17
+ const seen = new Set<string>();
18
+ if (legacyId) {
19
+ out.push(legacyId);
20
+ seen.add(legacyId);
21
+ }
22
+ if (composed) {
23
+ for (const id of composed) {
24
+ if (id && !seen.has(id)) {
25
+ out.push(id);
26
+ seen.add(id);
27
+ }
28
+ }
29
+ }
30
+ return out;
31
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Sanitize the filterInput we persist for a saved search.
3
+ *
4
+ * The chat popover input may include the mention trigger prefix
5
+ * (e.g. `@task: ` or `task: ` depending on what the trigger regex
6
+ * stripped). When the user "Saves this view", we want the persisted
7
+ * filterInput to contain ONLY the meaningful filter expression —
8
+ * `#key:value` clauses plus any free-text search the user typed —
9
+ * not the trigger residue.
10
+ *
11
+ * Pure function. Tested in isolation; called from
12
+ * `chat-command-popover.tsx` at the SaveViewFooter call site.
13
+ *
14
+ * See `features/saved-search-polish-v1.md` for the bug history.
15
+ */
16
+
17
+ import type { FilterClause } from "@/lib/filters/parse";
18
+
19
+ const TRIGGER_RESIDUE = /^@?[a-z]+:\s*/i;
20
+
21
+ export function cleanFilterInput(
22
+ clauses: FilterClause[],
23
+ rawQuery: string
24
+ ): string {
25
+ const cleanRawQuery = rawQuery.replace(TRIGGER_RESIDUE, "").trim();
26
+ return [
27
+ ...clauses.map((c) => `#${c.key}:${c.value}`),
28
+ ...(cleanRawQuery ? [cleanRawQuery] : []),
29
+ ].join(" ");
30
+ }
@@ -34,6 +34,7 @@ import {
34
34
  cleanupConversation,
35
35
  } from "./permission-bridge";
36
36
  import { getWorkspaceContext } from "@/lib/environment/workspace-context";
37
+ import type { ResolvedExecutionTarget } from "@/lib/agents/runtime/execution-target";
37
38
 
38
39
  // ── Helpers ──────────────────────────────────────────────────────────────
39
40
 
@@ -57,7 +58,8 @@ function asString(v: unknown): string | null {
57
58
  export async function* sendCodexMessage(
58
59
  conversationId: string,
59
60
  userContent: string,
60
- signal?: AbortSignal
61
+ signal?: AbortSignal,
62
+ targetOverride?: ResolvedExecutionTarget
61
63
  ): AsyncGenerator<ChatStreamEvent> {
62
64
  const conversation = await getConversation(conversationId);
63
65
  if (!conversation) {
@@ -65,7 +67,7 @@ export async function* sendCodexMessage(
65
67
  return;
66
68
  }
67
69
 
68
- const runtimeId = conversation.runtimeId;
70
+ const runtimeId = targetOverride?.effectiveRuntimeId ?? conversation.runtimeId;
69
71
  const providerId = getProviderForRuntime(runtimeId);
70
72
 
71
73
  // Enforce budget
@@ -187,8 +189,10 @@ export async function* sendCodexMessage(
187
189
  const availableIds = new Set(
188
190
  (modelResponse.models ?? []).map((m: { id: string }) => m.id)
189
191
  );
190
- if (conversation.modelId && availableIds.has(conversation.modelId)) {
191
- validatedModel = conversation.modelId;
192
+ const requestedModelId =
193
+ targetOverride?.effectiveModelId ?? conversation.modelId;
194
+ if (requestedModelId && availableIds.has(requestedModelId)) {
195
+ validatedModel = requestedModelId;
192
196
  }
193
197
  // If not available, validatedModel stays undefined → Codex uses its default
194
198
  } catch {
@@ -376,7 +380,18 @@ export async function* sendCodexMessage(
376
380
 
377
381
  // Save usage metadata
378
382
  const metadata = JSON.stringify({
379
- modelId: usage.modelId ?? conversation.modelId,
383
+ modelId:
384
+ usage.modelId ??
385
+ targetOverride?.effectiveModelId ??
386
+ conversation.modelId,
387
+ runtimeId,
388
+ requestedRuntimeId:
389
+ targetOverride?.requestedRuntimeId ?? conversation.runtimeId,
390
+ requestedModelId:
391
+ targetOverride?.requestedModelId ?? conversation.modelId,
392
+ ...(targetOverride?.fallbackReason
393
+ ? { fallbackReason: targetOverride.fallbackReason }
394
+ : {}),
380
395
  inputTokens: usage.inputTokens,
381
396
  outputTokens: usage.outputTokens,
382
397
  ...(quickAccess.length > 0 ? { quickAccess } : {}),
@@ -393,7 +408,11 @@ export async function* sendCodexMessage(
393
408
  activityType: "chat_turn",
394
409
  runtimeId,
395
410
  providerId,
396
- modelId: usage.modelId ?? conversation.modelId ?? null,
411
+ modelId:
412
+ usage.modelId ??
413
+ targetOverride?.effectiveModelId ??
414
+ conversation.modelId ??
415
+ null,
397
416
  inputTokens: usage.inputTokens ?? null,
398
417
  outputTokens: usage.outputTokens ?? null,
399
418
  totalTokens: usage.totalTokens ?? null,
@@ -413,7 +432,11 @@ export async function* sendCodexMessage(
413
432
  activityType: "chat_turn",
414
433
  runtimeId,
415
434
  providerId,
416
- modelId: usage.modelId ?? conversation.modelId ?? null,
435
+ modelId:
436
+ usage.modelId ??
437
+ targetOverride?.effectiveModelId ??
438
+ conversation.modelId ??
439
+ null,
417
440
  inputTokens: usage.inputTokens ?? null,
418
441
  outputTokens: usage.outputTokens ?? null,
419
442
  totalTokens: usage.totalTokens ?? null,
@@ -0,0 +1,61 @@
1
+ import type { ToolCatalogEntry, ToolGroup } from "./tool-catalog";
2
+
3
+ export const COMMAND_TAB_IDS = ["actions", "skills", "tools", "entities"] as const;
4
+ export type CommandTabId = (typeof COMMAND_TAB_IDS)[number];
5
+
6
+ export interface CommandTab {
7
+ id: CommandTabId;
8
+ label: string;
9
+ shortcut: string; // ⌘1..⌘4
10
+ }
11
+
12
+ export const COMMAND_TABS: CommandTab[] = [
13
+ { id: "actions", label: "Actions", shortcut: "⌘1" },
14
+ { id: "skills", label: "Skills", shortcut: "⌘2" },
15
+ { id: "tools", label: "Tools", shortcut: "⌘3" },
16
+ { id: "entities", label: "Entities", shortcut: "⌘4" },
17
+ ];
18
+
19
+ export const DEFAULT_COMMAND_TAB: CommandTabId = "actions";
20
+
21
+ export const GROUP_TO_TAB = {
22
+ // Stagent actions / session primitives
23
+ Session: "actions",
24
+ Tasks: "actions",
25
+ Projects: "actions",
26
+ Workflows: "actions",
27
+ Schedules: "actions",
28
+ Documents: "actions",
29
+ Tables: "actions",
30
+ Notifications: "actions",
31
+ Profiles: "actions",
32
+ Usage: "actions",
33
+ Settings: "actions",
34
+ Chat: "actions",
35
+ // Skills
36
+ Skills: "skills",
37
+ // Tools (filesystem / system / utility)
38
+ Browser: "tools",
39
+ Utility: "tools",
40
+ } satisfies Record<ToolGroup, CommandTabId>;
41
+
42
+ export function isCommandTabId(value: string): value is CommandTabId {
43
+ return (COMMAND_TAB_IDS as readonly string[]).includes(value);
44
+ }
45
+
46
+ export interface PartitionedCatalog {
47
+ actions: ToolCatalogEntry[];
48
+ skills: ToolCatalogEntry[];
49
+ tools: ToolCatalogEntry[];
50
+ entities: ToolCatalogEntry[];
51
+ }
52
+
53
+ export function partitionCatalogByTab(
54
+ catalog: ToolCatalogEntry[]
55
+ ): PartitionedCatalog {
56
+ const out: PartitionedCatalog = { actions: [], skills: [], tools: [], entities: [] };
57
+ for (const entry of catalog) {
58
+ out[GROUP_TO_TAB[entry.group]].push(entry);
59
+ }
60
+ return out;
61
+ }