@oar-sh/oar 0.9.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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
// Pure mapping from Copilot SDK `SessionEvent`s onto the relay's channel
|
|
2
|
+
// actions — the same six channels the Claude and Cursor workers publish on
|
|
3
|
+
// (init / stream / thought / activity / subagent / result), so the transcript,
|
|
4
|
+
// reasoning bubbles and activity rows render identically whichever engine ran
|
|
5
|
+
// the turn. No I/O, no SDK import: one instance per turn, fed every event the
|
|
6
|
+
// session emits while that turn is in flight.
|
|
7
|
+
//
|
|
8
|
+
// Shapes are taken from the phase-0 spike dumps, which are checked in under
|
|
9
|
+
// ./fixtures and replayed by the tests. Four findings drive most of the design
|
|
10
|
+
// decisions below and are worth stating up front:
|
|
11
|
+
//
|
|
12
|
+
// 1. `assistant.turn_end` fires once per MODEL CALL, not once per user
|
|
13
|
+
// request — a tool-using turn emits turn_start/turn_end, runs the tool,
|
|
14
|
+
// then opens a second turn. Only `session.idle` ends the interaction.
|
|
15
|
+
// 2. A quota-failed turn never reaches `session.idle` at all (the spike's
|
|
16
|
+
// event-dump has 2 idles for 3 turns), so `session.error` has to be a
|
|
17
|
+
// terminator in its own right or the worker hangs on every quota failure.
|
|
18
|
+
// 3. `turnId` is the string "0" in every event of every dump. It is not an
|
|
19
|
+
// identifier; `interactionId` is.
|
|
20
|
+
// 4. Subagent events are tagged with `agentId` on the event ENVELOPE (not on
|
|
21
|
+
// `data`), and it is absent for the root agent. See `isSubagentEvent`.
|
|
22
|
+
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
23
|
+
import { capThought } from '../../shared/thought-cap.mjs';
|
|
24
|
+
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
25
|
+
|
|
26
|
+
const MAX_TOOL_DETAIL_LENGTH = 140;
|
|
27
|
+
|
|
28
|
+
function truncate(text, maxLength = MAX_TOOL_DETAIL_LENGTH) {
|
|
29
|
+
const value = String(text || '').trim();
|
|
30
|
+
if (value.length <= maxLength) return value;
|
|
31
|
+
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The arguments most worth showing in an activity row, in preference order. */
|
|
35
|
+
const TOOL_SUMMARY_KEYS = [
|
|
36
|
+
'command',
|
|
37
|
+
'fullCommandText',
|
|
38
|
+
'file_path',
|
|
39
|
+
'filePath',
|
|
40
|
+
'path',
|
|
41
|
+
'pattern',
|
|
42
|
+
'description',
|
|
43
|
+
'url',
|
|
44
|
+
'query',
|
|
45
|
+
'question',
|
|
46
|
+
'prompt',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function renderScalar(value) {
|
|
50
|
+
if (typeof value === 'string') return truncate(value);
|
|
51
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
52
|
+
if (value === null) return 'null';
|
|
53
|
+
if (Array.isArray(value)) return `[${value.length} items]`;
|
|
54
|
+
if (typeof value === 'object') return '{…}';
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function summarizeToolInput(input) {
|
|
59
|
+
if (!input || typeof input !== 'object') return '';
|
|
60
|
+
for (const key of TOOL_SUMMARY_KEYS) {
|
|
61
|
+
const value = String(input[key] ?? '').trim();
|
|
62
|
+
if (value) return value;
|
|
63
|
+
}
|
|
64
|
+
// Fallback for tools with no recognised argument. Bounded on purpose: the
|
|
65
|
+
// result is truncated to 140 characters anyway, and a tool called with a
|
|
66
|
+
// megabyte of file contents must not be serialized in full to produce it.
|
|
67
|
+
const parts = [];
|
|
68
|
+
let budget = MAX_TOOL_DETAIL_LENGTH;
|
|
69
|
+
for (const [key, value] of Object.entries(input)) {
|
|
70
|
+
if (value === undefined || typeof value === 'function') continue;
|
|
71
|
+
const rendered = renderScalar(value);
|
|
72
|
+
if (!rendered) continue;
|
|
73
|
+
parts.push(`${key}=${rendered}`);
|
|
74
|
+
budget -= key.length + rendered.length + 2;
|
|
75
|
+
if (budget <= 0) break;
|
|
76
|
+
}
|
|
77
|
+
return parts.join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatToolActivityText(toolName, input) {
|
|
81
|
+
const name = String(toolName || '').trim() || 'tool';
|
|
82
|
+
const summary = summarizeToolInput(input);
|
|
83
|
+
return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when an event belongs to a SUBAGENT rather than the root agent.
|
|
88
|
+
*
|
|
89
|
+
* Every event interface in the SDK's generated schema carries the same
|
|
90
|
+
* optional envelope field: "Sub-agent instance identifier. Absent for events
|
|
91
|
+
* from the root/main agent and session-level events." Note it is on the
|
|
92
|
+
* envelope, NOT on `data`.
|
|
93
|
+
*
|
|
94
|
+
* The runtime forwards subagent streaming by default
|
|
95
|
+
* (`includeSubAgentStreamingEvents`, default true), and turning that flag off
|
|
96
|
+
* is not an option: a live probe against runtime 1.0.82 showed it also
|
|
97
|
+
* collapses the PARENT's tool-call argument streaming (`streaming_delta`
|
|
98
|
+
* 33→2, `tool_call_delta` 32→1), gutting main-transcript streaming. So the
|
|
99
|
+
* routing lives here instead: phase 1 dropped these events, phase 2 routes
|
|
100
|
+
* them to the subagent lane keyed on `agentId`.
|
|
101
|
+
*/
|
|
102
|
+
export function isSubagentEvent(event) {
|
|
103
|
+
return !!String(event?.agentId || '').trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The run id for a subagent event.
|
|
108
|
+
*
|
|
109
|
+
* Lifecycle events (`subagent.started` / `completed` / `failed`) carry
|
|
110
|
+
* `data.toolCallId` — the spawning tool call — while the tagged
|
|
111
|
+
* `assistant.message` that holds the subagent's actual reply carries only the
|
|
112
|
+
* envelope `agentId`. Whichever of the two is present is registered as an
|
|
113
|
+
* ALIAS of one canonical run id (see `resolveSubagentRun`), so the lane still
|
|
114
|
+
* assembles if a runtime version populates only one of them.
|
|
115
|
+
*/
|
|
116
|
+
export function subagentEventKeys(event) {
|
|
117
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
118
|
+
return {
|
|
119
|
+
agentId: sanitizeSubagentRunId(event?.agentId),
|
|
120
|
+
toolCallId: sanitizeSubagentRunId(data.toolCallId),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A short, human-readable label for a subagent, for the lane header. */
|
|
125
|
+
export function subagentDisplayName(data) {
|
|
126
|
+
return String(data?.agentDisplayName || data?.agentName || '').trim() || 'Subagent';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** "12.3k tokens · 4 tool calls · 8.1s" — whichever parts the runtime reported. */
|
|
130
|
+
export function formatSubagentStats(data) {
|
|
131
|
+
const parts = [];
|
|
132
|
+
const tokens = Number(data?.totalTokens);
|
|
133
|
+
if (Number.isFinite(tokens) && tokens > 0) parts.push(`${tokens} tokens`);
|
|
134
|
+
const toolCalls = Number(data?.totalToolCalls);
|
|
135
|
+
if (Number.isFinite(toolCalls) && toolCalls > 0) parts.push(`${toolCalls} tool calls`);
|
|
136
|
+
const durationMs = Number(data?.durationMs);
|
|
137
|
+
if (Number.isFinite(durationMs) && durationMs > 0) parts.push(`${(durationMs / 1000).toFixed(1)}s`);
|
|
138
|
+
return parts.join(' · ');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Per-turn token/cost accounting. `assistant.usage` fires once per model call,
|
|
143
|
+
* so a tool-using turn reports several — they are summed, while latency
|
|
144
|
+
* figures keep the FIRST call's time-to-first-token (that is the number a user
|
|
145
|
+
* experiences as "did it start answering").
|
|
146
|
+
*/
|
|
147
|
+
function createUsageAccumulator() {
|
|
148
|
+
const totals = {
|
|
149
|
+
inputTokens: 0,
|
|
150
|
+
outputTokens: 0,
|
|
151
|
+
cacheReadTokens: 0,
|
|
152
|
+
cacheWriteTokens: 0,
|
|
153
|
+
reasoningTokens: 0,
|
|
154
|
+
// `cost` is the PREMIUM MULTIPLIER, not money — the field name is
|
|
155
|
+
// misleading and there is no `premiumRequests` field at all. Real spend is
|
|
156
|
+
// `copilotUsage.totalNanoAiu`, summed separately below, which is what a
|
|
157
|
+
// usage card must report.
|
|
158
|
+
cost: 0,
|
|
159
|
+
totalNanoAiu: 0,
|
|
160
|
+
modelCalls: 0,
|
|
161
|
+
subagentModelCalls: 0,
|
|
162
|
+
durationMs: 0,
|
|
163
|
+
timeToFirstTokenMs: null,
|
|
164
|
+
model: '',
|
|
165
|
+
isByok: null,
|
|
166
|
+
quotaSnapshots: null,
|
|
167
|
+
};
|
|
168
|
+
function add(data, { allowModel = true } = {}) {
|
|
169
|
+
if (!data || typeof data !== 'object') return;
|
|
170
|
+
totals.modelCalls += 1;
|
|
171
|
+
if (!allowModel) totals.subagentModelCalls += 1;
|
|
172
|
+
for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'cost']) {
|
|
173
|
+
const value = Number(data[key]);
|
|
174
|
+
if (Number.isFinite(value)) totals[key] += value;
|
|
175
|
+
}
|
|
176
|
+
const nanoAiu = Number(data.copilotUsage?.totalNanoAiu);
|
|
177
|
+
if (Number.isFinite(nanoAiu)) totals.totalNanoAiu += nanoAiu;
|
|
178
|
+
const duration = Number(data.duration);
|
|
179
|
+
if (Number.isFinite(duration)) totals.durationMs += duration;
|
|
180
|
+
const ttft = Number(data.timeToFirstTokenMs);
|
|
181
|
+
if (totals.timeToFirstTokenMs === null && Number.isFinite(ttft)) totals.timeToFirstTokenMs = ttft;
|
|
182
|
+
// A subagent's usage counts toward spend but must not rename the turn's
|
|
183
|
+
// model: `task` can override the model per subagent.
|
|
184
|
+
const model = allowModel ? String(data.model || '').trim() : '';
|
|
185
|
+
if (model) totals.model = model;
|
|
186
|
+
if (typeof data.isByok === 'boolean') totals.isByok = data.isByok;
|
|
187
|
+
// Usually `{}` on a healthy call; a populated snapshot (the quota path)
|
|
188
|
+
// must not be overwritten by a later empty one.
|
|
189
|
+
if (data.quotaSnapshots && Object.keys(data.quotaSnapshots).length) {
|
|
190
|
+
totals.quotaSnapshots = data.quotaSnapshots;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
add,
|
|
195
|
+
setQuotaSnapshots(snapshots) {
|
|
196
|
+
if (snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length) {
|
|
197
|
+
totals.quotaSnapshots = snapshots;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
snapshot() {
|
|
201
|
+
return totals.modelCalls ? { ...totals } : (totals.quotaSnapshots ? { ...totals } : null);
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stateful per-turn normalizer. `normalize(event)` returns zero or more
|
|
208
|
+
* `{ channel, payload }` actions; the session process dispatches them to the
|
|
209
|
+
* relay and treats the first `result` action as the turn's terminator.
|
|
210
|
+
*/
|
|
211
|
+
export function createCopilotEventNormalizer() {
|
|
212
|
+
let sessionModel = '';
|
|
213
|
+
let sessionId = '';
|
|
214
|
+
let lastEmittedStreamText = '';
|
|
215
|
+
let aborted = false;
|
|
216
|
+
let terminalEmitted = false;
|
|
217
|
+
let contextUsage = null;
|
|
218
|
+
let interactionId = '';
|
|
219
|
+
const usage = createUsageAccumulator();
|
|
220
|
+
|
|
221
|
+
// Assistant text is assembled per `messageId`. A tool-using request produces
|
|
222
|
+
// several assistant messages (the first typically empty with `toolRequests`,
|
|
223
|
+
// a later one carrying the prose), so the reply is the ordered join of the
|
|
224
|
+
// non-empty ones rather than "the last message wins".
|
|
225
|
+
//
|
|
226
|
+
// `cachedPrefix` is the joined text of every message EXCEPT the newest one.
|
|
227
|
+
// Deltas only ever touch the newest, so composing the reply per delta is an
|
|
228
|
+
// append rather than a re-join of the whole conversation — the difference
|
|
229
|
+
// between O(n) and O(n²) work across a long streamed answer. It is dropped
|
|
230
|
+
// (rebuilt lazily) whenever a new message opens or an older one is rewritten.
|
|
231
|
+
const messageOrder = [];
|
|
232
|
+
const messageTexts = new Map();
|
|
233
|
+
let cachedPrefix = null;
|
|
234
|
+
const openThoughts = new Map(); // reasoningId -> accumulated text
|
|
235
|
+
const emittedThoughts = new Map(); // reasoningId -> last published text
|
|
236
|
+
const toolNames = new Map(); // toolCallId -> toolName
|
|
237
|
+
const permissionRequests = new Map(); // requestId -> summary
|
|
238
|
+
|
|
239
|
+
// ---- prompt segments (steering attribution) -------------------------------
|
|
240
|
+
//
|
|
241
|
+
// One interaction can answer SEVERAL prompts: a message sent mid-turn is
|
|
242
|
+
// queued by the runtime and picked up at the next model-call boundary, and
|
|
243
|
+
// the whole thing closes with a single `session.idle` (live-verified, see
|
|
244
|
+
// §4b/§5 of the plan). Each prompt the runtime actually starts work on opens
|
|
245
|
+
// with its own `user.message` event, so those events are the prompt
|
|
246
|
+
// boundaries — and they are what lets a steered queue row be answered with
|
|
247
|
+
// ITS OWN text instead of the whole interaction's transcript.
|
|
248
|
+
const segments = []; // array of arrays of messageIds, one per user.message
|
|
249
|
+
|
|
250
|
+
function currentSegment() {
|
|
251
|
+
if (!segments.length) segments.push([]);
|
|
252
|
+
return segments[segments.length - 1];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function noteMessage(messageId) {
|
|
256
|
+
const id = String(messageId || '').trim();
|
|
257
|
+
if (!id) return '';
|
|
258
|
+
if (!messageTexts.has(id)) {
|
|
259
|
+
messageTexts.set(id, '');
|
|
260
|
+
messageOrder.push(id);
|
|
261
|
+
currentSegment().push(id);
|
|
262
|
+
// The message that was newest is now part of the prefix.
|
|
263
|
+
cachedPrefix = null;
|
|
264
|
+
}
|
|
265
|
+
return id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The reply text for prompt `index` (0 = the prompt the turn opened with). */
|
|
269
|
+
function segmentText(index) {
|
|
270
|
+
const ids = segments[index];
|
|
271
|
+
if (!Array.isArray(ids) || !ids.length) return '';
|
|
272
|
+
const parts = [];
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
const text = String(messageTexts.get(id) || '');
|
|
275
|
+
if (text.trim()) parts.push(text);
|
|
276
|
+
}
|
|
277
|
+
return parts.join('\n\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- subagent lane --------------------------------------------------------
|
|
281
|
+
//
|
|
282
|
+
// Keyed on the envelope `agentId`, which a live BYOK probe confirmed is
|
|
283
|
+
// present on the lifecycle events AND on the tagged `assistant.message`
|
|
284
|
+
// carrying the reply (and is NOT equal to `data.toolCallId`). `toolCallId` is
|
|
285
|
+
// registered as an alias anyway so the lane still assembles if a future
|
|
286
|
+
// runtime tags only one of them.
|
|
287
|
+
const subagentRuns = new Map(); // runId -> { displayName, status, opened }
|
|
288
|
+
const subagentAliases = new Map(); // agentId|toolCallId -> runId
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `useToolCallId` is true only for `subagent.*` lifecycle events, where
|
|
292
|
+
* `data.toolCallId` names the tool call that SPAWNED this subagent.
|
|
293
|
+
*
|
|
294
|
+
* On any other event `toolCallId` names a tool call the subagent is MAKING,
|
|
295
|
+
* which is a different thing entirely — aliasing it would mean that if a
|
|
296
|
+
* subagent ever spawned a nested one, the child's `subagent.started` (whose
|
|
297
|
+
* `toolCallId` is that same inner call) would resolve to the PARENT's run:
|
|
298
|
+
* the child's text would merge into the parent's lane and the child's
|
|
299
|
+
* `completed` would close the parent's bubble while it was still running.
|
|
300
|
+
*/
|
|
301
|
+
function resolveSubagentRun(event, { useToolCallId = false } = {}) {
|
|
302
|
+
const { agentId, toolCallId } = subagentEventKeys(event);
|
|
303
|
+
const alias = useToolCallId ? toolCallId : null;
|
|
304
|
+
const runId = (agentId && subagentAliases.get(agentId))
|
|
305
|
+
|| (alias && subagentAliases.get(alias))
|
|
306
|
+
|| agentId
|
|
307
|
+
|| alias
|
|
308
|
+
|| null;
|
|
309
|
+
if (!runId) return null;
|
|
310
|
+
if (agentId) subagentAliases.set(agentId, runId);
|
|
311
|
+
if (alias) subagentAliases.set(alias, runId);
|
|
312
|
+
return runId;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Open a lane the moment anything references the run, even if
|
|
317
|
+
* `subagent.started` was missed or arrives out of order — a lane that only
|
|
318
|
+
* ever appears on a perfectly ordered event stream is a lane that silently
|
|
319
|
+
* vanishes on the day the stream is not perfectly ordered.
|
|
320
|
+
*/
|
|
321
|
+
function ensureSubagentRun(runId, displayName) {
|
|
322
|
+
let run = subagentRuns.get(runId);
|
|
323
|
+
if (!run) {
|
|
324
|
+
run = { displayName: displayName || 'Subagent', status: 'running', opened: false };
|
|
325
|
+
subagentRuns.set(runId, run);
|
|
326
|
+
} else if (displayName && run.displayName === 'Subagent') {
|
|
327
|
+
run.displayName = displayName;
|
|
328
|
+
}
|
|
329
|
+
if (run.opened) return [];
|
|
330
|
+
run.opened = true;
|
|
331
|
+
return [{
|
|
332
|
+
channel: 'subagent',
|
|
333
|
+
payload: {
|
|
334
|
+
subagentRunId: runId,
|
|
335
|
+
// Copilot reports one flat level: `subagent.started` names the parent
|
|
336
|
+
// TOOL CALL, not a parent agent, so there is no id to nest under. Same
|
|
337
|
+
// choice the Cursor worker makes.
|
|
338
|
+
parentSubagentId: null,
|
|
339
|
+
displayName: run.displayName,
|
|
340
|
+
status: 'running',
|
|
341
|
+
},
|
|
342
|
+
}];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function closeSubagentRun(runId, status, displayName) {
|
|
346
|
+
const actions = ensureSubagentRun(runId, displayName);
|
|
347
|
+
const run = subagentRuns.get(runId);
|
|
348
|
+
if (run) run.status = status;
|
|
349
|
+
actions.push({
|
|
350
|
+
channel: 'subagent',
|
|
351
|
+
payload: {
|
|
352
|
+
subagentRunId: runId,
|
|
353
|
+
parentSubagentId: null,
|
|
354
|
+
displayName: run?.displayName || displayName || 'Subagent',
|
|
355
|
+
status,
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
return actions;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The agent's explicit closing message, delivered via session.task_complete.
|
|
362
|
+
// In autopilot agent mode the model can finish with BOTH durable
|
|
363
|
+
// assistant.messages empty — the whole answer lives in the task summary
|
|
364
|
+
// (live burn-in, 2026-08-31: "echo CWD" produced 145 output tokens and an
|
|
365
|
+
// empty reply). The extension already treats task_complete({ summary }) as
|
|
366
|
+
// "the agent's explicit closing message — highest priority"
|
|
367
|
+
// (runtime/session-io.mjs); the same precedence applies here.
|
|
368
|
+
let taskCompleteSummary = '';
|
|
369
|
+
|
|
370
|
+
/** What the user should read: the task summary outranks composed text. */
|
|
371
|
+
function finalText() {
|
|
372
|
+
return taskCompleteSummary || composeText();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function composeText() {
|
|
376
|
+
if (!messageOrder.length) return '';
|
|
377
|
+
if (cachedPrefix === null) {
|
|
378
|
+
const parts = [];
|
|
379
|
+
for (let index = 0; index < messageOrder.length - 1; index += 1) {
|
|
380
|
+
const text = String(messageTexts.get(messageOrder[index]) || '');
|
|
381
|
+
if (text.trim()) parts.push(text);
|
|
382
|
+
}
|
|
383
|
+
cachedPrefix = parts.join('\n\n');
|
|
384
|
+
}
|
|
385
|
+
const newest = String(messageTexts.get(messageOrder[messageOrder.length - 1]) || '');
|
|
386
|
+
if (!newest.trim()) return cachedPrefix;
|
|
387
|
+
return cachedPrefix ? `${cachedPrefix}\n\n${newest}` : newest;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function streamActions() {
|
|
391
|
+
const text = composeText();
|
|
392
|
+
if (!shouldEmitStreamUpdate(text, lastEmittedStreamText)) return [];
|
|
393
|
+
lastEmittedStreamText = text;
|
|
394
|
+
return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function thoughtAction(reasoningId, text, done) {
|
|
398
|
+
return {
|
|
399
|
+
channel: 'thought',
|
|
400
|
+
payload: {
|
|
401
|
+
// Namespaced so a reasoning id can never collide with another
|
|
402
|
+
// provider's ids in the relay's thought rows.
|
|
403
|
+
reasoningId: `copilot-thought-${sanitizeSubagentRunId(reasoningId) || 'main'}`,
|
|
404
|
+
text,
|
|
405
|
+
done,
|
|
406
|
+
subagentRunId: null,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function activityAction(text, subagentRunId = null) {
|
|
412
|
+
return { channel: 'activity', payload: { text, subagentRunId } };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the single terminal `result` action for this turn. Guarded so a
|
|
417
|
+
* `session.error` arriving after a `session.idle` (or a second error) cannot
|
|
418
|
+
* publish a second response for the same queue row.
|
|
419
|
+
*/
|
|
420
|
+
function terminalAction({ isError = false, subtype = 'completed', errorMessage = null, errorData = null } = {}) {
|
|
421
|
+
if (terminalEmitted) return [];
|
|
422
|
+
terminalEmitted = true;
|
|
423
|
+
// A subagent still marked running when the interaction ends would render as
|
|
424
|
+
// a bubble spinning forever: the relay only reconciles open runs when the
|
|
425
|
+
// queue row FAILS, so a successful turn has to close its own. Cursor learnt
|
|
426
|
+
// the same lesson (`activeSubagentRuns` on handle recreation).
|
|
427
|
+
const strays = [];
|
|
428
|
+
for (const [runId, run] of subagentRuns) {
|
|
429
|
+
if (run.status !== 'running') continue;
|
|
430
|
+
run.status = 'failed';
|
|
431
|
+
strays.push({
|
|
432
|
+
channel: 'subagent',
|
|
433
|
+
payload: {
|
|
434
|
+
subagentRunId: runId,
|
|
435
|
+
parentSubagentId: null,
|
|
436
|
+
displayName: run.displayName,
|
|
437
|
+
status: 'failed',
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return [...strays, {
|
|
442
|
+
channel: 'result',
|
|
443
|
+
payload: {
|
|
444
|
+
text: finalText(),
|
|
445
|
+
// Per-prompt texts, so an interaction that absorbed a steered message
|
|
446
|
+
// can answer each queue row with the reply that belongs to it. The
|
|
447
|
+
// task summary closes the LAST prompt — earlier segments keep their
|
|
448
|
+
// own streamed replies.
|
|
449
|
+
segmentTexts: segments.map((_, index) => (
|
|
450
|
+
taskCompleteSummary && index === segments.length - 1
|
|
451
|
+
? taskCompleteSummary
|
|
452
|
+
: segmentText(index)
|
|
453
|
+
)),
|
|
454
|
+
promptCount: segments.length,
|
|
455
|
+
isError,
|
|
456
|
+
aborted,
|
|
457
|
+
subtype,
|
|
458
|
+
errorMessage,
|
|
459
|
+
// The raw payload travels with the result so the session process can
|
|
460
|
+
// run it through the shared terminal-error classifier without the
|
|
461
|
+
// normalizer needing to know about failure taxonomies.
|
|
462
|
+
errorData,
|
|
463
|
+
usage: usage.snapshot(),
|
|
464
|
+
contextUsage,
|
|
465
|
+
model: sessionModel,
|
|
466
|
+
interactionId,
|
|
467
|
+
},
|
|
468
|
+
}];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function normalize(event) {
|
|
472
|
+
if (!event || typeof event !== 'object') return [];
|
|
473
|
+
const type = String(event.type || '');
|
|
474
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {};
|
|
475
|
+
|
|
476
|
+
// ---- subagent lifecycle (session-scoped, tagged with the subagent's id) --
|
|
477
|
+
switch (type) {
|
|
478
|
+
case 'subagent.started': {
|
|
479
|
+
const runId = resolveSubagentRun(event, { useToolCallId: true });
|
|
480
|
+
if (!runId) return [];
|
|
481
|
+
return ensureSubagentRun(runId, subagentDisplayName(data));
|
|
482
|
+
}
|
|
483
|
+
case 'subagent.configured':
|
|
484
|
+
case 'subagent.selected': {
|
|
485
|
+
// `subagent.configured` is UNDOCUMENTED — it appears in no published
|
|
486
|
+
// schema and a live probe found it between started and completed,
|
|
487
|
+
// carrying `agentId` but neither `toolCallId` nor a display name. It is
|
|
488
|
+
// handled rather than ignored so the lane still opens if it ever
|
|
489
|
+
// arrives first, and it must never clobber the roster's display name
|
|
490
|
+
// with its own absent one (hence the `||` in ensureSubagentRun).
|
|
491
|
+
const runId = resolveSubagentRun(event, { useToolCallId: true });
|
|
492
|
+
if (!runId) return [];
|
|
493
|
+
return ensureSubagentRun(runId, subagentDisplayName(data));
|
|
494
|
+
}
|
|
495
|
+
case 'subagent.completed': {
|
|
496
|
+
const runId = resolveSubagentRun(event, { useToolCallId: true });
|
|
497
|
+
if (!runId) return [];
|
|
498
|
+
const actions = closeSubagentRun(runId, 'completed', subagentDisplayName(data));
|
|
499
|
+
const stats = formatSubagentStats(data);
|
|
500
|
+
if (stats) actions.push(activityAction(truncate(`Finished · ${stats}`), runId));
|
|
501
|
+
return actions;
|
|
502
|
+
}
|
|
503
|
+
case 'subagent.failed': {
|
|
504
|
+
const runId = resolveSubagentRun(event, { useToolCallId: true });
|
|
505
|
+
if (!runId) return [];
|
|
506
|
+
const name = subagentDisplayName(data);
|
|
507
|
+
const reason = String(data.error || '').trim() || 'unknown error';
|
|
508
|
+
const actions = closeSubagentRun(runId, 'failed', name);
|
|
509
|
+
actions.push(activityAction(truncate(`Subagent failed: ${reason}`), runId));
|
|
510
|
+
// ALSO on the main thread. The parent agent recovers from a failed
|
|
511
|
+
// subagent silently — it just carries on — so a failure reported only
|
|
512
|
+
// inside a collapsed lane bubble is a failure the user never learns
|
|
513
|
+
// about. This is the line that makes it visible.
|
|
514
|
+
actions.push(activityAction(truncate(`Subagent failed (${name}): ${reason}`)));
|
|
515
|
+
return actions;
|
|
516
|
+
}
|
|
517
|
+
case 'subagent.deselected':
|
|
518
|
+
return [];
|
|
519
|
+
default:
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---- subagent-tagged events → the lane -----------------------------------
|
|
524
|
+
//
|
|
525
|
+
// The runtime forwards subagent streaming by default and the flag that
|
|
526
|
+
// suppresses it also breaks PARENT streaming (see `isSubagentEvent`), so
|
|
527
|
+
// these are routed here rather than suppressed at the source. Hosted
|
|
528
|
+
// subagents emit no deltas at all: the whole reply arrives as one tagged
|
|
529
|
+
// `assistant.message` (live-verified), which is why this publishes it as a
|
|
530
|
+
// completed stream rather than accumulating.
|
|
531
|
+
if (isSubagentEvent(event)) {
|
|
532
|
+
const runId = resolveSubagentRun(event);
|
|
533
|
+
if (!runId) return [];
|
|
534
|
+
if (type === 'assistant.message') {
|
|
535
|
+
const content = String(data.content || '');
|
|
536
|
+
if (!content.trim()) return [];
|
|
537
|
+
const actions = ensureSubagentRun(runId);
|
|
538
|
+
actions.push({
|
|
539
|
+
channel: 'stream',
|
|
540
|
+
payload: { text: content, done: true, subagentRunId: runId },
|
|
541
|
+
});
|
|
542
|
+
return actions;
|
|
543
|
+
}
|
|
544
|
+
if (type === 'tool.execution_start') {
|
|
545
|
+
const toolName = String(data.toolName || '').trim() || 'tool';
|
|
546
|
+
const actions = ensureSubagentRun(runId);
|
|
547
|
+
actions.push(activityAction(formatToolActivityText(toolName, data.arguments), runId));
|
|
548
|
+
return actions;
|
|
549
|
+
}
|
|
550
|
+
if (type === 'assistant.usage') {
|
|
551
|
+
// Counted toward the turn's spend — a subagent's model call costs real
|
|
552
|
+
// money — but it must not set the turn's REPORTED model, which is the
|
|
553
|
+
// main thread's. Subagents can run a different model entirely
|
|
554
|
+
// (`task`'s `model` override).
|
|
555
|
+
usage.add(data, { allowModel: false });
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
// Everything else the subagent emits (deltas, turn_start/end, reasoning,
|
|
559
|
+
// its own user/system messages) is deliberately dropped: it would
|
|
560
|
+
// otherwise merge into the main reply or the main thinking bubble.
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
switch (type) {
|
|
565
|
+
// ---- prompt boundary --------------------------------------------------
|
|
566
|
+
case 'user.message': {
|
|
567
|
+
// The runtime has started work on a prompt. Opens a new segment so a
|
|
568
|
+
// message that was steered into this interaction gets its own reply
|
|
569
|
+
// text. Deliberately NOT gated on segment emptiness: two prompts in a
|
|
570
|
+
// row with no assistant output between them are still two prompts.
|
|
571
|
+
segments.push([]);
|
|
572
|
+
cachedPrefix = null;
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
// ---- session identity -------------------------------------------------
|
|
576
|
+
case 'session.start':
|
|
577
|
+
case 'session.resume': {
|
|
578
|
+
sessionId = String(data.sessionId || sessionId || '').trim();
|
|
579
|
+
const model = String(data.selectedModel || '').trim();
|
|
580
|
+
if (model) sessionModel = model;
|
|
581
|
+
return [{
|
|
582
|
+
channel: 'init',
|
|
583
|
+
payload: {
|
|
584
|
+
sessionId,
|
|
585
|
+
model: sessionModel,
|
|
586
|
+
resumed: type === 'session.resume',
|
|
587
|
+
eventCount: Number(data.eventCount) || 0,
|
|
588
|
+
},
|
|
589
|
+
}];
|
|
590
|
+
}
|
|
591
|
+
case 'session.model_change': {
|
|
592
|
+
const model = String(data.newModel || '').trim();
|
|
593
|
+
if (model) sessionModel = model;
|
|
594
|
+
return [{ channel: 'init', payload: { sessionId, model: sessionModel, resumed: false, eventCount: 0 } }];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- assistant text ---------------------------------------------------
|
|
598
|
+
case 'assistant.message_start': {
|
|
599
|
+
noteMessage(data.messageId);
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
case 'assistant.message_delta': {
|
|
603
|
+
const id = noteMessage(data.messageId);
|
|
604
|
+
if (!id) return [];
|
|
605
|
+
messageTexts.set(id, messageTexts.get(id) + String(data.deltaContent || ''));
|
|
606
|
+
if (id !== messageOrder[messageOrder.length - 1]) cachedPrefix = null;
|
|
607
|
+
return streamActions();
|
|
608
|
+
}
|
|
609
|
+
case 'assistant.message': {
|
|
610
|
+
const id = noteMessage(data.messageId);
|
|
611
|
+
const model = String(data.model || '').trim();
|
|
612
|
+
if (model) sessionModel = model;
|
|
613
|
+
interactionId = String(data.interactionId || interactionId || '').trim();
|
|
614
|
+
// The durable message is authoritative over whatever the deltas
|
|
615
|
+
// accumulated — but ONLY when it actually carries content. The
|
|
616
|
+
// tool-request message is documented to arrive with empty content, and
|
|
617
|
+
// letting that overwrite the accumulated deltas would blank an answer
|
|
618
|
+
// the user already watched stream in.
|
|
619
|
+
const content = String(data.content || '');
|
|
620
|
+
if (id && content) {
|
|
621
|
+
messageTexts.set(id, content);
|
|
622
|
+
if (id !== messageOrder[messageOrder.length - 1]) cachedPrefix = null;
|
|
623
|
+
}
|
|
624
|
+
return streamActions();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---- reasoning --------------------------------------------------------
|
|
628
|
+
case 'assistant.reasoning_delta': {
|
|
629
|
+
const id = String(data.reasoningId || '').trim();
|
|
630
|
+
if (!id) return [];
|
|
631
|
+
const next = capThought((openThoughts.get(id) || '') + String(data.deltaContent || ''));
|
|
632
|
+
openThoughts.set(id, next);
|
|
633
|
+
// Same gating as the stream channel, per reasoning block. Without it a
|
|
634
|
+
// long thought publishes a POST per token, and once the 16 KiB cap is
|
|
635
|
+
// reached every further delta would re-publish identical text forever.
|
|
636
|
+
// It also drops the empty-content case: hosted models return encrypted
|
|
637
|
+
// reasoning, whose events carry no text at all.
|
|
638
|
+
if (!shouldEmitStreamUpdate(next, emittedThoughts.get(id) || '')) return [];
|
|
639
|
+
emittedThoughts.set(id, next);
|
|
640
|
+
return [thoughtAction(id, next, false)];
|
|
641
|
+
}
|
|
642
|
+
case 'assistant.reasoning': {
|
|
643
|
+
const id = String(data.reasoningId || '').trim();
|
|
644
|
+
if (!id) return [];
|
|
645
|
+
const text = capThought(String(data.content || openThoughts.get(id) || ''));
|
|
646
|
+
openThoughts.delete(id);
|
|
647
|
+
emittedThoughts.delete(id);
|
|
648
|
+
// Hosted models emit this ephemerally with EMPTY content (the
|
|
649
|
+
// reasoning is encrypted; only `reasoningTokens` survives), so an
|
|
650
|
+
// empty thought must close nothing rather than publish a blank bubble.
|
|
651
|
+
if (!text.trim()) return [];
|
|
652
|
+
return [thoughtAction(id, text, true)];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---- turn lifecycle ---------------------------------------------------
|
|
656
|
+
case 'assistant.turn_start': {
|
|
657
|
+
interactionId = String(data.interactionId || interactionId || '').trim();
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
case 'assistant.turn_end':
|
|
661
|
+
// Deliberately inert: one per model call, not one per user request.
|
|
662
|
+
return [];
|
|
663
|
+
case 'assistant.usage': {
|
|
664
|
+
usage.add(data);
|
|
665
|
+
return [];
|
|
666
|
+
}
|
|
667
|
+
case 'session.usage_info': {
|
|
668
|
+
const tokenLimit = Number(data.tokenLimit);
|
|
669
|
+
const currentTokens = Number(data.currentTokens);
|
|
670
|
+
if (!Number.isFinite(tokenLimit) || !Number.isFinite(currentTokens)) return [];
|
|
671
|
+
contextUsage = {
|
|
672
|
+
tokenLimit,
|
|
673
|
+
currentTokens,
|
|
674
|
+
systemTokens: Number(data.systemTokens) || 0,
|
|
675
|
+
conversationTokens: Number(data.conversationTokens) || 0,
|
|
676
|
+
toolDefinitionsTokens: Number(data.toolDefinitionsTokens) || 0,
|
|
677
|
+
};
|
|
678
|
+
return [];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---- tools ------------------------------------------------------------
|
|
682
|
+
case 'tool.execution_start': {
|
|
683
|
+
const toolCallId = String(data.toolCallId || '').trim();
|
|
684
|
+
const toolName = String(data.toolName || '').trim() || 'tool';
|
|
685
|
+
if (toolCallId) toolNames.set(toolCallId, toolName);
|
|
686
|
+
return [activityAction(formatToolActivityText(toolName, data.arguments))];
|
|
687
|
+
}
|
|
688
|
+
case 'tool.execution_complete': {
|
|
689
|
+
// There is no `tool.*_failed` event type — failure is `success: false`
|
|
690
|
+
// with `error` in place of `result`.
|
|
691
|
+
if (data.success !== false) return [];
|
|
692
|
+
const toolCallId = String(data.toolCallId || '').trim();
|
|
693
|
+
const toolName = toolNames.get(toolCallId) || 'tool';
|
|
694
|
+
const reason = String(data.error?.message || data.error?.code || 'unknown error').trim();
|
|
695
|
+
return [activityAction(truncate(`Tool failed (${toolName}): ${reason}`))];
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---- permission / questions ------------------------------------------
|
|
699
|
+
case 'permission.requested': {
|
|
700
|
+
const requestId = String(data.requestId || '').trim();
|
|
701
|
+
const request = data.permissionRequest || data.promptRequest || {};
|
|
702
|
+
const summary = String(request.fullCommandText || request.intention || request.kind || '').trim();
|
|
703
|
+
if (requestId) permissionRequests.set(requestId, summary);
|
|
704
|
+
return [];
|
|
705
|
+
}
|
|
706
|
+
case 'permission.completed': {
|
|
707
|
+
const requestId = String(data.requestId || '').trim();
|
|
708
|
+
const summary = permissionRequests.get(requestId) || '';
|
|
709
|
+
permissionRequests.delete(requestId);
|
|
710
|
+
const kind = String(data.result?.kind || 'unknown').trim();
|
|
711
|
+
return [activityAction(truncate(summary
|
|
712
|
+
? `Permission ${kind}: ${summary}`
|
|
713
|
+
: `Permission ${kind}`))];
|
|
714
|
+
}
|
|
715
|
+
case 'user_input.requested': {
|
|
716
|
+
// The question is forwarded to the relay as a question card; this row
|
|
717
|
+
// records it in the transcript so the conversation still reads as a
|
|
718
|
+
// conversation once the card has been answered and dismissed.
|
|
719
|
+
const question = String(data.question || '').trim();
|
|
720
|
+
return [activityAction(truncate(
|
|
721
|
+
`Copilot asked: ${question || '(no question text)'}`,
|
|
722
|
+
))];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ---- failure / abort --------------------------------------------------
|
|
726
|
+
case 'abort': {
|
|
727
|
+
aborted = true;
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
case 'model.call_failure': {
|
|
731
|
+
// Carries the quota snapshots the usage card wants; the terminal
|
|
732
|
+
// classification itself rides `session.error`.
|
|
733
|
+
usage.setQuotaSnapshots(data.quotaSnapshots);
|
|
734
|
+
return [];
|
|
735
|
+
}
|
|
736
|
+
case 'session.error': {
|
|
737
|
+
return terminalAction({
|
|
738
|
+
isError: true,
|
|
739
|
+
subtype: String(data.errorType || data.errorCode || 'session_error').trim() || 'session_error',
|
|
740
|
+
errorMessage: String(data.message || '').trim() || null,
|
|
741
|
+
errorData: data,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
// ---- explicit closing message ----------------------------------------
|
|
745
|
+
case 'session.task_complete': {
|
|
746
|
+
// Root-agent only by construction: an agentId-tagged task_complete
|
|
747
|
+
// (a subagent finishing its own autopilot loop) was already consumed
|
|
748
|
+
// by the subagent block above and never reaches this switch.
|
|
749
|
+
const summary = String(data.summary || '').trim();
|
|
750
|
+
if (!summary) return [];
|
|
751
|
+
taskCompleteSummary = summary;
|
|
752
|
+
// Stream it so the reply is visible before the terminal result lands —
|
|
753
|
+
// in the empty-assistant-text case nothing has streamed yet.
|
|
754
|
+
const text = finalText();
|
|
755
|
+
if (!shouldEmitStreamUpdate(text, lastEmittedStreamText)) return [];
|
|
756
|
+
lastEmittedStreamText = text;
|
|
757
|
+
return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
|
|
758
|
+
}
|
|
759
|
+
// ---- the one true terminator -----------------------------------------
|
|
760
|
+
case 'session.idle': {
|
|
761
|
+
if (data.aborted === true) aborted = true;
|
|
762
|
+
return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
default:
|
|
766
|
+
// Everything else is deliberately ignored, silently — the event set
|
|
767
|
+
// grows between CLI releases (the live probe found an undocumented
|
|
768
|
+
// `subagent.configured` that is in no published schema), so an unknown
|
|
769
|
+
// type is normal and must never warn or throw. The high-volume
|
|
770
|
+
// offenders: `assistant.streaming_delta` is a byte counter (not
|
|
771
|
+
// content), `pending_messages.modified` and
|
|
772
|
+
// `session.background_tasks_changed` always carry an empty `data` (23
|
|
773
|
+
// of the latter fire during a single bash call), and the whole
|
|
774
|
+
// `model.*` family duplicates information already taken from the
|
|
775
|
+
// `assistant.*` events. `session.shutdown` is NOT treated as a
|
|
776
|
+
// failure — the resume fixture shows it arriving from a graceful
|
|
777
|
+
// disconnect immediately before a perfectly healthy turn. A runtime
|
|
778
|
+
// that dies under a live turn is detected from the client instead
|
|
779
|
+
// (`observeRuntimeExit`).
|
|
780
|
+
return [];
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
normalize,
|
|
786
|
+
finalStreamText: finalText,
|
|
787
|
+
segmentText,
|
|
788
|
+
promptCount: () => segments.length,
|
|
789
|
+
/**
|
|
790
|
+
* Runs still marked `running`. The turn runner force-closes these when a
|
|
791
|
+
* turn dies without reaching its terminator (abort, runtime exit, thrown
|
|
792
|
+
* exception) — those paths never build a `result`, so the stray-closing in
|
|
793
|
+
* `terminalAction` cannot run and the bubbles would spin forever.
|
|
794
|
+
*/
|
|
795
|
+
activeSubagentRuns: () => [...subagentRuns.entries()]
|
|
796
|
+
.filter(([, run]) => run.status === 'running')
|
|
797
|
+
.map(([subagentRunId, run]) => ({ subagentRunId, displayName: run.displayName })),
|
|
798
|
+
get model() { return sessionModel; },
|
|
799
|
+
};
|
|
800
|
+
}
|