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
@@ -43,25 +43,41 @@ export async function POST(
43
43
  return NextResponse.json({ error: "Already responded" }, { status: 409 });
44
44
  }
45
45
 
46
- // Validate updatedInput keys against the original tool input to prevent injection
46
+ // Validate updatedInput keys against the original tool input to prevent injection.
47
+ // AskUserQuestion is a special case: the original toolInput describes the question
48
+ // (`question`, `options?`) but the response carries the user's `answer` — a key not
49
+ // present in the original. Allow a tightly-scoped `{answer: string}` shape.
47
50
  let sanitizedUpdatedInput = updatedInput;
51
+ const isQuestion = notification.toolName === "AskUserQuestion";
48
52
  if (updatedInput !== undefined && updatedInput !== null && typeof updatedInput === "object" && !Array.isArray(updatedInput)) {
49
- try {
50
- const originalToolInput = typeof notification.toolInput === "string" ? JSON.parse(notification.toolInput) : (notification.toolInput ?? {});
51
- if (typeof originalToolInput === "object" && originalToolInput !== null) {
52
- const allowedKeys = new Set(Object.keys(originalToolInput));
53
- const inputRecord = updatedInput as Record<string, unknown>;
54
- const extraKeys = Object.keys(inputRecord).filter((k) => !allowedKeys.has(k));
55
- if (extraKeys.length > 0) {
56
- return NextResponse.json(
57
- { error: `updatedInput contains disallowed keys: ${extraKeys.join(", ")}` },
58
- { status: 400 }
59
- );
53
+ if (isQuestion) {
54
+ const inputRecord = updatedInput as Record<string, unknown>;
55
+ const keys = Object.keys(inputRecord);
56
+ const extraKeys = keys.filter((k) => k !== "answer");
57
+ if (extraKeys.length > 0 || typeof inputRecord.answer !== "string") {
58
+ return NextResponse.json(
59
+ { error: "AskUserQuestion response must be { answer: string }" },
60
+ { status: 400 }
61
+ );
62
+ }
63
+ } else {
64
+ try {
65
+ const originalToolInput = typeof notification.toolInput === "string" ? JSON.parse(notification.toolInput) : (notification.toolInput ?? {});
66
+ if (typeof originalToolInput === "object" && originalToolInput !== null) {
67
+ const allowedKeys = new Set(Object.keys(originalToolInput));
68
+ const inputRecord = updatedInput as Record<string, unknown>;
69
+ const extraKeys = Object.keys(inputRecord).filter((k) => !allowedKeys.has(k));
70
+ if (extraKeys.length > 0) {
71
+ return NextResponse.json(
72
+ { error: `updatedInput contains disallowed keys: ${extraKeys.join(", ")}` },
73
+ { status: 400 }
74
+ );
75
+ }
60
76
  }
77
+ } catch {
78
+ // If we can't parse the original notification data, reject updatedInput entirely
79
+ sanitizedUpdatedInput = undefined;
61
80
  }
62
- } catch {
63
- // If we can't parse the original notification data, reject updatedInput entirely
64
- sanitizedUpdatedInput = undefined;
65
81
  }
66
82
  }
67
83
 
@@ -2,13 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
3
  import { tasks } from "@/lib/db/schema";
4
4
  import { eq, and, inArray } from "drizzle-orm";
5
- import { resumeTaskWithAgent } from "@/lib/agents/router";
6
5
  import { MAX_RESUME_COUNT } from "@/lib/constants/task-status";
7
- import { DEFAULT_AGENT_RUNTIME } from "@/lib/agents/runtime/catalog";
8
6
  import {
9
7
  BudgetLimitExceededError,
10
8
  enforceTaskBudgetGuardrails,
11
9
  } from "@/lib/settings/budget-guardrails";
10
+ import { resolveResumeExecutionTarget } from "@/lib/agents/runtime/execution-target";
11
+ import { resumeTaskExecution } from "@/lib/agents/task-dispatch";
12
12
 
13
13
  export async function POST(
14
14
  _req: NextRequest,
@@ -67,8 +67,29 @@ export async function POST(
67
67
  );
68
68
  }
69
69
 
70
+ try {
71
+ await resolveResumeExecutionTarget({
72
+ requestedRuntimeId: task.assignedAgent,
73
+ effectiveRuntimeId: task.effectiveRuntimeId,
74
+ });
75
+ } catch (error) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ db.update(tasks)
78
+ .set({
79
+ status: "failed",
80
+ result: message,
81
+ updatedAt: new Date(),
82
+ })
83
+ .where(eq(tasks.id, id))
84
+ .run();
85
+ return NextResponse.json({ error: message }, { status: 400 });
86
+ }
87
+
70
88
  // Fire-and-forget
71
- resumeTaskWithAgent(id, task.assignedAgent ?? DEFAULT_AGENT_RUNTIME).catch((err) =>
89
+ resumeTaskExecution(id, {
90
+ requestedRuntimeId: task.assignedAgent,
91
+ effectiveRuntimeId: task.effectiveRuntimeId,
92
+ }).catch((err) =>
72
93
  console.error(`Task ${id} resume error:`, err)
73
94
  );
74
95
 
@@ -1,3 +1,4 @@
1
+ import { Suspense } from "react";
1
2
  import { db } from "@/lib/db";
2
3
  import { documents, tasks, projects, workflows } from "@/lib/db/schema";
3
4
  import { desc, eq } from "drizzle-orm";
@@ -47,7 +48,9 @@ export default async function DocumentsPage() {
47
48
 
48
49
  return (
49
50
  <PageShell title="Documents">
50
- <DocumentBrowser initialDocuments={docs} projects={projectList} />
51
+ <Suspense fallback={null}>
52
+ <DocumentBrowser initialDocuments={docs} projects={projectList} />
53
+ </Suspense>
51
54
  </PageShell>
52
55
  );
53
56
  }
@@ -7,6 +7,7 @@ import { ChatSettingsSection } from "@/components/settings/chat-settings-section
7
7
  import { RuntimeTimeoutSection } from "@/components/settings/runtime-timeout-section";
8
8
  import { BrowserToolsSection } from "@/components/settings/browser-tools-section";
9
9
  import { WebSearchSection } from "@/components/settings/web-search-section";
10
+ import { EnvironmentSection } from "@/components/settings/environment-section";
10
11
  import { LearningContextSection } from "@/components/settings/learning-context-section";
11
12
  import { OllamaSection } from "@/components/settings/ollama-section";
12
13
  import { ChannelsSection } from "@/components/settings/channels-section";
@@ -29,6 +30,7 @@ export default function SettingsPage() {
29
30
  <RuntimeTimeoutSection />
30
31
  <LearningContextSection />
31
32
  <WebSearchSection />
33
+ <EnvironmentSection />
32
34
  <BrowserToolsSection />
33
35
  <ChannelsSection />
34
36
  <BudgetGuardrailsSection />
@@ -143,7 +143,7 @@ function CodeBlockView({
143
143
 
144
144
  /* ─── ImageBlock ─── */
145
145
 
146
- const BOOK_IMAGE_BASE = "https://raw.githubusercontent.com/navam-io/stagent/main/book/images";
146
+ const BOOK_IMAGE_BASE = "https://raw.githubusercontent.com/manavsehgal/stagent/main/book/images";
147
147
 
148
148
  function ImageBlockView({
149
149
  src,
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { CapabilityBanner } from "../capability-banner";
4
+
5
+ describe("CapabilityBanner", () => {
6
+ beforeEach(() => {
7
+ sessionStorage.clear();
8
+ });
9
+
10
+ it("is hidden on claude-code runtime", () => {
11
+ render(<CapabilityBanner runtimeId="claude-code" />);
12
+ expect(screen.queryByRole("status")).toBeNull();
13
+ });
14
+
15
+ it("is hidden on openai-codex-app-server runtime", () => {
16
+ render(<CapabilityBanner runtimeId="openai-codex-app-server" />);
17
+ expect(screen.queryByRole("status")).toBeNull();
18
+ });
19
+
20
+ it("is visible on ollama runtime with capability message", () => {
21
+ render(<CapabilityBanner runtimeId="ollama" />);
22
+ const status = screen.getByRole("status");
23
+ expect(status.textContent).toContain("file read/write");
24
+ });
25
+
26
+ it("hides on dismiss and persists to sessionStorage", () => {
27
+ render(<CapabilityBanner runtimeId="ollama" />);
28
+ fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
29
+ expect(screen.queryByRole("status")).toBeNull();
30
+ expect(sessionStorage.getItem("stagent.capability-banner.dismissed.ollama")).toBe("1");
31
+ });
32
+
33
+ it("stays dismissed on remount if sessionStorage flag set", () => {
34
+ sessionStorage.setItem("stagent.capability-banner.dismissed.ollama", "1");
35
+ render(<CapabilityBanner runtimeId="ollama" />);
36
+ expect(screen.queryByRole("status")).toBeNull();
37
+ });
38
+ });
@@ -1,6 +1,6 @@
1
1
  import { act, render, screen, waitFor } from "@testing-library/react";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { useState } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
4
 
5
5
  import type { ChatMessageRow } from "@/lib/db/schema";
6
6
  import {
@@ -405,4 +405,169 @@ describe("ChatSessionProvider", () => {
405
405
  expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
406
406
  });
407
407
  });
408
+
409
+ it("view-remount telemetry pattern logs on unmount when streaming", async () => {
410
+ // Contract test for the `client.stream.view-remount` telemetry code.
411
+ // Mirrors the pattern ChatShell implements: track isStreaming in a ref,
412
+ // then log on unmount iff the ref was true. The ref is necessary because
413
+ // a stale closure would see isStreaming at effect-setup time, not at
414
+ // unmount time.
415
+ const consoleInfoSpy = vi
416
+ .spyOn(console, "info")
417
+ .mockImplementation(() => {});
418
+
419
+ function ViewRemountConsumer() {
420
+ const { isStreaming, activeId, sendMessage } = useChatSession();
421
+ const isStreamingRef = useRef(isStreaming);
422
+ const activeIdRef = useRef(activeId);
423
+ useEffect(() => {
424
+ isStreamingRef.current = isStreaming;
425
+ }, [isStreaming]);
426
+ useEffect(() => {
427
+ activeIdRef.current = activeId;
428
+ }, [activeId]);
429
+ useEffect(() => {
430
+ return () => {
431
+ if (isStreamingRef.current) {
432
+ // eslint-disable-next-line no-console
433
+ console.info("[chat-stream] client.stream.view-remount", {
434
+ conversationId: activeIdRef.current,
435
+ });
436
+ }
437
+ };
438
+ // Empty deps: run-once cleanup on unmount.
439
+ // eslint-disable-next-line react-hooks/exhaustive-deps
440
+ }, []);
441
+ return (
442
+ <div>
443
+ <div data-testid="vr-isStreaming">{String(isStreaming)}</div>
444
+ <button
445
+ data-testid="vr-send"
446
+ onClick={() => void sendMessage("hello")}
447
+ >
448
+ send
449
+ </button>
450
+ </div>
451
+ );
452
+ }
453
+
454
+ function ViewRemountWrapper() {
455
+ const [mounted, setMounted] = useState(true);
456
+ return (
457
+ <ChatSessionProvider>
458
+ <button data-testid="vr-unmount" onClick={() => setMounted(false)}>
459
+ unmount
460
+ </button>
461
+ {mounted && <ViewRemountConsumer />}
462
+ </ChatSessionProvider>
463
+ );
464
+ }
465
+
466
+ const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
467
+ const u = url.toString();
468
+ if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
469
+ if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
470
+ if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
471
+ return new Response(
472
+ JSON.stringify({
473
+ id: "conv-vr",
474
+ projectId: null,
475
+ title: "T",
476
+ status: "active",
477
+ runtimeId: "claude-code",
478
+ modelId: "haiku",
479
+ createdAt: new Date().toISOString(),
480
+ updatedAt: new Date().toISOString(),
481
+ }),
482
+ { status: 200 }
483
+ );
484
+ }
485
+ if (u.match(/\/api\/chat\/conversations\/conv-vr\/messages$/)) {
486
+ const signal = init?.signal as AbortSignal;
487
+ return new Response(makeHangingStream(signal), { status: 200 });
488
+ }
489
+ return new Response(null, { status: 404 });
490
+ });
491
+ vi.stubGlobal("fetch", fetchMock);
492
+
493
+ render(<ViewRemountWrapper />);
494
+
495
+ await act(async () => {
496
+ screen.getByTestId("vr-send").click();
497
+ });
498
+
499
+ await waitFor(() => {
500
+ expect(screen.getByTestId("vr-isStreaming").textContent).toBe("true");
501
+ });
502
+
503
+ // Unmount the consumer while streaming is in flight.
504
+ await act(async () => {
505
+ screen.getByTestId("vr-unmount").click();
506
+ });
507
+
508
+ expect(consoleInfoSpy).toHaveBeenCalledWith(
509
+ "[chat-stream] client.stream.view-remount",
510
+ expect.objectContaining({ conversationId: "conv-vr" })
511
+ );
512
+
513
+ consoleInfoSpy.mockRestore();
514
+ });
515
+
516
+ it("view-remount telemetry pattern does NOT log when not streaming", async () => {
517
+ // Guard case: unmounting without an active stream must not emit.
518
+ const consoleInfoSpy = vi
519
+ .spyOn(console, "info")
520
+ .mockImplementation(() => {});
521
+
522
+ function ViewRemountConsumer() {
523
+ const { isStreaming } = useChatSession();
524
+ const isStreamingRef = useRef(isStreaming);
525
+ useEffect(() => {
526
+ isStreamingRef.current = isStreaming;
527
+ }, [isStreaming]);
528
+ useEffect(() => {
529
+ return () => {
530
+ if (isStreamingRef.current) {
531
+ // eslint-disable-next-line no-console
532
+ console.info("[chat-stream] client.stream.view-remount", {
533
+ conversationId: null,
534
+ });
535
+ }
536
+ };
537
+ // eslint-disable-next-line react-hooks/exhaustive-deps
538
+ }, []);
539
+ return <div />;
540
+ }
541
+
542
+ function Wrapper() {
543
+ const [mounted, setMounted] = useState(true);
544
+ return (
545
+ <ChatSessionProvider>
546
+ <button data-testid="toggle" onClick={() => setMounted(false)}>
547
+ toggle
548
+ </button>
549
+ {mounted && <ViewRemountConsumer />}
550
+ </ChatSessionProvider>
551
+ );
552
+ }
553
+
554
+ vi.stubGlobal(
555
+ "fetch",
556
+ vi.fn(async () => new Response(null, { status: 204 }))
557
+ );
558
+
559
+ render(<Wrapper />);
560
+
561
+ await act(async () => {
562
+ screen.getByTestId("toggle").click();
563
+ });
564
+
565
+ const viewRemountCalls = consoleInfoSpy.mock.calls.filter(
566
+ ([msg]) =>
567
+ typeof msg === "string" && msg.includes("client.stream.view-remount")
568
+ );
569
+ expect(viewRemountCalls).toHaveLength(0);
570
+
571
+ consoleInfoSpy.mockRestore();
572
+ });
408
573
  });
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { Command, CommandList } from "@/components/ui/command";
4
+ import { SkillRow } from "../skill-row";
5
+ import type { EnrichedSkill } from "@/lib/environment/skill-enrichment";
6
+
7
+ const base: EnrichedSkill = {
8
+ id: "code-reviewer",
9
+ name: "code-reviewer",
10
+ tool: "claude-code",
11
+ scope: "user",
12
+ preview: "Review PRs for security",
13
+ sizeBytes: 100,
14
+ absPath: "/p",
15
+ absPaths: ["/p"],
16
+ healthScore: "healthy",
17
+ syncStatus: "synced",
18
+ linkedProfileId: "code-reviewer-profile",
19
+ };
20
+
21
+ // SkillRow uses CommandItem, which must live inside a cmdk Command/CommandList
22
+ function renderRow(skill: EnrichedSkill, recommended = false) {
23
+ return render(
24
+ <Command>
25
+ <CommandList>
26
+ <SkillRow skill={skill} recommended={recommended} onSelect={() => {}} />
27
+ </CommandList>
28
+ </Command>
29
+ );
30
+ }
31
+
32
+ describe("SkillRow", () => {
33
+ it("renders skill name and description", () => {
34
+ renderRow(base);
35
+ expect(screen.getByText("code-reviewer")).toBeInTheDocument();
36
+ expect(screen.getByText(/Review PRs/)).toBeInTheDocument();
37
+ });
38
+
39
+ it("shows synced badge when syncStatus is synced", () => {
40
+ renderRow(base);
41
+ expect(screen.getByText(/synced/i)).toBeInTheDocument();
42
+ });
43
+
44
+ it("shows profile linkage badge", () => {
45
+ renderRow(base);
46
+ expect(screen.getByText(/code-reviewer-profile/)).toBeInTheDocument();
47
+ });
48
+
49
+ it("shows 'stale' badge for stale health", () => {
50
+ renderRow({ ...base, healthScore: "stale" });
51
+ expect(screen.getByText(/stale/i)).toBeInTheDocument();
52
+ });
53
+
54
+ it("shows a recommended indicator when recommended=true", () => {
55
+ renderRow(base, true);
56
+ expect(screen.getByLabelText(/recommended/i)).toBeInTheDocument();
57
+ });
58
+
59
+ it("calls onDismissRecommendation when X is clicked", () => {
60
+ const onDismiss = vi.fn();
61
+ render(
62
+ <Command>
63
+ <CommandList>
64
+ <SkillRow
65
+ skill={base}
66
+ recommended
67
+ onSelect={() => {}}
68
+ onDismissRecommendation={onDismiss}
69
+ />
70
+ </CommandList>
71
+ </Command>
72
+ );
73
+ fireEvent.click(screen.getByLabelText("Dismiss recommendation"));
74
+ expect(onDismiss).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("does not render dismiss button when not recommended", () => {
78
+ render(
79
+ <Command>
80
+ <CommandList>
81
+ <SkillRow
82
+ skill={base}
83
+ onSelect={() => {}}
84
+ onDismissRecommendation={() => {}}
85
+ />
86
+ </CommandList>
87
+ </Command>
88
+ );
89
+ expect(screen.queryByLabelText("Dismiss recommendation")).toBeNull();
90
+ });
91
+ });
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { X } from "lucide-react";
5
+ import { getRuntimeFeatures, type AgentRuntimeId } from "@/lib/agents/runtime/catalog";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface CapabilityBannerProps {
9
+ runtimeId: AgentRuntimeId;
10
+ className?: string;
11
+ }
12
+
13
+ function dismissKey(runtimeId: string): string {
14
+ return `stagent.capability-banner.dismissed.${runtimeId}`;
15
+ }
16
+
17
+ function readDismissed(runtimeId: string): boolean {
18
+ if (typeof window === "undefined") return false;
19
+ try {
20
+ return window.sessionStorage.getItem(dismissKey(runtimeId)) === "1";
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function CapabilityBanner({ runtimeId, className }: CapabilityBannerProps) {
27
+ const [dismissed, setDismissed] = useState<boolean>(() => readDismissed(runtimeId));
28
+
29
+ useEffect(() => {
30
+ setDismissed(readDismissed(runtimeId));
31
+ }, [runtimeId]);
32
+
33
+ const features = getRuntimeFeatures(runtimeId);
34
+ const limited = !features.hasFilesystemTools && !features.hasBash;
35
+
36
+ if (!limited || dismissed) return null;
37
+
38
+ const handleDismiss = () => {
39
+ setDismissed(true);
40
+ try {
41
+ window.sessionStorage.setItem(dismissKey(runtimeId), "1");
42
+ } catch {
43
+ // ignore
44
+ }
45
+ };
46
+
47
+ return (
48
+ <div
49
+ role="status"
50
+ className={cn(
51
+ "flex items-start gap-2 px-4 py-1.5 text-xs text-muted-foreground animate-in fade-in-0",
52
+ className
53
+ )}
54
+ >
55
+ <span className="flex-1">
56
+ Features like file read/write, Bash, and hooks are not available on this runtime. Switch models to use them.
57
+ </span>
58
+ <button
59
+ type="button"
60
+ aria-label="Dismiss capability notice"
61
+ onClick={handleDismiss}
62
+ className="shrink-0 rounded p-0.5 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
63
+ >
64
+ <X className="h-3 w-3" />
65
+ </button>
66
+ </div>
67
+ );
68
+ }