stagent 0.10.0 → 0.11.1
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/README.md +44 -31
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -3
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/book/content-blocks.tsx +1 -1
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/playbook/playbook-detail-view.tsx +1 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +97 -22
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +56 -2
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { mockState } = vi.hoisted(() => ({
|
|
4
|
+
mockState: {
|
|
5
|
+
activeSkillId: null as string | null,
|
|
6
|
+
activeSkillIds: [] as string[],
|
|
7
|
+
skills: {} as Record<string, { name: string; content: string }>,
|
|
8
|
+
runtimeId: "ollama" as string, // default: Ollama (stagentInjectsSkills: true)
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// ── Mocks ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
vi.mock("@/lib/db", () => ({
|
|
15
|
+
db: {
|
|
16
|
+
select: () => ({
|
|
17
|
+
from() {
|
|
18
|
+
return this;
|
|
19
|
+
},
|
|
20
|
+
where() {
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
get() {
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
activeSkillId: mockState.activeSkillId,
|
|
26
|
+
activeSkillIds: mockState.activeSkillIds,
|
|
27
|
+
runtimeId: mockState.runtimeId,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("@/lib/db/schema", () => ({
|
|
35
|
+
conversations: {
|
|
36
|
+
id: "id",
|
|
37
|
+
activeSkillId: "activeSkillId",
|
|
38
|
+
activeSkillIds: "activeSkillIds",
|
|
39
|
+
runtimeId: "runtimeId",
|
|
40
|
+
},
|
|
41
|
+
projects: { id: "id" },
|
|
42
|
+
tasks: { id: "id" },
|
|
43
|
+
workflows: { id: "id" },
|
|
44
|
+
documents: { id: "id" },
|
|
45
|
+
schedules: { id: "id" },
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("drizzle-orm", () => ({
|
|
49
|
+
eq: () => ({}),
|
|
50
|
+
desc: () => ({}),
|
|
51
|
+
and: () => ({}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock("@/lib/data/chat", () => ({
|
|
55
|
+
getMessages: async () => [],
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock("@/lib/agents/profiles/registry", () => ({
|
|
59
|
+
getProfile: () => null,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock("@/lib/environment/list-skills", () => ({
|
|
63
|
+
getSkill: (id: string) => {
|
|
64
|
+
const skill = mockState.skills[id];
|
|
65
|
+
if (!skill) return null;
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
name: skill.name,
|
|
69
|
+
tool: "claude-code",
|
|
70
|
+
scope: "project",
|
|
71
|
+
preview: "",
|
|
72
|
+
sizeBytes: Buffer.byteLength(skill.content),
|
|
73
|
+
absPath: "/mock/path/SKILL.md",
|
|
74
|
+
content: skill.content,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
import { buildChatContext } from "../context-builder";
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
mockState.activeSkillId = null;
|
|
83
|
+
mockState.activeSkillIds = [];
|
|
84
|
+
mockState.skills = {};
|
|
85
|
+
mockState.runtimeId = "ollama";
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("active skill Tier 0 injection", () => {
|
|
89
|
+
it("does NOT inject anything when activeSkillId is null (common case)", async () => {
|
|
90
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
91
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill:");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("injects the skill's SKILL.md content under an Active Skill header when bound", async () => {
|
|
95
|
+
mockState.activeSkillId = ".claude/skills/capture";
|
|
96
|
+
mockState.skills[".claude/skills/capture"] = {
|
|
97
|
+
name: "capture",
|
|
98
|
+
content: "# capture\n\nCapture web content as markdown.",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
102
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: capture");
|
|
103
|
+
expect(ctx.systemPrompt).toContain("Capture web content as markdown");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("silently emits no section when the bound skill id is not found (skill deleted)", async () => {
|
|
107
|
+
mockState.activeSkillId = "dangling-id";
|
|
108
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
109
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill:");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("caps very large SKILL.md content to the token budget", async () => {
|
|
113
|
+
mockState.activeSkillId = "huge-skill";
|
|
114
|
+
mockState.skills["huge-skill"] = {
|
|
115
|
+
name: "capture",
|
|
116
|
+
content: "A".repeat(100_000), // ~25K tokens at 4 chars/token
|
|
117
|
+
};
|
|
118
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
119
|
+
// Budget is 4_000 tokens = ~16_000 chars; expect truncation marker
|
|
120
|
+
expect(ctx.systemPrompt).toContain("...(truncated)");
|
|
121
|
+
// Full 100K chars must NOT be inline
|
|
122
|
+
expect(ctx.systemPrompt.length).toBeLessThan(50_000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("runtime capability flag (stagentInjectsSkills)", () => {
|
|
126
|
+
it("does NOT inject on claude-code (native skill support — would duplicate)", async () => {
|
|
127
|
+
mockState.runtimeId = "claude-code";
|
|
128
|
+
mockState.activeSkillId = ".claude/skills/capture";
|
|
129
|
+
mockState.skills[".claude/skills/capture"] = {
|
|
130
|
+
name: "capture",
|
|
131
|
+
content: "# capture\n\nBody.",
|
|
132
|
+
};
|
|
133
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
134
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill:");
|
|
135
|
+
expect(ctx.systemPrompt).not.toContain("Body.");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does inject composed skills on claude-code when activeSkillIds are set", async () => {
|
|
139
|
+
mockState.runtimeId = "claude-code";
|
|
140
|
+
mockState.activeSkillId = ".claude/skills/researcher";
|
|
141
|
+
mockState.activeSkillIds = [".claude/skills/technical-writer"];
|
|
142
|
+
mockState.skills[".claude/skills/researcher"] = {
|
|
143
|
+
name: "researcher",
|
|
144
|
+
content: "Always gather sources first.",
|
|
145
|
+
};
|
|
146
|
+
mockState.skills[".claude/skills/technical-writer"] = {
|
|
147
|
+
name: "technical-writer",
|
|
148
|
+
content: "Prefer crisp, publishable prose.",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
152
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: researcher");
|
|
153
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: technical-writer");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("does NOT inject on openai-codex-app-server (native skill support — would duplicate)", async () => {
|
|
157
|
+
mockState.runtimeId = "openai-codex-app-server";
|
|
158
|
+
mockState.activeSkillId = ".agents/skills/capture";
|
|
159
|
+
mockState.skills[".agents/skills/capture"] = {
|
|
160
|
+
name: "capture",
|
|
161
|
+
content: "# capture\n\nBody.",
|
|
162
|
+
};
|
|
163
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
164
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill:");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("DOES inject on ollama (no native support — Stagent must inject)", async () => {
|
|
168
|
+
mockState.runtimeId = "ollama";
|
|
169
|
+
mockState.activeSkillId = ".claude/skills/capture";
|
|
170
|
+
mockState.skills[".claude/skills/capture"] = {
|
|
171
|
+
name: "capture",
|
|
172
|
+
content: "# capture\n\nOllama needs this.",
|
|
173
|
+
};
|
|
174
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
175
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: capture");
|
|
176
|
+
expect(ctx.systemPrompt).toContain("Ollama needs this.");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("falls through and injects when runtimeId is unknown (safer default than dropping)", async () => {
|
|
180
|
+
mockState.runtimeId = "some-future-runtime-not-in-catalog";
|
|
181
|
+
mockState.activeSkillId = ".claude/skills/capture";
|
|
182
|
+
mockState.skills[".claude/skills/capture"] = {
|
|
183
|
+
name: "capture",
|
|
184
|
+
content: "# capture\n\nBody.",
|
|
185
|
+
};
|
|
186
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
187
|
+
// Unknown runtime → catalog throws → catch → fall through to injection.
|
|
188
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: capture");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("composition budget trimming", () => {
|
|
193
|
+
it("keeps multiple composed skills when the combined payload fits", async () => {
|
|
194
|
+
mockState.runtimeId = "claude-code";
|
|
195
|
+
mockState.activeSkillId = ".claude/skills/researcher";
|
|
196
|
+
mockState.activeSkillIds = [".claude/skills/technical-writer"];
|
|
197
|
+
mockState.skills[".claude/skills/researcher"] = {
|
|
198
|
+
name: "researcher",
|
|
199
|
+
content: "Collect sources.",
|
|
200
|
+
};
|
|
201
|
+
mockState.skills[".claude/skills/technical-writer"] = {
|
|
202
|
+
name: "technical-writer",
|
|
203
|
+
content: "Write clearly.",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
207
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: researcher");
|
|
208
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: technical-writer");
|
|
209
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill Note");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("omits oldest composed skills first when the combined payload exceeds budget", async () => {
|
|
213
|
+
mockState.runtimeId = "claude-code";
|
|
214
|
+
mockState.activeSkillId = ".claude/skills/oldest";
|
|
215
|
+
mockState.activeSkillIds = [
|
|
216
|
+
".claude/skills/middle",
|
|
217
|
+
".claude/skills/newest",
|
|
218
|
+
];
|
|
219
|
+
mockState.skills[".claude/skills/oldest"] = {
|
|
220
|
+
name: "oldest",
|
|
221
|
+
content: "O".repeat(8_000),
|
|
222
|
+
};
|
|
223
|
+
mockState.skills[".claude/skills/middle"] = {
|
|
224
|
+
name: "middle",
|
|
225
|
+
content: "M".repeat(8_000),
|
|
226
|
+
};
|
|
227
|
+
mockState.skills[".claude/skills/newest"] = {
|
|
228
|
+
name: "newest",
|
|
229
|
+
content: "N".repeat(2_000),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
233
|
+
expect(ctx.systemPrompt).toContain("## Active Skill Note");
|
|
234
|
+
expect(ctx.systemPrompt).toContain("Omitted 1 older active skill to fit the prompt budget: oldest.");
|
|
235
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill: oldest");
|
|
236
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: middle");
|
|
237
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: newest");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("truncates the newest remaining skill when even one section exceeds budget", async () => {
|
|
241
|
+
mockState.runtimeId = "claude-code";
|
|
242
|
+
mockState.activeSkillId = ".claude/skills/oldest";
|
|
243
|
+
mockState.activeSkillIds = [".claude/skills/newest"];
|
|
244
|
+
mockState.skills[".claude/skills/oldest"] = {
|
|
245
|
+
name: "oldest",
|
|
246
|
+
content: "O".repeat(8_000),
|
|
247
|
+
};
|
|
248
|
+
mockState.skills[".claude/skills/newest"] = {
|
|
249
|
+
name: "newest",
|
|
250
|
+
content: "N".repeat(40_000),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const ctx = await buildChatContext({ conversationId: "conv-1" });
|
|
254
|
+
expect(ctx.systemPrompt).toContain("## Active Skill Note");
|
|
255
|
+
expect(ctx.systemPrompt).toContain("oldest");
|
|
256
|
+
expect(ctx.systemPrompt).toContain("## Active Skill: newest");
|
|
257
|
+
expect(ctx.systemPrompt).toContain("...(truncated)");
|
|
258
|
+
expect(ctx.systemPrompt).not.toContain("## Active Skill: oldest");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { cleanFilterInput } from "../clean-filter-input";
|
|
3
|
+
import { parseFilterInput } from "@/lib/filters/parse";
|
|
4
|
+
|
|
5
|
+
// Smoke through the full chain: parse the raw popover input, then clean
|
|
6
|
+
// the result. Mirrors what `chat-command-popover.tsx` does at the
|
|
7
|
+
// SaveViewFooter call site so the assertions catch any regression in
|
|
8
|
+
// either the parser OR the cleaner.
|
|
9
|
+
function persisted(input: string): string {
|
|
10
|
+
const parsed = parseFilterInput(input);
|
|
11
|
+
return cleanFilterInput(parsed.clauses, parsed.rawQuery);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("cleanFilterInput", () => {
|
|
15
|
+
it("strips bare mention-trigger prefix from clauses-only input", () => {
|
|
16
|
+
expect(persisted("@task: #priority:high")).toBe("#priority:high");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("strips trigger prefix and preserves free text", () => {
|
|
20
|
+
// Order: clauses first, then rawQuery — matches the cleaner's
|
|
21
|
+
// documented behavior.
|
|
22
|
+
expect(persisted("@task: foo #priority:high")).toBe(
|
|
23
|
+
"#priority:high foo"
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves clean inputs untouched (no trigger residue)", () => {
|
|
28
|
+
expect(persisted("#status:blocked")).toBe("#status:blocked");
|
|
29
|
+
expect(persisted("#status:blocked #priority:high")).toBe(
|
|
30
|
+
"#status:blocked #priority:high"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("preserves multi-word free text", () => {
|
|
35
|
+
expect(persisted('@project: my big query #status:active')).toBe(
|
|
36
|
+
"#status:active my big query"
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("never emits ':' not preceded by '#' (regression assertion)", () => {
|
|
41
|
+
const tricky = [
|
|
42
|
+
"@task: #status:blocked",
|
|
43
|
+
"@project: foo #status:active",
|
|
44
|
+
"@workflow: #status:running #priority:high",
|
|
45
|
+
"#status:blocked",
|
|
46
|
+
];
|
|
47
|
+
for (const input of tricky) {
|
|
48
|
+
const result = persisted(input);
|
|
49
|
+
// After every ':' there must be a non-':' char, and every ':' must
|
|
50
|
+
// be immediately preceded by either an alpha char (the key) or
|
|
51
|
+
// we expect the form #key:value. Simpler: assert no occurrence of
|
|
52
|
+
// ': ' (trigger residue always has a trailing space) and no
|
|
53
|
+
// alpha-only-prefix-colon at the start.
|
|
54
|
+
expect(result).not.toMatch(/^[a-z]+:\s/i);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles empty clauses + empty rawQuery", () => {
|
|
59
|
+
expect(cleanFilterInput([], "")).toBe("");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles clauses + only-trigger rawQuery", () => {
|
|
63
|
+
// `@task:` with no other input → rawQuery is `@task:` → cleaned to ""
|
|
64
|
+
expect(cleanFilterInput([{ key: "status", value: "blocked" }], "@task:")).toBe(
|
|
65
|
+
"#status:blocked"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
COMMAND_TABS,
|
|
4
|
+
GROUP_TO_TAB,
|
|
5
|
+
partitionCatalogByTab,
|
|
6
|
+
isCommandTabId,
|
|
7
|
+
type CommandTabId,
|
|
8
|
+
} from "../command-tabs";
|
|
9
|
+
import type { ToolCatalogEntry, ToolGroup } from "../tool-catalog";
|
|
10
|
+
|
|
11
|
+
const entry = (name: string, group: ToolGroup): ToolCatalogEntry => ({
|
|
12
|
+
name,
|
|
13
|
+
description: name,
|
|
14
|
+
group,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("command-tabs", () => {
|
|
18
|
+
it("exposes four tabs in canonical order", () => {
|
|
19
|
+
expect(COMMAND_TABS.map((t) => t.id)).toEqual([
|
|
20
|
+
"actions",
|
|
21
|
+
"skills",
|
|
22
|
+
"tools",
|
|
23
|
+
"entities",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("maps every ToolGroup to exactly one tab", () => {
|
|
28
|
+
const groups: ToolGroup[] = [
|
|
29
|
+
"Session", "Tasks", "Projects", "Workflows", "Schedules", "Documents", "Tables",
|
|
30
|
+
"Notifications", "Profiles", "Skills", "Usage", "Settings", "Chat",
|
|
31
|
+
"Browser", "Utility",
|
|
32
|
+
];
|
|
33
|
+
for (const g of groups) {
|
|
34
|
+
expect(GROUP_TO_TAB[g]).toBeDefined();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("routes Session group to the Actions tab", () => {
|
|
39
|
+
expect(GROUP_TO_TAB.Session).toBe("actions");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("routes Skills group to the Skills tab", () => {
|
|
43
|
+
expect(GROUP_TO_TAB.Skills).toBe("skills");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("routes Browser + Utility to the Tools tab", () => {
|
|
47
|
+
expect(GROUP_TO_TAB.Browser).toBe("tools");
|
|
48
|
+
expect(GROUP_TO_TAB.Utility).toBe("tools");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("partitions catalog entries by tab", () => {
|
|
52
|
+
const catalog: ToolCatalogEntry[] = [
|
|
53
|
+
entry("list_tasks", "Tasks"),
|
|
54
|
+
entry("researcher", "Skills"),
|
|
55
|
+
entry("take_screenshot", "Browser"),
|
|
56
|
+
];
|
|
57
|
+
const part = partitionCatalogByTab(catalog);
|
|
58
|
+
expect(part.actions.map((e) => e.name)).toEqual(["list_tasks"]);
|
|
59
|
+
expect(part.skills.map((e) => e.name)).toEqual(["researcher"]);
|
|
60
|
+
expect(part.tools.map((e) => e.name)).toEqual(["take_screenshot"]);
|
|
61
|
+
expect(part.entities).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("isCommandTabId rejects unknown values", () => {
|
|
65
|
+
expect(isCommandTabId("actions")).toBe(true);
|
|
66
|
+
expect(isCommandTabId("random")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -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
|
+
});
|