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.
- package/README.md +15 -2
- 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/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-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 +1 -1
- 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/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/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 +18 -0
- 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 +54 -0
- 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__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +7 -9
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- 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,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
|
+
});
|