From f1f6516056664209f0c844949d75f693c443dea0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 14 May 2026 04:02:09 +0200 Subject: [PATCH] refactor: reorganized exports and removed obsolete helper branches - Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals. --- .fallowrc.jsonc | 29 ++ biome.json | 6 + package.json | 283 ++++++----- packages/agent/src/agent-loop.ts | 22 - packages/agent/src/harmony-leak.ts | 1 - packages/agent/test/agent-loop.test.ts | 2 +- packages/agent/test/helpers.ts | 2 +- .../src/providers/azure-openai-responses.ts | 102 +--- .../ai/src/providers/google-gemini-cli.ts | 96 +--- packages/ai/src/providers/google-shared.ts | 450 +++++++++++++++++- packages/ai/src/providers/google-vertex.ts | 390 +-------------- packages/ai/src/providers/google.ts | 375 +-------------- packages/ai/src/providers/kimi.ts | 111 +---- .../ai/src/providers/openai-anthropic-shim.ts | 136 ++++++ .../src/providers/openai-codex-responses.ts | 126 +---- .../ai/src/providers/openai-completions.ts | 19 +- .../src/providers/openai-responses-shared.ts | 167 ++++++- packages/ai/src/providers/openai-responses.ts | 86 +--- packages/ai/src/providers/synthetic.ts | 117 +---- packages/ai/src/types.ts | 2 +- packages/ai/src/utils/oauth/lm-studio.ts | 2 - .../ai/test/anthropic-stream-envelope.test.ts | 8 +- packages/ai/test/github-copilot-login.test.ts | 2 - packages/ai/test/oauth.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- .../examples/extensions/plan-mode.ts | 1 - .../coding-agent/src/autoresearch/helpers.ts | 17 + .../src/autoresearch/tools/log-experiment.ts | 26 +- .../src/autoresearch/tools/run-experiment.ts | 19 +- packages/coding-agent/src/cli/list-models.ts | 2 +- packages/coding-agent/src/cli/shell-cli.ts | 16 +- packages/coding-agent/src/cli/update-cli.ts | 2 +- .../src/commit/analysis/conventional.ts | 74 +-- .../src/commit/map-reduce/reduce-phase.ts | 71 +-- packages/coding-agent/src/commit/pipeline.ts | 4 +- .../coding-agent/src/commit/shared-llm.ts | 89 ++++ .../src/config/model-equivalence.ts | 19 +- .../coding-agent/src/config/model-resolver.ts | 5 +- packages/coding-agent/src/config/settings.ts | 2 +- .../src/eval/js/shared/rewrite-imports.ts | 4 +- .../coding-agent/src/exec/bash-executor.ts | 3 +- .../src/extensibility/extensions/runner.ts | 2 +- .../src/extensibility/extensions/types.ts | 309 ++++-------- .../src/extensibility/hooks/types.ts | 403 ++++------------ .../src/extensibility/shared-events.ts | 335 +++++++++++++ .../src/hindsight/mental-models.ts | 2 +- .../src/internal-urls/agent-protocol.ts | 21 +- .../src/internal-urls/artifact-protocol.ts | 20 +- .../src/internal-urls/registry-helpers.ts | 25 + .../src/modes/components/bash-execution.ts | 87 +--- .../src/modes/components/custom-message.ts | 54 +-- .../src/modes/components/eval-execution.ts | 84 ++-- .../src/modes/components/execution-shared.ts | 102 ++++ .../src/modes/components/hook-message.ts | 66 +-- .../src/modes/components/message-frame.ts | 88 ++++ .../src/modes/components/model-selector.ts | 2 +- .../src/modes/components/session-selector.ts | 2 +- .../controllers/command-controller-shared.ts | 108 +++++ .../controllers/mcp-command-controller.ts | 62 +-- .../controllers/ssh-command-controller.ts | 75 +-- packages/coding-agent/src/modes/print-mode.ts | 94 +--- .../coding-agent/src/modes/rpc/rpc-mode.ts | 101 +--- .../coding-agent/src/modes/runtime-init.ts | 115 +++++ .../coding-agent/src/modes/theme/theme.ts | 12 +- .../src/modes/utils/ui-helpers.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 140 +++--- packages/coding-agent/src/session/messages.ts | 3 +- packages/coding-agent/src/tools/ast-edit.ts | 141 +----- packages/coding-agent/src/tools/ast-grep.ts | 140 +----- .../coding-agent/src/tools/browser/attach.ts | 6 +- .../coding-agent/src/tools/browser/launch.ts | 16 +- .../src/tools/browser/registry.ts | 6 +- .../src/tools/browser/tab-supervisor.ts | 4 - packages/coding-agent/src/tools/find.ts | 58 +-- .../coding-agent/src/tools/inspect-image.ts | 13 +- packages/coding-agent/src/tools/path-utils.ts | 123 +++++ packages/coding-agent/src/tools/read.ts | 2 - .../coding-agent/src/tools/render-utils.ts | 92 ++++ packages/coding-agent/src/tools/search.ts | 166 ++----- .../coding-agent/src/utils/file-mentions.ts | 2 +- packages/coding-agent/src/utils/fuzzy.ts | 108 ----- packages/coding-agent/src/vim/parser.ts | 17 - packages/coding-agent/src/vim/render.ts | 2 +- packages/coding-agent/src/vim/types.ts | 2 +- packages/coding-agent/test/acp-agent.test.ts | 4 +- .../test/agent-session-bash-detach.test.ts | 6 +- .../test/agent-session-retry-fallback.test.ts | 9 - .../test/autocomplete-max-visible.test.ts | 8 +- .../coding-agent/test/bash-executor.test.ts | 6 +- packages/coding-agent/test/config-cli.test.ts | 6 +- .../coding-agent/test/config-spacing.test.ts | 6 +- .../coding-agent/test/core/hashline.test.ts | 4 +- .../discovery/disabled-extensions.test.ts | 6 +- .../edit-auto-generated-regressions.test.ts | 4 +- .../test/edit-per-file-diff-content.test.ts | 6 +- .../test/extensions-runner.test.ts | 6 +- packages/coding-agent/test/fuzzy.test.ts | 2 +- .../test/hindsight-backend.test.ts | 10 +- .../coding-agent/test/hindsight-tools.test.ts | 10 +- .../interactive-mode-editor-component.test.ts | 6 +- .../test/interactive-mode-lsp-startup.test.ts | 6 +- .../test/interactive-mode-plan-review.test.ts | 6 +- .../coding-agent/test/issue-816-repro.test.ts | 6 +- .../coding-agent/test/issue-845-repro.test.ts | 6 +- .../coding-agent/test/issue-927-repro.test.ts | 6 +- .../test/memory-backend-resolve.test.ts | 6 +- .../coding-agent/test/model-registry.test.ts | 6 +- .../assistant-message-mermaid.test.ts | 6 +- .../event-controller-idle-compaction.test.ts | 6 +- ...-issue-1022-disabled-default-model.test.ts | 6 +- .../session-manager/tree-traversal.test.ts | 32 +- .../test/settings-manager.test.ts | 6 +- .../test/status-line-overflow.test.ts | 6 +- .../test/tools/auto-generated-guard.test.ts | 4 +- .../test/tools/conflict-integration.test.ts | 6 +- .../test/tools/fetch-kagi-toggle.test.ts | 17 +- packages/coding-agent/test/tools/gh.test.ts | 18 - .../coding-agent/test/tools/image-gen.test.ts | 24 - packages/coding-agent/test/tools/vim.test.ts | 4 +- .../web-scrapers/youtube-parallel.test.ts | 6 +- .../test/tools/web-search-searxng.test.ts | 4 +- packages/coding-agent/test/update-cli.test.ts | 8 +- packages/coding-agent/test/utilities.ts | 81 +--- packages/natives/package.json | 123 +++-- packages/natives/test/issue-892-repro.test.ts | 1 - .../src/client/components/BehaviorChart.tsx | 359 ++++---------- .../client/components/BehaviorModelsTable.tsx | 425 ++++++----------- .../stats/src/client/components/CostChart.tsx | 335 ++++--------- .../src/client/components/ModelsTable.tsx | 372 +++++---------- .../src/client/components/RequestDetail.tsx | 2 - .../src/client/components/chart-shared.tsx | 320 +++++++++++++ .../client/components/models-table-shared.tsx | 275 +++++++++++ packages/stats/src/client/types.ts | 142 +----- packages/stats/src/shared-types.ts | 204 ++++++++ packages/stats/src/types.ts | 205 +------- packages/tui/bench/_jskey.ts | 62 +-- packages/tui/src/components/markdown.ts | 10 +- packages/tui/test/test-themes.ts | 2 +- .../src/formatter.ts | 2 +- .../typescript-edit-benchmark/src/runner.ts | 364 -------------- .../typescript-edit-benchmark/src/tasks.ts | 2 - 141 files changed, 4219 insertions(+), 5465 deletions(-) create mode 100644 .fallowrc.jsonc create mode 100644 packages/ai/src/providers/openai-anthropic-shim.ts create mode 100644 packages/coding-agent/src/commit/shared-llm.ts create mode 100644 packages/coding-agent/src/extensibility/shared-events.ts create mode 100644 packages/coding-agent/src/internal-urls/registry-helpers.ts create mode 100644 packages/coding-agent/src/modes/components/execution-shared.ts create mode 100644 packages/coding-agent/src/modes/components/message-frame.ts create mode 100644 packages/coding-agent/src/modes/controllers/command-controller-shared.ts create mode 100644 packages/coding-agent/src/modes/runtime-init.ts delete mode 100644 packages/coding-agent/src/utils/fuzzy.ts create mode 100644 packages/stats/src/client/components/chart-shared.tsx create mode 100644 packages/stats/src/client/components/models-table-shared.tsx create mode 100644 packages/stats/src/shared-types.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc new file mode 100644 index 000000000..7672cb8ae --- /dev/null +++ b/.fallowrc.jsonc @@ -0,0 +1,29 @@ +{ + // Fallow configuration — see `bunx fallow config-schema` for the full schema. + // + // `entry` is critical: fallow's dead-code analyzer treats unlisted test files + // as unreachable, which means any helper imported only from other tests looks + // orphaned. Without this, `fallow fix` strips real `export` keywords from + // shared test utilities (e.g. `e2eApiKey`, `withEnv`, `waitForDelayOrAbort`), + // breaking the test build with `noUnusedVariables` lint errors. + "entry": [ + "packages/*/test/**/*.{ts,tsx}", + "packages/*/test/**/*.test.{ts,tsx}", + "packages/*/bench/**/*.{ts,tsx}", + "packages/*/scripts/**/*.ts" + ], + "duplicates": { + "ignore": [ + // Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts. + "packages/natives/native/index.js", + // Generated by `packages/coding-agent/scripts/generate-docs-index.ts`. + "packages/coding-agent/src/internal-urls/docs-index.generated.ts", + // Embedded HTML asset shipped as a static template, not a code module. + "packages/coding-agent/src/export/html/template.js", + // Generated/owned upstream — see packages/ai/scripts/generate-models.ts. + "packages/ai/src/models.json" + ], + "ignoreImports": true + }, + "rules": {} +} diff --git a/biome.json b/biome.json index a5a8f3ecf..6ea073e83 100644 --- a/biome.json +++ b/biome.json @@ -7,10 +7,16 @@ }, "linter": { "enabled": true, + "includes": ["**"], "rules": { "recommended": true, + "a11y": "off", "correctness": { "noUnusedImports": "error", + "noUnusedVariables": { + "level": "warn", + "fix": "none" + }, "noVoidTypeReturn": "off" }, "style": { diff --git a/package.json b/package.json index bd9eb98b9..8079e7703 100644 --- a/package.json +++ b/package.json @@ -1,144 +1,143 @@ { - "name": "omp-monorepo", - "private": true, - "type": "module", - "packageManager": "bun@1.3.14", - "workspaces": { - "packages": [ - "packages/*" - ], - "catalog": { - "@agentclientprotocol/sdk": "0.21.0", - "@anthropic-ai/sdk": "^0.94.0", - "@aws-sdk/client-bedrock-runtime": "^3.1043.0", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.3", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@biomejs/biome": "^2.4.14", - "@bufbuild/protobuf": "^2.12.0", - "@bufbuild/protoc-gen-es": "^2.12.0", - "@google/genai": "^1.52.0", - "@mozilla/readability": "^0.6.0", - "@napi-rs/cli": "3.6.2", - "@oh-my-pi/omp-stats": "15.0.0", - "@oh-my-pi/pi-agent-core": "15.0.0", - "@oh-my-pi/pi-ai": "15.0.0", - "@oh-my-pi/pi-coding-agent": "15.0.0", - "@oh-my-pi/pi-natives": "15.0.0", - "@oh-my-pi/pi-tui": "15.0.0", - "@oh-my-pi/pi-utils": "15.0.0", - "@puppeteer/browsers": "^2.13.0", - "@sinclair/typebox": "^0.34.49", - "@smithy/node-http-handler": "^4.6.1", - "@tailwindcss/node": "^4.2.4", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", - "@types/bun": "^1.3.14", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/turndown": "5.0.6", - "@typescript/native-preview": "7.0.0-dev.20260505.1", - "@xterm/headless": "^6.0.0", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "beautiful-mermaid": "^1.1.3", - "chalk": "^5.6.2", - "chart.js": "^4.5.1", - "date-fns": "^4.1.0", - "diff": "^9.0.0", - "fflate": "0.8.2", - "handlebars": "^4.7.9", - "linkedom": "^0.18.12", - "lint-staged": "^16.4.0", - "lru-cache": "11.3.6", - "lucide-react": "^1.14.0", - "marked": "^18.0.3", - "markit-ai": "0.5.3", - "openai": "^6.36.0", - "partial-json": "^0.1.7", - "postcss": "^8.5.14", - "prettier": "^3.8.3", - "proxy-agent": "^8.0.1", - "puppeteer-core": "^24.42.0", - "react": "19.2.5", - "react-chartjs-2": "^5.3.1", - "react-dom": "19.2.5", - "regexp-tree": "^0.1.27", - "tailwindcss": "^4.2.4", - "turndown": "7.2.4", - "turndown-plugin-gfm": "1.0.2", - "typescript": "^6.0.3", - "winston": "^3.19.0", - "winston-daily-rotate-file": "^5.0.0", - "zod": "4.4.3" - } - }, - "overrides": { - "ajv": "8.20.0" - }, - "scripts": { - "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", - "dev": "bun --cwd=packages/coding-agent src/cli.ts", - "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", - "build": "bun run --workspaces --if-present build", - "build:native": "bun --cwd=packages/natives run build", - "test": "bun run --parallel test:ts test:rs", - "test:ts": "bun run --workspaces --if-present test -- --only-failures", - "test:rs": "bun scripts/run-rs-task.ts test:rs", - "check": "bun run --parallel check:ts check:rs", - "check:ts": "bun run check:tools && bun run --workspaces --if-present check", - "check:tools": "biome check . --no-errors-on-unmatched", - "check:rs": "bun scripts/run-rs-task.ts check:rs", - "lint": "bun run --parallel lint:ts lint:rs", - "lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint", - "lint:tools": "biome lint . --no-errors-on-unmatched", - "lint:rs": "bun scripts/run-rs-task.ts lint:rs", - "fmt": "bun run --parallel fmt:ts fmt:rs", - "fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt", - "fmt:tools": "biome format --write . --no-errors-on-unmatched", - "fmt:rs": "bun scripts/run-rs-task.ts fmt:rs", - "fix": "bun run --parallel fix:ts fix:rs", - "fix:all": "bun run --parallel fix:ts:all fix:rs", - "fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix", - "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", - "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", - "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", - "fix:rs": "bun scripts/run-rs-task.ts fix:rs", - "ci:check:full": "bun run check:ts", - "ci:build:native": "bun scripts/ci-build-native.ts", - "ci:test:full": "bun run test", - "ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test", - "ci:test:install-methods": "bash scripts/install-tests/run-ci.sh", - "ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts", - "ci:release:publish": "bun scripts/ci-release-publish.ts", - "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", - "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", - "stats:sync": "python3 scripts/session-stats/sync.py", - "stats:tools": "python3 scripts/session-stats/analyze.py tools", - "stats:edits": "python3 scripts/session-stats/analyze.py edits", - "stats:followups": "python3 scripts/session-stats/analyze.py followups", - "prepublishOnly": "bun run check", - "prepare": "bun --cwd=packages/coding-agent run generate-docs-index", - "publish": "bun run prepublishOnly && npm publish -ws --access public", - "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", - "release": "bun scripts/release.ts", - "generate-models": "bun --cwd=packages/ai run generate-models", - "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", - "generate-template": "bun --cwd=packages/coding-agent run generate-template", - "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" - }, - "devDependencies": { - "@biomejs/biome": "catalog:", - "@bufbuild/protoc-gen-es": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "typescript": "catalog:", - "lint-staged": "catalog:", - "prettier": "catalog:" - }, - "lint-staged": { - "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" - } + "name": "omp-monorepo", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "workspaces": { + "packages": [ + "packages/*" + ], + "catalog": { + "@agentclientprotocol/sdk": "0.21.0", + "@anthropic-ai/sdk": "^0.94.0", + "@aws-sdk/client-bedrock-runtime": "^3.1043.0", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.3", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@biomejs/biome": "^2.4.14", + "@bufbuild/protobuf": "^2.12.0", + "@bufbuild/protoc-gen-es": "^2.12.0", + "@google/genai": "^1.52.0", + "@mozilla/readability": "^0.6.0", + "@napi-rs/cli": "3.6.2", + "@oh-my-pi/omp-stats": "15.0.0", + "@oh-my-pi/pi-agent-core": "15.0.0", + "@oh-my-pi/pi-ai": "15.0.0", + "@oh-my-pi/pi-coding-agent": "15.0.0", + "@oh-my-pi/pi-natives": "15.0.0", + "@oh-my-pi/pi-tui": "15.0.0", + "@oh-my-pi/pi-utils": "15.0.0", + "@puppeteer/browsers": "^2.13.0", + "@sinclair/typebox": "^0.34.49", + "@smithy/node-http-handler": "^4.6.1", + "@tailwindcss/node": "^4.2.4", + "@types/babel__generator": "^7.27.0", + "@types/babel__traverse": "^7.28.0", + "@types/bun": "^1.3.14", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/turndown": "5.0.6", + "@typescript/native-preview": "7.0.0-dev.20260505.1", + "@xterm/headless": "^6.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "beautiful-mermaid": "^1.1.3", + "chalk": "^5.6.2", + "chart.js": "^4.5.1", + "date-fns": "^4.1.0", + "diff": "^9.0.0", + "fflate": "0.8.2", + "handlebars": "^4.7.9", + "linkedom": "^0.18.12", + "lint-staged": "^16.4.0", + "lru-cache": "11.3.6", + "lucide-react": "^1.14.0", + "marked": "^18.0.3", + "markit-ai": "0.5.3", + "openai": "^6.36.0", + "partial-json": "^0.1.7", + "postcss": "^8.5.14", + "prettier": "^3.8.3", + "proxy-agent": "^8.0.1", + "puppeteer-core": "^24.42.0", + "react": "19.2.5", + "react-chartjs-2": "^5.3.1", + "react-dom": "19.2.5", + "regexp-tree": "^0.1.27", + "tailwindcss": "^4.2.4", + "turndown": "7.2.4", + "turndown-plugin-gfm": "1.0.2", + "typescript": "^6.0.3", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0", + "zod": "4.4.3" + } + }, + "overrides": { + "ajv": "8.20.0" + }, + "scripts": { + "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", + "dev": "bun --cwd=packages/coding-agent src/cli.ts", + "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", + "build": "bun run --workspaces --if-present build", + "build:native": "bun --cwd=packages/natives run build", + "test": "bun run --parallel test:ts test:rs", + "test:ts": "bun run --workspaces --if-present test -- --only-failures", + "test:rs": "bun scripts/run-rs-task.ts test:rs", + "check": "bun run --parallel check:ts check:rs", + "check:ts": "bun run check:tools && bun run --workspaces --if-present check", + "check:tools": "biome check . --no-errors-on-unmatched", + "check:rs": "bun scripts/run-rs-task.ts check:rs", + "lint": "bun run --parallel lint:ts lint:rs", + "lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint", + "lint:tools": "biome lint . --no-errors-on-unmatched", + "lint:rs": "bun scripts/run-rs-task.ts lint:rs", + "fmt": "bun run --parallel fmt:ts fmt:rs", + "fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt", + "fmt:tools": "biome format --write . --no-errors-on-unmatched", + "fmt:rs": "bun scripts/run-rs-task.ts fmt:rs", + "fix": "bun run --parallel fix:ts fix:rs", + "fix:all": "bun run --parallel fix:ts:all fix:rs", + "fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix", + "fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix", + "fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .", + "fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .", + "fix:rs": "bun scripts/run-rs-task.ts fix:rs", + "ci:check:full": "bun run check:ts", + "ci:build:native": "bun scripts/ci-build-native.ts", + "ci:test:full": "bun run test", + "ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test", + "ci:test:install-methods": "bash scripts/install-tests/run-ci.sh", + "ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts", + "ci:release:publish": "bun scripts/ci-release-publish.ts", + "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", + "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", + "stats:sync": "python3 scripts/session-stats/sync.py", + "stats:tools": "python3 scripts/session-stats/analyze.py tools", + "stats:edits": "python3 scripts/session-stats/analyze.py edits", + "stats:followups": "python3 scripts/session-stats/analyze.py followups", + "prepublishOnly": "bun run check", + "prepare": "bun --cwd=packages/coding-agent run generate-docs-index", + "publish": "bun run prepublishOnly && npm publish -ws --access public", + "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", + "release": "bun scripts/release.ts", + "generate-models": "bun --cwd=packages/ai run generate-models", + "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", + "generate-template": "bun --cwd=packages/coding-agent run generate-template", + "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" + }, + "devDependencies": { + "@biomejs/biome": "catalog:", + "prettier": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "lint-staged": "catalog:" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" + } } diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 63f1dd3dc..8727b3673 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -14,11 +14,9 @@ import { import { sanitizeText } from "@oh-my-pi/pi-natives"; import { createHarmonyAuditEvent, - extractHarmonyRemoved, type HarmonyDetection, type HarmonyRecoveredToolCall, isHarmonyLeakMitigationTarget, - recoverHarmonyToolCall, signalListLabel, } from "./harmony-leak"; import type { @@ -502,26 +500,6 @@ async function streamAssistantResponse( const responseIterator = response[Symbol.asyncIterator](); - const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => { - const recovered = recoverHarmonyToolCall(message, detection); - const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection); - harmonyAbortController?.abort(); - responseIterator.return?.()?.catch(() => {}); - if (recovered) { - if (addedPartial) { - context.messages[context.messages.length - 1] = recovered.message; - } else { - context.messages.push(recovered.message); - stream.push({ type: "message_start", message: { ...recovered.message } }); - } - stream.push({ type: "message_end", message: recovered.message }); - throw new HarmonyLeakInterruption(detection, removed, recovered); - } - if (addedPartial) { - context.messages.pop(); - } - throw new HarmonyLeakInterruption(detection, removed); - }; // Set up a single abort race: register the abort listener once for the whole // stream and reuse the same race promise for every iterator.next() instead of // allocating Promise.withResolvers and add/removeEventListener per event. diff --git a/packages/agent/src/harmony-leak.ts b/packages/agent/src/harmony-leak.ts index 18cd5de5d..db743a8f4 100644 --- a/packages/agent/src/harmony-leak.ts +++ b/packages/agent/src/harmony-leak.ts @@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/; const SCRIPT_CLASS = "\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F"; const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u"); -const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u"); // Recovery registry. Each entry's parser must recognize the configured // sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 8a79003ed..461af395a 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -779,7 +779,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () = }; const stream = agentLoop([userPrompt], context, config, undefined, streamFn); - for await (const _event of stream) { + for await (const _ of stream) { // consume } diff --git a/packages/agent/test/helpers.ts b/packages/agent/test/helpers.ts index f37016b50..102fe58f6 100644 --- a/packages/agent/test/helpers.ts +++ b/packages/agent/test/helpers.ts @@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent( } } -export function createUsage(): Usage { +function createUsage(): Usage { return { input: 0, output: 0, diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 55756e30d..d5f34a45f 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -6,17 +6,15 @@ import type { ResponseInput, } from "openai/resources/responses/responses"; import { getEnvApiKey } from "../stream"; -import { - type Api, - type AssistantMessage, - type Context, - type Model, - type ServiceTier, - type StreamFunction, - type StreamOptions, - shouldSendServiceTier, - type Tool, - type ToolChoice, +import type { + AssistantMessage, + Context, + Model, + ServiceTier, + StreamFunction, + StreamOptions, + Tool, + ToolChoice, } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; @@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; import { appendResponsesToolResultMessages, + applyCommonResponsesSamplingParams, + applyResponsesReasoningParams, convertResponsesAssistantMessage, convertResponsesInputContent, + createInitialResponsesAssistantMessage, normalizeResponsesToolCallIdForTransform, processResponsesStream, } from "./openai-responses-shared"; @@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" let firstTokenTime: number | undefined; const deploymentName = resolveDeploymentName(model, options); - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "azure-openai-responses" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage( + "azure-openai-responses", + model.provider, + model.id, + ); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); @@ -279,31 +268,7 @@ function buildParams( prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId), }; - if (options?.maxTokens) { - params.max_output_tokens = options?.maxTokens; - } - - if (options?.temperature !== undefined) { - params.temperature = options?.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.topK !== undefined) { - params.top_k = options.topK; - } - if (options?.minP !== undefined) { - params.min_p = options.minP; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (shouldSendServiceTier(options?.serviceTier, model.provider)) { - params.service_tier = options.serviceTier; - } + applyCommonResponsesSamplingParams(params, options, model.provider); if (context.tools) { params.tools = convertTools(context.tools); @@ -312,36 +277,7 @@ function buildParams( } } - if (model.reasoning) { - // Always request encrypted reasoning content so reasoning items can be - // replayed in multi-turn conversations when store is false (items aren't - // persisted server-side, so we must include the full content). - // See: https://github.com/can1357/oh-my-pi/issues/41 - params.include = ["reasoning.encrypted_content"]; - - if (options?.reasoning || options?.reasoningSummary !== undefined) { - const reasoningParams: NonNullable = { - effort: options?.reasoning || "medium", - }; - if (options?.reasoningSummary !== null) { - reasoningParams.summary = options?.reasoningSummary || "auto"; - } - params.reasoning = reasoningParams; - } else { - if (model.name.toLowerCase().startsWith("gpt-5")) { - // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 - messages.push({ - role: "developer", - content: [ - { - type: "input_text", - text: "# Juice: 0 !important", - }, - ], - }); - } - } - } + applyResponsesReasoningParams(params, model, options, messages); return params; } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index d2de661ef..3e75d286a 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -29,17 +29,22 @@ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHe import { convertMessages, convertTools, + type GoogleThinkingLevel, isThinkingPart, mapStopReasonString, mapToolChoice, + nextToolCallId, + pushBlockEndEvent, + pushToolCallEvents, retainThoughtSignature, + startTextOrThinkingBlock, } from "./google-shared"; /** - * Thinking level for Gemini 3 models. - * Mirrors Google's ThinkingLevel enum values. + * Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing + * `import { GoogleThinkingLevel } from "./google-gemini-cli"` callers keep working. */ -export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type { GoogleThinkingLevel }; export interface GoogleGeminiCliOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; @@ -72,9 +77,6 @@ export { getGeminiCliUserAgent, } from "./google-gemini-headers"; -// Counter for generating unique tool call IDs -let toolCallCounter = 0; - // Retry configuration const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; @@ -426,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( (!isThinking && currentBlock.type !== "text") ) { if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - ensureStarted(); - stream.push({ - type: "thinking_start", - contentIndex: blockIndex(), - partial: output, - }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - ensureStarted(); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); } + currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted); } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; @@ -488,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (part.functionCall) { hasContent = true; if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); currentBlock = null; } const providedId = part.functionCall.id; const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; + const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId; const toolCall: ToolCall = { type: "toolCall", @@ -523,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( output.content.push(toolCall); ensureStarted(); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ - type: "toolcall_end", - contentIndex: blockIndex(), - toolCall, - partial: output, - }); + pushToolCallEvents(toolCall, blockIndex(), output, stream); } } } @@ -572,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); } return hasContent; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 39501744c..98e1ba70a 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -1,8 +1,35 @@ /** * Shared utilities for Google Generative AI and Google Cloud Code Assist providers. */ -import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types"; +import { + type Content, + FinishReason, + FunctionCallingConfigMode, + type GenerateContentConfig, + type GenerateContentParameters, + type GenerateContentResponse, + type GoogleGenAI, + type Part, + type ThinkingConfig, + type ThinkingLevel, +} from "@google/genai"; +import { calculateCost } from "../models"; +import type { + Api, + AssistantMessage, + Context, + ImageContent, + Model, + StopReason, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types"; +import { normalizeSystemPrompts } from "../utils"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema"; import { transformMessages } from "./transform-messages"; import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; @@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle }; type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; +/** + * Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values. + * Defined here (not in any specific provider) so all Google providers can reference it + * without inducing a circular dependency. + */ +export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; + +/** + * Sampling/thinking options shared by `streamGoogle` and `streamGoogleVertex`. + * `google-gemini-cli` uses a different transport and request shape — do not extend this for it. + */ +export interface GoogleSharedStreamOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; + level?: GoogleThinkingLevel; + }; +} + /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". * @@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason { case FinishReason.NO_IMAGE: return "error"; default: { - const _exhaustive: never = reason; - throw new Error(`Unhandled stop reason: ${_exhaustive}`); + throw new Error(`Unhandled stop reason: ${reason satisfies never}`); } } } @@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason { return "error"; } } + +/** + * Module-local counter for generating unique tool call IDs across Google providers. + * Shared so that a single monotonically-increasing sequence is used regardless of which + * Google API surface produced the stream — purely for uniqueness, not ordering semantics. + */ +let toolCallCounter = 0; + +export function nextToolCallId(name: string): string { + return `${name}_${Date.now()}_${++toolCallCounter}`; +} + +/** + * Push the appropriate `text_end` / `thinking_end` event for the given block. + * Shared between the SDK-backed stream consumer and the gemini-cli SSE consumer so + * the end-of-block event shape stays in lockstep. + */ +export function pushBlockEndEvent( + block: TextContent | ThinkingContent, + contentIndex: number, + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex, content: block.text, partial: output }); + } else { + stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output }); + } +} + +/** + * Push the three lifecycle events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) for a + * fully-assembled `ToolCall`. Caller is responsible for appending the toolCall to `output.content` + * before invoking — this helper does not mutate `output.content`. + */ +export function pushToolCallEvents( + toolCall: ToolCall, + contentIndex: number, + output: AssistantMessage, + stream: AssistantMessageEventStream, +): void { + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + stream.push({ + type: "toolcall_delta", + contentIndex, + delta: JSON.stringify(toolCall.arguments), + partial: output, + }); + stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output }); +} + +/** + * Append a new text- or thinking-block to `output.content` and push the matching + * `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer + * inject its `ensureStarted()` first-token side effect into the canonical event order. + */ +export function startTextOrThinkingBlock( + isThinking: boolean, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onBeforeStartEvent?: () => void, +): TextContent | ThinkingContent { + const block: TextContent | ThinkingContent = isThinking + ? { type: "thinking", thinking: "", thinkingSignature: undefined } + : { type: "text", text: "" }; + output.content.push(block); + onBeforeStartEvent?.(); + const contentIndex = output.content.length - 1; + if (isThinking) { + stream.push({ type: "thinking_start", contentIndex, partial: output }); + } else { + stream.push({ type: "text_start", contentIndex, partial: output }); + } + return block; +} + +/** + * Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and + * the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and + * `streamGoogleVertex` — every observable event order and stop-reason rule is preserved. + * + * The caller still owns: `output` construction, timing fields (`duration`/`ttft`), + * `rawRequestDump`, the `client.models.generateContentStream(params)` call itself, + * pushing `start`/`done`/`error` events, and the surrounding try/catch that translates + * thrown errors into `output.stopReason`/`errorMessage`. + * + * This helper handles: the chunk loop, currentBlock flush transitions, usage metadata + * decoding (`calculateCost` included), tool-call id collision avoidance, finish-reason + * mapping, and the abort/stop-reason post-checks that re-throw to bubble into the + * caller's catch. + */ +export async function consumeGoogleStream(args: { + googleStream: AsyncIterable; + output: AssistantMessage; + stream: AssistantMessageEventStream; + model: Model; + options: { signal?: AbortSignal } | undefined; + /** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */ + retainTextSignature?: boolean; + onFirstToken?: () => void; +}): Promise { + const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + let currentBlock: TextContent | ThinkingContent | null = null; + let firstTokenSeen = false; + + const flushCurrent = () => { + if (!currentBlock) return; + pushBlockEndEvent(currentBlock, blockIndex(), output, stream); + }; + + for await (const chunk of googleStream) { + const candidate = chunk.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + if (!firstTokenSeen) { + firstTokenSeen = true; + onFirstToken?.(); + } + const isThinking = isThinkingPart(part); + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + flushCurrent(); + currentBlock = startTextOrThinkingBlock(isThinking, output, stream); + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } else { + currentBlock.text += part.text; + if (retainTextSignature) { + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); + } + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } + } + + if (part.functionCall) { + if (currentBlock) { + flushCurrent(); + currentBlock = null; + } + + // Generate unique ID if not provided or if it's a duplicate + const providedId = part.functionCall.id; + const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); + const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId; + + const toolCall: ToolCall = { + type: "toolCall", + id: toolCallId, + name: part.functionCall.name || "", + arguments: (part.functionCall.args ?? {}) as Record, + ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), + }; + + output.content.push(toolCall); + pushToolCallEvents(toolCall, blockIndex(), output, stream); + } + } + } + + if (candidate?.finishReason) { + output.stopReason = mapStopReason(candidate.finishReason); + if (output.content.some(b => b.type === "toolCall")) { + output.stopReason = "toolUse"; + } + } + + if (chunk.usageMetadata) { + // promptTokenCount includes cachedContentTokenCount when cached content is used. + // Subtract to get non-cached input, matching the OpenAI convention where + // input = uncached prompt tokens and cacheRead = cached tokens so that + // input + cacheRead = total prompt tokens (no double-counting). + // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata + const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; + const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; + output.usage = { + input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, + output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, + ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(model, output.usage); + } + } + + flushCurrent(); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error(output.errorMessage ?? "An unknown error occurred"); + } +} + +/** + * Generation/sampling fields that map directly onto Gemini's `GenerateContentConfig`. + * Excludes any provider-specific extensions (`topP`/`topK`/etc are all forwarded as-is). + */ +interface GoogleGenerationConfig extends GenerateContentConfig { + topP?: number; + topK?: number; + minP?: number; + presencePenalty?: number; + repetitionPenalty?: number; +} + +/** + * Build the `GenerateContentParameters` payload for the public Gemini API and Vertex AI. + * Both surfaces accept the same `GenerateContentConfig` shape — every numeric/string knob, + * tool-config, thinking-config, and system-instruction conversion is identical. + * + * `google-gemini-cli` is NOT routed through here: its `CloudCodeAssistRequest` body has a + * distinct top-level shape (project/request/requestType) and a different thinking-config + * placement on `generationConfig`. + */ +export function buildGoogleGenerateContentParams( + model: Model, + context: Context, + options: GoogleSharedStreamOptions, +): GenerateContentParameters { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + const contents = convertMessages(model, context); + + const generationConfig: GoogleGenerationConfig = {}; + if (options.temperature !== undefined) generationConfig.temperature = options.temperature; + if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens; + if (options.topP !== undefined) generationConfig.topP = options.topP; + if (options.topK !== undefined) generationConfig.topK = options.topK; + if (options.minP !== undefined) generationConfig.minP = options.minP; + if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty; + if (options.repetitionPenalty !== undefined) generationConfig.repetitionPenalty = options.repetitionPenalty; + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const cfg: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + // GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1. + cfg.thinkingLevel = options.thinking.level as ThinkingLevel; + } else if (options.thinking.budgetTokens !== undefined) { + cfg.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = cfg; + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + return { + model: model.id, + contents, + config, + }; +} + +/** + * Drive the `streamGoogle` / `streamGoogleVertex` event flow: build the assistant message, + * push start/done/error events, run `consumeGoogleStream`, and translate thrown errors into + * the canonical `error` event shape. + * + * Caller-supplied `prepare()` runs inside the try-block so any failure (missing project, + * bad auth, etc.) is funneled through the same error path as a streaming failure. + */ +export function streamGoogleGenAI(args: { + model: Model; + options: GoogleSharedStreamOptions | undefined; + api: T; + retainTextSignature?: boolean; + prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined }; +}): AssistantMessageEventStream { + const { model, options, api, retainTextSignature, prepare } = args; + const stream = new AssistantMessageEventStream(); + + (async () => { + const startTime = Date.now(); + let firstTokenTime: number | undefined; + + const output: AssistantMessage = { + role: "assistant", + content: [], + api: api as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + let rawRequestDump: RawHttpRequestDump | undefined; + + try { + const { client, params, url } = prepare(); + options?.onPayload?.(params); + rawRequestDump = { + provider: model.provider, + api: output.api, + model: model.id, + method: "POST", + url, + body: params, + }; + const googleStream = await client.models.generateContentStream(params); + + stream.push({ type: "start", partial: output }); + await consumeGoogleStream({ + googleStream, + output, + stream, + model, + options, + retainTextSignature, + onFirstToken: () => { + firstTokenTime = Date.now(); + }, + }); + + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); + output.duration = Date.now() - startTime; + if (firstTokenTime) output.ttft = firstTokenTime - startTime; + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +} diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 2bce003cd..47a72037b 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,317 +1,38 @@ -import { - type GenerateContentConfig, - type GenerateContentParameters, - GoogleGenAI, - type ThinkingConfig, - ThinkingLevel, -} from "@google/genai"; +import { GoogleGenAI } from "@google/genai"; import { $env } from "@oh-my-pi/pi-utils"; -import { calculateCost } from "../models"; -import type { - Api, - AssistantMessage, - Context, - Model, - StreamFunction, - StreamOptions, - TextContent, - ThinkingContent, - ToolCall, -} from "../types"; -import { normalizeSystemPrompts } from "../utils"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; -import type { GoogleThinkingLevel } from "./google-gemini-cli"; -import { - convertMessages, - convertTools, - isThinkingPart, - mapStopReason, - mapToolChoice, - retainThoughtSignature, -} from "./google-shared"; +import type { Context, Model, StreamFunction } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared"; -export interface GoogleVertexOptions extends StreamOptions { - toolChoice?: "auto" | "none" | "any"; - thinking?: { - enabled: boolean; - budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; - }; +export interface GoogleVertexOptions extends GoogleSharedStreamOptions { project?: string; location?: string; } -interface GoogleVertexSamplingConfig extends GenerateContentConfig { - topP?: number; - topK?: number; - minP?: number; - presencePenalty?: number; - repetitionPenalty?: number; -} - const API_VERSION = "v1"; -const THINKING_LEVEL_MAP: Record = { - THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, - MINIMAL: ThinkingLevel.MINIMAL, - LOW: ThinkingLevel.LOW, - MEDIUM: ThinkingLevel.MEDIUM, - HIGH: ThinkingLevel.HIGH, -}; - -// Counter for generating unique tool call IDs -let toolCallCounter = 0; - export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( model: Model<"google-vertex">, context: Context, options?: GoogleVertexOptions, -): AssistantMessageEventStream => { - const stream = new AssistantMessageEventStream(); - - (async () => { - const startTime = Date.now(); - let firstTokenTime: number | undefined; - - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "google-vertex" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let rawRequestDump: RawHttpRequestDump | undefined; - - try { +): AssistantMessageEventStream => + streamGoogleGenAI({ + model, + options, + api: "google-vertex", + retainTextSignature: true, + prepare: () => { const apiKey = resolveApiKey(options); const project = apiKey ? undefined : resolveProject(options); const location = apiKey ? undefined : resolveLocation(options); const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!); - const params = buildParams(model, context, options); - options?.onPayload?.(params); - rawRequestDump = { - provider: model.provider, - api: output.api, - model: model.id, - method: "POST", - url: apiKey - ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent` - : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`, - body: params, - }; - const googleStream = await client.models.generateContentStream(params); - - stream.push({ type: "start", partial: output }); - let currentBlock: TextContent | ThinkingContent | null = null; - const blocks = output.content; - const blockIndex = () => blocks.length - 1; - for await (const chunk of googleStream) { - const candidate = chunk.candidates?.[0]; - if (candidate?.content?.parts) { - for (const part of candidate.content.parts) { - if (part.text !== undefined) { - if (!firstTokenTime) firstTokenTime = Date.now(); - const isThinking = isThinkingPart(part); - if ( - !currentBlock || - (isThinking && currentBlock.type !== "thinking") || - (!isThinking && currentBlock.type !== "text") - ) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - } - if (currentBlock.type === "thinking") { - currentBlock.thinking += part.text; - currentBlock.thinkingSignature = retainThoughtSignature( - currentBlock.thinkingSignature, - part.thoughtSignature, - ); - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } else { - currentBlock.text += part.text; - currentBlock.textSignature = retainThoughtSignature( - currentBlock.textSignature, - part.thoughtSignature, - ); - stream.push({ - type: "text_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } - } - - if (part.functionCall) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - currentBlock = null; - } - - const providedId = part.functionCall.id; - const needsNewId = - !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; - - const toolCall: ToolCall = { - type: "toolCall", - id: toolCallId, - name: part.functionCall.name || "", - arguments: part.functionCall.args as Record, - ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), - }; - - output.content.push(toolCall); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); - } - } - } - - if (candidate?.finishReason) { - output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some(b => b.type === "toolCall")) { - output.stopReason = "toolUse"; - } - } - - if (chunk.usageMetadata) { - // promptTokenCount includes cachedContentTokenCount when cached content is used. - // Subtract to get non-cached input, matching the OpenAI convention where - // input = uncached prompt tokens and cacheRead = cached tokens so that - // input + cacheRead = total prompt tokens (no double-counting). - // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata - const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; - const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; - output.usage = { - input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, - output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: chunk.usageMetadata.totalTokenCount || 0, - ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }; - calculateCost(model, output.usage); - } - } - - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); - } - - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "done", reason: output.stopReason, message: output }); - stream.end(); - } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "error", reason: output.stopReason, error: output }); - stream.end(); - } - })(); - - return stream; -}; + const params = buildGoogleGenerateContentParams(model, context, options ?? {}); + const url = apiKey + ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent` + : `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`; + return { client, params, url }; + }, + }); function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record } | undefined { if (!model.headers) { @@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string { } return location; } - -function buildParams( - model: Model<"google-vertex">, - context: Context, - options: GoogleVertexOptions = {}, -): GenerateContentParameters { - const systemPrompts = normalizeSystemPrompts(context.systemPrompt); - const contents = convertMessages(model, context); - - const generationConfig: GoogleVertexSamplingConfig = {}; - if (options.temperature !== undefined) { - generationConfig.temperature = options.temperature; - } - if (options.maxTokens !== undefined) { - generationConfig.maxOutputTokens = options.maxTokens; - } - if (options.topP !== undefined) { - generationConfig.topP = options.topP; - } - if (options.topK !== undefined) { - generationConfig.topK = options.topK; - } - if (options.minP !== undefined) { - generationConfig.minP = options.minP; - } - if (options.presencePenalty !== undefined) { - generationConfig.presencePenalty = options.presencePenalty; - } - if (options.repetitionPenalty !== undefined) { - generationConfig.repetitionPenalty = options.repetitionPenalty; - } - - const config: GenerateContentConfig = { - ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), - }; - - if (context.tools && context.tools.length > 0 && options.toolChoice) { - config.toolConfig = { - functionCallingConfig: { - mode: mapToolChoice(options.toolChoice), - }, - }; - } else { - config.toolConfig = undefined; - } - - if (options.thinking?.enabled && model.reasoning) { - const cfg: ThinkingConfig = { includeThoughts: true }; - if (options.thinking.level !== undefined) { - cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level]; - } else if (options.thinking.budgetTokens !== undefined) { - cfg.thinkingBudget = options.thinking.budgetTokens; - } - config.thinkingConfig = cfg; - } - - if (options.signal) { - if (options.signal.aborted) { - throw new Error("Request aborted"); - } - config.abortSignal = options.signal; - } - - const params: GenerateContentParameters = { - model: model.id, - contents, - config, - }; - - return params; -} diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index d7c66c530..48e571564 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -1,297 +1,28 @@ -import { - type GenerateContentConfig, - type GenerateContentParameters, - GoogleGenAI, - type ThinkingConfig, -} from "@google/genai"; -import { calculateCost } from "../models"; +import { GoogleGenAI } from "@google/genai"; import { getEnvApiKey } from "../stream"; -import type { - Api, - AssistantMessage, - Context, - Model, - StreamFunction, - StreamOptions, - TextContent, - ThinkingContent, - ToolCall, -} from "../types"; -import { normalizeSystemPrompts } from "../utils"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; -import type { GoogleThinkingLevel } from "./google-gemini-cli"; -import { - convertMessages, - convertTools, - isThinkingPart, - mapStopReason, - mapToolChoice, - retainThoughtSignature, -} from "./google-shared"; +import type { Context, Model, StreamFunction } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared"; -export interface GoogleOptions extends StreamOptions { - toolChoice?: "auto" | "none" | "any"; - thinking?: { - enabled: boolean; - budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; - }; -} - -interface GoogleSamplingConfig extends GenerateContentConfig { - topP?: number; - topK?: number; - minP?: number; - presencePenalty?: number; - repetitionPenalty?: number; -} - -// Counter for generating unique tool call IDs -let toolCallCounter = 0; +export type GoogleOptions = GoogleSharedStreamOptions; export const streamGoogle: StreamFunction<"google-generative-ai"> = ( model: Model<"google-generative-ai">, context: Context, options?: GoogleOptions, -): AssistantMessageEventStream => { - const stream = new AssistantMessageEventStream(); - - (async () => { - const startTime = Date.now(); - let firstTokenTime: number | undefined; - - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "google-generative-ai" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let rawRequestDump: RawHttpRequestDump | undefined; - - try { +): AssistantMessageEventStream => + streamGoogleGenAI({ + model, + options, + api: "google-generative-ai", + prepare: () => { const apiKey = options?.apiKey || getEnvApiKey(model.provider); const client = createClient(model, apiKey); - const params = buildParams(model, context, options); - options?.onPayload?.(params); - rawRequestDump = { - provider: model.provider, - api: output.api, - model: model.id, - method: "POST", - url: model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined, - body: params, - }; - const googleStream = await client.models.generateContentStream(params); - - stream.push({ type: "start", partial: output }); - let currentBlock: TextContent | ThinkingContent | null = null; - const blocks = output.content; - const blockIndex = () => blocks.length - 1; - for await (const chunk of googleStream) { - const candidate = chunk.candidates?.[0]; - if (candidate?.content?.parts) { - for (const part of candidate.content.parts) { - if (part.text !== undefined) { - const isThinking = isThinkingPart(part); - if (!firstTokenTime) firstTokenTime = Date.now(); - if ( - !currentBlock || - (isThinking && currentBlock.type !== "thinking") || - (!isThinking && currentBlock.type !== "text") - ) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blocks.length - 1, - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - if (isThinking) { - currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; - output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } else { - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - } - if (currentBlock.type === "thinking") { - currentBlock.thinking += part.text; - currentBlock.thinkingSignature = retainThoughtSignature( - currentBlock.thinkingSignature, - part.thoughtSignature, - ); - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } else { - currentBlock.text += part.text; - stream.push({ - type: "text_delta", - contentIndex: blockIndex(), - delta: part.text, - partial: output, - }); - } - } - - if (part.functionCall) { - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - currentBlock = null; - } - - // Generate unique ID if not provided or if it's a duplicate - const providedId = part.functionCall.id; - const needsNewId = - !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId); - const toolCallId = needsNewId - ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` - : providedId; - - const toolCall: ToolCall = { - type: "toolCall", - id: toolCallId, - name: part.functionCall.name || "", - arguments: (part.functionCall.args ?? {}) as Record, - ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), - }; - - output.content.push(toolCall); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_delta", - contentIndex: blockIndex(), - delta: JSON.stringify(toolCall.arguments), - partial: output, - }); - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); - } - } - } - - if (candidate?.finishReason) { - output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some(b => b.type === "toolCall")) { - output.stopReason = "toolUse"; - } - } - - if (chunk.usageMetadata) { - // promptTokenCount includes cachedContentTokenCount when cached content is used. - // Subtract to get non-cached input, matching the OpenAI convention where - // input = uncached prompt tokens and cacheRead = cached tokens so that - // input + cacheRead = total prompt tokens (no double-counting). - // Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata - const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0; - const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0; - output.usage = { - input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens, - output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: chunk.usageMetadata.totalTokenCount || 0, - ...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}), - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }; - calculateCost(model, output.usage); - } - } - - if (currentBlock) { - if (currentBlock.type === "text") { - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: currentBlock.text, - partial: output, - }); - } else { - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: currentBlock.thinking, - partial: output, - }); - } - } - - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error(output.errorMessage ?? "An unknown error occurred"); - } - - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "done", reason: output.stopReason, message: output }); - stream.end(); - } catch (error) { - // Remove internal index property used during streaming - for (const block of output.content) { - if ("index" in block) { - delete (block as { index?: number }).index; - } - } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = await finalizeErrorMessage(error, rawRequestDump); - output.duration = Date.now() - startTime; - if (firstTokenTime) output.ttft = firstTokenTime - startTime; - stream.push({ type: "error", reason: output.stopReason, error: output }); - stream.end(); - } - })(); - - return stream; -}; + const params = buildGoogleGenerateContentParams(model, context, options ?? {}); + const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined; + return { client, params, url }; + }, + }); function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI { const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record } = {}; @@ -308,77 +39,3 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, }); } - -function buildParams( - model: Model<"google-generative-ai">, - context: Context, - options: GoogleOptions = {}, -): GenerateContentParameters { - const systemPrompts = normalizeSystemPrompts(context.systemPrompt); - const contents = convertMessages(model, context); - - const generationConfig: GoogleSamplingConfig = {}; - if (options.temperature !== undefined) { - generationConfig.temperature = options.temperature; - } - if (options.maxTokens !== undefined) { - generationConfig.maxOutputTokens = options.maxTokens; - } - if (options.topP !== undefined) { - generationConfig.topP = options.topP; - } - if (options.topK !== undefined) { - generationConfig.topK = options.topK; - } - if (options.minP !== undefined) { - generationConfig.minP = options.minP; - } - if (options.presencePenalty !== undefined) { - generationConfig.presencePenalty = options.presencePenalty; - } - if (options.repetitionPenalty !== undefined) { - generationConfig.repetitionPenalty = options.repetitionPenalty; - } - - const config: GenerateContentConfig = { - ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), - }; - - if (context.tools && context.tools.length > 0 && options.toolChoice) { - config.toolConfig = { - functionCallingConfig: { - mode: mapToolChoice(options.toolChoice), - }, - }; - } else { - config.toolConfig = undefined; - } - - if (options.thinking?.enabled && model.reasoning) { - const cfg: ThinkingConfig = { includeThoughts: true }; - if (options.thinking.level !== undefined) { - // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values - cfg.thinkingLevel = options.thinking.level as any; - } else if (options.thinking.budgetTokens !== undefined) { - cfg.thinkingBudget = options.thinking.budgetTokens; - } - config.thinkingConfig = cfg; - } - - if (options.signal) { - if (options.signal.aborted) { - throw new Error("Request aborted"); - } - config.abortSignal = options.signal; - } - - const params: GenerateContentParameters = { - model: model.id, - contents, - config, - }; - - return params; -} diff --git a/packages/ai/src/providers/kimi.ts b/packages/ai/src/providers/kimi.ts index 8c1e591ea..af98f4f34 100644 --- a/packages/ai/src/providers/kimi.ts +++ b/packages/ai/src/providers/kimi.ts @@ -9,19 +9,21 @@ * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output. */ -import { ANTHROPIC_THINKING } from "../stream"; -import type { Api, Context, Model, SimpleStreamOptions } from "../types"; -import { AssistantMessageEventStream } from "../utils/event-stream"; +import type { Api, Context, Model } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; -import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; -import { createProviderErrorMessage } from "./shared/error-message"; +import { + type OpenAIAnthropicApiFormat, + type OpenAIAnthropicShimOptions, + streamOpenAIAnthropicShim, +} from "./openai-anthropic-shim"; -export type KimiApiFormat = "openai" | "anthropic"; +export type KimiApiFormat = OpenAIAnthropicApiFormat; // Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1 const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding"; -export interface KimiOptions extends SimpleStreamOptions { +export interface KimiOptions extends OpenAIAnthropicShimOptions { /** API format: "openai" or "anthropic". Default: "anthropic" */ format?: KimiApiFormat; } @@ -35,96 +37,13 @@ export function streamKimi( context: Context, options?: KimiOptions, ): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - const format = options?.format ?? "anthropic"; - - // Async IIFE to handle header fetching and stream piping - (async () => { - try { - const mergedHeaders = { ...getKimiCommonHeaders(), ...options?.headers }; - - if (format === "anthropic") { - // Create a synthetic Anthropic model pointing to Kimi's endpoint - const anthropicModel: Model<"anthropic-messages"> = { - id: model.id, - name: model.name, - api: "anthropic-messages", - provider: model.provider, - baseUrl: KIMI_ANTHROPIC_BASE_URL, - headers: mergedHeaders, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - reasoning: model.reasoning, - input: model.input, - cost: model.cost, - }; - - // Calculate thinking budget from reasoning level - const reasoning = options?.reasoning; - const reasoningEffort = reasoning; - const thinkingEnabled = !!reasoningEffort && model.reasoning; - const thinkingBudget = reasoningEffort - ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) - : undefined; - - const innerStream = streamAnthropic(anthropicModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - thinkingEnabled, - thinkingBudgetTokens: thinkingBudget, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } else { - // OpenAI format - use original model with Kimi headers - const reasoningEffort = options?.reasoning; - const innerStream = streamOpenAICompletions(model, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? model.maxTokens, - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - reasoning: reasoningEffort, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } - } catch (err) { - stream.push({ - type: "error", - reason: "error", - error: createProviderErrorMessage(model, err), - }); - } - })(); - - return stream; + return streamOpenAIAnthropicShim(model, context, options, { + anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL, + defaultFormat: "anthropic", + extraHeaders: getKimiCommonHeaders, + }); } + /** * Check if a model is a Kimi Code model. */ diff --git a/packages/ai/src/providers/openai-anthropic-shim.ts b/packages/ai/src/providers/openai-anthropic-shim.ts new file mode 100644 index 000000000..23ae7fe4d --- /dev/null +++ b/packages/ai/src/providers/openai-anthropic-shim.ts @@ -0,0 +1,136 @@ +/** + * Shared implementation for providers that expose BOTH an OpenAI-compatible + * and an Anthropic-compatible API surface against the same model catalog + * (currently Kimi Code and Synthetic). + * + * Each call site supplies the provider-specific bits (base URLs, default + * format, optional extra headers); the streaming/forwarding plumbing lives + * here once. + */ + +import { ANTHROPIC_THINKING } from "../stream"; +import type { Context, Model, SimpleStreamOptions } from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; +import { createProviderErrorMessage } from "./shared/error-message"; + +export type OpenAIAnthropicApiFormat = "openai" | "anthropic"; + +export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions { + /** API format: "openai" or "anthropic". */ + format?: OpenAIAnthropicApiFormat; +} + +export interface OpenAIAnthropicShimConfig { + /** Base URL for the Anthropic-compatible endpoint (without trailing /v1/messages). */ + anthropicBaseUrl: string; + /** Optional override for the OpenAI-compatible base URL. If omitted, `model.baseUrl` is used as-is. */ + openaiBaseUrl?: string; + /** Default API format when caller does not specify one. */ + defaultFormat: OpenAIAnthropicApiFormat; + /** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */ + extraHeaders?: () => Record; +} + +/** + * Stream from an OpenAI-or-Anthropic compatible provider. Returns synchronously; + * async header fetching and stream piping happen internally. + */ +export function streamOpenAIAnthropicShim( + model: Model<"openai-completions">, + context: Context, + options: OpenAIAnthropicShimOptions | undefined, + config: OpenAIAnthropicShimConfig, +): AssistantMessageEventStream { + const stream = new AssistantMessageEventStream(); + const format = options?.format ?? config.defaultFormat; + + (async () => { + try { + const mergedHeaders = { + ...(config.extraHeaders?.() ?? {}), + ...options?.headers, + }; + + if (format === "anthropic") { + const anthropicModel: Model<"anthropic-messages"> = { + id: model.id, + name: model.name, + api: "anthropic-messages", + provider: model.provider, + baseUrl: config.anthropicBaseUrl, + headers: mergedHeaders, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + reasoning: model.reasoning, + input: model.input, + cost: model.cost, + }; + + const reasoningEffort = options?.reasoning; + const thinkingEnabled = !!reasoningEffort && model.reasoning; + const thinkingBudget = reasoningEffort + ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) + : undefined; + + const innerStream = streamAnthropic(anthropicModel, context, { + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + thinkingEnabled, + thinkingBudgetTokens: thinkingBudget, + }); + + for await (const event of innerStream) { + stream.push(event); + } + } else { + const openaiModel: Model<"openai-completions"> = config.openaiBaseUrl + ? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders } + : model; + + const reasoningEffort = options?.reasoning; + const innerStream = streamOpenAICompletions(openaiModel, context, { + apiKey: options?.apiKey, + temperature: options?.temperature, + topP: options?.topP, + topK: options?.topK, + minP: options?.minP, + presencePenalty: options?.presencePenalty, + repetitionPenalty: options?.repetitionPenalty, + maxTokens: options?.maxTokens ?? model.maxTokens, + signal: options?.signal, + headers: mergedHeaders, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + onSseEvent: options?.onSseEvent, + reasoning: reasoningEffort, + }); + + for await (const event of innerStream) { + stream.push(event); + } + } + } catch (err) { + stream.push({ + type: "error", + reason: "error", + error: createProviderErrorMessage(model, err), + }); + } + })(); + + return stream; +} diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 3d4b44107..f2fb641c4 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -7,8 +7,6 @@ import type { ResponseFunctionToolCall, ResponseInput, ResponseInputContent, - ResponseInputImage, - ResponseInputText, ResponseOutputMessage, ResponseReasoningItem, } from "openai/resources/responses/responses"; @@ -35,7 +33,6 @@ import { createOpenAIResponsesHistoryPayload, getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, - normalizeResponsesToolCallId, normalizeSystemPrompts, } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; @@ -54,14 +51,15 @@ import { import { parseCodexError } from "./openai-codex/response-handler"; import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses"; import { + appendResponsesToolResultMessages, + convertResponsesAssistantMessage, convertResponsesInputContent, encodeResponsesToolCallId, encodeTextSignatureV1, mapOpenAIResponsesStopReason, - parseTextSignature, + populateResponsesUsageFromResponse, } from "./openai-responses-shared"; import { transformMessages } from "./transform-messages"; -import { joinTextWithImagePlaceholder } from "./vision-guard"; export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -1233,19 +1231,7 @@ function handleResponseCompleted( } ).response; - if (response?.usage) { - const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; - const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0; - output.usage = { - input: (response.usage.input_tokens || 0) - cachedTokens, - output: response.usage.output_tokens || 0, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: response.usage.total_tokens || 0, - ...(reasoningTokens > 0 ? { reasoningTokens } : {}), - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - } + populateResponsesUsageFromResponse(output, response?.usage); if (typeof response?.id === "string" && response.id.length > 0) { output.responseId = response.id; } @@ -2356,6 +2342,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex // messages can be replayed as `custom_tool_call_output` rather than // `function_call_output` (OpenAI rejects mismatched pairs). const customCallIds = new Set(); + const knownCallIds = new Set(); for (const msg of transformedMessages) { if (msg.role === "user" || msg.role === "developer") { @@ -2407,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex continue; } - const outputItems: ResponseInput = []; - for (const block of msg.content) { - if (block.type === "thinking" && msg.stopReason !== "error") { - if (block.thinkingSignature) { - outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem); - } - continue; - } - if (block.type === "text") { - const textBlock = block as TextContent; - const parsedSignature = parseTextSignature(textBlock.textSignature); - let msgId = parsedSignature?.id; - if (!msgId) { - msgId = `msg_${msgIndex}`; - } else if (msgId.length > 64) { - msgId = `msg_${Bun.hash(msgId).toString(36)}`; - } - outputItems.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: textBlock.text.toWellFormed(), annotations: [] }], - status: "completed", - id: msgId, - phase: parsedSignature?.phase, - } satisfies ResponseOutputMessage); - continue; - } - if (block.type === "toolCall") { - const toolCall = block as ToolCall; - const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc"); - if (toolCall.customWireName) { - const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : ""; - customCallIds.add(normalized.callId); - outputItems.push({ - type: "custom_tool_call", - id: normalized.itemId, - call_id: normalized.callId, - name: toolCall.customWireName, - input: rawInput, - } as ResponseInput[number]); - continue; - } - outputItems.push({ - type: "function_call", - id: normalized.itemId, - call_id: normalized.callId, - name: toolCall.name, - arguments: JSON.stringify(toolCall.arguments), - }); - } - } + const outputItems = convertResponsesAssistantMessage( + msg as AssistantMessage, + model, + msgIndex, + knownCallIds, + true, + customCallIds, + ); if (outputItems.length > 0) { messages.push(...outputItems); } @@ -2466,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex } if (msg.role === "toolResult") { - const supportsImages = model.input.includes("image"); - const textResult = msg.content - .filter(content => content.type === "text") - .map(content => content.text) - .join("\n"); - const hasImages = msg.content.some(content => content.type === "image"); - const omittedImages = hasImages && !supportsImages; - const normalized = normalizeResponsesToolCallId(msg.toolCallId); - const output = ( - omittedImages - ? joinTextWithImagePlaceholder(textResult, true) - : textResult.length > 0 - ? textResult - : "(see attached image)" - ).toWellFormed(); - if (customCallIds.has(normalized.callId)) { - messages.push({ - type: "custom_tool_call_output", - call_id: normalized.callId, - output, - } as ResponseInput[number]); - } else { - messages.push({ - type: "function_call_output", - call_id: normalized.callId, - output, - }); - } - if (hasImages && supportsImages) { - const contentParts: ResponseInputContent[] = [ - { type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText, - ]; - for (const block of msg.content) { - if (block.type === "image") { - contentParts.push({ - type: "input_image", - detail: "auto", - image_url: `data:${block.mimeType};base64,${block.data}`, - } satisfies ResponseInputImage); - } - } - messages.push({ role: "user", content: contentParts }); - } + appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds); } msgIndex += 1; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e41eae2df..86f4c798a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -64,6 +64,7 @@ import { resolveGitHubCopilotBaseUrl, } from "./github-copilot-headers"; import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat"; +import { createInitialResponsesAssistantMessage } from "./openai-responses-shared"; import { transformMessages } from "./transform-messages"; import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; @@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( let firstTokenTime: number | undefined; let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined; - const output: AssistantMessage = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE); diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 3cf01120d..926d7dceb 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -12,17 +12,20 @@ import type { ResponseReasoningItem, } from "openai/resources/responses/responses"; import { calculateCost } from "../models"; -import type { - Api, - AssistantMessage, - ImageContent, - Model, - StopReason, - TextContent, - TextSignatureV1, - ThinkingContent, - ToolCall, - ToolResultMessage, +import { + type Api, + type AssistantMessage, + type ImageContent, + type Model, + type ServiceTier, + type StopReason, + type StreamOptions, + shouldSendServiceTier, + type TextContent, + type TextSignatureV1, + type ThinkingContent, + type ToolCall, + type ToolResultMessage, } from "../types"; import { normalizeResponsesToolCallId } from "../utils"; import type { AssistantMessageEventStream } from "../utils/event-stream"; @@ -540,19 +543,7 @@ export async function processResponsesStream( if (response?.id) { output.responseId = response.id; } - if (response?.usage) { - const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; - const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0; - output.usage = { - input: (response.usage.input_tokens || 0) - cachedTokens, - output: response.usage.output_tokens || 0, - cacheRead: cachedTokens, - cacheWrite: 0, - totalTokens: response.usage.total_tokens || 0, - ...(reasoningTokens > 0 ? { reasoningTokens } : {}), - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - } + populateResponsesUsageFromResponse(output, response?.usage); calculateCost(model, output.usage); output.stopReason = mapOpenAIResponsesStopReason(response?.status); if (response?.status === "failed" || response?.status === "cancelled") { @@ -605,3 +596,131 @@ export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseSt } } } + +/** Initial empty `AssistantMessage` that streaming providers accumulate into. */ +export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage { + return { + role: "assistant", + content: [], + api, + provider, + model: modelId, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */ +export type ResponsesSamplingParamsExtras = { + top_p?: number; + top_k?: number; + min_p?: number; + presence_penalty?: number; + repetition_penalty?: number; +}; + +type CommonResponsesParams = OpenAI.Responses.ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras; + +type CommonSamplingOptions = Pick< + StreamOptions, + "temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens" +> & { serviceTier?: ServiceTier }; + +/** + * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens, + * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`. + */ +export function applyCommonResponsesSamplingParams

( + params: P, + options: CommonSamplingOptions | undefined, + provider: string, +): void { + if (options?.maxTokens) params.max_output_tokens = options.maxTokens; + if (options?.temperature !== undefined) params.temperature = options.temperature; + if (options?.topP !== undefined) params.top_p = options.topP; + if (options?.topK !== undefined) params.top_k = options.topK; + if (options?.minP !== undefined) params.min_p = options.minP; + if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty; + if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty; + if (shouldSendServiceTier(options?.serviceTier, provider)) { + params.service_tier = options.serviceTier; + } +} + +type ReasoningOptions = { + reasoning?: string; + reasoningSummary?: "auto" | "detailed" | "concise" | null; +}; + +/** + * Apply reasoning-related Responses parameters: enable encrypted reasoning content for replay, + * set effort/summary when requested, and otherwise inject the GPT-5 "Juice: 0" no-reasoning hack. + * Mutates `params` and may push a developer message into `messages`. + */ +export function applyResponsesReasoningParams

( + params: P, + model: Model, + options: ReasoningOptions | undefined, + messages: ResponseInput, + mapEffort?: (effort: string) => string, +): void { + if (!model.reasoning) return; + // Always request encrypted reasoning content so reasoning items can be replayed in + // multi-turn conversations when store is false (items aren't persisted server-side, so + // we must include the full content). See: https://github.com/can1357/oh-my-pi/issues/41 + params.include = ["reasoning.encrypted_content"]; + + if (options?.reasoning || options?.reasoningSummary !== undefined) { + const requested = options?.reasoning || "medium"; + type ReasoningParam = NonNullable; + const reasoningParams: ReasoningParam = { + effort: (mapEffort ? mapEffort(requested) : requested) as ReasoningParam["effort"], + }; + if (options?.reasoningSummary !== null) { + reasoningParams.summary = options?.reasoningSummary || "auto"; + } + params.reasoning = reasoningParams as P["reasoning"]; + } else if (model.name.toLowerCase().startsWith("gpt-5")) { + // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 + messages.push({ + role: "developer", + content: [{ type: "input_text", text: "# Juice: 0 !important" }], + }); + } +} + +/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */ +export function populateResponsesUsageFromResponse( + output: AssistantMessage, + usage: + | { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + input_tokens_details?: { cached_tokens?: number | null } | null; + output_tokens_details?: { reasoning_tokens?: number | null } | null; + } + | null + | undefined, +): void { + if (!usage) return; + const cachedTokens = usage.input_tokens_details?.cached_tokens || 0; + const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0; + output.usage = { + input: (usage.input_tokens || 0) - cachedTokens, + output: usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: usage.total_tokens || 0, + ...(reasoningTokens > 0 ? { reasoningTokens } : {}), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 3fb864879..4f212adb2 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -7,7 +7,6 @@ import type { } from "openai/resources/responses/responses"; import { getEnvApiKey } from "../stream"; import { - type Api, type AssistantMessage, type CacheRetention, type Context, @@ -19,7 +18,6 @@ import { type ServiceTier, type StreamFunction, type StreamOptions, - shouldSendServiceTier, type Tool, type ToolChoice, } from "../types"; @@ -54,10 +52,13 @@ import { import { compactGrammarDefinition } from "./grammar"; import { appendResponsesToolResultMessages, + applyCommonResponsesSamplingParams, + applyResponsesReasoningParams, collectCustomCallIds, collectKnownCallIds, convertResponsesAssistantMessage, convertResponsesInputContent, + createInitialResponsesAssistantMessage, normalizeResponsesToolCallIdForTransform, processResponsesStream, } from "./openai-responses-shared"; @@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( const startTime = Date.now(); let firstTokenTime: number | undefined; - const output: AssistantMessage = { - role: "assistant", - content: [], - api: "openai-responses" as Api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; + const output: AssistantMessage = createInitialResponsesAssistantMessage( + "openai-responses", + model.provider, + model.id, + ); let rawRequestDump: RawHttpRequestDump | undefined; const abortTracker = createAbortSourceTracker(options?.signal); const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE); @@ -418,31 +407,7 @@ function buildParams( store: false, }; - if (options?.maxTokens) { - params.max_output_tokens = options?.maxTokens; - } - - if (options?.temperature !== undefined) { - params.temperature = options?.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.topK !== undefined) { - params.top_k = options.topK; - } - if (options?.minP !== undefined) { - params.min_p = options.minP; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.repetitionPenalty !== undefined) { - params.repetition_penalty = options.repetitionPenalty; - } - if (shouldSendServiceTier(options?.serviceTier, model.provider)) { - params.service_tier = options.serviceTier; - } + applyCommonResponsesSamplingParams(params, options, model.provider); if (context.tools) { params.tools = convertTools(context.tools, supportsStrictMode(model), model); @@ -460,36 +425,9 @@ function buildParams( } } - if (model.reasoning) { - // Always request encrypted reasoning content so reasoning items can be - // replayed in multi-turn conversations when store is false (items aren't - // persisted server-side, so we must include the full content). - // See: https://github.com/can1357/oh-my-pi/issues/41 - params.include = ["reasoning.encrypted_content"]; - - if (options?.reasoning || options?.reasoningSummary !== undefined) { - const reasoningParams: NonNullable = { - effort: mapReasoningEffort(options?.reasoning || "medium", model.compat?.reasoningEffortMap) as NonNullable< - OpenAIResponsesSamplingParams["reasoning"] - >["effort"], - }; - if (options?.reasoningSummary !== null) { - reasoningParams.summary = options?.reasoningSummary || "auto"; - } - params.reasoning = reasoningParams; - } else if (model.name.startsWith("gpt-5")) { - // Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7 - messages.push({ - role: "developer", - content: [ - { - type: "input_text", - text: "# Juice: 0 !important", - }, - ], - }); - } - } + applyResponsesReasoningParams(params, model, options, messages, effort => + mapReasoningEffort(effort as NonNullable, model.compat?.reasoningEffortMap), + ); return { conversationMessages, params }; } diff --git a/packages/ai/src/providers/synthetic.ts b/packages/ai/src/providers/synthetic.ts index 72933d210..f25ee296b 100644 --- a/packages/ai/src/providers/synthetic.ts +++ b/packages/ai/src/providers/synthetic.ts @@ -8,18 +8,20 @@ * @see https://dev.synthetic.new/docs/api/overview */ -import { ANTHROPIC_THINKING } from "../stream"; -import type { Api, Context, Model, SimpleStreamOptions } from "../types"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { streamAnthropic, streamOpenAICompletions } from "./register-builtins"; -import { createProviderErrorMessage } from "./shared/error-message"; +import type { Api, Context, Model } from "../types"; +import type { AssistantMessageEventStream } from "../utils/event-stream"; +import { + type OpenAIAnthropicApiFormat, + type OpenAIAnthropicShimOptions, + streamOpenAIAnthropicShim, +} from "./openai-anthropic-shim"; -export type SyntheticApiFormat = "openai" | "anthropic"; +export type SyntheticApiFormat = OpenAIAnthropicApiFormat; const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1"; const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic"; -export interface SyntheticOptions extends SimpleStreamOptions { +export interface SyntheticOptions extends OpenAIAnthropicShimOptions { /** API format: "openai" or "anthropic". Default: "openai" */ format?: SyntheticApiFormat; } @@ -33,102 +35,13 @@ export function streamSynthetic( context: Context, options?: SyntheticOptions, ): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - const format = options?.format ?? "openai"; - - // Async IIFE to handle stream piping - (async () => { - try { - const mergedHeaders = options?.headers ?? {}; - - if (format === "anthropic") { - // Create a synthetic Anthropic model pointing to Synthetic's endpoint - const anthropicModel: Model<"anthropic-messages"> = { - id: model.id, - name: model.name, - api: "anthropic-messages", - provider: model.provider, - baseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL, - headers: mergedHeaders, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - reasoning: model.reasoning, - input: model.input, - cost: model.cost, - }; - - // Calculate thinking budget from reasoning level - const reasoning = options?.reasoning; - const reasoningEffort = reasoning; - const thinkingEnabled = !!reasoningEffort && model.reasoning; - const thinkingBudget = reasoningEffort - ? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort]) - : undefined; - - const innerStream = streamAnthropic(anthropicModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000), - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - thinkingEnabled, - thinkingBudgetTokens: thinkingBudget, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } else { - // OpenAI format - use original model with Synthetic headers - const syntheticModel: Model<"openai-completions"> = { - ...model, - baseUrl: SYNTHETIC_NEW_BASE_URL, - headers: mergedHeaders, - }; - - const reasoningEffort = options?.reasoning; - const innerStream = streamOpenAICompletions(syntheticModel, context, { - apiKey: options?.apiKey, - temperature: options?.temperature, - topP: options?.topP, - topK: options?.topK, - minP: options?.minP, - presencePenalty: options?.presencePenalty, - repetitionPenalty: options?.repetitionPenalty, - maxTokens: options?.maxTokens ?? model.maxTokens, - signal: options?.signal, - headers: mergedHeaders, - sessionId: options?.sessionId, - onPayload: options?.onPayload, - onResponse: options?.onResponse, - onSseEvent: options?.onSseEvent, - reasoning: reasoningEffort, - }); - - for await (const event of innerStream) { - stream.push(event); - } - } - } catch (err) { - stream.push({ - type: "error", - reason: "error", - error: createProviderErrorMessage(model, err), - }); - } - })(); - - return stream; + return streamOpenAIAnthropicShim(model, context, options, { + anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL, + openaiBaseUrl: SYNTHETIC_NEW_BASE_URL, + defaultFormat: "openai", + }); } + /** * Check if a model is a Synthetic model. */ diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index dc99e4a5b..07b6ddee9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -64,7 +64,7 @@ type _CheckExhaustive = ? true : ["ApiOptionsMap is missing some KnownApi values", Exclude] : ["ApiOptionsMap doesn't extend Record"]; -const _exhaustive: _CheckExhaustive = true; +true satisfies _CheckExhaustive; export type OptionsForApi = | StreamOptions | (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never); diff --git a/packages/ai/src/utils/oauth/lm-studio.ts b/packages/ai/src/utils/oauth/lm-studio.ts index 5a315831e..d1e3921fe 100644 --- a/packages/ai/src/utils/oauth/lm-studio.ts +++ b/packages/ai/src/utils/oauth/lm-studio.ts @@ -10,8 +10,6 @@ import type { OAuthController, OAuthProvider } from "./types"; const PROVIDER_ID: OAuthProvider = "lm-studio"; -const _AUTH_URL = "https://lmstudio.ai/docs/api"; -const _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:1234/v1"; export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; /** diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index f80dc09f9..0b38d4f23 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => { vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" }); - for await (const _event of stream) { + for await (const _ of stream) { // drain stream } const result = await stream.result(); @@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => { }); const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" }); - for await (const _event of eagerStream) { + for await (const _ of eagerStream) { // drain stream } await eagerStream.result(); @@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => { toolContext, { apiKey: "sk-ant-test" }, ); - for await (const _event of disabledStream) { + for await (const _ of disabledStream) { // drain stream } await disabledStream.result(); @@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => { apiKey: "sk-ant-test", cacheRetention: "long", }); - for await (const _event of stream) { + for await (const _ of stream) { // drain stream } await stream.result(); diff --git a/packages/ai/test/github-copilot-login.test.ts b/packages/ai/test/github-copilot-login.test.ts index c0b3de070..2e24dfea4 100644 --- a/packages/ai/test/github-copilot-login.test.ts +++ b/packages/ai/test/github-copilot-login.test.ts @@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => { }); it("device flow error", async () => { - let _pollCount = 0; const fetchMock = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://github.com/login/device/code") { @@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => { }); } if (url === "https://github.com/login/oauth/access_token") { - _pollCount++; return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index f8981c408..af2f52dfe 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -16,7 +16,7 @@ import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; * E2E tests require explicit opt-in via E2E=1 environment variable. * This prevents accidental API calls when keys happen to be in the environment. */ -export const E2E_ENABLED = $flag("E2E"); +const E2E_ENABLED = $flag("E2E"); /** * Get an API key from environment, but only if E2E tests are enabled. diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 44b8290fa..a7a45de82 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => { ); const drain = (async () => { - for await (const _event of events) { + for await (const _ of events) { } })(); diff --git a/packages/coding-agent/examples/extensions/plan-mode.ts b/packages/coding-agent/examples/extensions/plan-mode.ts index d08fcb0c1..d0b206026 100644 --- a/packages/coding-agent/examples/extensions/plan-mode.ts +++ b/packages/coding-agent/examples/extensions/plan-mode.ts @@ -334,7 +334,6 @@ export default function planModeExtension(pi: ExtensionAPI) { } // Remove any previous plan-mode-context messages - const _beforeCount = event.messages.length; const filtered = event.messages.filter(m => { if (m.role === "user" && Array.isArray(m.content)) { const hasOldContext = m.content.some( diff --git a/packages/coding-agent/src/autoresearch/helpers.ts b/packages/coding-agent/src/autoresearch/helpers.ts index 7b7a68904..feed49d05 100644 --- a/packages/coding-agent/src/autoresearch/helpers.ts +++ b/packages/coding-agent/src/autoresearch/helpers.ts @@ -1,3 +1,4 @@ +import * as git from "../utils/git"; import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types"; export const METRIC_LINE_PREFIX = "METRIC"; @@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | undefined { } return undefined; } + +export async function tryGitStatus(cwd: string): Promise { + try { + return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); + } catch { + return ""; + } +} + +export async function tryGitPrefix(cwd: string): Promise { + try { + return await git.show.prefix(cwd); + } catch { + return ""; + } +} diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index d21d4c0f1..21989436e 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -8,7 +8,15 @@ import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; import * as git from "../../utils/git"; import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git"; -import { ensureNumericMetricMap, formatNum, mergeAsi, pathMatchesSpec, sanitizeAsi } from "../helpers"; +import { + ensureNumericMetricMap, + formatNum, + mergeAsi, + pathMatchesSpec, + sanitizeAsi, + tryGitPrefix, + tryGitStatus, +} from "../helpers"; import { buildExperimentState, computeConfidence, @@ -445,22 +453,6 @@ async function tryReadHeadSha(cwd: string): Promise { } } -async function tryGitStatus(cwd: string): Promise { - try { - return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); - } catch { - return ""; - } -} - -async function tryGitPrefix(cwd: string): Promise { - try { - return await git.show.prefix(cwd); - } catch { - return ""; - } -} - function buildLogText( state: ExperimentState, experiment: ExperimentResult, diff --git a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts index 5041c611b..df788bcdf 100644 --- a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -18,6 +18,8 @@ import { killTree, parseAsiLines, parseMetricLines, + tryGitPrefix, + tryGitStatus, } from "../helpers"; import { buildExperimentState } from "../state"; import { openAutoresearchStorageIfExists } from "../storage"; @@ -265,23 +267,6 @@ export function createRunExperimentTool( }, }; } - -async function tryGitStatus(cwd: string): Promise { - try { - return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true }); - } catch { - return ""; - } -} - -async function tryGitPrefix(cwd: string): Promise { - try { - return await git.show.prefix(cwd); - } catch { - return ""; - } -} - async function executeProcess(opts: { command: string[]; cwd: string; diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index d4191d617..eab9320b8 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -2,11 +2,11 @@ * List available models with optional fuzzy search */ import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai"; +import { fuzzyFilter } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions"; import { EventBus } from "../utils/event-bus"; -import { fuzzyFilter } from "../utils/fuzzy"; interface ProviderRow { provider: string; diff --git a/packages/coding-agent/src/cli/shell-cli.ts b/packages/coding-agent/src/cli/shell-cli.ts index 9213a0b22..62ec032fd 100644 --- a/packages/coding-agent/src/cli/shell-cli.ts +++ b/packages/coding-agent/src/cli/shell-cli.ts @@ -5,10 +5,11 @@ */ import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; +import { Shell } from "@oh-my-pi/pi-natives"; import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import { Settings, type ShellMinimizerSettings } from "../config/settings"; +import { Settings } from "../config/settings"; +import { buildMinimizerOptions } from "../exec/bash-executor"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; export interface ShellCommandArgs { @@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined { return result; } -function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined { - if (!group.enabled) return undefined; - return { - enabled: true, - settingsPath: group.settingsPath || undefined, - only: group.only.length > 0 ? group.only : undefined, - except: group.except.length > 0 ? group.except : undefined, - maxCaptureBytes: group.maxCaptureBytes, - }; -} - export async function runShellCommand(cmd: ShellCommandArgs): Promise { if (!process.stdin.isTTY) { process.stderr.write("Error: shell console requires an interactive TTY.\n"); diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 8dcb47b1c..044fff2d1 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -91,7 +91,7 @@ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "b return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; } -export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { +export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { return resolveUpdateMethod(ompPath, bunBinDir); } async function resolveUpdateTarget(): Promise { diff --git a/packages/coding-agent/src/commit/analysis/conventional.ts b/packages/coding-agent/src/commit/analysis/conventional.ts index 132446498..5b52bb5c4 100644 --- a/packages/coding-agent/src/commit/analysis/conventional.ts +++ b/packages/coding-agent/src/commit/analysis/conventional.ts @@ -1,52 +1,16 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" }; import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" }; -import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types"; +import type { ConventionalAnalysis } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; -import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; +import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm"; -const ConventionalAnalysisTool = { - name: "create_conventional_analysis", - description: "Analyze a diff and return conventional commit classification.", - parameters: Type.Object({ - type: Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("perf"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("revert"), - ]), - scope: Type.Union([Type.String(), Type.Null()]), - details: Type.Array( - Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), - }), - ), - issue_refs: Type.Array(Type.String()), - }), -}; +const ConventionalAnalysisTool = createConventionalAnalysisTool( + "Analyze a diff and return conventional commit classification.", +); export interface ConventionalAnalysisInput { model: Model; @@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, ); - return parseAnalysisFromResponse(response); -} - -function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis { - const toolCall = extractToolCall(message, "create_conventional_analysis"); - if (toolCall) { - const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); - } - - const text = extractTextContent(message); - const parsed = parseJsonPayload(text) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); + return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool); } diff --git a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts index 3657e275f..1ea5278e9 100644 --- a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts @@ -1,52 +1,14 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" }; import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" }; -import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types"; +import type { ConventionalAnalysis, FileObservation } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; -import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; +import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm"; -const ReduceTool = { - name: "create_conventional_analysis", - description: "Synthesize file observations into a conventional commit analysis.", - parameters: Type.Object({ - type: Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("perf"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("revert"), - ]), - scope: Type.Union([Type.String(), Type.Null()]), - details: Type.Array( - Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), - }), - ), - issue_refs: Type.Array(Type.String()), - }), -}; +const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis."); export interface ReducePhaseInput { model: Model; @@ -83,26 +45,5 @@ export async function runReducePhase({ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, ); - return parseAnalysisResponse(response); -} - -function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis { - const toolCall = extractToolCall(message, "create_conventional_analysis"); - if (toolCall) { - const parsed = validateToolCall([ReduceTool], toolCall) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); - } - const text = extractTextContent(message); - const parsed = parseJsonPayload(text) as { - type: ConventionalAnalysis["type"]; - scope: string | null; - details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; - issue_refs: string[]; - }; - return normalizeAnalysis(parsed); + return parseConventionalAnalysisResponse(response, ReduceTool); } diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 7badfe4b7..bc53e54fe 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types"; const SUMMARY_MAX_CHARS = 72; const RECENT_COMMITS_COUNT = 8; -let _typesDescription: string | undefined; -const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt)); +let typesDescription: string | undefined; +const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt)); /** * Execute the omp commit pipeline for staged changes. diff --git a/packages/coding-agent/src/commit/shared-llm.ts b/packages/coding-agent/src/commit/shared-llm.ts new file mode 100644 index 000000000..13bf88869 --- /dev/null +++ b/packages/coding-agent/src/commit/shared-llm.ts @@ -0,0 +1,89 @@ +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import type { ChangelogCategory, ConventionalAnalysis } from "./types"; +import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils"; + +/** + * Shared TypeBox schema for the `create_conventional_analysis` tool used by + * both the single-pass analysis call and the map-reduce reduce phase. Schemas + * are identical across phases — only the surrounding tool `description` + * differs to reflect the input the phase is summarizing. + */ +export const conventionalAnalysisParameters = Type.Object({ + type: Type.Union([ + Type.Literal("feat"), + Type.Literal("fix"), + Type.Literal("refactor"), + Type.Literal("docs"), + Type.Literal("test"), + Type.Literal("chore"), + Type.Literal("style"), + Type.Literal("perf"), + Type.Literal("build"), + Type.Literal("ci"), + Type.Literal("revert"), + ]), + scope: Type.Union([Type.String(), Type.Null()]), + details: Type.Array( + Type.Object({ + text: Type.String(), + changelog_category: Type.Optional( + Type.Union([ + Type.Literal("Added"), + Type.Literal("Changed"), + Type.Literal("Fixed"), + Type.Literal("Deprecated"), + Type.Literal("Removed"), + Type.Literal("Security"), + Type.Literal("Breaking Changes"), + ]), + ), + user_visible: Type.Optional(Type.Boolean()), + }), + ), + issue_refs: Type.Array(Type.String()), +}); + +export interface ConventionalAnalysisTool { + name: "create_conventional_analysis"; + description: string; + parameters: typeof conventionalAnalysisParameters; +} + +/** + * Build a `create_conventional_analysis` tool descriptor. Phase-specific + * `description` text is the only thing that varies between callers. + */ +export function createConventionalAnalysisTool(description: string): ConventionalAnalysisTool { + return { + name: "create_conventional_analysis", + description, + parameters: conventionalAnalysisParameters, + }; +} + +interface ParsedConventionalAnalysis { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; +} + +/** + * Extract a {@link ConventionalAnalysis} from an assistant response, preferring + * a structured tool call and falling back to JSON embedded in text content. + */ +export function parseConventionalAnalysisResponse( + message: AssistantMessage, + tool: ConventionalAnalysisTool, +): ConventionalAnalysis { + const toolCall = extractToolCall(message, tool.name); + if (toolCall) { + const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis; + return normalizeAnalysis(parsed); + } + const text = extractTextContent(message); + const parsed = parseJsonPayload(text) as ParsedConventionalAnalysis; + return normalizeAnalysis(parsed); +} diff --git a/packages/coding-agent/src/config/model-equivalence.ts b/packages/coding-agent/src/config/model-equivalence.ts index 6b3165887..100344d8c 100644 --- a/packages/coding-agent/src/config/model-equivalence.ts +++ b/packages/coding-agent/src/config/model-equivalence.ts @@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => { })(); const WRAPPER_PREFIXES = ["duo-chat-"] as const; -let __referenceDataCache: CanonicalReferenceData | undefined; +let referenceDataCache: CanonicalReferenceData | undefined; const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = { overrides: new Map(), exclude: new Set(), }; -const __resolutionCache: WeakMap< - CompiledEquivalenceConfig, - WeakMap, ResolvedCanonicalModel> -> = new WeakMap(); +const resolutionCache: WeakMap, ResolvedCanonicalModel>> = new WeakMap(); const FAMILY_EXTRACTION_PATTERNS = [ /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i, /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i, @@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model | undefined, candidate: Mod } function createCanonicalReferenceData(): CanonicalReferenceData { - if (__referenceDataCache) { - return __referenceDataCache; + if (referenceDataCache) { + return referenceDataCache; } const references = new Map>(); for (const provider of getBundledProviders()) { @@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData { } } const officialIds = new Set(references.keys()); - __referenceDataCache = { + referenceDataCache = { references: Object.freeze(references) as Map>, officialIds: Object.freeze(officialIds) as Set, }; - return __referenceDataCache; + return referenceDataCache; } function normalizeSelectorKey(selector: string): string { @@ -668,10 +665,10 @@ export function buildCanonicalModelIndex( const byId = new Map(); const bySelector = new Map(); - let modelCache = __resolutionCache.get(compiledEquivalence); + let modelCache = resolutionCache.get(compiledEquivalence); if (!modelCache) { modelCache = new WeakMap, ResolvedCanonicalModel>(); - __resolutionCache.set(compiledEquivalence, modelCache); + resolutionCache.set(compiledEquivalence, modelCache); } for (const model of models) { diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 1934dbd76..1d0ca277b 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -12,10 +12,10 @@ import { type Model, modelsAreEqual, } from "@oh-my-pi/pi-ai"; +import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import chalk from "chalk"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking"; -import { fuzzyMatch } from "../utils/fuzzy"; import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry"; import type { Settings } from "./settings"; @@ -607,9 +607,6 @@ export function resolveModelRoleValue( return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } - const lastColonIndex = normalized.lastIndexOf(":"); - const _thinkingSelector = - lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined; const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings); if (!effectivePatterns || effectivePatterns.length === 0) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 9335f66f1..d9293cd48 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean { * Reset the global singleton for testing. * @internal */ -export function _resetSettingsForTest(): void { +export function resetSettingsForTest(): void { globalInstance = null; globalInstancePromise = null; } diff --git a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts index f4f4f498e..e8594635e 100644 --- a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts +++ b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts @@ -180,7 +180,7 @@ export function rewriteImports(code: string): string { * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're * scoped to their enclosing function/block regardless of `var` vs `let`/`const`. */ -export function demoteTopLevelLexicals(code: string): string { +function demoteTopLevelLexicals(code: string): string { if (!/\b(?:const|let|class)\b/.test(code)) return code; const ast = parseProgram(code); @@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea * common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex * before invoking the transpiler. */ -export function stripTypeScript(code: string): string { +function stripTypeScript(code: string): string { if (!LOOKS_LIKE_TS.test(code)) return code; try { return new Bun.Transpiler({ loader: "ts" }).transformSync(code); diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index b1709a38d..0c60ccf6b 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -65,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise void; export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000; let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS; -export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void { +export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void { extensionHandlerTimeoutMs = timeoutMs; } diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index a51c3d114..59d445659 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -18,13 +18,11 @@ import type { ProviderResponseMetadata, SimpleStreamOptions, TextContent, - ToolResultMessage, } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; -import type { Rule } from "../../capability/rule"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; @@ -33,15 +31,9 @@ import type { BashResult } from "../../exec/bash-executor"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; +import type { CompactionResult } from "../../session/compaction"; import type { CustomMessage } from "../../session/messages"; -import type { - BranchSummaryEntry, - CompactionEntry, - ReadonlySessionManager, - SessionEntry, - SessionManager, -} from "../../session/session-manager"; +import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolDetails, BashToolInput, @@ -53,8 +45,39 @@ import type { SearchToolInput, WriteToolInput, } from "../../tools"; -import type { TodoItem } from "../../tools/todo-write"; import type { EventBus } from "../../utils/event-bus"; +import type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeBranchResult, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionCompactingResult, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TodoReminderEvent, + ToolCallEventResult, + ToolResultEventResult, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; import type { SlashCommandInfo } from "../slash-commands"; export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; @@ -104,6 +127,11 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookUIContext: extensions expose a strictly larger UI surface +// (custom editor component, header/footer, widgets, theming, terminal input) +// and may be invoked from event handlers that have already taken the agent +// loop's lock — hooks intentionally cannot. export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise; @@ -221,6 +249,11 @@ export interface CompactOptions { /** * Context passed to extension event handlers. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookContext: extensions expose a strictly larger runtime +// surface (model registry, system prompt, shutdown, full session manager +// access). Field overlap is incidental; merging into a base would require +// hooks to widen their public contract. export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; @@ -256,6 +289,10 @@ export interface ExtensionContext { * Extended context for command handlers. * Includes session control methods only safe in user-initiated commands. */ +// fallow-ignore-next-line code-duplication +// Parallel to HookCommandContext: same method names, different invariants — +// extension commands additionally permit `switchSession` and `reload`, +// which hooks must not call to avoid deadlocking the agent loop. export interface ExtensionCommandContext extends ExtensionContext { /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; @@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult { } // ============================================================================ -// Session Events +// Session Events (shared with hooks subsystem) // ============================================================================ -/** Fired on initial session load */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** Fired before switching to another session (can be cancelled) */ -export interface SessionBeforeSwitchEvent { - type: "session_before_switch"; - reason: "new" | "resume" | "fork"; - targetSessionFile?: string; -} - -/** Fired after switching to another session */ -export interface SessionSwitchEvent { - type: "session_switch"; - reason: "new" | "resume" | "fork"; - previousSessionFile: string | undefined; -} - -/** Fired before branching a session (can be cancelled) */ -export interface SessionBeforeBranchEvent { - type: "session_before_branch"; - entryId: string; -} - -/** Fired after branching a session */ -export interface SessionBranchEvent { - type: "session_branch"; - previousSessionFile: string | undefined; -} - -/** Fired before context compaction (can be cancelled or customized) */ -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - preparation: CompactionPreparation; - branchEntries: SessionEntry[]; - customInstructions?: string; - signal: AbortSignal; -} - -/** Fired before compaction summarization to customize prompts/context */ -export interface SessionCompactingEvent { - type: "session.compacting"; - sessionId: string; - messages: AgentMessage[]; -} - -/** Fired after context compaction */ -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - fromExtension: boolean; -} - -/** Fired on process exit */ -export interface SessionShutdownEvent { - type: "session_shutdown"; -} - -/** Preparation data for tree navigation */ -export interface TreePreparation { - targetId: string; - oldLeafId: string | null; - commonAncestorId: string | null; - entriesToSummarize: SessionEntry[]; - userWantsSummary: boolean; -} - -/** Fired before navigating in the session tree (can be cancelled) */ -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - preparation: TreePreparation; - signal: AbortSignal; -} - -/** Fired after navigating in the session tree */ -export interface SessionTreeEvent { - type: "session_tree"; - newLeafId: string | null; - oldLeafId: string | null; - summaryEntry?: BranchSummaryEntry; - fromExtension?: boolean; -} - -export type SessionEvent = - | SessionStartEvent - | SessionBeforeSwitchEvent - | SessionSwitchEvent - | SessionBeforeBranchEvent - | SessionBranchEvent - | SessionBeforeCompactEvent - | SessionCompactingEvent - | SessionCompactEvent - | SessionShutdownEvent - | SessionBeforeTreeEvent - | SessionTreeEvent; +export type { + SessionBeforeBranchEvent, + SessionBeforeCompactEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TreePreparation, +} from "../shared-events"; // ============================================================================ // Agent Events // ============================================================================ -/** Fired before each LLM call. Can modify messages. */ -export interface ContextEvent { - type: "context"; - messages: AgentMessage[]; -} +export type { ContextEvent } from "../shared-events"; /** Fired before a provider request is sent. Can replace the payload. */ export interface BeforeProviderRequestEvent { @@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent { systemPrompt: string[]; } -/** Fired when an agent loop starts */ -export interface AgentStartEvent { - type: "agent_start"; -} - -/** Fired when an agent loop ends */ -export interface AgentEndEvent { - type: "agent_end"; - messages: AgentMessage[]; -} - -/** Fired at the start of each turn */ -export interface TurnStartEvent { - type: "turn_start"; - turnIndex: number; - timestamp: number; -} - -/** Fired at the end of each turn */ -export interface TurnEndEvent { - type: "turn_end"; - turnIndex: number; - message: AgentMessage; - toolResults: ToolResultMessage[]; -} +export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events"; /** Fired when a message starts (user, assistant, or toolResult) */ export interface MessageStartEvent { @@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent { isError: boolean; } -/** Fired when auto-compaction starts */ -export interface AutoCompactionStartEvent { - type: "auto_compaction_start"; - reason: "threshold" | "overflow" | "idle"; - action: "context-full" | "handoff"; -} - -/** Fired when auto-compaction ends */ -export interface AutoCompactionEndEvent { - type: "auto_compaction_end"; - action: "context-full" | "handoff"; - result: CompactionResult | undefined; - aborted: boolean; - willRetry: boolean; - errorMessage?: string; - /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ - skipped?: boolean; -} - -/** Fired when auto-retry starts */ -export interface AutoRetryStartEvent { - type: "auto_retry_start"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -/** Fired when auto-retry ends */ -export interface AutoRetryEndEvent { - type: "auto_retry_end"; - success: boolean; - attempt: number; - finalError?: string; -} - -/** Fired when TTSR rule matching interrupts generation */ -export interface TtsrTriggeredEvent { - type: "ttsr_triggered"; - rules: Rule[]; -} - -/** Fired when todo reminder logic detects unfinished todos */ -export interface TodoReminderEvent { - type: "todo_reminder"; - todos: TodoItem[]; - attempt: number; - maxAttempts: number; -} +export type { + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + TodoReminderEvent, + TtsrTriggeredEvent, +} from "../shared-events"; /** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */ export interface CredentialDisabledEvent { @@ -857,10 +744,7 @@ export interface ContextEventResult { export type BeforeProviderRequestEventResult = unknown; -export interface ToolCallEventResult { - block?: boolean; - reason?: string; -} +export type { ToolCallEventResult } from "../shared-events"; /** Result from input event handler */ export interface InputEventResult { @@ -884,11 +768,7 @@ export interface UserPythonEventResult { result?: PythonResult; } -export interface ToolResultEventResult { - content?: (TextContent | ImageContent)[]; - details?: unknown; - isError?: boolean; -} +export type { ToolResultEventResult } from "../shared-events"; export interface BeforeAgentStartEventResult { message?: Pick; @@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult { systemPrompt?: string[]; } -export interface SessionBeforeSwitchResult { - cancel?: boolean; -} - -export interface SessionBeforeBranchResult { - cancel?: boolean; - skipConversationRestore?: boolean; -} - -export interface SessionBeforeCompactResult { - cancel?: boolean; - compaction?: CompactionResult; -} - -export interface SessionCompactingResult { - context?: string[]; - prompt?: string; - preserveData?: Record; -} - -export interface SessionBeforeTreeResult { - cancel?: boolean; - summary?: { - summary: string; - details?: unknown; - }; -} +export type { + SessionBeforeBranchResult, + SessionBeforeCompactResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionCompactingResult, +} from "../shared-events"; // ============================================================================ // Message Rendering @@ -942,6 +802,9 @@ export type MessageRenderer = ( // Command Registration // ============================================================================ +// fallow-ignore-next-line code-duplication +// Parallel to HookAPI's RegisteredCommand: extensions add +// `getArgumentCompletions` and bind handlers to ExtensionCommandContext. export interface RegisteredCommand { name: string; description?: string; diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 5aa398583..ad4ba071a 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -1,28 +1,44 @@ -/** - * Hook system types. - * - * Hooks are TypeScript modules that can subscribe to agent lifecycle events - * and interact with the user via UI primitives. - */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; -import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; import type { HookMessage } from "../../session/messages"; -import type { - BranchSummaryEntry, - CompactionEntry, - ReadonlySessionManager, - SessionEntry, - SessionManager, -} from "../../session/session-manager"; +import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools"; -import type { TodoItem } from "../../tools/todo-write"; +import type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeBranchResult, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionCompactingResult, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TodoReminderEvent, + ToolCallEventResult, + ToolResultEventResult, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -31,6 +47,11 @@ export type { ExecOptions, ExecResult } from "../../exec/exec"; * UI context for hooks to request interactive UI from the harness. * Each mode (interactive, RPC, print) provides its own implementation. */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI +// surface — no terminal-input listener, no editor component override, no +// theme management — because hooks are invoked from inside the agent loop +// and must not be able to seize ownership of the editor. export interface HookUIContext { /** * Show a selector and return the user's choice. @@ -137,6 +158,11 @@ export interface HookUIContext { * Context passed to hook event handlers. * For command handlers, see HookCommandContext which extends this with session control methods. */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionContext: hooks see a narrower runtime context (no +// model registry mutation, no system prompt access, no shutdown). The +// overlap in field names is intentional API symmetry; widening hooks to +// match extensions would let hooks call methods that deadlock the agent. export interface HookContext { /** UI methods for user interaction */ ui: HookUIContext; @@ -165,6 +191,11 @@ export interface HookContext { * These methods are not available in event handlers because they can cause * deadlocks when called from within the agent loop (e.g., tool_call, context events). */ +// fallow-ignore-next-line code-duplication +// Parallel to ExtensionCommandContext: hooks intentionally omit +// `switchSession`, `reload`, `compact`, and `getContextUsage` — those are +// safe only from extension command handlers, not from the hook execution +// context. export interface HookCommandContext extends HookContext { /** Wait for the agent to finish streaming */ waitForIdle(): Promise; @@ -210,138 +241,25 @@ export interface HookCommandContext extends HookContext { } // ============================================================================ -// Session Events +// Session Events (shared with extensions subsystem) // ============================================================================ -/** Fired on initial session load */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** Fired before switching to another session (can be cancelled) */ -export interface SessionBeforeSwitchEvent { - type: "session_before_switch"; - /** Reason for the switch */ - reason: "new" | "resume" | "fork"; - /** Session file we're switching to (only for "resume") */ - targetSessionFile?: string; -} - -/** Fired after switching to another session */ -export interface SessionSwitchEvent { - type: "session_switch"; - /** Reason for the switch */ - reason: "new" | "resume" | "fork"; - /** Session file we came from */ - previousSessionFile: string | undefined; -} - -/** Fired before branching a session (can be cancelled) */ -export interface SessionBeforeBranchEvent { - type: "session_before_branch"; - /** ID of the entry to branch from */ - entryId: string; -} - -/** Fired after branching a session */ -export interface SessionBranchEvent { - type: "session_branch"; - previousSessionFile: string | undefined; -} - -/** Fired before context compaction (can be cancelled) */ -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ - preparation: CompactionPreparation; - /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ - branchEntries: SessionEntry[]; - /** Optional user-provided instructions for the summary */ - customInstructions?: string; - /** Abort signal - hooks should pass this to LLM calls and check it periodically */ - signal: AbortSignal; -} - -/** Fired before compaction summarization to customize prompts/context */ -export interface SessionCompactingEvent { - type: "session.compacting"; - sessionId: string; - messages: AgentMessage[]; -} - -/** Fired after context compaction */ -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - /** Whether the compaction entry was provided by a hook */ - fromExtension: boolean; -} - -/** Fired on process exit (SIGINT/SIGTERM) */ -export interface SessionShutdownEvent { - type: "session_shutdown"; -} - -/** Preparation data for tree navigation (used by session_before_tree event) */ -export interface TreePreparation { - /** Node being switched to */ - targetId: string; - /** Current active leaf (being abandoned), null if no current position */ - oldLeafId: string | null; - /** Common ancestor of target and old leaf, null if no common ancestor */ - commonAncestorId: string | null; - /** Entries to summarize (old leaf back to common ancestor or compaction) */ - entriesToSummarize: SessionEntry[]; - /** Whether user chose to summarize */ - userWantsSummary: boolean; -} - -/** Fired before navigating to a different node in the session tree (can be cancelled) */ -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - /** Preparation data for the navigation */ - preparation: TreePreparation; - /** Abort signal - honors Escape during summarization (model available via ctx.model) */ - signal: AbortSignal; -} - -/** Fired after navigating to a different node in the session tree */ -export interface SessionTreeEvent { - type: "session_tree"; - /** The new active leaf, null if navigated to before first entry */ - newLeafId: string | null; - /** Previous active leaf, null if there was no position */ - oldLeafId: string | null; - /** Branch summary entry if one was created */ - summaryEntry?: BranchSummaryEntry; - /** Whether summary came from hook */ - fromExtension?: boolean; -} - -/** Union of all session event types */ -export type SessionEvent = - | SessionStartEvent - | SessionBeforeSwitchEvent - | SessionSwitchEvent - | SessionBeforeBranchEvent - | SessionBranchEvent - | SessionBeforeCompactEvent - | SessionCompactingEvent - | SessionCompactEvent - | SessionShutdownEvent - | SessionBeforeTreeEvent - | SessionTreeEvent; - -/** - * Event data for context event. - * Fired before each LLM call, allowing hooks to modify context non-destructively. - * Original session messages are NOT modified - only the messages sent to the LLM are affected. - */ -export interface ContextEvent { - type: "context"; - /** Messages about to be sent to the LLM (deep copy, safe to modify) */ - messages: AgentMessage[]; -} +export type { + ContextEvent, + SessionBeforeBranchEvent, + SessionBeforeCompactEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionBranchEvent, + SessionCompactEvent, + SessionCompactingEvent, + SessionEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + TreePreparation, +} from "../shared-events"; /** * Event data for before_agent_start event. @@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent { images?: ImageContent[]; } -/** - * Event data for agent_start event. - * Fired when an agent loop starts (once per user prompt). - */ -export interface AgentStartEvent { - type: "agent_start"; -} - -/** - * Event data for agent_end event. - */ -export interface AgentEndEvent { - type: "agent_end"; - messages: AgentMessage[]; -} - -/** - * Event data for turn_start event. - */ -export interface TurnStartEvent { - type: "turn_start"; - turnIndex: number; - timestamp: number; -} - -/** - * Event data for turn_end event. - */ -export interface TurnEndEvent { - type: "turn_end"; - turnIndex: number; - message: AgentMessage; - toolResults: ToolResultMessage[]; -} - -/** Event data for auto_compaction_start event. */ -export interface AutoCompactionStartEvent { - type: "auto_compaction_start"; - reason: "threshold" | "overflow" | "idle"; - action: "context-full" | "handoff"; -} - -/** Event data for auto_compaction_end event. */ -export interface AutoCompactionEndEvent { - type: "auto_compaction_end"; - action: "context-full" | "handoff"; - result: CompactionResult | undefined; - aborted: boolean; - willRetry: boolean; - errorMessage?: string; - /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ - skipped?: boolean; -} - -/** Event data for auto_retry_start event. */ -export interface AutoRetryStartEvent { - type: "auto_retry_start"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -/** Event data for auto_retry_end event. */ -export interface AutoRetryEndEvent { - type: "auto_retry_end"; - success: boolean; - attempt: number; - finalError?: string; -} - -/** Event data for ttsr_triggered event. */ -export interface TtsrTriggeredEvent { - type: "ttsr_triggered"; - rules: Rule[]; -} - -/** Event data for todo_reminder event. */ -export interface TodoReminderEvent { - type: "todo_reminder"; - todos: TodoItem[]; - attempt: number; - maxAttempts: number; -} +export type { + AgentEndEvent, + AgentStartEvent, + AutoCompactionEndEvent, + AutoCompactionStartEvent, + AutoRetryEndEvent, + AutoRetryStartEvent, + TodoReminderEvent, + TtsrTriggeredEvent, + TurnEndEvent, + TurnStartEvent, +} from "../shared-events"; /** * Event data for tool_call event. @@ -559,29 +405,7 @@ export interface ContextEventResult { messages?: Message[]; } -/** - * Return type for tool_call event handlers. - * Allows hooks to block tool execution. - */ -export interface ToolCallEventResult { - /** If true, block the tool from executing */ - block?: boolean; - /** Reason for blocking (returned to LLM as error) */ - reason?: string; -} - -/** - * Return type for tool_result event handlers. - * Allows hooks to modify tool results. - */ -export interface ToolResultEventResult { - /** Replacement content array (text and images) */ - content?: (TextContent | ImageContent)[]; - /** Replacement details */ - details?: unknown; - /** Override isError flag */ - isError?: boolean; -} +export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events"; /** * Return type for before_agent_start event handlers. @@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult { message?: Pick; } -/** Return type for session_before_switch handlers */ -export interface SessionBeforeSwitchResult { - /** If true, cancel the switch */ - cancel?: boolean; -} - -/** Return type for session_before_branch handlers */ -export interface SessionBeforeBranchResult { - /** - * If true, abort the branch entirely. No new session file is created, - * conversation stays unchanged. - */ - cancel?: boolean; - /** - * If true, the branch proceeds (new session file created, session state updated) - * but the in-memory conversation is NOT rewound to the branch point. - * - * Use case: git-checkpoint hook that restores code state separately. - * The hook handles state restoration itself, so it doesn't want the - * agent's conversation to be rewound (which would lose recent context). - * - * - `cancel: true` → nothing happens, user stays in current session - * - `skipConversationRestore: true` → branch happens, but messages stay as-is - * - neither → branch happens AND messages rewind to branch point (default) - */ - skipConversationRestore?: boolean; -} - -/** Return type for session_before_compact handlers */ -export interface SessionBeforeCompactResult { - /** If true, cancel the compaction */ - cancel?: boolean; - /** Custom compaction result - SessionManager adds id/parentId */ - compaction?: CompactionResult; -} - -/** Return type for session.compacting handlers */ -export interface SessionCompactingResult { - /** Additional context lines to include in summary */ - context?: string[]; - /** Override the default compaction prompt */ - prompt?: string; - /** Custom data to store in compaction entry */ - preserveData?: Record; -} - -/** Return type for session_before_tree handlers */ -export interface SessionBeforeTreeResult { - /** If true, cancel the navigation entirely */ - cancel?: boolean; - /** - * Custom summary (skips default summarizer). - * Only used if preparation.userWantsSummary is true. - */ - summary?: { - summary: string; - details?: unknown; - }; -} +export type { + SessionBeforeBranchResult, + SessionBeforeCompactResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionCompactingResult, +} from "../shared-events"; // ============================================================================ // Hook API @@ -681,6 +453,9 @@ export type HookMessageRenderer = ( /** * Command registration options. */ +// fallow-ignore-next-line code-duplication +// Parallel to extensions' RegisteredCommand: hooks bind to +// HookCommandContext and have no argument-completion hook. export interface RegisteredCommand { name: string; description?: string; diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts new file mode 100644 index 000000000..a49fd6722 --- /dev/null +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -0,0 +1,335 @@ +/** + * Event payload and result shapes shared between the extensions and hooks + * subsystems. + * + * Both subsystems observe the same agent/session lifecycle, so the *event* + * payloads (what happened) and the simpler *result* shapes (handler return + * values that don't depend on subsystem-specific identifiers like + * `AgentMessage` vs `Message`) are intentionally identical. + * + * Anything that diverges between the two subsystems — UI context, runtime + * context, command context, tool-call discrimination, or return shapes that + * carry subsystem-specific message types — lives in the per-subsystem + * `types.ts` files and is documented there. + */ +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { Rule } from "../capability/rule"; +import type { CompactionPreparation, CompactionResult } from "../session/compaction"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager"; +import type { TodoItem } from "../tools/todo-write"; + +// ============================================================================ +// Session Events +// ============================================================================ + +/** Fired on initial session load */ +export interface SessionStartEvent { + type: "session_start"; +} + +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + /** Reason for the switch */ + reason: "new" | "resume" | "fork"; + /** Session file we're switching to (only for "resume") */ + targetSessionFile?: string; +} + +/** Fired after switching to another session */ +export interface SessionSwitchEvent { + type: "session_switch"; + /** Reason for the switch */ + reason: "new" | "resume" | "fork"; + /** Session file we came from */ + previousSessionFile: string | undefined; +} + +/** Fired before branching a session (can be cancelled) */ +export interface SessionBeforeBranchEvent { + type: "session_before_branch"; + /** ID of the entry to branch from */ + entryId: string; +} + +/** Fired after branching a session */ +export interface SessionBranchEvent { + type: "session_branch"; + previousSessionFile: string | undefined; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ + preparation: CompactionPreparation; + /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ + branchEntries: SessionEntry[]; + /** Optional user-provided instructions for the summary */ + customInstructions?: string; + /** Abort signal - handlers should pass this to LLM calls and check it periodically */ + signal: AbortSignal; +} + +/** Fired before compaction summarization to customize prompts/context */ +export interface SessionCompactingEvent { + type: "session.compacting"; + sessionId: string; + messages: AgentMessage[]; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + /** Whether the compaction entry was provided by an extension/hook */ + fromExtension: boolean; +} + +/** Fired on process exit (SIGINT/SIGTERM) */ +export interface SessionShutdownEvent { + type: "session_shutdown"; +} + +/** Preparation data for tree navigation (used by session_before_tree event) */ +export interface TreePreparation { + /** Node being switched to */ + targetId: string; + /** Current active leaf (being abandoned), null if no current position */ + oldLeafId: string | null; + /** Common ancestor of target and old leaf, null if no common ancestor */ + commonAncestorId: string | null; + /** Entries to summarize (old leaf back to common ancestor or compaction) */ + entriesToSummarize: SessionEntry[]; + /** Whether user chose to summarize */ + userWantsSummary: boolean; +} + +/** Fired before navigating to a different node in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + /** Preparation data for the navigation */ + preparation: TreePreparation; + /** Abort signal - honors Escape during summarization (model available via ctx.model) */ + signal: AbortSignal; +} + +/** Fired after navigating to a different node in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + /** The new active leaf, null if navigated to before first entry */ + newLeafId: string | null; + /** Previous active leaf, null if there was no position */ + oldLeafId: string | null; + /** Branch summary entry if one was created */ + summaryEntry?: BranchSummaryEntry; + /** Whether summary came from extension/hook */ + fromExtension?: boolean; +} + +/** Union of all session event types */ +export type SessionEvent = + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionSwitchEvent + | SessionBeforeBranchEvent + | SessionBranchEvent + | SessionBeforeCompactEvent + | SessionCompactingEvent + | SessionCompactEvent + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; + +// ============================================================================ +// Agent / Turn Events +// ============================================================================ + +/** + * Fired before each LLM call. + * + * Original session messages are NOT modified - only the messages sent to the + * LLM are affected when a handler returns a replacement (the return shape + * differs between extensions and hooks; see each subsystem's + * `ContextEventResult`). + */ +export interface ContextEvent { + type: "context"; + /** Messages about to be sent to the LLM (deep copy, safe to modify) */ + messages: AgentMessage[]; +} + +/** + * Fired when an agent loop starts (once per user prompt). + */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** Fired when an agent loop ends */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AgentMessage[]; +} + +/** Fired at the start of each turn */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** Fired at the end of each turn */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AgentMessage; + toolResults: ToolResultMessage[]; +} + +// ============================================================================ +// Auto-compaction / Auto-retry Events +// ============================================================================ + +/** Fired when auto-compaction starts */ +export interface AutoCompactionStartEvent { + type: "auto_compaction_start"; + reason: "threshold" | "overflow" | "idle"; + action: "context-full" | "handoff"; +} + +/** Fired when auto-compaction ends */ +export interface AutoCompactionEndEvent { + type: "auto_compaction_end"; + action: "context-full" | "handoff"; + result: CompactionResult | undefined; + aborted: boolean; + willRetry: boolean; + errorMessage?: string; + /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */ + skipped?: boolean; +} + +/** Fired when auto-retry starts */ +export interface AutoRetryStartEvent { + type: "auto_retry_start"; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; +} + +/** Fired when auto-retry ends */ +export interface AutoRetryEndEvent { + type: "auto_retry_end"; + success: boolean; + attempt: number; + finalError?: string; +} + +// ============================================================================ +// TTSR / Todo Reminders +// ============================================================================ + +/** Fired when TTSR rule matching interrupts generation */ +export interface TtsrTriggeredEvent { + type: "ttsr_triggered"; + rules: Rule[]; +} + +/** Fired when todo reminder logic detects unfinished todos */ +export interface TodoReminderEvent { + type: "todo_reminder"; + todos: TodoItem[]; + attempt: number; + maxAttempts: number; +} + +// ============================================================================ +// Shared Event Result Shapes +// ============================================================================ + +/** + * Return type for `tool_call` handlers. + * Allows handlers to block tool execution. + */ +export interface ToolCallEventResult { + /** If true, block the tool from executing */ + block?: boolean; + /** Reason for blocking (returned to LLM as error) */ + reason?: string; +} + +/** + * Return type for `tool_result` handlers. + * Allows handlers to modify tool results. + */ +export interface ToolResultEventResult { + /** Replacement content array (text and images) */ + content?: (TextContent | ImageContent)[]; + /** Replacement details */ + details?: unknown; + /** Override isError flag */ + isError?: boolean; +} + +/** Return type for `session_before_switch` handlers */ +export interface SessionBeforeSwitchResult { + /** If true, cancel the switch */ + cancel?: boolean; +} + +/** Return type for `session_before_branch` handlers */ +export interface SessionBeforeBranchResult { + /** + * If true, abort the branch entirely. No new session file is created, + * conversation stays unchanged. + */ + cancel?: boolean; + /** + * If true, the branch proceeds (new session file created, session state updated) + * but the in-memory conversation is NOT rewound to the branch point. + * + * Use case: git-checkpoint handler that restores code state separately. + * The handler handles state restoration itself, so it doesn't want the + * agent's conversation to be rewound (which would lose recent context). + * + * - `cancel: true` → nothing happens, user stays in current session + * - `skipConversationRestore: true` → branch happens, but messages stay as-is + * - neither → branch happens AND messages rewind to branch point (default) + */ + skipConversationRestore?: boolean; +} + +/** Return type for `session_before_compact` handlers */ +export interface SessionBeforeCompactResult { + /** If true, cancel the compaction */ + cancel?: boolean; + /** Custom compaction result - SessionManager adds id/parentId */ + compaction?: CompactionResult; +} + +/** Return type for `session.compacting` handlers */ +export interface SessionCompactingResult { + /** Additional context lines to include in summary */ + context?: string[]; + /** Override the default compaction prompt */ + prompt?: string; + /** Custom data to store in compaction entry */ + preserveData?: Record; +} + +/** Return type for `session_before_tree` handlers */ +export interface SessionBeforeTreeResult { + /** If true, cancel the navigation entirely */ + cancel?: boolean; + /** + * Custom summary (skips default summarizer). + * Only used if preparation.userWantsSummary is true. + */ + summary?: { + summary: string; + details?: unknown; + }; +} diff --git a/packages/coding-agent/src/hindsight/mental-models.ts b/packages/coding-agent/src/hindsight/mental-models.ts index 51fd615d4..294fb5fab 100644 --- a/packages/coding-agent/src/hindsight/mental-models.ts +++ b/packages/coding-agent/src/hindsight/mental-models.ts @@ -379,4 +379,4 @@ export const MENTAL_MODEL_FIRST_TURN_DEADLINE_MS = 1500; export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000; /** Need-only export of the raw seed list for tests. */ -export const __builtinSeedsForTest: ReadonlyArray> = BUILTIN_SEEDS; +export const builtinSeedsForTest: ReadonlyArray> = BUILTIN_SEEDS; diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index bd3f2df0b..197f2d931 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -14,29 +14,10 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { AgentRegistry } from "../registry/agent-registry"; import { applyQuery, pathToQuery } from "./json-query"; +import { artifactsDirsFromRegistry } from "./registry-helpers"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; -/** - * Snapshot of artifacts dirs for every registered session, deduped. - * - * Prefers `sessionManager.getArtifactsDir()` because subagents adopt the - * parent's manager and report the parent's dir there; dedup then collapses - * the whole agent tree to one entry. Falls back to the raw session file - * when no live session reference is attached. - */ -function artifactsDirsFromRegistry(): string[] { - const dirs: string[] = []; - for (const ref of AgentRegistry.global().list()) { - const dir = - ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); - if (!dir) continue; - if (!dirs.includes(dir)) dirs.push(dir); - } - return dirs; -} - /** * Handler for agent:// URLs. * diff --git a/packages/coding-agent/src/internal-urls/artifact-protocol.ts b/packages/coding-agent/src/internal-urls/artifact-protocol.ts index 844387b25..61c2dee75 100644 --- a/packages/coding-agent/src/internal-urls/artifact-protocol.ts +++ b/packages/coding-agent/src/internal-urls/artifact-protocol.ts @@ -12,27 +12,9 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { AgentRegistry } from "../registry/agent-registry"; +import { artifactsDirsFromRegistry } from "./registry-helpers"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; -/** - * Snapshot of artifacts dirs across all registered sessions, deduped. - * - * Subagents adopt their parent's `ArtifactManager`, so their - * `sessionManager.getArtifactsDir()` returns the parent's dir; dedup - * collapses parent + N subagents to a single entry. - */ -function artifactsDirsFromRegistry(): string[] { - const dirs: string[] = []; - for (const ref of AgentRegistry.global().list()) { - const dir = - ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); - if (!dir) continue; - if (!dirs.includes(dir)) dirs.push(dir); - } - return dirs; -} - export class ArtifactProtocolHandler implements ProtocolHandler { readonly scheme = "artifact"; readonly immutable = true; diff --git a/packages/coding-agent/src/internal-urls/registry-helpers.ts b/packages/coding-agent/src/internal-urls/registry-helpers.ts new file mode 100644 index 000000000..2c8c5181e --- /dev/null +++ b/packages/coding-agent/src/internal-urls/registry-helpers.ts @@ -0,0 +1,25 @@ +/** + * Shared helpers for internal-url protocol handlers that resolve IDs against + * registered agent sessions. + */ +import { AgentRegistry } from "../registry/agent-registry"; + +/** + * Snapshot of artifacts dirs for every registered session, deduped. + * + * Prefers `sessionManager.getArtifactsDir()` because subagents adopt their + * parent's `ArtifactManager` and report the parent's dir there; dedup then + * collapses parent + N subagents (the whole agent tree) to one entry. Falls + * back to the raw session file (with the `.jsonl` suffix stripped) when no + * live session reference is attached. + */ +export function artifactsDirsFromRegistry(): string[] { + const dirs: string[] = []; + for (const ref of AgentRegistry.global().list()) { + const dir = + ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null); + if (!dir) continue; + if (!dirs.includes(dir)) dirs.push(dir); + } + return dirs; +} diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index d7a737ba6..3eacb120f 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -7,19 +7,23 @@ import { Container, Ellipsis, ImageProtocol, - Loader, - Spacer, + type Loader, TERMINAL, Text, type TUI, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; -import { getSymbolTheme, theme } from "../../modes/theme/theme"; -import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; +import { theme } from "../../modes/theme/theme"; +import type { TruncationMeta } from "../../tools/output-meta"; import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; -import { DynamicBorder } from "./dynamic-border"; -import { truncateToVisualLines } from "./visual-truncate"; +import { + buildExecutionFrame, + buildStatusFooter, + createCollapsedPreview, + type ExecutionStatus, + resolveExecutionStatus, +} from "./execution-shared"; // Preview line limit when not expanded (matches tool execution behavior) const PREVIEW_LINES = 20; @@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50; export class BashExecutionComponent extends Container { #outputLines: string[] = []; - #status: "running" | "complete" | "cancelled" | "error" = "running"; + #status: ExecutionStatus = "running"; #exitCode: number | undefined = undefined; #loader: Loader; #truncation?: TruncationMeta; @@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container { // Use dim border for excluded-from-context commands (!! prefix) const colorKey = excludeFromContext ? "dim" : "bashMode"; - const borderColor = (str: string) => theme.fg(colorKey, str); - - // Add spacer - this.addChild(new Spacer(1)); - - // Top border - this.addChild(new DynamicBorder(borderColor)); - - // Content container (holds dynamic content between borders) - this.#contentContainer = new Container(); - this.addChild(this.#contentContainer); + const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey); + this.#contentContainer = contentContainer; + this.#loader = loader; // Command header this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.#contentContainer.addChild(this.#headerText); - - // Loader - this.#loader = new Loader( - ui, - spinner => theme.fg(colorKey, spinner), - text => theme.fg("muted", text), - `Running… (esc to cancel)`, - getSymbolTheme().spinnerFrames, - ); this.#contentContainer.addChild(this.#loader); - - // Bottom border - this.addChild(new DynamicBorder(borderColor)); } /** @@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container { options?: { output?: string; truncation?: TruncationMeta }, ): void { this.#exitCode = exitCode; - this.#status = cancelled - ? "cancelled" - : exitCode !== 0 && exitCode !== undefined && exitCode !== null - ? "error" - : "complete"; + this.#status = resolveExecutionStatus(exitCode, cancelled); this.#truncation = options?.truncation; if (options?.output !== undefined) { this.#setOutput(options.output); @@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container { } else { // Use shared visual truncation utility, recomputed per render width const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); - const previewText = `\n${styledOutput}`; - this.#contentContainer.addChild({ - render: (width: number) => { - const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); - return visualLines; - }, - invalidate: () => {}, - }); + this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES)); } } @@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container { if (this.#status === "running") { this.#contentContainer.addChild(this.#loader); } else { - const statusParts: string[] = []; - - // Show how many lines are hidden (collapsed preview) - if (hiddenLineCount > 0 && !hasSixelOutput) { - statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); - } - - if (this.#status === "cancelled") { - statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.#status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); - } - - if (this.#truncation) { - statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation))); - } - - if (statusParts.length > 0) { - this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); - } + const footer = buildStatusFooter({ + status: this.#status, + exitCode: this.#exitCode, + truncation: this.#truncation, + hiddenLineCount, + suppressHiddenCount: hasSixelOutput, + }); + if (footer) this.#contentContainer.addChild(footer); } } diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 99529345e..1dcd8aa62 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -1,9 +1,9 @@ -import type { TextContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Box, Container, Spacer } from "@oh-my-pi/pi-tui"; import type { MessageRenderer } from "../../extensibility/extensions/types"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { theme } from "../../modes/theme/theme"; import type { CustomMessage } from "../../session/messages"; +import { renderFramedMessage } from "./message-frame"; /** * Component that renders a custom message entry from extensions. @@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container { } #rebuild(): void { - // Remove previous content component if (this.#customComponent) { this.removeChild(this.#customComponent); this.#customComponent = undefined; } this.removeChild(this.#box); - // Try custom renderer first - it handles its own styling - if (this.customRenderer) { - try { - const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); - if (component) { - this.#customComponent = component; - this.addChild(component); - return; - } - } catch { - // Fall through to default rendering - } - } + const custom = renderFramedMessage({ + message: this.message, + box: this.#box, + expanded: this.#expanded, + customRenderer: this.customRenderer, + // Extension messages render full content; no collapse-on-fold behaviour. + }); - // Default rendering uses our box - this.addChild(this.#box); - this.#box.clear(); - - // Default rendering: label + content - const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); - - // Extract text content - let text: string; - if (typeof this.message.content === "string") { - text = this.message.content; + if (custom) { + this.#customComponent = custom; + this.addChild(custom); } else { - text = this.message.content - .filter((c): c is TextContent => c.type === "text") - .map(c => c.text) - .join("\n"); + this.addChild(this.#box); } - - this.#box.addChild( - new Markdown(text, 0, 0, getMarkdownTheme(), { - color: (value: string) => theme.fg("customMessageText", value), - }), - ); } } diff --git a/packages/coding-agent/src/modes/components/eval-execution.ts b/packages/coding-agent/src/modes/components/eval-execution.ts index a42021d11..2e12a052a 100644 --- a/packages/coding-agent/src/modes/components/eval-execution.ts +++ b/packages/coding-agent/src/modes/components/eval-execution.ts @@ -4,11 +4,17 @@ */ import { sanitizeText } from "@oh-my-pi/pi-natives"; -import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme"; -import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; -import { DynamicBorder } from "./dynamic-border"; -import { truncateToVisualLines } from "./visual-truncate"; +import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { highlightCode, theme } from "../../modes/theme/theme"; +import type { TruncationMeta } from "../../tools/output-meta"; +import { + buildExecutionFrame, + buildStatusFooter, + createCollapsedPreview, + type ExecutionColorKey, + type ExecutionStatus, + resolveExecutionStatus, +} from "./execution-shared"; const PREVIEW_LINES = 20; const MAX_DISPLAY_LINE_CHARS = 4000; @@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js"; export class EvalExecutionComponent extends Container { #outputLines: string[] = []; - #status: "running" | "complete" | "cancelled" | "error" = "running"; + #status: ExecutionStatus = "running"; #exitCode: number | undefined = undefined; #loader: Loader; #truncation?: TruncationMeta; @@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container { return this.language === "js" ? "javascript" : "python"; } - #formatHeader(colorKey: "dim" | "pythonMode"): Text { + #formatHeader(colorKey: ExecutionColorKey): Text { const prompt = theme.fg(colorKey, theme.bold(">>>")); const continuation = theme.fg(colorKey, " "); const codeLines = highlightCode(this.code, this.#highlightLang()); @@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container { ) { super(); - const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; - const borderColor = (str: string) => theme.fg(colorKey, str); + const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode"; + const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey); + this.#contentContainer = contentContainer; + this.#loader = loader; - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder(borderColor)); - - this.#contentContainer = new Container(); - this.addChild(this.#contentContainer); this.#contentContainer.addChild(this.#formatHeader(colorKey)); - - this.#loader = new Loader( - ui, - spinner => theme.fg(colorKey, spinner), - text => theme.fg("muted", text), - `Running… (esc to cancel)`, - getSymbolTheme().spinnerFrames, - ); this.#contentContainer.addChild(this.#loader); - - this.addChild(new DynamicBorder(borderColor)); } setExpanded(expanded: boolean): void { @@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container { options?: { output?: string; truncation?: TruncationMeta }, ): void { this.#exitCode = exitCode; - this.#status = cancelled - ? "cancelled" - : exitCode !== 0 && exitCode !== undefined && exitCode !== null - ? "error" - : "complete"; + this.#status = resolveExecutionStatus(exitCode, cancelled); this.#truncation = options?.truncation; if (options?.output !== undefined) { this.#setOutput(options.output); @@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container { this.#contentContainer.clear(); - const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; + const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode"; this.#contentContainer.addChild(this.#formatHeader(colorKey)); if (availableLines.length > 0) { @@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container { this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); - const previewText = `\n${styledOutput}`; - this.#contentContainer.addChild({ - render: (width: number) => { - const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); - return visualLines; - }, - invalidate: () => {}, - }); + this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES)); } } if (this.#status === "running") { this.#contentContainer.addChild(this.#loader); } else { - const statusParts: string[] = []; - - if (hiddenLineCount > 0) { - statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); - } - - if (this.#status === "cancelled") { - statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.#status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); - } - - if (this.#truncation) { - statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation))); - } - - if (statusParts.length > 0) { - this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); - } + const footer = buildStatusFooter({ + status: this.#status, + exitCode: this.#exitCode, + truncation: this.#truncation, + hiddenLineCount, + }); + if (footer) this.#contentContainer.addChild(footer); } } diff --git a/packages/coding-agent/src/modes/components/execution-shared.ts b/packages/coding-agent/src/modes/components/execution-shared.ts new file mode 100644 index 000000000..0ad34a780 --- /dev/null +++ b/packages/coding-agent/src/modes/components/execution-shared.ts @@ -0,0 +1,102 @@ +/** + * Shared rendering primitives for bash/eval execution components. + * + * Each helper isolates a piece of structure both components share verbatim + * (frame layout, collapsed preview, post-run status line). Differences in + * how each component prepares its header, output lines, or sixel masking + * stay in their respective files. + */ + +import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { getSymbolTheme, theme } from "../../modes/theme/theme"; +import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; +import { DynamicBorder } from "./dynamic-border"; +import { truncateToVisualLines } from "./visual-truncate"; + +export type ExecutionStatus = "running" | "complete" | "cancelled" | "error"; + +/** Theme color keys valid for an execution frame. */ +export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode"; + +/** + * Build the spacer + top border + content container + bottom border scaffold + * that bash and eval execution components share. The caller appends the + * header (command vs `>>>` prompt) and the returned loader to + * `contentContainer` so per-mode order is preserved. + */ +export function buildExecutionFrame( + parent: Container, + ui: TUI, + colorKey: ExecutionColorKey, +): { contentContainer: Container; loader: Loader } { + const borderColor = (str: string) => theme.fg(colorKey, str); + + parent.addChild(new Spacer(1)); + parent.addChild(new DynamicBorder(borderColor)); + + const contentContainer = new Container(); + parent.addChild(contentContainer); + + const loader = new Loader( + ui, + spinner => theme.fg(colorKey, spinner), + text => theme.fg("muted", text), + `Running… (esc to cancel)`, + getSymbolTheme().spinnerFrames, + ); + + parent.addChild(new DynamicBorder(borderColor)); + return { contentContainer, loader }; +} + +/** + * Wrap a styled preview block in a render-time visual-line truncator. + * Recomputed per render width so wrapping stays in sync with terminal size. + */ +export function createCollapsedPreview(previewText: string, previewLines: number): Component { + return { + render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines, + invalidate: () => {}, + }; +} + +/** + * Build the post-run status block (hidden-line hint, exit/cancel marker, + * truncation notice). Returns undefined when there is nothing to display so + * callers can skip appending a stray Text child. + */ +export function buildStatusFooter(opts: { + status: ExecutionStatus; + exitCode: number | undefined; + truncation: TruncationMeta | undefined; + hiddenLineCount: number; + /** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */ + suppressHiddenCount?: boolean; +}): Text | undefined { + const parts: string[] = []; + + if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) { + parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`)); + } + if (opts.status === "cancelled") { + parts.push(theme.fg("warning", "(cancelled)")); + } else if (opts.status === "error") { + parts.push(theme.fg("error", `(exit ${opts.exitCode})`)); + } + if (opts.truncation) { + parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation))); + } + + if (parts.length === 0) return undefined; + return new Text(`\n${parts.join("\n")}`, 1, 0); +} + +/** + * Derive the post-run status from an exit code + cancellation flag using the + * same precedence both execution components apply. + */ +export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus { + if (cancelled) return "cancelled"; + if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error"; + return "complete"; +} diff --git a/packages/coding-agent/src/modes/components/hook-message.ts b/packages/coding-agent/src/modes/components/hook-message.ts index 14f4ca9a1..dc19d306c 100644 --- a/packages/coding-agent/src/modes/components/hook-message.ts +++ b/packages/coding-agent/src/modes/components/hook-message.ts @@ -1,9 +1,12 @@ -import type { TextContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Box, Container, Spacer } from "@oh-my-pi/pi-tui"; import type { HookMessageRenderer } from "../../extensibility/hooks/types"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { theme } from "../../modes/theme/theme"; import type { HookMessage } from "../../session/messages"; +import { renderFramedMessage } from "./message-frame"; + +/** Lines of default markdown body shown before the "…" fold when collapsed. */ +const HOOK_COLLAPSED_LINES = 5; /** * Component that renders a custom message entry from hooks. @@ -41,60 +44,25 @@ export class HookMessageComponent extends Container { } #rebuild(): void { - // Remove previous content component if (this.#customComponent) { this.removeChild(this.#customComponent); this.#customComponent = undefined; } this.removeChild(this.#box); - // Try custom renderer first - it handles its own styling - if (this.customRenderer) { - try { - const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); - if (component) { - // Custom renderer provides its own styled component - this.#customComponent = component; - this.addChild(component); - return; - } - } catch { - // Fall through to default rendering - } - } + const custom = renderFramedMessage({ + message: this.message, + box: this.#box, + expanded: this.#expanded, + customRenderer: this.customRenderer, + collapseAfterLines: HOOK_COLLAPSED_LINES, + }); - // Default rendering uses our box - this.addChild(this.#box); - this.#box.clear(); - - // Default rendering: label + content - const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); - - // Extract text content - let text: string; - if (typeof this.message.content === "string") { - text = this.message.content; + if (custom) { + this.#customComponent = custom; + this.addChild(custom); } else { - text = this.message.content - .filter((c): c is TextContent => c.type === "text") - .map(c => c.text) - .join("\n"); + this.addChild(this.#box); } - - // Limit lines when collapsed - if (!this.#expanded) { - const lines = text.split("\n"); - if (lines.length > 5) { - text = `${lines.slice(0, 5).join("\n")}\n…`; - } - } - - this.#box.addChild( - new Markdown(text, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("customMessageText", text), - }), - ); } } diff --git a/packages/coding-agent/src/modes/components/message-frame.ts b/packages/coding-agent/src/modes/components/message-frame.ts new file mode 100644 index 000000000..57157cfe4 --- /dev/null +++ b/packages/coding-agent/src/modes/components/message-frame.ts @@ -0,0 +1,88 @@ +/** + * Shared rendering for extension/hook custom message frames. + * + * Both `CustomMessageComponent` and `HookMessageComponent` wrap a + * `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall + * back to a label + markdown body when the renderer returns nothing or + * throws. The only meaningful difference is that hook messages collapse to + * the first N lines when not expanded; extension messages render in full. + */ + +import type { TextContent } from "@oh-my-pi/pi-ai"; +import type { Box, Component } from "@oh-my-pi/pi-tui"; +import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme"; + +/** Message shape consumed by the shared frame. */ +export interface FramedMessage { + customType: string; + content: string | (TextContent | { type: string })[]; +} + +/** + * Callable signature shared by `MessageRenderer` (extensions) and + * `HookMessageRenderer` (hooks). Both narrow `message` to their own type; + * this signature is the structural intersection callers can hand off here. + */ +export type FramedRenderer = ( + message: M, + options: { expanded: boolean }, + theme: Theme, +) => Component | undefined; + +export interface RebuildFrameOptions { + message: M; + box: Box; + expanded: boolean; + /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */ + collapseAfterLines?: number; + customRenderer?: FramedRenderer; +} + +/** + * Attempt the custom renderer; on failure or undefined return, populate + * `box` with the default `[customType]` label + markdown body and return + * undefined. When the custom renderer succeeds, return its Component so the + * caller can mount it and skip the default box. + */ +export function renderFramedMessage(opts: RebuildFrameOptions): Component | undefined { + if (opts.customRenderer) { + try { + const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme); + if (component) return component; + } catch { + // Fall through to default rendering + } + } + + opts.box.clear(); + + const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`)); + opts.box.addChild(new Text(label, 0, 0)); + opts.box.addChild(new Spacer(1)); + + let text: string; + if (typeof opts.message.content === "string") { + text = opts.message.content; + } else { + text = opts.message.content + .filter((c): c is TextContent => c.type === "text") + .map(c => c.text) + .join("\n"); + } + + if (!opts.expanded && opts.collapseAfterLines !== undefined) { + const lines = text.split("\n"); + if (lines.length > opts.collapseAfterLines) { + text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`; + } + } + + opts.box.addChild( + new Markdown(text, 0, 0, getMarkdownTheme(), { + color: (value: string) => theme.fg("customMessageText", value), + }), + ); + + return undefined; +} diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index fb04675d5..24dce7900 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; import { Container, + fuzzyFilter, getKeybindings, Input, matchesKey, @@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { getThinkingLevelMetadata } from "../../thinking"; -import { fuzzyFilter } from "../../utils/fuzzy"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 17805fd9e..759af5784 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -1,6 +1,7 @@ import { type Component, Container, + fuzzyFilter, Input, matchesKey, padding, @@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { SessionInfo } from "../../session/session-manager"; -import { fuzzyFilter } from "../../utils/fuzzy"; import { DynamicBorder } from "./dynamic-border"; import { HookSelectorComponent } from "./hook-selector"; diff --git a/packages/coding-agent/src/modes/controllers/command-controller-shared.ts b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts new file mode 100644 index 000000000..ba119d512 --- /dev/null +++ b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts @@ -0,0 +1,108 @@ +/** + * Shared helpers for /mcp and /ssh command controllers. + * + * Captures argument parsing, source grouping, and chat-message rendering that + * was duplicated between mcp-command-controller and ssh-command-controller. + * Intentionally kept narrow: subcommand routing, help text, success/error + * wording, and add-flow logic stay in the per-controller files because they + * diverge in workflow. + */ +import { Spacer, Text } from "@oh-my-pi/pi-tui"; +import type { SourceMeta } from "../../capability/types"; +import { shortenPath } from "../../tools/render-utils"; +import { DynamicBorder } from "../components/dynamic-border"; +import { parseCommandArgs } from "../shared"; +import type { InteractiveModeContext } from "../types"; + +export type ScopeValue = "project" | "user"; + +export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string }; + +/** + * Validate the value following a `--scope` flag. + */ +export function readScopeFlag(value: string | undefined): ScopeFlagResult { + if (!value || (value !== "project" && value !== "user")) { + return { ok: false, error: "Invalid --scope value. Use project or user." }; + } + return { ok: true, scope: value }; +} + +export type RemoveArgs = { name: string | undefined; scope: ScopeValue }; + +export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string }; + +/** + * Parse the argument tail of `/ remove [--scope project|user]`. + * + * `rest` is the text after the subcommand keyword. The caller is responsible + * for emitting the command-specific " name required" usage hint when + * `value.name` is undefined. + */ +export function parseRemoveArgs(rest: string): ParseRemoveResult { + const tokens = parseCommandArgs(rest); + + let name: string | undefined; + let scope: ScopeValue = "project"; + let i = 0; + + if (tokens.length > 0 && !tokens[0].startsWith("-")) { + name = tokens[0]; + i = 1; + } + + while (i < tokens.length) { + const token = tokens[i]; + if (token === "--scope") { + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) return { ok: false, error: r.error }; + scope = r.scope; + i += 2; + continue; + } + return { ok: false, error: `Unknown option: ${token}` }; + } + + return { ok: true, value: { name, scope } }; +} + +/** + * Group capability-loaded items by their source provider+path, yielding each + * group with a display-ready `shortPath`. + */ +export function* groupBySource( + items: Iterable, + getSource: (item: T) => SourceMeta, +): Iterable<{ providerName: string; shortPath: string; items: T[] }> { + const groups = new Map(); + for (const item of items) { + const src = getSource(item); + const key = `${src.providerName}|${src.path}`; + let group = groups.get(key); + if (!group) { + group = []; + groups.set(key, group); + } + group.push(item); + } + for (const [key, grouped] of groups) { + const sepIdx = key.indexOf("|"); + yield { + providerName: key.slice(0, sepIdx), + shortPath: shortenPath(key.slice(sepIdx + 1)), + items: grouped, + }; + } +} + +/** + * Render a message block (DynamicBorder / Text / DynamicBorder) into the chat + * container and request a render. + */ +export function showCommandMessage(ctx: InteractiveModeContext, text: string): void { + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.chatContainer.addChild(new Text(text, 1, 1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.ui.requestRender(); +} diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index dc28c7248..6929a52e9 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../ import type { OAuthCredential } from "../../session/auth-storage"; import { shortenPath } from "../../tools/render-utils"; import { openPath } from "../../utils/open"; -import { DynamicBorder } from "../components/dynamic-border"; import { MCPAddWizard } from "../components/mcp-add-wizard"; import { parseCommandArgs } from "../shared"; import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; +import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared"; function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { const { promise: timeoutPromise, reject } = Promise.withResolvers(); @@ -207,11 +207,11 @@ export class MCPCommandController { break; } if (argToken === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - return { scope, error: "Invalid --scope value. Use project or user." }; + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) { + return { scope, error: r.error }; } - scope = value; + scope = r.scope; i += 2; continue; } @@ -984,23 +984,7 @@ export class MCPCommandController { // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.) if (discoveredServers.length > 0) { - // Group by source display name + path - const bySource = new Map(); - for (const entry of discoveredServers) { - const key = `${entry.source.providerName}|${entry.source.path}`; - let group = bySource.get(key); - if (!group) { - group = []; - bySource.set(key, group); - } - group.push(entry); - } - - for (const [key, entries] of bySource) { - const sepIdx = key.indexOf("|"); - const providerName = key.slice(0, sepIdx); - const sourcePath = key.slice(sepIdx + 1); - const shortPath = shortenPath(sourcePath); + for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) { lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`)); for (const { name } of entries) { const state = this.ctx.mcpManager!.getConnectionStatus(name); @@ -1037,32 +1021,12 @@ export class MCPCommandController { async #handleRemove(text: string): Promise { const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i); const rest = match?.[1]?.trim() ?? ""; - const tokens = parseCommandArgs(rest); - - let name: string | undefined; - let scope: "project" | "user" = "project"; - let i = 0; - - if (tokens.length > 0 && !tokens[0].startsWith("-")) { - name = tokens[0]; - i = 1; - } - - while (i < tokens.length) { - const token = tokens[i]; - if (token === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); - return; - } - scope = value; - i += 2; - continue; - } - this.ctx.showError(`Unknown option: ${token}`); + const parsed = parseRemoveArgs(rest); + if (!parsed.ok) { + this.ctx.showError(parsed.error); return; } + const { name, scope } = parsed.value; if (!name) { this.ctx.showError("Server name required. Usage: /mcp remove [--scope project|user]"); @@ -1929,10 +1893,6 @@ export class MCPCommandController { * Show a message in the chat */ #showMessage(text: string): void { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.chatContainer.addChild(new Text(text, 1, 1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.ui.requestRender(); + showCommandMessage(this.ctx, text); } } diff --git a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts index 59ea119c2..b9f6b0d8c 100644 --- a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -3,18 +3,20 @@ * * Handles /ssh subcommands for managing SSH host configurations. */ -import { Spacer, Text } from "@oh-my-pi/pi-tui"; import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils"; import { type SSHHost, sshCapability } from "../../capability/ssh"; import { loadCapability } from "../../discovery"; import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; -import { shortenPath } from "../../tools/render-utils"; -import { DynamicBorder } from "../components/dynamic-border"; import { parseCommandArgs } from "../shared"; import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; - -type SSHAddScope = "user" | "project"; +import { + groupBySource, + parseRemoveArgs, + readScopeFlag, + type ScopeValue, + showCommandMessage, +} from "./command-controller-shared"; export class SSHCommandController { constructor(private ctx: InteractiveModeContext) {} @@ -90,7 +92,7 @@ export class SSHCommandController { } let name: string | undefined; - let scope: SSHAddScope = "project"; + let scope: ScopeValue = "project"; let host: string | undefined; let username: string | undefined; let port: number | undefined; @@ -167,12 +169,12 @@ export class SSHCommandController { continue; } if (argToken === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); + const r = readScopeFlag(tokens[i + 1]); + if (!r.ok) { + this.ctx.showError(r.error); return; } - scope = value; + scope = r.scope; i += 2; continue; } @@ -300,23 +302,7 @@ export class SSHCommandController { // Show discovered hosts (from ssh.json, .ssh.json in project root, etc.) if (discoveredHosts.length > 0) { - // Group by source - const bySource = new Map(); - for (const host of discoveredHosts) { - const key = `${host._source.providerName}|${host._source.path}`; - let group = bySource.get(key); - if (!group) { - group = []; - bySource.set(key, group); - } - group.push(host); - } - - for (const [key, hosts] of bySource) { - const sepIdx = key.indexOf("|"); - const providerName = key.slice(0, sepIdx); - const sourcePath = key.slice(sepIdx + 1); - const shortPath = shortenPath(sourcePath); + for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) { lines.push( theme.fg("accent", "Discovered") + theme.fg("muted", ` (${providerName}: ${shortPath}):`) + @@ -357,33 +343,12 @@ export class SSHCommandController { async #handleRemove(text: string): Promise { const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i); const rest = match?.[1]?.trim() ?? ""; - const tokens = parseCommandArgs(rest); - - let name: string | undefined; - let scope: "project" | "user" = "project"; - let i = 0; - - if (tokens.length > 0 && !tokens[0].startsWith("-")) { - name = tokens[0]; - i = 1; - } - - while (i < tokens.length) { - const token = tokens[i]; - if (token === "--scope") { - const value = tokens[i + 1]; - if (!value || (value !== "project" && value !== "user")) { - this.ctx.showError("Invalid --scope value. Use project or user."); - return; - } - scope = value; - i += 2; - continue; - } - this.ctx.showError(`Unknown option: ${token}`); + const parsed = parseRemoveArgs(rest); + if (!parsed.ok) { + this.ctx.showError(parsed.error); return; } - + const { name, scope } = parsed.value; if (!name) { this.ctx.showError("Host name required. Usage: /ssh remove [--scope project|user]"); return; @@ -412,10 +377,6 @@ export class SSHCommandController { * Show a message in the chat */ #showMessage(text: string): void { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.chatContainer.addChild(new Text(text, 1, 1)); - this.ctx.chatContainer.addChild(new DynamicBorder()); - this.ctx.ui.requestRender(); + showCommandMessage(this.ctx, text); } } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index ce693216b..460d5e86c 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -7,8 +7,8 @@ */ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import { sanitizeText } from "@oh-my-pi/pi-natives"; -import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; import type { AgentSession } from "../session/agent-session"; +import { initializeExtensions } from "./runtime-init"; /** * Options for print mode. @@ -39,90 +39,16 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } } // Set up extensions for print mode (no UI, no command context) - const extensionRunner = session.extensionRunner; - if (extensionRunner) { - extensionRunner.initialize( - // ExtensionActions - { - sendMessage: (message, options) => { - session.sendCustomMessage(message, options).catch(e => { - process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`); - }); - }, - sendUserMessage: (content, options) => { - session.sendUserMessage(content, options).catch(e => { - process.stderr.write( - `Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`, - ); - }); - }, - appendEntry: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }, - setLabel: (targetId, label) => { - session.sessionManager.appendLabelChange(targetId, label); - }, - getActiveTools: () => session.getActiveToolNames(), - getAllTools: () => session.getAllToolNames(), - setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - getCommands: () => [], - setModel: model => runExtensionSetModel(session, model), - getThinkingLevel: () => session.thinkingLevel, - setThinkingLevel: level => session.setThinkingLevel(level), - getSessionName: () => session.sessionManager.getSessionName(), - setSessionName: async name => { - await session.sessionManager.setSessionName(name, "user"); - }, - }, - // ExtensionContextActions - { - getModel: () => session.model, - isIdle: () => !session.isStreaming, - abort: () => session.abort(), - hasPendingMessages: () => session.queuedMessageCount > 0, - shutdown: () => {}, - getContextUsage: () => session.getContextUsage(), - getSystemPrompt: () => session.systemPrompt, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // ExtensionCommandContextActions - commands invokable via prompt("/command") - { - getContextUsage: () => session.getContextUsage(), - waitForIdle: () => session.agent.waitForIdle(), - newSession: async options => { - const success = await session.newSession({ parentSession: options?.parentSession }); - if (success && options?.setup) { - await options.setup(session.sessionManager); - } - return { cancelled: !success }; - }, - branch: async entryId => { - const result = await session.branch(entryId); - return { cancelled: result.cancelled }; - }, - navigateTree: async (targetId, options) => { - const result = await session.navigateTree(targetId, { summarize: options?.summarize }); - return { cancelled: result.cancelled }; - }, - switchSession: async sessionPath => { - const success = await session.switchSession(sessionPath); - return { cancelled: !success }; - }, - reload: async () => { - await session.reload(); - }, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // No UI context - ); - extensionRunner.onError(err => { + await initializeExtensions(session, { + reportSendError: (action, err) => { + process.stderr.write( + `Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`, + ); + }, + reportRuntimeError: err => { process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`); - }); - // Emit session_start event - await extensionRunner.emit({ - type: "session_start", - }); - } + }, + }); // Always subscribe to enable session persistence via _handleAgentEvent session.subscribe(event => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f25a63454..a02886066 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -17,9 +17,9 @@ import type { ExtensionUIDialogOptions, ExtensionWidgetOptions, } from "../../extensibility/extensions"; -import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler"; import { type Theme, theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; +import { initializeExtensions } from "../runtime-init"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; import type { RpcCommand, @@ -421,91 +421,18 @@ export async function runRpcMode( setToolUIContext?.(rpcUiContext, true); // Set up extensions with RPC-based UI context - const extensionRunner = session.extensionRunner; - if (extensionRunner) { - extensionRunner.initialize( - // ExtensionActions - { - sendMessage: (message, options) => { - session.sendCustomMessage(message, options).catch(e => { - output(error(undefined, "extension_send", e.message)); - }); - }, - sendUserMessage: (content, options) => { - session.sendUserMessage(content, options).catch(e => { - output(error(undefined, "extension_send_user", e.message)); - }); - }, - appendEntry: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }, - setLabel: (targetId, label) => { - session.sessionManager.appendLabelChange(targetId, label); - }, - getActiveTools: () => session.getActiveToolNames(), - getAllTools: () => session.getAllToolNames(), - setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - getCommands: () => [], - setModel: model => runExtensionSetModel(session, model), - getThinkingLevel: () => session.thinkingLevel, - setThinkingLevel: level => session.setThinkingLevel(level), - getSessionName: () => session.sessionManager.getSessionName(), - setSessionName: async name => { - await session.sessionManager.setSessionName(name, "user"); - }, - }, - // ExtensionContextActions - { - getModel: () => session.agent.state.model, - isIdle: () => !session.isStreaming, - abort: () => session.abort(), - hasPendingMessages: () => session.queuedMessageCount > 0, - shutdown: () => { - shutdownState.requested = true; - }, - getContextUsage: () => session.getContextUsage(), - getSystemPrompt: () => session.systemPrompt, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - // ExtensionCommandContextActions - commands invokable via prompt("/command") - { - getContextUsage: () => session.getContextUsage(), - waitForIdle: () => session.agent.waitForIdle(), - newSession: async options => { - const success = await session.newSession({ parentSession: options?.parentSession }); - // Note: setup callback runs but no UI feedback in RPC mode - if (success && options?.setup) { - await options.setup(session.sessionManager); - } - return { cancelled: !success }; - }, - branch: async entryId => { - const result = await session.branch(entryId); - return { cancelled: result.cancelled }; - }, - navigateTree: async (targetId, options) => { - const result = await session.navigateTree(targetId, { summarize: options?.summarize }); - return { cancelled: result.cancelled }; - }, - switchSession: async sessionPath => { - const success = await session.switchSession(sessionPath); - return { cancelled: !success }; - }, - reload: async () => { - await session.reload(); - }, - compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), - }, - rpcUiContext, - ); - extensionRunner.onError(err => { + await initializeExtensions(session, { + reportSendError: (action, err) => { + output(error(undefined, action, err.message)); + }, + reportRuntimeError: err => { output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); - }); - // Emit session_start event - await extensionRunner.emit({ - type: "session_start", - }); - } + }, + onShutdown: () => { + shutdownState.requested = true; + }, + uiContext: rpcUiContext, + }); // Output all agent events as JSON session.subscribe(event => { @@ -850,8 +777,8 @@ export async function runRpcMode( async function checkShutdownRequested(): Promise { if (!shutdownState.requested) return; - if (extensionRunner?.hasHandlers("session_shutdown")) { - await extensionRunner.emit({ type: "session_shutdown" }); + if (session.extensionRunner?.hasHandlers("session_shutdown")) { + await session.extensionRunner.emit({ type: "session_shutdown" }); } process.exit(0); diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts new file mode 100644 index 000000000..426265770 --- /dev/null +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -0,0 +1,115 @@ +/** + * Shared extension runtime wiring for print and RPC modes. + * + * Both modes initialize the extension runner with the same action handlers + * that delegate to the {@link AgentSession}. Only error reporting, shutdown + * behavior, and UI context differ between callers — those stay as + * caller-supplied hooks. + */ +import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; +import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types"; +import type { AgentSession } from "../session/agent-session"; + +/** Action name for an extension-originated send failure. */ +export type ExtensionSendAction = "extension_send" | "extension_send_user"; + +export interface InitializeExtensionsOptions { + /** Reports an error thrown by an extension-initiated send. */ + reportSendError: (action: ExtensionSendAction, error: Error) => void; + /** Reports a runtime error surfaced through {@link ExtensionRunner.onError}. */ + reportRuntimeError: (error: ExtensionError) => void; + /** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */ + onShutdown?: () => void; + /** Optional UI context (rpc supplies one; print runs headless). */ + uiContext?: ExtensionUIContext; +} + +/** + * Initialize the session's extension runner with the standard action set + * shared by non-interactive modes, then emit `session_start`. + * + * No-op when the session was constructed without an extension runner. + */ +export async function initializeExtensions(session: AgentSession, options: InitializeExtensionsOptions): Promise { + const runner = session.extensionRunner; + if (!runner) return; + + const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options; + const shutdown = onShutdown ?? (() => {}); + + runner.initialize( + // ExtensionActions + { + sendMessage: (message, sendOptions) => { + session.sendCustomMessage(message, sendOptions).catch(e => { + reportSendError("extension_send", e instanceof Error ? e : new Error(String(e))); + }); + }, + sendUserMessage: (content, sendOptions) => { + session.sendUserMessage(content, sendOptions).catch(e => { + reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e))); + }); + }, + appendEntry: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + setLabel: (targetId, label) => { + session.sessionManager.appendLabelChange(targetId, label); + }, + getActiveTools: () => session.getActiveToolNames(), + getAllTools: () => session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + getCommands: () => [], + setModel: model => runExtensionSetModel(session, model), + getThinkingLevel: () => session.thinkingLevel, + setThinkingLevel: level => session.setThinkingLevel(level), + getSessionName: () => session.sessionManager.getSessionName(), + setSessionName: async name => { + await session.sessionManager.setSessionName(name, "user"); + }, + }, + // ExtensionContextActions + { + getModel: () => session.model, + isIdle: () => !session.isStreaming, + abort: () => session.abort(), + hasPendingMessages: () => session.queuedMessageCount > 0, + shutdown, + getContextUsage: () => session.getContextUsage(), + getSystemPrompt: () => session.systemPrompt, + compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), + }, + // ExtensionCommandContextActions — commands invokable via prompt("/command") + { + getContextUsage: () => session.getContextUsage(), + waitForIdle: () => session.agent.waitForIdle(), + newSession: async newOptions => { + const success = await session.newSession({ parentSession: newOptions?.parentSession }); + if (success && newOptions?.setup) { + await newOptions.setup(session.sessionManager); + } + return { cancelled: !success }; + }, + branch: async entryId => { + const result = await session.branch(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, navOptions) => { + const result = await session.navigateTree(targetId, { summarize: navOptions?.summarize }); + return { cancelled: result.cancelled }; + }, + switchSession: async sessionPath => { + const success = await session.switchSession(sessionPath); + return { cancelled: !success }; + }, + reload: async () => { + await session.reload(); + }, + compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions), + }, + uiContext, + ); + + runner.onError(reportRuntimeError); + await runner.emit({ type: "session_start" }); +} diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 9fdebddb6..c8a2feac9 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2332,12 +2332,12 @@ export function getSymbolTheme(): SymbolTheme { }; } -let _markdownTheme: MarkdownTheme | undefined; -let _markdownThemeRef: Theme | undefined; +let cachedMarkdownTheme: MarkdownTheme | undefined; +let cachedMarkdownThemeRef: Theme | undefined; export function getMarkdownTheme(): MarkdownTheme { - if (_markdownTheme !== undefined && _markdownThemeRef === theme) { - return _markdownTheme; + if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) { + return cachedMarkdownTheme; } const markdownTheme: MarkdownTheme = { heading: (text: string) => theme.fg("mdHeading", text), @@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme { } }, }; - _markdownTheme = markdownTheme; - _markdownThemeRef = theme; + cachedMarkdownTheme = markdownTheme; + cachedMarkdownThemeRef = theme; return markdownTheme; } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 64f7a7f5b..9fd449775 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -245,7 +245,7 @@ export class UiHelpers { break; } default: { - const _exhaustive: never = message; + message satisfies never; } } return []; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f61f35a13..2f3e891a6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4684,8 +4684,6 @@ export class AgentSession { let hookCompaction: CompactionResult | undefined; let fromExtension = false; - let hookContext: string[] | undefined; - let hookPrompt: string | undefined; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { @@ -4707,23 +4705,7 @@ export class AgentSession { } } - if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { - const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this.#extensionRunner.emit({ - type: "session.compacting", - sessionId: this.sessionId, - messages: compactMessages, - })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; - - hookContext = result?.context; - hookPrompt = result?.prompt; - preserveData = result?.preserveData; - } - - const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); - if (memoryBackendContext) { - hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; - } + const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; @@ -4731,14 +4713,13 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Extension provided compaction content - summary = hookCompaction.summary; - shortSummary = hookCompaction.shortSummary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; - preserveData ??= hookCompaction.preserveData; + if (compactionPrep.kind === "fromHook") { + summary = compactionPrep.summary; + shortSummary = compactionPrep.shortSummary; + firstKeptEntryId = compactionPrep.firstKeptEntryId; + tokensBefore = compactionPrep.tokensBefore; + details = compactionPrep.details; + preserveData = compactionPrep.preserveData; } else { // Generate compaction result. Only convert known abort-shaped // rejections (AbortError raised while the abort signal is set, @@ -4757,8 +4738,8 @@ export class AgentSession { customInstructions, compactionAbortController.signal, { - promptOverride: hookPrompt, - extraContext: hookContext, + promptOverride: compactionPrep.hookPrompt, + extraContext: compactionPrep.hookContext, remoteInstructions: this.#baseSystemPrompt.join("\n\n"), }, ); @@ -4767,7 +4748,7 @@ export class AgentSession { firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; details = result.details; - preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) }; + preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) }; } catch (err) { if (err instanceof CompactionCancelledError) { throw err; @@ -5727,6 +5708,64 @@ export class AgentSession { throw this.#buildCompactionAuthError(); } + async #prepareCompactionFromHooks( + preparation: CompactionPreparation, + hookCompaction: CompactionResult | undefined, + ): Promise< + | { + kind: "fromHook"; + summary: string; + shortSummary: string | undefined; + firstKeptEntryId: string; + tokensBefore: number; + details: unknown; + preserveData: Record | undefined; + } + | { + kind: "needsLlm"; + hookContext: string[] | undefined; + hookPrompt: string | undefined; + preserveData: Record | undefined; + } + > { + let hookContext: string[] | undefined; + let hookPrompt: string | undefined; + let preserveData: Record | undefined; + + if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { + const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); + const result = (await this.#extensionRunner.emit({ + type: "session.compacting", + sessionId: this.sessionId, + messages: compactMessages, + })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; + + hookContext = result?.context; + hookPrompt = result?.prompt; + preserveData = result?.preserveData; + } + + const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); + if (memoryBackendContext) { + hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; + } + + if (hookCompaction) { + preserveData ??= hookCompaction.preserveData; + return { + kind: "fromHook", + summary: hookCompaction.summary, + shortSummary: hookCompaction.shortSummary, + firstKeptEntryId: hookCompaction.firstKeptEntryId, + tokensBefore: hookCompaction.tokensBefore, + details: hookCompaction.details, + preserveData, + }; + } + + return { kind: "needsLlm", hookContext, hookPrompt, preserveData }; + } + /** * Internal: Run auto-compaction with events. */ @@ -5848,8 +5887,6 @@ export class AgentSession { let hookCompaction: CompactionResult | undefined; let fromExtension = false; - let hookContext: string[] | undefined; - let hookPrompt: string | undefined; let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { @@ -5878,23 +5915,7 @@ export class AgentSession { } } - if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { - const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this.#extensionRunner.emit({ - type: "session.compacting", - sessionId: this.sessionId, - messages: compactMessages, - })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; - - hookContext = result?.context; - hookPrompt = result?.prompt; - preserveData = result?.preserveData; - } - - const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); - if (memoryBackendContext) { - hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; - } + const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction); let summary: string; let shortSummary: string | undefined; @@ -5902,14 +5923,13 @@ export class AgentSession { let tokensBefore: number; let details: unknown; - if (hookCompaction) { - // Extension provided compaction content - summary = hookCompaction.summary; - shortSummary = hookCompaction.shortSummary; - firstKeptEntryId = hookCompaction.firstKeptEntryId; - tokensBefore = hookCompaction.tokensBefore; - details = hookCompaction.details; - preserveData ??= hookCompaction.preserveData; + if (compactionPrep.kind === "fromHook") { + summary = compactionPrep.summary; + shortSummary = compactionPrep.shortSummary; + firstKeptEntryId = compactionPrep.firstKeptEntryId; + tokensBefore = compactionPrep.tokensBefore; + details = compactionPrep.details; + preserveData = compactionPrep.preserveData; } else { const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.settings.getGroup("retry"); @@ -5924,8 +5944,8 @@ export class AgentSession { while (true) { try { compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { - promptOverride: hookPrompt, - extraContext: hookContext, + promptOverride: compactionPrep.hookPrompt, + extraContext: compactionPrep.hookContext, remoteInstructions: this.#baseSystemPrompt.join("\n\n"), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", @@ -6003,7 +6023,7 @@ export class AgentSession { firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; details = compactResult.details; - preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; + preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; } if (autoCompactionSignal.aborted) { diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 061ef5d42..310a7cd8d 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { attribution: m.attribution ?? "agent", }; default: - // biome-ignore lint/correctness/noSwitchDeclarations: fine - const _exhaustiveCheck: never = m; + m satisfies never; return undefined; } }) diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 60d44a309..3be444d89 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import type { OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + appendParseErrorsBulletList, + createCachedComponent, dedupeParseErrors, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, formatParseErrors, - PARSE_ERRORS_LIMIT, + formatParseErrorsCountLabel, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { queueResolveHandler } from "./resolve"; import { ToolError } from "./tool-errors"; @@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let globFilter: string | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - const rawPaths = params.paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`); - } - resolvedPathInputs.push(resource.sourcePath); - } - let effectivePathInputs = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePathInputs = partition.valid; - } - if (effectivePathInputs.length === 1) { - const parsedPath = parseSearchPath(effectivePathInputs[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob; - multiTargets = multiSearchPath.targets; - scopePath = multiSearchPath.scopePath; - } - const resolvedSearchPath = searchPath; - scopePath = scopePath ?? formatScopePath(resolvedSearchPath); - let isDirectory: boolean; - try { - const stat = await Bun.file(resolvedSearchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - throw new ToolError(`Path not found: ${scopePath}`); - } + const scope = await resolveToolSearchScope({ + rawPaths: params.paths, + cwd: this.session.cwd, + internalUrlAction: "rewrite", + }); + const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope; const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, { rewrites: normalizedRewrites, @@ -502,15 +445,7 @@ export const astEditToolRenderer = { if (filesSearched > 0) meta.push(`searched ${filesSearched}`); const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme); const lines = [header, formatEmptyMessage("No replacements made", uiTheme)]; - if (details?.parseErrors?.length) { - const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); - for (const err of capped) { - lines.push(uiTheme.fg("warning", ` - ${err}`)); - } - if (details.parseErrors.length > PARSE_ERRORS_LIMIT) { - lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`)); - } - } + appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme); return new Text(lines.join("\n"), 0, 0); } @@ -523,28 +458,7 @@ export const astEditToolRenderer = { const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined; const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const allGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - allGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) allGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - allGroups.push(nonEmpty); - } - } + const allGroups = splitGroupsByBlankLine(textContent.split("\n")); const changeGroups = allGroups.filter( group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"), ); @@ -560,23 +474,15 @@ export const astEditToolRenderer = { extraLines.push(uiTheme.fg("warning", "limit reached; narrow path")); } if (details?.parseErrors?.length) { - const total = details.parseErrors.length; - const label = - total > PARSE_ERRORS_LIMIT - ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` - : `${total} parse issue${total !== 1 ? "s" : ""}`; - extraLines.push(uiTheme.fg("warning", label)); + extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors))); } - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const changeLines = renderTreeList( { items: changeGroups, - expanded, + expanded: options.expanded, maxCollapsed: changeGroups.length, maxCollapsedLines: COLLAPSED_CHANGE_LIMIT, itemType: "change", @@ -591,14 +497,9 @@ export const astEditToolRenderer = { }, uiTheme, ); - const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: rendered }; - return rendered; + return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 12fcd56b1..3e0a2f05c 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import type { OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + appendParseErrorsBulletList, + createCachedComponent, dedupeParseErrors, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, formatParseErrors, - PARSE_ERRORS_LIMIT, + formatParseErrorsCountLabel, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let globFilter: string | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - const rawPaths = params.paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`); - } - resolvedPathInputs.push(resource.sourcePath); - } - let effectivePathInputs = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePathInputs = partition.valid; - } - if (effectivePathInputs.length === 1) { - const parsedPath = parseSearchPath(effectivePathInputs[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob; - multiTargets = multiSearchPath.targets; - scopePath = multiSearchPath.scopePath; - } - - const resolvedSearchPath = searchPath; - scopePath = scopePath ?? formatScopePath(resolvedSearchPath); - let isDirectory: boolean; - try { - const stat = await Bun.file(resolvedSearchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - throw new ToolError(`Path not found: ${scopePath}`); - } + const scope = await resolveToolSearchScope({ + rawPaths: params.paths, + cwd: this.session.cwd, + internalUrlAction: "search", + }); + const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope; const DEFAULT_AST_LIMIT = 50; const result = multiTargets @@ -388,13 +330,7 @@ export const astGrepToolRenderer = { const lines = [header, formatEmptyMessage("No matches found", uiTheme)]; if (details?.parseErrors?.length) { lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence")); - const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); - for (const err of capped) { - lines.push(uiTheme.fg("warning", ` - ${err}`)); - } - if (details.parseErrors.length > PARSE_ERRORS_LIMIT) { - lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`)); - } + appendParseErrorsBulletList(lines, details.parseErrors, uiTheme); } return new Text(lines.join("\n"), 0, 0); } @@ -411,28 +347,7 @@ export const astGrepToolRenderer = { ); const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const allGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - allGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) allGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - allGroups.push(nonEmpty); - } - } + const allGroups = splitGroupsByBlankLine(textContent.split("\n")); const matchGroups = allGroups.filter( group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"), ); @@ -442,24 +357,16 @@ export const astGrepToolRenderer = { extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit")); } if (details?.parseErrors?.length) { - const total = details.parseErrors.length; - const label = - total > PARSE_ERRORS_LIMIT - ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` - : `${total} parse issue${total !== 1 ? "s" : ""}`; - extraLines.push(uiTheme.fg("warning", label)); + extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors))); } - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const matchLines = renderTreeList( { items: matchGroups, - expanded, + expanded: options.expanded, maxCollapsed: matchGroups.length, maxCollapsedLines: COLLAPSED_MATCH_LIMIT, itemType: "match", @@ -473,14 +380,9 @@ export const astGrepToolRenderer = { }, uiTheme, ); - const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: rendered }; - return rendered; + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/browser/attach.ts b/packages/coding-agent/src/tools/browser/attach.ts index c83285fd7..d6be0bec5 100644 --- a/packages/coding-agent/src/tools/browser/attach.ts +++ b/packages/coding-agent/src/tools/browser/attach.ts @@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives"; import type { Browser, Page } from "puppeteer-core"; import { ToolError, throwIfAborted } from "../tool-errors"; -export const ATTACH_TARGET_SKIP_PATTERN = +const ATTACH_TARGET_SKIP_PATTERN = /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i; /** @@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo * accepts both `--flag=value` and `--flag value`). Returns null if absent or * malformed. */ -export function findCdpPortInArgs(args: string[]): number | null { +function findCdpPortInArgs(args: string[]): number | null { for (const arg of args) { const m = /^--remote-debugging-port=(\d+)$/.exec(arg); if (m) { @@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null { } /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */ -export async function probeCdpAt(port: number, signal?: AbortSignal): Promise { +async function probeCdpAt(port: number, signal?: AbortSignal): Promise { const probeTimeout = AbortSignal.timeout(1500); const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; try { diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 823866570..4b6412f4b 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1 * connection dropped, etc.). */ export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000; -export const STEALTH_IGNORE_DEFAULT_ARGS = [ +const STEALTH_IGNORE_DEFAULT_ARGS = [ "--disable-extensions", "--disable-default-apps", "--disable-component-extensions-with-background-pages", ]; -export const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; +const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__"; @@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise | undefined; -export async function ensureChromiumExecutable(): Promise { +async function ensureChromiumExecutable(): Promise { const sysChrome = resolveSystemChromium(); if (sysChrome) return sysChrome; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; @@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise { return chromiumExecutablePromise; } -let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found +let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found function isExecutableFile(p: string): boolean { try { @@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] { return candidates; } -export function resolveSystemChromium(): string | undefined { - if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined; +function resolveSystemChromium(): string | undefined { + if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; const seen = new Set(); for (const candidate of systemChromiumCandidates()) { if (!candidate || seen.has(candidate)) continue; seen.add(candidate); if (isExecutableFile(candidate)) { - _resolvedChromium = candidate; + resolvedChromium = candidate; logger.debug("Using system Chrome/Chromium", { path: candidate }); return candidate; } } - _resolvedChromium = null; + resolvedChromium = null; return undefined; } diff --git a/packages/coding-agent/src/tools/browser/registry.ts b/packages/coding-agent/src/tools/browser/registry.ts index 78aa7ccb6..c8caff4c7 100644 --- a/packages/coding-agent/src/tools/browser/registry.ts +++ b/packages/coding-agent/src/tools/browser/registry.ts @@ -26,10 +26,6 @@ export interface BrowserHandle { const browsers = new Map(); -export function listBrowsers(): BrowserHandle[] { - return [...browsers.values()]; -} - function browserKey(kind: BrowserKind): string { switch (kind.kind) { case "headless": @@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea } } -export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { +async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { if (handle.kind.kind === "headless") { if (handle.browser.connected) { try { diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 16770068d..11cbee89d 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined { return tabs.get(name); } -export function listTabs(): TabSession[] { - return [...tabs.values()]; -} - export async function acquireTab( name: string, browser: BrowserHandle, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 2f1027d9e..64cb858cf 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import findDescription from "../prompts/tools/find.md" with { type: "text" }; import { type TruncationResult, truncateHead } from "../session/streaming-output"; -import { - Ellipsis, - Hasher, - type RenderCache, - renderFileList, - renderStatusLine, - renderTreeList, - truncateToWidth, -} from "../tui"; +import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; @@ -33,7 +25,13 @@ import { resolveExplicitFindPatterns, resolveToCwd, } from "./path-utils"; -import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; +import { + createCachedComponent, + formatCount, + formatEmptyMessage, + formatErrorMessage, + PREVIEW_LIMITS, +} from "./render-utils"; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -401,30 +399,22 @@ export const findToolRenderer = { }, uiTheme, ); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const listLines = renderTreeList( { items: lines, - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_LIST_LIMIT, itemType: "file", renderItem: line => uiTheme.fg("accent", line), }, uiTheme, ); - const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); } const fileCount = details?.fileCount ?? 0; @@ -467,28 +457,20 @@ export const findToolRenderer = { } if (missingNote) extraLines.push(missingNote); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const fileLines = renderFileList( { files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_LIST_LIMIT, }, uiTheme, ); - const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index 055ef66dd..6c9ad87ae 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,7 +1,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; +import { extractTextContent } from "../commit/utils"; import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" }; @@ -30,14 +31,6 @@ export interface InspectImageToolDetails { mimeType: string; } -function extractResponseText(message: AssistantMessage): string { - return message.content - .filter(content => content.type === "text") - .map(content => content.text) - .join("") - .trim(); -} - export class InspectImageTool implements AgentTool { readonly name = "inspect_image"; readonly label = "InspectImage"; @@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool; +} + +/** + * Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`: + * 1. normalize + reject empty paths, + * 2. resolve internal URLs through {@link InternalUrlRouter} to backing files, + * 3. partition existing vs missing when multiple paths are supplied, + * 4. derive a single search base path / glob, or a multi-target list, + * 5. stat the resolved base path so callers can branch on directory vs file scope. + */ +export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise { + const { rawPaths: inputs, cwd, internalUrlAction } = opts; + const rawPaths = inputs.map(normalizePathLikeInput); + if (rawPaths.some(rawPath => rawPath.length === 0)) { + throw new ToolError("`paths` must contain non-empty paths or globs"); + } + const internalRouter = InternalUrlRouter.instance(); + const resolvedPathInputs: string[] = []; + const immutableSourcePaths = new Set(); + for (const rawPath of rawPaths) { + if (!internalRouter.canHandle(rawPath)) { + resolvedPathInputs.push(rawPath); + continue; + } + if (hasGlobPathChars(rawPath)) { + throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); + } + const resource = await internalRouter.resolve(rawPath); + if (!resource.sourcePath) { + throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`); + } + if (opts.trackImmutableSources && resource.immutable) { + immutableSourcePaths.add(path.resolve(resource.sourcePath)); + } + resolvedPathInputs.push(resource.sourcePath); + } + + let missingPaths: string[] = []; + let effectivePaths = resolvedPathInputs; + if (resolvedPathInputs.length > 1) { + const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath); + if (partition.valid.length === 0) { + throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); + } + effectivePaths = partition.valid; + missingPaths = partition.missing; + } + + let searchPath: string; + let scopePath: string; + let globFilter: string | undefined; + let multiTargets: ResolvedSearchTarget[] | undefined; + let exactFilePaths: string[] | undefined; + if (effectivePaths.length === 1) { + const parsedPath = parseSearchPath(effectivePaths[0] ?? "."); + searchPath = resolveToCwd(parsedPath.basePath, cwd); + globFilter = parsedPath.glob; + scopePath = formatPathRelativeToCwd(searchPath, cwd); + } else { + const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd); + if (!multiSearchPath) { + throw new ToolError("`paths` must contain at least one path or glob"); + } + searchPath = multiSearchPath.basePath; + multiTargets = multiSearchPath.targets; + if (opts.surfaceExactFilePaths) { + exactFilePaths = multiSearchPath.exactFilePaths; + globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob; + } else { + globFilter = multiTargets ? undefined : multiSearchPath.glob; + } + scopePath = multiSearchPath.scopePath; + } + + let isDirectory: boolean; + try { + const stat = await Bun.file(searchPath).stat(); + isDirectory = stat.isDirectory(); + } catch { + const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : ""; + throw new ToolError(`Path not found: ${scopePath}${hint}`); + } + + return { + searchPath, + scopePath, + globFilter, + isDirectory, + multiTargets, + exactFilePaths, + missingPaths, + immutableSourcePaths, + }; +} diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 3220f1384..29780287d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1321,8 +1321,6 @@ export class ReadTool implements AgentTool { const imageMetadata = await readImageMetadata(absolutePath); const mimeType = imageMetadata?.mimeType; const ext = path.extname(absolutePath).toLowerCase(); - const _hasEditTool = this.session.hasEditTool ?? true; - const _language = getLanguageFromPath(absolutePath); const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext); // Read the file based on type let content: Array | undefined; diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index d064569c5..5c7bbcc15 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -9,10 +9,12 @@ import * as os from "node:os"; import * as path from "node:path"; import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { Ellipsis } from "@oh-my-pi/pi-natives"; +import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; import { pluralize } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { Theme } from "../modes/theme/theme"; +import { Hasher } from "../tui/utils"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; export { Ellipsis } from "@oh-my-pi/pi-natives"; @@ -642,6 +644,96 @@ export function formatParseErrors(errors: string[]): string[] { return [header, ...capped.map(err => `- ${err}`)]; } +// ============================================================================= +// Renderer helpers shared by search / find / ast tools +// ============================================================================= + +/** + * Group `rawLines` by blank-line separators, mirroring the historical search / + * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on + * runs of blank lines; otherwise collapses non-empty lines into a single group. + */ +export function splitGroupsByBlankLine(rawLines: string[]): string[][] { + const hasSeparators = rawLines.some(line => line.trim().length === 0); + const groups: string[][] = []; + if (hasSeparators) { + let current: string[] = []; + for (const line of rawLines) { + if (line.trim().length === 0) { + if (current.length > 0) { + groups.push(current); + current = []; + } + continue; + } + current.push(line); + } + if (current.length > 0) groups.push(current); + } else { + const nonEmpty = rawLines.filter(line => line.trim().length > 0); + if (nonEmpty.length > 0) { + groups.push(nonEmpty); + } + } + return groups; +} + +/** + * Standard width+expand keyed render cache used by every search-style tool + * renderer. `compute` re-runs only when the cache key changes; the returned + * Component is the canonical `{ render, invalidate }` pair. + */ +export function createCachedComponent( + getExpanded: () => boolean, + compute: (width: number, expanded: boolean) => string[], +): Component { + let cached: { key: bigint; lines: string[] } | undefined; + return { + render(width: number): string[] { + const expanded = getExpanded(); + const key = new Hasher().bool(expanded).u32(width).digest(); + if (cached?.key === key) return cached.lines; + const lines = compute(width, expanded); + cached = { key, lines }; + return lines; + }, + invalidate() { + cached = undefined; + }, + }; +} + +/** + * Append the indented bullet list of parse errors (capped at + * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the + * total exceeds the cap. No-op when `parseErrors` is empty. + */ +export function appendParseErrorsBulletList( + lines: string[], + parseErrors: readonly string[] | undefined, + theme: Theme, +): void { + if (!parseErrors || parseErrors.length === 0) return; + const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT); + for (const err of capped) { + lines.push(theme.fg("warning", ` - ${err}`)); + } + if (parseErrors.length > PARSE_ERRORS_LIMIT) { + lines.push(theme.fg("dim", ` … ${parseErrors.length - PARSE_ERRORS_LIMIT} more`)); + } +} + +/** + * Human-readable summary string for the parse-issues count, capped by + * {@link PARSE_ERRORS_LIMIT}. + */ +export function formatParseErrorsCountLabel(parseErrors: readonly string[]): string { + const total = parseErrors.length; + return total > PARSE_ERRORS_LIMIT + ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues` + : `${total} parse issue${total !== 1 ? "s" : ""}`; +} + // ============================================================================= // LSP Batching // ============================================================================= diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index ac043d38b..dbfd8ce65 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -8,32 +8,25 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { getFileReadCache } from "../edit/file-read-cache"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import searchDescription from "../prompts/tools/search.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; -import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatGroupedFiles } from "./grouped-file-output"; import { formatMatchLine } from "./match-line-format"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; +import { resolveToolSearchScope } from "./path-utils"; import { - formatPathRelativeToCwd, - hasGlobPathChars, - normalizePathLikeInput, - parseSearchPath, - partitionExistingPaths, - resolveExplicitSearchPaths, - resolveToCwd, -} from "./path-utils"; -import { + createCachedComponent, formatCodeFrameLine, formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, + splitGroupsByBlankLine, } from "./render-utils"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -141,78 +134,26 @@ export class SearchTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); - let searchPath: string; - let scopePath: string; - let exactFilePaths: string[] | undefined; - let multiTargets: Array<{ basePath: string; glob?: string }> | undefined; - let globFilter: string | undefined; - const rawPaths = paths.map(normalizePathLikeInput); - if (rawPaths.some(rawPath => rawPath.length === 0)) { - throw new ToolError("`paths` must contain non-empty paths or globs"); - } - const internalRouter = InternalUrlRouter.instance(); - const resolvedPathInputs: string[] = []; - // Absolute filesystem paths whose source is immutable (e.g. artifact://, - // pi://, skill://). Hashline anchors are suppressed for these on a - // per-file basis, leaving editable mixed-in files untouched. - const immutableSourcePaths = new Set(); - for (const rawPath of rawPaths) { - if (!internalRouter.canHandle(rawPath)) { - resolvedPathInputs.push(rawPath); - continue; - } - if (hasGlobPathChars(rawPath)) { - throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); - } - const resource = await internalRouter.resolve(rawPath); - if (!resource.sourcePath) { - throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`); - } - if (resource.immutable) { - immutableSourcePaths.add(path.resolve(resource.sourcePath)); - } - resolvedPathInputs.push(resource.sourcePath); - } + const scope = await resolveToolSearchScope({ + rawPaths: paths, + cwd: this.session.cwd, + internalUrlAction: "search", + trackImmutableSources: true, + surfaceExactFilePaths: true, + multipathStatHint: " (`paths` entries must each exist relative to cwd)", + }); + const { + searchPath, + scopePath, + isDirectory, + multiTargets, + exactFilePaths, + missingPaths, + immutableSourcePaths, + } = scope; + const { globFilter } = scope; const baseDisplayMode = resolveFileDisplayMode(this.session); const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true }); - // Tolerate missing entries in a multi-path call: skip ones whose base - // directory is gone, and only error if every entry is missing. Single - // missing path keeps the original ENOENT semantics. - let missingPaths: string[] = []; - let effectivePaths = resolvedPathInputs; - if (resolvedPathInputs.length > 1) { - const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath); - if (partition.valid.length === 0) { - throw new ToolError(`Path not found: ${partition.missing.join(", ")}`); - } - effectivePaths = partition.valid; - missingPaths = partition.missing; - } - if (effectivePaths.length === 1) { - const parsedPath = parseSearchPath(effectivePaths[0] ?? "."); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = parsedPath.glob; - scopePath = formatScopePath(searchPath); - } else { - const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter); - if (!multiSearchPath) { - throw new ToolError("`paths` must contain at least one path or glob"); - } - searchPath = multiSearchPath.basePath; - exactFilePaths = multiSearchPath.exactFilePaths; - multiTargets = multiSearchPath.targets; - globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob; - scopePath = multiSearchPath.scopePath; - } - let isDirectory: boolean; - try { - const stat = await Bun.file(searchPath).stat(); - isDirectory = stat.isDirectory(); - } catch { - const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : ""; - throw new ToolError(`Path not found: ${scopePath}${hint}`); - } const effectiveOutputMode = GrepOutputMode.Content; // Multi-scope = more than one file may match. We fetch up to @@ -531,16 +472,13 @@ export const searchToolRenderer = { { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] }, uiTheme, ); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const listLines = renderTreeList( { items: lines, - expanded, + expanded: options.expanded, maxCollapsed: COLLAPSED_TEXT_LIMIT, maxCollapsedLines: COLLAPSED_TEXT_LIMIT, itemType: "item", @@ -548,14 +486,9 @@ export const searchToolRenderer = { }, uiTheme, ); - const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); } const matchCount = details?.matchCount ?? 0; @@ -591,28 +524,7 @@ export const searchToolRenderer = { ); const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const rawLines = textContent.split("\n"); - const hasSeparators = rawLines.some(line => line.trim().length === 0); - const matchGroups: string[][] = []; - if (hasSeparators) { - let current: string[] = []; - for (const line of rawLines) { - if (line.trim().length === 0) { - if (current.length > 0) { - matchGroups.push(current); - current = []; - } - continue; - } - current.push(line); - } - if (current.length > 0) matchGroups.push(current); - } else { - const nonEmpty = rawLines.filter(line => line.trim().length > 0); - if (nonEmpty.length > 0) { - matchGroups.push(nonEmpty); - } - } + const matchGroups = splitGroupsByBlankLine(textContent.split("\n")); const renderedFileLimit = details?.fileLimitReached; const renderedPerFileLimit = details?.perFileLimitReached; @@ -629,17 +541,14 @@ export const searchToolRenderer = { } if (missingNote) extraLines.push(missingNote); - let cached: RenderCache | undefined; - return { - render(width: number): string[] { - const { expanded } = options; - const key = new Hasher().bool(expanded).u32(width).digest(); - if (cached?.key === key) return cached.lines; + return createCachedComponent( + () => options.expanded, + width => { const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0); const matchLines = renderTreeList( { items: matchGroups, - expanded, + expanded: options.expanded, maxCollapsed: matchGroups.length, maxCollapsedLines: collapsedMatchLineBudget, itemType: "match", @@ -652,14 +561,9 @@ export const searchToolRenderer = { }, uiTheme, ); - const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - cached = { key, lines: result }; - return result; + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); }, - invalidate() { - cached = undefined; - }, - }; + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index 55d73bc32..797d8dd04 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -10,6 +10,7 @@ import path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { glob } from "@oh-my-pi/pi-natives"; +import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils"; import { formatHashLines } from "../hashline/hash"; import type { FileMentionMessage } from "../session/messages"; @@ -20,7 +21,6 @@ import { truncateHeadBytes, } from "../session/streaming-output"; import { resolveReadPath } from "../tools/path-utils"; -import { fuzzyMatch } from "./fuzzy"; import { formatDimensionNote, resizeImage } from "./image-resize"; /** Regex to match @filepath patterns in text */ diff --git a/packages/coding-agent/src/utils/fuzzy.ts b/packages/coding-agent/src/utils/fuzzy.ts deleted file mode 100644 index 4903b4790..000000000 --- a/packages/coding-agent/src/utils/fuzzy.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive). -// Lower score = better match. - -export interface FuzzyMatch { - matches: boolean; - score: number; -} - -export function fuzzyMatch(query: string, text: string): FuzzyMatch { - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - - if (queryLower.length === 0) { - return { matches: true, score: 0 }; - } - - if (queryLower.length > textLower.length) { - return { matches: false, score: 0 }; - } - - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; - - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!); - - // Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o") - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; - } else { - consecutiveMatches = 0; - // Penalize gaps between matched characters - if (lastMatchIndex >= 0) { - score += (i - lastMatchIndex - 1) * 2; - } - } - - // Reward matches at word boundaries (start of words are more likely intentional targets) - if (isWordBoundary) { - score -= 10; - } - - // Slight penalty for matches later in the string (prefer earlier matches) - score += i * 0.1; - - lastMatchIndex = i; - queryIndex++; - } - } - - // Not all query characters were found in order - if (queryIndex < queryLower.length) { - return { matches: false, score: 0 }; - } - - return { matches: true, score }; -} - -// Filter and sort items by fuzzy match quality (best matches first) -// Supports space-separated tokens: all tokens must match, sorted by match count then score -export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { - if (!query.trim()) { - return items; - } - - // Split query into tokens - const tokens = query - .trim() - .split(/\s+/) - .filter(t => t.length > 0); - - if (tokens.length === 0) { - return items; - } - - const results: { item: T; totalScore: number }[] = []; - - for (const item of items) { - const text = getText(item); - let totalScore = 0; - let allMatch = true; - - // Check each token against the text - ALL must match - for (const token of tokens) { - const match = fuzzyMatch(token, text); - if (match.matches) { - totalScore += match.score; - } else { - allMatch = false; - break; - } - } - - // Only include if all tokens match - if (allMatch) { - results.push({ item, totalScore }); - } - } - - // Sort by score (asc, lower is better) - results.sort((a, b) => a.totalScore - b.totalScore); - - return results.map(r => r.item); -} diff --git a/packages/coding-agent/src/vim/parser.ts b/packages/coding-agent/src/vim/parser.ts index e4559c993..ae1e3794f 100644 --- a/packages/coding-agent/src/vim/parser.ts +++ b/packages/coding-agent/src/vim/parser.ts @@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] { return tokens; } -export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] { - return tokens.map(token => token.value); -} - export function replayTokens(values: readonly string[]): VimKeyToken[] { return values.map((value, index) => ({ value, @@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] { offset: index, })); } - -export function formatVimError(error: unknown): string { - if (!(error instanceof VimError)) { - return error instanceof Error ? error.message : String(error); - } - - const base = error.message; - if (!error.location) { - return base; - } - - return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`; -} diff --git a/packages/coding-agent/src/vim/render.ts b/packages/coding-agent/src/vim/render.ts index 6851eaf42..7ea9dae81 100644 --- a/packages/coding-agent/src/vim/render.ts +++ b/packages/coding-agent/src/vim/render.ts @@ -13,7 +13,7 @@ import type { export const VIM_OPEN_VIEWPORT_LINES = 80; export const VIM_DEFAULT_VIEWPORT_LINES = 10; -export const VIM_TAB_DISPLAY = "→"; +const VIM_TAB_DISPLAY = "→"; const VIM_INLINE_CURSOR = "▏"; const VIM_VIEWPORT_WIDTH = 140; diff --git a/packages/coding-agent/src/vim/types.ts b/packages/coding-agent/src/vim/types.ts index 0603ff525..5ca3d8940 100644 --- a/packages/coding-agent/src/vim/types.ts +++ b/packages/coding-agent/src/vim/types.ts @@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position { return { line: position.line, col: position.col }; } -export function comparePositions(left: Position, right: Position): number { +function comparePositions(left: Position, right: Position): number { if (left.line !== right.line) { return left.line - right.line; } diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index ee169311a..b187484a3 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -12,7 +12,7 @@ import { } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../src/config/settings"; +import { resetSettingsForTest, Settings } from "../src/config/settings"; import { AcpAgent } from "../src/modes/acp/acp-agent"; import type { PlanModeState } from "../src/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; @@ -316,7 +316,7 @@ afterEach(async () => { setAgentDir(fallbackAgentDir); delete process.env.PI_CODING_AGENT_DIR; } - _resetSettingsForTest(); + resetSettingsForTest(); for (const root of cleanupRoots.splice(0)) { await fs.promises.rm(root, { recursive: true, force: true }); diff --git a/packages/coding-agent/test/agent-session-bash-detach.test.ts b/packages/coding-agent/test/agent-session-bash-detach.test.ts index a568f1a5c..014d43506 100644 --- a/packages/coding-agent/test/agent-session-bash-detach.test.ts +++ b/packages/coding-agent/test/agent-session-bash-detach.test.ts @@ -45,7 +45,7 @@ import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-cor import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -165,7 +165,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) tempDir = path.join(os.tmpdir(), `pi-bash-detach-test-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); - _resetSettingsForTest(); + resetSettingsForTest(); // Fresh isolated Settings rooted in tempDir so we don't pick up the // developer's real config (snapshots, shell prefix, etc). await Settings.init({ inMemory: true, cwd: tempDir }); @@ -243,7 +243,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) }); afterAll(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => { diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 025cc8ecf..ab29a1187 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -36,15 +36,6 @@ function createAssistantMessage( }; } -async function _waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await Bun.sleep(10); - } - throw new Error("Timed out waiting for condition"); -} - type AutoRetryStartEvent = Extract; type AutoRetryEndEvent = Extract; diff --git a/packages/coding-agent/test/autocomplete-max-visible.test.ts b/packages/coding-agent/test/autocomplete-max-visible.test.ts index 56fc688a5..b2a24ad30 100644 --- a/packages/coding-agent/test/autocomplete-max-visible.test.ts +++ b/packages/coding-agent/test/autocomplete-max-visible.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; @@ -13,7 +13,7 @@ describe("autocompleteMaxVisible setting", () => { let projectDir: string; beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next()); agentDir = path.join(testDir, "agent"); projectDir = path.join(testDir, "project"); @@ -22,7 +22,7 @@ describe("autocompleteMaxVisible setting", () => { }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -34,7 +34,7 @@ describe("autocompleteMaxVisible setting", () => { await settings.flush(); // Re-init to verify persistence - _resetSettingsForTest(); + resetSettingsForTest(); const settings2 = await Settings.init({ cwd: projectDir, agentDir }); expect(settings2.get("autocompleteMaxVisible")).toBe(10); }); diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 02d6590ec..730331bd2 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; @@ -20,12 +20,12 @@ describe("executeBash", () => { beforeEach(async () => { tempDir = makeTempDir(); - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: tempDir }); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/config-cli.test.ts b/packages/coding-agent/test/config-cli.test.ts index ae67a50a2..62cba399a 100644 --- a/packages/coding-agent/test/config-cli.test.ts +++ b/packages/coding-agent/test/config-cli.test.ts @@ -4,21 +4,21 @@ import * as os from "node:os"; import * as path from "node:path"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { runConfigCommand } from "../src/cli/config-cli"; -import { _resetSettingsForTest } from "../src/config/settings"; +import { resetSettingsForTest } from "../src/config/settings"; let testAgentDir = ""; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); testAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-config-cli-")); setAgentDir(testAgentDir); }); afterEach(async () => { vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); if (originalAgentDir) { setAgentDir(originalAgentDir); } else { diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts index bab13e82e..ebb01c29d 100644 --- a/packages/coding-agent/test/config-spacing.test.ts +++ b/packages/coding-agent/test/config-spacing.test.ts @@ -2,21 +2,21 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; describe("indentation resolver", () => { let tempDir = ""; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); setDefaultTabWidth(3); tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); setDefaultTabWidth(3); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 3b37b900c..a0a38033c 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { applyHashlineEdits, buildCompactHashlineDiffPreview, @@ -27,7 +27,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { Value } from "@sinclair/typebox/value"; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); }); diff --git a/packages/coding-agent/test/discovery/disabled-extensions.test.ts b/packages/coding-agent/test/discovery/disabled-extensions.test.ts index 612efec55..22581f339 100644 --- a/packages/coding-agent/test/discovery/disabled-extensions.test.ts +++ b/packages/coding-agent/test/discovery/disabled-extensions.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type ContextFile, contextFileCapability } from "@oh-my-pi/pi-coding-agent/capability/context-file"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initializeWithSettings, loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; describe("disabledExtensions runtime filtering", () => { @@ -12,7 +12,7 @@ describe("disabledExtensions runtime filtering", () => { let originalHome: string | undefined; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); originalHome = process.env.HOME; tempHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-disabled-ext-home-")); process.env.HOME = tempHomeDir; @@ -32,7 +32,7 @@ describe("disabledExtensions runtime filtering", () => { }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); if (originalHome === undefined) { delete process.env.HOME; diff --git a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts index 90a17123a..c372953b8 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -19,7 +19,7 @@ import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -251,7 +251,7 @@ it("multi-entry edit on an auto-generated file surfaces isError + error text ins // The auto-generated guard reads from the *global* settings singleton, so we // must initialize it (the per-tool `Settings.isolated(...)` we pass into the // EditTool isn't what the guard sees). - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } }); try { diff --git a/packages/coding-agent/test/edit-per-file-diff-content.test.ts b/packages/coding-agent/test/edit-per-file-diff-content.test.ts index f08f76e51..738f7bd38 100644 --- a/packages/coding-agent/test/edit-per-file-diff-content.test.ts +++ b/packages/coding-agent/test/edit-per-file-diff-content.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { DEFAULT_FUZZY_THRESHOLD, EditTool, @@ -40,13 +40,13 @@ const noopBeginDeferred = (_p: string) => ({ let tempDir: string; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-")); await Settings.init({ inMemory: true, cwd: tempDir }); }); afterEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 629e4a895..054203631 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -8,9 +8,9 @@ import * as path from "node:path"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { - __test_setExtensionHandlerTimeoutMs, EXTENSION_HANDLER_TIMEOUT_MS, ExtensionRunner, + testSetExtensionHandlerTimeoutMs, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -34,7 +34,7 @@ describe("ExtensionRunner", () => { }); afterEach(() => { - __test_setExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); + testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); authStorage.close(); tempDir.removeSync(); }); @@ -644,7 +644,7 @@ describe("ExtensionRunner", () => { runner.onError(err => { errors.push(err); }); - __test_setExtensionHandlerTimeoutMs(50); + testSetExtensionHandlerTimeoutMs(50); const startedAt = performance.now(); await runner.emit({ type: "session_start" }); diff --git a/packages/coding-agent/test/fuzzy.test.ts b/packages/coding-agent/test/fuzzy.test.ts index 23c211ee5..d9b876f42 100644 --- a/packages/coding-agent/test/fuzzy.test.ts +++ b/packages/coding-agent/test/fuzzy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-coding-agent/utils/fuzzy"; +import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-tui"; describe("fuzzyMatch", () => { test("empty query matches everything with score 0", () => { diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index a9174b62b..550306447 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { hindsightBackend, reloadMentalModelsForSession } from "@oh-my-pi/pi-coding-agent/hindsight/backend"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import type { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; @@ -76,7 +76,7 @@ function makeFakeSession(deps: FakeSessionDeps) { describe("hindsightBackend.start", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -235,7 +235,7 @@ describe("hindsightBackend.start", () => { describe("hindsightBackend.preCompactionContext", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -297,7 +297,7 @@ describe("hindsightBackend.preCompactionContext", () => { describe("hindsightBackend first-turn injection", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { @@ -480,7 +480,7 @@ describe("hindsightBackend first-turn injection", () => { describe("hindsightBackend.clear", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { diff --git a/packages/coding-agent/test/hindsight-tools.test.ts b/packages/coding-agent/test/hindsight-tools.test.ts index 2f90f3092..9aa035445 100644 --- a/packages/coding-agent/test/hindsight-tools.test.ts +++ b/packages/coding-agent/test/hindsight-tools.test.ts @@ -8,7 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client"; import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config"; import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; @@ -94,7 +94,7 @@ function registerState(client: HindsightApi, settings?: Settings, opts: Register describe("Hindsight tool factories", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -122,7 +122,7 @@ describe("Hindsight tool factories", () => { describe("retain.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -210,7 +210,7 @@ describe("retain.execute", () => { describe("recall.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); @@ -278,7 +278,7 @@ describe("recall.execute", () => { describe("reflect.execute", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); registeredState = undefined; }); diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index fcbf84ca4..7b4497dff 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -24,7 +24,7 @@ describe("InteractiveMode.setEditorComponent", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-editor-component-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -56,7 +56,7 @@ describe("InteractiveMode.setEditorComponent", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("replaces the editor and rebinds interactive handlers", () => { diff --git a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts index f98b3d510..4ebd428ec 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -37,7 +37,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-interactive-mode-lsp-startup-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -77,7 +77,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("updates the welcome banner when startup warmup completes", async () => { diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 8dcba0268..b584658b5 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { Text } from "@oh-my-pi/pi-tui"; @@ -35,7 +35,7 @@ describe("InteractiveMode plan review rendering", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-plan-review-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -67,7 +67,7 @@ describe("InteractiveMode plan review rendering", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("appends each submitted plan review preview to preserve scrollback", async () => { diff --git a/packages/coding-agent/test/issue-816-repro.test.ts b/packages/coding-agent/test/issue-816-repro.test.ts index 260f29ee0..5e737b603 100644 --- a/packages/coding-agent/test/issue-816-repro.test.ts +++ b/packages/coding-agent/test/issue-816-repro.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; @@ -22,7 +22,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-816-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -52,7 +52,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("does not switch to the plan-role model after exit when the entry switch was deferred by streaming", async () => { diff --git a/packages/coding-agent/test/issue-845-repro.test.ts b/packages/coding-agent/test/issue-845-repro.test.ts index cc30bba25..cfdc2f1ed 100644 --- a/packages/coding-agent/test/issue-845-repro.test.ts +++ b/packages/coding-agent/test/issue-845-repro.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { _resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { resolveUpdateMethodForTest } from "../src/cli/update-cli"; // Issue #845: on Windows with Bun installed via Scoop, ~/.bun is a junction // to scoop\persist\Oven-sh.Bun\.bun. `bun pm bin -g` and the omp path that @@ -40,13 +40,13 @@ describe("issue-845: resolveUpdateMethod follows symlinks/junctions", () => { it("classifies omp reached through a symlinked bin dir as bun-managed", () => { // $which resolves through the symlink, `bun pm bin -g` returns the real path // (or vice versa). Either direction must be recognized. - const method = _resolveUpdateMethodForTest(ompPathViaLink, realBinDir); + const method = resolveUpdateMethodForTest(ompPathViaLink, realBinDir); expect(method).toBe("bun"); }); it("classifies omp at the real bin dir as bun-managed when bunBinDir is symlinked", () => { const ompAtReal = path.join(realBinDir, "omp"); - const method = _resolveUpdateMethodForTest(ompAtReal, linkedBinDir); + const method = resolveUpdateMethodForTest(ompAtReal, linkedBinDir); expect(method).toBe("bun"); }); }); diff --git a/packages/coding-agent/test/issue-927-repro.test.ts b/packages/coding-agent/test/issue-927-repro.test.ts index 6edd33060..5e1c9c5c5 100644 --- a/packages/coding-agent/test/issue-927-repro.test.ts +++ b/packages/coding-agent/test/issue-927-repro.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun: import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { submitInteractiveInput } from "@oh-my-pi/pi-coding-agent/main"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -30,7 +30,7 @@ describe("issue #927 optimistic pending spinner", () => { vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-927-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); @@ -56,7 +56,7 @@ describe("issue #927 optimistic pending spinner", () => { await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("clears the optimistic loading animation when prompt returns without a model turn", async () => { diff --git a/packages/coding-agent/test/memory-backend-resolve.test.ts b/packages/coding-agent/test/memory-backend-resolve.test.ts index 581263bb3..075f05e8f 100644 --- a/packages/coding-agent/test/memory-backend-resolve.test.ts +++ b/packages/coding-agent/test/memory-backend-resolve.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend"; describe("resolveMemoryBackend", () => { beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); }); it("returns the hindsight backend when memory.backend is hindsight, regardless of legacy memories.enabled", () => { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 18961636c..31294e2e9 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Effort, type Model, type OpenAICompat, type ThinkingConfig, writeModelCache } from "@oh-my-pi/pi-ai"; import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { hookFetch, Snowflake } from "@oh-my-pi/pi-utils"; @@ -15,7 +15,7 @@ describe("ModelRegistry", () => { let authStorage: AuthStorage; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); modelsJsonPath = path.join(tempDir, "models.json"); @@ -24,7 +24,7 @@ describe("ModelRegistry", () => { }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); authStorage.close(); if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 9a0f690f6..a099e69a0 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { clearMermaidCache } from "@oh-my-pi/pi-coding-agent/modes/theme/mermaid-cache"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -42,14 +42,14 @@ beforeAll(async () => { }); beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); clearMermaidCache(); setTerminalImageProtocol(null); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); setTerminalImageProtocol(originalImageProtocol); clearMermaidCache(); }); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts index 47e189ccf..57d99a0e3 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -26,7 +26,7 @@ function createAssistantMessage(): AssistantMessage { describe("EventController idle compaction teardown", () => { beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { @@ -41,7 +41,7 @@ describe("EventController idle compaction teardown", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); }); it("cancels scheduled idle compaction when disposed", async () => { diff --git a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts index 760c42f52..94ef606f1 100644 --- a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts +++ b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -23,7 +23,7 @@ describe("issue #1022 — path-scoped enabledModels respected by default fallbac let cwd: string; beforeEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); testDir = path.join(os.tmpdir(), `pi-issue-1022-${Snowflake.next()}`); agentDir = path.join(testDir, "agent"); cwd = path.join(testDir, "private", "sub"); @@ -32,7 +32,7 @@ describe("issue #1022 — path-scoped enabledModels respected by default fallbac }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index ddd42f052..6ecfc9d3e 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -30,7 +30,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const thinkingId = session.appendThinkingLevelChange("high"); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); expect(entries).toHaveLength(3); @@ -48,7 +48,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const modelId = session.appendModelChange("openai/gpt-4"); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); const modelEntry = entries.find(e => e.type === "model_change"); @@ -68,7 +68,7 @@ describe("SessionManager append and tree traversal", () => { const id1 = session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); const compactionId = session.appendCompaction("summary", undefined, id1, 1000); - const _id3 = session.appendMessage(userMsg("3")); + session.appendMessage(userMsg("3")); const entries = session.getEntries(); const compactionEntry = entries.find(e => e.type === "compaction"); @@ -89,7 +89,7 @@ describe("SessionManager append and tree traversal", () => { const msgId = session.appendMessage(userMsg("hello")); const customId = session.appendCustomEntry("my_hook", { key: "value" }); - const _msg2Id = session.appendMessage(assistantMsg("response")); + session.appendMessage(assistantMsg("response")); const entries = session.getEntries(); const customEntry = entries.find(e => e.type === "custom") as CustomEntry; @@ -151,8 +151,8 @@ describe("SessionManager append and tree traversal", () => { const id1 = session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); - const _id4 = session.appendMessage(assistantMsg("4")); + session.appendMessage(userMsg("3")); + session.appendMessage(assistantMsg("4")); const path = session.getBranch(id2); expect(path).toHaveLength(2); @@ -215,7 +215,7 @@ describe("SessionManager append and tree traversal", () => { it("handles multiple branches at same point", () => { const session = SessionManager.inMemory(); - const _id1 = session.appendMessage(userMsg("root")); + session.appendMessage(userMsg("root")); const id2 = session.appendMessage(assistantMsg("response")); // Branch A @@ -243,19 +243,19 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); // Main path: 1 -> 2 -> 3 -> 4 - const _id1 = session.appendMessage(userMsg("1")); + session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); const id3 = session.appendMessage(userMsg("3")); - const _id4 = session.appendMessage(assistantMsg("4")); + session.appendMessage(assistantMsg("4")); // Branch from 2: 2 -> 5 -> 6 session.branch(id2); const id5 = session.appendMessage(userMsg("5")); - const _id6 = session.appendMessage(assistantMsg("6")); + session.appendMessage(assistantMsg("6")); // Branch from 5: 5 -> 7 session.branch(id5); - const _id7 = session.appendMessage(userMsg("7")); + session.appendMessage(userMsg("7")); const tree = session.getTree(); @@ -276,7 +276,7 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); + session.appendMessage(assistantMsg("2")); const id3 = session.appendMessage(userMsg("3")); expect(session.getLeafId()).toBe(id3); @@ -296,7 +296,7 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); + session.appendMessage(assistantMsg("2")); session.branch(id1); const id3 = session.appendMessage(userMsg("branched")); @@ -312,8 +312,8 @@ describe("SessionManager append and tree traversal", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); - const _id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); + session.appendMessage(assistantMsg("2")); + session.appendMessage(userMsg("3")); const summaryId = session.branchWithSummary(id1, "Summary of abandoned work"); @@ -423,7 +423,7 @@ describe("createBranchedSession", () => { // Branch from 3: 3 -> 5 session.branch(id3); - const _id5 = session.appendMessage(userMsg("5")); + session.appendMessage(userMsg("5")); // Create branched session from id2 (should only have 1 -> 2) const result = session.createBranchedSession(id2); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 82d3ab110..d70c70af5 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; @@ -14,7 +14,7 @@ describe("Settings", () => { beforeEach(() => { // Reset global singleton so each test gets a fresh instance - _resetSettingsForTest(); + resetSettingsForTest(); // Use snowflake to isolate parallel test runs (SQLite files can't be shared) testDir = path.join(os.tmpdir(), "test-settings-tmp", Snowflake.next()); @@ -103,7 +103,7 @@ describe("Settings", () => { expect(workSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-5"]); expect(workSettings.get("disabledProviders")).toEqual(["ollama", "openai"]); - _resetSettingsForTest(); + resetSettingsForTest(); const privateSettings = await Settings.init({ cwd: privateDir, agentDir }); expect(privateSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "openai/gpt-5.2-codex"]); expect(privateSettings.get("disabledProviders")).toEqual(["ollama", "anthropic"]); diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index 33ee912ad..58ad0bb38 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { visibleWidth } from "@oh-my-pi/pi-tui"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../src/config/settings"; +import { resetSettingsForTest, Settings } from "../src/config/settings"; import type { StatusLineSegmentId } from "../src/config/settings-schema"; import { StatusLineComponent } from "../src/modes/components/status-line"; import type { SegmentContext } from "../src/modes/components/status-line/segments"; @@ -15,13 +15,13 @@ import { getSessionAccentAnsi, getSessionAccentHex } from "../src/utils/session- const originalProjectDir = getProjectDir(); beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); await initTheme(); }); afterAll(() => { - _resetSettingsForTest(); + resetSettingsForTest(); setProjectDir(originalProjectDir); }); diff --git a/packages/coding-agent/test/tools/auto-generated-guard.test.ts b/packages/coding-agent/test/tools/auto-generated-guard.test.ts index 90dbc393a..50a053878 100644 --- a/packages/coding-agent/test/tools/auto-generated-guard.test.ts +++ b/packages/coding-agent/test/tools/auto-generated-guard.test.ts @@ -2,14 +2,14 @@ import { beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { assertEditableFile, assertEditableFileContent } from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auto-gen-guard-")); await Settings.init({ inMemory: true, cwd: tempDir }); }); diff --git a/packages/coding-agent/test/tools/conflict-integration.test.ts b/packages/coding-agent/test/tools/conflict-integration.test.ts index f0b3112fa..15995831a 100644 --- a/packages/coding-agent/test/tools/conflict-integration.test.ts +++ b/packages/coding-agent/test/tools/conflict-integration.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ConflictHistory } from "@oh-my-pi/pi-coding-agent/tools/conflict-detect"; @@ -69,7 +69,7 @@ describe("read surfaces conflicts as a warning footer", () => { let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); }); @@ -295,7 +295,7 @@ describe("write resolves conflicts via conflict://N", () => { let tempDir: string; beforeAll(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); await Settings.init({ inMemory: true }); }); diff --git a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts index ee5bfd97e..0b3dfea14 100644 --- a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts +++ b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts @@ -435,7 +435,7 @@ describe("read tool URL handling", () => { const pageUrl = "https://bun.com/reference/bun/UnixSocketOptions"; const pageHtml = "

UnixSocketOptions

Page-specific docs.

"; const renderedMarkdown = `# UnixSocketOptions\n\n${"Page-specific API docs. ".repeat(8)}`; - using _missingSystemPython = withMissingSystemPython(); + using missingSystemPython = withMissingSystemPython(); const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async (requestedUrl: string) => { if (requestedUrl === pageUrl) { return { @@ -475,7 +475,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); + using hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); vi.spyOn(toolsManager, "ensureTool").mockResolvedValue(undefined); vi.spyOn(natives, "htmlToMarkdown").mockResolvedValue(renderedMarkdown); @@ -489,6 +489,8 @@ describe("read tool URL handling", () => { expect(requestedUrls).not.toContain("https://bun.com/.well-known/llms.txt"); expect(requestedUrls).not.toContain("https://bun.com/llms.txt"); expect(requestedUrls).not.toContain("https://bun.com/llms.md"); + void missingSystemPython; + void hook; }); it("uses section-scoped llms.txt fallback without requesting the site-wide file", async () => { @@ -497,8 +499,8 @@ describe("read tool URL handling", () => { const pageUrl = "https://example.com/docs/reference/widget"; const pageHtml = "

Widget

"; const lowQualityRender = `${"Please enable JavaScript to view this page.\n".repeat(6)}${"navigation\n".repeat(4)}`; - using _missingSystemPython = withMissingSystemPython(); - const _execSpy = vi.spyOn(ptree, "exec").mockResolvedValue({ ok: true, stdout: lowQualityRender } as never); + using missingSystemPython = withMissingSystemPython(); + vi.spyOn(ptree, "exec").mockResolvedValue({ ok: true, stdout: lowQualityRender } as never); const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async (requestedUrl: string) => { if (requestedUrl === pageUrl) { return { @@ -555,7 +557,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); + using hook = hookFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); vi.spyOn(toolsManager, "ensureTool").mockResolvedValue("/usr/bin/trafilatura"); const result = await tool.execute("fetch-section-llms", { path: pageUrl }); @@ -570,6 +572,8 @@ describe("read tool URL handling", () => { expect(requestedUrls).not.toContain("https://example.com/.well-known/llms.txt"); expect(requestedUrls).not.toContain("https://example.com/llms.txt"); expect(requestedUrls).not.toContain("https://example.com/llms.md"); + void missingSystemPython; + void hook; }); it("prefers Parallel extract before other HTML renderers when configured", async () => { process.env.PARALLEL_API_KEY = "test-parallel-key"; @@ -608,7 +612,7 @@ describe("read tool URL handling", () => { content: "", }; }); - using _hook = hookFetch(input => { + using parallelExtractHook = hookFetch(input => { const requestedUrl = String(input); if (requestedUrl === "https://api.parallel.ai/v1beta/extract") { return new Response( @@ -645,6 +649,7 @@ describe("read tool URL handling", () => { expect(textBlock?.text).toContain("Parallel-rendered content"); expect(ensureToolSpy).not.toHaveBeenCalled(); expect(htmlToMarkdownSpy).not.toHaveBeenCalled(); + void parallelExtractHook; }); it("reuses cached output for repeated plain URL reads", async () => { diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index f6bd524e5..a311e05f3 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -2,9 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { buildSearchDateQualifier, @@ -40,22 +38,6 @@ function createSession( }; } -function _createToolContext(settings: Settings): AgentToolContext { - return { - sessionManager: SessionManager.inMemory(), - settings, - modelRegistry: { - find: () => undefined, - getAll: () => [], - getApiKey: async () => undefined, - } as unknown as AgentToolContext["modelRegistry"], - model: undefined, - isIdle: () => true, - hasQueuedMessages: () => false, - abort: () => {}, - } as AgentToolContext; -} - function runGit(cwd: string, args: string[]): string { const result = Bun.spawnSync(["git", ...args], { cwd, diff --git a/packages/coding-agent/test/tools/image-gen.test.ts b/packages/coding-agent/test/tools/image-gen.test.ts index 160c44cfc..853386c43 100644 --- a/packages/coding-agent/test/tools/image-gen.test.ts +++ b/packages/coding-agent/test/tools/image-gen.test.ts @@ -21,30 +21,6 @@ afterEach(async () => { setPreferredImageProvider("auto"); }); -function _getHeaderValue(headers: RequestInit["headers"] | undefined, name: string): string | undefined { - if (!headers) return undefined; - if (headers instanceof Headers) { - return headers.get(name) ?? headers.get(name.toLowerCase()) ?? headers.get(name.toUpperCase()) ?? undefined; - } - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - if (key.toLowerCase() === name.toLowerCase()) { - return value; - } - } - return undefined; - } - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === name.toLowerCase()) { - if (typeof value === "string") { - return value; - } - return value.join(","); - } - } - return undefined; -} - describe("imageGenTool", () => { it("e2e writes OpenAI Responses image_generation WebP output to a temp file", async () => { let requestUrl: string | undefined; diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index e075649a7..064cf01d6 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -257,7 +257,7 @@ describe("vim tool", () => { let tmpDir: string; beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-tool-")); await Settings.init({ inMemory: true, cwd: tmpDir }); }); diff --git a/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts b/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts index 64f054f6e..42de8a4c8 100644 --- a/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts +++ b/packages/coding-agent/test/tools/web-scrapers/youtube-parallel.test.ts @@ -1,18 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../../../src/config/settings"; +import { resetSettingsForTest, Settings } from "../../../src/config/settings"; import * as toolsManager from "../../../src/utils/tools-manager"; import { handleYouTube } from "../../../src/web/scrapers/youtube"; describe("handleYouTube with Parallel extract", () => { beforeEach(async () => { - _resetSettingsForTest(); + resetSettingsForTest(); process.env.PARALLEL_API_KEY = "test-parallel-key"; await Settings.init({ inMemory: true, overrides: { "providers.parallelFetch": true } }); }); afterEach(() => { - _resetSettingsForTest(); + resetSettingsForTest(); vi.restoreAllMocks(); delete process.env.PARALLEL_API_KEY; }); diff --git a/packages/coding-agent/test/tools/web-search-searxng.test.ts b/packages/coding-agent/test/tools/web-search-searxng.test.ts index 3ac128118..db7289eb2 100644 --- a/packages/coding-agent/test/tools/web-search-searxng.test.ts +++ b/packages/coding-agent/test/tools/web-search-searxng.test.ts @@ -3,13 +3,13 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import { _resetSettingsForTest, Settings } from "../../src/config/settings"; +import { resetSettingsForTest, Settings } from "../../src/config/settings"; import { searchSearXNG } from "../../src/web/search/providers/searxng"; describe("SearXNG web search provider", () => { afterEach(() => { vi.restoreAllMocks(); - _resetSettingsForTest(); + resetSettingsForTest(); delete process.env.SEARXNG_ENDPOINT; delete process.env.SEARXNG_TOKEN; delete process.env.SEARXNG_BASIC_USERNAME; diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index d69780b04..c99837827 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from "bun:test"; -import { _resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { resolveUpdateMethodForTest } from "../src/cli/update-cli"; describe("update-cli install target detection", () => { it("uses bun update when prioritized omp is inside bun global bin", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin"); + const method = resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("bun"); }); it("uses binary update when prioritized omp is outside bun global bin", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin"); + const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin"); expect(method).toBe("binary"); }); it("uses binary update when bun global bin cannot be resolved", () => { - const method = _resolveUpdateMethodForTest("/Users/test/.local/bin/omp", undefined); + const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", undefined); expect(method).toBe("binary"); }); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 49801828e..a75b1bd83 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -17,6 +17,28 @@ import { e2eApiKey } from "../../ai/test/oauth"; export { e2eApiKey }; +/** + * Options for creating a test session. + */ +export interface TestSessionOptions { + /** Use in-memory session (no file persistence) */ + inMemory?: boolean; + /** Custom system prompt */ + systemPrompt?: string | string[]; + /** Custom settings overrides */ + settingsOverrides?: Record; +} + +/** + * Resources returned by createTestSession that need cleanup. + */ +export interface TestSessionContext { + session: AgentSession; + sessionManager: SessionManager; + tempDir: string; + cleanup: () => Promise; +} + /** * Create a minimal user message for testing. */ @@ -47,28 +69,6 @@ export function assistantMsg(text: string) { }; } -/** - * Options for creating a test session. - */ -export interface TestSessionOptions { - /** Use in-memory session (no file persistence) */ - inMemory?: boolean; - /** Custom system prompt */ - systemPrompt?: string | string[]; - /** Custom settings overrides */ - settingsOverrides?: Record; -} - -/** - * Resources returned by createTestSession that need cleanup. - */ -export interface TestSessionContext { - session: AgentSession; - sessionManager: SessionManager; - tempDir: string; - cleanup: () => Promise; -} - /** * Create an AgentSession for testing with proper setup and cleanup. * Use this for e2e tests that need real LLM calls. @@ -123,40 +123,3 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi return { session, sessionManager, tempDir, cleanup }; } - -/** - * Build a session tree for testing using SessionManager. - * Returns the IDs of all created entries. - * - * Example tree structure: - * ``` - * u1 -> a1 -> u2 -> a2 - * -> u3 -> a3 (branch from a1) - * u4 -> a4 (another root) - * ``` - */ -export function buildTestTree( - session: SessionManager, - structure: { - messages: Array<{ role: "user" | "assistant"; text: string; branchFrom?: string }>; - }, -): Map { - const ids = new Map(); - - for (const msg of structure.messages) { - if (msg.branchFrom) { - const branchFromId = ids.get(msg.branchFrom); - if (!branchFromId) { - throw new Error(`Cannot branch from unknown entry: ${msg.branchFrom}`); - } - session.branch(branchFromId); - } - - const id = - msg.role === "user" ? session.appendMessage(userMsg(msg.text)) : session.appendMessage(assistantMsg(msg.text)); - - ids.set(msg.text, id); - } - - return ids; -} diff --git a/packages/natives/package.json b/packages/natives/package.json index 164ba46d9..54e608e82 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,64 +1,63 @@ { - "name": "@oh-my-pi/pi-natives", - "version": "15.0.0", - "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", - "type": "module", - "homepage": "https://github.com/can1357/oh-my-pi", - "author": "Can Boluk", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/can1357/oh-my-pi.git", - "directory": "packages/natives" - }, - "bugs": { - "url": "https://github.com/can1357/oh-my-pi/issues" - }, - "keywords": [ - "napi", - "rust", - "native", - "grep", - "text-processing", - "clipboard", - "image", - "pty", - "shell", - "syntax-highlighting" - ], - "main": "./native/index.js", - "types": "./native/index.d.ts", - "scripts": { - "build": "bun scripts/build-native.ts", - "check": "biome check . && bun run check:types", - "check:types": "tsgo -p tsconfig.json --noEmit", - "lint": "biome lint .", - "test": "bun test", - "fix": "biome check --write --unsafe .", - "fmt": "biome format --write .", - "embed:native": "bun scripts/embed-native.ts", - "bench": "bun bench/grep.ts" - }, - "devDependencies": { - "@napi-rs/cli": "catalog:", - "@types/bun": "catalog:" - }, - "engines": { - "bun": ">=1.3.14" - }, - "napi": { - "binaryName": "pi_natives", - "triples": {} - }, - "files": [ - "src", - "native", - "README.md" - ], - "exports": { - ".": { - "types": "./native/index.d.ts", - "import": "./native/index.js" - } - } + "name": "@oh-my-pi/pi-natives", + "version": "15.0.0", + "description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", + "type": "module", + "homepage": "https://github.com/can1357/oh-my-pi", + "author": "Can Boluk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/natives" + }, + "bugs": { + "url": "https://github.com/can1357/oh-my-pi/issues" + }, + "keywords": [ + "napi", + "rust", + "native", + "grep", + "text-processing", + "clipboard", + "image", + "pty", + "shell", + "syntax-highlighting" + ], + "main": "./native/index.js", + "types": "./native/index.d.ts", + "scripts": { + "build": "bun scripts/build-native.ts", + "check": "biome check . && bun run check:types", + "check:types": "tsgo -p tsconfig.json --noEmit", + "lint": "biome lint .", + "test": "bun test", + "fix": "biome check --write --unsafe .", + "fmt": "biome format --write .", + "embed:native": "bun scripts/embed-native.ts", + "bench": "bun bench/grep.ts" + }, + "devDependencies": { + "@types/bun": "catalog:" + }, + "engines": { + "bun": ">=1.3.14" + }, + "napi": { + "binaryName": "pi_natives", + "triples": {} + }, + "files": [ + "src", + "native", + "README.md" + ], + "exports": { + ".": { + "types": "./native/index.d.ts", + "import": "./native/index.js" + } + } } diff --git a/packages/natives/test/issue-892-repro.test.ts b/packages/natives/test/issue-892-repro.test.ts index 5c3400839..4523f0fea 100644 --- a/packages/natives/test/issue-892-repro.test.ts +++ b/packages/natives/test/issue-892-repro.test.ts @@ -16,7 +16,6 @@ import * as path from "node:path"; const nativeDir = path.join(import.meta.dir, "..", "native"); const indexJsPath = path.join(nativeDir, "index.js"); const indexDtsPath = path.join(nativeDir, "index.d.ts"); -const _packageJsonPath = path.join(import.meta.dir, "..", "package.json"); const PUBLIC_SYMBOL_RE = /^export declare (?:class|function|enum) (\w+)/gm; diff --git a/packages/stats/src/client/components/BehaviorChart.tsx b/packages/stats/src/client/components/BehaviorChart.tsx index 7bcf00e8e..9ebe78d3c 100644 --- a/packages/stats/src/client/components/BehaviorChart.tsx +++ b/packages/stats/src/client/components/BehaviorChart.tsx @@ -11,45 +11,26 @@ import { Title, Tooltip, } from "chart.js"; -import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Bar, Line } from "react-chartjs-2"; import type { BehaviorTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + ChartFrame, + type ChartSeries, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "./chart-shared"; ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - -const CHART_THEMES = { - dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", - }, - light: { - legendLabel: "#475569", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", - }, -} as const; - const METRIC_OPTIONS = [ { value: "yelling", label: "Yelling" }, { value: "profanity", label: "Profanity" }, @@ -87,98 +68,36 @@ function ratePercent(hits: number, messages: number): number { return (hits / messages) * 100; } -interface ChartSeries { - labels: string[]; - datasets: Array<{ label: string; data: number[] }>; -} - interface DailyBucket { hits: number; messages: number; } function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - const byDay = new Map(); - for (const point of points) { - const bucket = byDay.get(point.timestamp) ?? { hits: 0, messages: 0 }; - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - byDay.set(point.timestamp, bucket); - } - - const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); - return { - labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), - datasets: [ - { - label: METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits", - data: sorted.map(([, b]) => ratePercent(b.hits, b.messages)), - }, - ], - }; + const label = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits"; + return buildAggregateTimeSeries(points, label, { + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); } -function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric, topN = 5): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - +function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { // Rank by message volume so the models you actually use surface first, - // matching the Behavior-by-Model table. - const totals = new Map(); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - if (existing) { - existing.messages += point.messages; - } else { - totals.set(key, { model: point.model, provider: point.provider, messages: point.messages }); - } - } - - const sorted = [...totals.entries()].sort((a, b) => b[1].messages - a[1].messages); - const topEntries = sorted.slice(0, topN); - const topKeys = new Set(topEntries.map(([key]) => key)); - - const modelCount = new Map(); - for (const [, { model }] of topEntries) { - modelCount.set(model, (modelCount.get(model) ?? 0) + 1); - } - const labelByKey = new Map(); - for (const [key, { model, provider }] of topEntries) { - labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); - } - - const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); - const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); - const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); - if (hasOther) seriesNames.push("Other"); - - // Track hits and messages separately per (day, series), then convert to a - // rate at the end. Summing rates would weight low-volume days unfairly. - const dayMap = new Map>(); - for (const day of allDays) dayMap.set(day, {}); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; - const row = dayMap.get(point.timestamp); - if (!row) continue; - const bucket = row[label] ?? { hits: 0, messages: 0 }; - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - row[label] = bucket; - } - - return { - labels: allDays.map(ts => format(new Date(ts), "MMM d")), - datasets: seriesNames.map(name => ({ - label: name, - data: allDays.map(day => { - const bucket = dayMap.get(day)?.[name]; - return bucket ? ratePercent(bucket.hits, bucket.messages) : 0; - }), - })), - }; + // matching the Behavior-by-Model table. Per-bucket math tracks hits + + // messages separately so the final rate isn't skewed by low-volume days. + return buildTopNByModelSeries(points, { + rankWeight: point => point.messages, + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); } export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { @@ -192,65 +111,36 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { [behaviorSeries, byModel, metric], ); - const sharedPlugins = { - legend: { - display: byModel, - position: "top" as const, - align: "start" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - boxWidth: 8, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - padding: 12, - cornerRadius: 8, - callbacks: { - label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { - const label = context.dataset.label ?? "Hits"; - const value = context.parsed.y ?? 0; - return `${label}: ${formatRateAxis(value)}`; - }, - }, - }, - }; + const sharedPlugins = buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Hits", + formatValue: formatRateAxis, + }); - const sharedScaleBase = { - grid: { color: chartTheme.grid, drawBorder: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }; + const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis }); - const yScale = { - ...sharedScaleBase, - ticks: { - ...sharedScaleBase.ticks, - callback: (value: number | string) => formatRateAxis(Number(value)), - }, - min: 0, - }; + const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? ""; + const metricTabs = ( +
+ {METRIC_OPTIONS.map(opt => ( + + ))} +
+ ); + let chartNode: React.ReactNode; if (byModel) { const lineData = { labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, - fill: true, - tension: 0, - pointRadius: 3, - pointHoverRadius: 4, - borderWidth: 2, - })), + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), }; const lineOptions: ChartOptions<"line"> = { @@ -261,114 +151,39 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { scales: { x: sharedScaleBase, y: yScale }, }; - return ( - - - - ); + chartNode = ; + } else { + const barData = { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + + const barOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: sharedPlugins, + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 8 } }, + }; + + chartNode = ; } - const barData = { - labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderWidth: 0, - borderRadius: 3, - })), - }; - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: sharedPlugins, - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 8 } }, - }; - return ( - - - - ); -} - -interface ChartWrapperProps { - byModel: boolean; - metric: Metric; - onByModelChange: (v: boolean) => void; - onMetricChange: (v: Metric) => void; - empty: boolean; - children: React.ReactNode; -} - -function ChartWrapper({ byModel, metric, onByModelChange, onMetricChange, empty, children }: ChartWrapperProps) { - const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? ""; - return ( -
-
-
-

User Tantrums

-

{metricLabel} as % of user messages per day

-
-
-
- {METRIC_OPTIONS.map(opt => ( - - ))} -
-
- - -
-
-
-
- {empty ? ( -
- No behavioral data yet. Sync to scan your sessions. -
- ) : ( -
{children}
- )} -
-
+ {chartNode} + ); } diff --git a/packages/stats/src/client/components/BehaviorModelsTable.tsx b/packages/stats/src/client/components/BehaviorModelsTable.tsx index d001f7949..16b5fccf6 100644 --- a/packages/stats/src/client/components/BehaviorModelsTable.tsx +++ b/packages/stats/src/client/components/BehaviorModelsTable.tsx @@ -9,24 +9,29 @@ import { Tooltip, } from "chart.js"; import { format } from "date-fns"; -import { ChevronDown, ChevronUp } from "lucide-react"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesSingleAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + MODEL_COLORS, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "./models-table-shared"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - const SERIES_COLORS = { yelling: "#fbbf24", // amber profanity: "#f87171", // red @@ -34,29 +39,6 @@ const SERIES_COLORS = { frustration: "#22d3ee", // cyan - new semantic signals } as const; -const CHART_THEMES = { - dark: { - legendLabel: "#cbd5e1", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#94a3b8", - }, - light: { - legendLabel: "#334155", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#475569", - }, -} as const; - -type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES]; - interface BehaviorModelsTableProps { models: BehaviorModelStats[]; behaviorSeries: BehaviorTimeSeriesPoint[]; @@ -107,7 +89,7 @@ function formatRate(total: number, messages: number): string { export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) { const [expandedKey, setExpandedKey] = useState(null); const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; + const chartTheme = TABLE_CHART_THEMES[theme]; const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]); @@ -119,153 +101,137 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa }); return ( -
-
-

Behavior by Model

-

- How often each model elicited a tantrum — rates are per user message -

-
+ + -
-
-
Model
-
Messages
-
CAPS %
-
Profanity %
-
Anguish %
-
Frustration %
-
Hits %
-
Trend
-
-
+ + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const trend = trendByKey.get(key)?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; + const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; -
- {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const trend = trendByKey.get(key)?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; - const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; - - return ( -
- - - {isExpanded && ( -
-
-
- - - - - - - -
-
- {trend.length === 0 ? ( -
- No data available -
- ) : ( - - )} -
-
+
+ {trend.length === 0 ? ( + + ) : ( + + )}
- )} -
- ); - })} - {sortedModels.length === 0 && ( -
- No user behavior recorded for this range yet. -
- )} -
-
-
+
+ } + /> + ); + })} + {sortedModels.length === 0 ? ( +
+ No user behavior recorded for this range yet. +
+ ) : null} + +
); } @@ -302,111 +268,22 @@ function DetailRow({ ); } -function TrendSparkline({ data, color }: { data: DailyPoint[]; color: string }) { +function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) { const chartData = { labels: data.map(d => format(new Date(d.timestamp), "MMM d")), datasets: [ - { - data: data.map(d => d.total), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, + { label: "CAPS", data: data.map(d => d.yelling), ...lineSeriesStyle(SERIES_COLORS.yelling) }, + { label: "Profanity", data: data.map(d => d.profanity), ...lineSeriesStyle(SERIES_COLORS.profanity) }, + { label: "Anguish", data: data.map(d => d.anguish), ...lineSeriesStyle(SERIES_COLORS.anguish) }, + { label: "Frustration", data: data.map(d => d.frustration), ...lineSeriesStyle(SERIES_COLORS.frustration) }, ], }; const options = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { - x: { display: false }, - y: { display: false, min: 0 }, - }, - }; - - return ; -} - -function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: ChartTheme }) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { - label: "CAPS", - data: data.map(d => d.yelling), - borderColor: SERIES_COLORS.yelling, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Profanity", - data: data.map(d => d.profanity), - borderColor: SERIES_COLORS.profanity, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Anguish", - data: data.map(d => d.anguish), - borderColor: SERIES_COLORS.anguish, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - { - label: "Frustration", - data: data.map(d => d.frustration), - borderColor: SERIES_COLORS.frustration, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: "top" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - cornerRadius: 8, - }, - }, - scales: { - x: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - min: 0, - }, - }, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesSingleAxis(chartTheme), }; return ; diff --git a/packages/stats/src/client/components/CostChart.tsx b/packages/stats/src/client/components/CostChart.tsx index 291c0d699..205daf943 100644 --- a/packages/stats/src/client/components/CostChart.tsx +++ b/packages/stats/src/client/components/CostChart.tsx @@ -12,45 +12,30 @@ import { Title, Tooltip, } from "chart.js"; -import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Bar, Line } from "react-chartjs-2"; import type { CostTimeSeriesPoint } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + ChartFrame, + type ChartSeries, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "./chart-shared"; ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - -const CHART_THEMES = { - dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", - barLabel: "rgba(248, 250, 252, 0.7)", - }, - light: { - legendLabel: "#475569", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", - barLabel: "rgba(15, 23, 42, 0.6)", - }, +/** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */ +const BAR_LABEL_COLORS = { + dark: "rgba(248, 250, 252, 0.7)", + light: "rgba(15, 23, 42, 0.6)", } as const; interface CostChartProps { @@ -83,6 +68,28 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> { }; } +function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { + return buildAggregateTimeSeries(points, "Cost", { + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); +} + +function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries { + // Rank models by total cost; per-day buckets are simple cost sums. + return buildTopNByModelSeries(points, { + rankWeight: point => point.cost, + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); +} + export function CostChart({ costSeries }: CostChartProps) { const [byModel, setByModel] = useState(false); const theme = useSystemTheme(); @@ -93,70 +100,28 @@ export function CostChart({ costSeries }: CostChartProps) { [costSeries, byModel], ); - const sharedPlugins = { - legend: { - display: byModel, - position: "top" as const, - align: "start" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - boxWidth: 8, - }, + const sharedPlugins = buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Cost", + formatValue: v => `$${Math.round(v)}`, + footer: items => { + if (!byModel || items.length < 2) return undefined; + const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); + return `Total: $${Math.round(total)}`; }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - padding: 12, - cornerRadius: 8, - callbacks: { - label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { - const label = context.dataset.label ?? "Cost"; - const value = context.parsed.y ?? 0; - return `${label}: $${Math.round(value)}`; - }, - footer: (items: { parsed: { y: number | null } }[]) => { - if (!byModel || items.length < 2) return undefined; - const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); - return `Total: $${Math.round(total)}`; - }, - }, - }, - }; + }); - const sharedScaleBase = { - grid: { color: chartTheme.grid, drawBorder: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }; - - const yScale = { - ...sharedScaleBase, - ticks: { - ...sharedScaleBase.ticks, - callback: (value: number | string) => `$${Math.round(Number(value))}`, - }, - min: 0, - }; + const { sharedScaleBase, yScale } = buildSharedScales({ + chartTheme, + formatY: v => `$${Math.round(v)}`, + }); + let chartNode: React.ReactNode; if (byModel) { const lineData = { labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, - fill: true, - tension: 0, - pointRadius: 3, - pointHoverRadius: 4, - borderWidth: 2, - })), + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), }; const lineOptions: ChartOptions<"line"> = { @@ -167,166 +132,40 @@ export function CostChart({ costSeries }: CostChartProps) { scales: { x: sharedScaleBase, y: yScale }, }; - return ( - - - - ); + chartNode = ; + } else { + const barData = { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + + const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]); + + const barOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 24 } }, + }; + + chartNode = ; } - const barData = { - labels: chartData.labels, - datasets: chartData.datasets.map((ds, index) => ({ - label: ds.label, - data: ds.data, - backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - borderWidth: 0, - borderRadius: 3, - })), - }; - - const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel); - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 24 } }, - }; - return ( - - - + + {chartNode} + ); } - -interface ChartWrapperProps { - byModel: boolean; - onByModelChange: (v: boolean) => void; - empty: boolean; - children: React.ReactNode; -} - -function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) { - return ( -
-
-
-

Daily Cost

-

API spending over time

-
-
-
- - -
-
-
-
- {empty ? ( -
- No cost data available -
- ) : ( -
{children}
- )} -
-
- ); -} - -interface ChartSeries { - labels: string[]; - datasets: Array<{ label: string; data: number[] }>; -} - -function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - const byDay = new Map(); - for (const point of points) { - byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost); - } - - const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); - return { - labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), - datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }], - }; -} - -function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries { - if (points.length === 0) return { labels: [], datasets: [] }; - - // Rank models by total cost - const totals = new Map(); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - if (existing) { - existing.total += point.cost; - } else { - totals.set(key, { model: point.model, provider: point.provider, total: point.cost }); - } - } - - const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total); - const topEntries = sorted.slice(0, topN); - const topKeys = new Set(topEntries.map(([key]) => key)); - - // Disambiguate model labels when same model name appears from multiple providers - const modelCount = new Map(); - for (const [, { model }] of topEntries) { - modelCount.set(model, (modelCount.get(model) ?? 0) + 1); - } - const labelByKey = new Map(); - for (const [key, { model, provider }] of topEntries) { - labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); - } - - // Collect all day buckets - const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); - - // Build per-day, per-series totals - const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); - const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); - if (hasOther) seriesNames.push("Other"); - - const dayMap = new Map>(); - for (const day of allDays) { - dayMap.set(day, {}); - } - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; - const row = dayMap.get(point.timestamp)!; - row[label] = (row[label] ?? 0) + point.cost; - } - - return { - labels: allDays.map(ts => format(new Date(ts), "MMM d")), - datasets: seriesNames.map(name => ({ - label: name, - data: allDays.map(day => dayMap.get(day)?.[name] ?? 0), - })), - }; -} diff --git a/packages/stats/src/client/components/ModelsTable.tsx b/packages/stats/src/client/components/ModelsTable.tsx index 97b53faa3..4f0fa629f 100644 --- a/packages/stats/src/client/components/ModelsTable.tsx +++ b/packages/stats/src/client/components/ModelsTable.tsx @@ -9,46 +9,31 @@ import { Tooltip, } from "chart.js"; import { format } from "date-fns"; -import { ChevronDown, ChevronUp } from "lucide-react"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; import type { ModelPerformancePoint, ModelStats } from "../types"; import { useSystemTheme } from "../useSystemTheme"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesDualAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + MODEL_COLORS, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "./models-table-shared"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; +const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; -const CHART_THEMES = { - dark: { - legendLabel: "#cbd5e1", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#94a3b8", - }, - light: { - legendLabel: "#334155", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#475569", - }, -} as const; - -type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES]; interface ModelsTableProps { models: ModelStats[]; performanceSeries: ModelPerformancePoint[]; @@ -69,184 +54,129 @@ export function ModelsTable({ models, performanceSeries }: ModelsTableProps) { const performanceSeriesByKey = useMemo(() => buildModelPerformanceLookup(performanceSeries), [performanceSeries]); const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; + const chartTheme = TABLE_CHART_THEMES[theme]; const sortedModels = [...models].sort( (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens), ); return ( -
-
-

Model Statistics

-
+ + -
-
-
Model
-
Requests
-
Cost
-
Tokens
-
Tokens/s
-
TTFT
-
14d Trend
-
-
+ + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const performance = performanceSeriesByKey.get(key); + const trendData = performance?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const errorRate = model.errorRate * 100; -
- {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const performance = performanceSeriesByKey.get(key); - const trendData = performance?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const errorRate = model.errorRate * 100; - - return ( -
- - - {isExpanded && ( -
-
-
-
-
Quality
-
-
- Error rate - 5 - ? "text-[var(--accent-red)]" - : "text-[var(--accent-green)]" - } - > - {errorRate.toFixed(1)}% - -
-
- Cache rate - - {(model.cacheRate * 100).toFixed(1)}% - -
-
+
Quality
+
+
+ Error rate + 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]" + } + > + {errorRate.toFixed(1)}% +
-
-
Latency
-
-
- Avg duration - - {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} - -
-
- Avg TTFT - - {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} - -
-
+
+ Cache rate + + {(model.cacheRate * 100).toFixed(1)}% +
-
- {trendData.length === 0 ? ( -
- No data available -
- ) : ( - - )} +
+
+
Latency
+
+
+ Avg duration + + {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} + +
+
+ Avg TTFT + + {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} + +
- )} -
- ); - })} -
-
-
+
+ {trendData.length === 0 ? ( + + ) : ( + + )} +
+
+ } + /> + ); + })} + + ); } -function TrendChart({ - data, - color, -}: { - data: Array<{ timestamp: number; avgTokensPerSecond: number | null }>; - color: string; -}) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { - data: data.map(d => d.avgTokensPerSecond ?? 0), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { - x: { display: false }, - y: { display: false, min: 0 }, - }, - }; - - return ; -} - function PerformanceChart({ data, color, @@ -254,7 +184,7 @@ function PerformanceChart({ }: { data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>; color: string; - chartTheme: ChartTheme; + chartTheme: TableChartTheme; }) { const chartData = { labels: data.map(d => format(new Date(d.timestamp), "MMM d")), @@ -262,21 +192,13 @@ function PerformanceChart({ { label: "TTFT", data: data.map(d => d.avgTtftSeconds ?? null), - borderColor: "#fbbf24", - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, + ...lineSeriesStyle("#fbbf24"), yAxisID: "y" as const, }, { label: "Tokens/s", data: data.map(d => d.avgTokensPerSecond ?? null), - borderColor: color, - backgroundColor: "transparent", - tension: 0.4, - pointRadius: 0, - borderWidth: 2, + ...lineSeriesStyle(color), yAxisID: "y1" as const, }, ], @@ -285,46 +207,8 @@ function PerformanceChart({ const options = { responsive: true, maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: "top" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - cornerRadius: 8, - }, - }, - scales: { - x: { - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y: { - type: "linear" as const, - display: true, - position: "left" as const, - grid: { color: chartTheme.grid }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - y1: { - type: "linear" as const, - display: true, - position: "right" as const, - grid: { drawOnChartArea: false }, - ticks: { color: chartTheme.tick, font: { size: 11 } }, - }, - }, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesDualAxis(chartTheme), }; return ; diff --git a/packages/stats/src/client/components/RequestDetail.tsx b/packages/stats/src/client/components/RequestDetail.tsx index 73dff894c..4bb5e223c 100644 --- a/packages/stats/src/client/components/RequestDetail.tsx +++ b/packages/stats/src/client/components/RequestDetail.tsx @@ -35,13 +35,11 @@ export function RequestDetail({ id, onClose }: RequestDetailProps) { if (!details) return null; return ( - // biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop dismissal
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation for modal content */}
; +} + +interface TooltipItem { + parsed: { y: number | null }; +} + +/** Tooltip + legend config common to bar and line variants of the time charts. */ +export function buildSharedPlugins(opts: { + chartTheme: ChartTheme; + showLegend: boolean; + defaultLabel: string; + formatValue: (n: number) => string; + footer?: (items: TooltipItem[]) => string | undefined; +}) { + const { chartTheme, showLegend, defaultLabel, formatValue, footer } = opts; + return { + legend: { + display: showLegend, + position: "top" as const, + align: "start" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + boxWidth: 8, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + callbacks: { + label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const label = ctx.dataset.label ?? defaultLabel; + const value = ctx.parsed.y ?? 0; + return `${label}: ${formatValue(value)}`; + }, + ...(footer ? { footer } : {}), + }, + }, + }; +} + +/** Y-axis tick formatter + grid/tick styling shared by both charts. */ +export function buildSharedScales(opts: { chartTheme: ChartTheme; formatY: (n: number) => string }) { + const { chartTheme, formatY } = opts; + const sharedScaleBase = { + grid: { color: chartTheme.grid, drawBorder: false }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }; + const yScale = { + ...sharedScaleBase, + ticks: { + ...sharedScaleBase.ticks, + callback: (value: number | string) => formatY(Number(value)), + }, + min: 0, + }; + return { sharedScaleBase, yScale }; +} + +/** Stylistic defaults for a single line dataset in a stacked/by-model chart. */ +export function lineDatasetStyle(color: string) { + return { + borderColor: color, + backgroundColor: `${color}20`, + fill: true, + tension: 0, + pointRadius: 3, + pointHoverRadius: 4, + borderWidth: 2, + }; +} + +/** Stylistic defaults for a single bar dataset in a stacked chart. */ +export function barDatasetStyle(color: string) { + return { + backgroundColor: color, + borderColor: color, + borderWidth: 0, + borderRadius: 3, + }; +} + +/** + * Map a generic ChartSeries' datasets through a per-index style function so + * callers can supply line or bar styling without repeating the label/data + * spread at every chart site. + */ +export function styleDatasets(series: ChartSeries, styleFor: (index: number) => Record) { + return series.datasets.map((ds, index) => ({ + label: ds.label, + data: ds.data, + ...styleFor(index), + })); +} + +/** + * Bucket points by day into a single aggregate series. Caller supplies the + * per-bucket accumulator + final value extractor; mirrors the shape of + * `buildTopNByModelSeries` for the non-by-model variant of each time chart. + */ +export function buildAggregateTimeSeries( + points: T[], + label: string, + opts: { + initBucket: () => B; + accumulate: (bucket: B, point: T) => void; + bucketToValue: (bucket: B) => number; + }, +): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + const { initBucket, accumulate, bucketToValue } = opts; + const byDay = new Map(); + for (const point of points) { + const bucket = byDay.get(point.timestamp) ?? initBucket(); + accumulate(bucket, point); + byDay.set(point.timestamp, bucket); + } + const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]); + return { + labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")), + datasets: [{ label, data: sorted.map(([, bucket]) => bucketToValue(bucket)) }], + }; +} + +interface ModelKeyedPoint { + timestamp: number; + model: string; + provider: string; +} + +/** + * Bucket points by day and by top-N model (with an "Other" rollup), producing + * a ChartSeries. Caller controls how points contribute to ranking and to each + * day-bucket value via the `rankWeight`/`accumulate`/`bucketToValue` callbacks + * — keeps the behavior chart's rate math separate from the cost chart's sum. + */ +export function buildTopNByModelSeries( + points: T[], + opts: { + topN?: number; + rankWeight: (point: T) => number; + initBucket: () => B; + accumulate: (bucket: B, point: T) => void; + bucketToValue: (bucket: B) => number; + }, +): ChartSeries { + if (points.length === 0) return { labels: [], datasets: [] }; + const { topN = 5, rankWeight, initBucket, accumulate, bucketToValue } = opts; + + const totals = new Map(); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const existing = totals.get(key); + if (existing) { + existing.weight += rankWeight(point); + } else { + totals.set(key, { model: point.model, provider: point.provider, weight: rankWeight(point) }); + } + } + + const sorted = [...totals.entries()].sort((a, b) => b[1].weight - a[1].weight); + const topEntries = sorted.slice(0, topN); + const topKeys = new Set(topEntries.map(([key]) => key)); + + const modelCount = new Map(); + for (const [, { model }] of topEntries) { + modelCount.set(model, (modelCount.get(model) ?? 0) + 1); + } + const labelByKey = new Map(); + for (const [key, { model, provider }] of topEntries) { + labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model); + } + + const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); + const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key); + const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`)); + if (hasOther) seriesNames.push("Other"); + + const dayMap = new Map>(); + for (const day of allDays) dayMap.set(day, {}); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; + const row = dayMap.get(point.timestamp); + if (!row) continue; + const bucket = row[label] ?? initBucket(); + accumulate(bucket, point); + row[label] = bucket; + } + + return { + labels: allDays.map(ts => format(new Date(ts), "MMM d")), + datasets: seriesNames.map(name => ({ + label: name, + data: allDays.map(day => { + const bucket = dayMap.get(day)?.[name]; + return bucket ? bucketToValue(bucket) : 0; + }), + })), + }; +} + +/** All Models / By Model segmented toggle — identical UI in every time chart. */ +export function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) { + return ( +
+ + +
+ ); +} + +/** + * Outer surface card used by both time charts. `controls` slot covers + * chart-specific tabs (e.g. behavior metric picker); the by-model toggle and + * empty-state are part of the frame so callers don't redeclare them. + */ +export function ChartFrame({ + title, + subtitle, + empty, + emptyMessage, + controls, + byModel, + onByModelChange, + children, +}: { + title: string; + subtitle: string; + empty: boolean; + emptyMessage: string; + controls?: React.ReactNode; + byModel: boolean; + onByModelChange: (v: boolean) => void; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+
+ {controls} + +
+
+
+ {empty ? ( +
+ {emptyMessage} +
+ ) : ( +
{children}
+ )} +
+
+ ); +} diff --git a/packages/stats/src/client/components/models-table-shared.tsx b/packages/stats/src/client/components/models-table-shared.tsx new file mode 100644 index 000000000..8c6dd60de --- /dev/null +++ b/packages/stats/src/client/components/models-table-shared.tsx @@ -0,0 +1,275 @@ +/** + * Shared primitives for the per-model breakdown tables (ModelsTable, + * BehaviorModelsTable). Each table still owns its column definitions, sort + * order, sidebar contents and chart type — this module owns the surface + * chrome, expand-row plumbing, theme palette, and the mini-sparkline plus + * the shared plugin/scale config consumed by multi-line detail charts. + */ + +import { format } from "date-fns"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { Line } from "react-chartjs-2"; + +export { MODEL_COLORS } from "./chart-shared"; + +export const TABLE_CHART_THEMES = { + dark: { + legendLabel: "#cbd5e1", + tooltipBackground: "#16161e", + tooltipTitle: "#f8fafc", + tooltipBody: "#94a3b8", + tooltipBorder: "rgba(255, 255, 255, 0.1)", + grid: "rgba(255, 255, 255, 0.06)", + tick: "#94a3b8", + }, + light: { + legendLabel: "#334155", + tooltipBackground: "#ffffff", + tooltipTitle: "#0f172a", + tooltipBody: "#334155", + tooltipBorder: "rgba(15, 23, 42, 0.18)", + grid: "rgba(15, 23, 42, 0.08)", + tick: "#475569", + }, +} as const; + +export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES]; + +/** Style defaults for one line in a non-stacked detail chart. */ +export function lineSeriesStyle(color: string) { + return { + borderColor: color, + backgroundColor: "transparent", + tension: 0.4, + pointRadius: 0, + borderWidth: 2, + }; +} + +/** + * No-axis, no-legend single-series sparkline used in the trend cell of every + * model row. Caller supplies the already-extracted numeric series so this + * stays agnostic of the row's underlying data shape. + */ +export function MiniSparkline({ + timestamps, + values, + color, +}: { + timestamps: number[]; + values: number[]; + color: string; +}) { + const chartData = { + labels: timestamps.map(ts => format(new Date(ts), "MMM d")), + datasets: [{ data: values, ...lineSeriesStyle(color) }], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + y: { display: false, min: 0 }, + }, + }; + + return ; +} + +/** + * Plugin block (legend + tooltip) shared by every multi-series detail chart + * in the table expanded views. + */ +export function detailChartPlugins(chartTheme: TableChartTheme) { + return { + legend: { + display: true, + position: "top" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + cornerRadius: 8, + }, + }; +} + +/** + * Single-Y-axis scales for a detail chart (used when every series shares a + * unit, e.g. behavior counts). Min anchored at 0. + */ +export function detailChartScalesSingleAxis(chartTheme: TableChartTheme) { + return { + x: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + min: 0, + }, + }; +} + +/** + * Dual-Y-axis scales for a detail chart with mixed units (e.g. TTFT seconds + * on left, tokens/s on right). Right-axis grid is suppressed so it doesn't + * collide with the left. + */ +export function detailChartScalesDualAxis(chartTheme: TableChartTheme) { + return { + x: { + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y: { + type: "linear" as const, + display: true, + position: "left" as const, + grid: { color: chartTheme.grid }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + y1: { + type: "linear" as const, + display: true, + position: "right" as const, + grid: { drawOnChartArea: false }, + ticks: { color: chartTheme.tick, font: { size: 11 } }, + }, + }; +} + +export interface TableColumn { + label: string; + align?: "left" | "right" | "center"; +} + +/** Outer card + section title used by every model table. */ +export function ModelTableShell({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
{children}
+
+ ); +} + +function alignClass(align: TableColumn["align"]): string { + if (align === "right") return "text-right"; + if (align === "center") return "text-center"; + return ""; +} + +/** Sticky column-header row for a model table. */ +export function ModelTableHeader({ columns, gridTemplate }: { columns: TableColumn[]; gridTemplate: string }) { + return ( +
+ {columns.map(col => ( +
+ {col.label} +
+ ))} + {/* trailing chevron column has no header label */} +
+
+ ); +} + +/** Scroll wrapper for the row stack — capped to fit the dashboard viewport. */ +export function ModelTableBody({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** + * Two-line model identity cell (model name + provider) shared by every + * per-model table. Kept as a stable named contract so callers don't restate + * the same two divs and font-utility classes. + */ +export function ModelNameCell({ model, provider }: { model: string; provider: string }) { + return ( +
+
{model}
+
{provider}
+
+ ); +} + +/** + * One expandable model row. `cells` matches the column order from + * `ModelTableHeader` plus the trend cell at the end (caller controls the + * sparkline / placeholder). `expandedContent` is the panel revealed on toggle. + */ +export function ExpandableModelRow({ + gridTemplate, + cells, + trendCell, + isExpanded, + onToggle, + expandedContent, +}: { + gridTemplate: string; + cells: React.ReactNode[]; + trendCell: React.ReactNode; + isExpanded: boolean; + onToggle: () => void; + expandedContent: React.ReactNode; +}) { + return ( +
+ + {isExpanded ? ( +
+ {expandedContent} +
+ ) : null} +
+ ); +} + +/** Placeholder shown in the trend cell when a model has no time-series data. */ +export function TrendEmpty() { + return
-
; +} + +/** Placeholder shown in the expanded detail-chart slot when data is missing. */ +export function DetailChartEmpty({ message = "No data available" }: { message?: string }) { + return
{message}
; +} diff --git a/packages/stats/src/client/types.ts b/packages/stats/src/client/types.ts index ab783d5c7..1423d2dae 100644 --- a/packages/stats/src/client/types.ts +++ b/packages/stats/src/client/types.ts @@ -1,8 +1,28 @@ /** * Client-side type definitions. - * Duplicated from ../types.ts to avoid pulling in server dependencies. + * + * Shared shapes (aggregations, time-series, dashboard payloads) live in + * `../shared-types` and are re-exported here. The types declared inline below + * are deliberately client-only because: + * - `Usage` is redeclared locally so the client bundle avoids importing + * `@oh-my-pi/pi-ai` (the server-side AI types package). + * - `MessageStats.stopReason` is widened from the server's `StopReason` + * enum to `string`, again to keep the client free of pi-ai types. + * - `TimeRange`, `OverviewStats`, `ModelDashboardStats`, + * `CostDashboardStats` are UI-only view shapes the server never produces. */ +import type { + AggregatedStats, + CostTimeSeriesPoint, + ModelPerformancePoint, + ModelStats, + ModelTimeSeriesPoint, + TimeSeriesPoint, +} from "../shared-types"; + +export * from "../shared-types"; + export interface Usage { input: number; output: number; @@ -40,80 +60,7 @@ export interface RequestDetails extends MessageStats { output: unknown; } -export interface AggregatedStats { - totalRequests: number; - successfulRequests: number; - failedRequests: number; - errorRate: number; - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheWriteTokens: number; - cacheRate: number; - totalCost: number; - totalPremiumRequests: number; - avgDuration: number | null; - avgTtft: number | null; - avgTokensPerSecond: number | null; - firstTimestamp: number; - lastTimestamp: number; -} - export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all"; -export interface ModelStats extends AggregatedStats { - model: string; - provider: string; -} - -export interface FolderStats extends AggregatedStats { - folder: string; -} - -export interface TimeSeriesPoint { - timestamp: number; - requests: number; - errors: number; - tokens: number; - cost: number; -} - -export interface ModelTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - requests: number; -} - -export interface ModelPerformancePoint { - timestamp: number; - model: string; - provider: string; - requests: number; - avgTtft: number | null; - avgTokensPerSecond: number | null; -} - -export interface CostTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - cost: number; - costInput: number; - costOutput: number; - costCacheRead: number; - costCacheWrite: number; - requests: number; -} - -export interface DashboardStats { - overall: AggregatedStats; - byModel: ModelStats[]; - byFolder: FolderStats[]; - timeSeries: TimeSeriesPoint[]; - modelSeries: ModelTimeSeriesPoint[]; - modelPerformanceSeries: ModelPerformancePoint[]; - costSeries: CostTimeSeriesPoint[]; -} export interface OverviewStats { overall: AggregatedStats; @@ -129,50 +76,3 @@ export interface ModelDashboardStats { export interface CostDashboardStats { costSeries: CostTimeSeriesPoint[]; } - -export interface BehaviorTimeSeriesPoint { - timestamp: number; - model: string; - provider: string; - messages: number; - yelling: number; - profanity: number; - anguish: number; - negation: number; - repetition: number; - blame: number; - chars: number; -} - -export interface BehaviorOverallStats { - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - firstTimestamp: number; - lastTimestamp: number; -} - -export interface BehaviorModelStats { - model: string; - provider: string; - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - lastTimestamp: number; -} - -export interface BehaviorDashboardStats { - overall: BehaviorOverallStats; - byModel: BehaviorModelStats[]; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} diff --git a/packages/stats/src/shared-types.ts b/packages/stats/src/shared-types.ts new file mode 100644 index 000000000..024d3419f --- /dev/null +++ b/packages/stats/src/shared-types.ts @@ -0,0 +1,204 @@ +/** + * Shared type definitions consumed by both the server-side stats code and the + * standalone client bundle. Keep this file free of any imports from server-only + * packages (e.g. `@oh-my-pi/pi-ai`, `bun:sqlite`) so the client can import it + * without dragging server dependencies into its bundle. + */ + +/** + * Aggregated stats for a model or folder. + */ +export interface AggregatedStats { + /** Total number of requests */ + totalRequests: number; + /** Number of successful requests */ + successfulRequests: number; + /** Number of failed requests */ + failedRequests: number; + /** Error rate (0-1) */ + errorRate: number; + /** Total input tokens */ + totalInputTokens: number; + /** Total output tokens */ + totalOutputTokens: number; + /** Total cache read tokens */ + totalCacheReadTokens: number; + /** Total cache write tokens */ + totalCacheWriteTokens: number; + /** Cache hit rate (0-1) */ + cacheRate: number; + /** Total cost */ + totalCost: number; + /** Total premium requests */ + totalPremiumRequests: number; + /** Average duration in ms */ + avgDuration: number | null; + /** Average TTFT in ms */ + avgTtft: number | null; + /** Average tokens per second (output tokens / duration) */ + avgTokensPerSecond: number | null; + /** Time range */ + firstTimestamp: number; + lastTimestamp: number; +} + +/** + * Stats grouped by model. + */ +export interface ModelStats extends AggregatedStats { + model: string; + provider: string; +} + +/** + * Stats grouped by folder. + */ +export interface FolderStats extends AggregatedStats { + folder: string; +} + +/** + * Time series data point. + */ +export interface TimeSeriesPoint { + /** Bucket timestamp (start of hour/day) */ + timestamp: number; + /** Request count */ + requests: number; + /** Error count */ + errors: number; + /** Total tokens */ + tokens: number; + /** Total cost */ + cost: number; +} + +/** + * Model usage time series data point (daily buckets). + */ +export interface ModelTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Request count */ + requests: number; +} + +/** + * Model performance time series data point (daily buckets). + */ +export interface ModelPerformancePoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Request count */ + requests: number; + /** Average TTFT in ms */ + avgTtft: number | null; + /** Average tokens per second */ + avgTokensPerSecond: number | null; +} + +/** + * Cost time series data point (daily buckets). + */ +export interface CostTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Model name */ + model: string; + /** Provider name */ + provider: string; + /** Total cost for this bucket */ + cost: number; + /** Cost breakdown */ + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + /** Request count */ + requests: number; +} + +/** + * Overall dashboard stats. + */ +export interface DashboardStats { + overall: AggregatedStats; + byModel: ModelStats[]; + byFolder: FolderStats[]; + timeSeries: TimeSeriesPoint[]; + modelSeries: ModelTimeSeriesPoint[]; + modelPerformanceSeries: ModelPerformancePoint[]; + costSeries: CostTimeSeriesPoint[]; +} + +/** + * Behavior time-series point (daily bucket, per responding model). + */ +export interface BehaviorTimeSeriesPoint { + /** Bucket timestamp (start of day) */ + timestamp: number; + /** Responding model ("unknown" if user msg never got a reply) */ + model: string; + /** Responding provider */ + provider: string; + /** Number of user messages in bucket */ + messages: number; + /** Total yelling sentences in bucket */ + yelling: number; + /** Total profanity hits in bucket */ + profanity: number; + /** Total anguish signal in bucket */ + anguish: number; + /** Total corrective-negation hits in bucket */ + negation: number; + /** Total user-repeating-themselves hits in bucket */ + repetition: number; + /** Total second-person blame hits in bucket */ + blame: number; + /** Total characters in bucket */ + chars: number; +} + +export interface BehaviorOverallStats { + totalMessages: number; + totalYelling: number; + totalProfanity: number; + totalAnguish: number; + totalNegation: number; + totalRepetition: number; + totalBlame: number; + totalChars: number; + firstTimestamp: number; + lastTimestamp: number; +} + +/** + * Per-model behavioral aggregate over the active range. + */ +export interface BehaviorModelStats { + model: string; + provider: string; + totalMessages: number; + totalYelling: number; + totalProfanity: number; + totalAnguish: number; + totalNegation: number; + totalRepetition: number; + totalBlame: number; + totalChars: number; + lastTimestamp: number; +} + +export interface BehaviorDashboardStats { + overall: BehaviorOverallStats; + byModel: BehaviorModelStats[]; + behaviorSeries: BehaviorTimeSeriesPoint[]; +} diff --git a/packages/stats/src/types.ts b/packages/stats/src/types.ts index 1baf1d2ef..db425d664 100644 --- a/packages/stats/src/types.ts +++ b/packages/stats/src/types.ts @@ -1,5 +1,7 @@ import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@oh-my-pi/pi-ai"; +export * from "./shared-types"; + /** * Extracted stats from an assistant message. */ @@ -36,142 +38,10 @@ export interface MessageStats { * Full details of a request, including content. */ export interface RequestDetails extends MessageStats { - messages: any[]; // The full conversation history or just the last turn - output: any; // The model's response -} - -/** - * Aggregated stats for a model or folder. - */ -export interface AggregatedStats { - /** Total number of requests */ - totalRequests: number; - /** Number of successful requests */ - successfulRequests: number; - /** Number of failed requests */ - failedRequests: number; - /** Error rate (0-1) */ - errorRate: number; - /** Total input tokens */ - totalInputTokens: number; - /** Total output tokens */ - totalOutputTokens: number; - /** Total cache read tokens */ - totalCacheReadTokens: number; - /** Total cache write tokens */ - totalCacheWriteTokens: number; - /** Cache hit rate (0-1) */ - cacheRate: number; - /** Total cost */ - totalCost: number; - /** Total premium requests */ - totalPremiumRequests: number; - /** Average duration in ms */ - avgDuration: number | null; - /** Average TTFT in ms */ - avgTtft: number | null; - /** Average tokens per second (output tokens / duration) */ - avgTokensPerSecond: number | null; - /** Time range */ - firstTimestamp: number; - lastTimestamp: number; -} - -/** - * Stats grouped by model. - */ -export interface ModelStats extends AggregatedStats { - model: string; - provider: string; -} - -/** - * Stats grouped by folder. - */ -export interface FolderStats extends AggregatedStats { - folder: string; -} - -/** - * Time series data point. - */ -export interface TimeSeriesPoint { - /** Bucket timestamp (start of hour/day) */ - timestamp: number; - /** Request count */ - requests: number; - /** Error count */ - errors: number; - /** Total tokens */ - tokens: number; - /** Total cost */ - cost: number; -} - -/** - * Model usage time series data point (daily buckets). - */ -export interface ModelTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Request count */ - requests: number; -} - -/** - * Model performance time series data point (daily buckets). - */ -export interface ModelPerformancePoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Request count */ - requests: number; - /** Average TTFT in ms */ - avgTtft: number | null; - /** Average tokens per second */ - avgTokensPerSecond: number | null; -} - -/** - * Cost time series data point (daily buckets). - */ -export interface CostTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Model name */ - model: string; - /** Provider name */ - provider: string; - /** Total cost for this bucket */ - cost: number; - /** Cost breakdown */ - costInput: number; - costOutput: number; - costCacheRead: number; - costCacheWrite: number; - /** Request count */ - requests: number; -} - -/** - * Overall dashboard stats. - */ -export interface DashboardStats { - overall: AggregatedStats; - byModel: ModelStats[]; - byFolder: FolderStats[]; - timeSeries: TimeSeriesPoint[]; - modelSeries: ModelTimeSeriesPoint[]; - modelPerformanceSeries: ModelPerformancePoint[]; - costSeries: CostTimeSeriesPoint[]; + /** The full conversation history or just the last turn. */ + messages: unknown[]; + /** The model's response. */ + output: unknown; } /** @@ -253,66 +123,3 @@ export interface UserMessageLink { model: string; provider: string; } - -/** - * Behavior time-series point (daily bucket, per responding model). - */ -export interface BehaviorTimeSeriesPoint { - /** Bucket timestamp (start of day) */ - timestamp: number; - /** Responding model ("unknown" if user msg never got a reply) */ - model: string; - /** Responding provider */ - provider: string; - /** Number of user messages in bucket */ - messages: number; - /** Total yelling sentences in bucket */ - yelling: number; - /** Total profanity hits in bucket */ - profanity: number; - /** Total anguish signal in bucket */ - anguish: number; - /** Total corrective-negation hits in bucket */ - negation: number; - /** Total user-repeating-themselves hits in bucket */ - repetition: number; - /** Total second-person blame hits in bucket */ - blame: number; - /** Total characters in bucket */ - chars: number; -} - -export interface BehaviorOverallStats { - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - firstTimestamp: number; - lastTimestamp: number; -} - -/** - * Per-model behavioral aggregate over the active range. - */ -export interface BehaviorModelStats { - model: string; - provider: string; - totalMessages: number; - totalYelling: number; - totalProfanity: number; - totalAnguish: number; - totalNegation: number; - totalRepetition: number; - totalBlame: number; - totalChars: number; - lastTimestamp: number; -} -export interface BehaviorDashboardStats { - overall: BehaviorOverallStats; - byModel: BehaviorModelStats[]; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} diff --git a/packages/tui/bench/_jskey.ts b/packages/tui/bench/_jskey.ts index 2905c8c95..87fb183ef 100644 --- a/packages/tui/bench/_jskey.ts +++ b/packages/tui/bench/_jskey.ts @@ -22,21 +22,21 @@ // Global Kitty Protocol State // ============================================================================= -let _kittyProtocolActive = false; +let kittyProtocolActive = false; /** * Set the global Kitty keyboard protocol state. * Called by ProcessTerminal after detecting protocol support. */ -export function setKittyProtocolActive(active: boolean): void { - _kittyProtocolActive = active; +function setKittyProtocolActive(active: boolean): void { + kittyProtocolActive = active; } /** * Query whether Kitty keyboard protocol is currently active. */ -export function isKittyProtocolActive(): boolean { - return _kittyProtocolActive; +function isKittyProtocolActive(): boolean { + return kittyProtocolActive; } // ============================================================================= @@ -169,7 +169,7 @@ export type KeyId = * - Key.ctrl("c"), Key.alt("x") for single modifier * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers */ -export const Key = { +const Key = { // Special keys escape: "escape" as const, esc: "esc" as const, @@ -488,13 +488,13 @@ interface ParsedKittySequence { } // Store the last parsed event type for isKeyRelease() to query -let _lastEventType: KeyEventType = "press"; +let lastEventType: KeyEventType = "press"; /** * Check if the last parsed key event was a key release. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -export function isKeyRelease(data: string): boolean { +function isKeyRelease(data: string): boolean { // Don't treat bracketed paste content as key release, even if it contains // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). // Terminal.ts re-wraps paste content with bracketed paste markers before @@ -524,7 +524,7 @@ export function isKeyRelease(data: string): boolean { * Check if the last parsed key event was a key repeat. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -export function isKeyRepeat(data: string): boolean { +function isKeyRepeat(data: string): boolean { // Don't treat bracketed paste content as key repeat, even if it contains // patterns like ":2F". See isKeyRelease() for details. if (data.includes("\x1b[200~")) { @@ -554,7 +554,7 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType { return "press"; } -export function parseKittySequence(data: string): ParsedKittySequence | null { +function parseKittySequence(data: string): ParsedKittySequence | null { // CSI u format with alternate keys (flag 4): // \x1b[u // \x1b[;u @@ -572,7 +572,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; const eventType = parseEventType(csiUMatch[5]); - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; } @@ -582,7 +582,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(arrowMatch[1]!, 10); const eventType = parseEventType(arrowMatch[2]); const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - _lastEventType = eventType; + lastEventType = eventType; return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; } @@ -602,7 +602,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } } @@ -613,7 +613,7 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(homeEndMatch[1]!, 10); const eventType = parseEventType(homeEndMatch[2]); const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - _lastEventType = eventType; + lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } @@ -702,7 +702,7 @@ function parseKeyId(keyId: string): ParsedKeyId | null { * @param data - Raw input data from terminal * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ -export function matchesKey(data: string, keyId: KeyId): boolean { +function matchesKey(data: string, keyId: KeyId): boolean { const parsed = parseKeyId(keyId); if (!parsed) return false; @@ -719,7 +719,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); case "space": - if (!_kittyProtocolActive) { + if (!kittyProtocolActive) { if (ctrl && !alt && !shift && data === "\x00") { return true; } @@ -758,7 +758,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { // When Kitty protocol is active, legacy sequences are custom terminal mappings // \x1b\r = Kitty's "map shift+enter send_text all \e\r" // \n = Ghostty's "keybind = shift+enter=text:\n" - if (_kittyProtocolActive) { + if (kittyProtocolActive) { return data === "\x1b\r" || data === "\n"; } return false; @@ -777,7 +777,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) // When Kitty protocol is active, alt+enter comes as CSI u sequence - if (!_kittyProtocolActive) { + if (!kittyProtocolActive) { return data === "\x1b\r"; } return false; @@ -785,7 +785,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (modifier === 0) { return ( data === "\r" || - (!_kittyProtocolActive && data === "\n") || + (!kittyProtocolActive && data === "\n") || data === "\x1bOM" || // SS3 M (numpad enter in some terminals) matchesKittySequence(data, CODEPOINTS.enter, 0) || matchesKittySequence(data, CODEPOINTS.kpEnter, 0) @@ -923,7 +923,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (alt && !ctrl && !shift) { return ( data === "\x1b[1;3D" || - (!_kittyProtocolActive && data === "\x1bB") || + (!kittyProtocolActive && data === "\x1bB") || data === "\x1bb" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) ); @@ -950,7 +950,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (alt && !ctrl && !shift) { return ( data === "\x1b[1;3C" || - (!_kittyProtocolActive && data === "\x1bF") || + (!kittyProtocolActive && data === "\x1bF") || data === "\x1bf" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) ); @@ -998,11 +998,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { const codepoint = key.charCodeAt(0); const isLetterKey = key >= "a" && key <= "z"; - if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + if (ctrl && alt && !shift && !kittyProtocolActive && key >= "a" && key <= "z") { return data === `\x1b${rawCtrlChar(key)}`; } - if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + if (alt && !ctrl && !shift && !kittyProtocolActive && key >= "a" && key <= "z") { // Legacy: alt+letter is ESC followed by the letter if (data === `\x1b${key}`) return true; } @@ -1048,7 +1048,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { * @param data - Raw input data from terminal * @returns Key identifier string (e.g., "ctrl+c") or undefined */ -export function parseKey(data: string): string | undefined { +function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { const { codepoint, baseLayoutKey, modifier } = kitty; @@ -1091,7 +1091,7 @@ export function parseKey(data: string): string | undefined { // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: // - \x1b\r = shift+enter (Kitty mapping), not alt+enter // - \n = shift+enter (Ghostty mapping) - if (_kittyProtocolActive) { + if (kittyProtocolActive) { if (data === "\x1b\r" || data === "\n") return "shift+enter"; } @@ -1101,17 +1101,17 @@ export function parseKey(data: string): string | undefined { // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) if (data === "\x1b") return "escape"; if (data === "\t") return "tab"; - if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; + if (data === "\r" || (!kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; if (data === "\x00") return "ctrl+space"; if (data === " ") return "space"; if (data === "\x7f" || data === "\x08") return "backspace"; if (data === "\x1b[Z") return "shift+tab"; - if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; - if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; + if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter"; + if (!kittyProtocolActive && data === "\x1b ") return "alt+space"; if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; - if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; - if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; - if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { + if (!kittyProtocolActive && data === "\x1bB") return "alt+left"; + if (!kittyProtocolActive && data === "\x1bF") return "alt+right"; + if (!kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { const code = data.charCodeAt(1); if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 63e0bb309..02ff82f5c 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -48,13 +48,13 @@ export function clearRenderCache(): void { // Stable numeric IDs for structural theme/style objects (no ID field on type). // WeakMap so GC can collect orphaned themes/styles without a leak. -const _objectIds = new WeakMap(); -let _nextObjectId = 0; +const objectIds = new WeakMap(); +let nextObjectId = 0; function objectId(o: object): number { - let id = _objectIds.get(o); + let id = objectIds.get(o); if (id === undefined) { - id = _nextObjectId++; - _objectIds.set(o, id); + id = nextObjectId++; + objectIds.set(o, id); } return id; } diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts index f6875b95c..bdc58c1ca 100644 --- a/packages/tui/test/test-themes.ts +++ b/packages/tui/test/test-themes.ts @@ -48,7 +48,7 @@ const defaultSymbols: SymbolTheme = { spinnerFrames: ["-", "\\", "|", "/"], }; -export const defaultSelectListTheme: SelectListTheme = { +const defaultSelectListTheme: SelectListTheme = { selectedPrefix: (text: string) => chalk.blue(text), selectedText: (text: string) => chalk.bold(text), description: (text: string) => chalk.dim(text), diff --git a/packages/typescript-edit-benchmark/src/formatter.ts b/packages/typescript-edit-benchmark/src/formatter.ts index ed9b3d88d..dd4c41334 100644 --- a/packages/typescript-edit-benchmark/src/formatter.ts +++ b/packages/typescript-edit-benchmark/src/formatter.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import * as prettier from "prettier"; import { listFiles } from "./shared"; -export const PRETTIER_OPTIONS: prettier.Options = { +const PRETTIER_OPTIONS: prettier.Options = { printWidth: 100, tabWidth: 2, useTabs: false, diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index 7cd0d0b76..06dd86af5 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -172,23 +172,6 @@ async function snapshotConversationDump(client: BenchmarkClient): Promise { - const dumpDir = params.config.conversationDumpDir; - if (!dumpDir) return undefined; - const snapshot = await snapshotConversationDump(params.client); - return writeConversationDump({ - dumpDir, - taskId: params.taskId, - runIndex: params.runIndex, - snapshot, - }); -} - function splitLines(value: string): string[] { return value.split("\n").filter((line, idx, arr) => idx < arr.length - 1 || line); } @@ -1349,325 +1332,6 @@ async function runSingleTask( }; } -async function _runRpcBenchmarkRun( - item: TaskRunItem, - config: BenchmarkConfig, - cwd: string, - expectedDir: string, - sessionDir: string, - client: BenchmarkClient, -): Promise { - const startTime = Date.now(); - const task = item.task; - const runIndex = item.runIndex; - let error: string | undefined; - let patchApplied = false; - let verificationPassed = false; - let indentScore: number | undefined; - let formattedEquivalent: boolean | undefined; - let diffStats: { linesChanged: number; charsChanged: number } | undefined; - let tokens: TokenStats = { input: 0, output: 0, total: 0 }; - let agentResponse: string | undefined; - let diff: string | undefined; - const editFailures: EditFailure[] = []; - const editWarnings: string[] = []; - let editAutocorrectCount = 0; - let timeoutTelemetry: PromptAttemptTelemetry | undefined; - let mutationIntentValidation: MutationIntentValidation | null = null; - const toolStats = { - read: 0, - edit: 0, - write: 0, - editSuccesses: 0, - editFailures: 0, - editWarnings: 0, - editAutocorrects: 0, - totalInputChars: 0, - }; - const hashlineSubtypes: Record = Object.fromEntries(HL_SUBTYPES.map(k => [k, 0])); - - const logFile = path.join(sessionDir, `run-${task.id}-${runIndex}.jsonl`); - const logEvent = async (event: unknown) => { - await fs.promises.appendFile(logFile, `${JSON.stringify(event)}\n`); - }; - const originalFiles = await collectOriginalFileContents(cwd, task.files); - let timeoutRetriesUsed = 0; - let zeroToolRetries = 0; - let providerFailureRetries = 0; - - try { - await fs.promises.appendFile( - logFile, - `{"type":"meta","task":"${task.id}","run":${runIndex},"workDir":"${cwd}"}\n`, - ); - - const maxAttempts = Math.max(1, Math.floor(config.maxAttempts ?? 1)); - const maxTimeoutRetries = config.maxTimeoutRetries ?? 3; - const noOpRetryLimit = config.noOpRetryLimit ?? 2; - const maxProviderFailureRetries = config.maxProviderFailureRetries ?? 3; - let retryContext: string | null = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const guidedContext = await buildGuidedContext(task, cwd, expectedDir, config); - const delivery = buildBenchmarkPromptDelivery({ - taskPrompt: task.prompt, - guidedContext, - retryContext, - }); - - await fs.promises.appendFile( - logFile, - `{"type":"prompt","attempt":${attempt + 1},"delivery":${JSON.stringify(delivery.kind)},"message":${JSON.stringify(delivery.message)}}\n`, - ); - - const statsBefore = await client.getSessionStats(); - let events: Array<{ type: string; [key: string]: unknown }>; - try { - events = await collectPromptEvents(client, delivery, config, logEvent); - } catch (err) { - if (err instanceof PromptTurnLimitError) { - error = err.message; - await logEvent({ type: "turn_limit_exceeded", attempt: attempt + 1, telemetry: err.telemetry }); - break; - } - if (err instanceof PromptTimeoutError) { - timeoutTelemetry = err.telemetry; - await logEvent({ type: "timeout", attempt: attempt + 1, telemetry: err.telemetry }); - timeoutRetriesUsed += 1; - retryContext = buildTimeoutRetryContext(err.telemetry, timeoutRetriesUsed, maxTimeoutRetries); - if (timeoutRetriesUsed >= maxTimeoutRetries) { - error = `Timeout exhausted after ${maxTimeoutRetries} retries (last: ${err.telemetry.elapsedMs}ms, events=${err.telemetry.eventCount}, last_event=${err.telemetry.lastEventType ?? "none"})`; - await logEvent({ - type: "timeout_exhausted", - retriesUsed: timeoutRetriesUsed, - telemetry: err.telemetry, - }); - break; - } - attempt--; // Don't consume a regular attempt slot for timeout retries - continue; - } - throw err; - } - const statsAfter = await client.getSessionStats(); - const attemptTokens = diffTokenStats(statsBefore, statsAfter, 0); - tokens = { - input: tokens.input + attemptTokens.input, - output: tokens.output + attemptTokens.output, - total: tokens.total + attemptTokens.total, - }; - await logEvent({ type: "stats", before: statsBefore, after: statsAfter, attempt: attempt + 1 }); - - agentResponse = (await client.getLastAssistantText()) ?? undefined; - await logEvent({ type: "response", text: agentResponse, attempt: attempt + 1 }); - - const providerFailure = detectProviderFailure(events); - const hasMutationToolCall = events.some( - event => - event.type === "tool_execution_start" && isMutationTool((event as { toolName?: unknown }).toolName), - ); - if (providerFailure && !hasMutationToolCall) { - await logEvent({ - type: "provider_failure", - attempt: attempt + 1, - kind: providerFailure.kind, - error: providerFailure.message, - }); - if (providerFailureRetries < maxProviderFailureRetries) { - providerFailureRetries += 1; - const delayMs = getProviderFailureRetryDelayMs(providerFailureRetries); - await logEvent({ - type: "provider_failure_retry", - attempt: attempt + 1, - retryNumber: providerFailureRetries, - retryLimit: maxProviderFailureRetries, - delayMs, - kind: providerFailure.kind, - }); - retryContext = buildProviderFailureRetryContext( - providerFailure, - providerFailureRetries, - maxProviderFailureRetries, - delayMs, - ); - await Bun.sleep(delayMs); - attempt--; // Don't consume a regular attempt slot for provider/auth retries - continue; - } - error = `Provider ${providerFailure.kind} failure: ${providerFailure.message}`; - await logEvent({ - type: "provider_failure_exhausted", - attempt: attempt + 1, - retriesUsed: providerFailureRetries, - kind: providerFailure.kind, - error: providerFailure.message, - }); - break; - } - const pendingEdits = new Map(); - for (const event of events) { - if (event.type === "tool_execution_start") { - const e = event as { toolName?: string; toolCallId?: string; args?: unknown }; - const toolName = e.toolName; - if (toolName === "read") { - toolStats.read++; - } else if (isEditTool(toolName)) { - toolStats.edit++; - if (e.toolCallId) pendingEdits.set(e.toolCallId, e.args); - } else if (toolName === "write") { - toolStats.write++; - } - - if (e.args) { - toolStats.totalInputChars += JSON.stringify(e.args).length; - } - } else if (event.type === "tool_execution_end") { - const e = event as { toolName?: string; toolCallId?: string; isError?: boolean; result?: unknown }; - if (isEditTool(e.toolName) && e.toolCallId && pendingEdits.has(e.toolCallId)) { - const args = pendingEdits.get(e.toolCallId) ?? null; - pendingEdits.delete(e.toolCallId); - if (config.editVariant === "hashline" && args) { - const counts = countHashlineEditSubtypes(args); - for (const key of HL_SUBTYPES) { - hashlineSubtypes[key] += counts[key]; - } - } - if (e.isError) { - toolStats.editFailures++; - const toolError = await appendNoChangeMutationHint( - extractToolErrorMessage(e.result), - args, - cwd, - originalFiles, - ); - editFailures.push({ - toolCallId: e.toolCallId, - args, - error: toolError, - category: categorizeEditFailure(toolError, args), - }); - } else { - toolStats.editSuccesses++; - if (e.toolName === "edit") { - const warningMessages = extractHashlineWarnings(e.result); - if (warningMessages.length > 0) { - editWarnings.push(...warningMessages); - toolStats.editWarnings += warningMessages.length; - if (hasHashlineAutocorrectWarning(warningMessages)) { - editAutocorrectCount++; - toolStats.editAutocorrects++; - } - } - } - } - } - } - } - - // Retry if the model didn't attempt any edit/write (read-only or no tool calls) - const madeEditAttempt = toolStats.edit > 0 || toolStats.write > 0; - if (!madeEditAttempt && zeroToolRetries < noOpRetryLimit) { - zeroToolRetries++; - await logEvent({ type: "zero_tool_retry", attempt: attempt + 1, retryNumber: zeroToolRetries }); - retryContext = `Previous attempt read files but made no edit attempt — you must use the edit or vim tool to apply the fix. Retry ${zeroToolRetries}/${noOpRetryLimit}.`; - attempt--; // Don't consume a regular attempt slot - continue; - } - - patchApplied = toolStats.edit > 0; - - const filesToVerify = task.files.length > 0 ? task.files : undefined; - const verification = await verifyExpectedFileSubset(expectedDir, cwd, filesToVerify); - if (config.autoFormat) { - await formatDirectory(cwd); - } - - verificationPassed = verification.success; - indentScore = verification.indentScore; - formattedEquivalent = verification.formattedEquivalent; - diffStats = verification.diffStats; - diff = verification.diff; - mutationIntentValidation = await evaluateMutationIntent(task, cwd, expectedDir); - if (!verification.success && verification.error) { - error = verification.error; - } - - if (verification.success) { - break; - } - - const mutationIntentSuffix = mutationIntentValidation - ? `\n\nMutation intent: ${mutationIntentValidation.matched ? "matched" : "not matched"} (${mutationIntentValidation.reason})` - : ""; - retryContext = error - ? `Verification failed: ${error}${diff ? `\n\nDiff (expected vs actual):\n\n\`\`\`diff\n${diff}\n\`\`\`` : ""}${mutationIntentSuffix}` - : `Previous attempt failed.${mutationIntentSuffix}`; - } - } catch (err) { - error = err instanceof Error ? err.message : String(err); - await logEvent({ type: "error", error }); - } - - const duration = Date.now() - startTime; - const mustUseEditTool = Boolean(config.requireEditToolCall) && !config.noEditRequired; - const mustUseReadTool = Boolean(config.requireReadToolCall) && !config.noEditRequired; - const editSucceeded = toolStats.editSuccesses > 0; - const success = - verificationPassed && (!mustUseEditTool || editSucceeded) && (!mustUseReadTool || toolStats.read > 0); - const metadata = task.metadata; - - await logEvent({ - type: "result", - success, - patchApplied, - verificationPassed, - error, - duration, - timeoutTelemetry, - mutationIntentValidation, - }); - console.log(` Log: ${formatLogPath(logFile)}`); - - await persistConversationDump({ - client, - config, - taskId: task.id, - runIndex, - }); - - return { - runIndex, - success, - patchApplied, - verificationPassed, - seed: metadata?.seed, - mutationType: metadata?.mutationType, - mutationCategory: metadata?.mutationCategory, - difficultyScore: metadata?.difficultyScore, - error, - tokens, - duration, - indentScore, - formattedEquivalent, - diffStats, - agentResponse, - diff, - toolCalls: toolStats, - editFailures, - editWarnings, - editAutocorrectCount, - hashlineEditSubtypes: config.editVariant === "hashline" ? hashlineSubtypes : undefined, - mutationIntentMatched: mutationIntentValidation?.matched, - mutationIntentReason: mutationIntentValidation?.reason, - timeoutTelemetry, - retryStats: { - timeoutRetries: timeoutRetriesUsed, - zeroToolRetries, - providerFailureRetries, - }, - }; -} - function extractToolText(result: unknown): string | null { if (typeof result === "string") return result; if (!result || typeof result !== "object") return null; @@ -1967,7 +1631,6 @@ function isGhostRun(r: TaskRunResult): boolean { function summarizeTaskRuns(task: EditTask, runs: TaskRunResult[]): TaskResult { const orderedRuns = runs.slice().sort((a, b) => a.runIndex - b.runIndex); - const _n = orderedRuns.length; const nonGhostRuns = orderedRuns.filter(r => !isGhostRun(r)); const effective = nonGhostRuns.length; const successfulRuns = orderedRuns.filter(r => r.success).length; @@ -2080,33 +1743,6 @@ async function runConcurrentBenchmarkRun( } } -export async function runTask( - task: EditTask, - config: BenchmarkConfig, - onProgress?: (event: ProgressEvent) => void, -): Promise { - const useInProcess = config.inProcess !== false; - const shared = useInProcess - ? await discoverSharedInfra({ - editVariant: config.editVariant, - editFuzzy: config.editFuzzy, - editFuzzyThreshold: config.editFuzzyThreshold, - }) - : undefined; - - const runPromises = Array.from({ length: config.runsPerTask }, async (_, index) => { - const tempDir = subtmp(task.id); - await copyFixtures(task, tempDir); - onProgress?.({ taskId: task.id, runIndex: index, status: "started" }); - const result = await runSingleTask(task, index, config, tempDir, task.expectedDir, shared); - onProgress?.({ taskId: task.id, runIndex: index, status: "completed", result }); - return result; - }); - - const runs = await Promise.all(runPromises); - return summarizeTaskRuns(task, runs); -} - export function buildBenchmarkResult(params: { tasks: EditTask[]; config: BenchmarkConfig; diff --git a/packages/typescript-edit-benchmark/src/tasks.ts b/packages/typescript-edit-benchmark/src/tasks.ts index 7dad25ed8..5656ad9bf 100644 --- a/packages/typescript-edit-benchmark/src/tasks.ts +++ b/packages/typescript-edit-benchmark/src/tasks.ts @@ -32,8 +32,6 @@ export interface TaskMetadata { mutatedSnippet?: string; } -export const DEFAULT_TARBALL_PATH = path.join(import.meta.dir, "../fixtures.tar.gz"); - function titleize(id: string): string { return id .split(/[-_]/)