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
package/src/lib/chat/engine.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { db } from "@/lib/db";
|
|
|
3
3
|
import { projects, chatMessages } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
5
|
import { getAuthEnv } from "@/lib/settings/auth";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildClaudeSdkEnv,
|
|
8
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
9
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
10
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
11
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
7
12
|
import {
|
|
8
13
|
extractUsageSnapshot,
|
|
9
14
|
mergeUsageSnapshot,
|
|
@@ -42,7 +47,7 @@ import {
|
|
|
42
47
|
} from "./permission-bridge";
|
|
43
48
|
import { isToolAllowed } from "@/lib/settings/permissions";
|
|
44
49
|
import { getLaunchCwd, getWorkspaceContext } from "@/lib/environment/workspace-context";
|
|
45
|
-
import {
|
|
50
|
+
import { createToolServer } from "./stagent-tools";
|
|
46
51
|
import {
|
|
47
52
|
getBrowserMcpServers,
|
|
48
53
|
getBrowserAllowedToolPatterns,
|
|
@@ -53,6 +58,36 @@ import {
|
|
|
53
58
|
isExaTool,
|
|
54
59
|
isExaReadOnly,
|
|
55
60
|
} from "@/lib/agents/browser-mcp";
|
|
61
|
+
import { resolveChatExecutionTarget } from "@/lib/agents/runtime/execution-target";
|
|
62
|
+
|
|
63
|
+
// Re-exported from runtime/claude-sdk.ts so chat/engine.ts remains a stable
|
|
64
|
+
// import surface for the Phase 1a test suite. The canonical definitions
|
|
65
|
+
// live in the runtime module since task execution needs them too — see
|
|
66
|
+
// features/task-runtime-skill-parity.md Task 1.
|
|
67
|
+
export {
|
|
68
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
69
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
70
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
71
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pure auto-allow policy for SDK filesystem + Skill tools. Exposed for tests.
|
|
75
|
+
* Returns `{ behavior: "allow" }` for auto-allowed tools, or
|
|
76
|
+
* `{ behavior: "pending" }` to signal "route through permission flow".
|
|
77
|
+
* The real canUseTool in query() options uses the full side-channel bridge.
|
|
78
|
+
*/
|
|
79
|
+
export async function canUseToolForTest(
|
|
80
|
+
toolName: string,
|
|
81
|
+
_input: Record<string, unknown>
|
|
82
|
+
): Promise<ToolPermissionResponse | { behavior: "pending" }> {
|
|
83
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
84
|
+
return { behavior: "allow" };
|
|
85
|
+
}
|
|
86
|
+
if (toolName === "Skill") {
|
|
87
|
+
return { behavior: "allow" };
|
|
88
|
+
}
|
|
89
|
+
return { behavior: "pending" };
|
|
90
|
+
}
|
|
56
91
|
|
|
57
92
|
// ── Streaming input wrapper (required for MCP tools) ─────────────────
|
|
58
93
|
|
|
@@ -151,21 +186,43 @@ export async function* sendMessage(
|
|
|
151
186
|
return;
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
let target;
|
|
190
|
+
try {
|
|
191
|
+
target = await resolveChatExecutionTarget({
|
|
192
|
+
requestedRuntimeId: conversation.runtimeId,
|
|
193
|
+
requestedModelId: conversation.modelId,
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
yield {
|
|
197
|
+
type: "error",
|
|
198
|
+
message: error instanceof Error ? error.message : "No chat runtime is available",
|
|
199
|
+
};
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (target.fallbackApplied && target.fallbackReason) {
|
|
204
|
+
yield {
|
|
205
|
+
type: "status",
|
|
206
|
+
phase: "runtime_fallback",
|
|
207
|
+
message: target.fallbackReason,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
154
211
|
// Route to Codex App Server for OpenAI models
|
|
155
|
-
if (
|
|
212
|
+
if (target.effectiveRuntimeId === "openai-codex-app-server") {
|
|
156
213
|
const { sendCodexMessage } = await import("./codex-engine");
|
|
157
|
-
yield* sendCodexMessage(conversationId, userContent, signal);
|
|
214
|
+
yield* sendCodexMessage(conversationId, userContent, signal, target);
|
|
158
215
|
return;
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
// Route to Ollama for local models
|
|
162
|
-
if (
|
|
219
|
+
if (target.effectiveRuntimeId === "ollama") {
|
|
163
220
|
const { sendOllamaMessage } = await import("./ollama-engine");
|
|
164
221
|
yield* sendOllamaMessage(conversationId, userContent, signal);
|
|
165
222
|
return;
|
|
166
223
|
}
|
|
167
224
|
|
|
168
|
-
const runtimeId =
|
|
225
|
+
const runtimeId = target.effectiveRuntimeId;
|
|
169
226
|
const providerId = getProviderForRuntime(runtimeId);
|
|
170
227
|
|
|
171
228
|
// Enforce budget before the turn
|
|
@@ -277,10 +334,11 @@ export async function* sendMessage(
|
|
|
277
334
|
|
|
278
335
|
// Create in-process MCP server for Stagent CRUD tools
|
|
279
336
|
const toolResults: ToolResultCapture[] = [];
|
|
280
|
-
const stagentServer =
|
|
337
|
+
const stagentServer = createToolServer(
|
|
281
338
|
conversation.projectId,
|
|
282
|
-
(toolName, result) => { toolResults.push({ toolName, result }); }
|
|
283
|
-
|
|
339
|
+
(toolName, result) => { toolResults.push({ toolName, result }); },
|
|
340
|
+
projectCwd,
|
|
341
|
+
).asMcpServer();
|
|
284
342
|
|
|
285
343
|
yield { type: "status", phase: "connecting", message: "Connecting to model..." };
|
|
286
344
|
|
|
@@ -300,7 +358,7 @@ export async function* sendMessage(
|
|
|
300
358
|
const response = query({
|
|
301
359
|
prompt: generatePrompt(fullPrompt),
|
|
302
360
|
options: {
|
|
303
|
-
model: conversation.modelId || undefined,
|
|
361
|
+
model: target.effectiveModelId || conversation.modelId || undefined,
|
|
304
362
|
maxTurns,
|
|
305
363
|
abortController,
|
|
306
364
|
includePartialMessages: true,
|
|
@@ -312,7 +370,13 @@ export async function* sendMessage(
|
|
|
312
370
|
if (stderrChunks.length > 50) stderrChunks.shift();
|
|
313
371
|
},
|
|
314
372
|
mcpServers: { stagent: stagentServer, ...browserServers, ...externalServers },
|
|
315
|
-
allowedTools: [
|
|
373
|
+
allowedTools: [
|
|
374
|
+
"mcp__stagent__*",
|
|
375
|
+
...browserToolPatterns,
|
|
376
|
+
...externalToolPatterns,
|
|
377
|
+
...CLAUDE_SDK_ALLOWED_TOOLS,
|
|
378
|
+
],
|
|
379
|
+
settingSources: [...CLAUDE_SDK_SETTING_SOURCES],
|
|
316
380
|
// @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
|
|
317
381
|
canUseTool: async (
|
|
318
382
|
toolName: string,
|
|
@@ -369,6 +433,32 @@ export async function* sendMessage(
|
|
|
369
433
|
// Mutation browser tools fall through to permission check below
|
|
370
434
|
}
|
|
371
435
|
|
|
436
|
+
// SDK filesystem read-only tools: auto-allow (mirror browser/exa pattern)
|
|
437
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
438
|
+
emitSideChannelEvent(conversationId, {
|
|
439
|
+
type: "status",
|
|
440
|
+
phase: "tool_use",
|
|
441
|
+
message: `Filesystem: ${toolName.toLowerCase()}...`,
|
|
442
|
+
});
|
|
443
|
+
return { behavior: "allow", updatedInput: input };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Skill tool: auto-allow. Rationale: the Skill tool loads skills from
|
|
447
|
+
// ~/.claude/skills/ and .claude/skills/ — the same sources the Claude Code
|
|
448
|
+
// CLI trusts unconditionally. Any tool the skill subsequently invokes
|
|
449
|
+
// (Bash, Edit, etc.) goes through this same canUseTool check. The trust
|
|
450
|
+
// assumption here is identical to using `claude` directly; no new attack
|
|
451
|
+
// surface is introduced. See: features/chat-claude-sdk-skills.md, Error
|
|
452
|
+
// & Rescue Registry row "settingSources loads hostile skill".
|
|
453
|
+
if (toolName === "Skill") {
|
|
454
|
+
emitSideChannelEvent(conversationId, {
|
|
455
|
+
type: "status",
|
|
456
|
+
phase: "tool_use",
|
|
457
|
+
message: `Skill: ${(input as { skill?: string }).skill ?? "unknown"}...`,
|
|
458
|
+
});
|
|
459
|
+
return { behavior: "allow", updatedInput: input };
|
|
460
|
+
}
|
|
461
|
+
|
|
372
462
|
const isQuestion = toolName === "AskUserQuestion";
|
|
373
463
|
|
|
374
464
|
// Layer 1: Check saved user permissions (skip for questions)
|
|
@@ -615,7 +705,11 @@ export async function* sendMessage(
|
|
|
615
705
|
|
|
616
706
|
// Save usage metadata + quick access links + screenshot attachments
|
|
617
707
|
const metadata = JSON.stringify({
|
|
618
|
-
modelId: usage.modelId ?? conversation.modelId,
|
|
708
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId,
|
|
709
|
+
runtimeId,
|
|
710
|
+
requestedRuntimeId: target.requestedRuntimeId ?? conversation.runtimeId,
|
|
711
|
+
requestedModelId: target.requestedModelId ?? conversation.modelId,
|
|
712
|
+
...(target.fallbackReason ? { fallbackReason: target.fallbackReason } : {}),
|
|
619
713
|
inputTokens: usage.inputTokens,
|
|
620
714
|
outputTokens: usage.outputTokens,
|
|
621
715
|
...(quickAccess.length > 0 ? { quickAccess } : {}),
|
|
@@ -632,7 +726,7 @@ export async function* sendMessage(
|
|
|
632
726
|
activityType: "chat_turn",
|
|
633
727
|
runtimeId,
|
|
634
728
|
providerId,
|
|
635
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
729
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
636
730
|
inputTokens: usage.inputTokens ?? null,
|
|
637
731
|
outputTokens: usage.outputTokens ?? null,
|
|
638
732
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -695,7 +789,7 @@ export async function* sendMessage(
|
|
|
695
789
|
activityType: "chat_turn",
|
|
696
790
|
runtimeId,
|
|
697
791
|
providerId,
|
|
698
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
792
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
699
793
|
inputTokens: usage.inputTokens ?? null,
|
|
700
794
|
outputTokens: usage.outputTokens ?? null,
|
|
701
795
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -722,7 +816,7 @@ export async function* sendMessage(
|
|
|
722
816
|
activityType: "chat_turn",
|
|
723
817
|
runtimeId,
|
|
724
818
|
providerId,
|
|
725
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
819
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
726
820
|
inputTokens: usage.inputTokens ?? null,
|
|
727
821
|
outputTokens: usage.outputTokens ?? null,
|
|
728
822
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Hoist mutable state so the mock factories can read it.
|
|
4
|
+
const { mockState } = vi.hoisted(() => ({
|
|
5
|
+
mockState: {
|
|
6
|
+
stdout: "" as string,
|
|
7
|
+
execFileThrows: false as boolean | Error,
|
|
8
|
+
files: new Map<string, { size: number; mtimeMs: number }>(),
|
|
9
|
+
realpathMap: new Map<string, string>(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("node:child_process", () => {
|
|
14
|
+
const execFileSync = vi.fn(() => {
|
|
15
|
+
if (mockState.execFileThrows) {
|
|
16
|
+
throw mockState.execFileThrows instanceof Error
|
|
17
|
+
? mockState.execFileThrows
|
|
18
|
+
: new Error("git not available");
|
|
19
|
+
}
|
|
20
|
+
return mockState.stdout;
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
default: { execFileSync },
|
|
24
|
+
execFileSync,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("node:fs", () => {
|
|
29
|
+
const realpathSync = (p: string) => mockState.realpathMap.get(p) ?? p;
|
|
30
|
+
const statSync = (absPath: string) => {
|
|
31
|
+
const f = mockState.files.get(absPath);
|
|
32
|
+
if (!f) throw new Error(`ENOENT: ${absPath}`);
|
|
33
|
+
return { size: f.size, mtimeMs: f.mtimeMs };
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
default: { realpathSync, statSync },
|
|
37
|
+
realpathSync,
|
|
38
|
+
statSync,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
import { searchFiles } from "../search";
|
|
43
|
+
|
|
44
|
+
// Helper: all test files live under this fake cwd
|
|
45
|
+
const CWD = "/repo";
|
|
46
|
+
|
|
47
|
+
function file(relPath: string, size: number, mtimeMs: number) {
|
|
48
|
+
mockState.files.set(`${CWD}/${relPath}`, { size, mtimeMs });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockState.stdout = "";
|
|
53
|
+
mockState.execFileThrows = false;
|
|
54
|
+
mockState.files.clear();
|
|
55
|
+
mockState.realpathMap.clear();
|
|
56
|
+
mockState.realpathMap.set(CWD, CWD);
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("searchFiles", () => {
|
|
61
|
+
it("returns all files when query is empty, mtime-sorted newest first", () => {
|
|
62
|
+
mockState.stdout = ["src/a.ts", "src/b.ts", "src/c.ts", ""].join("\n");
|
|
63
|
+
file("src/a.ts", 100, 1_000);
|
|
64
|
+
file("src/b.ts", 200, 3_000);
|
|
65
|
+
file("src/c.ts", 300, 2_000);
|
|
66
|
+
|
|
67
|
+
const hits = searchFiles(CWD, "", 10);
|
|
68
|
+
expect(hits.map((h) => h.path)).toEqual(["src/b.ts", "src/c.ts", "src/a.ts"]);
|
|
69
|
+
expect(hits[0].sizeBytes).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ranks filename matches above directory-path matches", () => {
|
|
73
|
+
mockState.stdout = [
|
|
74
|
+
"src/schema/other.ts", // directory match for "schema"
|
|
75
|
+
"src/lib/db/schema.ts", // filename match for "schema"
|
|
76
|
+
""
|
|
77
|
+
].join("\n");
|
|
78
|
+
file("src/schema/other.ts", 100, 1_000);
|
|
79
|
+
file("src/lib/db/schema.ts", 100, 500); // older but should still rank first
|
|
80
|
+
|
|
81
|
+
const hits = searchFiles(CWD, "schema", 10);
|
|
82
|
+
expect(hits[0].path).toBe("src/lib/db/schema.ts");
|
|
83
|
+
expect(hits[1].path).toBe("src/schema/other.ts");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("performs case-insensitive substring match", () => {
|
|
87
|
+
mockState.stdout = ["src/Foo.TSX", "src/bar.ts", ""].join("\n");
|
|
88
|
+
file("src/Foo.TSX", 100, 1_000);
|
|
89
|
+
file("src/bar.ts", 100, 1_000);
|
|
90
|
+
|
|
91
|
+
const hits = searchFiles(CWD, "foo", 10);
|
|
92
|
+
expect(hits).toHaveLength(1);
|
|
93
|
+
expect(hits[0].path).toBe("src/Foo.TSX");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("respects limit cap", () => {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
for (let i = 0; i < 50; i++) {
|
|
99
|
+
const p = `src/file${i}.ts`;
|
|
100
|
+
lines.push(p);
|
|
101
|
+
file(p, 100, i * 10);
|
|
102
|
+
}
|
|
103
|
+
mockState.stdout = lines.join("\n");
|
|
104
|
+
|
|
105
|
+
const hits = searchFiles(CWD, "", 5);
|
|
106
|
+
expect(hits).toHaveLength(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns [] when execFileSync throws (not a git repo)", () => {
|
|
110
|
+
mockState.execFileThrows = new Error("not a git repository");
|
|
111
|
+
const hits = searchFiles(CWD, "anything", 10);
|
|
112
|
+
expect(hits).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips files that disappeared between ls-files and stat", () => {
|
|
116
|
+
mockState.stdout = ["src/exists.ts", "src/ghost.ts", ""].join("\n");
|
|
117
|
+
file("src/exists.ts", 100, 1_000);
|
|
118
|
+
// src/ghost.ts intentionally absent from the files map — statSync throws
|
|
119
|
+
|
|
120
|
+
const hits = searchFiles(CWD, "", 10);
|
|
121
|
+
expect(hits.map((h) => h.path)).toEqual(["src/exists.ts"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("excludes files that would resolve outside cwd (defense-in-depth)", () => {
|
|
125
|
+
// git ls-files should never emit such a path, but if it did we must reject.
|
|
126
|
+
mockState.stdout = ["../escape.ts", "src/ok.ts", ""].join("\n");
|
|
127
|
+
// Do NOT register the escape path in files — resolve() would point outside
|
|
128
|
+
// /repo, and the startsWith check in search.ts will discard it before
|
|
129
|
+
// statSync is even called.
|
|
130
|
+
file("src/ok.ts", 100, 1_000);
|
|
131
|
+
|
|
132
|
+
const hits = searchFiles(CWD, "", 10);
|
|
133
|
+
expect(hits.map((h) => h.path)).toEqual(["src/ok.ts"]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { realpathSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format a single `entityType: "file"` mention for Tier 3.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - `cwd` is resolved by the caller from a trusted source (active project's
|
|
9
|
+
* workingDirectory, else `getLaunchCwd()`) — NEVER from the mention itself.
|
|
10
|
+
* - The mention's `relPath` is treated as a relative path; any path that
|
|
11
|
+
* resolves outside `cwd` is rejected without opening the file.
|
|
12
|
+
*
|
|
13
|
+
* Size semantics (matches spec §3 "tiered expansion"):
|
|
14
|
+
* - < 8 KB: inline content inside a fenced code block with path header.
|
|
15
|
+
* - >= 8 KB and < MAX_SIZE: emit a short reference line so agents with a
|
|
16
|
+
* `Read` tool can fetch the file on demand; agents without one degrade
|
|
17
|
+
* gracefully ("I can't read large files on this runtime").
|
|
18
|
+
* - >= MAX_SIZE (50 MB): skip silently — pathological.
|
|
19
|
+
*
|
|
20
|
+
* Non-crashing by design: any read/stat failure becomes a short note in
|
|
21
|
+
* the output, not a thrown error that would break the whole prompt build.
|
|
22
|
+
*/
|
|
23
|
+
export function expandFileMention(relPath: string, cwd: string): string[] {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
|
|
26
|
+
let cwdReal: string;
|
|
27
|
+
try {
|
|
28
|
+
cwdReal = realpathSync(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
lines.push(`\n### File: ${relPath}`);
|
|
31
|
+
lines.push("(cwd does not exist)");
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const abs = resolve(cwdReal, relPath);
|
|
36
|
+
if (!abs.startsWith(cwdReal)) {
|
|
37
|
+
lines.push(`\n### File: ${relPath}`);
|
|
38
|
+
lines.push("(invalid path — escapes working directory)");
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let stat: { size: number };
|
|
43
|
+
try {
|
|
44
|
+
stat = statSync(abs);
|
|
45
|
+
} catch {
|
|
46
|
+
lines.push(`\n### File: ${relPath}`);
|
|
47
|
+
lines.push("(file not found at context-build time)");
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INLINE_LIMIT = 8 * 1024;
|
|
52
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
53
|
+
if (stat.size > MAX_SIZE) return []; // skip silently
|
|
54
|
+
|
|
55
|
+
if (stat.size < INLINE_LIMIT) {
|
|
56
|
+
let content: string;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(abs, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
lines.push(`\n### File: ${relPath}`);
|
|
61
|
+
lines.push("(file could not be read as UTF-8)");
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
const ext = relPath.split(".").pop() ?? "";
|
|
65
|
+
lines.push(`\n### File: ${relPath}`);
|
|
66
|
+
lines.push("```" + ext);
|
|
67
|
+
lines.push(content);
|
|
68
|
+
lines.push("```");
|
|
69
|
+
} else {
|
|
70
|
+
lines.push(
|
|
71
|
+
`\n### File (by reference): ${relPath} (${Math.round(stat.size / 1024)} KB)`
|
|
72
|
+
);
|
|
73
|
+
lines.push("Use the Read tool to load this file if you need its content.");
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve, basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface FileSearchHit {
|
|
6
|
+
/** Path relative to the resolved cwd. */
|
|
7
|
+
path: string;
|
|
8
|
+
sizeBytes: number;
|
|
9
|
+
/** mtime in epoch ms. */
|
|
10
|
+
mtime: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return up to `limit` files under `cwd` (respecting .gitignore) whose
|
|
15
|
+
* path or basename contains `query` (case-insensitive). Filename matches
|
|
16
|
+
* rank above directory-path matches; secondary sort by mtime desc.
|
|
17
|
+
*
|
|
18
|
+
* Uses `git ls-files --cached --others --exclude-standard` to honor
|
|
19
|
+
* .gitignore natively — matches the subprocess pattern already in use
|
|
20
|
+
* in `src/lib/environment/workspace-context.ts`. No npm dep required.
|
|
21
|
+
* Returns [] if `cwd` is not inside a git repo or git is unavailable.
|
|
22
|
+
*
|
|
23
|
+
* Security: the caller is responsible for server-resolving `cwd` from
|
|
24
|
+
* a trusted source (e.g., the active project's workingDirectory or
|
|
25
|
+
* `getLaunchCwd()`). Never pass a client-controlled path directly.
|
|
26
|
+
*/
|
|
27
|
+
export function searchFiles(
|
|
28
|
+
cwd: string,
|
|
29
|
+
query: string,
|
|
30
|
+
limit = 20
|
|
31
|
+
): FileSearchHit[] {
|
|
32
|
+
const cwdReal = realpathSync(cwd);
|
|
33
|
+
|
|
34
|
+
let stdout: string;
|
|
35
|
+
try {
|
|
36
|
+
stdout = execFileSync(
|
|
37
|
+
"git",
|
|
38
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
39
|
+
{
|
|
40
|
+
cwd: cwdReal,
|
|
41
|
+
encoding: "utf-8",
|
|
42
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
43
|
+
timeout: 3000,
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
// Not a git repo, or git missing, or timeout — degrade to empty list.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const q = query.trim().toLowerCase();
|
|
52
|
+
const hits: Array<FileSearchHit & { score: number }> = [];
|
|
53
|
+
|
|
54
|
+
for (const rel of stdout.split("\n")) {
|
|
55
|
+
if (!rel) continue;
|
|
56
|
+
// Defensive: ensure the resolved path stays within cwd. `git ls-files`
|
|
57
|
+
// should never emit such a path, but stat-ing anything outside cwd
|
|
58
|
+
// would bypass the .gitignore guarantee anyway.
|
|
59
|
+
const abs = resolve(cwdReal, rel);
|
|
60
|
+
if (!abs.startsWith(cwdReal)) continue;
|
|
61
|
+
|
|
62
|
+
const relLower = rel.toLowerCase();
|
|
63
|
+
const baseLower = basename(rel).toLowerCase();
|
|
64
|
+
let score: number;
|
|
65
|
+
if (q === "") {
|
|
66
|
+
score = 1;
|
|
67
|
+
} else if (baseLower.includes(q)) {
|
|
68
|
+
score = 3;
|
|
69
|
+
} else if (relLower.includes(q)) {
|
|
70
|
+
score = 2;
|
|
71
|
+
} else {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let sizeBytes = 0;
|
|
76
|
+
let mtime = 0;
|
|
77
|
+
try {
|
|
78
|
+
const s = statSync(abs);
|
|
79
|
+
sizeBytes = s.size;
|
|
80
|
+
mtime = s.mtimeMs;
|
|
81
|
+
} catch {
|
|
82
|
+
// File disappeared between ls-files and stat — skip.
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hits.push({ path: rel, sizeBytes, mtime, score });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
hits.sort((a, b) => {
|
|
90
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
91
|
+
return b.mtime - a.mtime;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return hits.slice(0, limit).map(({ path, sizeBytes, mtime }) => ({
|
|
95
|
+
path,
|
|
96
|
+
sizeBytes,
|
|
97
|
+
mtime,
|
|
98
|
+
}));
|
|
99
|
+
}
|