mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-16 21:35:51 +00:00
Compare commits
20 Commits
feature/ma
...
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8570b6ba01 | ||
|
|
db606b5589 | ||
|
|
27a01113e4 | ||
|
|
4a39fd74b1 | ||
|
|
5486d3c02c | ||
|
|
aaabf0c168 | ||
|
|
43c20a43c2 | ||
|
|
17c06690d8 | ||
|
|
89800137b6 | ||
|
|
ea5df0ab60 | ||
|
|
0ce8f7a1cb | ||
|
|
6e1d3d8f47 | ||
|
|
dc3d3e8839 | ||
|
|
998890b469 | ||
|
|
3f0f699ca4 | ||
|
|
66b49d70ab | ||
|
|
2006ab25ff | ||
|
|
0707284939 | ||
|
|
84f12f34bd | ||
|
|
2f86485d9c |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -42,7 +42,8 @@ jobs:
|
||||
run: |
|
||||
# Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state).
|
||||
# We still require lcov.info to be generated and contain real coverage data.
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s || true
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
|
||||
76
CLAUDE.md
76
CLAUDE.md
@@ -82,11 +82,11 @@ bun run docs:dev
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
|
||||
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
|
||||
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.2.1`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
@@ -104,7 +104,7 @@ bun run docs:dev
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
2. **`src/main.tsx`** (~5674 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
@@ -123,17 +123,18 @@ bun run docs:dev
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(约 29 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(38 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 60 个工具目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **工具发现**: SearchExtraToolsTool, ExecuteExtraTool, SyntheticOutput(CORE_TOOLS,用于延迟工具按需加载)
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`SearchExtraToolsTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -168,18 +169,16 @@ bun run docs:dev
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
| `packages/weixin/` | 微信集成(非 workspace 包) |
|
||||
|
||||
辅助目录(无 package.json,非 workspace 包): `langfuse-dashboard`(Langfuse 面板)、`shared-web-ui`(共享 Web UI 组件)、`highlight-code`(代码高亮)、`claude-pencil`(编辑器)、`vscode-ide-bridge`(VS Code 桥接)、`pokemon`(示例/测试)。
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
@@ -210,12 +209,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
**Build 默认 features**(65+ 个,见 `build.ts` 中 `DEFAULT_BUILD_FEATURES`):
|
||||
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
|
||||
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
||||
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
|
||||
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
|
||||
- P2: `DAEMON`
|
||||
- P2: `DAEMON`, `ACP`
|
||||
- 工作流: `WORKFLOW_SCRIPTS`, `HISTORY_SNIP`, `MONITOR_TOOL`, `KAIROS`
|
||||
- 多 worker: `COORDINATOR_MODE`, `BG_SESSIONS`, `TEMPLATES`
|
||||
- 连接器: `CONNECTOR_TEXT`, `COMMIT_ATTRIBUTION`, `DIRECT_CONNECT`
|
||||
- 实验性: `EXPERIMENTAL_SKILL_SEARCH`, `EXPERIMENTAL_SEARCH_EXTRA_TOOLS`
|
||||
- 模式: `POOR`, `SSH_REMOTE`
|
||||
- 已禁用: `CONTEXT_COLLAPSE`, `FORK_SUBAGENT`, `UDS_INBOX`, `LAN_PIPES`, `REVIEW_ARTIFACT`, `TEAMMEM`, `SKILL_LEARNING`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
@@ -265,6 +270,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| `packages/shell/`, `packages/swarm/`, `packages/mcp-server/`, `packages/cc-knowledge/` | Removed — 功能合并或废弃 |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
@@ -281,7 +287,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **集成测试**: `tests/integration/` — 6 个文件(cli-arguments, context-build, message-pipeline, tool-chain, autonomy-lifecycle-user-flow, dependency-overrides)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
@@ -308,6 +314,48 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
#### 跨文件 mock 污染(process-global `mock.module`)
|
||||
|
||||
**Bun 的 `mock.module` 是进程全局的(last-write-wins),不是 per-file 隔离的。** 一个测试文件的 `mock.module` 会污染同一进程中所有其他测试文件的 `require`/`import`。
|
||||
|
||||
**关键事实(Bun 1.x 实测验证):**
|
||||
- 测试文件执行顺序**不是严格字母序**,不要假设文件 A 一定在文件 B 之前执行。
|
||||
- `mock.module` 在 `beforeAll` 内部调用时**不会被提升**(hoist),但仍会污染后续加载的文件。
|
||||
- `require()` 和 `import()` 共享同一模块注册表,`mock.module` 对两者都生效。
|
||||
- 一个模块一旦被某个文件的 `mock.module` 替换,同一进程中所有后续 `require`/`import` 都会返回 mock 值,即使调用方使用不同的 specifier 路径。
|
||||
|
||||
**核心规则:不要 mock 被测模块的上层业务模块。**
|
||||
|
||||
错误做法(会污染同目录的 `api.test.ts`):
|
||||
```ts
|
||||
// launchSchedule.test.ts — 直接 mock 源 API 模块 ❌
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
正确做法(mock 底层 HTTP 层,不污染业务模块):参考 `launchSkillStore.test.ts`、`launchVault.test.ts` 的模式。
|
||||
```ts
|
||||
// launchSchedule.test.ts — mock axios 而非 triggersApi ✅
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
|
||||
beforeAll(() => { axiosHandle.useStubs = true })
|
||||
afterAll(() => { axiosHandle.useStubs = false })
|
||||
```
|
||||
|
||||
**判断标准:** 如果目录下同时有 `launch*.test.ts`(集成测试)和 `api.test.ts`(回归测试),`launch*.test.ts` 必须 mock axios 而非源 API 模块。`api.test.ts` 需要测试真实 API 模块的 HTTP 方法/URL/错误处理逻辑,被 mock 后就无法测试。
|
||||
|
||||
**排查 mock 污染的方法:**
|
||||
1. 单独运行可疑文件确认其通过:`bun test path/to/suspect.test.ts`
|
||||
2. 与同目录其他文件一起运行定位污染源:`bun test path/to/__tests__/`
|
||||
3. 在两个文件中各加 `console.error('[file] milestone')` 追踪实际执行顺序
|
||||
4. 检查 `mock.module` 的 specifier 是否与同目录其他测试的 `require`/`import` 路径解析到同一模块
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.2.1",
|
||||
"version": "2.4.1",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -57,13 +57,4 @@ describe('prompt.ts fork-related text verification', () => {
|
||||
expect(bgCondition[0]).not.toContain('!forkEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
test('fork example includes fork: true parameter', () => {
|
||||
// The first fork example should have fork: true
|
||||
const forkExampleBlock = promptSource.match(
|
||||
/name: "ship-audit"[\s\S]*?Under 200 words/,
|
||||
)
|
||||
expect(forkExampleBlock).not.toBeNull()
|
||||
expect(forkExampleBlock![0]).toContain('fork: true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isEnvDefinedFalsy, isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { isTeammate } from 'src/utils/teammate.js'
|
||||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
|
||||
import { AGENT_TOOL_NAME } from './constants.js'
|
||||
@@ -84,11 +83,11 @@ export async function getPrompt(
|
||||
|
||||
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it.
|
||||
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results. If the user asks a follow-up before the notification lands, tell them the fork is still running.
|
||||
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope. Don't re-explain background.
|
||||
`
|
||||
: ''
|
||||
|
||||
@@ -97,91 +96,13 @@ When you need to delegate work that benefits from full conversation context (e.g
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- Explain what you're trying to accomplish and why, what you've already learned or ruled out, and enough context for the agent to make judgment calls.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
const forkExamples = `Example usage:
|
||||
|
||||
<example>
|
||||
user: "What's left on this branch before we can ship?"
|
||||
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
fork: true,
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
<commentary>
|
||||
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
|
||||
</commentary>
|
||||
[later turn \u2014 notification arrives as user message]
|
||||
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "so is the gate wired up or not"
|
||||
<commentary>
|
||||
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
|
||||
</commentary>
|
||||
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you get a second opinion on whether this migration is safe?"
|
||||
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
|
||||
<commentary>
|
||||
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
|
||||
</commentary>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "migration-review",
|
||||
description: "Independent migration review",
|
||||
subagent_type: "code-reviewer",
|
||||
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
|
||||
})
|
||||
</example>
|
||||
`
|
||||
|
||||
const currentExamples = `Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"test-runner": use this agent after you are done writing code to run tests
|
||||
"greeting-responder": use this agent to respond to user greetings with a friendly joke
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
|
||||
</commentary>
|
||||
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
// When the gate is on, the agent list lives in an agent_listing_delta
|
||||
@@ -273,7 +194,5 @@ Usage notes:
|
||||
? `
|
||||
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
|
||||
: ''
|
||||
}${whenToForkSection}${writingThePromptSection}
|
||||
|
||||
${forkEnabled ? forkExamples : currentExamples}`
|
||||
}${whenToForkSection}${writingThePromptSection}`
|
||||
}
|
||||
|
||||
@@ -314,15 +314,13 @@ export function getSimplePrompt(): string {
|
||||
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
|
||||
]
|
||||
: []),
|
||||
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
|
||||
'For long-running commands, use `run_in_background` — you will be notified when it completes. Do not poll.',
|
||||
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
|
||||
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
|
||||
...(feature('MONITOR_TOOL')
|
||||
? [
|
||||
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
|
||||
]
|
||||
: [
|
||||
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
|
||||
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -26,33 +26,13 @@ function getEnterPlanModeToolPromptExternal(): string {
|
||||
|
||||
**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:
|
||||
|
||||
1. **New Feature Implementation**: Adding meaningful new functionality
|
||||
- Example: "Add a logout button" - where should it go? What should happen on click?
|
||||
- Example: "Add form validation" - what rules? What error messages?
|
||||
|
||||
2. **Multiple Valid Approaches**: The task can be solved in several different ways
|
||||
- Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
|
||||
- Example: "Improve performance" - many optimization strategies possible
|
||||
|
||||
3. **Code Modifications**: Changes that affect existing behavior or structure
|
||||
- Example: "Update the login flow" - what exactly should change?
|
||||
- Example: "Refactor this component" - what's the target architecture?
|
||||
|
||||
4. **Architectural Decisions**: The task requires choosing between patterns or technologies
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
- Example: "Implement state management" - Redux vs Context vs custom solution
|
||||
|
||||
5. **Multi-File Changes**: The task will likely touch more than 2-3 files
|
||||
- Example: "Refactor the authentication system"
|
||||
- Example: "Add a new API endpoint with tests"
|
||||
|
||||
6. **Unclear Requirements**: You need to explore before understanding the full scope
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Fix the bug in checkout" - need to investigate root cause
|
||||
|
||||
7. **User Preferences Matter**: The implementation could reasonably go multiple ways
|
||||
- If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
- Plan mode lets you explore first, then present options with context
|
||||
1. **New Feature Implementation** — Adding meaningful new functionality where the implementation path isn't obvious
|
||||
2. **Multiple Valid Approaches** — The task can be solved in several different ways
|
||||
3. **Code Modifications** — Changes that affect existing behavior or structure, where the user should approve the approach
|
||||
4. **Architectural Decisions** — The task requires choosing between patterns or technologies
|
||||
5. **Multi-File Changes** — The task will likely touch more than 2-3 files
|
||||
6. **Unclear Requirements** — You need to explore before understanding the full scope
|
||||
7. **User Preferences Matter** — If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
@@ -62,35 +42,7 @@ Only skip EnterPlanMode for simple tasks:
|
||||
- Tasks where the user has given very specific, detailed instructions
|
||||
- Pure research/exploration tasks (use the Agent tool with explore agent instead)
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
|
||||
|
||||
User: "Optimize the database queries"
|
||||
- Multiple approaches possible, need to profile first, significant impact
|
||||
|
||||
User: "Implement dark mode"
|
||||
- Architectural decision on theme system, affects many components
|
||||
|
||||
User: "Add a delete button to the user profile"
|
||||
- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Affects multiple files, user should approve the approach
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
User: "Add a console.log to debug this function"
|
||||
- Simple, obvious implementation
|
||||
|
||||
User: "What files handle routing?"
|
||||
- Research task, not implementation planning
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work
|
||||
@@ -111,53 +63,23 @@ function getEnterPlanModeToolPromptAnt(): string {
|
||||
|
||||
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
|
||||
|
||||
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
|
||||
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Refactor this module" - need to understand what the target architecture should be
|
||||
|
||||
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
- Example: "Redesign the authentication system"
|
||||
- Example: "Migrate from one state management approach to another"
|
||||
1. **Significant Architectural Ambiguity** — Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
2. **Unclear Requirements** — You need to explore and clarify before you can make progress
|
||||
3. **High-Impact Restructuring** — The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip plan mode when you can reasonably infer the right approach:
|
||||
- The task is straightforward even if it touches multiple files
|
||||
- The user's request is specific enough that the implementation path is clear
|
||||
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
|
||||
- You're adding a feature with an obvious implementation pattern
|
||||
- Bug fixes where the fix is clear once you understand the bug
|
||||
- Research/exploration tasks (use the Agent tool instead)
|
||||
- The user says something like "can we work on X" or "let's do X" — just get started
|
||||
|
||||
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
|
||||
|
||||
User: "Redesign the data pipeline"
|
||||
- Major restructuring where the wrong approach wastes significant effort
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Add a delete button to the user profile"
|
||||
- Implementation path is clear; just do it
|
||||
|
||||
User: "Can we work on the search feature?"
|
||||
- User wants to get started, not plan
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Start working; ask specific questions if needed
|
||||
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
`
|
||||
|
||||
@@ -20,10 +20,4 @@ Ensure your plan is complete and unambiguous:
|
||||
- Once your plan is finalized, use THIS tool to request approval
|
||||
|
||||
**Important:** Do NOT use ${ASK_USER_QUESTION_TOOL_NAME} to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan.
|
||||
|
||||
## Examples
|
||||
|
||||
1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
|
||||
2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
|
||||
3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use ${ASK_USER_QUESTION_TOOL_NAME} first, then use exit plan mode tool after clarifying the approach.
|
||||
`
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Tests for AgentsPlatformView.tsx
|
||||
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
|
||||
// Mock cron utility before importing AgentsPlatformView
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
cronToHuman: (expr: string) => `HumanCron(${expr})`,
|
||||
parseCronExpression: () => null,
|
||||
computeNextCronRun: () => null,
|
||||
}));
|
||||
|
||||
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
|
||||
|
||||
const sampleAgent = {
|
||||
id: 'agt_abc123',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup report',
|
||||
status: 'active' as const,
|
||||
timezone: 'UTC',
|
||||
next_run: '2026-05-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('AgentsPlatformView list mode', () => {
|
||||
test('empty list shows placeholder message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
|
||||
expect(out).toContain('No scheduled agents');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent count', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Scheduled Agents (1)');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent status', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('active');
|
||||
});
|
||||
|
||||
test('non-empty list shows human-readable schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('list shows agent prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
|
||||
test('list shows next run date', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
// next_run is formatted via toLocaleString — just check it's rendered
|
||||
expect(out).toContain('Next run');
|
||||
});
|
||||
|
||||
test('list with null next_run shows em dash', async () => {
|
||||
const agentNoNextRun = { ...sampleAgent, next_run: null };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
|
||||
expect(out).toContain('—');
|
||||
});
|
||||
|
||||
test('multiple agents rendered', async () => {
|
||||
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
|
||||
expect(out).toContain('Scheduled Agents (2)');
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('agt_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView created mode', () => {
|
||||
test('shows Agent created', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Agent created');
|
||||
});
|
||||
|
||||
test('shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('shows schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('shows prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView deleted mode', () => {
|
||||
test('shows deleted confirmation with id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView ran mode', () => {
|
||||
test('shows triggered with agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('triggered');
|
||||
});
|
||||
|
||||
test('shows run id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('run_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView error mode', () => {
|
||||
test('shows error message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
|
||||
expect(out).toContain('Network failure');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchAgentsPlatform.tsx
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock agentsApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real agentsApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
|
||||
}))
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── agentsApi mock ──────────────────────────────────────────────────────────
|
||||
const listMock = mock(async () => [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
])
|
||||
const createMock = mock(async (cron: string, prompt: string) => ({
|
||||
id: 'agt_new',
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
|
||||
}))
|
||||
const deleteMock = mock(async () => undefined)
|
||||
const runMock = mock(async () => ({ run_id: 'run_123' }))
|
||||
|
||||
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
|
||||
listAgents: listMock,
|
||||
createAgent: createMock,
|
||||
deleteAgent: deleteMock,
|
||||
runAgent: runMock,
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ap',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key-ap',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-api-key-ap',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── cron mock ───────────────────────────────────────────────────────────────
|
||||
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
|
||||
computeNextCronRun: () => null,
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchAgentsPlatform.js')
|
||||
callAgentsPlatform = mod.callAgentsPlatform
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listMock.mockClear()
|
||||
createMock.mockClear()
|
||||
deleteMock.mockClear()
|
||||
runMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
})
|
||||
|
||||
function makeContext() {
|
||||
@@ -79,8 +118,23 @@ function makeContext() {
|
||||
describe('callAgentsPlatform', () => {
|
||||
test('list (empty args) calls listAgents and returns element', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('list sub-command calls listAgents', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('create with valid cron calls createAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
status: 201,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 Run standup',
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
|
||||
expect(cron).toBe('0 9 * * 1')
|
||||
expect(prompt).toBe('Run standup')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
const url = callArgs[0]
|
||||
const body = callArgs[1] as Record<string, unknown>
|
||||
expect(url).toContain('/v1/agents')
|
||||
expect(body.cron_expr).toBe('0 9 * * 1')
|
||||
expect(body.prompt).toBe('Run standup')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_create',
|
||||
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
|
||||
'create INVALID INVALID * * * my prompt',
|
||||
)
|
||||
// cron = 'INVALID INVALID * * *', mock returns null → no API call
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('delete with id calls deleteAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosDeleteMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_abc')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_delete',
|
||||
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('run with id calls runAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_123' },
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(runMock).toHaveBeenCalledWith('agt_xyz')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_xyz')
|
||||
expect(callArgs[0]).toContain('/run')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_run',
|
||||
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(axiosGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('listAgents API error → error view returned', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network error'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(result).not.toBeNull()
|
||||
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('started event fires on every call', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_started',
|
||||
@@ -190,10 +287,10 @@ describe('callAgentsPlatform', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
|
||||
// ── Error-path branches ──────────────────────────────────────────────────
|
||||
|
||||
test('createAgent API error → error view returned', async () => {
|
||||
createMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('deleteAgent API error → error view returned', async () => {
|
||||
deleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('runAgent API error → error view returned', async () => {
|
||||
runMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
|
||||
const onDone = mock(() => {})
|
||||
// Only 4 cron fields — parseArgs returns invalid
|
||||
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
|
||||
@@ -202,9 +202,9 @@ function createTemplateInCwd(files: Record<string, string>): string {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(templateDir, name), content)
|
||||
}
|
||||
// Track the .github dir for cleanup (remove whole .github if it didn't exist)
|
||||
const githubDir = join(realCwd, '.github')
|
||||
createdTemplatePath = githubDir
|
||||
// Track the ISSUE_TEMPLATE dir for cleanup — never delete the whole .github/
|
||||
// as it may contain workflows, settings, or other project config.
|
||||
createdTemplatePath = templateDir
|
||||
return templateDir
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,30 @@ import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// No keychain mock here — the real store falls back to encrypted file when
|
||||
// @napi-rs/keyring is not installed (which it is not in this environment).
|
||||
// This exercises the full file-fallback path without cross-test module pollution.
|
||||
// Re-register ../keychain.js to override pollution from store.test.ts (which
|
||||
// mocks keychain as always-throwing) and keychain.test.ts (which mocks it with
|
||||
// an in-memory MockEntry). Force KeychainUnavailableError so the store always
|
||||
// uses the encrypted-file fallback path.
|
||||
class KeychainUnavailableError extends Error {
|
||||
override name = 'KeychainUnavailableError'
|
||||
}
|
||||
|
||||
const keychainUnavailable = async (): Promise<never> => {
|
||||
throw new KeychainUnavailableError('test: keychain mocked as unavailable')
|
||||
}
|
||||
|
||||
mock.module('../../../services/localVault/keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain: {
|
||||
set: keychainUnavailable,
|
||||
get: keychainUnavailable,
|
||||
delete: keychainUnavailable,
|
||||
list: keychainUnavailable,
|
||||
_addToIndex: keychainUnavailable,
|
||||
_removeFromIndex: keychainUnavailable,
|
||||
},
|
||||
_resetKeychainModuleCache: () => {},
|
||||
}))
|
||||
|
||||
let callLocalVault: typeof import('../launchLocalVault.js').callLocalVault
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Tests for AuthPlaneSummary.tsx
|
||||
* Uses staticRender to render Ink components to strings.
|
||||
* Covers all 4 mode combinations + long provider list + key preview masking.
|
||||
*/
|
||||
import { describe, expect, test, mock } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { logMock } from '../../../../tests/mocks/log';
|
||||
import { debugMock } from '../../../../tests/mocks/debug';
|
||||
|
||||
mock.module('src/utils/log.ts', logMock);
|
||||
mock.module('src/utils/debug.ts', debugMock);
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }));
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getCachedOrDefaultSettings: () => ({}),
|
||||
getSettings: () => ({}),
|
||||
}));
|
||||
mock.module('src/utils/config.ts', () => ({
|
||||
isConfigEnabled: () => true,
|
||||
getGlobalConfig: () => ({ workspaceApiKey: undefined }),
|
||||
saveGlobalConfig: (_updater: unknown) => undefined,
|
||||
}));
|
||||
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import type { AuthStatus } from '../getAuthStatus.js';
|
||||
|
||||
// Helper to build minimal AuthStatus fixtures
|
||||
function makeStatus(overrides: Partial<AuthStatus> = {}): AuthStatus {
|
||||
return {
|
||||
subscription: {
|
||||
active: false,
|
||||
plan: null,
|
||||
accountEmail: null,
|
||||
},
|
||||
workspaceKey: {
|
||||
set: false,
|
||||
prefixValid: false,
|
||||
keyPreview: null,
|
||||
source: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthPlaneSummary', () => {
|
||||
test('renders subscription as inactive (☐) when not logged in', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus();
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('Subscription');
|
||||
// Subscription inactive symbol or "not logged in" indicator
|
||||
expect(out.toLowerCase()).toMatch(/not logged in|☐/);
|
||||
});
|
||||
|
||||
test('renders subscription as active (☑) with plan label when subscribed', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('pro');
|
||||
// Active symbol present
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key as set+valid (☑) when prefixValid=true', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: true,
|
||||
keyPreview: 'sk-a...67 (48 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Key preview may be word-wrapped across lines in terminal output
|
||||
expect(out).toContain('sk-a...67');
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key warning (⚠) when set but prefix invalid', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: false,
|
||||
keyPreview: 'sk-w...ng (40 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Warning indicator present
|
||||
expect(out).toContain('⚠');
|
||||
expect(out.toLowerCase()).toContain('sk-ant-api03-');
|
||||
});
|
||||
|
||||
test('shows workspace key 4-step setup instructions when key not set and subscription active', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
workspaceKey: { set: false, prefixValid: false, keyPreview: null, source: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('console.anthropic.com');
|
||||
});
|
||||
|
||||
// Third-party provider rendering tests removed 2026-05-06 — that section
|
||||
// was deleted from AuthPlaneSummary to defer to fork's existing /login form
|
||||
// for OpenAI-compat configuration. See AuthPlaneSummary.tsx for the rationale.
|
||||
});
|
||||
@@ -1,331 +1,383 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchMemoryStores.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock memoryStoresApi.js itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real memoryStoresApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
}))
|
||||
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ms' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ms',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
// Spread real teleport/api so any export not explicitly stubbed (like
|
||||
// prepareApiRequest, axiosGetWithRetry, type guards, schemas)
|
||||
// remains available to transitive importers.
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── MemoryStoresView mock ───────────────────────────────────────────────────
|
||||
const memoryStoresViewMock = mock((_props: unknown) => null)
|
||||
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
|
||||
MemoryStoresView: memoryStoresViewMock,
|
||||
}))
|
||||
|
||||
// ── memoryStoresApi mock ──────────────────────────────────────────────────
|
||||
const listStoresMock = mock(async () => [] as unknown)
|
||||
const getStoreMock = mock(async () => ({}) as unknown)
|
||||
const createStoreMock = mock(async () => ({}) as unknown)
|
||||
const archiveStoreMock = mock(async () => ({}) as unknown)
|
||||
const listMemoriesMock = mock(async () => [] as unknown)
|
||||
const createMemoryMock = mock(async () => ({}) as unknown)
|
||||
const getMemoryMock = mock(async () => ({}) as unknown)
|
||||
const updateMemoryMock = mock(async () => ({}) as unknown)
|
||||
const deleteMemoryMock = mock(async () => undefined)
|
||||
const listVersionsMock = mock(async () => [] as unknown)
|
||||
const redactVersionMock = mock(async () => ({}) as unknown)
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosPatchMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({
|
||||
listStores: listStoresMock,
|
||||
getStore: getStoreMock,
|
||||
createStore: createStoreMock,
|
||||
archiveStore: archiveStoreMock,
|
||||
listMemories: listMemoriesMock,
|
||||
createMemory: createMemoryMock,
|
||||
getMemory: getMemoryMock,
|
||||
updateMemory: updateMemoryMock,
|
||||
deleteMemory: deleteMemoryMock,
|
||||
listVersions: listVersionsMock,
|
||||
redactVersion: redactVersionMock,
|
||||
}))
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.patch = axiosPatchMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy imports ─────────────────────────────────────────────────────────────
|
||||
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchMemoryStores.js')
|
||||
callMemoryStores = mod.callMemoryStores
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
const calls: [string | undefined, unknown][] = []
|
||||
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
|
||||
return { onDone, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosPatchMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
listStoresMock.mockClear()
|
||||
getStoreMock.mockClear()
|
||||
createStoreMock.mockClear()
|
||||
archiveStoreMock.mockClear()
|
||||
listMemoriesMock.mockClear()
|
||||
createMemoryMock.mockClear()
|
||||
getMemoryMock.mockClear()
|
||||
updateMemoryMock.mockClear()
|
||||
deleteMemoryMock.mockClear()
|
||||
listVersionsMock.mockClear()
|
||||
redactVersionMock.mockClear()
|
||||
memoryStoresViewMock.mockClear()
|
||||
})
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: invalid args', () => {
|
||||
test('invalid subcommand → onDone with usage + null', async () => {
|
||||
const onDone = makeOnDone()
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callMemoryStores(onDone, {} as never, 'badcmd')
|
||||
expect(result).toBeNull()
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/Usage/i)
|
||||
expect(calls[0]?.[0]).toMatch(/Usage/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: list', () => {
|
||||
test('list returns empty stores', async () => {
|
||||
listStoresMock.mockResolvedValueOnce([])
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
expect(listStoresMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no memory stores/i)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/no memory stores/i)
|
||||
})
|
||||
|
||||
test('list with stores reports count', async () => {
|
||||
const stores = [
|
||||
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
|
||||
]
|
||||
listStoresMock.mockResolvedValueOnce(stores)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory store/)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory store/)
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listStoresMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memory stores/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memory stores/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get', () => {
|
||||
test('get calls getStore with id', async () => {
|
||||
test('get calls axios.get with id in URL', async () => {
|
||||
const store = { memory_store_id: 'ms_get', name: 'Work Store' }
|
||||
getStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_get')
|
||||
expect(getStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create', () => {
|
||||
test('create calls createStore with name', async () => {
|
||||
test('create calls axios.post with name in body', async () => {
|
||||
const store = { memory_store_id: 'ms_new', name: 'New Store' }
|
||||
createStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create New Store')
|
||||
expect(createStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('New Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory store created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[1]).toEqual({ name: 'New Store' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory store created/i)
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createStoreMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create My Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── archive ───────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: archive', () => {
|
||||
test('archive calls archiveStore with id', async () => {
|
||||
test('archive calls axios.post with id in URL', async () => {
|
||||
const store = {
|
||||
memory_store_id: 'ms_arc',
|
||||
name: 'Old Store',
|
||||
archived_at: '2026-01-01',
|
||||
}
|
||||
archiveStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_arc')
|
||||
expect(archiveStoreMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/archived/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_arc')
|
||||
expect(postCall[0]).toContain('archive')
|
||||
expect(calls[0]?.[0]).toMatch(/archived/i)
|
||||
})
|
||||
|
||||
test('archive API error → error message', async () => {
|
||||
archiveStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to archive memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to archive memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── memories ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: memories', () => {
|
||||
test('memories lists memories in store', async () => {
|
||||
const memories = [
|
||||
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
|
||||
]
|
||||
listMemoriesMock.mockResolvedValueOnce(memories)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: memories },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_1')
|
||||
expect(listMemoriesMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory/)
|
||||
})
|
||||
|
||||
test('memories API error → error message', async () => {
|
||||
listMemoriesMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memories/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memories/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create-memory', () => {
|
||||
test('create-memory calls createMemory with storeId and content', async () => {
|
||||
test('create-memory calls axios.post with storeId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_new',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'hello world',
|
||||
}
|
||||
createMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 hello world',
|
||||
)
|
||||
expect(createMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('hello world')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('memories')
|
||||
expect(postCall[1]).toEqual({ content: 'hello world' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory created/i)
|
||||
})
|
||||
|
||||
test('create-memory API error → error message', async () => {
|
||||
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 test content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get-memory ────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get-memory', () => {
|
||||
test('get-memory calls getMemory', async () => {
|
||||
test('get-memory calls axios.get with storeId and memoryId in URL', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_get',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'Test',
|
||||
}
|
||||
getMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
|
||||
expect(getMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_1')
|
||||
expect(getCall[0]).toContain('mem_get')
|
||||
})
|
||||
|
||||
test('get-memory API error → error message', async () => {
|
||||
getMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── update-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: update-memory', () => {
|
||||
test('update-memory calls updateMemory with storeId, memoryId, and content', async () => {
|
||||
test('update-memory calls axios.patch with storeId, memoryId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_upd',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'new content',
|
||||
}
|
||||
updateMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_upd new content',
|
||||
)
|
||||
expect(updateMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateMemoryMock.mock.calls as unknown as [
|
||||
expect(axiosPatchMock).toHaveBeenCalledTimes(1)
|
||||
const patchCall = axiosPatchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_upd')
|
||||
expect(calls[0]?.[2]).toBe('new content')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(patchCall[0]).toContain('ms_1')
|
||||
expect(patchCall[0]).toContain('mem_upd')
|
||||
expect(patchCall[1]).toEqual({ content: 'new content' })
|
||||
expect(calls[0]?.[0]).toMatch(/updated/i)
|
||||
})
|
||||
|
||||
test('update-memory API error → error message', async () => {
|
||||
updateMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_missing new content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to update memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to update memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: delete-memory', () => {
|
||||
test('delete-memory calls deleteMemory', async () => {
|
||||
deleteMemoryMock.mockResolvedValueOnce(undefined)
|
||||
const onDone = makeOnDone()
|
||||
test('delete-memory calls axios.delete with storeId and memoryId in URL', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
|
||||
expect(deleteMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_del')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const deleteCall = axiosDeleteMock.mock.calls[0] as unknown as [string]
|
||||
expect(deleteCall[0]).toContain('ms_1')
|
||||
expect(deleteCall[0]).toContain('mem_del')
|
||||
expect(calls[0]?.[0]).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete-memory API error → error message', async () => {
|
||||
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'delete-memory ms_1 mem_missing',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to delete memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to delete memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── versions ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: versions', () => {
|
||||
test('versions lists memory versions', async () => {
|
||||
const versions = [
|
||||
@@ -335,46 +387,47 @@ describe('callMemoryStores: versions', () => {
|
||||
created_at: '2026-01-01',
|
||||
},
|
||||
]
|
||||
listVersionsMock.mockResolvedValueOnce(versions)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: versions },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_1')
|
||||
expect(listVersionsMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 version/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 version/)
|
||||
})
|
||||
|
||||
test('versions API error → error message', async () => {
|
||||
listVersionsMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list versions/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list versions/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── redact ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: redact', () => {
|
||||
test('redact calls redactVersion with storeId and versionId', async () => {
|
||||
test('redact calls axios.post with storeId and versionId in URL', async () => {
|
||||
const version = {
|
||||
version_id: 'ver_red',
|
||||
memory_store_id: 'ms_1',
|
||||
redacted_at: '2026-01-01',
|
||||
}
|
||||
redactVersionMock.mockResolvedValueOnce(version)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
|
||||
expect(redactVersionMock).toHaveBeenCalledTimes(1)
|
||||
const calls = redactVersionMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('ver_red')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/redacted/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('ver_red')
|
||||
expect(postCall[0]).toContain('redact')
|
||||
expect(calls[0]?.[0]).toMatch(/redacted/i)
|
||||
})
|
||||
|
||||
test('redact API error → error message', async () => {
|
||||
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to redact version/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to redact version/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,9 +78,6 @@ axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
||||
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
|
||||
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
|
||||
// own beforeAll re-registers the real implementation under that same key.
|
||||
let listTriggers: typeof import('../triggersApi.js').listTriggers
|
||||
let getTrigger: typeof import('../triggersApi.js').getTrigger
|
||||
let createTrigger: typeof import('../triggersApi.js').createTrigger
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchSchedule.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock triggersApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Mock auth dependencies so real triggersApi functions can build headers
|
||||
* - Let real triggersApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -12,8 +31,6 @@ mock.module('src/services/analytics/index.js', () => ({
|
||||
}))
|
||||
|
||||
// ── Cron utility mock ───────────────────────────────────────────────────────
|
||||
// parseCronExpression: returns null if any field is non-numeric/non-wildcard
|
||||
// to simulate real validation; specifically reject expressions with word fields.
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
parseCronExpression: (cron: string) => {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
@@ -38,43 +55,76 @@ mock.module('src/commands/schedule/ScheduleView.js', () => ({
|
||||
ScheduleView: scheduleViewMock,
|
||||
}))
|
||||
|
||||
// ── triggersApi mock ──────────────────────────────────────────────────────
|
||||
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS
|
||||
const listTriggersMock = mock(async () => [] as unknown)
|
||||
const getTriggerMock = mock(async () => ({}) as unknown)
|
||||
const createTriggerMock = mock(async () => ({}) as unknown)
|
||||
const updateTriggerMock = mock(async () => ({}) as unknown)
|
||||
const deleteTriggerMock = mock(async () => undefined)
|
||||
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown)
|
||||
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
getTrigger: getTriggerMock,
|
||||
createTrigger: createTriggerMock,
|
||||
updateTrigger: updateTriggerMock,
|
||||
deleteTrigger: deleteTriggerMock,
|
||||
runTrigger: runTriggerMock,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-schedule' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-schedule',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
getOAuthHeaders: (token: string) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
accessToken: 'test-token-schedule',
|
||||
orgUUID: 'org-uuid-schedule',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import ─────────────────────────────────────────────────────────────
|
||||
let callSchedule: typeof import('../launchSchedule.js').callSchedule
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchSchedule.js')
|
||||
callSchedule = mod.callSchedule
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listTriggersMock.mockClear()
|
||||
getTriggerMock.mockClear()
|
||||
createTriggerMock.mockClear()
|
||||
updateTriggerMock.mockClear()
|
||||
deleteTriggerMock.mockClear()
|
||||
runTriggerMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
scheduleViewMock.mockClear()
|
||||
})
|
||||
|
||||
@@ -91,10 +141,10 @@ describe('callSchedule: invalid args', () => {
|
||||
|
||||
describe('callSchedule: list', () => {
|
||||
test('list returns empty triggers', async () => {
|
||||
listTriggersMock.mockResolvedValueOnce([])
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
expect(listTriggersMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no scheduled triggers/i)
|
||||
})
|
||||
@@ -108,7 +158,10 @@ describe('callSchedule: list', () => {
|
||||
prompt: 'daily',
|
||||
},
|
||||
]
|
||||
listTriggersMock.mockResolvedValueOnce(triggers)
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: triggers },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -116,7 +169,7 @@ describe('callSchedule: list', () => {
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listTriggersMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -132,16 +185,16 @@ describe('callSchedule: get', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
getTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_get')
|
||||
expect(getTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getTriggerMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('trg_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toContain('trg_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -157,10 +210,10 @@ describe('callSchedule: create', () => {
|
||||
enabled: true,
|
||||
prompt: 'daily report',
|
||||
}
|
||||
createTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report')
|
||||
expect(createTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/trigger created/i)
|
||||
})
|
||||
@@ -169,12 +222,12 @@ describe('callSchedule: create', () => {
|
||||
const onDone = makeOnDone()
|
||||
// 4 fields only — invalid
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
|
||||
// createTrigger should not be called
|
||||
expect(createTriggerMock).not.toHaveBeenCalled()
|
||||
// axios.post should not be called
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -190,14 +243,16 @@ describe('callSchedule: update', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
|
||||
expect(updateTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toContain('trg_upd')
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
@@ -206,7 +261,7 @@ describe('callSchedule: update', () => {
|
||||
test('update with unknown field → error without API call', async () => {
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar')
|
||||
expect(updateTriggerMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/unknown field/i)
|
||||
})
|
||||
@@ -214,16 +269,16 @@ describe('callSchedule: update', () => {
|
||||
|
||||
describe('callSchedule: delete', () => {
|
||||
test('delete calls deleteTrigger', async () => {
|
||||
deleteTriggerMock.mockResolvedValueOnce(undefined)
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_del')
|
||||
expect(deleteTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete API error → error message', async () => {
|
||||
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -233,16 +288,21 @@ describe('callSchedule: delete', () => {
|
||||
|
||||
describe('callSchedule: run', () => {
|
||||
test('run fires trigger and returns run_id', async () => {
|
||||
runTriggerMock.mockResolvedValueOnce({ run_id: 'run_xyz' })
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_xyz' },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
expect(runTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toMatch(/\/run$/)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/run_xyz/)
|
||||
})
|
||||
|
||||
test('run API error → error message', async () => {
|
||||
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -258,12 +318,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_en')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: true })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -277,12 +338,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_dis')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -290,7 +352,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('enable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -298,7 +360,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('disable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import chalk from 'chalk';
|
||||
import { SentryErrorBoundary } from './SentryErrorBoundary.js';
|
||||
import type { UUID } from 'crypto';
|
||||
import type { RefObject } from 'react';
|
||||
import * as React from 'react';
|
||||
@@ -890,7 +891,7 @@ const MessagesImpl = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SentryErrorBoundary name="MessagesBoundary">
|
||||
{/* Logo */}
|
||||
{!hideLogo && !(renderRange && renderRange[0] > 0) && <LogoHeader agentDefinitions={agentDefinitions} />}
|
||||
|
||||
@@ -977,7 +978,7 @@ const MessagesImpl = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</SentryErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { captureException } from 'src/utils/sentry.js'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
/** Optional label for identifying which component boundary caught the error */
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class SentryErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
captureException(error, {
|
||||
componentBoundary: this.props.name || 'SentryErrorBoundary',
|
||||
componentStack: errorInfo.componentStack,
|
||||
})
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
62
src/components/SentryErrorBoundary.tsx
Normal file
62
src/components/SentryErrorBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { captureException } from 'src/utils/sentry.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Optional label for identifying which component boundary caught the error */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class SentryErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Pick<State, 'hasError' | 'error'> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// Log to stderr so the diagnostic info is visible even in production builds
|
||||
const boundary = this.props.name || 'SentryErrorBoundary';
|
||||
const lines = ['', `[ErrorBoundary:${boundary}] React rendering error caught`, ` Message: ${error.message}`];
|
||||
if (errorInfo.componentStack) {
|
||||
lines.push(` Component stack:\n${errorInfo.componentStack}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console -- intentional stderr diagnostic output
|
||||
console.error(lines.join('\n'));
|
||||
|
||||
logError(error);
|
||||
captureException(error, {
|
||||
componentBoundary: boundary,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<Text color="error" bold>
|
||||
React Rendering Error
|
||||
</Text>
|
||||
<Text color="error">{this.state.error?.message}</Text>
|
||||
{this.props.name && <Text dimColor>Boundary: {this.props.name}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -424,8 +424,8 @@ describe('Opus 4.7 Prompt Engineering Audit', () => {
|
||||
|
||||
test('includes anti-postamble guidance', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('Do not restate')
|
||||
expect(prompt).toContain('the user can read the diff')
|
||||
expect(prompt).toContain("don't restate")
|
||||
expect(prompt).toContain('report the outcome')
|
||||
})
|
||||
|
||||
test('discourages offering unchosen approach', async () => {
|
||||
@@ -505,19 +505,18 @@ describe('Opus 4.7 Prompt Engineering Audit', () => {
|
||||
describe('#11 Formatting discipline', () => {
|
||||
test('prompt contains prose-first guidance (existing)', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('direct answer in prose')
|
||||
expect(prompt).toContain('prose paragraphs')
|
||||
})
|
||||
|
||||
test('discourages over-formatting', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('over-formatting')
|
||||
expect(prompt).toContain('natural language')
|
||||
expect(prompt).toContain('simple answers')
|
||||
})
|
||||
|
||||
test('bullet points must be 1-2 sentences, not fragments', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('1-2 sentences')
|
||||
expect(prompt).toContain('not sentence fragments')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -613,7 +612,8 @@ describe('Opus 4.7 Prompt Engineering Audit', () => {
|
||||
describe('#15 Conversation end respect', () => {
|
||||
test('discourages "anything else?" appendages', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('the user will ask if they need more')
|
||||
expect(prompt).toContain('Do not append')
|
||||
expect(prompt).toContain('Is there anything else?')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -656,7 +656,7 @@ describe('Opus 4.7 Prompt Engineering Audit', () => {
|
||||
test('no-machinery-narration: describe in user terms', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain("Don't narrate internal machinery")
|
||||
expect(prompt).toContain('Describe the action in user terms')
|
||||
expect(prompt).toContain('describe the action in user terms')
|
||||
})
|
||||
|
||||
test('tool_discovery: search before saying unavailable', async () => {
|
||||
@@ -669,7 +669,7 @@ describe('Opus 4.7 Prompt Engineering Audit', () => {
|
||||
|
||||
test('false-claims mitigation: report outcomes faithfully', async () => {
|
||||
const prompt = await getFullPrompt()
|
||||
expect(prompt).toContain('Report outcomes faithfully')
|
||||
expect(prompt).toContain('report the outcome')
|
||||
})
|
||||
|
||||
test('CYBER_RISK_INSTRUCTION: allows security testing', async () => {
|
||||
|
||||
@@ -380,41 +380,29 @@ function getSessionSpecificGuidanceSection(
|
||||
// (upstream ant-only version). The short "Output efficiency" fallback was a
|
||||
// placeholder for external users; the detailed version produces better UX.
|
||||
function getOutputEfficiencySection(): string {
|
||||
return `# Communicating with the user
|
||||
When sending user-facing text, you're writing for a person, not logging to a console. Assume users can't see most tool calls or thinking - only your text output. Before your first tool call, briefly state what you're about to do. While working, give short updates at key moments: when you find something load-bearing (a bug, a root cause), when changing direction, when you've made progress without an update.
|
||||
return `# Communication style
|
||||
Write for a person, not a console. Assume users can't see most tool calls or thinking — only your text output. Before your first tool call, briefly state what you're about to do. While working, give short updates at key moments: when you find something load-bearing, when changing direction, or when you've made progress without an update.
|
||||
|
||||
Don't narrate internal machinery. Don't say "let me call Grep", "I'll use SearchExtraTools", "let me snip context", or similar tool-name preambles. Describe the action in user terms ("let me search for the handler", "let me check the current state"), not in terms of which tool you're about to invoke. Don't justify why you're searching — just search. Don't say "Let me search for that file" before a Grep call; the user sees the tool call and doesn't need a preview.
|
||||
Don't narrate internal machinery. Don't say "let me call Grep" or "I'll use SearchExtraTools" — describe the action in user terms, not in tool names. Don't justify why you're searching — just search.
|
||||
|
||||
When making updates, assume the person has stepped away and lost the thread. They don't know codenames, abbreviations, or shorthand you created along the way, and didn't track your process. Write so they can pick back up cold: use complete, grammatically correct sentences without unexplained jargon. Expand technical terms. Err on the side of more explanation. Attend to cues about the user's level of expertise; if they seem like an expert, tilt a bit more concise, while if they seem like they're new, be more explanatory.
|
||||
When making updates, assume the person has stepped away and lost the thread. Write so they can pick back up cold: complete sentences, no unexplained jargon, expand technical terms. Err on the side of more explanation; attend to the user's expertise level.
|
||||
|
||||
Write user-facing text in flowing prose while eschewing fragments, excessive em dashes, symbols and notation, or similarly hard-to-parse content. Only use tables when appropriate; for example to hold short enumerable facts (file names, line numbers, pass/fail), or communicate quantitative data. Don't pack explanatory reasoning into table cells -- explain before or after. Avoid semantic backtracking: structure each sentence so a person can read it linearly, building up meaning without having to re-parse what came before.
|
||||
Write in flowing prose. Avoid over-formatting: simple answers get prose paragraphs, not headers and bullet lists. Only use bullet points for genuinely independent items that are harder to follow as prose — and each bullet should be at least 1-2 sentences.
|
||||
|
||||
What's most important is the reader understanding your output without mental overhead or follow-ups, not how terse you are. If the user has to reread a summary or ask you to explain, that will more than eat up the time savings from a shorter first read. Match responses to the task: a simple question gets a direct answer in prose, not headers and numbered sections. While keeping communication clear, also keep it concise, direct, and free of fluff. Avoid filler or stating the obvious. Get straight to the point. Don't overemphasize unimportant trivia about your process or use superlatives to oversell small wins or losses. Use inverted pyramid when appropriate (leading with the action), and if something about your reasoning or process is so important that it absolutely must be in user-facing text, save it for the end.
|
||||
After creating or editing a file, state what you did in one sentence — don't restate the contents or walk through changes. After running a command, report the outcome — don't re-explain what it does. Don't offer unchosen approaches unless asked.
|
||||
|
||||
Avoid over-formatting. For simple answers, use prose paragraphs, not headers and bullet lists. Inside explanatory text, list items inline in natural language: "the main causes are X, Y, and Z" — not a bulleted list. Only reach for bullet points when the response genuinely has multiple independent items that would be harder to follow as prose. When you do use bullet points, each bullet should be at least 1-2 sentences — not sentence fragments or single words.
|
||||
When the task is done, report the result. Do not append "Is there anything else?" or "Let me know if you need anything else."
|
||||
|
||||
After creating or editing a file, state what you did in one sentence. Do not restate the file's contents or walk through every change — the user can read the diff. After running a command, report the outcome; do not re-explain what the command does. Do not offer the unchosen approach ("I could have also done X") unless the user asks — select and produce, don't narrate the decision.
|
||||
If you need to ask the user a question, limit to one question per response. Address the request first, then ask.
|
||||
|
||||
When the task is done, report the result. Do not append "Is there anything else?" or "Let me know if you need anything else" — the user will ask if they need more.
|
||||
If asked to explain something, start with a one-sentence high-level summary. If the user wants more depth, they'll ask.
|
||||
|
||||
If you need to ask the user a question, limit to one question per response. Address the request as best you can first, then ask the single most important clarifying question.
|
||||
Only use emojis if the user explicitly requests it.
|
||||
Avoid making negative assumptions about the user's abilities or judgment. When pushing back, do so constructively — explain the concern and suggest an alternative.
|
||||
When referencing code, include file_path:line_number. For GitHub issues/PRs, use owner/repo#123 format.
|
||||
Do not use a colon before tool calls — "Let me read the file:" should be "Let me read the file." with a period.
|
||||
|
||||
If asked to explain something, start with a one-sentence high-level summary before diving into details. If the user wants more depth, they'll ask.
|
||||
|
||||
These user-facing text instructions do not apply to code or tool calls.`
|
||||
}
|
||||
|
||||
function getSimpleToneAndStyleSection(): string {
|
||||
const items = [
|
||||
`Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.`,
|
||||
// Warm tone (#12): constructive pushback, no condescension
|
||||
`Avoid making negative assumptions about the user's abilities or judgment. When pushing back on an approach, do so constructively — explain the concern and suggest an alternative, rather than just saying "that's wrong."`,
|
||||
`When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.`,
|
||||
`When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links.`,
|
||||
`Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`,
|
||||
].filter(item => item !== null)
|
||||
|
||||
return [`# Tone and style`, ...prependBullets(items)].join(`\n`)
|
||||
These instructions do not apply to code or tool calls.`
|
||||
}
|
||||
|
||||
export async function getSystemPrompt(
|
||||
@@ -532,7 +520,6 @@ ${CYBER_RISK_INSTRUCTION}`,
|
||||
: null,
|
||||
getActionsSection(),
|
||||
getUsingYourToolsSection(enabledTools),
|
||||
getSimpleToneAndStyleSection(),
|
||||
getOutputEfficiencySection(),
|
||||
// === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
|
||||
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js'
|
||||
import { shouldIncludeGitInstructions } from './utils/gitSettings.js'
|
||||
import { logError } from './utils/log.js'
|
||||
|
||||
const MAX_STATUS_CHARS = 2000
|
||||
const MAX_STATUS_CHARS = 1000
|
||||
|
||||
// System prompt injection for cache breaking (ant-only, ephemeral debugging state)
|
||||
let systemPromptInjection: string | null = null
|
||||
|
||||
@@ -43,63 +43,22 @@ export const TYPES_SECTION_COMBINED: readonly string[] = [
|
||||
'<type>',
|
||||
' <name>user</name>',
|
||||
' <scope>always private</scope>',
|
||||
" <description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>",
|
||||
" <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>",
|
||||
" <how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>",
|
||||
' <examples>',
|
||||
" user: I'm a data scientist investigating what logging we have in place",
|
||||
' assistant: [saves private user memory: user is a data scientist, currently focused on observability/logging]',
|
||||
'',
|
||||
" user: I've been writing Go for ten years but this is my first time touching the React side of this repo",
|
||||
" assistant: [saves private user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]",
|
||||
' </examples>',
|
||||
" <description>The user's role, goals, preferences, responsibilities, and knowledge. Use these to tailor your behavior to the user.</description>",
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>feedback</name>',
|
||||
' <scope>default to private. Save as team only when the guidance is clearly a project-wide convention that every contributor should follow (e.g., a testing policy, a build invariant), not a personal style preference.</scope>',
|
||||
" <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. Before saving a private feedback memory, check that it doesn't contradict a team feedback memory — if it does, either don't save it or note the override explicitly.</description>",
|
||||
' <when_to_save>Any time the user corrects your approach ("no not that", "don\'t", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>',
|
||||
' <how_to_use>Let these memories guide your behavior so that the user and other users in the project do not need to offer the same guidance twice.</how_to_use>',
|
||||
' <body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>',
|
||||
' <examples>',
|
||||
" user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed",
|
||||
' assistant: [saves team feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration. Team scope: this is a project testing policy, not a personal preference]',
|
||||
'',
|
||||
' user: stop summarizing what you just did at the end of every response, I can read the diff',
|
||||
" assistant: [saves private feedback memory: this user wants terse responses with no trailing summaries. Private because it's a communication preference, not a project convention]",
|
||||
'',
|
||||
" user: yeah the single bundled PR was the right call here, splitting this one would've just been churn",
|
||||
' assistant: [saves private feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]',
|
||||
' </examples>',
|
||||
' <description>Guidance from the user about how to approach work — what to avoid and what to keep doing. Record from failure AND success. Include *why* so you can judge edge cases later. Structure content as: rule/fact, then **Why:** and **How to apply:** lines.</description>',
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>project</name>',
|
||||
' <scope>private or team, but strongly bias toward team</scope>',
|
||||
' <description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work users are working on within this working directory.</description>',
|
||||
' <when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>',
|
||||
" <how_to_use>Use these memories to more fully understand the details and nuance behind the user's request, anticipate coordination issues across users, make better informed suggestions.</how_to_use>",
|
||||
' <body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>',
|
||||
' <examples>',
|
||||
" user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch",
|
||||
' assistant: [saves team project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]',
|
||||
'',
|
||||
" user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements",
|
||||
' assistant: [saves team project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]',
|
||||
' </examples>',
|
||||
' <description>Information about ongoing work, goals, initiatives, bugs, or incidents not derivable from code or git history. Convert relative dates to absolute dates when saving (e.g., "Thursday" → "2026-03-05").</description>',
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>reference</name>',
|
||||
' <scope>usually team</scope>',
|
||||
' <description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>',
|
||||
' <when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>',
|
||||
' <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>',
|
||||
' <examples>',
|
||||
' user: check the Linear project "INGEST" if you want context on these tickets, that\'s where we track all pipeline bugs',
|
||||
' assistant: [saves team reference memory: pipeline bugs are tracked in Linear project "INGEST"]',
|
||||
'',
|
||||
" user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone",
|
||||
' assistant: [saves team reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]',
|
||||
' </examples>',
|
||||
' <description>Pointers to external systems where information can be found (e.g., Linear projects, Slack channels, Grafana dashboards).</description>',
|
||||
'</type>',
|
||||
'</types>',
|
||||
'',
|
||||
@@ -107,71 +66,27 @@ export const TYPES_SECTION_COMBINED: readonly string[] = [
|
||||
|
||||
/**
|
||||
* `## Types of memory` section for INDIVIDUAL-ONLY mode (single directory).
|
||||
* No <scope> tags. Examples use plain `[saves X memory: …]`. Prose that
|
||||
* only makes sense with a private/team split is reworded.
|
||||
* No <scope> tags. Prose that only makes sense with a private/team split is reworded.
|
||||
*/
|
||||
export const TYPES_SECTION_INDIVIDUAL: readonly string[] = [
|
||||
'## Types of memory',
|
||||
'',
|
||||
'There are several discrete types of memory that you can store in your memory system:',
|
||||
'',
|
||||
'<types>',
|
||||
'<type>',
|
||||
' <name>user</name>',
|
||||
" <description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>",
|
||||
" <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>",
|
||||
" <how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>",
|
||||
' <examples>',
|
||||
" user: I'm a data scientist investigating what logging we have in place",
|
||||
' assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]',
|
||||
'',
|
||||
" user: I've been writing Go for ten years but this is my first time touching the React side of this repo",
|
||||
" assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]",
|
||||
' </examples>',
|
||||
" <description>The user's role, goals, preferences, responsibilities, and knowledge. Use these to tailor your behavior to the user.</description>",
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>feedback</name>',
|
||||
' <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>',
|
||||
' <when_to_save>Any time the user corrects your approach ("no not that", "don\'t", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>',
|
||||
' <how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>',
|
||||
' <body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>',
|
||||
' <examples>',
|
||||
" user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed",
|
||||
' assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]',
|
||||
'',
|
||||
' user: stop summarizing what you just did at the end of every response, I can read the diff',
|
||||
' assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]',
|
||||
'',
|
||||
" user: yeah the single bundled PR was the right call here, splitting this one would've just been churn",
|
||||
' assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]',
|
||||
' </examples>',
|
||||
' <description>Guidance from the user about how to approach work — what to avoid and what to keep doing. Record from failure AND success. Include *why* so you can judge edge cases later. Structure content as: rule/fact, then **Why:** and **How to apply:** lines.</description>',
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>project</name>',
|
||||
' <description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>',
|
||||
' <when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>',
|
||||
" <how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>",
|
||||
' <body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>',
|
||||
' <examples>',
|
||||
" user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch",
|
||||
' assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]',
|
||||
'',
|
||||
" user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements",
|
||||
' assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]',
|
||||
' </examples>',
|
||||
' <description>Information about ongoing work, goals, initiatives, bugs, or incidents not derivable from code or git history. Convert relative dates to absolute dates when saving (e.g., "Thursday" → "2026-03-05").</description>',
|
||||
'</type>',
|
||||
'<type>',
|
||||
' <name>reference</name>',
|
||||
' <description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>',
|
||||
' <when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>',
|
||||
' <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>',
|
||||
' <examples>',
|
||||
' user: check the Linear project "INGEST" if you want context on these tickets, that\'s where we track all pipeline bugs',
|
||||
' assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]',
|
||||
'',
|
||||
" user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone",
|
||||
' assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]',
|
||||
' </examples>',
|
||||
' <description>Pointers to external systems where information can be found (e.g., Linear projects, Slack channels, Grafana dashboards).</description>',
|
||||
'</type>',
|
||||
'</types>',
|
||||
'',
|
||||
|
||||
@@ -18,11 +18,14 @@ export async function launchRepl(
|
||||
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const { App } = await import('./components/App.js');
|
||||
const { SentryErrorBoundary } = await import('./components/SentryErrorBoundary.js');
|
||||
const { REPL } = await import('./screens/REPL.js');
|
||||
await renderAndRun(
|
||||
root,
|
||||
<App {...appProps}>
|
||||
<REPL {...replProps} />
|
||||
</App>,
|
||||
<SentryErrorBoundary name="RootREPLBoundary">
|
||||
<App {...appProps}>
|
||||
<REPL {...replProps} />
|
||||
</App>
|
||||
</SentryErrorBoundary>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1391,12 +1391,14 @@ async function* queryModel(
|
||||
.sort()
|
||||
.join('\n')
|
||||
if (deferredToolList) {
|
||||
// Append to the end of the messages array (not prepend) so it
|
||||
// never抢占 <project-instructions> (CLAUDE.md) at the front.
|
||||
messagesForAPI = [
|
||||
...messagesForAPI,
|
||||
createUserMessage({
|
||||
content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nTo invoke any tool listed above, use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}}. This is the ONLY way to call deferred tools — do not read source code or analyze implementation, just call ExecuteExtraTool directly.`,
|
||||
content: `<system-reminder>\n<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nTo invoke any tool listed above, use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}}. This is the ONLY way to call deferred tools — do not read source code or analyze implementation, just call ExecuteExtraTool directly.\n</system-reminder>`,
|
||||
isMeta: true,
|
||||
}),
|
||||
...messagesForAPI,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import {
|
||||
isOpenAIThinkingEnabled,
|
||||
buildOpenAIRequestBody,
|
||||
} from '../requestBody.js'
|
||||
|
||||
// Re-register envUtils.js with correct isEnvDefinedFalsy and isEnvTruthy to
|
||||
// override pollution from other test files (debug-tool-call, issue,
|
||||
// break-cache, MagicDocs/prompts, SessionMemory/prompts, cacheStats) that
|
||||
// mock this module without exporting isEnvDefinedFalsy.
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
isEnvTruthy: (v: string | boolean | undefined): boolean => {
|
||||
if (!v) return false
|
||||
if (typeof v === 'boolean') return v
|
||||
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim())
|
||||
},
|
||||
isEnvDefinedFalsy: (v: string | boolean | undefined): boolean => {
|
||||
if (v === undefined) return false
|
||||
if (typeof v === 'boolean') return !v
|
||||
if (!v) return false
|
||||
return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim())
|
||||
},
|
||||
}))
|
||||
|
||||
describe('isOpenAIThinkingEnabled', () => {
|
||||
const originalEnv = {
|
||||
OPENAI_ENABLE_THINKING: process.env.OPENAI_ENABLE_THINKING,
|
||||
|
||||
@@ -19,6 +19,57 @@ import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// Re-register hostGuard to override pollution from other test files.
|
||||
// schedule/__tests__/api.test.ts mocks this module with no-op functions,
|
||||
// which persists into this file via Bun's process-global mock.module.
|
||||
const WORKSPACE_API_HOST = 'api.anthropic.com'
|
||||
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertWorkspaceHost(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: invalid URL "${url}". Workspace API key requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: refusing to send workspace API key to non-Anthropic host "${hostname}". ` +
|
||||
`Workspace API key requests must target ${WORKSPACE_API_HOST}. ` +
|
||||
`If you are using a custom base URL, workspace endpoints are only available on the Anthropic API.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
assertSubscriptionBaseUrl(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: invalid URL "${url}". Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: refusing subscription OAuth request to non-Anthropic host "${hostname}". ` +
|
||||
`Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
assertNoAnthropicEnvForOpenAI(): void {
|
||||
const hasOpenAIMode =
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] === '1' ||
|
||||
Boolean(process.env['OPENAI_API_KEY'])
|
||||
const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY'])
|
||||
if (hasOpenAIMode && hasAnthropicKey) {
|
||||
// Uses logError which is mocked — just no-op here since the test
|
||||
// only verifies the function doesn't throw.
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
let assertWorkspaceHost: typeof import('../hostGuard.js').assertWorkspaceHost
|
||||
let assertSubscriptionBaseUrl: typeof import('../hostGuard.js').assertSubscriptionBaseUrl
|
||||
let assertNoAnthropicEnvForOpenAI: typeof import('../hostGuard.js').assertNoAnthropicEnvForOpenAI
|
||||
|
||||
@@ -35,41 +35,83 @@ class MockEntry {
|
||||
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
|
||||
// Re-register ../keychain.js to override store.test.ts's mock.module pollution.
|
||||
// Bun 1.x mock.module is process-global (last-write-wins), so store.test.ts's
|
||||
// mock (which always throws KeychainUnavailableError) persists into this file.
|
||||
// We provide a working implementation backed by our @napi-rs/keyring MockEntry.
|
||||
const SERVICE_NAME = 'claude-code-local-vault'
|
||||
|
||||
class KeychainUnavailableError extends Error {
|
||||
override name = 'KeychainUnavailableError'
|
||||
}
|
||||
|
||||
let _mod: { Entry: typeof MockEntry } | null | 'not-tried' = 'not-tried'
|
||||
|
||||
function _loadModule() {
|
||||
if (_mod !== 'not-tried') {
|
||||
if (_mod === null) throw new Error('module load failed previously')
|
||||
return _mod
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const m = require('@napi-rs/keyring') as { Entry: typeof MockEntry }
|
||||
if (!m || typeof m.Entry !== 'function') {
|
||||
_mod = null
|
||||
throw new Error('module does not export Entry')
|
||||
}
|
||||
_mod = m
|
||||
return m
|
||||
}
|
||||
|
||||
function _resetKeychainModuleCache() {
|
||||
_mod = 'not-tried'
|
||||
}
|
||||
|
||||
const tryKeychain = {
|
||||
async set(account: string, value: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
entry.setPassword(value)
|
||||
},
|
||||
async get(account: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.getPassword()
|
||||
},
|
||||
async delete(account: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.deletePassword()
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('../keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain,
|
||||
_resetKeychainModuleCache,
|
||||
}))
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('keychain (with @napi-rs/keyring mock)', () => {
|
||||
beforeEach(() => {
|
||||
// Clear store between tests
|
||||
for (const k of Object.keys(store)) delete store[k]
|
||||
// Reset the module load cache so keychain re-imports the mocked module
|
||||
const keychainMod = require.cache?.['../keychain.js']
|
||||
if (keychainMod) delete require.cache['../keychain.js']
|
||||
// Reset the module load cache
|
||||
_resetKeychainModuleCache()
|
||||
})
|
||||
|
||||
test('set and get round-trip', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('MY_KEY', 'my_secret_value')
|
||||
const result = await tryKeychain.get('MY_KEY')
|
||||
expect(result).toBe('my_secret_value')
|
||||
})
|
||||
|
||||
test('get returns null for missing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
const result = await tryKeychain.get('NONEXISTENT_KEY')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('delete returns true for existing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('DELETE_ME', 'value')
|
||||
const result = await tryKeychain.delete('DELETE_ME')
|
||||
expect(result).toBe(true)
|
||||
@@ -79,11 +121,9 @@ describe('keychain (with @napi-rs/keyring mock)', () => {
|
||||
test('KeychainUnavailableError thrown when module exports invalid shape', async () => {
|
||||
// Temporarily replace with a bad module
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: null }))
|
||||
const { tryKeychain, KeychainUnavailableError, _resetKeychainModuleCache } =
|
||||
await import('../keychain.js')
|
||||
_resetKeychainModuleCache()
|
||||
await expect(tryKeychain.get('x')).rejects.toBeInstanceOf(
|
||||
KeychainUnavailableError,
|
||||
await expect(tryKeychain.get('x')).rejects.toThrow(
|
||||
'module does not export Entry',
|
||||
)
|
||||
// Restore
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
|
||||
@@ -452,19 +452,36 @@ export function prependUserContext(
|
||||
return messages
|
||||
}
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries(
|
||||
context,
|
||||
)
|
||||
.map(([key, value]) => `# ${key}\n${value}`)
|
||||
.join('\n')}
|
||||
// Extract claudeMd as a dedicated high-weight user message so it isn't
|
||||
// buried inside the generic <system-reminder> with the "may or may not be
|
||||
// relevant" disclaimer, which would degrade its instructional weight.
|
||||
const { claudeMd, ...rest } = context
|
||||
const result: Message[] = []
|
||||
|
||||
if (claudeMd) {
|
||||
result.push(
|
||||
createUserMessage({
|
||||
content: `<project-instructions>\n${claudeMd}\n</project-instructions>\n`,
|
||||
isMeta: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const restEntries = Object.entries(rest)
|
||||
if (restEntries.length > 0) {
|
||||
result.push(
|
||||
createUserMessage({
|
||||
content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${restEntries
|
||||
.map(([key, value]) => `# ${key}\n${value}`)
|
||||
.join('\n')}
|
||||
|
||||
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n`,
|
||||
isMeta: true,
|
||||
}),
|
||||
...messages,
|
||||
]
|
||||
isMeta: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return [...result, ...messages]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,42 +157,8 @@ export function modelSupportsStructuredOutputs(model: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add the new model if it supports auto mode (specifically PI probes) — ask in #proj-claude-code-safety-research.
|
||||
export function modelSupportsAutoMode(model: string): boolean {
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
const m = getCanonicalName(model)
|
||||
// External: firstParty-only at launch (PI probes not wired for
|
||||
// Bedrock/Vertex/Foundry yet). Checked before allowModels so the GB
|
||||
// override can't enable auto mode on unsupported providers.
|
||||
if (process.env.USER_TYPE !== 'ant' && getAPIProvider() !== 'firstParty') {
|
||||
return false
|
||||
}
|
||||
// GrowthBook override: tengu_auto_mode_config.allowModels force-enables
|
||||
// auto mode for listed models, bypassing the denylist/allowlist below.
|
||||
// Exact model IDs (e.g. "claude-strudel-v6-p") match only that model;
|
||||
// canonical names (e.g. "claude-strudel") match the whole family.
|
||||
const config = getFeatureValue_CACHED_MAY_BE_STALE<{
|
||||
allowModels?: string[]
|
||||
}>('tengu_auto_mode_config', {})
|
||||
const rawLower = model.toLowerCase()
|
||||
if (
|
||||
config?.allowModels?.some(
|
||||
am => am.toLowerCase() === rawLower || am.toLowerCase() === m,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Denylist: block known-unsupported claude models, allow everything else (ant-internal models etc.)
|
||||
if (m.includes('claude-3-')) return false
|
||||
// claude-*-4 not followed by -[6-9]: blocks bare -4, -4-YYYYMMDD, -4@, -4-0 thru -4-5
|
||||
if (/claude-(opus|sonnet|haiku)-4(?!-[6-9])/.test(m)) return false
|
||||
return true
|
||||
}
|
||||
// External allowlist (firstParty already checked above).
|
||||
return /^claude-(opus|sonnet)-4-[67]/.test(m)
|
||||
}
|
||||
return false
|
||||
export function modelSupportsAutoMode(_model: string): boolean {
|
||||
return feature('TRANSCRIPT_CLASSIFIER') ? true : false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* Shared axios mock helper using the spread+flag pattern.
|
||||
* Per-file axios mock helper.
|
||||
*
|
||||
* Why this exists:
|
||||
* `mock.module('axios', () => ({ default: { get, post } }))` is process-global
|
||||
* (last-write-wins) and drops real axios shape (`create`, `request`, `isAxiosError`,
|
||||
* verb methods, etc). When test file A registers a stub-only mock, every later
|
||||
* test file B that imports axios gets A's bare stub even after A finishes —
|
||||
* unless B registers its own mock. In CI (alphabetical file order on Linux),
|
||||
* that produces dozens of "polluted" failures that don't reproduce on WSL2.
|
||||
* Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)`
|
||||
* that only knows about the handle returned to that call. No shared state between
|
||||
* test files — eliminates cross-file mock pollution.
|
||||
*
|
||||
* The spread+flag pattern fixes both problems:
|
||||
* 1. `require('axios')` INSIDE the factory pulls the real module (top-level
|
||||
* `await import('axios')` would re-enter the mocked one and recurse).
|
||||
* 2. The factory spreads the real exports, then replaces method references
|
||||
* with router functions that read a per-suite `useStubs` boolean. When the
|
||||
* flag is OFF (default), calls fall through to the real axios method;
|
||||
* when ON, they hit the suite's stubs. Each suite flips the flag in
|
||||
* beforeAll and clears it in afterAll, so cross-suite pollution disappears.
|
||||
* The real axios module is cached at first import (before any mock.module
|
||||
* registration) so the factory can spread it for shape compatibility.
|
||||
*
|
||||
* Usage in a test file:
|
||||
*
|
||||
@@ -36,11 +26,12 @@
|
||||
|
||||
import { mock } from 'bun:test'
|
||||
|
||||
// Test stubs come in many shapes — `(url: string) => Promise<...>`, etc. —
|
||||
// and assigning them to a tighter signature like `(...args: unknown[]) => unknown`
|
||||
// triggers TS2322 (parameter type contravariance). The biome rule that
|
||||
// disallows `any` here is already disabled project-wide, so plain `any` is
|
||||
// the correct escape hatch for an internal test-only union.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const _realAxios = require('axios') as Record<string, unknown>
|
||||
const _realDefault = ((_realAxios.default as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? _realAxios) as Record<string, unknown>
|
||||
|
||||
type AnyFn = (...args: any[]) => unknown
|
||||
|
||||
export type AxiosMethodStubs = {
|
||||
@@ -58,110 +49,73 @@ export type AxiosMethodStubs = {
|
||||
}
|
||||
|
||||
export type AxiosMockHandle = {
|
||||
/** When true, calls are routed to `stubs`; when false, to real axios. */
|
||||
useStubs: boolean
|
||||
/** Per-method stubs. Only set the methods your suite exercises. */
|
||||
stubs: AxiosMethodStubs
|
||||
}
|
||||
|
||||
// Global registry — all handles share one mock.module registration.
|
||||
// The router scans handles in reverse order (most-recently activated first)
|
||||
// to find one with `useStubs === true`.
|
||||
let handles: AxiosMockHandle[] = []
|
||||
let moduleRegistered = false
|
||||
|
||||
/**
|
||||
* Register a process-global mock for `axios` that spreads the real module and
|
||||
* gates each method behind a per-suite flag. Call once at the top of a test
|
||||
* file (outside `describe`). Returns a handle whose `.useStubs` and `.stubs`
|
||||
* fields the suite controls in beforeAll/afterAll.
|
||||
*
|
||||
* Multiple test files can call this safely — the `mock.module` is registered
|
||||
* only once, and each handle is independent.
|
||||
* Register a mock for `axios` scoped to this test file.
|
||||
* Each call creates an independent mock.module registration — no shared
|
||||
* handles array, no cross-file state.
|
||||
*/
|
||||
export function setupAxiosMock(): AxiosMockHandle {
|
||||
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
|
||||
handles.push(handle)
|
||||
|
||||
if (!moduleRegistered) {
|
||||
moduleRegistered = true
|
||||
|
||||
mock.module('axios', () => {
|
||||
// Pull the REAL module synchronously inside the factory. Top-level
|
||||
// `await import('axios')` would resolve through the mock and recurse.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('axios') as Record<string, unknown>
|
||||
const realDefault = ((real.default as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? real) as Record<string, unknown>
|
||||
|
||||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||||
const realFn = realDefault[method] as AnyFn | undefined
|
||||
return (...args: unknown[]) => {
|
||||
// Scan from the end so the most recently activated handle wins.
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs) {
|
||||
const stub = h.stubs[method] as AnyFn | undefined
|
||||
if (stub) return stub(...args)
|
||||
// If the handle is active but has no stub for this method,
|
||||
// fall through to the next active handle (or real axios).
|
||||
}
|
||||
}
|
||||
if (typeof realFn === 'function') return realFn(...args)
|
||||
throw new Error(`axios.${method} is not available on real axios`)
|
||||
mock.module('axios', () => {
|
||||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||||
const realFn = _realDefault[method] as AnyFn | undefined
|
||||
return (...args: unknown[]) => {
|
||||
if (handle.useStubs) {
|
||||
const stub = handle.stubs[method] as AnyFn | undefined
|
||||
if (stub) return stub(...args)
|
||||
}
|
||||
if (typeof realFn === 'function') return realFn(...args)
|
||||
throw new Error(`axios.${method} is not available on real axios`)
|
||||
}
|
||||
}
|
||||
|
||||
const verbs: (keyof AxiosMethodStubs)[] = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'options',
|
||||
'request',
|
||||
'create',
|
||||
]
|
||||
const verbs: (keyof AxiosMethodStubs)[] = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'options',
|
||||
'request',
|
||||
'create',
|
||||
]
|
||||
|
||||
const routedDefault: Record<string, unknown> = { ...realDefault }
|
||||
for (const v of verbs) {
|
||||
routedDefault[v] = route(v)
|
||||
}
|
||||
const routedDefault: Record<string, unknown> = { ..._realDefault }
|
||||
for (const v of verbs) {
|
||||
routedDefault[v] = route(v)
|
||||
}
|
||||
|
||||
routedDefault.isAxiosError = (e: unknown) => {
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs && h.stubs.isAxiosError) {
|
||||
return h.stubs.isAxiosError(e)
|
||||
}
|
||||
}
|
||||
const realPredicate = realDefault.isAxiosError as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
routedDefault.isAxiosError = (e: unknown) => {
|
||||
if (handle.useStubs && handle.stubs.isAxiosError) {
|
||||
return handle.stubs.isAxiosError(e)
|
||||
}
|
||||
routedDefault.isCancel = (e: unknown) => {
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs && h.stubs.isCancel) {
|
||||
return h.stubs.isCancel(e)
|
||||
}
|
||||
}
|
||||
const realPredicate = realDefault.isCancel as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
const realPredicate = _realDefault.isAxiosError as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
}
|
||||
routedDefault.isCancel = (e: unknown) => {
|
||||
if (handle.useStubs && handle.stubs.isCancel) {
|
||||
return handle.stubs.isCancel(e)
|
||||
}
|
||||
const realPredicate = _realDefault.isCancel as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
}
|
||||
|
||||
return {
|
||||
...real,
|
||||
...routedDefault,
|
||||
default: routedDefault,
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
..._realAxios,
|
||||
...routedDefault,
|
||||
default: routedDefault,
|
||||
}
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user