stagent 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -31
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -3
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/book/content-blocks.tsx +1 -1
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/playbook/playbook-detail-view.tsx +1 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +97 -22
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +56 -2
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
|
@@ -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
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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/
|
|
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
|
+
}
|