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,220 @@
1
+ import { db } from "@/lib/db";
2
+ import { agentLogs, notifications, tasks } from "@/lib/db/schema";
3
+ import { eq } from "drizzle-orm";
4
+ import { executeTaskWithRuntime, resumeTaskWithRuntime } from "@/lib/agents/runtime";
5
+ import {
6
+ resolveResumeExecutionTarget,
7
+ resolveTaskExecutionTarget,
8
+ type ResolvedExecutionTarget,
9
+ } from "@/lib/agents/runtime/execution-target";
10
+ import {
11
+ classifyTaskFailureReason,
12
+ RetryableRuntimeLaunchError,
13
+ } from "@/lib/agents/runtime/launch-failure";
14
+ import { getRuntimeCatalogEntry } from "@/lib/agents/runtime/catalog";
15
+
16
+ async function persistExecutionTarget(
17
+ taskId: string,
18
+ target: ResolvedExecutionTarget
19
+ ) {
20
+ await db
21
+ .update(tasks)
22
+ .set({
23
+ effectiveRuntimeId: target.effectiveRuntimeId,
24
+ effectiveModelId: target.effectiveModelId,
25
+ runtimeFallbackReason: target.fallbackReason,
26
+ updatedAt: new Date(),
27
+ })
28
+ .where(eq(tasks.id, taskId));
29
+
30
+ if (target.fallbackApplied && target.fallbackReason) {
31
+ await db.insert(agentLogs).values({
32
+ id: crypto.randomUUID(),
33
+ taskId,
34
+ agentType: "runtime-router",
35
+ event: "runtime_fallback",
36
+ payload: JSON.stringify({
37
+ requestedRuntimeId: target.requestedRuntimeId,
38
+ effectiveRuntimeId: target.effectiveRuntimeId,
39
+ reason: target.fallbackReason,
40
+ }),
41
+ timestamp: new Date(),
42
+ });
43
+ }
44
+ }
45
+
46
+ async function logRuntimeLaunchFailure(
47
+ taskId: string,
48
+ error: RetryableRuntimeLaunchError
49
+ ) {
50
+ await db.insert(agentLogs).values({
51
+ id: crypto.randomUUID(),
52
+ taskId,
53
+ agentType: "runtime-router",
54
+ event: "runtime_launch_failed",
55
+ payload: JSON.stringify({
56
+ runtimeId: error.runtimeId,
57
+ error: error.message,
58
+ }),
59
+ timestamp: new Date(),
60
+ });
61
+ }
62
+
63
+ async function markTaskLaunchFailed(
64
+ taskId: string,
65
+ taskTitle: string,
66
+ error: unknown
67
+ ) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ await db
70
+ .update(tasks)
71
+ .set({
72
+ status: "failed",
73
+ result: message,
74
+ failureReason: classifyTaskFailureReason(
75
+ error instanceof Error ? error : new Error(message)
76
+ ),
77
+ sessionId: null,
78
+ updatedAt: new Date(),
79
+ })
80
+ .where(eq(tasks.id, taskId));
81
+
82
+ await db.insert(notifications).values({
83
+ id: crypto.randomUUID(),
84
+ taskId,
85
+ type: "task_failed",
86
+ title: `Task failed: ${taskTitle}`,
87
+ body: message.slice(0, 500),
88
+ createdAt: new Date(),
89
+ });
90
+ }
91
+
92
+ function buildLaunchFallbackTarget(input: {
93
+ originalTarget: ResolvedExecutionTarget;
94
+ retryTarget: ResolvedExecutionTarget;
95
+ launchError: RetryableRuntimeLaunchError;
96
+ }): ResolvedExecutionTarget {
97
+ const effectiveLabel = getRuntimeCatalogEntry(
98
+ input.retryTarget.effectiveRuntimeId
99
+ ).label;
100
+
101
+ return {
102
+ ...input.retryTarget,
103
+ fallbackApplied: true,
104
+ fallbackReason: `${input.launchError.message}. Fell back to ${effectiveLabel}.`,
105
+ requestedRuntimeId:
106
+ input.retryTarget.requestedRuntimeId ?? input.originalTarget.requestedRuntimeId,
107
+ requestedModelId:
108
+ input.retryTarget.requestedModelId ?? input.originalTarget.requestedModelId,
109
+ effectiveModelId: input.retryTarget.effectiveModelId,
110
+ effectiveRuntimeId: input.retryTarget.effectiveRuntimeId,
111
+ };
112
+ }
113
+
114
+ async function retryTaskWithFallback(
115
+ task: typeof tasks.$inferSelect,
116
+ originalTarget: ResolvedExecutionTarget,
117
+ launchError: RetryableRuntimeLaunchError
118
+ ) {
119
+ await logRuntimeLaunchFailure(task.id, launchError);
120
+
121
+ let retryTarget: ResolvedExecutionTarget;
122
+ try {
123
+ retryTarget = await resolveTaskExecutionTarget({
124
+ title: task.title,
125
+ description: task.description,
126
+ requestedRuntimeId: originalTarget.requestedRuntimeId ?? task.assignedAgent,
127
+ profileId: task.agentProfile,
128
+ unavailableRuntimeIds: [launchError.runtimeId],
129
+ unavailableReasons: {
130
+ [launchError.runtimeId]: launchError.message,
131
+ },
132
+ });
133
+ } catch (error) {
134
+ await markTaskLaunchFailed(task.id, task.title, error);
135
+ throw error;
136
+ }
137
+
138
+ const fallbackTarget = buildLaunchFallbackTarget({
139
+ originalTarget,
140
+ retryTarget,
141
+ launchError,
142
+ });
143
+
144
+ await db
145
+ .update(tasks)
146
+ .set({
147
+ status: "running",
148
+ result: null,
149
+ failureReason: null,
150
+ sessionId: null,
151
+ updatedAt: new Date(),
152
+ })
153
+ .where(eq(tasks.id, task.id));
154
+
155
+ await persistExecutionTarget(task.id, fallbackTarget);
156
+ try {
157
+ return await executeTaskWithRuntime(task.id, fallbackTarget.effectiveRuntimeId);
158
+ } catch (error) {
159
+ if (error instanceof RetryableRuntimeLaunchError) {
160
+ await markTaskLaunchFailed(task.id, task.title, error);
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ export async function startTaskExecution(
167
+ taskId: string,
168
+ options?: { requestedRuntimeId?: string | null }
169
+ ) {
170
+ const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId));
171
+ if (!task) {
172
+ throw new Error(`Task ${taskId} not found`);
173
+ }
174
+
175
+ const target = await resolveTaskExecutionTarget({
176
+ title: task.title,
177
+ description: task.description,
178
+ requestedRuntimeId: options?.requestedRuntimeId ?? task.assignedAgent,
179
+ profileId: task.agentProfile,
180
+ });
181
+
182
+ await db
183
+ .update(tasks)
184
+ .set({ status: "running", updatedAt: new Date() })
185
+ .where(eq(tasks.id, taskId));
186
+ await persistExecutionTarget(taskId, target);
187
+ try {
188
+ return await executeTaskWithRuntime(taskId, target.effectiveRuntimeId);
189
+ } catch (error) {
190
+ if (error instanceof RetryableRuntimeLaunchError) {
191
+ return retryTaskWithFallback(task, target, error);
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ export async function resumeTaskExecution(
198
+ taskId: string,
199
+ options?: {
200
+ requestedRuntimeId?: string | null;
201
+ effectiveRuntimeId?: string | null;
202
+ }
203
+ ) {
204
+ const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId));
205
+ if (!task) {
206
+ throw new Error(`Task ${taskId} not found`);
207
+ }
208
+
209
+ const target = await resolveResumeExecutionTarget({
210
+ requestedRuntimeId: options?.requestedRuntimeId ?? task.assignedAgent,
211
+ effectiveRuntimeId: options?.effectiveRuntimeId ?? task.effectiveRuntimeId,
212
+ });
213
+
214
+ await db
215
+ .update(tasks)
216
+ .set({ status: "running", updatedAt: new Date() })
217
+ .where(eq(tasks.id, taskId));
218
+ await persistExecutionTarget(taskId, target);
219
+ return resumeTaskWithRuntime(taskId, target.effectiveRuntimeId);
220
+ }
@@ -12,6 +12,7 @@ import { notifications } from "@/lib/db/schema";
12
12
  import { eq } from "drizzle-orm";
13
13
  import type { CanUseToolPolicy } from "./profiles/types";
14
14
  import { isExaTool, isExaReadOnly } from "./browser-mcp";
15
+ import { CLAUDE_SDK_READ_ONLY_FS_TOOLS } from "./runtime/claude-sdk";
15
16
 
16
17
  // ── Types ────────────────────────────────────────────────────────────
17
18
 
@@ -120,7 +121,10 @@ export async function handleToolPermission(
120
121
  ): Promise<ToolPermissionResponse> {
121
122
  const isQuestion = toolName === "AskUserQuestion";
122
123
 
123
- // Layer 1: Profile-level canUseToolPolicy — fastest check, no I/O
124
+ // Layer 1: Profile-level canUseToolPolicy — fastest check, no I/O.
125
+ // Runs BEFORE Layer 1.75's SDK filesystem auto-allow so `autoDeny: ["Read"]`
126
+ // still denies; `autoApprove` for Read/Grep/Glob is redundant (Layer 1.75
127
+ // would also allow) but harmless.
124
128
  if (!isQuestion && canUseToolPolicy) {
125
129
  if (canUseToolPolicy.autoApprove?.includes(toolName)) {
126
130
  return buildAllowedToolPermissionResponse(input);
@@ -135,6 +139,17 @@ export async function handleToolPermission(
135
139
  return buildAllowedToolPermissionResponse(input);
136
140
  }
137
141
 
142
+ // Layer 1.75: SDK filesystem read-only tools and Skill invocations —
143
+ // auto-approve without I/O. Mirrors the chat-side Phase 1a policy
144
+ // (src/lib/chat/engine.ts canUseTool). Read/Grep/Glob are non-destructive;
145
+ // Skill load is equivalent to using `claude` CLI directly — any tool the
146
+ // loaded skill subsequently invokes (Bash, Edit, etc.) goes through this
147
+ // same canUseTool check. See features/chat-claude-sdk-skills.md Error
148
+ // & Rescue Registry row "settingSources loads hostile skill."
149
+ if (!isQuestion && (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName) || toolName === "Skill")) {
150
+ return buildAllowedToolPermissionResponse(input);
151
+ }
152
+
138
153
  // Layer 2: Saved user permissions — skip notification for pre-approved tools
139
154
  if (!isQuestion) {
140
155
  const { isToolAllowed } = await import("@/lib/settings/permissions");
@@ -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
+ });