@vellumai/assistant 0.6.0 → 0.6.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +68 -15
- package/Dockerfile +2 -2
- package/bun.lock +6 -2
- package/docker-entrypoint.sh +42 -1
- package/docs/architecture/integrations.md +1 -1
- package/docs/architecture/memory.md +21 -24
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
- package/openapi.yaml +539 -4
- package/package.json +5 -1
- package/src/__tests__/anthropic-provider.test.ts +160 -95
- package/src/__tests__/app-dir-path-guard.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +47 -1
- package/src/__tests__/app-source-watcher.test.ts +159 -0
- package/src/__tests__/assistant-event-hub.test.ts +30 -0
- package/src/__tests__/checker.test.ts +138 -172
- package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
- package/src/__tests__/config-schema.test.ts +5 -0
- package/src/__tests__/context-overflow-approval.test.ts +5 -5
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
- package/src/__tests__/conversation-agent-loop.test.ts +4 -51
- package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
- package/src/__tests__/conversation-directories-parse.test.ts +105 -0
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
- package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
- package/src/__tests__/conversation-wipe.test.ts +2 -6
- package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
- package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
- package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
- package/src/__tests__/date-context.test.ts +76 -210
- package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
- package/src/__tests__/file-list-tool.test.ts +219 -0
- package/src/__tests__/first-greeting.test.ts +1 -1
- package/src/__tests__/heartbeat-service.test.ts +180 -3
- package/src/__tests__/identity-routes.test.ts +328 -0
- package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
- package/src/__tests__/injection-block.test.ts +24 -0
- package/src/__tests__/inline-command-runner.test.ts +7 -5
- package/src/__tests__/install-skill-routing.test.ts +7 -6
- package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
- package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
- package/src/__tests__/llm-context-normalization.test.ts +18 -18
- package/src/__tests__/llm-context-route-provider.test.ts +101 -0
- package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
- package/src/__tests__/log-export-workspace.test.ts +257 -100
- package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
- package/src/__tests__/mcp-abort-signal.test.ts +5 -0
- package/src/__tests__/mcp-client-auth.test.ts +5 -0
- package/src/__tests__/memory-recall-log-store.test.ts +132 -0
- package/src/__tests__/migration-export-streaming.test.ts +304 -0
- package/src/__tests__/migration-import-commit-http.test.ts +11 -10
- package/src/__tests__/mock-fetch.ts +87 -0
- package/src/__tests__/navigate-settings-tab.test.ts +14 -1
- package/src/__tests__/notification-broadcaster.test.ts +65 -0
- package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
- package/src/__tests__/onboarding-template-contract.test.ts +63 -14
- package/src/__tests__/parser.test.ts +32 -0
- package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
- package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
- package/src/__tests__/permission-mode-sse.test.ts +418 -0
- package/src/__tests__/permission-mode-store.test.ts +277 -0
- package/src/__tests__/permission-mode.test.ts +101 -0
- package/src/__tests__/pkb-autoinject.test.ts +96 -0
- package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
- package/src/__tests__/profiler-routes.test.ts +502 -0
- package/src/__tests__/profiler-run-store.test.ts +441 -0
- package/src/__tests__/proxy-approval-callback.test.ts +4 -75
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/require-fresh-approval.test.ts +0 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
- package/src/__tests__/sandbox-host-parity.test.ts +5 -4
- package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
- package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
- package/src/__tests__/search-skills-unified.test.ts +4 -3
- package/src/__tests__/send-endpoint-busy.test.ts +42 -3
- package/src/__tests__/set-permission-mode.test.ts +274 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
- package/src/__tests__/skill-memory.test.ts +2 -783
- package/src/__tests__/strip-memory-injections.test.ts +187 -0
- package/src/__tests__/subagent-detail.test.ts +84 -0
- package/src/__tests__/subagent-disposal.test.ts +308 -0
- package/src/__tests__/subagent-manager-notify.test.ts +19 -10
- package/src/__tests__/subagent-notify-parent.test.ts +390 -0
- package/src/__tests__/subagent-role-registry.test.ts +108 -0
- package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
- package/src/__tests__/subagent-tools.test.ts +464 -4
- package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
- package/src/__tests__/task-memory-cleanup.test.ts +12 -12
- package/src/__tests__/terminal-sandbox.test.ts +1 -1
- package/src/__tests__/terminal-tools.test.ts +16 -29
- package/src/__tests__/test-preload.ts +18 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
- package/src/__tests__/tool-executor.test.ts +4 -27
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
- package/src/__tests__/top-level-renderer.test.ts +10 -13
- package/src/__tests__/transport-hints-queue.test.ts +77 -0
- package/src/__tests__/trust-store.test.ts +4 -4
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
- package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
- package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
- package/src/__tests__/workspace-policy.test.ts +2 -7
- package/src/agent/loop.ts +6 -29
- package/src/approvals/guardian-request-resolvers.ts +24 -0
- package/src/avatar/traits-png-sync.ts +3 -3
- package/src/channels/types.ts +5 -0
- package/src/cli/__tests__/run-assistant-command.ts +56 -0
- package/src/cli/__tests__/unknown-command.test.ts +33 -0
- package/src/cli/commands/__tests__/email-download.test.ts +245 -0
- package/src/cli/commands/__tests__/email-list.test.ts +192 -0
- package/src/cli/commands/__tests__/email-register.test.ts +186 -0
- package/src/cli/commands/__tests__/email-send.test.ts +291 -0
- package/src/cli/commands/__tests__/email-status.test.ts +181 -0
- package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
- package/src/cli/commands/__tests__/routes.test.ts +562 -0
- package/src/cli/commands/conversations.ts +1 -8
- package/src/cli/commands/default-action.ts +68 -1
- package/src/cli/commands/email.ts +584 -835
- package/src/cli/commands/memory.ts +1 -34
- package/src/cli/commands/notifications.ts +7 -2
- package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
- package/src/cli/commands/oauth/connect.ts +25 -5
- package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
- package/src/cli/commands/routes.ts +396 -0
- package/src/cli/commands/skills.ts +130 -20
- package/src/cli/program.ts +11 -2
- package/src/cli.ts +1 -120
- package/src/config/assistant-feature-flags.ts +59 -55
- package/src/config/bundled-skills/app-builder/SKILL.md +91 -5
- package/src/config/bundled-skills/gmail/SKILL.md +13 -8
- package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
- package/src/config/bundled-skills/messaging/SKILL.md +7 -0
- package/src/config/bundled-skills/schedule/SKILL.md +22 -2
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/settings/TOOLS.json +1 -1
- package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
- package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
- package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
- package/src/config/bundled-skills/slack/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/SKILL.md +43 -3
- package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
- package/src/config/env-registry.ts +63 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/schema.ts +8 -0
- package/src/config/schemas/filing.ts +51 -0
- package/src/config/schemas/heartbeat.ts +15 -12
- package/src/config/schemas/memory-lifecycle.ts +12 -0
- package/src/config/schemas/security.ts +14 -0
- package/src/config/schemas/services.ts +8 -0
- package/src/credential-execution/approval-bridge.ts +0 -1
- package/src/credential-execution/managed-catalog.ts +3 -7
- package/src/daemon/app-source-watcher.ts +93 -0
- package/src/daemon/config-watcher.ts +85 -3
- package/src/daemon/context-overflow-approval.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
- package/src/daemon/conversation-agent-loop.ts +179 -65
- package/src/daemon/conversation-attachments.ts +0 -1
- package/src/daemon/conversation-history.ts +4 -19
- package/src/daemon/conversation-lifecycle.ts +8 -14
- package/src/daemon/conversation-messaging.ts +3 -0
- package/src/daemon/conversation-process.ts +30 -8
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +359 -308
- package/src/daemon/conversation-surfaces.ts +65 -0
- package/src/daemon/conversation-tool-setup.ts +44 -17
- package/src/daemon/conversation-workspace.ts +1 -2
- package/src/daemon/conversation.ts +19 -3
- package/src/daemon/date-context.ts +26 -53
- package/src/daemon/first-greeting.ts +1 -1
- package/src/daemon/handlers/conversations.ts +5 -7
- package/src/daemon/handlers/shared.test.ts +143 -0
- package/src/daemon/handlers/shared.ts +70 -5
- package/src/daemon/handlers/skills.ts +11 -18
- package/src/daemon/lifecycle.ts +220 -158
- package/src/daemon/message-types/conversations.ts +29 -6
- package/src/daemon/message-types/messages.ts +9 -2
- package/src/daemon/message-types/notifications.ts +12 -0
- package/src/daemon/message-types/schedules.ts +1 -0
- package/src/daemon/message-types/settings.ts +18 -0
- package/src/daemon/profiler-run-store.ts +557 -0
- package/src/daemon/server.ts +87 -10
- package/src/daemon/shutdown-handlers.ts +5 -0
- package/src/daemon/tool-side-effects.ts +23 -3
- package/src/daemon/transport-hints.ts +33 -0
- package/src/export/transcript-formatter.ts +148 -0
- package/src/filing/filing-service.ts +228 -0
- package/src/heartbeat/heartbeat-service.ts +96 -7
- package/src/index.ts +1 -1
- package/src/mcp/client.ts +6 -0
- package/src/mcp/mcp-oauth-provider.ts +149 -27
- package/src/memory/admin.ts +33 -32
- package/src/memory/app-store.ts +69 -0
- package/src/memory/conversation-bootstrap.ts +1 -1
- package/src/memory/conversation-crud.ts +151 -117
- package/src/memory/conversation-directories.ts +39 -0
- package/src/memory/conversation-group-migration.ts +66 -6
- package/src/memory/conversation-queries.ts +58 -12
- package/src/memory/conversation-title-service.ts +1 -0
- package/src/memory/db-init.ts +182 -376
- package/src/memory/embedding-local.ts +1 -1
- package/src/memory/graph/bootstrap.ts +75 -66
- package/src/memory/graph/capability-seed.ts +167 -17
- package/src/memory/graph/consolidation.ts +38 -4
- package/src/memory/graph/conversation-graph-memory.ts +133 -104
- package/src/memory/graph/extraction-job.ts +9 -4
- package/src/memory/graph/extraction.ts +66 -23
- package/src/memory/graph/graph-memory-state-store.ts +37 -0
- package/src/memory/graph/graph-search.ts +29 -15
- package/src/memory/graph/injection.ts +38 -8
- package/src/memory/graph/inspect.ts +12 -3
- package/src/memory/graph/retriever.ts +365 -262
- package/src/memory/graph/store.test.ts +48 -0
- package/src/memory/graph/store.ts +150 -11
- package/src/memory/graph/tool-handlers.ts +84 -209
- package/src/memory/graph/tools.ts +8 -52
- package/src/memory/graph/types.ts +24 -0
- package/src/memory/group-crud.ts +25 -9
- package/src/memory/job-handlers/cleanup.ts +44 -1
- package/src/memory/jobs-store.ts +70 -60
- package/src/memory/jobs-worker.ts +44 -28
- package/src/memory/llm-request-log-store.ts +96 -12
- package/src/memory/memory-recall-log-store.ts +49 -5
- package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
- package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
- package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
- package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
- package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
- package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
- package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
- package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
- package/src/memory/migrations/index.ts +8 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/conversations.ts +14 -0
- package/src/memory/schema/infrastructure.ts +8 -1
- package/src/memory/schema/memory-core.ts +0 -51
- package/src/memory/schema/memory-graph.ts +15 -0
- package/src/memory/task-memory-cleanup.ts +30 -11
- package/src/messaging/provider.ts +1 -1
- package/src/notifications/broadcaster.ts +6 -0
- package/src/notifications/conversation-pairing.ts +12 -4
- package/src/notifications/copy-composer.ts +86 -0
- package/src/notifications/decision-engine.ts +35 -0
- package/src/notifications/emit-signal.ts +14 -0
- package/src/notifications/signal.ts +11 -0
- package/src/oauth/platform-connection.test.ts +2 -2
- package/src/oauth/seed-providers.ts +1 -0
- package/src/permissions/checker.ts +15 -4
- package/src/permissions/defaults.ts +7 -8
- package/src/permissions/permission-mode-store.ts +180 -0
- package/src/permissions/permission-mode.ts +31 -0
- package/src/permissions/prompter.ts +0 -2
- package/src/permissions/workspace-policy.ts +9 -0
- package/src/platform/client.ts +1 -1
- package/src/prompts/system-prompt.ts +59 -7
- package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
- package/src/prompts/templates/BOOTSTRAP.md +76 -162
- package/src/prompts/templates/HEARTBEAT.md +3 -1
- package/src/prompts/templates/SOUL.md +30 -9
- package/src/prompts/templates/UPDATES.md +8 -0
- package/src/providers/anthropic/client.ts +107 -219
- package/src/runtime/assistant-event-hub.ts +22 -0
- package/src/runtime/auth/route-policy.ts +23 -0
- package/src/runtime/auth/token-service.ts +8 -0
- package/src/runtime/http-server.ts +32 -2
- package/src/runtime/http-types.ts +12 -1
- package/src/runtime/migrations/vbundle-builder.ts +389 -3
- package/src/runtime/migrations/vbundle-importer.ts +8 -6
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
- package/src/runtime/routes/app-management-routes.ts +1 -11
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
- package/src/runtime/routes/archive-utils.ts +29 -0
- package/src/runtime/routes/avatar-routes.ts +2 -9
- package/src/runtime/routes/btw-routes.ts +14 -1
- package/src/runtime/routes/conversation-analysis-routes.ts +185 -0
- package/src/runtime/routes/conversation-management-routes.ts +1 -14
- package/src/runtime/routes/conversation-query-routes.ts +49 -3
- package/src/runtime/routes/conversation-routes.ts +270 -44
- package/src/runtime/routes/group-routes.ts +22 -8
- package/src/runtime/routes/heartbeat-routes.ts +4 -10
- package/src/runtime/routes/identity-routes.ts +53 -18
- package/src/runtime/routes/llm-context-normalization.ts +14 -10
- package/src/runtime/routes/log-export/AGENTS.md +104 -0
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
- package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
- package/src/runtime/routes/log-export-routes.ts +41 -278
- package/src/runtime/routes/memory-item-routes.test.ts +168 -233
- package/src/runtime/routes/migration-routes.ts +18 -7
- package/src/runtime/routes/profiler-routes.ts +350 -0
- package/src/runtime/routes/schedule-routes.ts +27 -12
- package/src/runtime/routes/settings-routes.ts +95 -8
- package/src/runtime/routes/subagents-routes.ts +28 -7
- package/src/runtime/routes/user-route-dispatcher.ts +223 -0
- package/src/runtime/routes/user-routes.ts +41 -0
- package/src/runtime/routes/workspace-routes.ts +0 -1
- package/src/schedule/schedule-store.ts +30 -0
- package/src/schedule/scheduler.ts +45 -18
- package/src/skills/catalog-install.ts +10 -2
- package/src/skills/inline-command-runner.ts +12 -14
- package/src/skills/managed-store.ts +2 -2
- package/src/skills/skill-memory.ts +1 -293
- package/src/subagent/index.ts +13 -3
- package/src/subagent/manager.ts +308 -29
- package/src/subagent/types.ts +68 -0
- package/src/tasks/task-runner.ts +4 -4
- package/src/tools/apps/executors.ts +29 -4
- package/src/tools/filesystem/list.ts +93 -0
- package/src/tools/permission-checker.ts +78 -18
- package/src/tools/registry.ts +4 -0
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +1 -0
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/secret-detection-handler.ts +0 -1
- package/src/tools/shared/filesystem/errors.ts +5 -0
- package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
- package/src/tools/shared/filesystem/types.ts +17 -0
- package/src/tools/shared/shell-output.ts +31 -2
- package/src/tools/skills/sandbox-runner.ts +3 -6
- package/src/tools/subagent/abort.ts +12 -2
- package/src/tools/subagent/message.ts +9 -2
- package/src/tools/subagent/notify-parent.ts +79 -0
- package/src/tools/subagent/read.ts +29 -8
- package/src/tools/subagent/resolve.ts +21 -0
- package/src/tools/subagent/spawn.ts +2 -0
- package/src/tools/subagent/status.ts +11 -1
- package/src/tools/system/avatar-generator.ts +3 -3
- package/src/tools/system/register.ts +23 -0
- package/src/tools/system/set-permission-mode.ts +103 -0
- package/src/tools/terminal/parser.ts +30 -5
- package/src/tools/terminal/safe-env.ts +16 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
- package/src/tools/terminal/sandbox.ts +4 -1
- package/src/tools/terminal/shell.ts +3 -5
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +2 -3
- package/src/util/logger.ts +1 -1
- package/src/util/platform.ts +50 -17
- package/src/watcher/provider-types.ts +1 -1
- package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
- package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
- package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
- package/src/workspace/migrations/029-seed-pkb.ts +85 -0
- package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/workspace/top-level-renderer.ts +5 -9
- package/src/__tests__/cli-memory.test.ts +0 -377
- package/src/__tests__/clipboard.test.ts +0 -88
- package/src/cli/cli-memory.ts +0 -179
- package/src/util/clipboard.ts +0 -34
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { stripExistingMemoryInjections } from "../memory/graph/conversation-graph-memory.js";
|
|
4
|
+
import type { ContentBlock, Message } from "../providers/types.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// stripExistingMemoryInjections — removes memory-injected blocks from the
|
|
8
|
+
// front of the last user message while preserving user-attached content.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function userMsg(...content: ContentBlock[]): Message {
|
|
12
|
+
return { role: "user", content };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assistantMsg(text: string): Message {
|
|
16
|
+
return { role: "assistant", content: [{ type: "text", text }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const textBlock = (text: string): ContentBlock => ({ type: "text", text });
|
|
20
|
+
|
|
21
|
+
const imageBlock: ContentBlock = {
|
|
22
|
+
type: "image",
|
|
23
|
+
source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const memoryTextBlock: ContentBlock = {
|
|
27
|
+
type: "text",
|
|
28
|
+
text: "<memory __injected>\nSome recalled context\n</memory>",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const memoryImageMarker: ContentBlock = {
|
|
32
|
+
type: "text",
|
|
33
|
+
text: "<memory_image __injected>\nA photo of a sunset",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const memoryImageClose: ContentBlock = {
|
|
37
|
+
type: "text",
|
|
38
|
+
text: "</memory_image>",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Legacy 2-block format (persisted in older conversations)
|
|
42
|
+
const legacyMemoryImageMarker: ContentBlock = {
|
|
43
|
+
type: "text",
|
|
44
|
+
text: "<memory_image>A photo of a sunset</memory_image>",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const memoryImage: ContentBlock = {
|
|
48
|
+
type: "image",
|
|
49
|
+
source: {
|
|
50
|
+
type: "base64",
|
|
51
|
+
media_type: "image/jpeg",
|
|
52
|
+
data: "/9j/4AAQ==",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("stripExistingMemoryInjections", () => {
|
|
57
|
+
test("no-op when content has no memory blocks", () => {
|
|
58
|
+
const messages = [userMsg(textBlock("hello"), imageBlock)];
|
|
59
|
+
const result = stripExistingMemoryInjections(messages);
|
|
60
|
+
expect(result).toEqual(messages);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("no-op for empty messages array", () => {
|
|
64
|
+
const result = stripExistingMemoryInjections([]);
|
|
65
|
+
expect(result).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no-op when last message is assistant role", () => {
|
|
69
|
+
const messages = [userMsg(textBlock("hi")), assistantMsg("hey")];
|
|
70
|
+
const result = stripExistingMemoryInjections(messages);
|
|
71
|
+
expect(result).toEqual(messages);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("strips memory text block", () => {
|
|
75
|
+
const messages = [userMsg(memoryTextBlock, textBlock("hello"))];
|
|
76
|
+
const result = stripExistingMemoryInjections(messages);
|
|
77
|
+
expect(result[0].content).toEqual([textBlock("hello")]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("strips 3-block memory image (marker + image + close)", () => {
|
|
81
|
+
const messages = [
|
|
82
|
+
userMsg(memoryTextBlock, memoryImageMarker, memoryImage, memoryImageClose, textBlock("hi")),
|
|
83
|
+
];
|
|
84
|
+
const result = stripExistingMemoryInjections(messages);
|
|
85
|
+
expect(result[0].content).toEqual([textBlock("hi")]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("strips multiple 3-block memory image groups", () => {
|
|
89
|
+
const messages = [
|
|
90
|
+
userMsg(
|
|
91
|
+
memoryTextBlock,
|
|
92
|
+
memoryImageMarker,
|
|
93
|
+
memoryImage,
|
|
94
|
+
memoryImageClose,
|
|
95
|
+
memoryImageMarker,
|
|
96
|
+
memoryImage,
|
|
97
|
+
memoryImageClose,
|
|
98
|
+
textBlock("hello"),
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
const result = stripExistingMemoryInjections(messages);
|
|
102
|
+
expect(result[0].content).toEqual([textBlock("hello")]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("strips legacy 2-block memory image (no closing tag)", () => {
|
|
106
|
+
const messages = [
|
|
107
|
+
userMsg(memoryTextBlock, legacyMemoryImageMarker, memoryImage, textBlock("hi")),
|
|
108
|
+
];
|
|
109
|
+
const result = stripExistingMemoryInjections(messages);
|
|
110
|
+
expect(result[0].content).toEqual([textBlock("hi")]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("preserves user-attached image when it is the only content", () => {
|
|
114
|
+
const messages = [userMsg(imageBlock)];
|
|
115
|
+
const result = stripExistingMemoryInjections(messages);
|
|
116
|
+
expect(result[0].content).toEqual([imageBlock]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("preserves user-attached image with text", () => {
|
|
120
|
+
const messages = [userMsg(imageBlock, textBlock("what is this?"))];
|
|
121
|
+
const result = stripExistingMemoryInjections(messages);
|
|
122
|
+
expect(result[0].content).toEqual([
|
|
123
|
+
imageBlock,
|
|
124
|
+
textBlock("what is this?"),
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("preserves user image after stripping 3-block memory blocks", () => {
|
|
129
|
+
const messages = [
|
|
130
|
+
userMsg(
|
|
131
|
+
memoryTextBlock,
|
|
132
|
+
memoryImageMarker,
|
|
133
|
+
memoryImage,
|
|
134
|
+
memoryImageClose,
|
|
135
|
+
imageBlock,
|
|
136
|
+
textBlock("look at this"),
|
|
137
|
+
),
|
|
138
|
+
];
|
|
139
|
+
const result = stripExistingMemoryInjections(messages);
|
|
140
|
+
expect(result[0].content).toEqual([
|
|
141
|
+
imageBlock,
|
|
142
|
+
textBlock("look at this"),
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("preserves user image-only message after stripping memory blocks", () => {
|
|
147
|
+
const messages = [userMsg(memoryTextBlock, imageBlock)];
|
|
148
|
+
const result = stripExistingMemoryInjections(messages);
|
|
149
|
+
expect(result[0].content).toEqual([imageBlock]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("does not modify earlier messages", () => {
|
|
153
|
+
const earlier = userMsg(textBlock("first"));
|
|
154
|
+
const messages = [earlier, assistantMsg("ok"), userMsg(memoryTextBlock, textBlock("second"))];
|
|
155
|
+
const result = stripExistingMemoryInjections(messages);
|
|
156
|
+
expect(result[0]).toBe(earlier);
|
|
157
|
+
expect(result[2].content).toEqual([textBlock("second")]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("does not strip user text that equals </memory_image>", () => {
|
|
161
|
+
const messages = [userMsg(textBlock("</memory_image>"))];
|
|
162
|
+
const result = stripExistingMemoryInjections(messages);
|
|
163
|
+
expect(result[0].content).toEqual([textBlock("</memory_image>")]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("does not strip </memory_image> after memory text block (no image context)", () => {
|
|
167
|
+
const messages = [
|
|
168
|
+
userMsg(memoryTextBlock, textBlock("</memory_image>"), textBlock("hello")),
|
|
169
|
+
];
|
|
170
|
+
const result = stripExistingMemoryInjections(messages);
|
|
171
|
+
expect(result[0].content).toEqual([textBlock("</memory_image>"), textBlock("hello")]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("strips images-first then text (actual injectMemoryBlock order)", () => {
|
|
175
|
+
const messages = [
|
|
176
|
+
userMsg(
|
|
177
|
+
memoryImageMarker,
|
|
178
|
+
memoryImage,
|
|
179
|
+
memoryImageClose,
|
|
180
|
+
memoryTextBlock,
|
|
181
|
+
textBlock("hello"),
|
|
182
|
+
),
|
|
183
|
+
];
|
|
184
|
+
const result = stripExistingMemoryInjections(messages);
|
|
185
|
+
expect(result[0].content).toEqual([textBlock("hello")]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for parseSubagentMessages — verifies that tool result content is
|
|
3
|
+
* correctly extracted from both string and array formats.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { parseSubagentMessages } from "../runtime/routes/subagents-routes.js";
|
|
9
|
+
|
|
10
|
+
function msg(role: string, content: unknown[]) {
|
|
11
|
+
return { role, content: JSON.stringify(content) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("parseSubagentMessages", () => {
|
|
15
|
+
test("extracts string tool_result content", () => {
|
|
16
|
+
const messages = [
|
|
17
|
+
msg("user", [{ type: "text", text: "Do something" }]),
|
|
18
|
+
msg("assistant", [
|
|
19
|
+
{ type: "tool_use", id: "t1", name: "web_search", input: { query: "test" } },
|
|
20
|
+
]),
|
|
21
|
+
msg("user", [
|
|
22
|
+
{ type: "tool_result", tool_use_id: "t1", content: "Search results here" },
|
|
23
|
+
]),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const result = parseSubagentMessages("sub-1", messages);
|
|
27
|
+
const toolResult = result.events.find((e) => e.type === "tool_result");
|
|
28
|
+
expect(toolResult).toBeDefined();
|
|
29
|
+
expect(toolResult!.content).toBe("Search results here");
|
|
30
|
+
expect(toolResult!.toolName).toBe("web_search");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("extracts array-format tool_result content", () => {
|
|
34
|
+
const messages = [
|
|
35
|
+
msg("user", [{ type: "text", text: "Do something" }]),
|
|
36
|
+
msg("assistant", [
|
|
37
|
+
{ type: "tool_use", id: "t2", name: "file_read", input: { file_path: "/tmp/test.txt" } },
|
|
38
|
+
]),
|
|
39
|
+
msg("user", [
|
|
40
|
+
{
|
|
41
|
+
type: "tool_result",
|
|
42
|
+
tool_use_id: "t2",
|
|
43
|
+
content: [
|
|
44
|
+
{ type: "text", text: "Line 1 of file" },
|
|
45
|
+
{ type: "text", text: "Line 2 of file" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const result = parseSubagentMessages("sub-1", messages);
|
|
52
|
+
const toolResult = result.events.find((e) => e.type === "tool_result");
|
|
53
|
+
expect(toolResult).toBeDefined();
|
|
54
|
+
expect(toolResult!.content).toBe("Line 1 of file\nLine 2 of file");
|
|
55
|
+
expect(toolResult!.toolName).toBe("file_read");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles null tool_result content gracefully", () => {
|
|
59
|
+
const messages = [
|
|
60
|
+
msg("user", [{ type: "text", text: "Do something" }]),
|
|
61
|
+
msg("assistant", [
|
|
62
|
+
{ type: "tool_use", id: "t3", name: "bash", input: { command: "echo hi" } },
|
|
63
|
+
]),
|
|
64
|
+
msg("user", [
|
|
65
|
+
{ type: "tool_result", tool_use_id: "t3", content: null },
|
|
66
|
+
]),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const result = parseSubagentMessages("sub-1", messages);
|
|
70
|
+
const toolResult = result.events.find((e) => e.type === "tool_result");
|
|
71
|
+
expect(toolResult).toBeDefined();
|
|
72
|
+
expect(toolResult!.content).toBe("");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("extracts objective from first user message", () => {
|
|
76
|
+
const messages = [
|
|
77
|
+
msg("user", [{ type: "text", text: "Research vampire lore" }]),
|
|
78
|
+
msg("assistant", [{ type: "text", text: "On it." }]),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const result = parseSubagentMessages("sub-1", messages);
|
|
82
|
+
expect(result.objective).toBe("Research vampire lore");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
4
|
+
import { SubagentManager } from "../subagent/manager.js";
|
|
5
|
+
import type { SubagentState } from "../subagent/types.js";
|
|
6
|
+
|
|
7
|
+
/** Minimal shape matching the private ManagedSubagent interface for test injection. */
|
|
8
|
+
interface FakeManagedSubagent {
|
|
9
|
+
conversation: {
|
|
10
|
+
abort: () => void;
|
|
11
|
+
dispose: () => void;
|
|
12
|
+
messages: Array<{
|
|
13
|
+
role: string;
|
|
14
|
+
content: Array<{ type: string; text: string }>;
|
|
15
|
+
}>;
|
|
16
|
+
sendToClient: (msg: ServerMessage) => void;
|
|
17
|
+
persistUserMessage?: (msg: string) => string;
|
|
18
|
+
runAgentLoop?: () => Promise<void>;
|
|
19
|
+
enqueueMessage?: () => { rejected: boolean; queued: boolean };
|
|
20
|
+
usageStats: {
|
|
21
|
+
inputTokens: number;
|
|
22
|
+
outputTokens: number;
|
|
23
|
+
estimatedCost: number;
|
|
24
|
+
};
|
|
25
|
+
} | null;
|
|
26
|
+
state: SubagentState;
|
|
27
|
+
parentSendToClient: (msg: ServerMessage) => void;
|
|
28
|
+
retainedUntil?: number;
|
|
29
|
+
hadEnqueuedMessages?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Type-safe accessor for SubagentManager's private internals via bracket notation. */
|
|
33
|
+
interface ManagerInternals {
|
|
34
|
+
subagents: Map<string, FakeManagedSubagent>;
|
|
35
|
+
parentToChildren: Map<string, Set<string>>;
|
|
36
|
+
runSubagent: (subagentId: string, objective: string) => Promise<void>;
|
|
37
|
+
sweepTerminal: () => void;
|
|
38
|
+
stopSweep: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asInternals(manager: SubagentManager): ManagerInternals {
|
|
42
|
+
return manager as unknown as ManagerInternals;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeFakeConversation(): NonNullable<
|
|
46
|
+
FakeManagedSubagent["conversation"]
|
|
47
|
+
> {
|
|
48
|
+
return {
|
|
49
|
+
abort: () => {},
|
|
50
|
+
dispose: () => {},
|
|
51
|
+
messages: [],
|
|
52
|
+
sendToClient: () => {},
|
|
53
|
+
usageStats: { inputTokens: 100, outputTokens: 50, estimatedCost: 0.005 },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function injectFakeSubagent(
|
|
58
|
+
manager: SubagentManager,
|
|
59
|
+
subagentId: string,
|
|
60
|
+
state: SubagentState,
|
|
61
|
+
parentSendToClient?: (msg: ServerMessage) => void,
|
|
62
|
+
conversation?: FakeManagedSubagent["conversation"],
|
|
63
|
+
): void {
|
|
64
|
+
const internals = asInternals(manager);
|
|
65
|
+
|
|
66
|
+
internals.subagents.set(subagentId, {
|
|
67
|
+
conversation: conversation === undefined ? makeFakeConversation() : conversation,
|
|
68
|
+
state,
|
|
69
|
+
parentSendToClient: parentSendToClient ?? (() => {}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const parentId = state.config.parentConversationId;
|
|
73
|
+
if (!internals.parentToChildren.has(parentId)) {
|
|
74
|
+
internals.parentToChildren.set(parentId, new Set());
|
|
75
|
+
}
|
|
76
|
+
internals.parentToChildren.get(parentId)!.add(subagentId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeState(
|
|
80
|
+
subagentId: string,
|
|
81
|
+
overrides: Partial<SubagentState> = {},
|
|
82
|
+
): SubagentState {
|
|
83
|
+
return {
|
|
84
|
+
config: {
|
|
85
|
+
id: subagentId,
|
|
86
|
+
parentConversationId: "parent-sess-1",
|
|
87
|
+
label: "Test subagent",
|
|
88
|
+
objective: "Do something",
|
|
89
|
+
},
|
|
90
|
+
status: "running",
|
|
91
|
+
conversationId: "conv-sub-1",
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
usage: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("SubagentManager terminal disposal", () => {
|
|
99
|
+
test("completed subagent has conversation === null but state is preserved", async () => {
|
|
100
|
+
const manager = new SubagentManager();
|
|
101
|
+
const subagentId = "sub-1";
|
|
102
|
+
const state = makeState(subagentId);
|
|
103
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
104
|
+
|
|
105
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
106
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
107
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
108
|
+
|
|
109
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
110
|
+
|
|
111
|
+
expect(state.status).toBe("completed");
|
|
112
|
+
expect(managed.conversation).toBeNull();
|
|
113
|
+
// State is still accessible via getState.
|
|
114
|
+
expect(manager.getState(subagentId)).toBeDefined();
|
|
115
|
+
expect(manager.getState(subagentId)!.status).toBe("completed");
|
|
116
|
+
expect(managed.retainedUntil).toBeGreaterThan(Date.now());
|
|
117
|
+
|
|
118
|
+
// Cleanup
|
|
119
|
+
asInternals(manager).stopSweep();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("failed subagent releases live conversation", async () => {
|
|
123
|
+
const manager = new SubagentManager();
|
|
124
|
+
const subagentId = "sub-1";
|
|
125
|
+
const state = makeState(subagentId);
|
|
126
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
127
|
+
|
|
128
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
129
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
130
|
+
managed.conversation!.runAgentLoop = async () => {
|
|
131
|
+
throw new Error("LLM error");
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
135
|
+
|
|
136
|
+
expect(state.status).toBe("failed");
|
|
137
|
+
expect(managed.conversation).toBeNull();
|
|
138
|
+
expect(manager.getState(subagentId)).toBeDefined();
|
|
139
|
+
|
|
140
|
+
asInternals(manager).stopSweep();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("aborted subagent releases conversation when runSubagent catches", async () => {
|
|
144
|
+
const manager = new SubagentManager();
|
|
145
|
+
const subagentId = "sub-1";
|
|
146
|
+
const state = makeState(subagentId, { status: "aborted" });
|
|
147
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
148
|
+
|
|
149
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
150
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
151
|
+
managed.conversation!.runAgentLoop = async () => {
|
|
152
|
+
throw new Error("Conversation aborted");
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
156
|
+
|
|
157
|
+
expect(managed.conversation).toBeNull();
|
|
158
|
+
|
|
159
|
+
asInternals(manager).stopSweep();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("sendMessage returns 'terminal' after conversation is released", async () => {
|
|
163
|
+
const manager = new SubagentManager();
|
|
164
|
+
const subagentId = "sub-1";
|
|
165
|
+
const state = makeState(subagentId);
|
|
166
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
167
|
+
|
|
168
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
169
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
170
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
171
|
+
|
|
172
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
173
|
+
|
|
174
|
+
expect(managed.conversation).toBeNull();
|
|
175
|
+
const result = await manager.sendMessage(subagentId, "hello");
|
|
176
|
+
expect(result).toBe("terminal");
|
|
177
|
+
|
|
178
|
+
asInternals(manager).stopSweep();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("parent disposal removes terminal child state", () => {
|
|
182
|
+
const manager = new SubagentManager();
|
|
183
|
+
injectFakeSubagent(
|
|
184
|
+
manager,
|
|
185
|
+
"sub-1",
|
|
186
|
+
makeState("sub-1", { status: "completed" }),
|
|
187
|
+
undefined,
|
|
188
|
+
null, // already released
|
|
189
|
+
);
|
|
190
|
+
asInternals(manager).subagents.get("sub-1")!.retainedUntil =
|
|
191
|
+
Date.now() + 1000;
|
|
192
|
+
|
|
193
|
+
// Verify the terminal subagent exists.
|
|
194
|
+
expect(manager.getState("sub-1")).toBeDefined();
|
|
195
|
+
|
|
196
|
+
// Parent disposal should remove it.
|
|
197
|
+
manager.abortAllForParent("parent-sess-1");
|
|
198
|
+
|
|
199
|
+
expect(manager.getState("sub-1")).toBeUndefined();
|
|
200
|
+
expect(manager.getChildrenOf("parent-sess-1")).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("TTL sweep removes expired terminal entries but not active subagents", () => {
|
|
204
|
+
const manager = new SubagentManager();
|
|
205
|
+
|
|
206
|
+
// Terminal entry with expired retention.
|
|
207
|
+
injectFakeSubagent(
|
|
208
|
+
manager,
|
|
209
|
+
"sub-expired",
|
|
210
|
+
makeState("sub-expired", { status: "completed" }),
|
|
211
|
+
undefined,
|
|
212
|
+
null,
|
|
213
|
+
);
|
|
214
|
+
asInternals(manager).subagents.get("sub-expired")!.retainedUntil =
|
|
215
|
+
Date.now() - 1000; // already expired
|
|
216
|
+
|
|
217
|
+
// Active subagent — no retainedUntil.
|
|
218
|
+
injectFakeSubagent(
|
|
219
|
+
manager,
|
|
220
|
+
"sub-active",
|
|
221
|
+
makeState("sub-active", { status: "running" }),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Terminal but not yet expired.
|
|
225
|
+
injectFakeSubagent(
|
|
226
|
+
manager,
|
|
227
|
+
"sub-fresh",
|
|
228
|
+
makeState("sub-fresh", { status: "completed" }),
|
|
229
|
+
undefined,
|
|
230
|
+
null,
|
|
231
|
+
);
|
|
232
|
+
asInternals(manager).subagents.get("sub-fresh")!.retainedUntil =
|
|
233
|
+
Date.now() + 60_000;
|
|
234
|
+
|
|
235
|
+
asInternals(manager).sweepTerminal();
|
|
236
|
+
|
|
237
|
+
expect(manager.getState("sub-expired")).toBeUndefined();
|
|
238
|
+
expect(manager.getState("sub-active")).toBeDefined();
|
|
239
|
+
expect(manager.getState("sub-fresh")).toBeDefined();
|
|
240
|
+
|
|
241
|
+
asInternals(manager).stopSweep();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("dispose handles already-released conversation gracefully", () => {
|
|
245
|
+
const manager = new SubagentManager();
|
|
246
|
+
injectFakeSubagent(
|
|
247
|
+
manager,
|
|
248
|
+
"sub-1",
|
|
249
|
+
makeState("sub-1", { status: "completed" }),
|
|
250
|
+
undefined,
|
|
251
|
+
null, // conversation already released
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Should not throw.
|
|
255
|
+
manager.dispose("sub-1");
|
|
256
|
+
expect(manager.getState("sub-1")).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("usage stats are preserved after conversation release", async () => {
|
|
260
|
+
const manager = new SubagentManager();
|
|
261
|
+
const subagentId = "sub-1";
|
|
262
|
+
const state = makeState(subagentId);
|
|
263
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
264
|
+
|
|
265
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
266
|
+
managed.conversation!.usageStats = {
|
|
267
|
+
inputTokens: 500,
|
|
268
|
+
outputTokens: 200,
|
|
269
|
+
estimatedCost: 0.05,
|
|
270
|
+
};
|
|
271
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
272
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
273
|
+
|
|
274
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
275
|
+
|
|
276
|
+
expect(managed.conversation).toBeNull();
|
|
277
|
+
expect(state.usage).toEqual({
|
|
278
|
+
inputTokens: 500,
|
|
279
|
+
outputTokens: 200,
|
|
280
|
+
estimatedCost: 0.05,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
asInternals(manager).stopSweep();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("defers release when messages were enqueued during run", async () => {
|
|
287
|
+
const manager = new SubagentManager();
|
|
288
|
+
const subagentId = "sub-1";
|
|
289
|
+
const state = makeState(subagentId);
|
|
290
|
+
injectFakeSubagent(manager, subagentId, state);
|
|
291
|
+
|
|
292
|
+
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
293
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
294
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
295
|
+
// Simulate that a message was enqueued during the run.
|
|
296
|
+
managed.hadEnqueuedMessages = true;
|
|
297
|
+
|
|
298
|
+
await asInternals(manager).runSubagent(subagentId, "Do something");
|
|
299
|
+
|
|
300
|
+
// Conversation should NOT be released — drain may still be active.
|
|
301
|
+
expect(managed.conversation).not.toBeNull();
|
|
302
|
+
// But retainedUntil should be set for eventual TTL cleanup.
|
|
303
|
+
expect(managed.retainedUntil).toBeGreaterThan(Date.now());
|
|
304
|
+
expect(state.status).toBe("completed");
|
|
305
|
+
|
|
306
|
+
asInternals(manager).stopSweep();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -22,7 +22,7 @@ interface FakeManagedSubagent {
|
|
|
22
22
|
outputTokens: number;
|
|
23
23
|
estimatedCost: number;
|
|
24
24
|
};
|
|
25
|
-
};
|
|
25
|
+
} | null;
|
|
26
26
|
state: SubagentState;
|
|
27
27
|
parentSendToClient: (msg: ServerMessage) => void;
|
|
28
28
|
}
|
|
@@ -32,6 +32,7 @@ interface ManagerInternals {
|
|
|
32
32
|
subagents: Map<string, FakeManagedSubagent>;
|
|
33
33
|
parentToChildren: Map<string, Set<string>>;
|
|
34
34
|
runSubagent: (subagentId: string, objective: string) => Promise<void>;
|
|
35
|
+
stopSweep: () => void;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function asInternals(manager: SubagentManager): ManagerInternals {
|
|
@@ -275,8 +276,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
275
276
|
|
|
276
277
|
// Patch the fake conversation to simulate a successful agent loop.
|
|
277
278
|
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
278
|
-
managed.conversation
|
|
279
|
-
managed.conversation
|
|
279
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
280
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
280
281
|
|
|
281
282
|
const notifications: { parentConversationId: string; message: string }[] =
|
|
282
283
|
[];
|
|
@@ -298,6 +299,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
298
299
|
'[Subagent "Test subagent" completed]',
|
|
299
300
|
);
|
|
300
301
|
expect(notifications[0].message).toContain("subagent_read");
|
|
302
|
+
|
|
303
|
+
asInternals(manager).stopSweep();
|
|
301
304
|
});
|
|
302
305
|
|
|
303
306
|
test("failed subagent notifies parent with error and asks user before retry", async () => {
|
|
@@ -309,8 +312,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
309
312
|
// Patch the fake conversation to simulate a failure.
|
|
310
313
|
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
311
314
|
|
|
312
|
-
managed.conversation
|
|
313
|
-
managed.conversation
|
|
315
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
316
|
+
managed.conversation!.runAgentLoop = async () => {
|
|
314
317
|
throw new Error("API rate limit exceeded");
|
|
315
318
|
};
|
|
316
319
|
|
|
@@ -333,6 +336,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
333
336
|
expect(notifications[0].message).toContain("failed");
|
|
334
337
|
expect(notifications[0].message).toContain("API rate limit exceeded");
|
|
335
338
|
expect(notifications[0].message).toContain("Do NOT re-spawn");
|
|
339
|
+
|
|
340
|
+
asInternals(manager).stopSweep();
|
|
336
341
|
});
|
|
337
342
|
|
|
338
343
|
test("failed subagent does not notify if already aborted", async () => {
|
|
@@ -343,8 +348,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
343
348
|
|
|
344
349
|
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
345
350
|
|
|
346
|
-
managed.conversation
|
|
347
|
-
managed.conversation
|
|
351
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
352
|
+
managed.conversation!.runAgentLoop = async () => {
|
|
348
353
|
throw new Error("Conversation aborted");
|
|
349
354
|
};
|
|
350
355
|
|
|
@@ -358,6 +363,8 @@ describe("SubagentManager notifyParent (via runSubagent)", () => {
|
|
|
358
363
|
|
|
359
364
|
// Should NOT notify — status was already terminal (aborted).
|
|
360
365
|
expect(notifications).toHaveLength(0);
|
|
366
|
+
|
|
367
|
+
asInternals(manager).stopSweep();
|
|
361
368
|
});
|
|
362
369
|
});
|
|
363
370
|
|
|
@@ -422,9 +429,9 @@ describe("SubagentManager abort race guard", () => {
|
|
|
422
429
|
// Patch conversation to simulate successful completion after abort.
|
|
423
430
|
const managed = asInternals(manager).subagents.get(subagentId)!;
|
|
424
431
|
|
|
425
|
-
managed.conversation
|
|
426
|
-
managed.conversation
|
|
427
|
-
managed.conversation
|
|
432
|
+
managed.conversation!.persistUserMessage = () => "msg-1";
|
|
433
|
+
managed.conversation!.runAgentLoop = async () => {};
|
|
434
|
+
managed.conversation!.messages = [
|
|
428
435
|
{ role: "assistant", content: [{ type: "text", text: "Done!" }] },
|
|
429
436
|
];
|
|
430
437
|
|
|
@@ -440,6 +447,8 @@ describe("SubagentManager abort race guard", () => {
|
|
|
440
447
|
expect(notifications).toHaveLength(0);
|
|
441
448
|
// Status should remain aborted, not overwritten to completed.
|
|
442
449
|
expect(state.status).toBe("aborted");
|
|
450
|
+
|
|
451
|
+
asInternals(manager).stopSweep();
|
|
443
452
|
});
|
|
444
453
|
});
|
|
445
454
|
|