diff --git a/.changeset/action-route-stored-active-org-fallback.md b/.changeset/action-route-stored-active-org-fallback.md new file mode 100644 index 0000000000..802e39fd07 --- /dev/null +++ b/.changeset/action-route-stored-active-org-fallback.md @@ -0,0 +1,10 @@ +--- +"@agent-native/core": patch +--- + +Action routes now fall back to the caller's stored active organization when a +cookie session resolves no org, matching what the adapter/A2A path already did. +An empty org silently narrowed every scoped read to rows with a null `org_id`, +so a user could stop seeing their own org-scoped dashboards and resources. An +explicit Personal selection still resolves to no org, and a transient database +failure propagates instead of reading as "this user has no org". diff --git a/.changeset/brand-kit-motion-token-type.md b/.changeset/brand-kit-motion-token-type.md new file mode 100644 index 0000000000..d862b19c1e --- /dev/null +++ b/.changeset/brand-kit-motion-token-type.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Add `motion` to `BrandKitTokenType` so durations, easings, and transitions have +a real category. Extractors drop tokens they cannot classify, so the missing +bucket meant no imported design system ever carried its motion. diff --git a/.changeset/chat-image-lightbox.md b/.changeset/chat-image-lightbox.md new file mode 100644 index 0000000000..1675c51b26 --- /dev/null +++ b/.changeset/chat-image-lightbox.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Open agent chat image attachments in a full-size lightbox when their thumbnails are clicked. diff --git a/.changeset/collapsed-chat-first-new-chat.md b/.changeset/collapsed-chat-first-new-chat.md new file mode 100644 index 0000000000..43e72d814e --- /dev/null +++ b/.changeset/collapsed-chat-first-new-chat.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep the chat-first New chat action icon-only when the desktop rail is collapsed. diff --git a/.changeset/compact-share-dialog.md b/.changeset/compact-share-dialog.md new file mode 100644 index 0000000000..db494d3dd6 --- /dev/null +++ b/.changeset/compact-share-dialog.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Simplify share controls with compact copy-link rows and always-visible access details. diff --git a/.changeset/dispatch-workspace-sso.md b/.changeset/dispatch-workspace-sso.md new file mode 100644 index 0000000000..7a4a372a3d --- /dev/null +++ b/.changeset/dispatch-workspace-sso.md @@ -0,0 +1,8 @@ +--- +"@agent-native/dispatch": minor +--- + +Add a default-off Dispatch workspace sign-in rollout for iframe app panes. The +flagged path mints short-lived, app-scoped embed sessions for exact first-party +origins, explicitly registered custom workspace apps, and same-origin mounted +workspace apps without changing the existing MCP access policy. diff --git a/.changeset/durable-github-design-system-sources.md b/.changeset/durable-github-design-system-sources.md new file mode 100644 index 0000000000..a21f759b72 --- /dev/null +++ b/.changeset/durable-github-design-system-sources.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Support durable, scoped GitHub sources and replayable sync for Builder design-system imports. diff --git a/.changeset/durable-resource-instructions-and-dashboard-reference-search.md b/.changeset/durable-resource-instructions-and-dashboard-reference-search.md new file mode 100644 index 0000000000..ea55f4f3ae --- /dev/null +++ b/.changeset/durable-resource-instructions-and-dashboard-reference-search.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Ensure durable resource instructions remain in lean agent prompts and fail loudly when AGENTS.md cannot be read. diff --git a/.changeset/extension-shell-x-cloak.md b/.changeset/extension-shell-x-cloak.md new file mode 100644 index 0000000000..96f6afe5d3 --- /dev/null +++ b/.changeset/extension-shell-x-cloak.md @@ -0,0 +1,8 @@ +--- +"@agent-native/core": patch +--- + +Hide `x-cloak` content in the extension iframe shell until Alpine boots. +Extension content is a body snippet, so it cannot define the rule itself: an +`x-cloak` overlay painted over the whole extension until the deferred Alpine +CDN script resolved, and permanently when it failed to. diff --git a/.changeset/fix-agent-panel-overlay-focus.md b/.changeset/fix-agent-panel-overlay-focus.md new file mode 100644 index 0000000000..be435a01eb --- /dev/null +++ b/.changeset/fix-agent-panel-overlay-focus.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep feedback and other sibling overlays open when launched from the Agent panel overflow menu. diff --git a/.changeset/fix-native-sqlite-runtime.md b/.changeset/fix-native-sqlite-runtime.md new file mode 100644 index 0000000000..af40ac38da --- /dev/null +++ b/.changeset/fix-native-sqlite-runtime.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Repair and validate native SQLite bindings against the Node runtime used by development, builds, and production starts. diff --git a/.changeset/fix-slack-bare-user-mentions.md b/.changeset/fix-slack-bare-user-mentions.md new file mode 100644 index 0000000000..d6f20f3960 --- /dev/null +++ b/.changeset/fix-slack-bare-user-mentions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Convert bare Slack user IDs in outbound agent responses into native mentions. diff --git a/.changeset/folder-backed-agents-and-factory-surface.md b/.changeset/folder-backed-agents-and-factory-surface.md new file mode 100644 index 0000000000..6d5084db9d --- /dev/null +++ b/.changeset/folder-backed-agents-and-factory-surface.md @@ -0,0 +1,8 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": minor +--- + +Add folder-backed agent packs with safe Claude/Cowork-style import, agent-owned +references and skills, and a shared Factory Agents surface for managing simple +agents alongside mounted agentic apps. diff --git a/.changeset/grayscale-unselected-app-icons.md b/.changeset/grayscale-unselected-app-icons.md new file mode 100644 index 0000000000..c1c3365640 --- /dev/null +++ b/.changeset/grayscale-unselected-app-icons.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Grayscale non-selected workspace app icons so the active app is easier to identify in the chat-first rail. diff --git a/.changeset/guided-questions-submit-context.md b/.changeset/guided-questions-submit-context.md new file mode 100644 index 0000000000..422028c3e4 --- /dev/null +++ b/.changeset/guided-questions-submit-context.md @@ -0,0 +1,8 @@ +--- +"@agent-native/core": patch +--- + +Add an optional `submitContext` to the guided-questions payload, appended to the +context of whichever message the card sends. A question card's answer opens a +continuation turn that inherits nothing from the turn that posed it, so context +the follow-up work depends on had no way to survive the hop. diff --git a/.changeset/keep-workspace-app-surfaces-alive.md b/.changeset/keep-workspace-app-surfaces-alive.md new file mode 100644 index 0000000000..b54ca53d17 --- /dev/null +++ b/.changeset/keep-workspace-app-surfaces-alive.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": patch +--- + +Keep visited workspace app frames mounted while switching apps so returning restores live state instantly. diff --git a/.changeset/org-scoped-desktop-sso-discovery.md b/.changeset/org-scoped-desktop-sso-discovery.md new file mode 100644 index 0000000000..db70ec6ce9 --- /dev/null +++ b/.changeset/org-scoped-desktop-sso-discovery.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Detect active organization-scoped feature-flag rollouts for anonymous Desktop discovery while keeping authenticated authorization scoped to the user's email and organization. diff --git a/.changeset/preserve-builder-callback-state.md b/.changeset/preserve-builder-callback-state.md new file mode 100644 index 0000000000..53a4576c36 --- /dev/null +++ b/.changeset/preserve-builder-callback-state.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Preserve signed Builder callback state when mounted route events normalize away the raw query string. diff --git a/.changeset/provision-identity-sso-schema.md b/.changeset/provision-identity-sso-schema.md new file mode 100644 index 0000000000..bc2f51e5a6 --- /dev/null +++ b/.changeset/provision-identity-sso-schema.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +--- + +Provision cross-app SSO state and authorization-code tables during release migrations so production serverless requests never perform schema DDL. diff --git a/.changeset/quiet-mail-approval-flow.md b/.changeset/quiet-mail-approval-flow.md new file mode 100644 index 0000000000..2e024992be --- /dev/null +++ b/.changeset/quiet-mail-approval-flow.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep approval continuations out of visible chat history and keep approval controls usable at narrow widths. diff --git a/.changeset/recover-completed-background-tool-turn.md b/.changeset/recover-completed-background-tool-turn.md new file mode 100644 index 0000000000..c9e5f3a67a --- /dev/null +++ b/.changeset/recover-completed-background-tool-turn.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Continue chat turns when a background tool completes before the assistant sends its final response. diff --git a/.changeset/render-inline-markdown.md b/.changeset/render-inline-markdown.md new file mode 100644 index 0000000000..760ab4ab08 --- /dev/null +++ b/.changeset/render-inline-markdown.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add a safe inline Markdown renderer for compact user-authored text surfaces. diff --git a/.changeset/repair-workspace-skill-links.md b/.changeset/repair-workspace-skill-links.md new file mode 100644 index 0000000000..1078e01bd1 --- /dev/null +++ b/.changeset/repair-workspace-skill-links.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Repair existing workspace app skill copies during scaffold updates by linking them to the shared workspace skill surface. diff --git a/.changeset/simple-agents-import.md b/.changeset/simple-agents-import.md new file mode 100644 index 0000000000..800324c8af --- /dev/null +++ b/.changeset/simple-agents-import.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": minor +--- + +Add a simple Agents workspace for creating reusable profiles, importing Claude-style or generic agent definitions, and connecting existing HTTP/A2A agents. diff --git a/.changeset/single-owner-run-stream-rendering.md b/.changeset/single-owner-run-stream-rendering.md new file mode 100644 index 0000000000..df5e77bbde --- /dev/null +++ b/.changeset/single-owner-run-stream-rendering.md @@ -0,0 +1,32 @@ +--- +"@agent-native/core": patch +--- + +Chat no longer renders the same assistant turn twice — the long-standing report +of a final message streaming in two places at once and tool outputs appearing +more than once. Four independent causes, all of which let one run be folded into +UI state more than once: + +- SSE resume cursors were kept in a single browser-wide slot, and + `updateActiveRunSeq` took no run identity, so it wrote the caller's sequence + into whichever run happened to occupy the slot. With chats streaming in + parallel (agent teams, multiple tabs) runs evicted each other, and + `resolveReconnectAfterSeq` then returned 0 — replaying an entire run on top of + history that already contained it. Cursors are now stored per `{threadId, +runId}`, identity is required to advance one, and a cursor outlives its run + losing focus so a later reconnect resumes instead of replaying. +- The adapter's stream and the reconnect reader could both fold one run at once. + Ownership was a React ref re-checked by a 1s poll that is skipped while the tab + is hidden, and the refs were per-component-instance while several chat + instances mount against one run. Ownership now lives in a module-scoped + registry claimed and checked synchronously, and the adapter preempts the + reconnect fallback when it takes over. +- The reconnect overlay was deliberately kept mounted beside the live message + list for up to 2500ms after handoff, leaving two independent folds of the same + turn on screen with only content-similarity heuristics hiding the second. The + overlay now renders only while no runtime owns the turn. +- The server fold pushed a tool card for every `tool_start`, including the + replays that journal and zombie-ledger recovery emit for calls that already + ran. The live client coalesced those onto the original card, so a duplicate + tool output appeared only after a reload. A replayed `tool_start` now folds + onto its existing card. diff --git a/.changeset/steady-chat-replay.md b/.changeset/steady-chat-replay.md new file mode 100644 index 0000000000..137cdc59a0 --- /dev/null +++ b/.changeset/steady-chat-replay.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent assistant panel crashes when dense chat replays synchronously update React. diff --git a/.changeset/toolkit-canvas-interactions.md b/.changeset/toolkit-canvas-interactions.md new file mode 100644 index 0000000000..3f3f2e7782 --- /dev/null +++ b/.changeset/toolkit-canvas-interactions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Fix toolkit canvas interaction and collaboration UI behavior. diff --git a/.changeset/trim-amplitude-exception-context.md b/.changeset/trim-amplitude-exception-context.md new file mode 100644 index 0000000000..2e8f968315 --- /dev/null +++ b/.changeset/trim-amplitude-exception-context.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep nested exception debugging context out of Amplitude event properties while preserving it for internal error tracking. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2d4296ae0..cdfae47181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,31 @@ permissions: contents: read jobs: + change-scope: + name: Determine change scope + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + docs_only: ${{ steps.scope.outputs.docs_only }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + + - name: Test change scope classifier + run: node --experimental-strip-types --test scripts/ci-change-scope.test.ts + + - name: Classify changed paths + id: scope + env: + CI_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CI_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node --experimental-strip-types scripts/ci-change-scope.ts + lint: name: Lint & format runs-on: ubuntu-latest @@ -63,14 +88,18 @@ jobs: - name: Typecheck run: pnpm typecheck - # The full fast-test suite runs on every PR — nothing is change-selected, so a - # test can never be silently skipped. It's split into parallel lanes purely for - # wall-clock: @agent-native/core runs isolated and uncapped (test-core), and - # every other test package is partitioned across balanced lanes (test-rest) by - # scripts/ci-test-lanes.ts, which asserts every package lands in exactly one - # lane. Adding a package auto-assigns it; dropping one fails the discover job. + # Non-docs PRs run the full fast-test suite — nothing is change-selected, so a + # test can never be silently skipped. Docs-only PRs use the focused docs job + # below and the stable Fast tests gate records the intentional skip. The full + # suite is split into parallel lanes purely for wall-clock: @agent-native/core + # runs isolated and uncapped (test-core), and every other test package is + # partitioned across balanced lanes (test-rest) by scripts/ci-test-lanes.ts, + # which asserts every package lands in exactly one lane. Adding a package + # auto-assigns it; dropping one fails the discover job. discover-lanes: name: Plan test lanes + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -88,6 +117,8 @@ jobs: test-core: name: Fast tests core + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -139,7 +170,8 @@ jobs: test-rest: name: Fast tests ${{ matrix.lane }} - needs: discover-lanes + needs: [change-scope, discover-lanes] + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest # Cap the run so a hung test fails fast instead of pinning a runner for # GitHub's 6h default. Slow DB/integration/e2e specs run in separate jobs @@ -197,16 +229,35 @@ jobs: # gate), not the per-lane jobs whose names vary with the matrix. fast-tests: name: Fast tests - needs: [discover-lanes, test-core, test-rest] + needs: [change-scope, docs, discover-lanes, test-core, test-rest] if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Gate on lane results + env: + CHANGE_SCOPE_RESULT: ${{ needs.change-scope.result }} + DOCS_ONLY: ${{ needs.change-scope.outputs.docs_only }} + DOCS_RESULT: ${{ needs.docs.result }} + DISCOVER_LANES_RESULT: ${{ needs.discover-lanes.result }} + TEST_CORE_RESULT: ${{ needs.test-core.result }} + TEST_REST_RESULT: ${{ needs.test-rest.result }} run: | - for dep in "discover-lanes:${{ needs.discover-lanes.result }}" \ - "test-core:${{ needs.test-core.result }}" \ - "test-rest:${{ needs.test-rest.result }}"; do + if [ "$CHANGE_SCOPE_RESULT" != "success" ]; then + echo "::error::change-scope did not succeed ($CHANGE_SCOPE_RESULT)" + exit 1 + fi + if [ "$DOCS_ONLY" = "true" ]; then + if [ "$DOCS_RESULT" != "success" ]; then + echo "::error::docs checks did not succeed ($DOCS_RESULT)" + exit 1 + fi + echo "Docs-only change: full fast-test lanes are not required." + exit 0 + fi + for dep in "discover-lanes:$DISCOVER_LANES_RESULT" \ + "test-core:$TEST_CORE_RESULT" \ + "test-rest:$TEST_REST_RESULT"; do name="${dep%%:*}"; result="${dep##*:}" if [ "$result" != "success" ]; then echo "::error::$name did not succeed ($result)" @@ -215,8 +266,37 @@ jobs: done echo "Full fast-test suite passed (core + all lanes)." + docs: + name: Docs checks + needs: change-scope + if: needs.change-scope.outputs.docs_only == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "pnpm" + + - name: Restore dist + tsBuildInfo cache + uses: ./.github/actions/restore-dist-cache + + - run: pnpm install --frozen-lockfile + + - name: Run docs package tests + run: pnpm --filter @agent-native/docs test + + - name: Typecheck docs package + run: pnpm --filter @agent-native/docs typecheck + content-parity: name: Content parity + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -239,6 +319,8 @@ jobs: content-db-tests: name: Content DB tests + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -272,6 +354,8 @@ jobs: content-db-postgres-tests: name: Content DB PostgreSQL locking + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 services: @@ -312,6 +396,8 @@ jobs: core-integration-tests: name: Core integration tests + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -344,6 +430,8 @@ jobs: plan-e2e-tests: name: Plan E2E tests + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -366,6 +454,8 @@ jobs: brain-evals: name: Brain evals + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest # Brain evals are offline action/fixture retrieval checks; live LLM evals # belong in the gated eval workflow. @@ -390,6 +480,8 @@ jobs: brain-privacy-evals: name: Brain privacy leakage evals + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -412,6 +504,8 @@ jobs: build: name: Build + needs: change-scope + if: needs.change-scope.result == 'success' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -430,10 +524,17 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build all packages + if: needs.change-scope.outputs.docs_only != 'true' run: pnpm build + - name: Build docs site + if: needs.change-scope.outputs.docs_only == 'true' + run: pnpm --filter @agent-native/docs build + trusted-acceptance: name: Trusted acceptance substrate + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -465,6 +566,8 @@ jobs: scaffold-e2e: name: Scaffold E2E — create + pnpm install + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -593,6 +696,8 @@ jobs: ssr-boot-smoke: name: SSR cold-start smoke + needs: change-scope + if: needs.change-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6bce0cc482..b12e303576 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -62,6 +62,7 @@ values from `.env` files, and use placeholders such as `` in examples. | `DATABASE_AUTH_TOKEN` | Separate database auth token for providers such as Turso/libSQL. | | `DB_CONNECT_COOLDOWN_MS` | How long an endpoint stops attempting new connections after one attempt fails (default 2000, jittered). Prevents a refused attempt from immediately producing the next one. | | `APP_URL` | Optional canonical public origin for auth, OAuth, A2A, webhooks, and generated links; hosting metadata is inferred when unset. | +| `IDENTITY_SSO_APP_REGISTRY_JSON` | Optional exact JSON registry of custom workspace apps that explicitly opt into Desktop cross-app SSO. | | `APP_BASE_PATH` | Server-side mount prefix for a workspace app such as `/mail`. | | `PORT` | Local Node/Nitro server port. | | `NITRO_PRESET` | Nitro build/deployment preset. | @@ -82,6 +83,7 @@ values from `.env` files, and use placeholders such as `` in examples. | `AGENT_NATIVE_SSR_CACHE` | Deployment-wide SSR shell cache policy. | | `NODE_ENV` | Node runtime mode. | | `CI` | Continuous-integration marker used by test/build behavior. | +| `DOCS_ONLY` | Internal CI marker emitted by the change-scope job when a change contains documentation only; do not set manually. | | `DEBUG` | General debug logging switch used by local tooling and selected runtime paths. | | `COOKIE_DOMAIN` / `CORS_ALLOWED_ORIGINS` | Optional cookie-domain and cross-origin request policy. | | `PING_MESSAGE` | Minimal template smoke-test message used by example apps. | diff --git a/package.json b/package.json index 494e0e0265..f4eb913349 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test:content-row-mutations-postgres": "test -n \"$CONTENT_ROW_MUTATION_POSTGRES_URL\" && pnpm --filter content exec vitest --run actions/upsert-database-item-by-key.db.test.ts --config vitest.config.ts", "test:content-db-postgres": "pnpm --filter content exec vitest --run actions/migrate-content-database-rows.postgres.integration.test.ts --config vitest.config.ts", "test:core-integration": "pnpm --filter @agent-native/core exec vitest --run src/agent/engine/translate-ai-sdk.integration.spec.ts src/agent/run-loop-with-resume.integration.spec.ts src/client/extensions/AgentNativeExtensionFrame.e2e.spec.ts src/client/session-replay-iframe.e2e.spec.ts src/scripts/db/migrate-encrypt-credentials.e2e.spec.ts src/scripts/db/scope-isolation.e2e.spec.ts src/server/csrf-plugin-ordering.integration.spec.ts src/server/embedded.integration.spec.ts --passWithNoTests", + "test:ci-change-scope": "tsx --test scripts/ci-change-scope.test.ts", "test:oauth-postgres": "pnpm --filter @agent-native/core exec vitest --run src/oauth-tokens/lifecycle.postgres.integration.spec.ts --config vitest.config.ts", "test:plan-e2e": "pnpm --filter plan exec vitest --run actions/create-visual-recap.e2e.spec.ts --passWithNoTests", "test:trusted-acceptance": "tsx --test scripts/trusted-acceptance.spec.ts scripts/guard-trusted-acceptance-workflow.spec.ts scripts/trusted-acceptance/*.spec.ts", diff --git a/packages/code-agents-ui/src/CodeAgentsApp.tsx b/packages/code-agents-ui/src/CodeAgentsApp.tsx index eb84cb43a9..c0eb69a7c8 100644 --- a/packages/code-agents-ui/src/CodeAgentsApp.tsx +++ b/packages/code-agents-ui/src/CodeAgentsApp.tsx @@ -2156,6 +2156,7 @@ export default function CodeAgentsApp({ onOpenScheduled={() => chatFirstNavigation?.onOpenScheduled()} onSearch={openSearchPanel} activeTab={chatFirstNavigation?.activeTab} + collapsed={railCollapsed} stickyNewChat /> ) : ( diff --git a/packages/core/package.json b/packages/core/package.json index 82b5f5df5b..a797f85667 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -116,6 +116,7 @@ "./client/automation": "./dist/client/automation/index.js", "./client/chat": "./dist/client/chat/index.js", "./client/changelog": "./dist/client/changelog/index.js", + "./client/markdown": "./dist/client/markdown/index.js", "./client/collab": "./dist/client/collab/index.js", "./client/composer": "./dist/client/composer/index.js", "./client/conversation": "./dist/client/conversation/index.js", diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index a1ebd85eca..48e781e43d 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -9662,7 +9662,11 @@ export function createProductionAgentHandler( const profilePrompt = `${requestSystemPrompt}\n\n\n` + (profile.description ? `${profile.description}\n\n` : "") + - `${profile.instructions}\n`; + `${profile.instructions}` + + (profile.workspace?.resources.length + ? `\n\nAgent pack resources (read these with the resources tools when relevant):\n${profile.workspace.resources.map((resource) => `- ${resource.path}${resource.name ? ` (${resource.name})` : ""}`).join("\n")}` + : "") + + `\n`; let responseText = ""; const subUsage = await runAgentLoop({ diff --git a/packages/core/src/agent/thread-data-builder.spec.ts b/packages/core/src/agent/thread-data-builder.spec.ts index 3cda268fc8..d06a7dfe60 100644 --- a/packages/core/src/agent/thread-data-builder.spec.ts +++ b/packages/core/src/agent/thread-data-builder.spec.ts @@ -37,6 +37,77 @@ describe("extractThreadMeta", () => { }); describe("buildAssistantMessage", () => { + it("folds a replayed tool_start onto the original card instead of persisting a second one", () => { + // Journal / zombie-ledger recovery re-emits tool_start + tool_done for a + // call that already ran in an interrupted chunk. The live client coalesces + // those onto the original card, so persisting both is how a tool output the + // user saw once came back duplicated after a reload. + const events: RunEvent[] = [ + { + seq: 0, + event: { + type: "tool_start", + id: "call_a", + tool: "query", + input: { sql: "select 1" }, + }, + }, + { + seq: 1, + event: { type: "tool_done", id: "call_a", tool: "query", result: "1" }, + }, + { + seq: 2, + event: { + type: "tool_start", + id: "call_a", + tool: "query", + input: { sql: "select 1" }, + }, + }, + { + seq: 3, + event: { + type: "tool_done", + id: "call_a", + tool: "query", + result: + "(Already completed in an earlier interrupted attempt - not re-run to avoid a duplicate side effect.)\n\n1", + }, + }, + ]; + + const message = buildAssistantMessage(events, "run-replay"); + const toolCalls = (message?.content ?? []).filter( + (part: { type: string }) => part.type === "tool-call", + ); + + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]).toMatchObject({ toolName: "query" }); + }); + + it("keeps two cards when one id is reused across different tools", () => { + const events: RunEvent[] = [ + { seq: 0, event: { type: "tool_start", id: "dup", tool: "query" } }, + { + seq: 1, + event: { type: "tool_done", id: "dup", tool: "query", result: "1" }, + }, + { seq: 2, event: { type: "tool_start", id: "dup", tool: "write" } }, + { + seq: 3, + event: { type: "tool_done", id: "dup", tool: "write", result: "ok" }, + }, + ]; + + const message = buildAssistantMessage(events, "run-id-reuse"); + const toolCalls = (message?.content ?? []).filter( + (part: { type: string }) => part.type === "tool-call", + ); + + expect(toolCalls).toHaveLength(2); + }); + it("clears rejected draft text while preserving completed tool results", () => { const events: RunEvent[] = [ { diff --git a/packages/core/src/agent/thread-data-builder.ts b/packages/core/src/agent/thread-data-builder.ts index 97381eba7d..4965077531 100644 --- a/packages/core/src/agent/thread-data-builder.ts +++ b/packages/core/src/agent/thread-data-builder.ts @@ -177,9 +177,27 @@ export function buildAssistantMessage( } if (event.type === "tool_start") { + const explicitToolCallId = event.id?.trim(); + // A tool_start whose id is already in this turn is a REPLAY, not a new + // call: the tool-call journal and zombie-ledger recovery paths re-emit + // tool_start/tool_done for calls that already ran in an interrupted + // chunk. The live client coalesces those onto the original card, so a + // blind push here persisted a second copy of a call the user had only + // ever seen once — the duplicate that appears only after a reload. + // Matching the tool name too, so an id reused across different tools + // stays two cards rather than being silently merged into one. + if (explicitToolCallId) { + const replayed = content.some( + (part) => + part.type === "tool-call" && + part.toolCallId === explicitToolCallId && + part.toolName === (event.tool ?? "unknown"), + ); + if (replayed) continue; + } toolCallCounter += 1; const toolCallId = - event.id?.trim() || + explicitToolCallId || (runId ? `${runId}:tc_${toolCallCounter}` : `tc_${toolCallCounter}`); const args = (event.input ?? {}) as Record; content.push({ diff --git a/packages/core/src/cli/skills-content/help.ts b/packages/core/src/cli/skills-content/help.ts index 49b769c27a..c8ee119464 100644 --- a/packages/core/src/cli/skills-content/help.ts +++ b/packages/core/src/cli/skills-content/help.ts @@ -74,7 +74,9 @@ run "npx @agent-native/core@latest recap setup" / "npx @agent-native/core@latest verify GitHub Actions. Docs: ${docsUrl("pr-visual-recap")}. The status/update commands inspect copied Agent Native skill folders and refresh -their instruction files from the current @agent-native/core package. In generated -apps/workspaces, "skills update scaffold --project" refreshes the framework -skills copied into the scaffold and repairs AGENTS.md / CLAUDE.md and -.agents/skills / .claude/skills compatibility links.`; +their instruction files from the current @agent-native/core package. In a +standalone generated app, "skills update scaffold --project" refreshes the +framework skill copies. In a generated workspace, it refreshes workspace-core, +removes stale generic framework copies, repairs inherited .agents/skills links +for every app, and repairs AGENTS.md / CLAUDE.md / .claude/skills compatibility +links.`; diff --git a/packages/core/src/cli/skills.spec.ts b/packages/core/src/cli/skills.spec.ts index c47f6d0d14..b53ee2ff14 100644 --- a/packages/core/src/cli/skills.spec.ts +++ b/packages/core/src/cli/skills.spec.ts @@ -2811,6 +2811,156 @@ describe("agent-native skills", () => { expect(fs.existsSync(path.join(root, ".claude", "skills"))).toBe(true); }); + it("preserves app-owned framework-named skills in existing workspace apps", async () => { + const root = tmpDir(); + const shared = path.join(root, "packages", "shared"); + const app = path.join(root, "apps", "mail"); + fs.mkdirSync(path.join(shared, ".agents", "skills", "actions"), { + recursive: true, + }); + fs.mkdirSync(path.join(app, ".agents", "skills", "actions"), { + recursive: true, + }); + fs.mkdirSync(path.join(app, ".agents", "skills", "call-coach"), { + recursive: true, + }); + fs.mkdirSync(path.join(app, ".agents", "skills", "feature-flags"), { + recursive: true, + }); + fs.mkdirSync(path.join(root, "apps"), { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify( + { + name: "my-workspace", + "agent-native": { workspaceCore: "@my/shared" }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(shared, "package.json"), + JSON.stringify({ name: "@my/shared" }, null, 2), + ); + fs.writeFileSync( + path.join(shared, ".agents", "skills", "actions", "SKILL.md"), + "old actions skill\n", + ); + fs.writeFileSync( + path.join(app, "package.json"), + JSON.stringify({ name: "mail" }, null, 2), + ); + fs.writeFileSync( + path.join(app, ".agents", "skills", "actions", "SKILL.md"), + "old actions skill\n", + ); + fs.writeFileSync( + path.join(app, ".agents", "skills", "call-coach", "SKILL.md"), + "app-owned skill\n", + ); + fs.writeFileSync( + path.join(app, ".agents", "skills", "feature-flags", "SKILL.md"), + "copied optional framework skill\n", + ); + + const stdout: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + + await runSkills(["update", "scaffold", "--scope", "project", "--json"], { + baseDir: root, + runCommand: async () => 0, + }); + + expect( + fs.readFileSync( + path.join(app, ".agents", "skills", "actions", "SKILL.md"), + "utf8", + ), + ).toBe("old actions skill\n"); + expect( + fs.readFileSync( + path.join(app, ".agents", "skills", "call-coach", "SKILL.md"), + "utf8", + ), + ).toBe("app-owned skill\n"); + expect( + fs.existsSync(path.join(app, ".agents", "skills", "feature-flags")), + ).toBe(true); + }); + + it("repairs copied skills even when workspace-core is already current", async () => { + const root = tmpDir(); + const shared = path.join(root, "packages", "shared"); + const app = path.join(root, "apps", "mail"); + fs.mkdirSync(path.join(root, "apps"), { recursive: true }); + fs.cpSync( + path.join( + workspaceRoot(), + "packages", + "core", + "src", + "templates", + "workspace-core", + ".agents", + "skills", + ), + path.join(shared, ".agents", "skills"), + { recursive: true }, + ); + fs.mkdirSync(path.join(app, ".agents", "skills", "actions"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify( + { + name: "my-workspace", + "agent-native": { workspaceCore: "@my/shared" }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(shared, "package.json"), + JSON.stringify({ name: "@my/shared" }, null, 2), + ); + fs.writeFileSync( + path.join(app, "package.json"), + JSON.stringify({ name: "mail" }, null, 2), + ); + fs.cpSync( + path.join(shared, ".agents", "skills", "actions"), + path.join(app, ".agents", "skills", "actions"), + { recursive: true }, + ); + + const stdout: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + + await runSkills(["update", "scaffold", "--scope", "project", "--json"], { + baseDir: root, + runCommand: async () => 0, + }); + + expect(JSON.parse(stdout.join(""))).toMatchObject({ + scaffold: [{ status: "current" }], + updated: 0, + }); + expect( + fs + .lstatSync(path.join(app, ".agents", "skills", "actions")) + .isSymbolicLink(), + ).toBe(true); + }); + it("updates generated standalone headless scaffold skills", async () => { const root = tmpDir(); fs.mkdirSync(path.join(root, "actions"), { recursive: true }); diff --git a/packages/core/src/cli/skills.ts b/packages/core/src/cli/skills.ts index 78160b7d5f..c26c2f2c4f 100644 --- a/packages/core/src/cli/skills.ts +++ b/packages/core/src/cli/skills.ts @@ -58,6 +58,10 @@ import { WIREFRAME_REFERENCE_MD, } from "./skills-content/index.js"; import { createCliTelemetry, type CliTelemetry } from "./telemetry.js"; +import { + linkDefaultWorkspaceSkills, + removeCopiedFrameworkSkills, +} from "./workspacify.js"; export { CANVAS_REFERENCE_MD, @@ -2067,6 +2071,21 @@ function repairScaffoldAgentLinks(states: ScaffoldGuidanceState[]): void { if (!entry.isDirectory()) continue; const appDir = path.join(appsDir, entry.name); if (fs.existsSync(path.join(appDir, "package.json"))) { + const preserved = new Set([ + ...removeCopiedFrameworkSkills(appDir, { + workspaceRoot: state.workspaceRoot, + }), + ...linkDefaultWorkspaceSkills(appDir, state.workspaceRoot), + ]); + if (preserved.size > 0) { + console.warn( + `[skills] Preserved app-local framework-named skills in ${appDir}: ${[ + ...preserved, + ].join( + ", ", + )}. Migrate them explicitly before inheriting workspace copies.`, + ); + } setupAgentSymlinks(appDir); refreshCopiedClaudeSkills(appDir); } @@ -4132,8 +4151,13 @@ function runSkillsStatusOrUpdate( const scaffoldChanged = update ? updateScaffoldGuidanceStates(scaffoldBefore, parsed.dryRun) : []; - if (update && !parsed.dryRun && scaffoldChanged.length > 0) { - repairScaffoldAgentLinks(scaffoldChanged); + if (update && !parsed.dryRun) { + const workspaceStates = scaffoldBefore.filter( + (state) => state.workspaceRoot && state.sharedPackageDir, + ); + if (scaffoldChanged.length > 0 || workspaceStates.length > 0) { + repairScaffoldAgentLinks([...scaffoldChanged, ...workspaceStates]); + } } const skillAfter = update && !parsed.dryRun diff --git a/packages/core/src/cli/workspacify.ts b/packages/core/src/cli/workspacify.ts index f530abca7b..d36a996b4a 100644 --- a/packages/core/src/cli/workspacify.ts +++ b/packages/core/src/cli/workspacify.ts @@ -186,33 +186,104 @@ export function workspacifyApp(opts: WorkspacifyOptions): void { } function linkInheritedWorkspaceSkills(opts: WorkspacifyOptions): void { - const appSkillsDir = path.join(opts.appDir, ".agents", "skills"); const workspaceSkillsDir = path.join(opts.workspaceRoot, ".agents", "skills"); if (!fs.existsSync(workspaceSkillsDir)) return; - fs.mkdirSync(appSkillsDir, { recursive: true }); + removeCopiedFrameworkSkills(opts.appDir, { allowUnverified: true }); + linkDefaultWorkspaceSkills(opts.appDir, opts.workspaceRoot); +} + +function skillDirContentsMatch(sourceDir: string, targetDir: string): boolean { + const sourceStat = fs.lstatSync(sourceDir, { throwIfNoEntry: false }); + const targetStat = fs.lstatSync(targetDir, { throwIfNoEntry: false }); + if (!sourceStat?.isDirectory() || !targetStat?.isDirectory()) return false; + + const sourceEntries = fs.readdirSync(sourceDir, { withFileTypes: true }); + const targetEntries = fs.readdirSync(targetDir, { withFileTypes: true }); + if (sourceEntries.length !== targetEntries.length) return false; + + return sourceEntries.every((sourceEntry) => { + const sourcePath = path.join(sourceDir, sourceEntry.name); + const targetPath = path.join(targetDir, sourceEntry.name); + const targetEntry = targetEntries.find( + (entry) => entry.name === sourceEntry.name, + ); + if (!targetEntry) return false; + + if (sourceEntry.isSymbolicLink()) { + return ( + targetEntry.isSymbolicLink() && + fs.readlinkSync(sourcePath) === fs.readlinkSync(targetPath) + ); + } + if (sourceEntry.isDirectory()) { + return ( + targetEntry.isDirectory() && + skillDirContentsMatch(sourcePath, targetPath) + ); + } + return ( + sourceEntry.isFile() && + targetEntry.isFile() && + fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath)) + ); + }); +} +export function removeCopiedFrameworkSkills( + appDir: string, + options: { workspaceRoot?: string; allowUnverified?: boolean } = {}, +): string[] { + const appSkillsDir = path.join(appDir, ".agents", "skills"); + const workspaceSkillsDir = options.workspaceRoot + ? path.join(options.workspaceRoot, ".agents", "skills") + : undefined; + const preserved: string[] = []; for (const skill of FRAMEWORK_TEMPLATE_SHARED_SKILLS) { const localPath = path.join(appSkillsDir, skill); + const stat = fs.lstatSync(localPath, { throwIfNoEntry: false }); + if (!stat || stat.isSymbolicLink()) continue; + + const workspacePath = workspaceSkillsDir + ? path.join(workspaceSkillsDir, skill) + : undefined; if ( - fs.existsSync(localPath) || - fs.lstatSync(localPath, { throwIfNoEntry: false }) + !options.allowUnverified && + (!workspacePath || !skillDirContentsMatch(workspacePath, localPath)) ) { - fs.rmSync(localPath, { recursive: true, force: true }); + preserved.push(skill); + continue; } + fs.rmSync(localPath, { recursive: true, force: true }); } + return preserved; +} +export function linkDefaultWorkspaceSkills( + appDir: string, + workspaceRoot: string, +): string[] { + const appSkillsDir = path.join(appDir, ".agents", "skills"); + const workspaceSkillsDir = path.join(workspaceRoot, ".agents", "skills"); + if (!fs.existsSync(workspaceSkillsDir)) return []; + + fs.mkdirSync(appSkillsDir, { recursive: true }); + const preserved: string[] = []; for (const skill of DEFAULT_WORKSPACE_SKILLS) { const inheritedPath = path.join(workspaceSkillsDir, skill); if (!fs.existsSync(inheritedPath)) continue; const linkPath = path.join(appSkillsDir, skill); + const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false }); if ( - fs.existsSync(linkPath) || - fs.lstatSync(linkPath, { throwIfNoEntry: false }) + existing && + !existing.isSymbolicLink() && + !skillDirContentsMatch(inheritedPath, linkPath) ) { - fs.rmSync(linkPath, { recursive: true, force: true }); + preserved.push(skill); + continue; } + fs.rmSync(linkPath, { recursive: true, force: true }); try { fs.symlinkSync( @@ -229,6 +300,7 @@ function linkInheritedWorkspaceSkills(opts: WorkspacifyOptions): void { ); } } + return preserved; } /** diff --git a/packages/core/src/client/AgentPanel.header.spec.ts b/packages/core/src/client/AgentPanel.header.spec.ts index 70d0b04978..6f63a88105 100644 --- a/packages/core/src/client/AgentPanel.header.spec.ts +++ b/packages/core/src/client/AgentPanel.header.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { AgentChatSurface, + consumeAgentPanelOverlayFocusRestore, deferAgentPanelOverlayOpen, getAgentPanelShortcutHints, getActiveTabScrollDelta, @@ -285,6 +286,20 @@ describe("AgentPanel header overflow actions", () => { } }); + it("consumes the pending menu focus restore for the sibling overlay", () => { + const pendingOverlayRef = { current: true }; + const event = { preventDefault: vi.fn() }; + + consumeAgentPanelOverlayFocusRestore(pendingOverlayRef, event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(pendingOverlayRef.current).toBe(false); + + const secondEvent = { preventDefault: vi.fn() }; + consumeAgentPanelOverlayFocusRestore(pendingOverlayRef, secondEvent); + expect(secondEvent.preventDefault).not.toHaveBeenCalled(); + }); + it("keeps width and full-view actions out of the icon row", () => { const source = readFileSync("src/client/AgentPanel.tsx", { encoding: "utf8", @@ -308,6 +323,10 @@ describe("AgentPanel header overflow actions", () => { "{widenChatHint}", ); expect(overflowMenu.match(/deferAgentPanelOverlayOpen/g)).toHaveLength(3); + expect(overflowMenu).toContain("onCloseAutoFocus"); + expect( + overflowMenu.match(/closeHeaderMenuForOverlay/g)?.length, + ).toBeGreaterThanOrEqual(3); expect(overflowMenu).toContain('t("agentPanel.openFullView")'); expect(overflowMenu).toContain("onSelect={onFullViewRequest}"); expect(source).toContain("onFullViewRequest={onFullscreenRequest}"); diff --git a/packages/core/src/client/AgentPanel.overlay.spec.tsx b/packages/core/src/client/AgentPanel.overlay.spec.tsx new file mode 100644 index 0000000000..fcd9058a3c --- /dev/null +++ b/packages/core/src/client/AgentPanel.overlay.spec.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import React, { act, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + consumeAgentPanelOverlayFocusRestore, + deferAgentPanelOverlayOpen, +} from "./AgentPanel.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./components/ui/dropdown-menu.js"; + +function OverlayHandoffHarness({ + onFocusRestore, +}: { + onFocusRestore?: (prevented: boolean) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const pendingOverlayRef = useRef(false); + + const closeMenuForOverlay = () => { + pendingOverlayRef.current = true; + setMenuOpen(false); + }; + + return ( +
+ + + + + { + consumeAgentPanelOverlayFocusRestore(pendingOverlayRef, event); + onFocusRestore?.(event.defaultPrevented); + }} + > + + deferAgentPanelOverlayOpen(event, closeMenuForOverlay, () => + setFeedbackOpen(true), + ) + } + > + Feedback + + + + + + + + + + + Feedback form + + + +
+ ); +} + +describe("AgentPanel sibling overlay handoff", () => { + let container: HTMLDivElement; + let root: Root; + let frames: Array; + let requestAnimationFrame: typeof window.requestAnimationFrame; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + frames = []; + requestAnimationFrame = window.requestAnimationFrame; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }) as typeof window.requestAnimationFrame; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + window.requestAnimationFrame = requestAnimationFrame; + vi.unstubAllGlobals(); + }); + + it("keeps feedback open after the menu restores focus", async () => { + const focusRestorePrevented = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + const trigger = container.querySelector( + '[data-testid="menu-trigger"]', + ); + trigger?.dispatchEvent( + new MouseEvent("pointerdown", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + trigger?.click(); + }); + + expect( + document.querySelector('[data-testid="feedback-item"]'), + ).toBeTruthy(); + + await act(async () => { + document + .querySelector('[data-testid="feedback-item"]') + ?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(focusRestorePrevented).toHaveBeenCalledWith(true); + expect(frames).toHaveLength(1); + + await act(async () => { + frames[0]!(0); + }); + + expect( + document.body.querySelector('[data-testid="feedback-content"]'), + ).toBeTruthy(); + }); +}); diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index 3ee6fb6a8a..ccbfcd8c30 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -266,6 +266,15 @@ export function deferAgentPanelOverlayOpen( } } +export function consumeAgentPanelOverlayFocusRestore( + pendingOverlayRef: { current: boolean }, + event: { preventDefault: () => void }, +): void { + if (!pendingOverlayRef.current) return; + pendingOverlayRef.current = false; + event.preventDefault(); +} + interface AvailableCli { command: string; label: string; @@ -1254,6 +1263,11 @@ function AgentPanelInner({ const [headerMenuOpen, setHeaderMenuOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const [shareFromMenuOpen, setShareFromMenuOpen] = useState(false); + const preventHeaderMenuFocusRestoreRef = useRef(false); + const closeHeaderMenuForOverlay = useCallback(() => { + preventHeaderMenuFocusRestoreRef.current = true; + setHeaderMenuOpen(false); + }, []); const getChatThreadShareUrl = useCallback( (threadId: string) => { @@ -1424,7 +1438,19 @@ function AgentPanelInner({ - + { + // A sibling overlay owns focus next; restoring it to the menu + // trigger would dismiss that overlay as an outside interaction. + consumeAgentPanelOverlayFocusRestore( + preventHeaderMenuFocusRestoreRef, + event, + ); + }} + > {onCollapse && ( <> @@ -1493,7 +1519,7 @@ function AgentPanelInner({ onSelect={(event) => deferAgentPanelOverlayOpen( event, - () => setHeaderMenuOpen(false), + closeHeaderMenuForOverlay, () => setShareFromMenuOpen(true), ) } @@ -1510,7 +1536,7 @@ function AgentPanelInner({ onSelect={(event) => deferAgentPanelOverlayOpen( event, - () => setHeaderMenuOpen(false), + closeHeaderMenuForOverlay, toggleHistory, ) } @@ -1569,7 +1595,7 @@ function AgentPanelInner({ onSelect={(event) => deferAgentPanelOverlayOpen( event, - () => setHeaderMenuOpen(false), + closeHeaderMenuForOverlay, () => setFeedbackOpen(true), ) } @@ -1658,6 +1684,7 @@ function AgentPanelInner({ allowSettingsMode, availableClis, canUseCodeTools, + closeHeaderMenuForOverlay, closeAllCliTabs, closeAllTabsHint, closeCliTab, diff --git a/packages/core/src/client/AssistantChat.display.spec.ts b/packages/core/src/client/AssistantChat.display.spec.ts index 951fc26d8a..8ebc6b1e12 100644 --- a/packages/core/src/client/AssistantChat.display.spec.ts +++ b/packages/core/src/client/AssistantChat.display.spec.ts @@ -30,6 +30,7 @@ import { assistantUiRecoverableRenderErrorKind, createUserMessageRunConfig, dedupeReconnectContentAgainstMessages, + shouldShowReconnectOverlay, hoistQueuedMessageToFront, displayableUserMessageText, isAssistantUiRecoverableRenderError, @@ -1421,6 +1422,26 @@ describe("missing agent engine setup", () => { }); }); +describe("tool approval continuation", () => { + it("keeps the approval acknowledgement out of visible chat history", () => { + const source = readFileSync("src/client/AssistantChat.tsx", { + encoding: "utf8", + }); + const start = source.indexOf("onApprove: (approvalKey: string) => {"); + const end = source.indexOf("...(approvalActions?.onDeny", start); + const approvalSource = source.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(approvalSource).toContain( + '"Approved. Go ahead and run the requested action."', + ); + expect(approvalSource).toContain( + "true, // hideUserMessage: this is a protocol continuation, not a new prompt", + ); + }); +}); + describe("chat connection suggestion alignment", () => { it("uses the fullscreen composer width contract and removes page-only insets", () => { const panelSource = readFileSync("src/client/AgentPanel.tsx", { @@ -2325,7 +2346,9 @@ describe("waitForThreadRunToClear", () => { expect(renderSource).toContain("visibleReconnectContent.length > 0"); expect(renderSource).toContain("visibleReconnectContent.length === 0"); expect(renderSource).toContain("reconnectContent.length === 0"); - expect(renderSource).toContain("adapterHandoffPending"); + // The overlay is a second fold of the run; it may only render while no + // adapter runtime owns the turn. See the showReconnectOverlay tests below. + expect(renderSource).toContain("showReconnectOverlay"); expect(renderSource.replace(/\s+/g, "")).toContain( "allowActivitySpinner={!reconnectFrozen}", ); @@ -2517,20 +2540,61 @@ describe("server thread snapshot caching", () => { }); }); -describe("adapter reconnect handoff", () => { - it("defers wiping reconnect content until the adapter message catches up", () => { - const source = readFileSync("src/client/AssistantChat.tsx", { - encoding: "utf8", - }); - expect(source).toContain("adapterHandoffPending"); - expect(source).toContain("setAdapterHandoffPending(true)"); - expect(source).toMatch( - /suppressToolRepeats:\s+adapterHandoffPending \|\| isReconnecting \|\| reconnectFrozen/, - ); - expect(source).toContain("Do not memoize this on `messages` identity"); - expect(source).toMatch( - /\(isReconnecting \|\|\s+reconnectFrozen \|\|\s+adapterHandoffPending\)/, - ); +describe("shouldShowReconnectOverlay", () => { + // The reconnect overlay is a second, independent fold of the same run. Every + // duplicate-render report traces back to it being on screen at the same time + // as the adapter's own message. Ownership decides visibility here, so these + // assert behavior rather than grepping the render source. + it("hides the overlay whenever a runtime owns the turn", () => { + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: true, + isReconnecting: true, + reconnectFrozen: false, + }), + ).toBe(false); + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: true, + isReconnecting: false, + reconnectFrozen: true, + }), + ).toBe(false); + // Both readers claiming the turn at once is the exact duplicate-render case. + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: true, + isReconnecting: true, + reconnectFrozen: true, + }), + ).toBe(false); + }); + + it("shows the overlay only when no runtime is streaming", () => { + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: false, + isReconnecting: true, + reconnectFrozen: false, + }), + ).toBe(true); + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: false, + isReconnecting: false, + reconnectFrozen: true, + }), + ).toBe(true); + }); + + it("stays hidden when there is nothing to reconnect", () => { + expect( + shouldShowReconnectOverlay({ + isRuntimeRunning: false, + isReconnecting: false, + reconnectFrozen: false, + }), + ).toBe(false); }); }); diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index dbbed12435..58f119748a 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -195,6 +195,12 @@ import { type McpConnectionResumeRequest, } from "./resources/mcp-connection-resume.js"; import { McpConnectionSuggestion } from "./resources/McpConnectionSuggestion.js"; +import { + claimRunStream, + createRunStreamToken, + ownsRunStream, + releaseRunStream, +} from "./run-stream-ownership.js"; import { AgentAutoContinueSignal, type ContentPart, @@ -1282,6 +1288,31 @@ function trimReconnectTextAlreadyRendered( return changed ? next : content; } +/** + * Whether the reconnect overlay — a SECOND fold of a run, rendered as a sibling + * of the message list — may appear. + * + * The adapter runtime and the reconnect reader both fold the same SSE events + * into their own accumulator. Whenever both are on screen the user sees the + * turn twice: duplicate tool cards (one spinning, one static) and the final + * message streaming in two places. Content-similarity dedupe cannot reliably + * hide the second copy, because the two readers disagree on tool-call identity + * (id-less activity cards get reader-local ids) and on how a turn is split + * across assistant messages. + * + * So ownership decides visibility, not similarity: if a runtime owns the turn, + * the overlay does not render. Keep this a pure function — it is the invariant + * the duplicate-render bug kept violating, and it must stay falsifiable. + */ +export function shouldShowReconnectOverlay(state: { + isRuntimeRunning: boolean; + isReconnecting: boolean; + reconnectFrozen: boolean; +}): boolean { + if (state.isRuntimeRunning) return false; + return state.isReconnecting || state.reconnectFrozen; +} + export function dedupeReconnectContentAgainstMessages( content: ContentPart[], messages: readonly unknown[], @@ -2665,7 +2696,6 @@ const AssistantChatInner = forwardRef< const [reconnectFrozen, setReconnectFrozen] = useState(false); // Adapter took over while reconnect still had visible tool cards — keep the // overlay until the adapter message catches up so we don't flash an empty gap. - const [adapterHandoffPending, setAdapterHandoffPending] = useState(false); const reconnectRunIdRef = useRef(null); const reconnectTurnIdRef = useRef(null); const reconnectTailOnlyRef = useRef(false); @@ -2688,7 +2718,6 @@ const AssistantChatInner = forwardRef< setIsReconnecting(false); setReconnectFrozen(false); setReconnectContent([]); - setAdapterHandoffPending(false); setPendingReconnectRecovery(null); resetRunningActivity(); }, [resetRunningActivity]); @@ -2780,15 +2809,10 @@ const AssistantChatInner = forwardRef< }); const reconnectActivityContent = useMemo( () => - isReconnecting || reconnectFrozen || adapterHandoffPending + isReconnecting || reconnectFrozen ? reconnectActivityFallbackContent(runningActivityTool) : [], - [ - adapterHandoffPending, - isReconnecting, - reconnectFrozen, - runningActivityTool, - ], + [isReconnecting, reconnectFrozen, runningActivityTool], ); const lastBroadcastRunningRef = useRef(isRunning); const tiptapRef = useRef(null); @@ -3139,6 +3163,12 @@ const AssistantChatInner = forwardRef< if (isRuntimeRunningRef.current || isAutoResumingRef.current) { return false; } + // The refs above lag a render and are per-component-instance, while + // MultiTabAssistantChat mounts several instances against one run. The + // claim is the actual mutual exclusion: module-scoped, synchronous, and + // re-checked on every state write below. + const ownershipToken = createRunStreamToken(`reconnect:${runId}`); + if (!claimRunStream(threadId, runId, ownershipToken)) return false; // SUPERSEDE THE PREVIOUS RECONNECT GENERATION. A turn that keeps failing // (e.g. repeated stale_run at "Contacting model") produces a new runId @@ -3180,7 +3210,6 @@ const AssistantChatInner = forwardRef< }); setIsReconnecting(true); setReconnectFrozen(false); - setAdapterHandoffPending(false); setReconnectContent([]); window.dispatchEvent( new CustomEvent("agentNative.chatRunning", { @@ -3353,7 +3382,8 @@ const AssistantChatInner = forwardRef< rafPending = false; if ( !reconnectOwnerMountedRef.current || - reconnectRunIdRef.current !== runId + reconnectRunIdRef.current !== runId || + !ownsRunStream(threadId, runId, ownershipToken) ) { return; } @@ -3369,9 +3399,13 @@ const AssistantChatInner = forwardRef< tabId, scheduleUpdate, (seq, isProgress) => { + // The adapter can preempt this reader mid-stream. Advancing + // the cursor after that would move a run this reader no + // longer represents. + if (!ownsRunStream(threadId, runId, ownershipToken)) return; markReconnectProgress(); reconnectRetryCount = 0; - updateActiveRunSeq(seq, isProgress); + updateActiveRunSeq(threadId, runId, seq, isProgress); }, { preparingActionState }, ); @@ -3422,6 +3456,7 @@ const AssistantChatInner = forwardRef< threadPollEngine?.stop(); watchdog.stop(); clearInterval(idleCheck); + releaseRunStream(threadId, runId, ownershipToken); } // A newer reader, live adapter, stop action, or component unmount took @@ -4425,11 +4460,14 @@ const AssistantChatInner = forwardRef< prevIsRuntimeRunningRef.current = isRuntimeRunning; if (isRuntimeRunning && !wasRunning) { // SINGLE-READER OWNERSHIP: the adapter runtime just took over (a new run - // started or an adopted run resumed). Abort the reconnect reader, but keep - // its visible content until the adapter message has tool/text parts so the - // UI does not flash an empty gap between readers. + // started or an adopted run resumed), so it is now the only owner of this + // turn's rendering. The overlay used to be kept alive here for up to + // 2500ms so the UI would not flash a gap — but that deliberately put two + // independent folds of the same run on screen at once, and the only thing + // hiding the second was content-similarity guessing that fails whenever + // the two readers disagree (id-less activity cards, a turn split across + // several assistant messages). One owner, one surface: drop the overlay. if (reconnectRunIdRef.current !== null) { - const keepOverlay = reconnectContent.length > 0; reconnectRunIdRef.current = null; reconnectAbortRef.current?.abort(); reconnectAbortRef.current = null; @@ -4437,55 +4475,17 @@ const AssistantChatInner = forwardRef< setReconnectFrozen(false); reconnectCanMaterializeRef.current = false; reconnectTailOnlyRef.current = false; - if (keepOverlay) { - setAdapterHandoffPending(true); - } else { - setReconnectContent([]); - setAdapterHandoffPending(false); - } + setReconnectContent([]); } else if (reconnectFrozen) { setReconnectFrozen(false); setReconnectContent([]); - setAdapterHandoffPending(false); reconnectCanMaterializeRef.current = false; } if (forceStopped) { setForceStopped(false); } } - }, [ - isRuntimeRunning, - reconnectFrozen, - forceStopped, - reconnectContent.length, - ]); - - // Release the deferred reconnect overlay once thread messages have caught - // up enough that dedupe would hide the overlay, or after a short timeout so - // a stuck handoff cannot leave duplicate tool cards forever. - useEffect(() => { - if (!adapterHandoffPending) return; - if (!isRuntimeRunning) { - setReconnectContent([]); - setAdapterHandoffPending(false); - return; - } - const stillNeeded = - dedupeReconnectContentAgainstMessages(reconnectContent, messages, { - suppressToolRepeats: true, - trimTailTextOverlap: true, - }).length > 0; - if (!stillNeeded) { - setReconnectContent([]); - setAdapterHandoffPending(false); - return; - } - const timer = window.setTimeout(() => { - setReconnectContent([]); - setAdapterHandoffPending(false); - }, 2500); - return () => window.clearTimeout(timer); - }, [adapterHandoffPending, isRuntimeRunning, messages, reconnectContent]); + }, [isRuntimeRunning, reconnectFrozen, forceStopped]); // Same transition guard for isReconnecting: only clear forceStopped on // the false→true edge (a new reconnect starting on page load). @@ -4503,7 +4503,6 @@ const AssistantChatInner = forwardRef< if (!reconnectCanMaterializeRef.current) { setReconnectFrozen(false); setReconnectContent([]); - setAdapterHandoffPending(false); return; } try { @@ -4553,7 +4552,6 @@ const AssistantChatInner = forwardRef< threadRuntime.import(ensureMessageMetadata(repo)); setReconnectFrozen(false); setReconnectContent([]); - setAdapterHandoffPending(false); reconnectCanMaterializeRef.current = false; reconnectTurnIdRef.current = null; } catch (err) { @@ -4693,7 +4691,6 @@ const AssistantChatInner = forwardRef< reconnectAbortRef.current = null; reconnectRunIdRef.current = null; setIsReconnecting(false); - setAdapterHandoffPending(false); const shouldFreezeReconnectContent = !reconnectTailOnlyRef.current && reconnectCanMaterializeRef.current && @@ -5222,12 +5219,21 @@ const AssistantChatInner = forwardRef< // an ahead-of-the-thread reconnect copy visible creates the familiar // two-card stack while the adapter catches up, so prefer one row and // let the live message advance in place. - suppressToolRepeats: - adapterHandoffPending || isReconnecting || reconnectFrozen, - trimTailTextOverlap: - adapterHandoffPending || reconnectTailOnlyRef.current, + suppressToolRepeats: isReconnecting || reconnectFrozen, + trimTailTextOverlap: reconnectTailOnlyRef.current, }, ); + // The reconnect overlay is a SECOND fold of the same run, rendered as a + // sibling of the message list. It may only appear while no adapter runtime + // owns the turn. Deriving it from `isRuntimeRunning` at render time — rather + // than relying on an effect to clear the overlay's own flags afterwards — + // is what makes two streaming copies structurally impossible instead of + // merely unlikely; the effect below runs a frame too late to prevent it. + const showReconnectOverlay = shouldShowReconnectOverlay({ + isRuntimeRunning, + isReconnecting, + reconnectFrozen, + }); const latestMessage = messages[messages.length - 1]; const reconnectStatusContent = visibleReconnectContent.length > 0 @@ -5504,7 +5510,7 @@ const AssistantChatInner = forwardRef< false, false, false, - false, + true, // hideUserMessage: this is a protocol continuation, not a new prompt undefined, [approvalKey], ); @@ -5829,9 +5835,7 @@ const AssistantChatInner = forwardRef< /> )} - {(isReconnecting || - reconnectFrozen || - adapterHandoffPending) && + {showReconnectOverlay && visibleReconnectContent.length > 0 && ( )} - {(isReconnecting || - reconnectFrozen || - adapterHandoffPending) && + {showReconnectOverlay && visibleReconnectContent.length === 0 && reconnectContent.length === 0 && reconnectActivityContent.length > 0 && ( diff --git a/packages/core/src/client/RunStuckBanner.spec.tsx b/packages/core/src/client/RunStuckBanner.spec.tsx index 5704b8654f..dabf56209a 100644 --- a/packages/core/src/client/RunStuckBanner.spec.tsx +++ b/packages/core/src/client/RunStuckBanner.spec.tsx @@ -187,7 +187,7 @@ describe("RunStuckBanner", () => { expect(container.textContent).toBe("stuck"); await act(async () => { - updateActiveRunSeq(2, true); + updateActiveRunSeq("thread-1", "run-streaming", 2, true); }); expect(container.textContent).toBe("healthy"); }); diff --git a/packages/core/src/client/active-run-state.spec.ts b/packages/core/src/client/active-run-state.spec.ts index fb0314fc7a..9929b55a6c 100644 --- a/packages/core/src/client/active-run-state.spec.ts +++ b/packages/core/src/client/active-run-state.spec.ts @@ -56,12 +56,42 @@ describe("resolveReconnectAfterSeq", () => { expect(resolveReconnectAfterSeq("thread-1", "run-1")).toBe(42); }); - it("returns 0 when there is no stored cursor or the run does not match", () => { + it("returns 0 only for a run this browser has never read", () => { setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 10 }); expect(resolveReconnectAfterSeq("thread-1", "run-2")).toBe(0); expect(resolveReconnectAfterSeq("thread-2", "run-1")).toBe(0); + }); + + it("keeps a run's cursor after it stops being the focused run", () => { + // Losing focus is not evidence the run ended. Dropping the cursor here made + // the next reconnect resume from seq 0 and replay the whole run on top of + // history that already had it — the duplicate-turn report. + setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 10 }); clearActiveRun(); - expect(resolveReconnectAfterSeq("thread-1", "run-1")).toBe(0); + expect(getActiveRun()).toBeNull(); + expect(resolveReconnectAfterSeq("thread-1", "run-1")).toBe(11); + }); + + it("keeps concurrent runs' cursors independent", () => { + // Agent-teams tabs stream in parallel. A shared single-slot cursor let the + // second run's seq overwrite the first run's, so the first run reconnected + // at the wrong offset (or from 0) and rendered its turn twice. + setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 5 }); + setActiveRun({ threadId: "thread-2", runId: "run-2", lastSeq: 100 }); + + updateActiveRunSeq("thread-2", "run-2", 130); + updateActiveRunSeq("thread-1", "run-1", 7); + + expect(resolveReconnectAfterSeq("thread-1", "run-1")).toBe(8); + expect(resolveReconnectAfterSeq("thread-2", "run-2")).toBe(131); + }); + + it("ignores a cursor write for a run it is not tracking", () => { + setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 5 }); + updateActiveRunSeq("thread-9", "run-9", 900); + + expect(resolveReconnectAfterSeq("thread-9", "run-9")).toBe(0); + expect(resolveReconnectAfterSeq("thread-1", "run-1")).toBe(6); }); it("persists the current activity tool for refresh-time reconnects", () => { @@ -73,7 +103,7 @@ describe("resolveReconnectAfterSeq", () => { ); expect(getActiveRunActivityTool("thread-1", "run-2")).toBeNull(); - updateActiveRunSeq(12); + updateActiveRunSeq("thread-1", "run-1", 12); expect(getActiveRun()).toMatchObject({ threadId: "thread-1", runId: "run-1", @@ -81,7 +111,7 @@ describe("resolveReconnectAfterSeq", () => { activityTool: "generate-design", }); - updateActiveRunSeq(13, true); + updateActiveRunSeq("thread-1", "run-1", 13, true); expect(getActiveRun()).toMatchObject({ threadId: "thread-1", runId: "run-1", @@ -102,7 +132,7 @@ describe("resolveReconnectAfterSeq", () => { it("preserves real-progress freshness when reconnect code replaces the cursor", () => { setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 10 }); - updateActiveRunSeq(11, true); + updateActiveRunSeq("thread-1", "run-1", 11, true); const progress = getActiveRun(); setActiveRun({ threadId: "thread-1", runId: "run-1", lastSeq: 10 }); diff --git a/packages/core/src/client/active-run-state.ts b/packages/core/src/client/active-run-state.ts index 769d7dc1e5..01cfecdef5 100644 Binary files a/packages/core/src/client/active-run-state.ts and b/packages/core/src/client/active-run-state.ts differ diff --git a/packages/core/src/client/agent-chat-adapter.spec.ts b/packages/core/src/client/agent-chat-adapter.spec.ts index d88855dd78..2db37524bd 100644 --- a/packages/core/src/client/agent-chat-adapter.spec.ts +++ b/packages/core/src/client/agent-chat-adapter.spec.ts @@ -6491,7 +6491,7 @@ describe("createAgentChatAdapter", () => { expect(last.content.at(-1).text).toContain("Working and done"); }); - it("adds final text when a terminal followed run contains only completed tool work", async () => { + it("continues a followed background run whose done event follows completed tool work", async () => { vi.useFakeTimers(); vi.stubGlobal("window", { dispatchEvent: vi.fn() }); vi.stubGlobal( @@ -6506,29 +6506,134 @@ describe("createAgentChatAdapter", () => { }, ); + let postCount = 0; let requestTurnId = ""; const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => { if (url === "/_agent-native/agent-chat" && init?.method === "POST") { - requestTurnId = JSON.parse(init.body as string).turnId; - return backgroundSseResponse( - [ - { - type: "tool_start", - id: "sync-1", - tool: "sync-source", - input: {}, - }, - { - type: "tool_done", - id: "sync-1", - tool: "sync-source", - result: '{"synced":4}', - completedSideEffect: true, - }, - { type: "auto_continue", reason: "run_timeout" }, - ], - "run-bg-tool-only", - ); + postCount += 1; + requestTurnId = (JSON.parse(init.body as string) as { turnId: string }) + .turnId; + return postCount === 1 + ? backgroundSseResponse( + [ + { + type: "tool_start", + id: "blocks-1", + tool: "get-plan-blocks", + input: { format: "reference" }, + }, + { + type: "tool_done", + id: "blocks-1", + tool: "get-plan-blocks", + result: '{"count":20}', + }, + { type: "auto_continue", reason: "run_timeout" }, + ], + "run-follow-tool-only", + ) + : backgroundSseResponse( + [{ type: "text", text: "The plan is ready." }, { type: "done" }], + "run-follow-final", + ); + } + if (url.includes("/runs/active")) { + return jsonResponse({ + active: true, + runId: "run-follow-tool-only", + threadId: "thread-bg-follow-tool-only", + turnId: requestTurnId, + status: "completed", + dispatchMode: "background-processing", + heartbeatAt: Date.now(), + lastProgressAt: Date.now(), + }); + } + if (url.includes("/runs/run-follow-tool-only/events")) { + return sseResponse([{ type: "done" }], "run-follow-tool-only"); + } + return jsonResponse({ error: "unexpected" }, 500); + }); + vi.stubGlobal("fetch", fetchSpy); + + const adapter = createAgentChatAdapter({ + apiUrl: "/_agent-native/agent-chat", + tabId: "chat-bg-follow-tool-only", + threadId: "thread-bg-follow-tool-only", + }); + const promise = drain( + adapter.run({ + messages: [ + { + role: "user", + content: [{ type: "text", text: "visualize this plan" }], + }, + ], + abortSignal: new AbortController().signal, + } as any), + ); + + await vi.advanceTimersByTimeAsync(5_000); + const results = await promise; + + expect(postCount).toBe(2); + expect((results.at(-1) as any).content.at(-1).text).toBe( + "The plan is ready.", + ); + expect( + (results.at(-1) as any).metadata?.custom?.runWarning, + ).toBeUndefined(); + }); + + it("continues when a terminal followed run contains only completed tool work", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { dispatchEvent: vi.fn() }); + vi.stubGlobal( + "CustomEvent", + class CustomEvent { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + ); + + let requestTurnId = ""; + const postBodies: Array> = []; + const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "/_agent-native/agent-chat" && init?.method === "POST") { + const body = JSON.parse(init.body as string) as Record; + postBodies.push(body); + requestTurnId = body.turnId; + return postBodies.length === 1 + ? backgroundSseResponse( + [ + { + type: "tool_start", + id: "sync-1", + tool: "sync-source", + input: {}, + }, + { + type: "tool_done", + id: "sync-1", + tool: "sync-source", + result: '{"synced":4}', + completedSideEffect: true, + }, + { type: "auto_continue", reason: "run_timeout" }, + ], + "run-bg-tool-only", + ) + : backgroundSseResponse( + [ + { type: "text", text: "The source is synced." }, + { type: "done" }, + ], + "run-bg-tool-final", + ); } if (url.includes("/runs/active")) { return jsonResponse({ @@ -6569,13 +6674,82 @@ describe("createAgentChatAdapter", () => { await vi.advanceTimersByTimeAsync(5_000); const results = await promise; + expect(postBodies).toHaveLength(2); + expect(postBodies[1]).toMatchObject({ internalContinuation: true }); + expect(postBodies[1].history.at(-1).content).toContain("Tool: sync-source"); const last = results.at(-1) as any; - expect(last.status).toEqual({ type: "complete", reason: "stop" }); - expect(last.metadata?.custom?.runWarning?.errorCode).toBe( - "final_response_missing_after_tool", + expect(last.content.at(-1).text).toBe("The source is synced."); + expect(last.metadata?.custom?.runWarning).toBeUndefined(); + }); + + it("recovers a background response whose done event follows completed tool work", async () => { + vi.stubGlobal("window", { dispatchEvent: vi.fn() }); + vi.stubGlobal( + "CustomEvent", + class CustomEvent { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, ); - expect(last.content.at(-1).text).toContain( - "stopped before sending a final message", + + let postCount = 0; + const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => { + if (url !== "/_agent-native/agent-chat" || init?.method !== "POST") { + return jsonResponse({ active: false, status: "idle" }); + } + postCount += 1; + return postCount === 1 + ? backgroundSseResponse( + [ + { + type: "tool_start", + id: "blocks-1", + tool: "get-plan-blocks", + input: { format: "reference" }, + }, + { + type: "tool_done", + id: "blocks-1", + tool: "get-plan-blocks", + result: '{"count":20}', + }, + { type: "done" }, + ], + "run-bg-done-tool-only", + ) + : backgroundSseResponse( + [{ type: "text", text: "The plan is ready." }, { type: "done" }], + "run-bg-done-final", + ); + }); + vi.stubGlobal("fetch", fetchSpy); + + const adapter = createAgentChatAdapter({ + apiUrl: "/_agent-native/agent-chat", + tabId: "chat-bg-done-tool-only", + threadId: "thread-bg-done-tool-only", + }); + const promise = drain( + adapter.run({ + messages: [ + { + role: "user", + content: [{ type: "text", text: "visualize this plan" }], + }, + ], + abortSignal: new AbortController().signal, + } as any), + ); + + const results = await promise; + + expect(postCount).toBe(2); + expect((results.at(-1) as any).content.at(-1).text).toBe( + "The plan is ready.", ); }); diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts index 36433ac10e..5189570f1e 100644 --- a/packages/core/src/client/agent-chat-adapter.ts +++ b/packages/core/src/client/agent-chat-adapter.ts @@ -24,6 +24,11 @@ import { getOrCreateAnalyticsSessionId } from "./analytics-session.js"; import { captureError } from "./analytics.js"; import { agentNativePath } from "./api-path.js"; import { formatChatErrorText, normalizeChatError } from "./error-format.js"; +import { + createRunStreamToken, + preemptRunStream, + releaseRunStream, +} from "./run-stream-ownership.js"; import { AgentAutoContinueSignal, INTERRUPTED_TOOL_RESULT, @@ -1277,6 +1282,29 @@ function snapshotContent(content: ContentPart[]): ContentPart[] { ); } +function hasMissingFinalResponseAfterTool(content: ContentPart[]): boolean { + const warning = appendMissingFinalResponseWarning(snapshotContent(content)); + return warning?.errorCode === "final_response_missing_after_tool"; +} + +function missingFinalResponseWarningFromResult( + result: ChatModelRunResult, +): { message: string } | null { + const metadata = result.metadata as { custom?: unknown } | undefined; + const custom = metadata?.custom; + if (!custom || typeof custom !== "object") return null; + const warning = (custom as Record).runWarning; + if (!warning || typeof warning !== "object") return null; + const warningRecord = warning as Record; + if (warningRecord.errorCode !== "final_response_missing_after_tool") { + return null; + } + return { + message: + typeof warningRecord.message === "string" ? warningRecord.message : "", + }; +} + function stableJson(value: unknown): string { try { return JSON.stringify(value); @@ -2032,7 +2060,19 @@ export function createAgentChatAdapter( activeRun.runId === runId) ); }; + // The adapter's own stream outranks AssistantChat's reconnect fallback: + // when it attaches to a run, any reconnect reader folding the same run + // must stop writing UI state or both folds render at once. + const streamOwnershipToken = createRunStreamToken(`adapter:${turnId}`); + const takeRunStreamOwnership = () => { + if (threadId && runId) { + preemptRunStream(threadId, runId, streamOwnershipToken); + } + }; const settleTerminalChatRun = () => { + if (threadId && runId) { + releaseRunStream(threadId, runId, streamOwnershipToken); + } if (!ownsActiveRunState()) return; if (threadId && runId) { clearActiveRunIfMatches(threadId, runId); @@ -2444,6 +2484,7 @@ export function createAgentChatAdapter( reconnectRes.headers.get("X-Dispatch-Mode"), ); + takeRunStreamOwnership(); for await (const result of readSSEStream( reconnectRes.body, content, @@ -2451,7 +2492,9 @@ export function createAgentChatAdapter( tabId, (seq, isProgress) => { rememberRunSeq(seq); - if (threadId) updateActiveRunSeq(seq, isProgress); + if (threadId && runId) { + updateActiveRunSeq(threadId, runId, seq, isProgress); + } }, runId, currentSSEOptions(), @@ -2830,7 +2873,7 @@ export function createAgentChatAdapter( // persistently unattachable run still terminates loudly). const followAttachOnce = async function* (): AsyncGenerator< ChatModelRunResult, - "completed" | "aborted" | "detached" | "gone", + "completed" | "client_continue" | "aborted" | "detached" | "gone", unknown > { if (!runId) return "gone"; @@ -2849,6 +2892,8 @@ export function createAgentChatAdapter( updateCurrentRunDispatchMode( eventsRes.headers.get("X-Dispatch-Mode"), ); + takeRunStreamOwnership(); + let missingFinalResponseResult: ChatModelRunResult | null = null; for await (const result of readSSEStream( eventsRes.body, content, @@ -2856,7 +2901,9 @@ export function createAgentChatAdapter( tabId, (seq, isProgress) => { rememberRunSeq(seq); - if (threadId) updateActiveRunSeq(seq, isProgress); + if (threadId && runId) { + updateActiveRunSeq(threadId, runId, seq, isProgress); + } }, runId, currentSSEOptions({ @@ -2866,11 +2913,39 @@ export function createAgentChatAdapter( }), )) { const nextResult = withRequestModeMetadata(result); + if ( + isDurableBackgroundDispatch() && + missingFinalResponseWarningFromResult(nextResult) + ) { + missingFinalResponseResult = nextResult; + continue; + } if (isTerminalChatModelRunResult(nextResult)) { settleTerminalChatRun(); } yield nextResult; } + if (missingFinalResponseResult) { + const warning = missingFinalResponseWarningFromResult( + missingFinalResponseResult, + ); + const lastContentPart = content.at(-1); + if ( + warning?.message && + lastContentPart?.type === "text" && + lastContentPart.text === warning.message + ) { + content.pop(); + } + if (continueAfterMissingFinalResponse()) { + await delay(250, abortSignal); + return "client_continue"; + } + settleTerminalChatRun(); + yield missingFinalResponseResult; + clearActiveRun(); + return "completed"; + } // readSSEStream returned normally: a terminal done/error was // consumed and rendered — the turn is over. clearActiveRun(); @@ -3213,6 +3288,13 @@ export function createAgentChatAdapter( continue; } } + if ( + hasMissingFinalResponseAfterTool(content) && + continueAfterMissingFinalResponse() + ) { + await delay(250, abortSignal); + return "client_continue"; + } yield* emitBackgroundTerminalOutcome(active); return "completed"; } @@ -3241,6 +3323,9 @@ export function createAgentChatAdapter( } const seqBeforeAttach = lastSeq; const attach = yield* followAttachOnce(); + if (attach === "client_continue") { + return "client_continue"; + } if (attach === "completed" || attach === "aborted") { return "completed"; } @@ -3634,6 +3719,21 @@ export function createAgentChatAdapter( }; }; + const continueAfterMissingFinalResponse = (): boolean => { + const continuation = prepareAutoContinuation( + new AgentAutoContinueSignal({ reason: "stream_ended" }), + ); + if (!continuation.ok) return false; + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("agent-chat:auto-continue", { + detail: { tabId }, + }), + ); + } + return true; + }; + while (true) { try { runId = null; @@ -3908,6 +4008,8 @@ export function createAgentChatAdapter( setActiveRun({ threadId, runId, turnId, lastSeq: -1 }); } + takeRunStreamOwnership(); + let missingFinalResponseResult: ChatModelRunResult | null = null; for await (const result of readSSEStream( res.body, content, @@ -3916,19 +4018,47 @@ export function createAgentChatAdapter( (seq, isProgress) => { rememberRunSeq(seq); if (runId && threadId) { - updateActiveRunSeq(seq, isProgress); + updateActiveRunSeq(threadId, runId, seq, isProgress); } }, runId, currentSSEOptions(), )) { const nextResult = withRequestModeMetadata(result); + if ( + isDurableBackgroundDispatch() && + missingFinalResponseWarningFromResult(nextResult) + ) { + missingFinalResponseResult = nextResult; + continue; + } if (isTerminalChatModelRunResult(nextResult)) { settleTerminalChatRun(); } yield nextResult; } + if (isDurableBackgroundDispatch() && missingFinalResponseResult) { + const warning = missingFinalResponseWarningFromResult( + missingFinalResponseResult, + ); + const lastContentPart = content.at(-1); + if ( + warning?.message && + lastContentPart?.type === "text" && + lastContentPart.text === warning.message + ) { + content.pop(); + } + if (continueAfterMissingFinalResponse()) { + continue; + } + settleTerminalChatRun(); + yield missingFinalResponseResult; + clearActiveRun(); + return; + } + // Run completed normally — clear active run state clearActiveRun(); return; diff --git a/packages/core/src/client/analytics.spec.ts b/packages/core/src/client/analytics.spec.ts index ba0c5f9c52..3ed876e255 100644 --- a/packages/core/src/client/analytics.spec.ts +++ b/packages/core/src/client/analytics.spec.ts @@ -292,6 +292,49 @@ describe("browser analytics pageviews", () => { expect(sentryMock.captureException).toHaveBeenCalledTimes(1); }); + it("keeps exception context in first-party analytics but omits it from Amplitude", async () => { + installBrowser(); + const { analyticsCalls } = installFetch(); + vi.stubEnv("VITE_AMPLITUDE_API_KEY", "amplitude_test"); + const { captureException, configureTracking } = await freshAnalytics(); + + configureTracking({ + key: "anpk_configured", + endpoint: "https://analytics.example.test/track", + errorCapture: { + captureGlobalErrors: false, + captureUnhandledRejections: false, + }, + }); + await tick(); + amplitudeMock.track.mockClear(); + analyticsCalls.length = 0; + + captureException(new Error("Renderer failed"), { + tags: { route: "/api/run", status_code: 500 }, + extra: { request_id: "request-1", runId: "run-1" }, + }); + await tick(); + + const firstPartyException = analyticsCalls + .map(([, init]) => JSON.parse(String(init.body))) + .find((body) => body.event === "$exception"); + expect(firstPartyException?.properties).toMatchObject({ + exceptionTags: { route: "/api/run", status_code: "500" }, + exceptionExtra: { request_id: "request-1", runId: "run-1" }, + }); + + const amplitudeException = amplitudeMock.track.mock.calls.find( + ([name]) => name === "$exception", + ); + expect(amplitudeException?.[1]).toMatchObject({ + exceptionType: "Error", + exceptionMessage: "Renderer failed", + }); + expect(amplitudeException?.[1]).not.toHaveProperty("exceptionTags"); + expect(amplitudeException?.[1]).not.toHaveProperty("exceptionExtra"); + }); + it("accepts the first-party public key and endpoint at configure time", async () => { installBrowser(); const { analyticsCalls } = installFetch(); diff --git a/packages/core/src/client/analytics.ts b/packages/core/src/client/analytics.ts index 01ad49305b..0fc8fd4705 100644 --- a/packages/core/src/client/analytics.ts +++ b/packages/core/src/client/analytics.ts @@ -1335,6 +1335,19 @@ function exceptionEventProperties( }; } +function amplitudeEventProperties( + name: string, + properties: Record, +): Record { + if (name !== AGENT_NATIVE_EXCEPTION_EVENT_NAME) return properties; + const { + exceptionTags: _exceptionTags, + exceptionExtra: _exceptionExtra, + ...stableProperties + } = properties; + return stableProperties; +} + /** * Resolve browser PostHog config, or `undefined` when error capture should not * reach PostHog. Reads the SSR-injected shell config first, then Vite env for @@ -1926,12 +1939,13 @@ export function trackEvent( if (typeof window === "undefined") return; ensureSentry(); const props = resolveProps(name, params); + const amplitudeProps = amplitudeEventProperties(name, props); window.gtag?.("event", name.replace(/\s+/g, "_"), props); if (ensureAmplitude()) { - _amplitudeModule?.track(name, props); + _amplitudeModule?.track(name, amplitudeProps); } else if (_amplitudeApiKey) { if (_pendingAmplitudeEvents.length < 100) { - _pendingAmplitudeEvents.push([name, props]); + _pendingAmplitudeEvents.push([name, amplitudeProps]); } } sendAgentNativeAnalytics(name, props); diff --git a/packages/core/src/client/chat-first/apps-rail.spec.tsx b/packages/core/src/client/chat-first/apps-rail.spec.tsx new file mode 100644 index 0000000000..00643123dd --- /dev/null +++ b/packages/core/src/client/chat-first/apps-rail.spec.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChatFirstAppsRail } from "./apps-rail.js"; + +describe("ChatFirstAppsRail", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("grays non-selected app icons while keeping the selected icon in color", () => { + act(() => { + root.render( + ( + {app.name} + )} + />, + ); + }); + + const selectedIcon = container.querySelector( + '[data-app-id="content"] [data-chat-first-app-icon]', + ); + const inactiveIcon = container.querySelector( + '[data-app-id="analytics"] [data-chat-first-app-icon]', + ); + + expect(selectedIcon?.className).not.toContain("grayscale"); + expect( + selectedIcon?.closest("[data-chat-first-app]")?.className, + ).not.toContain("bg-sidebar-accent"); + expect( + selectedIcon + ?.querySelector("[data-icon-inactive]") + ?.getAttribute("data-icon-inactive"), + ).toBe("false"); + expect(inactiveIcon?.className).toContain("grayscale"); + expect( + inactiveIcon + ?.querySelector("[data-icon-inactive]") + ?.getAttribute("data-icon-inactive"), + ).toBe("true"); + }); + + it("keeps app icons in color when no app is selected", () => { + act(() => { + root.render( + ( + {app.name} + )} + />, + ); + }); + + const icon = container.querySelector( + "[data-chat-first-app-icon]", + ); + expect(icon?.className).not.toContain("grayscale"); + expect( + icon + ?.querySelector("[data-icon-inactive]") + ?.getAttribute("data-icon-inactive"), + ).toBe("false"); + }); + + it("does not add an outer selection background in the expanded rail", () => { + act(() => { + root.render( + ( + {app.name} + )} + />, + ); + }); + + const appRow = container.querySelector( + '[data-chat-first-app][data-app-id="content"]', + ); + expect(appRow?.className).not.toContain("bg-sidebar-accent"); + }); +}); diff --git a/packages/core/src/client/chat-first/apps-rail.tsx b/packages/core/src/client/chat-first/apps-rail.tsx index 9c6e33aa6d..63bcc00b5f 100644 --- a/packages/core/src/client/chat-first/apps-rail.tsx +++ b/packages/core/src/client/chat-first/apps-rail.tsx @@ -29,10 +29,36 @@ import { cn } from "../utils.js"; import { defaultChatFirstCopy } from "./copy.js"; import type { ChatFirstAppItem, + ChatFirstAppIconRenderOptions, ChatFirstAppRailProps, ChatFirstCopy, } from "./types.js"; +function ChatFirstRailAppIcon({ + app, + activeAppId, + renderIcon, +}: { + app: ChatFirstAppItem; + activeAppId?: string; + renderIcon: ( + app: ChatFirstAppItem, + options?: ChatFirstAppIconRenderOptions, + ) => ReactNode; +}) { + const isActive = activeAppId !== undefined && activeAppId === app.id; + const isInactive = activeAppId !== undefined && !isActive; + + return ( + + {renderIcon(app, { isActive, isInactive })} + + ); +} + function AppRows({ apps, activeAppId, @@ -57,7 +83,10 @@ function AppRows({ onRemoveApp?: (app: ChatFirstAppItem) => void; onTogglePinned: (id: string) => void; onMove: (id: string, direction: -1 | 1) => void; - renderIcon: (app: ChatFirstAppItem) => ReactNode; + renderIcon: ( + app: ChatFirstAppItem, + options?: ChatFirstAppIconRenderOptions, + ) => ReactNode; copy: ChatFirstCopy; }) { const orderedIds = orderChatFirstAppIds( @@ -85,7 +114,7 @@ function AppRows({ className={cn( "group flex h-8 w-full min-w-0 items-center gap-1 rounded-md px-0 text-sm", active - ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" + ? "font-medium text-sidebar-foreground" : "text-sidebar-foreground/75 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", )} onDragStart={(event) => { @@ -113,7 +142,11 @@ function AppRows({ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" aria-label={copy("openApp", { name: app.name })} > - {renderIcon(app)} + {app.name} ))} {onOpenAllApps ? ( diff --git a/packages/core/src/client/chat-first/primary-nav.spec.tsx b/packages/core/src/client/chat-first/primary-nav.spec.tsx index f4c725c8b4..fcc9756239 100644 --- a/packages/core/src/client/chat-first/primary-nav.spec.tsx +++ b/packages/core/src/client/chat-first/primary-nav.spec.tsx @@ -107,4 +107,26 @@ describe("ChatFirstPrimaryNavigation", () => { ).not.toContain("New chat"); expect(container.querySelector(".code-agents-nav-list")).not.toBeNull(); }); + + it("keeps collapsed New chat icon-only with an accessible tooltip", () => { + act(() => { + root.render( + , + ); + }); + + const newChat = container.querySelector( + ".code-agents-primary-new-chat", + ); + expect(newChat).not.toBeNull(); + expect(newChat?.querySelector("span")?.className).toContain("sr-only"); + expect(newChat?.getAttribute("aria-label")).toBe("New chat"); + expect(newChat?.getAttribute("title")).toBe("New chat"); + }); }); diff --git a/packages/core/src/client/chat-first/primary-nav.tsx b/packages/core/src/client/chat-first/primary-nav.tsx index 586d84c171..40fc1031d0 100644 --- a/packages/core/src/client/chat-first/primary-nav.tsx +++ b/packages/core/src/client/chat-first/primary-nav.tsx @@ -17,6 +17,7 @@ export function ChatFirstPrimaryNavigation({ onOpenScheduled, onSearch, activeTab, + collapsed = false, stickyNewChat = false, copy = defaultChatFirstCopy, }: { @@ -25,6 +26,7 @@ export function ChatFirstPrimaryNavigation({ onOpenScheduled: () => void; onSearch?: () => void; activeTab?: ChatFirstPrimaryTab; + collapsed?: boolean; stickyNewChat?: boolean; copy?: ChatFirstCopy; }) { @@ -45,6 +47,8 @@ export function ChatFirstPrimaryNavigation({ type="button" role="tab" aria-selected={activeTab === tab} + aria-label={tab === "new-chat" ? copy("newChat") : undefined} + title={tab === "new-chat" && collapsed ? copy("newChat") : undefined} className={[tabClassName(tab), className].filter(Boolean).join(" ")} onClick={onSelect} > @@ -57,7 +61,9 @@ export function ChatFirstPrimaryNavigation({ "new-chat", <>