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,474 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/** Mutable test state shared across module-level mocks. */
|
|
4
|
+
const { mockState } = vi.hoisted(() => ({
|
|
5
|
+
mockState: {
|
|
6
|
+
skills: [] as Array<{
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
preview: string;
|
|
12
|
+
sizeBytes: number;
|
|
13
|
+
absPath: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}>,
|
|
16
|
+
conversations: new Map<string, {
|
|
17
|
+
id: string;
|
|
18
|
+
activeSkillId: string | null;
|
|
19
|
+
activeSkillIds: string[];
|
|
20
|
+
runtimeId: string;
|
|
21
|
+
}>(),
|
|
22
|
+
lastUpdateId: null as string | null,
|
|
23
|
+
lastUpdateValues: null as Record<string, unknown> | null,
|
|
24
|
+
lastSelectedId: null as string | null,
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@/lib/environment/list-skills", () => ({
|
|
29
|
+
listSkills: () => mockState.skills.map(({ content: _content, ...rest }) => rest),
|
|
30
|
+
getSkill: (id: string) => {
|
|
31
|
+
const hit = mockState.skills.find((s) => s.id === id);
|
|
32
|
+
return hit ?? null;
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("@/lib/db", () => {
|
|
37
|
+
const selectBuilder = {
|
|
38
|
+
from() {
|
|
39
|
+
return this;
|
|
40
|
+
},
|
|
41
|
+
where() {
|
|
42
|
+
return this;
|
|
43
|
+
},
|
|
44
|
+
get() {
|
|
45
|
+
const id = mockState.lastSelectedId;
|
|
46
|
+
return Promise.resolve(
|
|
47
|
+
id ? mockState.conversations.get(id) : undefined
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
// Hoisted fields for the select chain — updated from the eq() stub
|
|
52
|
+
mockState.lastSelectedId = null;
|
|
53
|
+
return {
|
|
54
|
+
db: {
|
|
55
|
+
select: () => selectBuilder,
|
|
56
|
+
update: () => ({
|
|
57
|
+
set: (values: Record<string, unknown>) => {
|
|
58
|
+
mockState.lastUpdateValues = values;
|
|
59
|
+
return {
|
|
60
|
+
where: () => {
|
|
61
|
+
// Apply the update to the tracked conversation so follow-up
|
|
62
|
+
// reads see the new state.
|
|
63
|
+
if (mockState.lastUpdateId) {
|
|
64
|
+
const row = mockState.conversations.get(mockState.lastUpdateId);
|
|
65
|
+
if (row) {
|
|
66
|
+
mockState.conversations.set(mockState.lastUpdateId, {
|
|
67
|
+
...row,
|
|
68
|
+
activeSkillId:
|
|
69
|
+
"activeSkillId" in values
|
|
70
|
+
? (values.activeSkillId as string | null)
|
|
71
|
+
: row.activeSkillId,
|
|
72
|
+
activeSkillIds:
|
|
73
|
+
"activeSkillIds" in values
|
|
74
|
+
? (values.activeSkillIds as string[])
|
|
75
|
+
: row.activeSkillIds,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
vi.mock("@/lib/db/schema", () => ({
|
|
89
|
+
conversations: {
|
|
90
|
+
id: "id",
|
|
91
|
+
activeSkillId: "activeSkillId",
|
|
92
|
+
activeSkillIds: "activeSkillIds",
|
|
93
|
+
runtimeId: "runtimeId",
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
vi.mock("drizzle-orm", () => ({
|
|
98
|
+
eq: (_col: unknown, val: unknown) => {
|
|
99
|
+
// Side channel so the select/update chains know which row to touch.
|
|
100
|
+
const id = typeof val === "string" ? val : null;
|
|
101
|
+
mockState.lastSelectedId = id;
|
|
102
|
+
mockState.lastUpdateId = id;
|
|
103
|
+
return {};
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
vi.mock("@/lib/agents/runtime/catalog", () => ({
|
|
108
|
+
getRuntimeFeatures: (runtimeId: string) => {
|
|
109
|
+
if (runtimeId === "ollama") {
|
|
110
|
+
return { supportsSkillComposition: false, maxActiveSkills: 1 };
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
runtimeId === "claude-code" ||
|
|
114
|
+
runtimeId === "openai-codex-app-server" ||
|
|
115
|
+
runtimeId === "anthropic-direct" ||
|
|
116
|
+
runtimeId === "openai-direct"
|
|
117
|
+
) {
|
|
118
|
+
return { supportsSkillComposition: true, maxActiveSkills: 3 };
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Unknown runtime: ${runtimeId}`);
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
import { skillTools } from "../skill-tools";
|
|
125
|
+
|
|
126
|
+
function getTool(name: string) {
|
|
127
|
+
const tools = skillTools({ projectId: "proj-1" } as never);
|
|
128
|
+
const tool = tools.find((t) => t.name === name);
|
|
129
|
+
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
130
|
+
return tool;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function call(toolName: string, args: Record<string, unknown> = {}) {
|
|
134
|
+
const tool = getTool(toolName);
|
|
135
|
+
return tool.handler(args);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parse(result: {
|
|
139
|
+
content: Array<{ type: string; text: string }>;
|
|
140
|
+
isError?: boolean;
|
|
141
|
+
}) {
|
|
142
|
+
return {
|
|
143
|
+
data: JSON.parse(result.content[0].text) as Record<string, unknown>,
|
|
144
|
+
isError: result.isError ?? false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
mockState.skills = [];
|
|
150
|
+
mockState.conversations.clear();
|
|
151
|
+
mockState.lastUpdateId = null;
|
|
152
|
+
mockState.lastUpdateValues = null;
|
|
153
|
+
mockState.lastSelectedId = null;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("skill-tools", () => {
|
|
157
|
+
describe("list_skills", () => {
|
|
158
|
+
it("returns all discoverable skills with id/name/tool/scope/preview/size, no absPath", async () => {
|
|
159
|
+
mockState.skills = [
|
|
160
|
+
{
|
|
161
|
+
id: ".claude/skills/capture",
|
|
162
|
+
name: "capture",
|
|
163
|
+
tool: "claude-code",
|
|
164
|
+
scope: "project",
|
|
165
|
+
preview: "Capture web content as markdown.",
|
|
166
|
+
sizeBytes: 2048,
|
|
167
|
+
absPath: "/abs/.claude/skills/capture/SKILL.md",
|
|
168
|
+
content: "# capture\n\nBody.",
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
const { data, isError } = parse(await call("list_skills"));
|
|
172
|
+
expect(isError).toBe(false);
|
|
173
|
+
expect(data.count).toBe(1);
|
|
174
|
+
const skills = data.skills as Array<Record<string, unknown>>;
|
|
175
|
+
expect(skills[0]).toMatchObject({
|
|
176
|
+
id: ".claude/skills/capture",
|
|
177
|
+
name: "capture",
|
|
178
|
+
tool: "claude-code",
|
|
179
|
+
scope: "project",
|
|
180
|
+
sizeBytes: 2048,
|
|
181
|
+
});
|
|
182
|
+
expect("absPath" in skills[0]).toBe(false); // never surface to LLM
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns count=0 when no skills are discovered", async () => {
|
|
186
|
+
const { data } = parse(await call("list_skills"));
|
|
187
|
+
expect(data.count).toBe(0);
|
|
188
|
+
expect(data.skills).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("get_skill", () => {
|
|
193
|
+
it("returns full SKILL.md content for a known id", async () => {
|
|
194
|
+
mockState.skills = [
|
|
195
|
+
{
|
|
196
|
+
id: ".agents/skills/codegen",
|
|
197
|
+
name: "codegen",
|
|
198
|
+
tool: "codex",
|
|
199
|
+
scope: "user",
|
|
200
|
+
preview: "Codegen helper.",
|
|
201
|
+
sizeBytes: 1000,
|
|
202
|
+
absPath: "/abs/.agents/skills/codegen/SKILL.md",
|
|
203
|
+
content: "# codegen\n\nFull body here.",
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
const { data, isError } = parse(
|
|
207
|
+
await call("get_skill", { id: ".agents/skills/codegen" })
|
|
208
|
+
);
|
|
209
|
+
expect(isError).toBe(false);
|
|
210
|
+
expect(data.content).toContain("Full body here");
|
|
211
|
+
expect(data.name).toBe("codegen");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns an error when the id is not found", async () => {
|
|
215
|
+
const { data, isError } = parse(
|
|
216
|
+
await call("get_skill", { id: "nope" })
|
|
217
|
+
);
|
|
218
|
+
expect(isError).toBe(true);
|
|
219
|
+
expect(data.error).toContain("Skill not found");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("activate_skill", () => {
|
|
224
|
+
it("binds a skill to a conversation and returns confirmation", async () => {
|
|
225
|
+
mockState.skills = [
|
|
226
|
+
{
|
|
227
|
+
id: ".claude/skills/capture",
|
|
228
|
+
name: "capture",
|
|
229
|
+
tool: "claude-code",
|
|
230
|
+
scope: "project",
|
|
231
|
+
preview: "…",
|
|
232
|
+
sizeBytes: 500,
|
|
233
|
+
absPath: "/abs/path",
|
|
234
|
+
content: "# capture",
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
mockState.conversations.set("conv-1", { id: "conv-1", activeSkillId: null, activeSkillIds: [], runtimeId: "claude-code" });
|
|
238
|
+
|
|
239
|
+
const { data, isError } = parse(
|
|
240
|
+
await call("activate_skill", {
|
|
241
|
+
conversationId: "conv-1",
|
|
242
|
+
skillId: ".claude/skills/capture",
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
expect(isError).toBe(false);
|
|
246
|
+
expect(data.activatedSkillId).toBe(".claude/skills/capture");
|
|
247
|
+
expect(data.skillName).toBe("capture");
|
|
248
|
+
expect(mockState.lastUpdateValues).toMatchObject({
|
|
249
|
+
activeSkillId: ".claude/skills/capture",
|
|
250
|
+
});
|
|
251
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillId).toBe(
|
|
252
|
+
".claude/skills/capture"
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("errors on unknown conversation", async () => {
|
|
257
|
+
mockState.skills = [
|
|
258
|
+
{
|
|
259
|
+
id: ".claude/skills/capture",
|
|
260
|
+
name: "capture",
|
|
261
|
+
tool: "claude-code",
|
|
262
|
+
scope: "project",
|
|
263
|
+
preview: "…",
|
|
264
|
+
sizeBytes: 500,
|
|
265
|
+
absPath: "/abs/path",
|
|
266
|
+
content: "# capture",
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
const { data, isError } = parse(
|
|
270
|
+
await call("activate_skill", {
|
|
271
|
+
conversationId: "ghost",
|
|
272
|
+
skillId: ".claude/skills/capture",
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
expect(isError).toBe(true);
|
|
276
|
+
expect(data.error).toContain("Conversation not found");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("errors on unknown skill id (validates before writing)", async () => {
|
|
280
|
+
mockState.conversations.set("conv-1", { id: "conv-1", activeSkillId: null, activeSkillIds: [], runtimeId: "claude-code" });
|
|
281
|
+
const { data, isError } = parse(
|
|
282
|
+
await call("activate_skill", {
|
|
283
|
+
conversationId: "conv-1",
|
|
284
|
+
skillId: "not-a-real-skill",
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
expect(isError).toBe(true);
|
|
288
|
+
expect(data.error).toContain("Skill not found");
|
|
289
|
+
expect(mockState.lastUpdateValues).toBeNull(); // no write happened
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("replaces a previously active skill (single-active rule)", async () => {
|
|
293
|
+
mockState.skills = [
|
|
294
|
+
{
|
|
295
|
+
id: "first",
|
|
296
|
+
name: "first",
|
|
297
|
+
tool: "claude-code",
|
|
298
|
+
scope: "project",
|
|
299
|
+
preview: "",
|
|
300
|
+
sizeBytes: 100,
|
|
301
|
+
absPath: "/a",
|
|
302
|
+
content: "# first",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: "second",
|
|
306
|
+
name: "second",
|
|
307
|
+
tool: "claude-code",
|
|
308
|
+
scope: "project",
|
|
309
|
+
preview: "",
|
|
310
|
+
sizeBytes: 100,
|
|
311
|
+
absPath: "/b",
|
|
312
|
+
content: "# second",
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
mockState.conversations.set("conv-1", {
|
|
316
|
+
id: "conv-1",
|
|
317
|
+
activeSkillId: "first",
|
|
318
|
+
activeSkillIds: [],
|
|
319
|
+
runtimeId: "claude-code",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await call("activate_skill", {
|
|
323
|
+
conversationId: "conv-1",
|
|
324
|
+
skillId: "second",
|
|
325
|
+
});
|
|
326
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillId).toBe("second");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("mode:add appends a second skill on a composition-capable runtime", async () => {
|
|
330
|
+
mockState.skills = [
|
|
331
|
+
{ id: "first-skill", name: "first", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/a", content: "# first\nUse foo." },
|
|
332
|
+
{ id: "second-skill", name: "second", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/b", content: "# second\nUse bar." },
|
|
333
|
+
];
|
|
334
|
+
mockState.conversations.set("conv-1", {
|
|
335
|
+
id: "conv-1",
|
|
336
|
+
activeSkillId: "first-skill",
|
|
337
|
+
activeSkillIds: [],
|
|
338
|
+
runtimeId: "claude-code",
|
|
339
|
+
});
|
|
340
|
+
const { data, isError } = parse(
|
|
341
|
+
await call("activate_skill", {
|
|
342
|
+
conversationId: "conv-1",
|
|
343
|
+
skillId: "second-skill",
|
|
344
|
+
mode: "add",
|
|
345
|
+
force: true,
|
|
346
|
+
})
|
|
347
|
+
);
|
|
348
|
+
expect(isError).toBe(false);
|
|
349
|
+
expect(data.activeSkillIds).toEqual(["first-skill", "second-skill"]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("mode:add fails on Ollama with capability hint", async () => {
|
|
353
|
+
mockState.skills = [
|
|
354
|
+
{ id: "any", name: "any", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/a", content: "# any" },
|
|
355
|
+
];
|
|
356
|
+
mockState.conversations.set("conv-1", {
|
|
357
|
+
id: "conv-1",
|
|
358
|
+
activeSkillId: null,
|
|
359
|
+
activeSkillIds: [],
|
|
360
|
+
runtimeId: "ollama",
|
|
361
|
+
});
|
|
362
|
+
const { data, isError } = parse(
|
|
363
|
+
await call("activate_skill", {
|
|
364
|
+
conversationId: "conv-1",
|
|
365
|
+
skillId: "any",
|
|
366
|
+
mode: "add",
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
expect(isError).toBe(true);
|
|
370
|
+
expect(data.error).toMatch(/composition/i);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("mode:add enforces maxActiveSkills (Claude allows 3)", async () => {
|
|
374
|
+
mockState.skills = [
|
|
375
|
+
{ id: "a", name: "a", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/a", content: "" },
|
|
376
|
+
{ id: "b", name: "b", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/b", content: "" },
|
|
377
|
+
{ id: "c", name: "c", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/c", content: "" },
|
|
378
|
+
{ id: "d", name: "d", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/d", content: "" },
|
|
379
|
+
];
|
|
380
|
+
mockState.conversations.set("conv-1", {
|
|
381
|
+
id: "conv-1",
|
|
382
|
+
activeSkillId: "a",
|
|
383
|
+
activeSkillIds: ["b", "c"],
|
|
384
|
+
runtimeId: "claude-code",
|
|
385
|
+
});
|
|
386
|
+
const { data, isError } = parse(
|
|
387
|
+
await call("activate_skill", {
|
|
388
|
+
conversationId: "conv-1",
|
|
389
|
+
skillId: "d",
|
|
390
|
+
mode: "add",
|
|
391
|
+
force: true,
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
expect(isError).toBe(true);
|
|
395
|
+
expect(data.error).toMatch(/max active skills/i);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("mode:add returns conflicts without writing when conflicts detected (no force)", async () => {
|
|
399
|
+
mockState.skills = [
|
|
400
|
+
{ id: "tdd", name: "tdd", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/a", content: "Always write tests first." },
|
|
401
|
+
{ id: "spike", name: "spike", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/b", content: "Never write tests during a spike." },
|
|
402
|
+
];
|
|
403
|
+
mockState.conversations.set("conv-1", {
|
|
404
|
+
id: "conv-1",
|
|
405
|
+
activeSkillId: "tdd",
|
|
406
|
+
activeSkillIds: [],
|
|
407
|
+
runtimeId: "claude-code",
|
|
408
|
+
});
|
|
409
|
+
const { data, isError } = parse(
|
|
410
|
+
await call("activate_skill", {
|
|
411
|
+
conversationId: "conv-1",
|
|
412
|
+
skillId: "spike",
|
|
413
|
+
mode: "add",
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
expect(isError).toBe(false);
|
|
417
|
+
expect(data.requiresConfirmation).toBe(true);
|
|
418
|
+
expect(Array.isArray(data.conflicts)).toBe(true);
|
|
419
|
+
// Must NOT have written
|
|
420
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillIds).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("default mode:replace clears prior composed skills (back-compat)", async () => {
|
|
424
|
+
mockState.skills = [
|
|
425
|
+
{ id: "new", name: "new", tool: "x", scope: "project", preview: "", sizeBytes: 100, absPath: "/n", content: "" },
|
|
426
|
+
];
|
|
427
|
+
mockState.conversations.set("conv-1", {
|
|
428
|
+
id: "conv-1",
|
|
429
|
+
activeSkillId: "old",
|
|
430
|
+
activeSkillIds: ["other"],
|
|
431
|
+
runtimeId: "claude-code",
|
|
432
|
+
});
|
|
433
|
+
await call("activate_skill", { conversationId: "conv-1", skillId: "new" });
|
|
434
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillId).toBe("new");
|
|
435
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillIds).toEqual([]);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("deactivate_skill", () => {
|
|
440
|
+
it("clears the active skill and returns the previous id", async () => {
|
|
441
|
+
mockState.conversations.set("conv-1", {
|
|
442
|
+
id: "conv-1",
|
|
443
|
+
activeSkillId: ".claude/skills/capture",
|
|
444
|
+
activeSkillIds: [],
|
|
445
|
+
runtimeId: "claude-code",
|
|
446
|
+
});
|
|
447
|
+
const { data, isError } = parse(
|
|
448
|
+
await call("deactivate_skill", { conversationId: "conv-1" })
|
|
449
|
+
);
|
|
450
|
+
expect(isError).toBe(false);
|
|
451
|
+
expect(data.previousSkillId).toBe(".claude/skills/capture");
|
|
452
|
+
expect(data.activeSkillId).toBeNull();
|
|
453
|
+
expect(mockState.conversations.get("conv-1")?.activeSkillId).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("is idempotent when no skill was active", async () => {
|
|
457
|
+
mockState.conversations.set("conv-1", { id: "conv-1", activeSkillId: null, activeSkillIds: [], runtimeId: "claude-code" });
|
|
458
|
+
const { data, isError } = parse(
|
|
459
|
+
await call("deactivate_skill", { conversationId: "conv-1" })
|
|
460
|
+
);
|
|
461
|
+
expect(isError).toBe(false);
|
|
462
|
+
expect(data.previousSkillId).toBeNull();
|
|
463
|
+
expect(data.activeSkillId).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("errors on unknown conversation", async () => {
|
|
467
|
+
const { data, isError } = parse(
|
|
468
|
+
await call("deactivate_skill", { conversationId: "ghost" })
|
|
469
|
+
);
|
|
470
|
+
expect(isError).toBe(true);
|
|
471
|
+
expect(data.error).toContain("Conversation not found");
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -282,6 +282,53 @@ describe("execute_task stale agentProfile surfacing", () => {
|
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
+
describe("create_task assignedAgent runtime validation", () => {
|
|
286
|
+
it("returns a descriptive error listing valid runtime ids when assignedAgent is invalid", async () => {
|
|
287
|
+
const result = await callHandler("create_task", {
|
|
288
|
+
title: "test task",
|
|
289
|
+
assignedAgent: "claude-bogus",
|
|
290
|
+
});
|
|
291
|
+
expect(result.isError).toBe(true);
|
|
292
|
+
const text = getToolResultText(result);
|
|
293
|
+
expect(text).toContain("claude-bogus");
|
|
294
|
+
expect(text).toMatch(/Invalid runtime/i);
|
|
295
|
+
expect(text).toMatch(/anthropic-direct|openai-direct|claude/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("inserts with the given assignedAgent when it is a valid runtime id", async () => {
|
|
299
|
+
const result = await callHandler("create_task", {
|
|
300
|
+
title: "test task",
|
|
301
|
+
assignedAgent: "anthropic-direct",
|
|
302
|
+
});
|
|
303
|
+
expect(result.isError).toBeFalsy();
|
|
304
|
+
expect(mockState.lastInsertValues?.assignedAgent).toBe("anthropic-direct");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("execute_task assignedAgent runtime validation", () => {
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
mockState.rows = [{
|
|
311
|
+
id: "task-1",
|
|
312
|
+
title: "existing",
|
|
313
|
+
status: "planned",
|
|
314
|
+
projectId: null,
|
|
315
|
+
agentProfile: null,
|
|
316
|
+
assignedAgent: null,
|
|
317
|
+
} as TaskRow];
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("returns an error listing valid runtime ids when the passed assignedAgent is invalid", async () => {
|
|
321
|
+
const result = await callHandler("execute_task", {
|
|
322
|
+
taskId: "task-1",
|
|
323
|
+
assignedAgent: "claude-bogus",
|
|
324
|
+
});
|
|
325
|
+
expect(result.isError).toBe(true);
|
|
326
|
+
const text = getToolResultText(result);
|
|
327
|
+
expect(text).toContain("claude-bogus");
|
|
328
|
+
expect(text).toMatch(/Invalid runtime/i);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
285
332
|
describe("list_tasks empty-result note", () => {
|
|
286
333
|
it("returns an envelope with note when a project filter is active and zero rows result", async () => {
|
|
287
334
|
mockState.rows = [];
|
|
@@ -214,4 +214,138 @@ describe("findSimilarWorkflows", () => {
|
|
|
214
214
|
expect(result).toHaveLength(1);
|
|
215
215
|
expect(result[0].similarity).toBe(1); // exact name match
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
// ── Legitimate variant tolerance ────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// Regression tests for the concern flagged in the code review of
|
|
221
|
+
// commit b5ed09b: that WORKFLOW_DEDUP_THRESHOLD = 0.7 on a pooled
|
|
222
|
+
// Jaccard over keywords would flag legitimate target-entity variants
|
|
223
|
+
// (e.g. "Enrich contacts" vs "Enrich accounts") as duplicates,
|
|
224
|
+
// eroding trust in the guardrail. Each pair here shares a dominant
|
|
225
|
+
// verb and most of the step structure — the only difference is the
|
|
226
|
+
// target entity noun.
|
|
227
|
+
//
|
|
228
|
+
// Success criterion per spec:
|
|
229
|
+
// - the two "positive-variant" cases must return [] (no match)
|
|
230
|
+
// - the two "guard" cases must still flag duplicates (similarity >=
|
|
231
|
+
// WORKFLOW_DEDUP_THRESHOLD, or exact-name match)
|
|
232
|
+
describe("legitimate variant tolerance", () => {
|
|
233
|
+
it("allows Enrich contacts and Enrich accounts as distinct workflows", async () => {
|
|
234
|
+
setRows([
|
|
235
|
+
{
|
|
236
|
+
id: "wf1",
|
|
237
|
+
name: "Enrich contacts",
|
|
238
|
+
definition: JSON.stringify({
|
|
239
|
+
pattern: "sequence",
|
|
240
|
+
steps: [
|
|
241
|
+
{ id: "s1", name: "Load rows from contacts table", prompt: "Select rows from the contacts table" },
|
|
242
|
+
{ id: "s2", name: "Call enrichment agent", prompt: "Invoke enrichment agent on each row" },
|
|
243
|
+
{ id: "s3", name: "Write back to table", prompt: "Write enriched data back to the contacts table" },
|
|
244
|
+
],
|
|
245
|
+
}),
|
|
246
|
+
projectId: "proj_a",
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const result = await findSimilarWorkflows(
|
|
251
|
+
"proj_a",
|
|
252
|
+
"Enrich accounts",
|
|
253
|
+
JSON.stringify({
|
|
254
|
+
pattern: "sequence",
|
|
255
|
+
steps: [
|
|
256
|
+
{ id: "s1", name: "Load rows from accounts table", prompt: "Select rows from the accounts table" },
|
|
257
|
+
{ id: "s2", name: "Call enrichment agent", prompt: "Invoke enrichment agent on each row" },
|
|
258
|
+
{ id: "s3", name: "Write back to table", prompt: "Write enriched data back to the accounts table" },
|
|
259
|
+
],
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(result).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("allows Daily standup digest and Weekly standup digest as distinct workflows", async () => {
|
|
267
|
+
setRows([
|
|
268
|
+
{
|
|
269
|
+
id: "wf1",
|
|
270
|
+
name: "Daily standup digest",
|
|
271
|
+
definition: JSON.stringify({
|
|
272
|
+
pattern: "sequence",
|
|
273
|
+
steps: [
|
|
274
|
+
{ id: "s1", name: "Fetch standup messages", prompt: "Pull daily standup messages from the team channel" },
|
|
275
|
+
{ id: "s2", name: "Summarize daily topics", prompt: "Write a daily digest of key topics and blockers" },
|
|
276
|
+
{ id: "s3", name: "Post digest to channel", prompt: "Post the daily summary digest to the #ops channel" },
|
|
277
|
+
],
|
|
278
|
+
}),
|
|
279
|
+
projectId: "proj_a",
|
|
280
|
+
},
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
const result = await findSimilarWorkflows(
|
|
284
|
+
"proj_a",
|
|
285
|
+
"Weekly standup digest",
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
pattern: "sequence",
|
|
288
|
+
steps: [
|
|
289
|
+
{ id: "s1", name: "Fetch standup messages", prompt: "Pull weekly standup messages from the team channel" },
|
|
290
|
+
{ id: "s2", name: "Summarize weekly topics", prompt: "Write a weekly digest of key topics and blockers" },
|
|
291
|
+
{ id: "s3", name: "Post digest to channel", prompt: "Post the weekly summary digest to the #ops channel" },
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(result).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("still blocks exact case-insensitive name matches (guard)", async () => {
|
|
300
|
+
setRows([
|
|
301
|
+
{
|
|
302
|
+
id: "wf1",
|
|
303
|
+
name: "Enrich contacts",
|
|
304
|
+
definition: JSON.stringify({
|
|
305
|
+
pattern: "sequence",
|
|
306
|
+
steps: [
|
|
307
|
+
{ id: "s1", name: "Load rows from contacts table", prompt: "Select rows from the contacts table" },
|
|
308
|
+
],
|
|
309
|
+
}),
|
|
310
|
+
projectId: "proj_a",
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const result = await findSimilarWorkflows(
|
|
315
|
+
"proj_a",
|
|
316
|
+
"ENRICH CONTACTS", // same name, different case
|
|
317
|
+
JSON.stringify({ pattern: "sequence", steps: [] })
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(result).toHaveLength(1);
|
|
321
|
+
expect(result[0].similarity).toBe(1);
|
|
322
|
+
expect(result[0].reason).toContain("Same name");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("still blocks near-identical step content with near-identical name (guard)", async () => {
|
|
326
|
+
const sharedSteps = [
|
|
327
|
+
{ id: "s1", name: "Fetch customer segments list", prompt: "Load the customer segments list from BigQuery warehouse" },
|
|
328
|
+
{ id: "s2", name: "Classify each segment bucket", prompt: "Classify each customer segment bucket using ML model" },
|
|
329
|
+
{ id: "s3", name: "Write segments back warehouse", prompt: "Write segment classifications back into BigQuery warehouse" },
|
|
330
|
+
];
|
|
331
|
+
setRows([
|
|
332
|
+
{
|
|
333
|
+
id: "wf1",
|
|
334
|
+
name: "Classify customer segments v1",
|
|
335
|
+
definition: JSON.stringify({ pattern: "sequence", steps: sharedSteps }),
|
|
336
|
+
projectId: "proj_a",
|
|
337
|
+
},
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
const result = await findSimilarWorkflows(
|
|
341
|
+
"proj_a",
|
|
342
|
+
"Classify customer segments v2",
|
|
343
|
+
JSON.stringify({ pattern: "sequence", steps: sharedSteps })
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
347
|
+
expect(result[0].id).toBe("wf1");
|
|
348
|
+
expect(result[0].similarity).toBeGreaterThanOrEqual(0.7);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
217
351
|
});
|