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
package/src/lib/chat/engine.ts
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
updateConversation,
|
|
22
22
|
} from "@/lib/data/chat";
|
|
23
23
|
import { buildChatContext, type MentionReference } from "./context-builder";
|
|
24
|
+
import { finalizeStreamingMessage } from "./reconcile";
|
|
25
|
+
import { recordTermination } from "./stream-telemetry";
|
|
26
|
+
import { registerChatStream, unregisterChatStream } from "./active-streams";
|
|
24
27
|
import {
|
|
25
28
|
detectEntities,
|
|
26
29
|
extractToolResultEntities,
|
|
@@ -250,6 +253,8 @@ export async function* sendMessage(
|
|
|
250
253
|
status: "streaming",
|
|
251
254
|
});
|
|
252
255
|
|
|
256
|
+
registerChatStream(conversationId);
|
|
257
|
+
|
|
253
258
|
// Create side channel for canUseTool → SSE bridge communication
|
|
254
259
|
const sideChannel = createSideChannel(conversationId);
|
|
255
260
|
|
|
@@ -636,6 +641,13 @@ export async function* sendMessage(
|
|
|
636
641
|
finishedAt: new Date(),
|
|
637
642
|
});
|
|
638
643
|
|
|
644
|
+
recordTermination({
|
|
645
|
+
reason: "stream.completed",
|
|
646
|
+
conversationId,
|
|
647
|
+
messageId: assistantMsg.id,
|
|
648
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
649
|
+
});
|
|
650
|
+
|
|
639
651
|
yield {
|
|
640
652
|
type: "done",
|
|
641
653
|
messageId: assistantMsg.id,
|
|
@@ -647,7 +659,27 @@ export async function* sendMessage(
|
|
|
647
659
|
|
|
648
660
|
// Enrich the error with stderr diagnostics when available
|
|
649
661
|
const stderrTail = stderrChunks.join("").trim();
|
|
650
|
-
const
|
|
662
|
+
const rawErrorMessage = diagnoseProcessError(rawMessage, stderrTail);
|
|
663
|
+
// Truncate at 4KB to prevent multi-MB stderr dumps bloating chat_messages
|
|
664
|
+
const errorMessage =
|
|
665
|
+
rawErrorMessage.length > 4096
|
|
666
|
+
? rawErrorMessage.slice(0, 4096) + "... (truncated)"
|
|
667
|
+
: rawErrorMessage;
|
|
668
|
+
|
|
669
|
+
// Telemetry: record BEFORE the yield below. If this code is reached
|
|
670
|
+
// via iterator abandonment (consumer broke the for-await and the
|
|
671
|
+
// generator's own yield throws GeneratorReturn), control would skip
|
|
672
|
+
// past any post-yield statement. Recording up front guarantees the
|
|
673
|
+
// event lands in the ring buffer regardless of whether the yield
|
|
674
|
+
// completes or aborts. Matches the same invariant we rely on for
|
|
675
|
+
// the success-path recordTermination before the done yield.
|
|
676
|
+
recordTermination({
|
|
677
|
+
reason: signal?.aborted ? "stream.aborted.signal" : "stream.finalized.error",
|
|
678
|
+
conversationId,
|
|
679
|
+
messageId: assistantMsg.id,
|
|
680
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
681
|
+
error: errorMessage.slice(0, 500),
|
|
682
|
+
});
|
|
651
683
|
|
|
652
684
|
if (fullText && fullText.length > 50) {
|
|
653
685
|
// Substantial content was already streamed — complete gracefully with warning
|
|
@@ -674,10 +706,14 @@ export async function* sendMessage(
|
|
|
674
706
|
|
|
675
707
|
yield { type: "done", messageId: assistantMsg.id, quickAccess: [] };
|
|
676
708
|
} else {
|
|
677
|
-
// No meaningful content — show as error
|
|
709
|
+
// No meaningful content — show as error. Fallback chain ensures we
|
|
710
|
+
// never write an empty string even if both fullText and errorMessage
|
|
711
|
+
// happen to be blank.
|
|
678
712
|
await updateMessageContent(
|
|
679
713
|
assistantMsg.id,
|
|
680
|
-
fullText ||
|
|
714
|
+
fullText ||
|
|
715
|
+
errorMessage ||
|
|
716
|
+
"(Response failed — no error detail available.)"
|
|
681
717
|
);
|
|
682
718
|
await updateMessageStatus(assistantMsg.id, "error");
|
|
683
719
|
|
|
@@ -698,6 +734,17 @@ export async function* sendMessage(
|
|
|
698
734
|
yield { type: "error", message: errorMessage };
|
|
699
735
|
}
|
|
700
736
|
} finally {
|
|
737
|
+
// Safety net: guarantee the placeholder row never remains in
|
|
738
|
+
// status='streaming' after the generator exits. Catches code paths that
|
|
739
|
+
// bypass the catch block — most notably async iterator abandonment, where
|
|
740
|
+
// a consumer `break`ing out of a `for await` loop triggers the generator's
|
|
741
|
+
// return() method and jumps straight here, skipping catch entirely.
|
|
742
|
+
try {
|
|
743
|
+
await finalizeStreamingMessage(assistantMsg.id, fullText);
|
|
744
|
+
} catch (finalizeErr) {
|
|
745
|
+
console.error("[chat] finalize safety net failed:", finalizeErr);
|
|
746
|
+
}
|
|
747
|
+
unregisterChatStream(conversationId);
|
|
701
748
|
cleanupConversation(conversationId);
|
|
702
749
|
}
|
|
703
750
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import { chatMessages } from "@/lib/db/schema";
|
|
3
|
+
import { and, eq, lt } from "drizzle-orm";
|
|
4
|
+
import { recordTermination } from "./stream-telemetry";
|
|
5
|
+
|
|
6
|
+
const INTERRUPTED_FALLBACK =
|
|
7
|
+
"(Response interrupted. Please try again.)";
|
|
8
|
+
|
|
9
|
+
const ORPHAN_FALLBACK =
|
|
10
|
+
"(Interrupted — this response was not completed. Please retry.)";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Safety-net finalizer called from the chat engine's top-level `finally` block.
|
|
14
|
+
*
|
|
15
|
+
* Guarantees the invariant: no `chat_messages` row remains in
|
|
16
|
+
* `status='streaming'` after `sendMessage()` returns or throws. Catches every
|
|
17
|
+
* code path the engine's own `catch` block misses — most notably async
|
|
18
|
+
* iterator abandonment, where a consumer `break`ing out of a `for await` loop
|
|
19
|
+
* triggers the generator's `return()` method and jumps straight to `finally`,
|
|
20
|
+
* skipping `catch` entirely.
|
|
21
|
+
*
|
|
22
|
+
* No-op if the message is already in a terminal state. Idempotent.
|
|
23
|
+
*
|
|
24
|
+
* When the salvage path actually fires (row was still in streaming → now
|
|
25
|
+
* updated to complete/error), records a `stream.abandoned` telemetry event
|
|
26
|
+
* so maintainers can see that the engine's own happy/catch paths both
|
|
27
|
+
* missed the termination. A non-zero count here signals a real gap that
|
|
28
|
+
* may warrant investigation — e.g., the dev HMR interrupting a stream,
|
|
29
|
+
* or a consumer break pattern that bypasses the telemetry in both primary
|
|
30
|
+
* code paths.
|
|
31
|
+
*/
|
|
32
|
+
export async function finalizeStreamingMessage(
|
|
33
|
+
messageId: string,
|
|
34
|
+
fullText: string,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const current = db
|
|
37
|
+
.select()
|
|
38
|
+
.from(chatMessages)
|
|
39
|
+
.where(eq(chatMessages.id, messageId))
|
|
40
|
+
.get();
|
|
41
|
+
|
|
42
|
+
if (!current || current.status !== "streaming") {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasContent = fullText && fullText.trim().length > 0;
|
|
47
|
+
const salvage = hasContent ? fullText : INTERRUPTED_FALLBACK;
|
|
48
|
+
const nextStatus = hasContent && fullText.length > 50 ? "complete" : "error";
|
|
49
|
+
|
|
50
|
+
db.update(chatMessages)
|
|
51
|
+
.set({ status: nextStatus, content: salvage })
|
|
52
|
+
.where(eq(chatMessages.id, messageId))
|
|
53
|
+
.run();
|
|
54
|
+
|
|
55
|
+
// Telemetry: this code path means neither stream.completed nor the
|
|
56
|
+
// engine's catch-block recordTermination fired. Capture the gap so
|
|
57
|
+
// the diagnostics endpoint can surface it.
|
|
58
|
+
recordTermination({
|
|
59
|
+
reason: "stream.abandoned",
|
|
60
|
+
conversationId: current.conversationId ?? null,
|
|
61
|
+
messageId,
|
|
62
|
+
durationMs: current.createdAt
|
|
63
|
+
? Date.now() - new Date(current.createdAt).getTime()
|
|
64
|
+
: null,
|
|
65
|
+
error: hasContent ? undefined : "no content streamed before abandonment",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sweep orphaned chat assistant messages left in `status='streaming'` past a
|
|
71
|
+
* reasonable cutoff. These rows are produced when the chat engine's finally
|
|
72
|
+
* block is bypassed (process crash, iterator abandonment under heavy load,
|
|
73
|
+
* HTTP disconnect before update commits, etc.).
|
|
74
|
+
*
|
|
75
|
+
* Safe to call idempotently at chat page load. Uses a 10-minute cutoff — far
|
|
76
|
+
* longer than any legitimate in-flight streaming response — so in-flight rows
|
|
77
|
+
* are never clobbered.
|
|
78
|
+
*
|
|
79
|
+
* Returns the number of rows swept. Never throws.
|
|
80
|
+
*/
|
|
81
|
+
export async function reconcileStreamingMessages(): Promise<number> {
|
|
82
|
+
const cutoff = new Date(Date.now() - 10 * 60 * 1000);
|
|
83
|
+
const orphans = db
|
|
84
|
+
.select()
|
|
85
|
+
.from(chatMessages)
|
|
86
|
+
.where(
|
|
87
|
+
and(
|
|
88
|
+
eq(chatMessages.status, "streaming"),
|
|
89
|
+
lt(chatMessages.createdAt, cutoff),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
.all();
|
|
93
|
+
|
|
94
|
+
for (const row of orphans) {
|
|
95
|
+
const salvage =
|
|
96
|
+
row.content && row.content.length > 0 ? row.content : ORPHAN_FALLBACK;
|
|
97
|
+
db.update(chatMessages)
|
|
98
|
+
.set({ status: "error", content: salvage })
|
|
99
|
+
.where(eq(chatMessages.id, row.id))
|
|
100
|
+
.run();
|
|
101
|
+
|
|
102
|
+
// Telemetry: record the orphan sweep so diagnostics can tell how often
|
|
103
|
+
// the safety net actually fires vs. how often the normal finalize path
|
|
104
|
+
// catches everything first. If this code ever logs a row, the engine's
|
|
105
|
+
// `finally` block missed it.
|
|
106
|
+
recordTermination({
|
|
107
|
+
reason: "stream.reconciled.stale",
|
|
108
|
+
conversationId: row.conversationId ?? null,
|
|
109
|
+
messageId: row.id,
|
|
110
|
+
durationMs: row.createdAt
|
|
111
|
+
? Date.now() - new Date(row.createdAt).getTime()
|
|
112
|
+
: null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return orphans.length;
|
|
117
|
+
}
|
|
@@ -22,6 +22,7 @@ import { handoffTools } from "./tools/handoff-tools";
|
|
|
22
22
|
import { tableTools } from "./tools/table-tools";
|
|
23
23
|
import { runtimeTools } from "./tools/runtime-tools";
|
|
24
24
|
|
|
25
|
+
|
|
25
26
|
// ── Tool server types ────────────────────────────────────────────────
|
|
26
27
|
|
|
27
28
|
export interface ProviderToolKit {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat stream termination telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, in-memory ring buffer that records how SSE chat streams
|
|
5
|
+
* terminate. Added in response to a sibling-repo bug report claiming
|
|
6
|
+
* conversations refresh mid-stream — the proposed root cause (Next.js dev
|
|
7
|
+
* HMR remounting ChatShell) is already mitigated in this repo, so rather
|
|
8
|
+
* than port a speculative fix, we instrument the termination boundaries
|
|
9
|
+
* and let real data decide whether a resume protocol is worth building.
|
|
10
|
+
*
|
|
11
|
+
* Six server-side reason codes:
|
|
12
|
+
* - stream.completed — normal end-of-generator (success path)
|
|
13
|
+
* - stream.aborted.signal — req.signal fired, engine catch block entered
|
|
14
|
+
* - stream.aborted.client — ReadableStream cancel callback fired
|
|
15
|
+
* - stream.finalized.error — non-abort exception in engine catch block
|
|
16
|
+
* - stream.abandoned — generator return() called by consumer
|
|
17
|
+
* (finally ran but catch was skipped). Covers
|
|
18
|
+
* iterator abandonment — the case where the
|
|
19
|
+
* route's for-await breaks out gracefully and
|
|
20
|
+
* the engine's own happy/catch paths are both
|
|
21
|
+
* bypassed. Recorded from finalizeStreamingMessage
|
|
22
|
+
* when it actually performs a salvage update.
|
|
23
|
+
* - stream.reconciled.stale — reconcileStreamingMessages swept an orphan
|
|
24
|
+
* at chat page load (10-min cutoff)
|
|
25
|
+
*
|
|
26
|
+
* Three client-side reason codes (logged via console.info with a stable
|
|
27
|
+
* prefix so tests and grep can find them):
|
|
28
|
+
* - client.stream.done — reader.read() returned done: true
|
|
29
|
+
* - client.stream.user-abort — user clicked Stop / AbortController fired
|
|
30
|
+
* - client.stream.reader-error — reader.read() or decode threw
|
|
31
|
+
*
|
|
32
|
+
* As of the `chat-session-persistence-provider` feature, the SSE reader
|
|
33
|
+
* loop runs inside `ChatSessionProvider` (rendered from the root layout),
|
|
34
|
+
* not inside the route-scoped `ChatShell`. Sidebar navigation no longer
|
|
35
|
+
* tears down the reader loop, so "client.stream.user-abort" should only
|
|
36
|
+
* fire when the user explicitly clicks Stop. If it starts firing on plain
|
|
37
|
+
* view switches again, something has regressed the provider hoisting.
|
|
38
|
+
* HMR in dev can still reset the provider module — that is expected.
|
|
39
|
+
*
|
|
40
|
+
* Read via the dev-only `GET /api/diagnostics/chat-streams` endpoint.
|
|
41
|
+
* The buffer is process-local — a server restart clears it, which is fine
|
|
42
|
+
* for dev diagnostics and avoids adding a persistence layer that would
|
|
43
|
+
* itself need testing.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type TerminationReason =
|
|
47
|
+
| "stream.completed"
|
|
48
|
+
| "stream.aborted.signal"
|
|
49
|
+
| "stream.aborted.client"
|
|
50
|
+
| "stream.finalized.error"
|
|
51
|
+
| "stream.abandoned"
|
|
52
|
+
| "stream.reconciled.stale";
|
|
53
|
+
|
|
54
|
+
export interface TerminationEvent {
|
|
55
|
+
reason: TerminationReason;
|
|
56
|
+
conversationId: string | null;
|
|
57
|
+
messageId: string | null;
|
|
58
|
+
durationMs: number | null;
|
|
59
|
+
error?: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ring buffer capacity — ~500 events is ~50KB, negligible for a dev tool. */
|
|
64
|
+
const CAPACITY = 500;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Module-level circular buffer. Newer events overwrite older ones once
|
|
68
|
+
* capacity is reached. Writes are O(1), reads copy-out in order.
|
|
69
|
+
*
|
|
70
|
+
* Next.js dev HMR may re-import this module and reset the buffer — that
|
|
71
|
+
* is expected behavior and not a bug. The buffer is intentionally not
|
|
72
|
+
* persisted; its purpose is "what happened in the last N minutes of this
|
|
73
|
+
* process", not forensic logging.
|
|
74
|
+
*/
|
|
75
|
+
const buffer: TerminationEvent[] = new Array(CAPACITY);
|
|
76
|
+
let writeIndex = 0;
|
|
77
|
+
let writeCount = 0;
|
|
78
|
+
|
|
79
|
+
export function recordTermination(event: Omit<TerminationEvent, "timestamp">): void {
|
|
80
|
+
const full: TerminationEvent = { ...event, timestamp: Date.now() };
|
|
81
|
+
buffer[writeIndex] = full;
|
|
82
|
+
writeIndex = (writeIndex + 1) % CAPACITY;
|
|
83
|
+
writeCount++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return all recorded events in chronological order (oldest → newest).
|
|
88
|
+
* Copies out of the ring buffer so callers can't mutate internal state.
|
|
89
|
+
*/
|
|
90
|
+
export function readTerminations(): TerminationEvent[] {
|
|
91
|
+
const count = Math.min(writeCount, CAPACITY);
|
|
92
|
+
if (count === 0) return [];
|
|
93
|
+
const result: TerminationEvent[] = new Array(count);
|
|
94
|
+
// Start at the oldest slot. When the buffer is full, the oldest is at
|
|
95
|
+
// writeIndex (the next slot to be overwritten). When not full, it's at 0.
|
|
96
|
+
const start = writeCount > CAPACITY ? writeIndex : 0;
|
|
97
|
+
for (let i = 0; i < count; i++) {
|
|
98
|
+
result[i] = buffer[(start + i) % CAPACITY]!;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Aggregate event counts by reason code for the last `windowMs` milliseconds.
|
|
105
|
+
* Pass 0 or omit to get counts across the entire buffer.
|
|
106
|
+
*/
|
|
107
|
+
export function countTerminations(windowMs = 0): Record<TerminationReason, number> {
|
|
108
|
+
const counts: Record<TerminationReason, number> = {
|
|
109
|
+
"stream.completed": 0,
|
|
110
|
+
"stream.aborted.signal": 0,
|
|
111
|
+
"stream.aborted.client": 0,
|
|
112
|
+
"stream.finalized.error": 0,
|
|
113
|
+
"stream.abandoned": 0,
|
|
114
|
+
"stream.reconciled.stale": 0,
|
|
115
|
+
};
|
|
116
|
+
const cutoff = windowMs > 0 ? Date.now() - windowMs : 0;
|
|
117
|
+
for (const event of readTerminations()) {
|
|
118
|
+
if (event.timestamp >= cutoff) {
|
|
119
|
+
counts[event.reason]++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return counts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reset the buffer. Intended for tests — do not call in production code.
|
|
127
|
+
*/
|
|
128
|
+
export function __resetForTesting(): void {
|
|
129
|
+
for (let i = 0; i < CAPACITY; i++) buffer[i] = undefined as never;
|
|
130
|
+
writeIndex = 0;
|
|
131
|
+
writeCount = 0;
|
|
132
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { db } from "@/lib/db";
|
|
2
|
-
import { projects, tasks,
|
|
2
|
+
import { projects, tasks, schedules, userTables } from "@/lib/db/schema";
|
|
3
3
|
import { eq, desc } from "drizzle-orm";
|
|
4
4
|
import type { PromptCategory, SuggestedPrompt } from "./types";
|
|
5
5
|
|
|
@@ -40,6 +40,25 @@ async function buildExplorePrompts(): Promise<SuggestedPrompt[]> {
|
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Context-sensitive suggestion: if any user tables exist, surface an
|
|
44
|
+
// enrichment prompt for the most recently updated one. Users commonly have
|
|
45
|
+
// tables with empty cells waiting to be enriched; even without a column-level
|
|
46
|
+
// scan, pointing the chat LLM at enrich_table makes the bulk-row fan-out
|
|
47
|
+
// capability discoverable via suggested prompts rather than only via direct
|
|
48
|
+
// intent.
|
|
49
|
+
const recentTable = await db
|
|
50
|
+
.select({ id: userTables.id, name: userTables.name })
|
|
51
|
+
.from(userTables)
|
|
52
|
+
.orderBy(desc(userTables.updatedAt))
|
|
53
|
+
.limit(1);
|
|
54
|
+
|
|
55
|
+
if (recentTable.length > 0) {
|
|
56
|
+
prompts.push({
|
|
57
|
+
label: `Enrich "${truncate(recentTable[0].name, 28)}" rows`,
|
|
58
|
+
prompt: `I'd like to enrich rows in the "${recentTable[0].name}" table (id: ${recentTable[0].id}) using an agent. Ask me which column is missing data, what prompt template to use (reference row fields naturally), and which agent profile is best. Then use enrich_table to kick off the loop workflow.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// Fill with static fallbacks
|
|
44
63
|
const fallbacks: SuggestedPrompt[] = [
|
|
45
64
|
{
|
|
@@ -76,6 +95,14 @@ function buildCreatePrompts(): SuggestedPrompt[] {
|
|
|
76
95
|
label: "Set up a multi-step workflow",
|
|
77
96
|
prompt: "Help me design a multi-step workflow. I want to define a sequence of tasks with dependencies. Ask me what the workflow should accomplish and suggest a structure.",
|
|
78
97
|
},
|
|
98
|
+
{
|
|
99
|
+
label: "Design a drip sequence",
|
|
100
|
+
prompt: "Help me build a drip workflow with delay steps between sends. Ask me about the cadence (e.g. 3 days between touches), the number of touches, and the content goal for each step. Then use create_workflow with a sequence pattern, interleaving task steps and delay steps (delayDuration format: Nm|Nh|Nd|Nw, bounds 1m..30d). Do not create separate workflows or schedules — a single workflow with inline delay steps is the idiomatic pattern.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
label: "Enrich a table with an agent",
|
|
104
|
+
prompt: "I have a table with missing data that I want an agent to fill in. Help me use enrich_table to fan out rows to the agent. Ask me which table, which column is missing, what prompt template to use (the row is available as JSON context — tell the agent to read the relevant fields and return just the value, or NOT_FOUND if none can be determined), and which agent profile is best (sales-researcher, content-creator, data-analyst, etc.). Do not hand-roll a loop workflow — enrich_table already handles the loop, row binding, postAction writeback, and idempotent skip.",
|
|
105
|
+
},
|
|
79
106
|
{
|
|
80
107
|
label: "Draft a document outline",
|
|
81
108
|
prompt: "Help me create a structured document outline. Ask me about the topic, audience, and purpose, then suggest sections and key points to cover.",
|
|
@@ -21,11 +21,12 @@ export const STAGENT_SYSTEM_PROMPT = `You are Stagent, an AI workspace assistant
|
|
|
21
21
|
|
|
22
22
|
### Workflows
|
|
23
23
|
- list_workflows: List all workflows
|
|
24
|
-
- create_workflow: Create a multi-step workflow with a definition
|
|
24
|
+
- create_workflow: Create a multi-step workflow with a definition. Steps can be task steps (profile + prompt) or **delay steps** (delayDuration like '3d', '2h', '30m', '1w') that pause the workflow before the next step. Delay steps enable time-distributed sequences.
|
|
25
25
|
- get_workflow: Get workflow details and definition
|
|
26
26
|
- update_workflow: Update a draft workflow
|
|
27
27
|
- delete_workflow: Delete a workflow and its children [requires approval]
|
|
28
28
|
- execute_workflow: Start workflow execution [requires approval]
|
|
29
|
+
- resume_workflow: Resume a paused (delayed) workflow immediately instead of waiting for its scheduled resume time [requires approval]
|
|
29
30
|
- get_workflow_status: Get current execution status with step progress
|
|
30
31
|
- find_related_documents: Search the project document pool for documents to attach as workflow context
|
|
31
32
|
|
|
@@ -58,9 +59,30 @@ export const STAGENT_SYSTEM_PROMPT = `You are Stagent, an AI workspace assistant
|
|
|
58
59
|
- get_usage_summary: Get token and cost statistics over a time period
|
|
59
60
|
- get_settings: Read current configuration (auth method, budgets, runtime)
|
|
60
61
|
|
|
62
|
+
### Tables
|
|
63
|
+
Structured user data lives in Stagent tables (separate from Stagent's own internal records). Every table tool takes a tableId; use list_tables or search_table to find them first.
|
|
64
|
+
- list_tables: List all user tables in a project
|
|
65
|
+
- get_table_schema: Get a table's columns, types, and metadata
|
|
66
|
+
- query_table: Filter, sort, and paginate rows with operators (eq, neq, gt, gte, lt, lte, contains, starts_with, in, is_empty, is_not_empty)
|
|
67
|
+
- search_table: Full-text search across row cell values
|
|
68
|
+
- aggregate_table: Compute count/sum/avg/min/max over a column with optional group-by
|
|
69
|
+
- add_rows: Insert one or more rows
|
|
70
|
+
- update_row: Update a single row's cell values
|
|
71
|
+
- delete_rows: Delete rows matching a filter [requires approval]
|
|
72
|
+
- create_table: Create a new empty table with specified columns
|
|
73
|
+
- import_document_as_table: Parse an uploaded document into a new table
|
|
74
|
+
- export_table: Export rows as CSV/JSON
|
|
75
|
+
- add_column / update_column / delete_column / reorder_columns: Schema edits
|
|
76
|
+
- list_triggers / create_trigger / update_trigger / delete_trigger: Per-row trigger evaluation
|
|
77
|
+
- get_table_history: Read change history for a table
|
|
78
|
+
- save_as_template: Save a table's shape as a reusable template
|
|
79
|
+
- **enrich_table**: Run an agent task for every row in a table matching a filter, writing results to a target column. Use for bulk research, classification, content generation, or any table-row fan-out pattern. Generates the optimal loop workflow, binds each row as context, skips already-populated rows for idempotency [requires approval]
|
|
80
|
+
|
|
61
81
|
## When to Use Which Tools
|
|
62
82
|
- CRUD operations ("create a task", "list workflows", "update the schedule") → Use the appropriate Stagent tool
|
|
63
83
|
- Execution ("run this task", "execute the workflow") → Use execute_task / execute_workflow
|
|
84
|
+
- Time-distributed multi-step sequences ("send email, wait 3 days, follow up", "drip campaign", "onboarding flow") → Use create_workflow with delay steps in a sequence pattern. Do NOT create separate workflows and schedules for each touch — a single workflow with inline delay steps is the idiomatic pattern.
|
|
85
|
+
- Bulk per-row operations ("research every contact", "classify all tickets", "enrich rows missing X", "for each row do Y") → Use enrich_table. Do NOT hand-roll a loop workflow for this — enrich_table already generates the optimal loop, handles row-data binding, wires up the postAction writeback, and skips already-populated rows for idempotency.
|
|
64
86
|
- Approvals ("approve that", "allow it", "deny the request") → Use respond_notification
|
|
65
87
|
- Monitoring ("what's pending?", "any approval requests?") → Use list_notifications
|
|
66
88
|
- Usage ("how much have I spent?", "token usage this week") → Use get_usage_summary
|
|
@@ -82,6 +104,9 @@ Be proactive with tools. If the user asks about project status, use list_tasks t
|
|
|
82
104
|
- If a project context is active, scope operations to it unless the user specifies otherwise.
|
|
83
105
|
- Tools marked [requires approval] will prompt the user before executing.
|
|
84
106
|
- For workflows, valid patterns are: sequence, parallel, checkpoint, planner-executor, swarm, loop.
|
|
107
|
+
- **Delay steps** (sequence pattern only): a step with \`delayDuration\` (format: Nm|Nh|Nd|Nw, bounds 1m..30d) pauses the workflow between task steps. Format examples: "30m", "2h", "3d", "1w". Delay steps must have NO profile or prompt — they are pure waits. Use them for outreach sequences, drip campaigns, cooling periods, staged rollouts. A paused workflow resumes automatically when its scheduled time arrives, or immediately when the user clicks "Resume Now".
|
|
108
|
+
- **enrich_table idempotency:** \`enrich_table\` skips rows where the target column already has a non-empty value. If the user wants to overwrite existing values, explain that force re-enrichment is not supported in v1 — they must manually clear the target column first (e.g. via update_row) before re-running.
|
|
109
|
+
- **create_workflow dedup:** Before calling \`create_workflow\`, call \`list_workflows\` (filtered by the current project) to check whether a similar workflow already exists. If the user asks to "redesign", "redo", or "update" an existing workflow, call \`update_workflow\` on the matching row instead of creating a new one. \`create_workflow\` performs its own near-duplicate check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when it finds one — when that happens, surface the matches to the user and confirm intent. Only pass \`force: true\` to \`create_workflow\` when the user has explicitly confirmed they want a second workflow alongside a similar one (e.g., "v2", "alternate approach").
|
|
85
110
|
- When a working directory is specified, always create files relative to it. Never assume the git root is the working directory — they may differ in worktree environments.
|
|
86
111
|
|
|
87
112
|
## Document Pool Awareness
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
Clock,
|
|
12
12
|
Globe,
|
|
13
13
|
Sun,
|
|
14
|
-
CheckCheck,
|
|
15
14
|
Sparkles,
|
|
16
15
|
Table2,
|
|
17
16
|
} from "lucide-react";
|
|
@@ -171,11 +170,13 @@ const STAGENT_TOOLS: ToolCatalogEntry[] = [
|
|
|
171
170
|
{ name: "delete_trigger", description: "Delete a trigger", group: "Tables", paramHint: "tableId, triggerId" },
|
|
172
171
|
{ name: "get_table_history", description: "Get row change history for a table", group: "Tables", paramHint: "tableId, limit" },
|
|
173
172
|
{ name: "save_as_template", description: "Save a table as a reusable template", group: "Tables", paramHint: "tableId, name, category" },
|
|
173
|
+
{ name: "enrich_table", description: "Bulk-enrich rows by running an agent task per row, writing results to a target column", group: "Tables", paramHint: "tableId, prompt, targetColumn, filter" },
|
|
174
174
|
|
|
175
175
|
// ── Chat History ──
|
|
176
176
|
{ name: "list_conversations", description: "List recent chat conversations", group: "Chat", paramHint: "search, limit" },
|
|
177
177
|
{ name: "get_conversation_messages", description: "Get messages from a past conversation", group: "Chat", paramHint: "conversationId, limit" },
|
|
178
178
|
{ name: "search_messages", description: "Search across all conversations", group: "Chat", paramHint: "query" },
|
|
179
|
+
|
|
179
180
|
];
|
|
180
181
|
|
|
181
182
|
const BROWSER_TOOLS: ToolCatalogEntry[] = [
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { mockCreateEnrichmentWorkflow } = vi.hoisted(() => ({
|
|
4
|
+
mockCreateEnrichmentWorkflow: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("@/lib/tables/enrichment", () => ({
|
|
8
|
+
createEnrichmentWorkflow: mockCreateEnrichmentWorkflow,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Stub the rest of @/lib/data/tables so importing table-tools doesn't drag in DB.
|
|
12
|
+
vi.mock("@/lib/data/tables", () => ({
|
|
13
|
+
listTables: vi.fn(),
|
|
14
|
+
getTable: vi.fn(),
|
|
15
|
+
createTable: vi.fn(),
|
|
16
|
+
updateTable: vi.fn(),
|
|
17
|
+
deleteTable: vi.fn(),
|
|
18
|
+
listRows: vi.fn(),
|
|
19
|
+
addRows: vi.fn(),
|
|
20
|
+
updateRow: vi.fn(),
|
|
21
|
+
deleteRows: vi.fn(),
|
|
22
|
+
listTemplates: vi.fn(),
|
|
23
|
+
cloneFromTemplate: vi.fn(),
|
|
24
|
+
addColumn: vi.fn(),
|
|
25
|
+
updateColumn: vi.fn(),
|
|
26
|
+
deleteColumn: vi.fn(),
|
|
27
|
+
reorderColumns: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("@/lib/tables/history", () => ({ getTableHistory: vi.fn() }));
|
|
31
|
+
vi.mock("@/lib/tables/import", () => ({
|
|
32
|
+
extractStructuredData: vi.fn(),
|
|
33
|
+
inferColumnTypes: vi.fn(),
|
|
34
|
+
importRows: vi.fn(),
|
|
35
|
+
createImportRecord: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { tableTools } from "../table-tools";
|
|
39
|
+
|
|
40
|
+
function findEnrichTool() {
|
|
41
|
+
const tools = tableTools({ projectId: "proj_test" });
|
|
42
|
+
const tool = tools.find((t) => t.name === "enrich_table");
|
|
43
|
+
if (!tool) throw new Error("enrich_table tool not registered");
|
|
44
|
+
return tool;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("enrich_table tool", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockCreateEnrichmentWorkflow.mockReset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is registered in tableTools", () => {
|
|
53
|
+
const tools = tableTools({ projectId: "proj_test" });
|
|
54
|
+
const names = tools.map((t) => t.name);
|
|
55
|
+
expect(names).toContain("enrich_table");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("delegates to createEnrichmentWorkflow with the supplied params", async () => {
|
|
59
|
+
mockCreateEnrichmentWorkflow.mockResolvedValueOnce({
|
|
60
|
+
workflowId: "wf_xyz",
|
|
61
|
+
rowCount: 4,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const tool = findEnrichTool();
|
|
65
|
+
const result = await tool.handler({
|
|
66
|
+
tableId: "tbl_contacts",
|
|
67
|
+
prompt: "Find LinkedIn for {{row.name}}",
|
|
68
|
+
targetColumn: "linkedin",
|
|
69
|
+
filter: { column: "linkedin", operator: "is_empty" },
|
|
70
|
+
agentProfile: "sales-researcher",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(mockCreateEnrichmentWorkflow).toHaveBeenCalledWith(
|
|
74
|
+
"tbl_contacts",
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
prompt: "Find LinkedIn for {{row.name}}",
|
|
77
|
+
targetColumn: "linkedin",
|
|
78
|
+
filter: { column: "linkedin", operator: "is_empty" },
|
|
79
|
+
agentProfile: "sales-researcher",
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(result.isError).toBeFalsy();
|
|
84
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
85
|
+
workflowId: string;
|
|
86
|
+
rowCount: number;
|
|
87
|
+
};
|
|
88
|
+
expect(payload.workflowId).toBe("wf_xyz");
|
|
89
|
+
expect(payload.rowCount).toBe(4);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to ctx.projectId when projectId is not supplied", async () => {
|
|
93
|
+
mockCreateEnrichmentWorkflow.mockResolvedValueOnce({
|
|
94
|
+
workflowId: "wf_a",
|
|
95
|
+
rowCount: 1,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const tool = findEnrichTool();
|
|
99
|
+
await tool.handler({
|
|
100
|
+
tableId: "tbl_x",
|
|
101
|
+
prompt: "x",
|
|
102
|
+
targetColumn: "linkedin",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const callArg = mockCreateEnrichmentWorkflow.mock.calls[0][1] as {
|
|
106
|
+
projectId?: string;
|
|
107
|
+
};
|
|
108
|
+
expect(callArg.projectId).toBe("proj_test");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns an error result when createEnrichmentWorkflow throws", async () => {
|
|
112
|
+
mockCreateEnrichmentWorkflow.mockRejectedValueOnce(
|
|
113
|
+
new Error("Table tbl_missing not found")
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const tool = findEnrichTool();
|
|
117
|
+
const result = await tool.handler({
|
|
118
|
+
tableId: "tbl_missing",
|
|
119
|
+
prompt: "x",
|
|
120
|
+
targetColumn: "linkedin",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.isError).toBe(true);
|
|
124
|
+
const payload = JSON.parse(result.content[0].text) as { error: string };
|
|
125
|
+
expect(payload.error).toContain("not found");
|
|
126
|
+
});
|
|
127
|
+
});
|