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.
Files changed (176) hide show
  1. package/README.md +44 -31
  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/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. package/src/test/setup.ts +10 -0
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `useSavedSearches` — client-side store for saved filter combinations
5
+ * surfaced in the chat mention popover and `⌘K` palette.
6
+ *
7
+ * Mirrors `use-pinned-entries.ts`: fetches once on mount, keeps an
8
+ * in-memory list, and writes back via PUT on every mutation (full-list
9
+ * replacement — see `src/app/api/settings/chat/saved-searches/route.ts`
10
+ * for design rationale).
11
+ */
12
+
13
+ import { useCallback, useEffect, useState } from "react";
14
+
15
+ export type SavedSearchSurface =
16
+ | "task"
17
+ | "project"
18
+ | "workflow"
19
+ | "document"
20
+ | "skill"
21
+ | "profile";
22
+
23
+ export interface SavedSearch {
24
+ id: string;
25
+ surface: SavedSearchSurface;
26
+ label: string;
27
+ filterInput: string;
28
+ createdAt: string;
29
+ }
30
+
31
+ interface UseSavedSearchesReturn {
32
+ searches: SavedSearch[];
33
+ loading: boolean;
34
+ save: (entry: Omit<SavedSearch, "id" | "createdAt">) => SavedSearch;
35
+ remove: (id: string) => void;
36
+ forSurface: (surface: SavedSearchSurface) => SavedSearch[];
37
+ /**
38
+ * Re-fetch from the server. Each `useSavedSearches()` consumer holds
39
+ * its own state — the chat popover and the ⌘K palette do not share a
40
+ * cache. Components that need to see edits made elsewhere (e.g. the
41
+ * palette opening after a save in the popover) call `refetch()` at
42
+ * the right moment to revalidate.
43
+ *
44
+ * See features/saved-search-polish-v1.md for the bug history.
45
+ */
46
+ refetch: () => Promise<void>;
47
+ rename: (id: string, label: string) => void;
48
+ }
49
+
50
+ export function useSavedSearches(): UseSavedSearchesReturn {
51
+ const [searches, setSearches] = useState<SavedSearch[]>([]);
52
+ const [loading, setLoading] = useState(true);
53
+
54
+ // Single fetch helper used by both the mount effect and `refetch`.
55
+ // Returns void so consumers can `await` revalidation if they want
56
+ // to wait for fresh data before continuing.
57
+ const fetchSearches = useCallback(async (): Promise<void> => {
58
+ try {
59
+ const r = await fetch("/api/settings/chat/saved-searches");
60
+ const data: { searches?: SavedSearch[] } = r.ok
61
+ ? await r.json()
62
+ : { searches: [] };
63
+ setSearches(data.searches ?? []);
64
+ } catch {
65
+ setSearches([]);
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ void fetchSearches();
73
+ }, [fetchSearches]);
74
+
75
+ const persist = useCallback(async (next: SavedSearch[]) => {
76
+ try {
77
+ await fetch("/api/settings/chat/saved-searches", {
78
+ method: "PUT",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({ searches: next }),
81
+ });
82
+ } catch {
83
+ // Optimistic update already applied; server-sync failure silently
84
+ // swallowed. Matches the pins-hook contract.
85
+ }
86
+ }, []);
87
+
88
+ const save = useCallback(
89
+ (entry: Omit<SavedSearch, "id" | "createdAt">): SavedSearch => {
90
+ const full: SavedSearch = {
91
+ ...entry,
92
+ id:
93
+ typeof crypto !== "undefined" && "randomUUID" in crypto
94
+ ? crypto.randomUUID()
95
+ : `ss-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
96
+ createdAt: new Date().toISOString(),
97
+ };
98
+ setSearches((prev) => {
99
+ const next = [...prev, full];
100
+ void persist(next);
101
+ return next;
102
+ });
103
+ return full;
104
+ },
105
+ [persist]
106
+ );
107
+
108
+ const remove = useCallback(
109
+ (id: string) => {
110
+ setSearches((prev) => {
111
+ const next = prev.filter((s) => s.id !== id);
112
+ void persist(next);
113
+ return next;
114
+ });
115
+ },
116
+ [persist]
117
+ );
118
+
119
+ const forSurface = useCallback(
120
+ (surface: SavedSearchSurface) =>
121
+ searches.filter((s) => s.surface === surface),
122
+ [searches]
123
+ );
124
+
125
+ const refetch = useCallback(() => fetchSearches(), [fetchSearches]);
126
+
127
+ const rename = useCallback(
128
+ (id: string, label: string) => {
129
+ setSearches((prev) => {
130
+ const idx = prev.findIndex((s) => s.id === id);
131
+ if (idx === -1) return prev;
132
+ const next = prev.slice();
133
+ next[idx] = { ...next[idx], label };
134
+ void persist(next);
135
+ return next;
136
+ });
137
+ },
138
+ [persist]
139
+ );
140
+
141
+ return { searches, loading, save, remove, forSurface, refetch, rename };
142
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+
5
+ describe("claude-agent.ts SDK options parity with chat engine", () => {
6
+ const agentSource = fs.readFileSync(
7
+ path.resolve(__dirname, "../claude-agent.ts"),
8
+ "utf8",
9
+ );
10
+
11
+ // Split the source at the resumeClaudeTask function boundary. The first
12
+ // half contains executeClaudeTask's query() block; the second half contains
13
+ // resumeClaudeTask's query() block. This is more robust than a single
14
+ // regex over the whole file — a future edit that adds a stray `canUseTool`
15
+ // reference above either function won't cause a parity test to match the
16
+ // wrong query() call.
17
+ const resumeMarker = "export async function resumeClaudeTask";
18
+ const splitIndex = agentSource.indexOf(resumeMarker);
19
+ if (splitIndex === -1) {
20
+ throw new Error(
21
+ "claude-agent-sdk-options.test.ts: could not find `" + resumeMarker +
22
+ "` in claude-agent.ts — rename or refactor broke this test's assumptions",
23
+ );
24
+ }
25
+ const executeSection = agentSource.slice(0, splitIndex);
26
+ const resumeSection = agentSource.slice(splitIndex);
27
+
28
+ it("imports CLAUDE_SDK_ALLOWED_TOOLS from runtime/claude-sdk", () => {
29
+ expect(agentSource).toMatch(/CLAUDE_SDK_ALLOWED_TOOLS[\s\S]*runtime\/claude-sdk/);
30
+ });
31
+
32
+ it("imports CLAUDE_SDK_SETTING_SOURCES from runtime/claude-sdk", () => {
33
+ expect(agentSource).toMatch(/CLAUDE_SDK_SETTING_SOURCES[\s\S]*runtime\/claude-sdk/);
34
+ });
35
+
36
+ it("imports getFeaturesForModel to gate native-skill options", () => {
37
+ expect(agentSource).toMatch(/getFeaturesForModel/);
38
+ });
39
+
40
+ it("passes settingSources inside executeClaudeTask query() options", () => {
41
+ // The execute section must contain a query( call AND settingSources.
42
+ expect(executeSection).toMatch(/query\(/);
43
+ expect(executeSection).toContain("settingSources");
44
+ });
45
+
46
+ it("passes settingSources inside resumeClaudeTask query() options", () => {
47
+ expect(resumeSection).toMatch(/query\(/);
48
+ expect(resumeSection).toContain("settingSources");
49
+ });
50
+
51
+ it("hooks field is NOT present in either query() options block", () => {
52
+ for (const section of [executeSection, resumeSection]) {
53
+ expect(section).not.toMatch(/\bhooks\s*:/);
54
+ }
55
+ });
56
+ });
@@ -292,10 +292,12 @@ describe("executeClaudeTask", () => {
292
292
  expect(stagentCount).toBe(1);
293
293
  });
294
294
 
295
- it("A-stagent-3: omits allowedTools when profile has none (preset defaults preserved)", async () => {
295
+ it("A-stagent-3: falls back to CLAUDE_SDK_ALLOWED_TOOLS when profile has none and runtime has native skills", async () => {
296
296
  mockWhere.mockResolvedValueOnce([makeTask({ projectId: "proj-7" })]);
297
- // Default mockGetProfile returns allowedTools: undefined, so ctx.payload.allowedTools
298
- // will also be undefined the query() call should NOT include an allowedTools option.
297
+ // Default mockGetProfile returns allowedTools: undefined. Task-runtime-skill-parity
298
+ // (Task 3) changed withStagentAllowedTools so the Phase 1a tool set (Skill,
299
+ // Read/Grep/Glob, Edit/Write/Bash, TodoWrite) is passed alongside mcp__stagent__*
300
+ // when the runtime has hasNativeSkills=true — which is the claude-code default.
299
301
  mockQuery.mockReturnValue(
300
302
  createMockStream([
301
303
  { type: "result", result: "done" },
@@ -307,7 +309,18 @@ describe("executeClaudeTask", () => {
307
309
  const queryCall = mockQuery.mock.calls[0][0] as {
308
310
  options: { allowedTools?: string[] };
309
311
  };
310
- expect(queryCall.options.allowedTools).toBeUndefined();
312
+ expect(queryCall.options.allowedTools).toBeDefined();
313
+ expect(queryCall.options.allowedTools).toEqual([
314
+ "mcp__stagent__*",
315
+ "Skill",
316
+ "Read",
317
+ "Grep",
318
+ "Glob",
319
+ "Edit",
320
+ "Write",
321
+ "Bash",
322
+ "TodoWrite",
323
+ ]);
311
324
  });
312
325
 
313
326
  it("A3: captures sessionId from init message and re-calls setExecution", async () => {
@@ -0,0 +1,166 @@
1
+ import { randomUUID } from "crypto";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { eq } from "drizzle-orm";
4
+ import { db } from "@/lib/db";
5
+ import { agentLogs, notifications, tasks } from "@/lib/db/schema";
6
+ import { RetryableRuntimeLaunchError } from "@/lib/agents/runtime/launch-failure";
7
+
8
+ const {
9
+ mockExecuteTaskWithRuntime,
10
+ mockResumeTaskWithRuntime,
11
+ mockResolveTaskExecutionTarget,
12
+ mockResolveResumeExecutionTarget,
13
+ } = vi.hoisted(() => ({
14
+ mockExecuteTaskWithRuntime: vi.fn(),
15
+ mockResumeTaskWithRuntime: vi.fn(),
16
+ mockResolveTaskExecutionTarget: vi.fn(),
17
+ mockResolveResumeExecutionTarget: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("@/lib/agents/runtime", () => ({
21
+ executeTaskWithRuntime: mockExecuteTaskWithRuntime,
22
+ resumeTaskWithRuntime: mockResumeTaskWithRuntime,
23
+ }));
24
+
25
+ vi.mock("@/lib/agents/runtime/execution-target", () => ({
26
+ resolveTaskExecutionTarget: mockResolveTaskExecutionTarget,
27
+ resolveResumeExecutionTarget: mockResolveResumeExecutionTarget,
28
+ }));
29
+
30
+ import { startTaskExecution } from "../task-dispatch";
31
+
32
+ function seedTask() {
33
+ const id = randomUUID();
34
+ const now = new Date();
35
+ db.insert(tasks)
36
+ .values({
37
+ id,
38
+ title: "Upgrade local clone",
39
+ description: "Merge upstream changes safely",
40
+ status: "queued",
41
+ assignedAgent: "claude-code",
42
+ agentProfile: "upgrade-assistant",
43
+ priority: 2,
44
+ resumeCount: 0,
45
+ createdAt: now,
46
+ updatedAt: now,
47
+ })
48
+ .run();
49
+ return id;
50
+ }
51
+
52
+ describe("startTaskExecution", () => {
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ db.delete(notifications).run();
56
+ db.delete(agentLogs).run();
57
+ db.delete(tasks).run();
58
+ });
59
+
60
+ it("retries once on a retryable launch failure and persists the fallback runtime", async () => {
61
+ const taskId = seedTask();
62
+
63
+ mockResolveTaskExecutionTarget
64
+ .mockResolvedValueOnce({
65
+ requestedRuntimeId: "claude-code",
66
+ effectiveRuntimeId: "claude-code",
67
+ requestedModelId: null,
68
+ effectiveModelId: null,
69
+ fallbackApplied: false,
70
+ fallbackReason: null,
71
+ })
72
+ .mockResolvedValueOnce({
73
+ requestedRuntimeId: "claude-code",
74
+ effectiveRuntimeId: "openai-codex-app-server",
75
+ requestedModelId: null,
76
+ effectiveModelId: null,
77
+ fallbackApplied: false,
78
+ fallbackReason: null,
79
+ });
80
+
81
+ mockExecuteTaskWithRuntime
82
+ .mockRejectedValueOnce(
83
+ new RetryableRuntimeLaunchError({
84
+ runtimeId: "claude-code",
85
+ message:
86
+ "Claude Code failed to launch before task execution started: Claude Code process exited with code 1",
87
+ cause: new Error("Claude Code process exited with code 1"),
88
+ })
89
+ )
90
+ .mockResolvedValueOnce(undefined);
91
+
92
+ await startTaskExecution(taskId, { requestedRuntimeId: "claude-code" });
93
+
94
+ expect(mockExecuteTaskWithRuntime).toHaveBeenNthCalledWith(1, taskId, "claude-code");
95
+ expect(mockExecuteTaskWithRuntime).toHaveBeenNthCalledWith(
96
+ 2,
97
+ taskId,
98
+ "openai-codex-app-server"
99
+ );
100
+ expect(mockResolveTaskExecutionTarget).toHaveBeenNthCalledWith(2, {
101
+ title: "Upgrade local clone",
102
+ description: "Merge upstream changes safely",
103
+ requestedRuntimeId: "claude-code",
104
+ profileId: "upgrade-assistant",
105
+ unavailableRuntimeIds: ["claude-code"],
106
+ unavailableReasons: {
107
+ "claude-code":
108
+ "Claude Code failed to launch before task execution started: Claude Code process exited with code 1",
109
+ },
110
+ });
111
+
112
+ const row = db.select().from(tasks).where(eq(tasks.id, taskId)).get();
113
+ expect(row?.status).toBe("running");
114
+ expect(row?.effectiveRuntimeId).toBe("openai-codex-app-server");
115
+ expect(row?.runtimeFallbackReason).toContain("Fell back to OpenAI Codex App Server.");
116
+
117
+ const logs = db
118
+ .select({ event: agentLogs.event, payload: agentLogs.payload })
119
+ .from(agentLogs)
120
+ .where(eq(agentLogs.taskId, taskId))
121
+ .all();
122
+ expect(logs.map((log) => log.event)).toContain("runtime_launch_failed");
123
+ expect(logs.map((log) => log.event)).toContain("runtime_fallback");
124
+ });
125
+
126
+ it("marks the task failed when a retryable launch failure has no compatible alternate", async () => {
127
+ const taskId = seedTask();
128
+
129
+ mockResolveTaskExecutionTarget
130
+ .mockResolvedValueOnce({
131
+ requestedRuntimeId: "claude-code",
132
+ effectiveRuntimeId: "claude-code",
133
+ requestedModelId: null,
134
+ effectiveModelId: null,
135
+ fallbackApplied: false,
136
+ fallbackReason: null,
137
+ })
138
+ .mockRejectedValueOnce(
139
+ new Error("No compatible configured runtime is available for this task.")
140
+ );
141
+
142
+ mockExecuteTaskWithRuntime.mockRejectedValueOnce(
143
+ new RetryableRuntimeLaunchError({
144
+ runtimeId: "claude-code",
145
+ message:
146
+ "Claude Code failed to launch before task execution started: Claude Code process exited with code 1",
147
+ cause: new Error("Claude Code process exited with code 1"),
148
+ })
149
+ );
150
+
151
+ await expect(
152
+ startTaskExecution(taskId, { requestedRuntimeId: "claude-code" })
153
+ ).rejects.toThrow("No compatible configured runtime is available for this task.");
154
+
155
+ const row = db.select().from(tasks).where(eq(tasks.id, taskId)).get();
156
+ expect(row?.status).toBe("failed");
157
+ expect(row?.result).toBe("No compatible configured runtime is available for this task.");
158
+
159
+ const taskNotifications = db
160
+ .select()
161
+ .from(notifications)
162
+ .where(eq(notifications.taskId, taskId))
163
+ .all();
164
+ expect(taskNotifications).toHaveLength(1);
165
+ });
166
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("@/lib/db", () => ({
4
+ db: {
5
+ insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
6
+ select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
7
+ },
8
+ }));
9
+
10
+ vi.mock("@/lib/settings/permissions", () => ({
11
+ isToolAllowed: vi.fn().mockResolvedValue(false),
12
+ }));
13
+
14
+ import { handleToolPermission, clearPermissionCache } from "@/lib/agents/tool-permissions";
15
+
16
+ describe("handleToolPermission — SDK filesystem and Skill auto-allow", () => {
17
+ beforeEach(() => {
18
+ clearPermissionCache("test-task");
19
+ clearPermissionCache("test-task-edit");
20
+ });
21
+
22
+ it("auto-allows Read without creating a notification", async () => {
23
+ const result = await handleToolPermission("test-task", "Read", { file_path: "/tmp/x" });
24
+ expect(result.behavior).toBe("allow");
25
+ expect(result.updatedInput).toEqual({ file_path: "/tmp/x" });
26
+ });
27
+
28
+ it("auto-allows Grep", async () => {
29
+ const result = await handleToolPermission("test-task", "Grep", { pattern: "foo" });
30
+ expect(result.behavior).toBe("allow");
31
+ });
32
+
33
+ it("auto-allows Glob", async () => {
34
+ const result = await handleToolPermission("test-task", "Glob", { pattern: "**/*.ts" });
35
+ expect(result.behavior).toBe("allow");
36
+ });
37
+
38
+ it("auto-allows Skill invocations", async () => {
39
+ const result = await handleToolPermission("test-task", "Skill", { skill: "code-reviewer" });
40
+ expect(result.behavior).toBe("allow");
41
+ });
42
+
43
+ it("does NOT auto-allow Edit (must route through notification flow)", async () => {
44
+ const { db } = await import("@/lib/db");
45
+ const insertSpy = vi.spyOn(db, "insert");
46
+ handleToolPermission("test-task-edit", "Edit", { file_path: "/tmp/x", content: "y" });
47
+ await new Promise((r) => setTimeout(r, 10));
48
+ expect(insertSpy).toHaveBeenCalled();
49
+ });
50
+
51
+ it("profile autoDeny for Read wins over auto-allow", async () => {
52
+ const result = await handleToolPermission(
53
+ "test-task",
54
+ "Read",
55
+ { file_path: "/tmp/x" },
56
+ { autoApprove: [], autoDeny: ["Read"] },
57
+ );
58
+ expect(result.behavior).toBe("deny");
59
+ });
60
+ });