Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c161e6e
feat(card): AI Card v2 — block-based rendering, media embeds, quote/t…
soimy Apr 6, 2026
076cbb1
fix(test): repair 21 test failures after PR#494 rebase
soimy Apr 6, 2026
470b4cb
docs: update handoff with completed status and test fix details
soimy Apr 6, 2026
7f2f74d
docs(spec): add inbound-handler test refactor design
soimy Apr 6, 2026
4be1e7b
docs(plan): add inbound-handler test refactor implementation plan
soimy Apr 6, 2026
6c1087a
test: add shared mock fixture for inbound-handler tests
soimy Apr 6, 2026
4ea8f8a
test(inbound-handler): extract downloadMedia tests to separate file
soimy Apr 6, 2026
d2c8bff
test(inbound-handler): extract access control tests with merged allow…
soimy Apr 6, 2026
5d5ce6e
test(inbound-handler): extract abort pre-lock bypass tests to separat…
soimy Apr 6, 2026
d3a1eef
test(inbound-handler): extract quote handling tests with merged resol…
soimy Apr 6, 2026
037f118
test(inbound-handler): extract @sub-agent feature tests to dedicated …
soimy Apr 6, 2026
add3c10
test(inbound-handler): extract slash command tests with merged alias …
soimy Apr 6, 2026
5451fa7
fix(test): resolve type errors and add missing test setup
soimy Apr 6, 2026
825f202
test(inbound-handler): extract card streaming tests with merged buffe…
soimy Apr 6, 2026
b3d2eb8
test(inbound-handler): extract media handling tests
soimy Apr 6, 2026
5e242dd
docs: add test file scale control guidelines to all contributor docs
soimy Apr 6, 2026
6499de6
test(inbound-handler): extract card lifecycle tests with merged fallb…
soimy Apr 6, 2026
60c45af
test(inbound-handler): extract ack reaction tests with merged fallbac…
soimy Apr 6, 2026
d02ea40
refactor(test): remove duplicate tests from main file, fix split test…
soimy Apr 6, 2026
f65c2b4
docs: add fix plan for Codex review issues (P1/P2)
soimy Apr 6, 2026
e9ebc75
fix(card-v2): resolve 4 adversarial review issues for cross-user/data…
soimy Apr 7, 2026
3e36a47
feat(card-v2): align with V2 template - hasAction, agent in taskInfo,…
soimy Apr 7, 2026
267c4ed
docs: add handoff document for Card V2 template alignment
soimy Apr 8, 2026
1c032bb
merge: sync main branch changes (upstream alignment + media fixes)
soimy Apr 9, 2026
92a36ee
fix(card): reroute local markdown images and refresh task info
soimy Apr 10, 2026
1b941aa
fix(card): throttle answer streaming updates
soimy Apr 14, 2026
a168a6f
fix(card): address review follow-up regressions
soimy Apr 14, 2026
055607f
fix(card): unify media routing follow-ups
soimy Apr 14, 2026
29cf6da
docs(plans): add design and investigation notes
soimy Apr 14, 2026
5f3ac16
test(ci): align media flow expectations with unified send service
soimy Apr 14, 2026
b152de5
Merge remote-tracking branch 'origin/main' into card-template-v2-clean
soimy Apr 14, 2026
6e0dcb1
refactor(channel): extract domain adapters from assembly root
soimy Apr 14, 2026
a527e41
fix(card): preserve stop recovery state
soimy Apr 14, 2026
7dc403b
fix(card): normalize abort finalize block type
soimy Apr 14, 2026
e5225d6
fix(card): normalize think block format leakage
soimy Apr 19, 2026
6ab9a2c
feat(card): add run-usage-store for tracking token consumption per se…
soimy Apr 19, 2026
1d2e514
feat(card): register llm_output hook to capture token usage
soimy Apr 19, 2026
fb9899d
fix(test): type mockApi as OpenClawPluginApi in llm_output hook test
soimy Apr 19, 2026
872d17c
feat(card): wire token usage tracking into card reply strategy
soimy Apr 19, 2026
9eea441
fix(test): add on mock to runtime-peer-index test for llm_output hook
soimy Apr 19, 2026
e1ec736
docs: add token usage tracking implementation plan
soimy Apr 19, 2026
39b5d31
fix(lint): add curly braces to satisfy eslint(curly) rule
soimy Apr 19, 2026
730d6b3
fix(card): scope usage store by runId to prevent cross-agent pollution
soimy Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ Planned domain summary:
- Message dedup state in `src/dedup.ts`
- Runtime stored via getter/setter in `src/runtime.ts`

**Test File Structure:**

- Single test file should stay under 500 lines; files approaching 800+ lines require split planning
- Use `-` suffix to split by feature domain: `inbound-handler-quote.test.ts`, `send-service-media.test.ts`
- Share mock fixtures via `tests/unit/fixtures/` for complex multi-file test suites
- Each split file should focus on one feature domain with 10-25 tests
- Keep core end-to-end flow tests in the main file; extract sub-feature tests to split files
- Before splitting, analyze test chain for redundancy: merge tests validating same behavior ≥3 times
- Test file naming follows `source-module-{domain}.test.ts` pattern

## ANTI-PATTERNS (THIS PROJECT)

**Prohibited:**
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ New code should align with these logical boundaries (physical moves are incremen
- `clearMocks`, `restoreMocks`, `mockReset` are all enabled globally in vitest config
- When work involves DingTalk real-device validation, PR-scoped test checklist generation, `验证 TODO` drafting, or contributor guidance for that workflow, read and follow `skills/dingtalk-real-device-testing/SKILL.md` first.

### Test File Scale Control

- Target: <500 lines per test file; files >800 lines should be split
- Split pattern: `source-module-{domain}.test.ts` (e.g., `inbound-handler-quote.test.ts`)
- Shared fixtures: `tests/unit/fixtures/` for mock factories and test utilities
- Redundancy check before split: merge tests that validate identical behavior
- Main file retains core end-to-end flow; domain-specific tests go to split files

## Important Anti-Patterns

- Do not add business logic to `src/channel.ts`
Expand Down
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ What each command covers:

The test suite uses mocks for network calls. Do not depend on real DingTalk API access in automated tests.

## Test File Maintenance

When adding new tests or maintaining existing test files, follow these scale guidelines:

### Scale Thresholds

| Lines | Action |
|-------|--------|
| <500 | Acceptable, no action needed |
| 500-800 | Plan split for future work |
| >800 | Split required before merge |

### Split Strategy

1. **Identify feature domains** — Group tests by the feature they validate (e.g., quote handling, card lifecycle)
2. **Extract shared mocks** — Create fixture module in `tests/unit/fixtures/` for reusable mock setup
3. **Split by domain** — Create `source-module-{domain}.test.ts` files, each with 10-25 tests
4. **Retain core flows** — Keep end-to-end pipeline tests in the main file
5. **Clean redundancy** — Before splitting, merge tests that validate identical behavior ≥3 times

### Naming Convention

- Split files: `inbound-handler-quote.test.ts`, `send-service-media.test.ts`
- Fixture files: `tests/unit/fixtures/inbound-handler-fixture.ts`

## Manual Testing Expectations

If your change affects runtime behavior, include a short manual test note in the PR description.
Expand Down
25 changes: 25 additions & 0 deletions CONTRIBUTING.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ pnpm test:coverage

自动化测试中的网络请求应保持 mock;不要依赖真实 DingTalk API 访问来通过测试。

## 测试文件维护

添加新测试或维护既有测试文件时,请遵循以下规模指南:

### 规模阈值

| 行数 | 处理 |
|------|------|
| <500 | 正常,无需处理 |
| 500-800 | 规划拆分,可在后续工作中执行 |
| >800 | 必须拆分后再合并 |

### 拆分策略

1. **识别功能域** — 按被测功能分组(如 quote handling、card lifecycle)
2. **提取共享 mock** — 在 `tests/unit/fixtures/` 创建 fixture 模块
3. **按域拆分** — 创建 `source-module-{domain}.test.ts`,每个文件 10-25 个测试
4. **保留核心流程** — 端到端 pipeline 测试留在主文件
5. **清理冗余** — 拆分前合并重复验证相同行为 ≥3 次的测试

### 命名规范

- 拆分文件:`inbound-handler-quote.test.ts`、`send-service-media.test.ts`
- Fixture 文件:`tests/unit/fixtures/inbound-handler-fixture.ts`

## 手工测试建议

如果你的改动影响运行时行为,请在 PR 描述里附上一段简短的手工测试说明。
Expand Down
182 changes: 182 additions & 0 deletions docs/artifacts/2026-04-04-v2-card-fix-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# V2 Card Template 修复实施记录

**日期:** 2026-04-04
**分支:** card-template-v2-clean
**PR:** #480

## 问题背景

Codex review 发现 V2 卡片模板实现存在多个问题,核心是 finalize 链路仍使用 streaming API 而非 instances API。

## 修复清单

| # | 问题 | 优先级 | Commit |
|---|------|--------|--------|
| 1 | `getRenderedContent()` 返回 JSON 而非 markdown | P1 | `2e10243` |
| 2 | `finishAICard()` 使用 streaming API finalize | P0 | `6dc9419` |
| 3 | 远程 media URL 直接传给 `uploadMedia()` | P2 | `d9f8f80` |
| 4 | 非图片附件硬编码为 `image` 类型 | P2 | `d9f8f80` |
| 5 | Quote header 用错内容来源 | P3 | `fab3738` |

## 实现路径

### P1: getRenderedContent 方法拆分

**问题**:`getRenderedContent()` 返回 `CardBlock[]` JSON,但调用方期望 markdown 文本。

**解决方案**:拆分为两个方法

```typescript
// card-draft-controller.ts 接口
getRenderedBlocks: () => string // CardBlock[] JSON (供 instances API)
getRenderedContent: () => string // 纯 markdown (供复制/fallback)
```

**实现**:两个方法共享 `renderTimelineAsBlocks()` 逻辑

```typescript
getRenderedBlocks: (options?) => {
const blocks = renderTimelineAsBlocks(options);
return blocks.length > 0 ? JSON.stringify(blocks) : "";
},
getRenderedContent: (options?) => {
const blocks = renderTimelineAsBlocks(options);
return blocks
.filter((b) => b.type === 0 && "markdown" in block && block.markdown)
.map((block) => block.markdown)
.join("\n\n");
},
```

### P0: commitAICardBlocks 单次 instances API 固化

**问题**:`finishAICard()` 调用 `streamAICard()` 使用 streaming API,但 V2 template 实验表明 finalize 必须用 instances API。

**解决方案**:重构 `commitAICardBlocks()` 为 V2 finalize 入口

```typescript
export interface FinalizeCardOptions {
blockListJson: string; // CardBlock[] JSON
content: string; // 纯文本 (供复制)
quoteContent?: string; // 引用内容
quotedRef?: QuotedRef; // 用于缓存
}

export async function commitAICardBlocks(
card: AICardInstance,
options: FinalizeCardOptions,
log?: Logger,
): Promise<void> {
const updates: Record<string, string | number> = {
blockList: options.blockListJson,
content: options.content, // 同样通过 instances 写入
flowStatus: 3, // 完成态
};

// 单次 instances API 调用
await updateCardVariables(card, updates, log);

// 缓存 + 状态更新
cacheCardContentByProcessQueryKey(...);
card.state = AICardStatus.FINISHED;
removePendingCard(card, log);
}
```

**关键发现**:
- V2 template 的 `flowStatus=3` 时,Stop Button 自动隐藏,无需单独调用 `hideCardStopButton()`
- `content` key 可以通过 instances API 写入,用于复制按钮取值

### P2: 远程 Media URL 处理

**问题**:`uploadMedia()` 只支持本地文件路径,远程 URL 导致 ENOENT。

**解决方案**:复用 `prepareMediaInput()` 处理远程 URL

```typescript
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";

for (const url of payload.mediaUrls || []) {
const prepared = await prepareMediaInput(url, log);
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });

if (mediaType === "image") {
// 图片 → 嵌入卡片
const result = await uploadMedia(config, prepared.path, "image", log);
await controller.appendImageBlock(result.mediaId);
} else {
// 非图片 → 降级为卡片外发送 (V2 卡片只支持 type=3 图片块)
log?.debug?.(`[DingTalk][Card] Skipping non-image media in card: ${url}`);
}

// 清理临时文件
await prepared.cleanup?.();
}
```

### P3: Quote 内容来源修复

**问题**:`quoteContent` 使用 `extractedContent.text`(当前消息),而非被引用消息。

**解决方案**:从正确的来源获取

```typescript
// inbound-handler.ts
const quotePreview = extractedContent.quoted?.previewText
|| data.content?.quoteContent
|| "";

quoteContent: quotedRef ? quotePreview : "",
```

## API 路由总结

| 阶段 | API | 变量 | 用途 |
|------|-----|------|------|
| 创建 | instances (createAndDeliver) | content, stop_action, hasQuote | 初始化卡片 |
| 流式预览 | streaming | content | 实时预览文本 |
| 流式块更新 | instances | blockList | 结构化内容 |
| Finalize | instances | blockList, content, flowStatus | 一次性固化 |

## 经验总结

### 1. API 选择原则

- **streaming API**:仅用于简单 string 类型变量(如 `content` 的实时预览)
- **instances API**:用于复杂类型(`blockList` loopArray)和 finalize 固化

### 2. V2 Template 特性

- `flowStatus=3` 自动隐藏 Stop Button
- `content` key 可通过 instances API 写入,作为复制按钮取值来源
- `blockList` 是 JSON string,需要 `JSON.stringify(CardBlock[])`

### 3. 方法命名规范

- `getRenderedBlocks()` - 返回结构化数据 JSON
- `getRenderedContent()` - 返回用户可见文本
- 语义清晰,避免调用方混淆

### 4. 测试策略

- Mock `prepareMediaInput` 和 `resolveOutboundMediaType` 以隔离 media 处理逻辑
- 测试文件使用 `.png`/`.jpg` 而非 `.pdf` 以触发 image 路径
- 默认 mock 返回 `"file"` 类型,image 测试显式 override

## 文件变更

| 文件 | 变更类型 |
|------|----------|
| `src/card-draft-controller.ts` | 接口拆分 |
| `src/card-service.ts` | 重构 commitAICardBlocks |
| `src/reply-strategy-card.ts` | finalize 切换 + media 处理 |
| `src/inbound-handler.ts` | quote 来源修复 |
| `tests/unit/card-draft-controller.test.ts` | 新增测试 |
| `tests/unit/reply-strategy-card.test.ts` | media mock 更新 |
| `tests/unit/inbound-handler.test.ts` | quote 测试更新 |

## 验证结果

- 831 tests passed
- type-check passed
- lint passed (0 errors, 89 warnings for `no-explicit-any`)
43 changes: 18 additions & 25 deletions docs/assets/card-data-mock-v2.json
Original file line number Diff line number Diff line change
@@ -1,44 +1,34 @@
{
"hasAction": true,
"content": "# 流式富文本内容",
"hasQuote": true,
"quoteContent": "这是一条测试引用文本",
"blockList": [
{
"text": "Reason: 思考消息,注意单段无法换行的思考消息。思考消息,注意单段无法换行的思考消息",
"markdown": "## markdown 内容 1",
"isTool": true,
"type": 1,
"mediaId": "",
"btns": []
"markdown": "# markdown 内容",
"mediaId": ""
},
{
"text": "测试主题",
"markdown": "# markdown 内容",
"type": 5,
"mediaId": "",
"btns": []
"markdown": "# markdown 内容",
"mediaId": ""
},
{
"text": "Reason: 思考消息,注意单段无法换行的思考消息。思考消息,注意单段无法换行的思考消息",
"markdown": "## 富文本二级标题\n富文本正文",
"type": 0,
"mediaId": "",
"btns": []
"text": "",
"mediaId": ""
},
{
"text": "Exec: 思考消息,注意单段无法换行的思考消息。思考消息,注意单段无法换行的思考消息",
"markdown": "## markdown 内容 2",
"isTool": false,
"text": " Exec: 思考消息,注意单段无法换行的思考消息。思考消息,注意单段无法换行的思考消息",
"type": 2,
"mediaId": "",
"btns": []
"markdown": "# markdown 内容",
"mediaId": ""
},
{
"text": "",
"markdown": "# markdown 内容",
"type": 4,
"mediaId": "@lALPDfmVRLVVAm_NBdzNBdw",
"btns": [
{
"text": "允许",
Expand Down Expand Up @@ -82,21 +72,24 @@
}
}
}
]
},
{
],
"text": "",
"markdown": "# markdown 内容",
"mediaId": ""
},
{
"type": 3,
"mediaId": "@lALPDfmVRLVVAm_NBdzNBdw",
"btns": []
"text": "",
"markdown": "# markdown 内容"
}
],
"taskInfo": {
"model": "gpt-5.4",
"effort": "medium",
"dapi_usage": 12,
"taskTime": 24
"taskTime": 24,
"agent": "main"
},
"version": 1
}
}
1 change: 1 addition & 0 deletions docs/assets/card-template-v2.json

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions docs/contributor/architecture.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,31 @@ When reviewing or opening a PR, ask:
4. Does the change introduce a new generic helper file that is really hiding missing domain boundaries?
5. Could the same behavior be implemented with a small new module instead of widening an unrelated one?

## Test File Maintenance

Test files should follow the same domain boundaries as source code.

### Scale Thresholds

| Lines | Action |
|-------|--------|
| <500 | Acceptable, no action needed |
| 500-800 | Plan split for future work |
| >800 | Split required before merge |

### Split Strategy

1. **Identify feature domains** — Group tests by the feature they validate
2. **Extract shared mocks** — Create fixture module in `tests/unit/fixtures/`
3. **Split by domain** — Create `source-module-{domain}.test.ts` files with 10-25 tests each
4. **Retain core flows** — Keep end-to-end pipeline tests in the main file
5. **Clean redundancy** — Merge tests that validate identical behavior ≥3 times

### Naming Convention

- Split files: `inbound-handler-quote.test.ts`, `send-service-media.test.ts`
- Fixture files: `tests/unit/fixtures/inbound-handler-fixture.ts`

## Related Entry Points

- `README.md` for project overview and developer entry points
Expand Down
Loading
Loading