stagent 0.9.5 → 0.10.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 +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import type { ChatMessageRow } from "@/lib/db/schema";
|
|
6
|
+
import {
|
|
7
|
+
ChatSessionProvider,
|
|
8
|
+
useChatSession,
|
|
9
|
+
} from "@/components/chat/chat-session-provider";
|
|
10
|
+
|
|
11
|
+
// Satisfy the type import linter — we use ChatMessageRow in the Consumer
|
|
12
|
+
// probes below but through inference from session.messages.
|
|
13
|
+
void ({} as ChatMessageRow | undefined);
|
|
14
|
+
|
|
15
|
+
// ── Next.js router mock ──────────────────────────────────────────────
|
|
16
|
+
vi.mock("next/navigation", () => ({
|
|
17
|
+
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// ── Sonner mock ──────────────────────────────────────────────────────
|
|
21
|
+
const toastErrorSpy = vi.fn();
|
|
22
|
+
vi.mock("sonner", () => ({
|
|
23
|
+
toast: {
|
|
24
|
+
error: (...args: unknown[]) => toastErrorSpy(...args),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// ── Test helpers ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Small consumer component that exposes the session value via test ids.
|
|
32
|
+
* Text probes let us assert state without wiring up the full ChatShell.
|
|
33
|
+
*/
|
|
34
|
+
function Consumer({ label }: { label?: string }) {
|
|
35
|
+
const session = useChatSession();
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<div data-testid={`${label ?? "c"}-active`}>{session.activeId ?? ""}</div>
|
|
39
|
+
<div data-testid={`${label ?? "c"}-isStreaming`}>
|
|
40
|
+
{String(session.isStreaming)}
|
|
41
|
+
</div>
|
|
42
|
+
<div data-testid={`${label ?? "c"}-messageCount`}>
|
|
43
|
+
{session.messages.length}
|
|
44
|
+
</div>
|
|
45
|
+
<div data-testid={`${label ?? "c"}-assistantContent`}>
|
|
46
|
+
{session.messages
|
|
47
|
+
.filter((m: ChatMessageRow) => m.role === "assistant")
|
|
48
|
+
.map((m: ChatMessageRow) => m.content)
|
|
49
|
+
.join("|")}
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
data-testid={`${label ?? "c"}-send`}
|
|
53
|
+
onClick={() => void session.sendMessage("hello")}
|
|
54
|
+
>
|
|
55
|
+
send
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
data-testid={`${label ?? "c"}-stop`}
|
|
59
|
+
onClick={() => session.stopStreaming()}
|
|
60
|
+
>
|
|
61
|
+
stop
|
|
62
|
+
</button>
|
|
63
|
+
<button
|
|
64
|
+
data-testid={`${label ?? "c"}-select`}
|
|
65
|
+
onClick={() => session.setActiveConversation("conv-1")}
|
|
66
|
+
>
|
|
67
|
+
select
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
data-testid={`${label ?? "c"}-hydrate`}
|
|
71
|
+
onClick={() =>
|
|
72
|
+
session.hydrate({
|
|
73
|
+
conversations: [
|
|
74
|
+
{
|
|
75
|
+
id: "conv-1",
|
|
76
|
+
projectId: null,
|
|
77
|
+
title: "Test conv",
|
|
78
|
+
status: "active",
|
|
79
|
+
runtimeId: "claude-code",
|
|
80
|
+
modelId: "sonnet",
|
|
81
|
+
createdAt: new Date(),
|
|
82
|
+
updatedAt: new Date(),
|
|
83
|
+
archivedAt: null,
|
|
84
|
+
} as unknown as never,
|
|
85
|
+
],
|
|
86
|
+
initialActiveId: "conv-1",
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
hydrate
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A wrapper that keeps the provider mounted while letting tests mount and
|
|
98
|
+
* unmount a child consumer on demand. This is how we verify that state
|
|
99
|
+
* survives a consumer unmount/remount cycle — the provider is stable, only
|
|
100
|
+
* the child toggles.
|
|
101
|
+
*/
|
|
102
|
+
function ProviderWithToggle() {
|
|
103
|
+
const [show, setShow] = useState(true);
|
|
104
|
+
return (
|
|
105
|
+
<ChatSessionProvider>
|
|
106
|
+
<button data-testid="toggle" onClick={() => setShow((v) => !v)}>
|
|
107
|
+
toggle
|
|
108
|
+
</button>
|
|
109
|
+
<div data-testid="consumer-visible">{String(show)}</div>
|
|
110
|
+
{show && <Consumer />}
|
|
111
|
+
</ChatSessionProvider>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a ReadableStream that emits the given SSE chunks as `data: ...` lines.
|
|
117
|
+
* Each chunk is JSON-serialized and prefixed with `data: ` + newline.
|
|
118
|
+
*/
|
|
119
|
+
function makeSSEStream(
|
|
120
|
+
chunks: unknown[],
|
|
121
|
+
opts: { closeAfterMs?: number } = {}
|
|
122
|
+
): ReadableStream<Uint8Array> {
|
|
123
|
+
const encoder = new TextEncoder();
|
|
124
|
+
return new ReadableStream({
|
|
125
|
+
async start(controller) {
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
const line = `data: ${JSON.stringify(chunk)}\n`;
|
|
128
|
+
controller.enqueue(encoder.encode(line));
|
|
129
|
+
// Tiny yield so React can flush state between chunks.
|
|
130
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
131
|
+
}
|
|
132
|
+
if (opts.closeAfterMs) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, opts.closeAfterMs));
|
|
134
|
+
}
|
|
135
|
+
controller.close();
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a ReadableStream that waits indefinitely (useful for testing
|
|
142
|
+
* abort behavior). Signal-aware: closes early if signal aborts.
|
|
143
|
+
*/
|
|
144
|
+
function makeHangingStream(signal: AbortSignal): ReadableStream<Uint8Array> {
|
|
145
|
+
return new ReadableStream({
|
|
146
|
+
start(controller) {
|
|
147
|
+
signal.addEventListener("abort", () => {
|
|
148
|
+
controller.error(
|
|
149
|
+
Object.assign(new Error("aborted"), { name: "AbortError" })
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Suites ───────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("ChatSessionProvider", () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
toastErrorSpy.mockReset();
|
|
161
|
+
vi.stubGlobal("crypto", {
|
|
162
|
+
randomUUID: () => `uuid-${Math.random().toString(36).slice(2, 10)}`,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
vi.unstubAllGlobals();
|
|
168
|
+
vi.restoreAllMocks();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("sendMessage accumulates SSE deltas into the assistant message", async () => {
|
|
172
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
|
|
173
|
+
const u = url.toString();
|
|
174
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
175
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
176
|
+
if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
|
|
177
|
+
return new Response(
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
id: "conv-new",
|
|
180
|
+
projectId: null,
|
|
181
|
+
title: "New Chat",
|
|
182
|
+
status: "active",
|
|
183
|
+
runtimeId: "claude-code",
|
|
184
|
+
modelId: "haiku",
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
}),
|
|
188
|
+
{ status: 200 }
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (u.match(/\/api\/chat\/conversations\/conv-new\/messages$/)) {
|
|
192
|
+
return new Response(
|
|
193
|
+
makeSSEStream([
|
|
194
|
+
{ type: "delta", content: "Hello" },
|
|
195
|
+
{ type: "delta", content: " world" },
|
|
196
|
+
{ type: "done", messageId: "msg-final", quickAccess: [] },
|
|
197
|
+
]),
|
|
198
|
+
{ status: 200 }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (u.startsWith("/api/chat/conversations/conv-new")) {
|
|
202
|
+
// GET metadata refresh after "done" event
|
|
203
|
+
return new Response(
|
|
204
|
+
JSON.stringify({ id: "conv-new", title: "Auto Title" }),
|
|
205
|
+
{ status: 200 }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return new Response(null, { status: 404 });
|
|
209
|
+
});
|
|
210
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<ChatSessionProvider>
|
|
214
|
+
<Consumer />
|
|
215
|
+
</ChatSessionProvider>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
screen.getByTestId("c-send").click();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
224
|
+
"Hello world"
|
|
225
|
+
);
|
|
226
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("preserves messages across consumer unmount/remount", async () => {
|
|
231
|
+
// Seed state: hydrate with conv-1 (fetch returns empty message list),
|
|
232
|
+
// then send a message and verify it's visible. Then toggle the consumer
|
|
233
|
+
// off and back on and verify the messages are still there.
|
|
234
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
|
|
235
|
+
const u = url.toString();
|
|
236
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
237
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
238
|
+
if (u.match(/\/api\/chat\/conversations\/conv-1\/messages$/)) {
|
|
239
|
+
// Support both GET (select refresh) and POST (send)
|
|
240
|
+
// We can distinguish in a real test but here both return empty/delta
|
|
241
|
+
// If POST, return the SSE stream. Differentiate by checking if there's a body.
|
|
242
|
+
return new Response(
|
|
243
|
+
makeSSEStream([
|
|
244
|
+
{ type: "delta", content: "persisted" },
|
|
245
|
+
{ type: "done", messageId: "msg-a", quickAccess: [] },
|
|
246
|
+
]),
|
|
247
|
+
{ status: 200 }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (u.startsWith("/api/chat/conversations/conv-1")) {
|
|
251
|
+
return new Response(
|
|
252
|
+
JSON.stringify({ id: "conv-1", title: "T" }),
|
|
253
|
+
{ status: 200 }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return new Response(null, { status: 404 });
|
|
257
|
+
});
|
|
258
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
259
|
+
|
|
260
|
+
render(<ProviderWithToggle />);
|
|
261
|
+
|
|
262
|
+
// Hydrate (sets conv-1 as active) and select
|
|
263
|
+
await act(async () => {
|
|
264
|
+
screen.getByTestId("c-hydrate").click();
|
|
265
|
+
});
|
|
266
|
+
await act(async () => {
|
|
267
|
+
screen.getByTestId("c-send").click();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
272
|
+
"persisted"
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Unmount the consumer
|
|
277
|
+
await act(async () => {
|
|
278
|
+
screen.getByTestId("toggle").click();
|
|
279
|
+
});
|
|
280
|
+
expect(screen.queryByTestId("c-assistantContent")).toBeNull();
|
|
281
|
+
expect(screen.getByTestId("consumer-visible").textContent).toBe("false");
|
|
282
|
+
|
|
283
|
+
// Remount the consumer — provider state should still be there
|
|
284
|
+
await act(async () => {
|
|
285
|
+
screen.getByTestId("toggle").click();
|
|
286
|
+
});
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
289
|
+
"persisted"
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("selectConversation fetch failure calls toast.error and does not clear state", async () => {
|
|
295
|
+
// The bug this test pins down: `handleSelectConversation`'s old catch
|
|
296
|
+
// block was `setMessages([])`, which wiped all prior turns on any
|
|
297
|
+
// fetch hiccup. The fix: on failure, call toast.error and leave
|
|
298
|
+
// messagesByConversation untouched.
|
|
299
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
300
|
+
const u = url.toString();
|
|
301
|
+
const method = init?.method ?? "GET";
|
|
302
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
303
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
304
|
+
if (u.match(/\/api\/chat\/conversations\/conv-missing\/messages$/) && method === "GET") {
|
|
305
|
+
return new Response("boom", { status: 500 });
|
|
306
|
+
}
|
|
307
|
+
if (u.startsWith("/api/chat/conversations/conv-missing")) {
|
|
308
|
+
return new Response(JSON.stringify({ id: "conv-missing" }), { status: 200 });
|
|
309
|
+
}
|
|
310
|
+
return new Response(null, { status: 404 });
|
|
311
|
+
});
|
|
312
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
313
|
+
|
|
314
|
+
// Custom consumer that exposes a button to select a specific (failing) conversation
|
|
315
|
+
function FailingSelectConsumer() {
|
|
316
|
+
const session = useChatSession();
|
|
317
|
+
return (
|
|
318
|
+
<div>
|
|
319
|
+
<div data-testid="cache-keys">
|
|
320
|
+
{Object.keys(session.conversations.length ? { placeholder: 1 } : {}).join(",")}
|
|
321
|
+
</div>
|
|
322
|
+
<button
|
|
323
|
+
data-testid="select-failing"
|
|
324
|
+
onClick={() => {
|
|
325
|
+
// Directly call setActiveConversation with an id that has no
|
|
326
|
+
// cache entry — this triggers loadMessagesForConversation,
|
|
327
|
+
// which will hit the failing mock.
|
|
328
|
+
session.setActiveConversation("conv-missing");
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
select failing
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
render(
|
|
338
|
+
<ChatSessionProvider>
|
|
339
|
+
<FailingSelectConsumer />
|
|
340
|
+
</ChatSessionProvider>
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
await act(async () => {
|
|
344
|
+
screen.getByTestId("select-failing").click();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// The fetch fails → toast.error must be called. Prior to the fix,
|
|
348
|
+
// the code would have called `setMessages([])`. Now it calls toast and
|
|
349
|
+
// leaves state alone.
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(toastErrorSpy).toHaveBeenCalledWith(
|
|
352
|
+
"Failed to load conversation messages"
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("stopStreaming aborts an in-flight stream", async () => {
|
|
358
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
359
|
+
const u = url.toString();
|
|
360
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
361
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
362
|
+
if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
|
|
363
|
+
return new Response(
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
id: "conv-abort",
|
|
366
|
+
projectId: null,
|
|
367
|
+
title: "T",
|
|
368
|
+
status: "active",
|
|
369
|
+
runtimeId: "claude-code",
|
|
370
|
+
modelId: "haiku",
|
|
371
|
+
createdAt: new Date().toISOString(),
|
|
372
|
+
updatedAt: new Date().toISOString(),
|
|
373
|
+
}),
|
|
374
|
+
{ status: 200 }
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (u.match(/\/api\/chat\/conversations\/conv-abort\/messages$/)) {
|
|
378
|
+
const signal = init?.signal as AbortSignal;
|
|
379
|
+
return new Response(makeHangingStream(signal), { status: 200 });
|
|
380
|
+
}
|
|
381
|
+
return new Response(null, { status: 404 });
|
|
382
|
+
});
|
|
383
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
384
|
+
|
|
385
|
+
render(
|
|
386
|
+
<ChatSessionProvider>
|
|
387
|
+
<Consumer />
|
|
388
|
+
</ChatSessionProvider>
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await act(async () => {
|
|
392
|
+
screen.getByTestId("c-send").click();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Give the fetch a microtask to kick off
|
|
396
|
+
await waitFor(() => {
|
|
397
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("true");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await act(async () => {
|
|
401
|
+
screen.getByTestId("c-stop").click();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await waitFor(() => {
|
|
405
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -184,8 +184,8 @@ function ToolCatalogItems({
|
|
|
184
184
|
text: entry.behavior === "execute_immediately"
|
|
185
185
|
? entry.name
|
|
186
186
|
: entry.group === "Skills"
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
? `Use the ${entry.name} profile: `
|
|
188
|
+
: `Use ${entry.name} to `,
|
|
189
189
|
})
|
|
190
190
|
}
|
|
191
191
|
>
|
|
@@ -9,6 +9,7 @@ import { ChatCommandPopover } from "./chat-command-popover";
|
|
|
9
9
|
import { useChatAutocomplete, type MentionReference } from "@/hooks/use-chat-autocomplete";
|
|
10
10
|
import { getToolCatalog } from "@/lib/chat/tool-catalog";
|
|
11
11
|
import { useProjectSkills } from "@/hooks/use-project-skills";
|
|
12
|
+
import { toggleTheme } from "@/lib/theme";
|
|
12
13
|
import type { ChatModelOption } from "@/lib/chat/types";
|
|
13
14
|
|
|
14
15
|
interface ChatInputProps {
|
|
@@ -112,9 +113,7 @@ export function ChatInput({
|
|
|
112
113
|
if (entry?.behavior === "execute_immediately") {
|
|
113
114
|
autocomplete.close();
|
|
114
115
|
if (entry.name === "toggle_theme") {
|
|
115
|
-
|
|
116
|
-
document.documentElement.classList.toggle("dark");
|
|
117
|
-
localStorage.setItem("stagent-theme", isDark ? "light" : "dark");
|
|
116
|
+
toggleTheme();
|
|
118
117
|
} else if (entry.name === "mark_all_read") {
|
|
119
118
|
fetch("/api/notifications/mark-all-read", { method: "PATCH" });
|
|
120
119
|
}
|