Merge pull request #260 from nrslib/release/v0.13.0-alpha.1
Release v0.13.0-alpha.1
This commit is contained in:
commit
76fed1f902
24
.github/workflows/auto-tag.yml
vendored
24
.github/workflows/auto-tag.yml
vendored
@ -86,13 +86,21 @@ jobs:
|
||||
- name: Verify dist-tags
|
||||
run: |
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
LATEST=$(npm view "${PACKAGE_NAME}" dist-tags.latest)
|
||||
NEXT=$(npm view "${PACKAGE_NAME}" dist-tags.next || true)
|
||||
|
||||
echo "latest=${LATEST}"
|
||||
echo "next=${NEXT}"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
LATEST=$(npm view "${PACKAGE_NAME}" dist-tags.latest)
|
||||
NEXT=$(npm view "${PACKAGE_NAME}" dist-tags.next || true)
|
||||
|
||||
if [ "${{ steps.npm-tag.outputs.tag }}" = "latest" ] && [ "${LATEST}" != "${NEXT}" ]; then
|
||||
echo "Expected next to match latest on stable release, but they differ."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt ${attempt}: latest=${LATEST}, next=${NEXT}"
|
||||
|
||||
if [ "${{ steps.npm-tag.outputs.tag }}" != "latest" ] || [ "${LATEST}" = "${NEXT}" ]; then
|
||||
echo "Dist-tags verified."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq 5 ]; then
|
||||
echo "::warning::dist-tags not synced after 5 attempts (latest=${LATEST}, next=${NEXT}). Registry propagation may be delayed."
|
||||
exit 0
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.13.0-alpha.1] - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Team Leader ムーブメント**: ムーブメント内でチームリーダーエージェントがタスクを動的にサブタスク(Part)へ分解し、複数のパートエージェントを並列実行する新しいムーブメントタイプ — `team_leader` 設定(persona, maxParts, timeoutMs, partPersona, partEdit, partPermissionMode)をサポート (#244)
|
||||
- **構造化出力(Structured Output)**: エージェント呼び出しに JSON Schema ベースの構造化出力を導入 — タスク分解(decomposition)、ルール評価(evaluation)、ステータス判定(judgment)の3つのスキーマを `builtins/schemas/` に追加。Claude / Codex 両プロバイダーで対応 (#257)
|
||||
- **`backend` ビルトインピース**: バックエンド開発特化のピースを新規追加 — バックエンド、セキュリティ、QA の並列専門家レビュー対応
|
||||
- **`backend-cqrs` ビルトインピース**: CQRS+ES 特化のバックエンド開発ピースを新規追加 — CQRS+ES、セキュリティ、QA の並列専門家レビュー対応
|
||||
- **AbortSignal によるパートタイムアウト**: Team Leader のパート実行にタイムアウト制御と親シグナル連動の AbortSignal を追加
|
||||
- **エージェントユースケース層**: `agent-usecases.ts` にエージェント呼び出しのユースケース(`decomposeTask`, `executeAgent`, `evaluateRules`)を集約し、構造化出力の注入を一元管理
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING: パブリック API の整理**: `src/index.ts` の公開 API を大幅に絞り込み — 内部実装の詳細(セッション管理、Claude/Codex クライアント詳細、ユーティリティ関数等)を非公開化し、安定した最小限の API サーフェスに (#257)
|
||||
- **Phase 3 判定ロジックの刷新**: `JudgmentDetector` / `FallbackStrategy` を廃止し、構造化出力ベースの `status-judgment-phase.ts` に統合。判定の安定性と保守性を向上 (#257)
|
||||
- **Report フェーズのリトライ改善**: Report Phase(Phase 2)が失敗した場合、新規セッションで自動リトライするよう改善 (#245)
|
||||
- **Ctrl+C シャットダウンの統一**: `sigintHandler.ts` を廃止し、`ShutdownManager` に統合 — グレースフルシャットダウン → タイムアウト → 強制終了の3段階制御を全プロバイダーで共通化 (#237)
|
||||
- フロントエンドナレッジにデザイントークンとテーマスコープのガイダンスを追加
|
||||
- アーキテクチャナレッジの改善(en/ja 両対応)
|
||||
|
||||
### Fixed
|
||||
|
||||
- clone 時に既存ブランチの checkout が失敗する問題を修正 — `git clone --shared` で `--branch` を渡してからリモートを削除するよう変更
|
||||
- Issue 参照付きブランチ名から `#` を除去(`takt/#N/slug` → `takt/N/slug`)
|
||||
- OpenCode の report フェーズで deprecated ツール依存を解消し、permission 中心の制御へ移行 (#246)
|
||||
- 不要な export を排除し、パブリック API の整合性を確保
|
||||
|
||||
### Internal
|
||||
|
||||
- Team Leader 関連のテスト追加(engine-team-leader, team-leader-schema-loader, task-decomposer)
|
||||
- 構造化出力関連のテスト追加(parseStructuredOutput, claude-executor-structured-output, codex-structured-output, provider-structured-output, structured-output E2E)
|
||||
- ShutdownManager のユニットテスト追加
|
||||
- AbortSignal のユニットテスト追加(abort-signal, claude-executor-abort-signal, claude-provider-abort-signal)
|
||||
- Report Phase リトライのユニットテスト追加(report-phase-retry)
|
||||
- パブリック API エクスポートのユニットテスト追加(public-api-exports)
|
||||
- E2E テストの大幅拡充: cycle-detection, model-override, multi-step-sequential, pipeline-local-repo, report-file-output, run-sigint-graceful, session-log, structured-output, task-status-persistence
|
||||
- E2E テストヘルパーのリファクタリング(共通 setup 関数の抽出)
|
||||
- `judgment/` ディレクトリ(JudgmentDetector, FallbackStrategy)を削除
|
||||
- `ruleIndex.ts` ユーティリティを追加(1-based → 0-based インデックス変換)
|
||||
|
||||
## [0.12.1] - 2026-02-11
|
||||
|
||||
### Fixed
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@ -371,19 +371,30 @@ Files: `.takt/logs/{sessionId}.jsonl`, with `latest.json` pointer. Legacy `.json
|
||||
|
||||
**Instruction auto-injection over explicit placeholders.** The instruction builder auto-injects `{task}`, `{previous_response}`, `{user_inputs}`, and status rules. Templates should contain only step-specific instructions, not boilerplate.
|
||||
|
||||
**Persona prompts contain only domain knowledge.** Persona prompt files (`builtins/{lang}/personas/*.md`) must contain only domain expertise and behavioral principles — never piece-specific procedures. Piece-specific details (which reports to read, step routing, specific templates with hardcoded step names) belong in the piece YAML's `instruction_template`. This keeps personas reusable across different pieces.
|
||||
**Faceted prompting: each facet has a dedicated file type.** TAKT assembles agent prompts from 4 facets. Each facet has a distinct role. When adding new rules or knowledge, place content in the correct facet.
|
||||
|
||||
What belongs in persona prompts:
|
||||
- Role definition ("You are a ... specialist")
|
||||
- Domain expertise, review criteria, judgment standards
|
||||
- Do / Don't behavioral rules
|
||||
- Tool usage knowledge (general, not piece-specific)
|
||||
```
|
||||
builtins/{lang}/
|
||||
personas/ — WHO: identity, expertise, behavioral habits
|
||||
policies/ — HOW: judgment criteria, REJECT/APPROVE rules, prohibited patterns
|
||||
knowledge/ — WHAT TO KNOW: domain patterns, anti-patterns, detailed reasoning with examples
|
||||
instructions/ — WHAT TO DO NOW: step-specific procedures and checklists
|
||||
```
|
||||
|
||||
What belongs in piece `instruction_template`:
|
||||
- Step-specific procedures ("Read these specific reports")
|
||||
- References to other steps or their outputs
|
||||
- Specific report file names or formats
|
||||
- Comment/output templates with hardcoded review type names
|
||||
| Deciding where to place content | Facet | Example |
|
||||
|--------------------------------|-------|---------|
|
||||
| Role definition, AI habit prevention | Persona | "置き換えたコードを残す → 禁止" |
|
||||
| Actionable REJECT/APPROVE criterion | Policy | "内部実装のパブリックAPIエクスポート → REJECT" |
|
||||
| Detailed reasoning, REJECT/OK table with examples | Knowledge | "パブリックAPIの公開範囲" section |
|
||||
| This-step-only procedure or checklist | Instruction | "レビュー観点: 構造・設計の妥当性..." |
|
||||
| Workflow structure, facet assignment | Piece YAML | `persona: coder`, `policy: coding`, `knowledge: architecture` |
|
||||
|
||||
Key rules:
|
||||
- Persona files are reusable across pieces. Never include piece-specific procedures (report names, step references)
|
||||
- Policy REJECT lists are what reviewers enforce. If a criterion is not in the policy REJECT list, reviewers will not catch it — even if knowledge explains the reasoning
|
||||
- Knowledge provides the WHY behind policy criteria. Knowledge alone does not trigger enforcement
|
||||
- Instructions are bound to a single piece step. They reference procedures, not principles
|
||||
- Piece YAML `instruction_template` is for step-specific details (which reports to read, step routing, output templates)
|
||||
|
||||
**Separation of concerns in piece engine:**
|
||||
- `PieceEngine` - Orchestration, state management, event emission
|
||||
|
||||
@ -474,6 +474,8 @@ TAKT includes multiple builtin pieces:
|
||||
| `unit-test` | Unit test focused piece: test analysis → test implementation → review → fix. |
|
||||
| `e2e-test` | E2E test focused piece: E2E analysis → E2E implementation → review → fix (Vitest-based E2E flow). |
|
||||
| `frontend` | Frontend-specialized development piece with React/Next.js focused reviews and knowledge injection. |
|
||||
| `backend` | Backend-specialized development piece with backend, security, and QA expert reviews. |
|
||||
| `backend-cqrs` | CQRS+ES-specialized backend development piece with CQRS+ES, security, and QA expert reviews. |
|
||||
|
||||
**Per-persona provider overrides:** Use `persona_providers` in config to route specific personas to different providers (e.g., coder on Codex, reviewers on Claude) without duplicating pieces.
|
||||
|
||||
|
||||
@ -18,6 +18,26 @@
|
||||
- No circular dependencies
|
||||
- Appropriate directory hierarchy
|
||||
|
||||
**Operation Discoverability:**
|
||||
|
||||
When calls to the same generic function are scattered across the codebase with different purposes, it becomes impossible to understand what the system does without grepping every call site. Group related operations into purpose-named functions within a single module. Reading that module should reveal the complete list of operations the system performs.
|
||||
|
||||
| Judgment | Criteria |
|
||||
|----------|----------|
|
||||
| REJECT | Same generic function called directly from 3+ places with different purposes |
|
||||
| REJECT | Understanding all system operations requires grepping every call site |
|
||||
| OK | Purpose-named functions defined and collected in a single module |
|
||||
|
||||
**Public API Surface:**
|
||||
|
||||
Public APIs should expose only domain-level functions and types. Do not export infrastructure internals (provider-specific functions, internal parsers, etc.).
|
||||
|
||||
| Judgment | Criteria |
|
||||
|----------|----------|
|
||||
| REJECT | Infrastructure-layer functions exported from public API |
|
||||
| REJECT | Internal implementation functions callable from outside |
|
||||
| OK | External consumers interact only through domain-level abstractions |
|
||||
|
||||
**Function Design:**
|
||||
|
||||
- One responsibility per function
|
||||
@ -299,19 +319,18 @@ Correct handling:
|
||||
|
||||
## DRY Violation Detection
|
||||
|
||||
Detect duplicate code.
|
||||
Eliminate duplication by default. When logic is essentially the same and should be unified, apply DRY. Do not judge mechanically by count.
|
||||
|
||||
| Pattern | Judgment |
|
||||
|---------|----------|
|
||||
| Same logic in 3+ places | Immediate REJECT - Extract to function/method |
|
||||
| Same validation in 2+ places | Immediate REJECT - Extract to validator function |
|
||||
| Similar components 3+ | Immediate REJECT - Create shared component |
|
||||
| Copy-paste derived code | Immediate REJECT - Parameterize or abstract |
|
||||
| Essentially identical logic duplicated | REJECT - Extract to function/method |
|
||||
| Same validation duplicated | REJECT - Extract to validator function |
|
||||
| Essentially identical component structure | REJECT - Create shared component |
|
||||
| Copy-paste derived code | REJECT - Parameterize or abstract |
|
||||
|
||||
AHA principle (Avoid Hasty Abstractions) balance:
|
||||
- 2 duplications → Wait and see
|
||||
- 3 duplications → Extract immediately
|
||||
- Different domain duplications → Don't abstract (e.g., customer validation vs admin validation are different)
|
||||
When NOT to apply DRY:
|
||||
- Different domains: Don't abstract (e.g., customer validation vs admin validation are different things)
|
||||
- Superficially similar but different reasons to change: Treat as separate code
|
||||
|
||||
## Spec Compliance Verification
|
||||
|
||||
|
||||
@ -224,6 +224,54 @@ Signs to make separate components:
|
||||
- Added variant is clearly different from original component's purpose
|
||||
- Props specification becomes complex on the usage side
|
||||
|
||||
### Theme Differences and Design Tokens
|
||||
|
||||
When you need different visuals with the same functional components, manage it with design tokens + theme scope.
|
||||
|
||||
Principles:
|
||||
- Define color, spacing, radius, shadow, and typography as tokens (CSS variables)
|
||||
- Apply role/page-specific differences by overriding tokens in a theme scope (e.g. `.consumer-theme`, `.admin-theme`)
|
||||
- Do not hardcode hex colors (`#xxxxxx`) in feature components
|
||||
- Keep logic differences (API/state) separate from visual differences (tokens)
|
||||
|
||||
```css
|
||||
/* tokens.css */
|
||||
:root {
|
||||
--color-bg-page: #f3f4f6;
|
||||
--color-surface: #ffffff;
|
||||
--color-text-primary: #1f2937;
|
||||
--color-border: #d1d5db;
|
||||
--color-accent: #2563eb;
|
||||
}
|
||||
|
||||
.consumer-theme {
|
||||
--color-bg-page: #f7f8fa;
|
||||
--color-accent: #4daca1;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// same component, different look by scope
|
||||
<div className="consumer-theme">
|
||||
<Button variant="primary">Submit</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Operational rules:
|
||||
- Implement shared UI primitives (Button/Card/Input/Tabs) using tokens only
|
||||
- In feature views, use theme-common utility classes (e.g. `surface`, `title`, `chip`) to avoid duplicated styling logic
|
||||
- For a new theme, follow: "add tokens -> override by scope -> reuse existing components"
|
||||
|
||||
Review checklist:
|
||||
- No copy-pasted hardcoded colors/spacings
|
||||
- No duplicated components per theme for the same UI behavior
|
||||
- No API/state-management changes made solely for visual adjustments
|
||||
|
||||
Anti-patterns:
|
||||
- Creating `ButtonConsumer`, `ButtonAdmin` for styling only
|
||||
- Hardcoding colors in each feature component
|
||||
- Changing response shaping logic when only the theme changed
|
||||
|
||||
## Abstraction Level Evaluation
|
||||
|
||||
**Conditional branch bloat detection:**
|
||||
|
||||
@ -33,4 +33,5 @@ You are the implementer. Focus on implementation, not design decisions.
|
||||
- Making design decisions arbitrarily → Report and ask for guidance
|
||||
- Dismissing reviewer feedback → Prohibited
|
||||
- Adding backward compatibility or legacy support without being asked → Absolutely prohibited
|
||||
- Leaving replaced code/exports after refactoring → Prohibited (remove unless explicitly told to keep)
|
||||
- Layering workarounds that bypass safety mechanisms on top of a root cause fix → Prohibited
|
||||
|
||||
@ -9,7 +9,10 @@ piece_categories:
|
||||
🎨 Frontend:
|
||||
pieces:
|
||||
- frontend
|
||||
⚙️ Backend: {}
|
||||
⚙️ Backend:
|
||||
pieces:
|
||||
- backend
|
||||
- backend-cqrs
|
||||
🔧 Expert:
|
||||
Full Stack:
|
||||
pieces:
|
||||
|
||||
267
builtins/en/pieces/backend-cqrs.yaml
Normal file
267
builtins/en/pieces/backend-cqrs.yaml
Normal file
@ -0,0 +1,267 @@
|
||||
name: backend-cqrs
|
||||
description: CQRS+ES, Security, QA Expert Review
|
||||
max_movements: 30
|
||||
initial_movement: plan
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
persona: planner
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: plan
|
||||
rules:
|
||||
- condition: Task analysis and planning is complete
|
||||
next: implement
|
||||
- condition: Requirements are unclear and planning cannot proceed
|
||||
next: ABORT
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 00-plan.md
|
||||
format: plan
|
||||
- name: implement
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: implement
|
||||
rules:
|
||||
- condition: Implementation is complete
|
||||
next: ai_review
|
||||
- condition: No implementation (report only)
|
||||
next: ai_review
|
||||
- condition: Cannot proceed with implementation
|
||||
next: ai_review
|
||||
- condition: User input required
|
||||
next: implement
|
||||
requires_user_input: true
|
||||
interactive_only: true
|
||||
output_contracts:
|
||||
report:
|
||||
- Scope: 01-coder-scope.md
|
||||
- Decisions: 02-coder-decisions.md
|
||||
- name: ai_review
|
||||
edit: false
|
||||
persona: ai-antipattern-reviewer
|
||||
policy:
|
||||
- review
|
||||
- ai-antipattern
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-review
|
||||
rules:
|
||||
- condition: No AI-specific issues found
|
||||
next: reviewers
|
||||
- condition: AI-specific issues detected
|
||||
next: ai_fix
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 03-ai-review.md
|
||||
format: ai-review
|
||||
- name: ai_fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-fix
|
||||
rules:
|
||||
- condition: AI Reviewer's issues have been fixed
|
||||
next: ai_review
|
||||
- condition: No fix needed (verified target files/spec)
|
||||
next: ai_no_fix
|
||||
- condition: Unable to proceed with fixes
|
||||
next: ai_no_fix
|
||||
- name: ai_no_fix
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
rules:
|
||||
- condition: ai_review's findings are valid (fix required)
|
||||
next: ai_fix
|
||||
- condition: ai_fix's judgment is valid (no fix needed)
|
||||
next: reviewers
|
||||
instruction: arbitrate
|
||||
- name: reviewers
|
||||
parallel:
|
||||
- name: cqrs-es-review
|
||||
edit: false
|
||||
persona: cqrs-es-reviewer
|
||||
policy: review
|
||||
knowledge:
|
||||
- cqrs-es
|
||||
- backend
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-cqrs-es
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 04-cqrs-es-review.md
|
||||
format: cqrs-es-review
|
||||
- name: security-review
|
||||
edit: false
|
||||
persona: security-reviewer
|
||||
policy: review
|
||||
knowledge: security
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-security
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 05-security-review.md
|
||||
format: security-review
|
||||
- name: qa-review
|
||||
edit: false
|
||||
persona: qa-reviewer
|
||||
policy:
|
||||
- review
|
||||
- qa
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-qa
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 06-qa-review.md
|
||||
format: qa-review
|
||||
rules:
|
||||
- condition: all("approved")
|
||||
next: supervise
|
||||
- condition: any("needs_fix")
|
||||
next: fix
|
||||
- name: fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
permission_mode: edit
|
||||
rules:
|
||||
- condition: Fix complete
|
||||
next: reviewers
|
||||
- condition: Cannot proceed, insufficient info
|
||||
next: plan
|
||||
instruction: fix
|
||||
- name: supervise
|
||||
edit: false
|
||||
persona: expert-supervisor
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: supervise
|
||||
rules:
|
||||
- condition: All validations pass and ready to merge
|
||||
next: COMPLETE
|
||||
- condition: Issues detected during final review
|
||||
next: fix_supervisor
|
||||
output_contracts:
|
||||
report:
|
||||
- Validation: 07-supervisor-validation.md
|
||||
- Summary: summary.md
|
||||
- name: fix_supervisor
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: fix-supervisor
|
||||
rules:
|
||||
- condition: Supervisor's issues have been fixed
|
||||
next: supervise
|
||||
- condition: Unable to proceed with fixes
|
||||
next: plan
|
||||
263
builtins/en/pieces/backend.yaml
Normal file
263
builtins/en/pieces/backend.yaml
Normal file
@ -0,0 +1,263 @@
|
||||
name: backend
|
||||
description: Backend, Security, QA Expert Review
|
||||
max_movements: 30
|
||||
initial_movement: plan
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
persona: planner
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: plan
|
||||
rules:
|
||||
- condition: Task analysis and planning is complete
|
||||
next: implement
|
||||
- condition: Requirements are unclear and planning cannot proceed
|
||||
next: ABORT
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 00-plan.md
|
||||
format: plan
|
||||
- name: implement
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: implement
|
||||
rules:
|
||||
- condition: Implementation is complete
|
||||
next: ai_review
|
||||
- condition: No implementation (report only)
|
||||
next: ai_review
|
||||
- condition: Cannot proceed with implementation
|
||||
next: ai_review
|
||||
- condition: User input required
|
||||
next: implement
|
||||
requires_user_input: true
|
||||
interactive_only: true
|
||||
output_contracts:
|
||||
report:
|
||||
- Scope: 01-coder-scope.md
|
||||
- Decisions: 02-coder-decisions.md
|
||||
- name: ai_review
|
||||
edit: false
|
||||
persona: ai-antipattern-reviewer
|
||||
policy:
|
||||
- review
|
||||
- ai-antipattern
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-review
|
||||
rules:
|
||||
- condition: No AI-specific issues found
|
||||
next: reviewers
|
||||
- condition: AI-specific issues detected
|
||||
next: ai_fix
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 03-ai-review.md
|
||||
format: ai-review
|
||||
- name: ai_fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-fix
|
||||
rules:
|
||||
- condition: AI Reviewer's issues have been fixed
|
||||
next: ai_review
|
||||
- condition: No fix needed (verified target files/spec)
|
||||
next: ai_no_fix
|
||||
- condition: Unable to proceed with fixes
|
||||
next: ai_no_fix
|
||||
- name: ai_no_fix
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
rules:
|
||||
- condition: ai_review's findings are valid (fix required)
|
||||
next: ai_fix
|
||||
- condition: ai_fix's judgment is valid (no fix needed)
|
||||
next: reviewers
|
||||
instruction: arbitrate
|
||||
- name: reviewers
|
||||
parallel:
|
||||
- name: arch-review
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
knowledge:
|
||||
- architecture
|
||||
- backend
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-arch
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 04-architect-review.md
|
||||
format: architecture-review
|
||||
- name: security-review
|
||||
edit: false
|
||||
persona: security-reviewer
|
||||
policy: review
|
||||
knowledge: security
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-security
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 05-security-review.md
|
||||
format: security-review
|
||||
- name: qa-review
|
||||
edit: false
|
||||
persona: qa-reviewer
|
||||
policy:
|
||||
- review
|
||||
- qa
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-qa
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 06-qa-review.md
|
||||
format: qa-review
|
||||
rules:
|
||||
- condition: all("approved")
|
||||
next: supervise
|
||||
- condition: any("needs_fix")
|
||||
next: fix
|
||||
- name: fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
permission_mode: edit
|
||||
rules:
|
||||
- condition: Fix complete
|
||||
next: reviewers
|
||||
- condition: Cannot proceed, insufficient info
|
||||
next: plan
|
||||
instruction: fix
|
||||
- name: supervise
|
||||
edit: false
|
||||
persona: expert-supervisor
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: supervise
|
||||
rules:
|
||||
- condition: All validations pass and ready to merge
|
||||
next: COMPLETE
|
||||
- condition: Issues detected during final review
|
||||
next: fix_supervisor
|
||||
output_contracts:
|
||||
report:
|
||||
- Validation: 07-supervisor-validation.md
|
||||
- Summary: summary.md
|
||||
- name: fix_supervisor
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: fix-supervisor
|
||||
rules:
|
||||
- condition: Supervisor's issues have been fixed
|
||||
next: supervise
|
||||
- condition: Unable to proceed with fixes
|
||||
next: plan
|
||||
@ -7,7 +7,7 @@ Prioritize correctness over speed, and code accuracy over ease of implementation
|
||||
| Principle | Criteria |
|
||||
|-----------|----------|
|
||||
| Simple > Easy | Prioritize readability over writability |
|
||||
| DRY | Extract after 3 repetitions |
|
||||
| DRY | Eliminate essential duplication |
|
||||
| Comments | Why only. Never write What/How |
|
||||
| Function size | One function, one responsibility. ~30 lines |
|
||||
| File size | ~300 lines as a guideline. Be flexible depending on the task |
|
||||
@ -245,23 +245,19 @@ Request → toInput() → UseCase/Service → Output → Response.from()
|
||||
|
||||
## Shared Code Decisions
|
||||
|
||||
### Rule of Three
|
||||
|
||||
- 1st occurrence: Write it inline
|
||||
- 2nd occurrence: Do not extract yet (observe)
|
||||
- 3rd occurrence: Consider extracting
|
||||
Eliminate duplication by default. When logic is essentially the same and should be unified, apply DRY. Do not decide mechanically by count.
|
||||
|
||||
### Should Be Shared
|
||||
|
||||
- Same logic in 3+ places
|
||||
- Essentially identical logic duplicated
|
||||
- Same style/UI pattern
|
||||
- Same validation logic
|
||||
- Same formatting logic
|
||||
|
||||
### Should Not Be Shared
|
||||
|
||||
- Similar but subtly different (forced generalization adds complexity)
|
||||
- Used in only 1-2 places
|
||||
- Duplication across different domains (e.g., customer validation and admin validation are separate concerns)
|
||||
- Superficially similar code with different reasons to change
|
||||
- Based on "might need it in the future" predictions
|
||||
|
||||
```typescript
|
||||
@ -289,4 +285,6 @@ function formatPercentage(value: number): string { ... }
|
||||
- **Hardcoded secrets**
|
||||
- **Scattered try-catch** - Centralize error handling at the upper layer
|
||||
- **Unsolicited backward compatibility / legacy support** - Not needed unless explicitly instructed
|
||||
- **Internal implementation exported from public API** - Only export domain-level functions and types. Do not export infrastructure functions or internal classes
|
||||
- **Replaced code surviving after refactoring** - Remove replaced code and exports. Do not keep unless explicitly told to
|
||||
- **Workarounds that bypass safety mechanisms** - If the root fix is correct, no additional bypass is needed
|
||||
|
||||
@ -38,9 +38,11 @@ REJECT without exception if any of the following apply.
|
||||
- Direct mutation of objects/arrays
|
||||
- Swallowed errors (empty catch blocks)
|
||||
- TODO comments (not tracked in an issue)
|
||||
- Duplicated code in 3+ places (DRY violation)
|
||||
- Essentially identical logic duplicated (DRY violation)
|
||||
- Method proliferation doing the same thing (should be absorbed by configuration differences)
|
||||
- Specific implementation leaking into generic layers (imports and branching for specific implementations in generic layers)
|
||||
- Internal implementation exported from public API (infrastructure functions or internal classes exposed publicly)
|
||||
- Replaced code/exports surviving after refactoring
|
||||
- Missing cross-validation of related fields (invariants of semantically coupled config values left unverified)
|
||||
|
||||
### Warning
|
||||
|
||||
@ -18,6 +18,26 @@
|
||||
- 循環依存がないか
|
||||
- 適切なディレクトリ階層か
|
||||
|
||||
**操作の一覧性**
|
||||
|
||||
同じ汎用関数への呼び出しがコードベースに散在すると、システムが何をしているか把握できなくなる。操作には目的に応じた名前を付けて関数化し、関連する操作を1つのモジュールにまとめる。そのモジュールを読めば「このシステムが行う操作の全体像」がわかる状態にする。
|
||||
|
||||
| 判定 | 基準 |
|
||||
|------|------|
|
||||
| REJECT | 同じ汎用関数が目的の異なる3箇所以上から直接呼ばれている |
|
||||
| REJECT | 呼び出し元を全件 grep しないとシステムの操作一覧がわからない |
|
||||
| OK | 目的ごとに名前付き関数が定義され、1モジュールに集約されている |
|
||||
|
||||
**パブリック API の公開範囲**
|
||||
|
||||
パブリック API が公開するのは、ドメインの操作に対応する関数・型のみ。インフラの実装詳細(特定プロバイダーの関数、内部パーサー等)を公開しない。
|
||||
|
||||
| 判定 | 基準 |
|
||||
|------|------|
|
||||
| REJECT | インフラ層の関数がパブリック API からエクスポートされている |
|
||||
| REJECT | 内部実装の関数が外部から直接呼び出し可能になっている |
|
||||
| OK | 外部消費者がドメインレベルの抽象のみを通じて対話する |
|
||||
|
||||
**関数設計**
|
||||
|
||||
- 1関数1責務になっているか
|
||||
@ -299,19 +319,18 @@ TODOが許容される唯一のケース:
|
||||
|
||||
## DRY違反の検出
|
||||
|
||||
重複コードを検出する。
|
||||
基本的に重複は排除する。本質的に同じロジックであり、まとめるべきと判断したら DRY にする。回数で機械的に判断しない。
|
||||
|
||||
| パターン | 判定 |
|
||||
|---------|------|
|
||||
| 同じロジックが3箇所以上 | 即REJECT - 関数/メソッドに抽出 |
|
||||
| 同じバリデーションが2箇所以上 | 即REJECT - バリデーター関数に抽出 |
|
||||
| 似たようなコンポーネントが3個以上 | 即REJECT - 共通コンポーネント化 |
|
||||
| コピペで派生したコード | 即REJECT - パラメータ化または抽象化 |
|
||||
| 本質的に同じロジックの重複 | REJECT - 関数/メソッドに抽出 |
|
||||
| 同じバリデーションの重複 | REJECT - バリデーター関数に抽出 |
|
||||
| 本質的に同じ構造のコンポーネント | REJECT - 共通コンポーネント化 |
|
||||
| コピペで派生したコード | REJECT - パラメータ化または抽象化 |
|
||||
|
||||
AHA原則(Avoid Hasty Abstractions)とのバランス:
|
||||
- 2回の重複 → 様子見
|
||||
- 3回の重複 → 即抽出
|
||||
- ドメインが異なる重複 → 抽象化しない(例: 顧客用バリデーションと管理者用バリデーションは別物)
|
||||
DRY にしないケース:
|
||||
- ドメインが異なる重複は抽象化しない(例: 顧客用バリデーションと管理者用バリデーションは別物)
|
||||
- 表面的に似ているが、変更理由が異なるコードは別物として扱う
|
||||
|
||||
## 仕様準拠の検証
|
||||
|
||||
|
||||
@ -369,6 +369,54 @@ export function StepperButton(props) {
|
||||
- 追加したvariantが元のコンポーネントの用途と明らかに違う
|
||||
- 使う側のprops指定が複雑になる
|
||||
|
||||
### テーマ差分とデザイントークン
|
||||
|
||||
同じ機能コンポーネントを再利用しつつ見た目だけ変える場合は、デザイントークン + テーマスコープで管理する。
|
||||
|
||||
原則:
|
||||
- 色・余白・角丸・影・タイポをトークン(CSS Variables)として定義する
|
||||
- 画面/ロール別の差分はテーマスコープ(例: `.consumer-theme`, `.admin-theme`)で上書きする
|
||||
- コンポーネント内に16進カラー値(`#xxxxxx`)を直書きしない
|
||||
- ロジック差分(API・状態管理)と見た目差分(トークン)を混在させない
|
||||
|
||||
```css
|
||||
/* tokens.css */
|
||||
:root {
|
||||
--color-bg-page: #f3f4f6;
|
||||
--color-surface: #ffffff;
|
||||
--color-text-primary: #1f2937;
|
||||
--color-border: #d1d5db;
|
||||
--color-accent: #2563eb;
|
||||
}
|
||||
|
||||
.consumer-theme {
|
||||
--color-bg-page: #f7f8fa;
|
||||
--color-accent: #4daca1;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// same component, different look by scope
|
||||
<div className="consumer-theme">
|
||||
<Button variant="primary">Submit</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
運用ルール:
|
||||
- 共通UI(Button/Card/Input/Tabs)はトークン参照のみで実装する
|
||||
- feature側はテーマ共通クラス(例: `surface`, `title`, `chip`)を利用し、装飾ロジックを重複させない
|
||||
- 追加テーマ実装時は「トークン追加 → スコープ上書き → 既存コンポーネント流用」の順で進める
|
||||
|
||||
レビュー観点:
|
||||
- 直書き色・直書き余白のコピペがないか
|
||||
- 同一UIパターンがテーマごとに別コンポーネント化されていないか
|
||||
- 見た目変更のためにデータ取得/状態管理が改変されていないか
|
||||
|
||||
NG例:
|
||||
- 見た目差分のために `ButtonConsumer`, `ButtonAdmin` を乱立
|
||||
- featureコンポーネントごとに色を直書き
|
||||
- テーマ切り替えのたびにAPIレスポンス整形ロジックを変更
|
||||
|
||||
## 抽象化レベルの評価
|
||||
|
||||
### 条件分岐の肥大化検出
|
||||
|
||||
@ -33,4 +33,5 @@
|
||||
- 設計判断を勝手にする → 報告して判断を仰ぐ
|
||||
- レビュワーの指摘を軽視する → 禁止
|
||||
- 後方互換・Legacy 対応を勝手に追加する → 絶対禁止
|
||||
- リファクタリングで置き換えたコード・エクスポートを残す → 禁止(明示的に残すよう指示されない限り削除する)
|
||||
- 根本原因を修正した上で安全機構を迂回するワークアラウンドを重ねる → 禁止
|
||||
|
||||
@ -9,7 +9,10 @@ piece_categories:
|
||||
🎨 フロントエンド:
|
||||
pieces:
|
||||
- frontend
|
||||
⚙️ バックエンド: {}
|
||||
⚙️ バックエンド:
|
||||
pieces:
|
||||
- backend
|
||||
- backend-cqrs
|
||||
🔧 エキスパート:
|
||||
フルスタック:
|
||||
pieces:
|
||||
|
||||
267
builtins/ja/pieces/backend-cqrs.yaml
Normal file
267
builtins/ja/pieces/backend-cqrs.yaml
Normal file
@ -0,0 +1,267 @@
|
||||
name: backend-cqrs
|
||||
description: CQRS+ES・セキュリティ・QA専門家レビュー
|
||||
max_movements: 30
|
||||
initial_movement: plan
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
persona: planner
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: plan
|
||||
rules:
|
||||
- condition: タスク分析と計画が完了した
|
||||
next: implement
|
||||
- condition: 要件が不明確で計画を立てられない
|
||||
next: ABORT
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 00-plan.md
|
||||
format: plan
|
||||
- name: implement
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: implement
|
||||
rules:
|
||||
- condition: 実装が完了した
|
||||
next: ai_review
|
||||
- condition: 実装未着手(レポートのみ)
|
||||
next: ai_review
|
||||
- condition: 実装を進行できない
|
||||
next: ai_review
|
||||
- condition: ユーザー入力が必要
|
||||
next: implement
|
||||
requires_user_input: true
|
||||
interactive_only: true
|
||||
output_contracts:
|
||||
report:
|
||||
- Scope: 01-coder-scope.md
|
||||
- Decisions: 02-coder-decisions.md
|
||||
- name: ai_review
|
||||
edit: false
|
||||
persona: ai-antipattern-reviewer
|
||||
policy:
|
||||
- review
|
||||
- ai-antipattern
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-review
|
||||
rules:
|
||||
- condition: AI特有の問題が見つからない
|
||||
next: reviewers
|
||||
- condition: AI特有の問題が検出された
|
||||
next: ai_fix
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 03-ai-review.md
|
||||
format: ai-review
|
||||
- name: ai_fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-fix
|
||||
rules:
|
||||
- condition: AI Reviewerの指摘に対する修正が完了した
|
||||
next: ai_review
|
||||
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
|
||||
next: ai_no_fix
|
||||
- condition: 修正を進行できない
|
||||
next: ai_no_fix
|
||||
- name: ai_no_fix
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
rules:
|
||||
- condition: ai_reviewの指摘が妥当(修正すべき)
|
||||
next: ai_fix
|
||||
- condition: ai_fixの判断が妥当(修正不要)
|
||||
next: reviewers
|
||||
instruction: arbitrate
|
||||
- name: reviewers
|
||||
parallel:
|
||||
- name: cqrs-es-review
|
||||
edit: false
|
||||
persona: cqrs-es-reviewer
|
||||
policy: review
|
||||
knowledge:
|
||||
- cqrs-es
|
||||
- backend
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-cqrs-es
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 04-cqrs-es-review.md
|
||||
format: cqrs-es-review
|
||||
- name: security-review
|
||||
edit: false
|
||||
persona: security-reviewer
|
||||
policy: review
|
||||
knowledge: security
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-security
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 05-security-review.md
|
||||
format: security-review
|
||||
- name: qa-review
|
||||
edit: false
|
||||
persona: qa-reviewer
|
||||
policy:
|
||||
- review
|
||||
- qa
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-qa
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 06-qa-review.md
|
||||
format: qa-review
|
||||
rules:
|
||||
- condition: all("approved")
|
||||
next: supervise
|
||||
- condition: any("needs_fix")
|
||||
next: fix
|
||||
- name: fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
permission_mode: edit
|
||||
rules:
|
||||
- condition: 修正が完了した
|
||||
next: reviewers
|
||||
- condition: 修正を進行できない
|
||||
next: plan
|
||||
instruction: fix
|
||||
- name: supervise
|
||||
edit: false
|
||||
persona: expert-supervisor
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: supervise
|
||||
rules:
|
||||
- condition: すべての検証が完了し、マージ可能な状態である
|
||||
next: COMPLETE
|
||||
- condition: 問題が検出された
|
||||
next: fix_supervisor
|
||||
output_contracts:
|
||||
report:
|
||||
- Validation: 07-supervisor-validation.md
|
||||
- Summary: summary.md
|
||||
- name: fix_supervisor
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- cqrs-es
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: fix-supervisor
|
||||
rules:
|
||||
- condition: 監督者の指摘に対する修正が完了した
|
||||
next: supervise
|
||||
- condition: 修正を進行できない
|
||||
next: plan
|
||||
263
builtins/ja/pieces/backend.yaml
Normal file
263
builtins/ja/pieces/backend.yaml
Normal file
@ -0,0 +1,263 @@
|
||||
name: backend
|
||||
description: バックエンド・セキュリティ・QA専門家レビュー
|
||||
max_movements: 30
|
||||
initial_movement: plan
|
||||
movements:
|
||||
- name: plan
|
||||
edit: false
|
||||
persona: planner
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: plan
|
||||
rules:
|
||||
- condition: タスク分析と計画が完了した
|
||||
next: implement
|
||||
- condition: 要件が不明確で計画を立てられない
|
||||
next: ABORT
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 00-plan.md
|
||||
format: plan
|
||||
- name: implement
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: implement
|
||||
rules:
|
||||
- condition: 実装が完了した
|
||||
next: ai_review
|
||||
- condition: 実装未着手(レポートのみ)
|
||||
next: ai_review
|
||||
- condition: 実装を進行できない
|
||||
next: ai_review
|
||||
- condition: ユーザー入力が必要
|
||||
next: implement
|
||||
requires_user_input: true
|
||||
interactive_only: true
|
||||
output_contracts:
|
||||
report:
|
||||
- Scope: 01-coder-scope.md
|
||||
- Decisions: 02-coder-decisions.md
|
||||
- name: ai_review
|
||||
edit: false
|
||||
persona: ai-antipattern-reviewer
|
||||
policy:
|
||||
- review
|
||||
- ai-antipattern
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-review
|
||||
rules:
|
||||
- condition: AI特有の問題が見つからない
|
||||
next: reviewers
|
||||
- condition: AI特有の問題が検出された
|
||||
next: ai_fix
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 03-ai-review.md
|
||||
format: ai-review
|
||||
- name: ai_fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
session: refresh
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: ai-fix
|
||||
rules:
|
||||
- condition: AI Reviewerの指摘に対する修正が完了した
|
||||
next: ai_review
|
||||
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
|
||||
next: ai_no_fix
|
||||
- condition: 修正を進行できない
|
||||
next: ai_no_fix
|
||||
- name: ai_no_fix
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
rules:
|
||||
- condition: ai_reviewの指摘が妥当(修正すべき)
|
||||
next: ai_fix
|
||||
- condition: ai_fixの判断が妥当(修正不要)
|
||||
next: reviewers
|
||||
instruction: arbitrate
|
||||
- name: reviewers
|
||||
parallel:
|
||||
- name: arch-review
|
||||
edit: false
|
||||
persona: architecture-reviewer
|
||||
policy: review
|
||||
knowledge:
|
||||
- architecture
|
||||
- backend
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-arch
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 04-architect-review.md
|
||||
format: architecture-review
|
||||
- name: security-review
|
||||
edit: false
|
||||
persona: security-reviewer
|
||||
policy: review
|
||||
knowledge: security
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-security
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 05-security-review.md
|
||||
format: security-review
|
||||
- name: qa-review
|
||||
edit: false
|
||||
persona: qa-reviewer
|
||||
policy:
|
||||
- review
|
||||
- qa
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
rules:
|
||||
- condition: approved
|
||||
- condition: needs_fix
|
||||
instruction: review-qa
|
||||
output_contracts:
|
||||
report:
|
||||
- name: 06-qa-review.md
|
||||
format: qa-review
|
||||
rules:
|
||||
- condition: all("approved")
|
||||
next: supervise
|
||||
- condition: any("needs_fix")
|
||||
next: fix
|
||||
- name: fix
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
permission_mode: edit
|
||||
rules:
|
||||
- condition: 修正が完了した
|
||||
next: reviewers
|
||||
- condition: 修正を進行できない
|
||||
next: plan
|
||||
instruction: fix
|
||||
- name: supervise
|
||||
edit: false
|
||||
persona: expert-supervisor
|
||||
policy: review
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: supervise
|
||||
rules:
|
||||
- condition: すべての検証が完了し、マージ可能な状態である
|
||||
next: COMPLETE
|
||||
- condition: 問題が検出された
|
||||
next: fix_supervisor
|
||||
output_contracts:
|
||||
report:
|
||||
- Validation: 07-supervisor-validation.md
|
||||
- Summary: summary.md
|
||||
- name: fix_supervisor
|
||||
edit: true
|
||||
persona: coder
|
||||
policy:
|
||||
- coding
|
||||
- testing
|
||||
knowledge:
|
||||
- backend
|
||||
- security
|
||||
- architecture
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Edit
|
||||
- Write
|
||||
- Bash
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
instruction: fix-supervisor
|
||||
rules:
|
||||
- condition: 監督者の指摘に対する修正が完了した
|
||||
next: supervise
|
||||
- condition: 修正を進行できない
|
||||
next: plan
|
||||
@ -7,7 +7,7 @@
|
||||
| 原則 | 基準 |
|
||||
|------|------|
|
||||
| Simple > Easy | 書きやすさより読みやすさを優先 |
|
||||
| DRY | 3回重複したら抽出 |
|
||||
| DRY | 本質的な重複は排除する |
|
||||
| コメント | Why のみ。What/How は書かない |
|
||||
| 関数サイズ | 1関数1責務。30行目安 |
|
||||
| ファイルサイズ | 目安として300行。タスクに応じて柔軟に |
|
||||
@ -245,23 +245,19 @@ Request → toInput() → UseCase/Service → Output → Response.from()
|
||||
|
||||
## 共通化の判断
|
||||
|
||||
### 3回ルール
|
||||
|
||||
- 1回目: そのまま書く
|
||||
- 2回目: まだ共通化しない(様子見)
|
||||
- 3回目: 共通化を検討
|
||||
基本的に重複は排除する。本質的に同じロジックであり、まとめるべきと判断したら DRY にする。回数で機械的に判断しない。
|
||||
|
||||
### 共通化すべきもの
|
||||
|
||||
- 同じ処理が3箇所以上
|
||||
- 本質的に同じロジックの重複
|
||||
- 同じスタイル/UIパターン
|
||||
- 同じバリデーションロジック
|
||||
- 同じフォーマット処理
|
||||
|
||||
### 共通化すべきでないもの
|
||||
|
||||
- 似ているが微妙に違うもの(無理に汎用化すると複雑化)
|
||||
- 1-2箇所しか使わないもの
|
||||
- ドメインが異なる重複(例: 顧客用バリデーションと管理者用バリデーションは別物)
|
||||
- 表面的に似ているが変更理由が異なるコード
|
||||
- 「将来使うかも」という予測に基づくもの
|
||||
|
||||
```typescript
|
||||
@ -289,4 +285,6 @@ function formatPercentage(value: number): string { ... }
|
||||
- **機密情報のハードコーディング**
|
||||
- **各所でのtry-catch** - エラーは上位層で一元処理
|
||||
- **後方互換・Legacy対応の自発的追加** - 明示的な指示がない限り不要
|
||||
- **内部実装のパブリック API エクスポート** - 公開するのはドメイン操作の関数・型のみ。インフラ層の関数や内部クラスをエクスポートしない
|
||||
- **リファクタリング後の旧コード残存** - 置き換えたコード・エクスポートは削除する。明示的に残すよう指示されない限り残さない
|
||||
- **安全機構を迂回するワークアラウンド** - 根本修正が正しいなら追加の迂回は不要
|
||||
|
||||
@ -38,9 +38,11 @@
|
||||
- オブジェクト/配列の直接変更
|
||||
- エラーの握りつぶし(空の catch)
|
||||
- TODO コメント(Issue化されていないもの)
|
||||
- 3箇所以上の重複コード(DRY違反)
|
||||
- 本質的に同じロジックの重複(DRY違反)
|
||||
- 同じことをするメソッドの増殖(構成の違いで吸収すべき)
|
||||
- 特定実装の汎用層への漏洩(汎用層に特定実装のインポート・分岐がある)
|
||||
- 内部実装のパブリック API エクスポート(インフラ層の関数・内部クラスが公開されている)
|
||||
- リファクタリングで置き換えられた旧コード・旧エクスポートの残存
|
||||
- 関連フィールドのクロスバリデーション欠如(意味的に結合した設定値の不変条件が未検証)
|
||||
|
||||
### Warning(警告)
|
||||
|
||||
33
builtins/schemas/decomposition.json
Normal file
33
builtins/schemas/decomposition.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique part identifier"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable part title"
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "Instruction for the part agent"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Optional timeout in ms"
|
||||
}
|
||||
},
|
||||
"required": ["id", "title", "instruction", "timeout_ms"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["parts"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
15
builtins/schemas/evaluation.json
Normal file
15
builtins/schemas/evaluation.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matched_index": {
|
||||
"type": "integer",
|
||||
"description": "Matched condition number (1-based)"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this condition was matched"
|
||||
}
|
||||
},
|
||||
"required": ["matched_index", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
15
builtins/schemas/judgment.json
Normal file
15
builtins/schemas/judgment.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"step": {
|
||||
"type": "integer",
|
||||
"description": "Matched rule number (1-based)"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief justification for the decision"
|
||||
}
|
||||
},
|
||||
"required": ["step", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@ -474,6 +474,8 @@ TAKTには複数のビルトインピースが同梱されています:
|
||||
| `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 |
|
||||
| `e2e-test` | E2Eテスト重視ピース: E2E分析 → E2E実装 → レビュー → 修正(VitestベースのE2Eフロー)。 |
|
||||
| `frontend` | フロントエンド特化開発ピース: React/Next.js 向けのレビューとナレッジ注入。 |
|
||||
| `backend` | バックエンド特化開発ピース: バックエンド、セキュリティ、QA の専門家レビュー。 |
|
||||
| `backend-cqrs` | CQRS+ES 特化バックエンド開発ピース: CQRS+ES、セキュリティ、QA の専門家レビュー。 |
|
||||
|
||||
**ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude)。ピースを複製する必要はありません。
|
||||
|
||||
|
||||
127
docs/implements/structured-output.ja.md
Normal file
127
docs/implements/structured-output.ja.md
Normal file
@ -0,0 +1,127 @@
|
||||
# Structured Output — Phase 3 ステータス判定
|
||||
|
||||
## 概要
|
||||
|
||||
Phase 3(ステータス判定)において、エージェントの出力を structured output(JSON スキーマ)で取得し、ルールマッチングの精度と信頼性を向上させる。
|
||||
|
||||
## プロバイダ別の挙動
|
||||
|
||||
| プロバイダ | メソッド | 仕組み |
|
||||
|-----------|---------|--------|
|
||||
| Claude | `structured_output` | SDK が `StructuredOutput` ツールを自動追加。エージェントがツール経由で `{ step, reason }` を返す |
|
||||
| Codex | `structured_output` | `TurnOptions.outputSchema` で API レベルの JSON 制約。テキストが JSON になる |
|
||||
| OpenCode | `structured_output` | プロンプト末尾に JSON スキーマ付き出力指示を注入。テキストレスポンスから `parseStructuredOutput()` で JSON を抽出 |
|
||||
|
||||
## フォールバックチェーン
|
||||
|
||||
`judgeStatus()` は3段階の独立した LLM 呼び出しでルールをマッチする。
|
||||
|
||||
```
|
||||
Stage 1: structured_output — outputSchema 付き LLM 呼び出し → structuredOutput.step(1-based integer)
|
||||
Stage 2: phase3_tag — outputSchema なし LLM 呼び出し → content 内の [MOVEMENT:N] タグ検出
|
||||
Stage 3: ai_judge — evaluateCondition() による AI 条件評価
|
||||
```
|
||||
|
||||
各ステージは専用のインストラクションで LLM に問い合わせる。Stage 1 は「ルール番号を JSON で返せ」、Stage 2 は「タグを1行で出力せよ」と聞き方が異なる。
|
||||
|
||||
セッションログには `toJudgmentMatchMethod()` で変換された値が記録される。
|
||||
|
||||
| 内部メソッド | セッションログ |
|
||||
|-------------|--------------|
|
||||
| `structured_output` | `structured_output` |
|
||||
| `phase3_tag` / `phase1_tag` | `tag_fallback` |
|
||||
| `ai_judge` / `ai_judge_fallback` | `ai_judge` |
|
||||
|
||||
## インストラクション分岐
|
||||
|
||||
Phase 3 テンプレート(`perform_phase3_message`)は `structuredOutput` フラグで2つのモードを持つ。
|
||||
|
||||
### Structured Output モード(`structuredOutput: true`)
|
||||
|
||||
主要指示: ルール番号(1-based)と理由を返せ。
|
||||
フォールバック指示: structured output が使えない場合はタグを出力せよ。
|
||||
|
||||
### タグモード(`structuredOutput: false`)
|
||||
|
||||
従来の指示: 対応するタグを1行で出力せよ。
|
||||
|
||||
現在、Phase 3 は常に `structuredOutput: true` で実行される。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
StatusJudgmentBuilder
|
||||
└─ structuredOutput: true
|
||||
├─ criteriaTable: ルール条件テーブル(常に含む)
|
||||
├─ outputList: タグ一覧(フォールバック用に含む)
|
||||
└─ テンプレート: "ルール番号と理由を返せ + タグはフォールバック"
|
||||
|
||||
runStatusJudgmentPhase()
|
||||
└─ judgeStatus() → JudgeStatusResult { ruleIndex, method }
|
||||
└─ StatusJudgmentPhaseResult { tag, ruleIndex, method }
|
||||
|
||||
MovementExecutor
|
||||
├─ Phase 3 あり → judgeStatus の結果を直接使用(method 伝搬)
|
||||
└─ Phase 3 なし → detectMatchedRule() で Phase 1 コンテンツから検出
|
||||
```
|
||||
|
||||
## JSON スキーマ
|
||||
|
||||
### judgment.json(judgeStatus 用)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"step": { "type": "integer", "description": "Matched rule number (1-based)" },
|
||||
"reason": { "type": "string", "description": "Brief justification" }
|
||||
},
|
||||
"required": ["step", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
```
|
||||
|
||||
### evaluation.json(evaluateCondition 用)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matched_index": { "type": "integer" },
|
||||
"reason": { "type": "string" }
|
||||
},
|
||||
"required": ["matched_index", "reason"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
```
|
||||
|
||||
## parseStructuredOutput() — JSON 抽出
|
||||
|
||||
Codex と OpenCode はテキストレスポンスから JSON を抽出する。3段階のフォールバック戦略を持つ。
|
||||
|
||||
```
|
||||
1. Direct parse — テキスト全体が `{` で始まる JSON オブジェクト
|
||||
2. Code block — ```json ... ``` または ``` ... ``` 内の JSON
|
||||
3. Brace extraction — テキスト内の最初の `{` から最後の `}` までを切り出し
|
||||
```
|
||||
|
||||
## OpenCode 固有の仕組み
|
||||
|
||||
OpenCode SDK は `outputFormat` を型定義でサポートしていない。代わりにプロンプト末尾に JSON 出力指示を注入する。
|
||||
|
||||
```
|
||||
---
|
||||
IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema. No other text, no markdown code blocks, no explanation.
|
||||
```json
|
||||
{ "type": "object", ... }
|
||||
```
|
||||
```
|
||||
|
||||
エージェントが返すテキストを `parseStructuredOutput()` でパースし、`AgentResponse.structuredOutput` に格納する。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- OpenAI API(Codex)は `required` に全プロパティを含めないとエラーになる(`additionalProperties: false` 時)
|
||||
- Codex SDK の `TurnCompletedEvent` には `finalResponse` フィールドがない。structured output は `AgentMessageItem.text` の JSON テキストから `parseStructuredOutput()` でパースする
|
||||
- Claude SDK は `StructuredOutput` ツール方式のため、インストラクションでタグ出力を強調しすぎるとエージェントがツールを呼ばずタグを出力してしまう
|
||||
- OpenCode のプロンプト注入方式はモデルの指示従順性に依存する。JSON 以外のテキストが混在する場合は `parseStructuredOutput()` の code block / brace extraction で回収する
|
||||
34
docs/report-phase-permissions.md
Normal file
34
docs/report-phase-permissions.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Report Phase Permissions Design
|
||||
|
||||
## Summary
|
||||
|
||||
The report phase now uses permission mode as the primary control surface.
|
||||
Call sites only provide resume metadata (for example, `maxTurns`), and tool compatibility details are isolated inside `OptionsBuilder`.
|
||||
|
||||
## Problem
|
||||
|
||||
Historically, report phase calls passed `allowedTools: []` directly from `phase-runner`.
|
||||
This made phase control depend on a tool list setting that is treated as legacy in OpenCode.
|
||||
|
||||
## Design
|
||||
|
||||
1. `phase-runner` uses `buildResumeOptions(step, sessionId, { maxTurns })`.
|
||||
2. `OptionsBuilder.buildResumeOptions` enforces:
|
||||
- `permissionMode: 'readonly'`
|
||||
- `allowedTools: []` (compatibility layer for SDK behavior differences)
|
||||
3. OpenCode-specific execution is controlled by permission rules (`readonly` => deny).
|
||||
|
||||
## Rationale
|
||||
|
||||
- OpenCode permission rules are the stable and explicit control mechanism for report-phase safety.
|
||||
- Centralizing compatibility behavior in `OptionsBuilder` prevents policy leakage into movement orchestration code.
|
||||
- Resume-session behavior remains deterministic for both report and status phases.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- `src/__tests__/options-builder.test.ts`
|
||||
- verifies report/status resume options force `readonly` and empty tools.
|
||||
- `src/__tests__/phase-runner-report-history.test.ts`
|
||||
- verifies report phase passes only `{ maxTurns: 3 }` override.
|
||||
- `src/__tests__/opencode-types.test.ts`
|
||||
- verifies readonly maps to deny in OpenCode permission config.
|
||||
@ -17,7 +17,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
||||
## E2E用config.yaml
|
||||
- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。
|
||||
- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。
|
||||
- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。
|
||||
- E2E実行中の `takt` 内通知音は `notification_sound: false` で無効化する。
|
||||
- `npm run test:e2e` は成否にかかわらず最後に1回ベルを鳴らし、終了コードはテスト結果を維持する。
|
||||
- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。
|
||||
- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。
|
||||
|
||||
|
||||
@ -2,10 +2,10 @@ provider: claude
|
||||
language: en
|
||||
log_level: info
|
||||
default_piece: default
|
||||
notification_sound: true
|
||||
notification_sound: false
|
||||
notification_sound_events:
|
||||
iteration_limit: false
|
||||
piece_complete: false
|
||||
piece_abort: false
|
||||
run_complete: true
|
||||
run_abort: true
|
||||
run_abort: false
|
||||
|
||||
37
e2e/fixtures/pieces/mock-cycle-detect.yaml
Normal file
37
e2e/fixtures/pieces/mock-cycle-detect.yaml
Normal file
@ -0,0 +1,37 @@
|
||||
name: e2e-cycle-detect
|
||||
description: Piece with loop_monitors for cycle detection E2E testing
|
||||
|
||||
max_movements: 20
|
||||
initial_movement: review
|
||||
|
||||
loop_monitors:
|
||||
- cycle: [review, fix]
|
||||
threshold: 2
|
||||
judge:
|
||||
persona: ../agents/test-reviewer-b.md
|
||||
rules:
|
||||
- condition: continue
|
||||
next: review
|
||||
- condition: abort_loop
|
||||
next: ABORT
|
||||
|
||||
movements:
|
||||
- name: review
|
||||
persona: ../agents/test-reviewer-a.md
|
||||
instruction_template: |
|
||||
Review the code.
|
||||
rules:
|
||||
- condition: approved
|
||||
next: COMPLETE
|
||||
- condition: needs_fix
|
||||
next: fix
|
||||
|
||||
- name: fix
|
||||
persona: ../agents/test-coder.md
|
||||
edit: true
|
||||
permission_mode: edit
|
||||
instruction_template: |
|
||||
Fix the issues found in review.
|
||||
rules:
|
||||
- condition: fixed
|
||||
next: review
|
||||
18
e2e/fixtures/pieces/structured-output.yaml
Normal file
18
e2e/fixtures/pieces/structured-output.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
name: e2e-structured-output
|
||||
description: E2E piece to verify structured output rule matching
|
||||
|
||||
max_movements: 5
|
||||
|
||||
movements:
|
||||
- name: execute
|
||||
edit: false
|
||||
persona: ../agents/test-coder.md
|
||||
permission_mode: readonly
|
||||
instruction_template: |
|
||||
Reply with exactly: "Task completed successfully."
|
||||
Do not do anything else.
|
||||
rules:
|
||||
- condition: Task completed
|
||||
next: COMPLETE
|
||||
- condition: Task failed
|
||||
next: ABORT
|
||||
13
e2e/fixtures/scenarios/cycle-detect-abort.json
Normal file
13
e2e/fixtures/scenarios/cycle-detect-abort.json
Normal file
@ -0,0 +1,13 @@
|
||||
[
|
||||
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."},
|
||||
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nStill needs fix."},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed again."},
|
||||
{"persona": "agents/test-reviewer-b", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]\n\nAbort this loop."},
|
||||
{"persona": "conductor", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]"},
|
||||
{"persona": "conductor", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]"}
|
||||
]
|
||||
9
e2e/fixtures/scenarios/cycle-detect-pass.json
Normal file
9
e2e/fixtures/scenarios/cycle-detect-pass.json
Normal file
@ -0,0 +1,9 @@
|
||||
[
|
||||
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
|
||||
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."},
|
||||
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:1]\n\nApproved."},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:1]"},
|
||||
{"persona": "conductor", "status": "done", "content": "[REVIEW:1]"}
|
||||
]
|
||||
@ -1,7 +1,9 @@
|
||||
[
|
||||
{ "persona": "test-coder", "status": "done", "content": "Plan created." },
|
||||
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture approved." },
|
||||
{ "persona": "test-reviewer-b", "status": "done", "content": "Security approved." },
|
||||
{ "persona": "agents/test-coder", "status": "done", "content": "Plan created." },
|
||||
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture approved." },
|
||||
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security approved." },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" }
|
||||
]
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
[
|
||||
{ "persona": "test-coder", "status": "done", "content": "Plan created." },
|
||||
{ "persona": "agents/test-coder", "status": "done", "content": "Plan created." },
|
||||
|
||||
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture looks good." },
|
||||
{ "persona": "test-reviewer-b", "status": "done", "content": "Security issues found." },
|
||||
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture looks good." },
|
||||
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security issues found." },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
|
||||
|
||||
{ "persona": "test-coder", "status": "done", "content": "Fix applied." },
|
||||
{ "persona": "agents/test-coder", "status": "done", "content": "Fix applied." },
|
||||
|
||||
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture still approved." },
|
||||
{ "persona": "test-reviewer-b", "status": "done", "content": "Security now approved." },
|
||||
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture still approved." },
|
||||
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security now approved." },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
|
||||
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" }
|
||||
]
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
[
|
||||
{
|
||||
"persona": "test-reporter",
|
||||
"persona": "agents/test-reporter",
|
||||
"status": "done",
|
||||
"content": "Work completed."
|
||||
},
|
||||
{
|
||||
"persona": "test-reporter",
|
||||
"persona": "agents/test-reporter",
|
||||
"status": "done",
|
||||
"content": "Report summary: OK"
|
||||
},
|
||||
{
|
||||
"persona": "conductor",
|
||||
"status": "done",
|
||||
"content": "[EXECUTE:1]"
|
||||
},
|
||||
{
|
||||
"persona": "conductor",
|
||||
"status": "done",
|
||||
|
||||
26
e2e/helpers/session-log.ts
Normal file
26
e2e/helpers/session-log.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Read session NDJSON log records from a piece execution run.
|
||||
* Finds the first .jsonl file whose first record is `piece_start`.
|
||||
*/
|
||||
export function readSessionRecords(repoPath: string): Array<Record<string, unknown>> {
|
||||
const runsDir = join(repoPath, '.takt', 'runs');
|
||||
const runDirs = readdirSync(runsDir).sort();
|
||||
|
||||
for (const runDir of runDirs) {
|
||||
const logsDir = join(runsDir, runDir, 'logs');
|
||||
const logFiles = readdirSync(logsDir).filter((file) => file.endsWith('.jsonl'));
|
||||
for (const file of logFiles) {
|
||||
const content = readFileSync(join(logsDir, file), 'utf-8').trim();
|
||||
if (!content) continue;
|
||||
const records = content.split('\n').map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
if (records[0]?.type === 'piece_start') {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Session NDJSON log not found');
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
import { rmSync } from 'node:fs';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface LocalRepo {
|
||||
path: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export interface TestRepo {
|
||||
path: string;
|
||||
repoName: string;
|
||||
@ -11,6 +15,26 @@ export interface TestRepo {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local git repository in a temporary directory.
|
||||
* Use this for tests that don't need a remote (GitHub) repository.
|
||||
*/
|
||||
export function createLocalRepo(): LocalRepo {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTestRepoOptions {
|
||||
/** Skip creating a test branch (stay on default branch). Use for pipeline tests. */
|
||||
skipBranch?: boolean;
|
||||
@ -30,6 +54,66 @@ function getGitHubUser(): string {
|
||||
return user;
|
||||
}
|
||||
|
||||
function canUseGitHubRepo(): boolean {
|
||||
try {
|
||||
const user = getGitHubUser();
|
||||
const repoName = `${user}/takt-testing`;
|
||||
execFileSync('gh', ['repo', 'view', repoName], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isGitHubE2EAvailable(): boolean {
|
||||
return canUseGitHubRepo();
|
||||
}
|
||||
|
||||
function createOfflineTestRepo(options?: CreateTestRepoOptions): TestRepo {
|
||||
const sandboxPath = mkdtempSync(join(tmpdir(), 'takt-e2e-repo-'));
|
||||
const originPath = join(sandboxPath, 'origin.git');
|
||||
const repoPath = join(sandboxPath, 'work');
|
||||
|
||||
execFileSync('git', ['init', '--bare', originPath], { stdio: 'pipe' });
|
||||
execFileSync('git', ['clone', originPath, repoPath], { stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['push', '-u', 'origin', 'HEAD'], { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const testBranch = options?.skipBranch ? undefined : `e2e-test-${Date.now()}`;
|
||||
if (testBranch) {
|
||||
execFileSync('git', ['checkout', '-b', testBranch], {
|
||||
cwd: repoPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
const currentBranch = testBranch
|
||||
?? execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
repoName: 'local/takt-testing',
|
||||
branch: currentBranch,
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(sandboxPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the takt-testing repository and create a test branch.
|
||||
*
|
||||
@ -39,6 +123,10 @@ function getGitHubUser(): string {
|
||||
* 3. Delete local directory
|
||||
*/
|
||||
export function createTestRepo(options?: CreateTestRepoOptions): TestRepo {
|
||||
if (!canUseGitHubRepo()) {
|
||||
return createOfflineTestRepo(options);
|
||||
}
|
||||
|
||||
const user = getGitHubUser();
|
||||
const repoName = `${user}/takt-testing`;
|
||||
|
||||
|
||||
@ -9,11 +9,12 @@ import {
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
||||
import { createTestRepo, isGitHubE2EAvailable, type TestRepo } from '../helpers/test-repo';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const requiresGitHub = isGitHubE2EAvailable();
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Add task from GitHub issue (takt add)', () => {
|
||||
@ -67,7 +68,7 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a task file from issue reference', () => {
|
||||
it.skipIf(!requiresGitHub)('should create a task file from issue reference', () => {
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/add-task.json');
|
||||
|
||||
const result = runTakt({
|
||||
|
||||
@ -1,31 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-catalog-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Catalog command (takt catalog)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,31 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-clear-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Clear sessions command (takt clear)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,31 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-config-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Config command (takt config)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,31 +1,15 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Export-cc command (takt export-cc)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
let fakeHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -1,31 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-help-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Help command (takt --help)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,36 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-prompt-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Prompt preview command (takt prompt)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,31 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-reset-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Reset categories command (takt reset categories)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,31 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-switch-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Switch piece command (takt switch)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
88
e2e/specs/cycle-detection.e2e.ts
Normal file
88
e2e/specs/cycle-detection.e2e.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
import { readSessionRecords } from '../helpers/session-log';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Cycle detection via loop_monitors (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
});
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should abort when cycle threshold is reached and judge selects ABORT', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-abort.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test cycle detection abort',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
|
||||
const records = readSessionRecords(repo.path);
|
||||
const judgeStep = records.find((r) => r.type === 'step_complete' && r.step === '_loop_judge_review_fix');
|
||||
const abort = records.find((r) => r.type === 'piece_abort');
|
||||
|
||||
expect(judgeStep).toBeDefined();
|
||||
expect(abort).toBeDefined();
|
||||
}, 240_000);
|
||||
|
||||
it('should complete when cycle threshold is not reached', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-pass.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test cycle detection pass',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const records = readSessionRecords(repo.path);
|
||||
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
|
||||
expect(records.some((r) => r.type === 'piece_abort')).toBe(false);
|
||||
}, 240_000);
|
||||
});
|
||||
@ -1,40 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
/**
|
||||
* Create a minimal local git repository for eject tests.
|
||||
* No GitHub access needed — just a local git init.
|
||||
*/
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-eject-e2e-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
// Create initial commit so branch exists
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Eject builtin pieces (takt eject)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,36 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-error-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Error handling edge cases (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
74
e2e/specs/model-override.e2e.ts
Normal file
74
e2e/specs/model-override.e2e.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: --model option override (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should complete direct task execution with --model', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test model override direct',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
'--model', 'mock-model-override',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Piece completed');
|
||||
}, 240_000);
|
||||
|
||||
it('should complete pipeline --skip-git execution with --model', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--pipeline',
|
||||
'--task', 'Test model override pipeline',
|
||||
'--piece', piecePath,
|
||||
'--skip-git',
|
||||
'--provider', 'mock',
|
||||
'--model', 'mock-model-override',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('completed');
|
||||
}, 240_000);
|
||||
});
|
||||
57
e2e/specs/multi-step-sequential.e2e.ts
Normal file
57
e2e/specs/multi-step-sequential.e2e.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
import { readSessionRecords } from '../helpers/session-log';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Sequential multi-step session log transitions (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should record step_complete for both step-1 and step-2', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test sequential transitions',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const records = readSessionRecords(repo.path);
|
||||
const completedSteps = records
|
||||
.filter((r) => r.type === 'step_complete')
|
||||
.map((r) => String(r.step));
|
||||
|
||||
expect(completedSteps).toContain('step-1');
|
||||
expect(completedSteps).toContain('step-2');
|
||||
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
|
||||
}, 240_000);
|
||||
});
|
||||
@ -1,36 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-piece-err-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Piece error handling (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
91
e2e/specs/pipeline-local-repo.e2e.ts
Normal file
91
e2e/specs/pipeline-local-repo.e2e.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createNonGitDir(): { path: string; cleanup: () => void } {
|
||||
const dirPath = mkdtempSync(join(tmpdir(), 'takt-e2e-pipeline-nongit-'));
|
||||
writeFileSync(join(dirPath, 'README.md'), '# non-git\n');
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(dirPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Pipeline --skip-git on local/non-git directories (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should execute pipeline with --skip-git in a local git repository', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--pipeline',
|
||||
'--task', 'Pipeline local repo test',
|
||||
'--piece', piecePath,
|
||||
'--skip-git',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('completed');
|
||||
}, 240_000);
|
||||
|
||||
it('should execute pipeline with --skip-git in a non-git directory', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
const dir = createNonGitDir();
|
||||
|
||||
try {
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--pipeline',
|
||||
'--task', 'Pipeline non-git test',
|
||||
'--piece', piecePath,
|
||||
'--skip-git',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: dir.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('completed');
|
||||
} finally {
|
||||
dir.cleanup();
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
@ -1,40 +1,21 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-provider-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Provider error handling (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
@ -1,36 +1,17 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-quiet-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Quiet mode (--quiet)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
|
||||
68
e2e/specs/report-file-output.e2e.ts
Normal file
68
e2e/specs/report-file-output.e2e.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Report file output (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
});
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should write report file to .takt/runs/*/reports with expected content', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/report-judge.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/report-judge.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test report output',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const runsDir = join(repo.path, '.takt', 'runs');
|
||||
expect(existsSync(runsDir)).toBe(true);
|
||||
|
||||
const runDirs = readdirSync(runsDir).sort();
|
||||
expect(runDirs.length).toBeGreaterThan(0);
|
||||
|
||||
const latestRun = runDirs[runDirs.length - 1]!;
|
||||
const reportPath = join(runsDir, latestRun, 'reports', 'report.md');
|
||||
|
||||
expect(existsSync(reportPath)).toBe(true);
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
expect(report).toContain('Report summary: OK');
|
||||
}, 240_000);
|
||||
});
|
||||
@ -1,40 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-run-multi-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Run multiple tasks (takt run)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
|
||||
|
||||
@ -171,4 +171,85 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
|
||||
expect(stderr).not.toContain('UnhandledPromiseRejection');
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('should force exit immediately on second SIGINT', async () => {
|
||||
const binPath = resolve(__dirname, '../../bin/takt');
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-slow-multi-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-sigint-parallel.json');
|
||||
|
||||
const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml');
|
||||
mkdirSync(join(testRepo.path, '.takt'), { recursive: true });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
writeFileSync(
|
||||
tasksFile,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: sigint-a',
|
||||
' status: pending',
|
||||
' content: "E2E SIGINT task A"',
|
||||
` piece: "${piecePath}"`,
|
||||
' worktree: true',
|
||||
` created_at: "${now}"`,
|
||||
' started_at: null',
|
||||
' completed_at: null',
|
||||
' owner_pid: null',
|
||||
' - name: sigint-b',
|
||||
' status: pending',
|
||||
' content: "E2E SIGINT task B"',
|
||||
` piece: "${piecePath}"`,
|
||||
' worktree: true',
|
||||
` created_at: "${now}"`,
|
||||
' started_at: null',
|
||||
' completed_at: null',
|
||||
' owner_pid: null',
|
||||
' - name: sigint-c',
|
||||
' status: pending',
|
||||
' content: "E2E SIGINT task C"',
|
||||
` piece: "${piecePath}"`,
|
||||
' worktree: true',
|
||||
` created_at: "${now}"`,
|
||||
' started_at: null',
|
||||
' completed_at: null',
|
||||
' owner_pid: null',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const child = spawn('node', [binPath, 'run', '--provider', 'mock'], {
|
||||
cwd: testRepo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
TAKT_E2E_SELF_SIGINT_TWICE: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const workersFilled = await waitFor(
|
||||
() => stdout.includes('=== Task: sigint-b ==='),
|
||||
30_000,
|
||||
20,
|
||||
);
|
||||
expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true);
|
||||
|
||||
const exit = await waitForClose(child, 60_000);
|
||||
expect(
|
||||
exit.signal === 'SIGINT' || exit.code === 130,
|
||||
`unexpected exit: code=${exit.code}, signal=${exit.signal}`,
|
||||
).toBe(true);
|
||||
|
||||
if (stderr.trim().length > 0) {
|
||||
expect(stderr).not.toContain('UnhandledPromiseRejection');
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
81
e2e/specs/session-log.e2e.ts
Normal file
81
e2e/specs/session-log.e2e.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
import { readSessionRecords } from '../helpers/session-log';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Session NDJSON log output (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should write piece_start, step_complete, and piece_complete on success', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test session log success',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const records = readSessionRecords(repo.path);
|
||||
expect(records.some((r) => r.type === 'piece_start')).toBe(true);
|
||||
expect(records.some((r) => r.type === 'step_complete')).toBe(true);
|
||||
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
|
||||
}, 240_000);
|
||||
|
||||
it('should write piece_abort with reason on failure', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Test session log abort',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
'--provider', 'mock',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
|
||||
const records = readSessionRecords(repo.path);
|
||||
const abortRecord = records.find((r) => r.type === 'piece_abort');
|
||||
expect(abortRecord).toBeDefined();
|
||||
expect(typeof abortRecord?.reason).toBe('string');
|
||||
expect((abortRecord?.reason as string).length).toBeGreaterThan(0);
|
||||
}, 240_000);
|
||||
});
|
||||
96
e2e/specs/structured-output.e2e.ts
Normal file
96
e2e/specs/structured-output.e2e.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { readSessionRecords } from '../helpers/session-log';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* E2E: Structured output for status judgment (Phase 3).
|
||||
*
|
||||
* Verifies that real providers (Claude, Codex, OpenCode) can execute a piece
|
||||
* where the status judgment phase uses structured output (`outputSchema`)
|
||||
* internally via `judgeStatus()`.
|
||||
*
|
||||
* The piece has 2 rules per step, so `judgeStatus` cannot auto-select
|
||||
* and must actually call the provider with an outputSchema to determine
|
||||
* which rule matched.
|
||||
*
|
||||
* If structured output works correctly, `judgeStatus` extracts the step
|
||||
* number from `response.structuredOutput.step` (recorded as `structured_output`).
|
||||
* If the agent happens to output `[STEP:N]` tags, the RuleEvaluator detects
|
||||
* them as `phase3_tag`/`phase1_tag` (recorded as `tag_fallback` in session log).
|
||||
* The session log matchMethod is transformed by `toJudgmentMatchMethod()`.
|
||||
*
|
||||
* Run with:
|
||||
* TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.structured-output.ts
|
||||
* TAKT_E2E_PROVIDER=codex vitest run --config vitest.config.e2e.structured-output.ts
|
||||
* TAKT_E2E_PROVIDER=opencode TAKT_E2E_MODEL=openai/gpt-4 vitest run --config vitest.config.e2e.structured-output.ts
|
||||
*/
|
||||
describe('E2E: Structured output rule matching', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should complete piece via Phase 3 status judgment with 2-rule step', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/structured-output.yaml');
|
||||
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task', 'Say hello',
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
console.log('=== STDOUT ===\n', result.stdout);
|
||||
console.log('=== STDERR ===\n', result.stderr);
|
||||
}
|
||||
|
||||
// Always log the matchMethod for diagnostic purposes
|
||||
const allRecords = readSessionRecords(repo.path);
|
||||
const sc = allRecords.find((r) => r.type === 'step_complete');
|
||||
console.log(`=== matchMethod: ${sc?.matchMethod ?? '(none)'} ===`);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Piece completed');
|
||||
|
||||
// Verify session log has proper step_complete with matchMethod
|
||||
const records = readSessionRecords(repo.path);
|
||||
|
||||
const pieceComplete = records.find((r) => r.type === 'piece_complete');
|
||||
expect(pieceComplete).toBeDefined();
|
||||
|
||||
const stepComplete = records.find((r) => r.type === 'step_complete');
|
||||
expect(stepComplete).toBeDefined();
|
||||
|
||||
// matchMethod should be present — the 2-rule step required actual judgment
|
||||
// (auto_select is only used for single-rule steps)
|
||||
const matchMethod = stepComplete?.matchMethod as string | undefined;
|
||||
expect(matchMethod).toBeDefined();
|
||||
|
||||
// Session log records transformed matchMethod via toJudgmentMatchMethod():
|
||||
// structured_output → structured_output (judgeStatus extracted from structuredOutput.step)
|
||||
// phase3_tag / phase1_tag → tag_fallback (agent output [STEP:N] tag, detected by RuleEvaluator)
|
||||
// ai_judge / ai_judge_fallback → ai_judge (AI evaluated conditions as fallback)
|
||||
const validMethods = ['structured_output', 'tag_fallback', 'ai_judge'];
|
||||
expect(validMethods).toContain(matchMethod);
|
||||
}, 240_000);
|
||||
});
|
||||
@ -1,40 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function createLocalRepo(): { path: string; cleanup: () => void } {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-contentfile-'));
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' });
|
||||
writeFileSync(join(repoPath, 'README.md'), '# test\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
return {
|
||||
path: repoPath,
|
||||
cleanup: () => {
|
||||
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Task content_file reference (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: { path: string; cleanup: () => void };
|
||||
let repo: LocalRepo;
|
||||
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
|
||||
|
||||
109
e2e/specs/task-status-persistence.e2e.ts
Normal file
109
e2e/specs/task-status-persistence.e2e.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { createIsolatedEnv, updateIsolatedConfig, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function writeSinglePendingTask(repoPath: string, piecePath: string): void {
|
||||
const now = new Date().toISOString();
|
||||
mkdirSync(join(repoPath, '.takt'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repoPath, '.takt', 'tasks.yaml'),
|
||||
[
|
||||
'tasks:',
|
||||
' - name: task-1',
|
||||
' status: pending',
|
||||
' content: "Task 1"',
|
||||
` piece: "${piecePath}"`,
|
||||
` created_at: "${now}"`,
|
||||
' started_at: null',
|
||||
' completed_at: null',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: Task status persistence in tasks.yaml (mock)', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('should remove task record after successful completion', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
writeSinglePendingTask(repo.path, piecePath);
|
||||
|
||||
const result = runTakt({
|
||||
args: ['run', '--provider', 'mock'],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
const tasks = parseYaml(tasksContent) as { tasks: Array<Record<string, unknown>> };
|
||||
expect(Array.isArray(tasks.tasks)).toBe(true);
|
||||
expect(tasks.tasks.length).toBe(0);
|
||||
}, 240_000);
|
||||
|
||||
it('should persist failed status and failure details on failure', () => {
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json');
|
||||
|
||||
writeSinglePendingTask(repo.path, piecePath);
|
||||
|
||||
const result = runTakt({
|
||||
args: ['run', '--provider', 'mock'],
|
||||
cwd: repo.path,
|
||||
env: {
|
||||
...isolatedEnv.env,
|
||||
TAKT_MOCK_SCENARIO: scenarioPath,
|
||||
},
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
const tasks = parseYaml(tasksContent) as {
|
||||
tasks: Array<{
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failure?: { error?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(tasks.tasks.length).toBe(1);
|
||||
expect(tasks.tasks[0]?.status).toBe('failed');
|
||||
expect(tasks.tasks[0]?.started_at).toBeTruthy();
|
||||
expect(tasks.tasks[0]?.completed_at).toBeTruthy();
|
||||
expect(tasks.tasks[0]?.failure?.error).toBeTruthy();
|
||||
}, 240_000);
|
||||
});
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "takt",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0-alpha.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "takt",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0-alpha.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "takt",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.0-alpha.1",
|
||||
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@ -14,8 +14,8 @@
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "npm run test:e2e:all",
|
||||
"test:e2e:mock": "vitest run --config vitest.config.e2e.mock.ts --reporter=verbose",
|
||||
"test:e2e": "npm run test:e2e:mock; code=$?; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code",
|
||||
"test:e2e:mock": "TAKT_E2E_PROVIDER=mock vitest run --config vitest.config.e2e.mock.ts --reporter=verbose",
|
||||
"test:e2e:all": "npm run test:e2e:mock && npm run test:e2e:provider",
|
||||
"test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex",
|
||||
"test:e2e:provider:claude": "TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.provider.ts --reporter=verbose",
|
||||
|
||||
72
src/__tests__/LogManager.test.ts
Normal file
72
src/__tests__/LogManager.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('chalk', () => {
|
||||
const passthrough = (value: string) => value;
|
||||
const bold = Object.assign((value: string) => value, {
|
||||
cyan: (value: string) => value,
|
||||
});
|
||||
|
||||
return {
|
||||
default: {
|
||||
gray: passthrough,
|
||||
blue: passthrough,
|
||||
yellow: passthrough,
|
||||
red: passthrough,
|
||||
green: passthrough,
|
||||
white: passthrough,
|
||||
bold,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { LogManager } from '../shared/ui/LogManager.js';
|
||||
|
||||
describe('LogManager', () => {
|
||||
beforeEach(() => {
|
||||
// Given: テスト間でシングルトン状態が共有されないようにする
|
||||
LogManager.resetInstance();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should filter by info level as debug=false, info=true, error=true', () => {
|
||||
// Given: ログレベルが info
|
||||
const manager = LogManager.getInstance();
|
||||
manager.setLogLevel('info');
|
||||
|
||||
// When: 各レベルの出力可否を判定する
|
||||
const debugResult = manager.shouldLog('debug');
|
||||
const infoResult = manager.shouldLog('info');
|
||||
const errorResult = manager.shouldLog('error');
|
||||
|
||||
// Then: info基準のフィルタリングが適用される
|
||||
expect(debugResult).toBe(false);
|
||||
expect(infoResult).toBe(true);
|
||||
expect(errorResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect level change after setLogLevel', () => {
|
||||
// Given: 初期レベル(info)
|
||||
const manager = LogManager.getInstance();
|
||||
|
||||
// When: warn レベルに変更する
|
||||
manager.setLogLevel('warn');
|
||||
|
||||
// Then: info は抑制され warn は出力対象になる
|
||||
expect(manager.shouldLog('info')).toBe(false);
|
||||
expect(manager.shouldLog('warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear singleton state when resetInstance is called', () => {
|
||||
// Given: エラーレベルに変更済みのインスタンス
|
||||
const first = LogManager.getInstance();
|
||||
first.setLogLevel('error');
|
||||
expect(first.shouldLog('info')).toBe(false);
|
||||
|
||||
// When: シングルトンをリセットして再取得する
|
||||
LogManager.resetInstance();
|
||||
const second = LogManager.getInstance();
|
||||
|
||||
// Then: 新しいインスタンスは初期レベルに戻る
|
||||
expect(second.shouldLog('info')).toBe(true);
|
||||
});
|
||||
});
|
||||
68
src/__tests__/abort-signal.test.ts
Normal file
68
src/__tests__/abort-signal.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAbortSignal } from '../core/piece/engine/abort-signal.js';
|
||||
|
||||
describe('buildAbortSignal', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('タイムアウトでabortされる', () => {
|
||||
const { signal, dispose } = buildAbortSignal(100, undefined);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(signal.reason).toBeInstanceOf(Error);
|
||||
expect((signal.reason as Error).message).toBe('Part timeout after 100ms');
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('親シグナルがabortされると子シグナルへ伝搬する', () => {
|
||||
const parent = new AbortController();
|
||||
const { signal, dispose } = buildAbortSignal(1000, parent.signal);
|
||||
const reason = new Error('parent aborted');
|
||||
|
||||
parent.abort(reason);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(signal.reason).toBe(reason);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('disposeでタイマーと親リスナーを解放する', () => {
|
||||
const parent = new AbortController();
|
||||
const addSpy = vi.spyOn(parent.signal, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(parent.signal, 'removeEventListener');
|
||||
const { signal, dispose } = buildAbortSignal(100, parent.signal);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispose();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('親シグナルが既にabort済みなら即時伝搬する', () => {
|
||||
const parent = new AbortController();
|
||||
const reason = new Error('already aborted');
|
||||
const addSpy = vi.spyOn(parent.signal, 'addEventListener');
|
||||
parent.abort(reason);
|
||||
|
||||
const { signal, dispose } = buildAbortSignal(1000, parent.signal);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(signal.reason).toBe(reason);
|
||||
expect(addSpy).not.toHaveBeenCalled();
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
232
src/__tests__/agent-usecases.test.ts
Normal file
232
src/__tests__/agent-usecases.test.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { parseParts } from '../core/piece/engine/task-decomposer.js';
|
||||
import { detectJudgeIndex } from '../agents/judge-utils.js';
|
||||
import {
|
||||
executeAgent,
|
||||
generateReport,
|
||||
executePart,
|
||||
evaluateCondition,
|
||||
judgeStatus,
|
||||
decomposeTask,
|
||||
} from '../core/piece/agent-usecases.js';
|
||||
|
||||
vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/schema-loader.js', () => ({
|
||||
loadJudgmentSchema: vi.fn(() => ({ type: 'judgment' })),
|
||||
loadEvaluationSchema: vi.fn(() => ({ type: 'evaluation' })),
|
||||
loadDecompositionSchema: vi.fn((maxParts: number) => ({ type: 'decomposition', maxParts })),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/engine/task-decomposer.js', () => ({
|
||||
parseParts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agents/judge-utils.js', () => ({
|
||||
buildJudgePrompt: vi.fn(() => 'judge prompt'),
|
||||
detectJudgeIndex: vi.fn(() => -1),
|
||||
}));
|
||||
|
||||
function doneResponse(content: string, structuredOutput?: Record<string, unknown>) {
|
||||
return {
|
||||
persona: 'tester',
|
||||
status: 'done' as const,
|
||||
content,
|
||||
timestamp: new Date('2026-02-12T00:00:00Z'),
|
||||
structuredOutput,
|
||||
};
|
||||
}
|
||||
|
||||
const judgeOptions = { cwd: '/repo', movementName: 'review' };
|
||||
|
||||
describe('agent-usecases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('executeAgent/generateReport/executePart は runAgent に委譲する', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue(doneResponse('ok'));
|
||||
|
||||
await executeAgent('coder', 'do work', { cwd: '/tmp' });
|
||||
await generateReport('coder', 'write report', { cwd: '/tmp' });
|
||||
await executePart('coder', 'part work', { cwd: '/tmp' });
|
||||
|
||||
expect(runAgent).toHaveBeenCalledTimes(3);
|
||||
expect(runAgent).toHaveBeenNthCalledWith(1, 'coder', 'do work', { cwd: '/tmp' });
|
||||
expect(runAgent).toHaveBeenNthCalledWith(2, 'coder', 'write report', { cwd: '/tmp' });
|
||||
expect(runAgent).toHaveBeenNthCalledWith(3, 'coder', 'part work', { cwd: '/tmp' });
|
||||
});
|
||||
|
||||
it('evaluateCondition は構造化出力の matched_index を優先する', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue(doneResponse('ignored', { matched_index: 2 }));
|
||||
|
||||
const result = await evaluateCondition('agent output', [
|
||||
{ index: 0, text: 'first' },
|
||||
{ index: 1, text: 'second' },
|
||||
], { cwd: '/repo' });
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(runAgent).toHaveBeenCalledWith(undefined, 'judge prompt', expect.objectContaining({
|
||||
cwd: '/repo',
|
||||
outputSchema: { type: 'evaluation' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('evaluateCondition は構造化出力が使えない場合にタグ検出へフォールバックする', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue(doneResponse('[JUDGE:2]'));
|
||||
vi.mocked(detectJudgeIndex).mockReturnValue(1);
|
||||
|
||||
const result = await evaluateCondition('agent output', [
|
||||
{ index: 0, text: 'first' },
|
||||
{ index: 1, text: 'second' },
|
||||
], { cwd: '/repo' });
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]');
|
||||
});
|
||||
|
||||
it('evaluateCondition は runAgent が done 以外なら -1 を返す', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue({
|
||||
persona: 'tester',
|
||||
status: 'error',
|
||||
content: 'failed',
|
||||
timestamp: new Date('2026-02-12T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await evaluateCondition('agent output', [
|
||||
{ index: 0, text: 'first' },
|
||||
], { cwd: '/repo' });
|
||||
|
||||
expect(result).toBe(-1);
|
||||
expect(detectJudgeIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- judgeStatus: 3-stage fallback ---
|
||||
|
||||
it('judgeStatus は単一ルール時に auto_select を返す', async () => {
|
||||
const result = await judgeStatus('structured', 'tag', [{ condition: 'always', next: 'done' }], judgeOptions);
|
||||
|
||||
expect(result).toEqual({ ruleIndex: 0, method: 'auto_select' });
|
||||
expect(runAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('judgeStatus はルールが空ならエラー', async () => {
|
||||
await expect(judgeStatus('structured', 'tag', [], judgeOptions))
|
||||
.rejects.toThrow('judgeStatus requires at least one rule');
|
||||
});
|
||||
|
||||
it('judgeStatus は Stage 1 で構造化出力 step を採用する', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('x', { step: 2 }));
|
||||
|
||||
const result = await judgeStatus('structured', 'tag', [
|
||||
{ condition: 'a', next: 'one' },
|
||||
{ condition: 'b', next: 'two' },
|
||||
], judgeOptions);
|
||||
|
||||
expect(result).toEqual({ ruleIndex: 1, method: 'structured_output' });
|
||||
expect(runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(runAgent).toHaveBeenCalledWith('conductor', 'structured', expect.objectContaining({
|
||||
outputSchema: { type: 'judgment' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('judgeStatus は Stage 2 でタグ検出を使う', async () => {
|
||||
// Stage 1: structured output fails (no structuredOutput)
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
|
||||
// Stage 2: tag detection succeeds
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('[REVIEW:2]'));
|
||||
|
||||
const result = await judgeStatus('structured', 'tag', [
|
||||
{ condition: 'a', next: 'one' },
|
||||
{ condition: 'b', next: 'two' },
|
||||
], judgeOptions);
|
||||
|
||||
expect(result).toEqual({ ruleIndex: 1, method: 'phase3_tag' });
|
||||
expect(runAgent).toHaveBeenCalledTimes(2);
|
||||
expect(runAgent).toHaveBeenNthCalledWith(1, 'conductor', 'structured', expect.objectContaining({
|
||||
outputSchema: { type: 'judgment' },
|
||||
}));
|
||||
expect(runAgent).toHaveBeenNthCalledWith(2, 'conductor', 'tag', expect.not.objectContaining({
|
||||
outputSchema: expect.anything(),
|
||||
}));
|
||||
});
|
||||
|
||||
it('judgeStatus は Stage 3 で AI Judge を使う', async () => {
|
||||
// Stage 1: structured output fails
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
|
||||
// Stage 2: tag detection fails
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
|
||||
// Stage 3: evaluateCondition succeeds
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('ignored', { matched_index: 2 }));
|
||||
|
||||
const result = await judgeStatus('structured', 'tag', [
|
||||
{ condition: 'a', next: 'one' },
|
||||
{ condition: 'b', next: 'two' },
|
||||
], judgeOptions);
|
||||
|
||||
expect(result).toEqual({ ruleIndex: 1, method: 'ai_judge' });
|
||||
expect(runAgent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('judgeStatus は全ての判定に失敗したらエラー', async () => {
|
||||
// Stage 1: structured output fails
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
|
||||
// Stage 2: tag detection fails
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
|
||||
// Stage 3: evaluateCondition fails
|
||||
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('still no match'));
|
||||
vi.mocked(detectJudgeIndex).mockReturnValue(-1);
|
||||
|
||||
await expect(judgeStatus('structured', 'tag', [
|
||||
{ condition: 'a', next: 'one' },
|
||||
{ condition: 'b', next: 'two' },
|
||||
], judgeOptions)).rejects.toThrow('Status not found for movement "review"');
|
||||
});
|
||||
|
||||
// --- decomposeTask ---
|
||||
|
||||
it('decomposeTask は構造化出力 parts を返す', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', {
|
||||
parts: [
|
||||
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeout_ms: 1000 },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await decomposeTask('instruction', 3, { cwd: '/repo', persona: 'team-leader' });
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeoutMs: 1000 },
|
||||
]);
|
||||
expect(parseParts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decomposeTask は構造化出力がない場合 parseParts にフォールバックする', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue(doneResponse('```json [] ```'));
|
||||
vi.mocked(parseParts).mockReturnValue([
|
||||
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
|
||||
]);
|
||||
|
||||
const result = await decomposeTask('instruction', 2, { cwd: '/repo' });
|
||||
|
||||
expect(parseParts).toHaveBeenCalledWith('```json [] ```', 2);
|
||||
expect(result).toEqual([
|
||||
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decomposeTask は done 以外をエラーにする', async () => {
|
||||
vi.mocked(runAgent).mockResolvedValue({
|
||||
persona: 'team-leader',
|
||||
status: 'error',
|
||||
content: 'failure',
|
||||
error: 'bad output',
|
||||
timestamp: new Date('2026-02-12T00:00:00Z'),
|
||||
});
|
||||
|
||||
await expect(decomposeTask('instruction', 2, { cwd: '/repo' }))
|
||||
.rejects.toThrow('Team leader failed: bad output');
|
||||
});
|
||||
});
|
||||
@ -6,17 +6,9 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { handleBlocked } from '../core/piece/engine/blocked-handler.js';
|
||||
import type { PieceMovement, AgentResponse } from '../core/models/types.js';
|
||||
import type { AgentResponse } from '../core/models/types.js';
|
||||
import type { PieceEngineOptions } from '../core/piece/types.js';
|
||||
|
||||
function makeMovement(): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
};
|
||||
}
|
||||
import { makeMovement } from './test-helpers.js';
|
||||
|
||||
function makeResponse(content: string): AgentResponse {
|
||||
return {
|
||||
|
||||
78
src/__tests__/branchGitCommands.test.ts
Normal file
78
src/__tests__/branchGitCommands.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parseDistinctHashes, runGit } from '../infra/task/branchGitCommands.js';
|
||||
|
||||
const mockExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
describe('parseDistinctHashes', () => {
|
||||
it('should remove only consecutive duplicates', () => {
|
||||
// Given: 連続重複と非連続重複を含む出力
|
||||
const output = 'a\na\nb\nb\na\n';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 連続重複のみ除去される
|
||||
expect(result).toEqual(['a', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('should return empty array when output is empty', () => {
|
||||
// Given: 空文字列
|
||||
const output = '';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 空配列を返す
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should trim each line and drop blank lines', () => {
|
||||
// Given: 前後空白と空行を含む出力
|
||||
const output = ' hash1 \n\n hash2\n \n';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: トリム済みの値のみ残る
|
||||
expect(result).toEqual(['hash1', 'hash2']);
|
||||
});
|
||||
|
||||
it('should return single hash as one-element array', () => {
|
||||
// Given: 単一ハッシュ
|
||||
const output = 'single-hash';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 1件配列として返る
|
||||
expect(result).toEqual(['single-hash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute git command with expected options and trim output', () => {
|
||||
// Given: gitコマンドのモック応答
|
||||
mockExecFileSync.mockReturnValue(' abc123 \n' as never);
|
||||
|
||||
// When: runGit を実行する
|
||||
const result = runGit('/repo', ['rev-parse', 'HEAD']);
|
||||
|
||||
// Then: execFileSync が正しい引数で呼ばれ、trimされた値を返す
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: '/repo',
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
});
|
||||
@ -46,7 +46,7 @@ function setupRepoForIssue167(options?: { disableReflog?: boolean; firstBranchCo
|
||||
writeAndCommit(repoDir, 'develop-takt.txt', 'develop takt\n', 'takt: old instruction on develop');
|
||||
writeAndCommit(repoDir, 'develop-b.txt', 'develop b\n', 'develop commit B');
|
||||
|
||||
const taktBranch = 'takt/#167/fix-original-instruction';
|
||||
const taktBranch = 'takt/167/fix-original-instruction';
|
||||
runGit(repoDir, ['checkout', '-b', taktBranch]);
|
||||
const firstBranchCommitMessage = options?.firstBranchCommitMessage ?? 'takt: github-issue-167-fix-original-instruction';
|
||||
writeAndCommit(repoDir, 'task-1.txt', 'task1\n', firstBranchCommitMessage);
|
||||
|
||||
89
src/__tests__/claude-executor-abort-signal.test.ts
Normal file
89
src/__tests__/claude-executor-abort-signal.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
queryMock,
|
||||
interruptMock,
|
||||
AbortErrorMock,
|
||||
} = vi.hoisted(() => {
|
||||
const interruptMock = vi.fn(async () => {});
|
||||
class AbortErrorMock extends Error {}
|
||||
const queryMock = vi.fn(() => {
|
||||
let interrupted = false;
|
||||
interruptMock.mockImplementation(async () => {
|
||||
interrupted = true;
|
||||
});
|
||||
|
||||
return {
|
||||
interrupt: interruptMock,
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<never, void, unknown> {
|
||||
while (!interrupted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new AbortErrorMock('aborted');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
queryMock,
|
||||
interruptMock,
|
||||
AbortErrorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
||||
query: queryMock,
|
||||
AbortError: AbortErrorMock,
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../shared/utils/index.js')>();
|
||||
return {
|
||||
...original,
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { QueryExecutor } from '../infra/claude/executor.js';
|
||||
|
||||
describe('QueryExecutor abortSignal wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('abortSignal 発火時に query.interrupt() を呼ぶ', async () => {
|
||||
const controller = new AbortController();
|
||||
const executor = new QueryExecutor();
|
||||
|
||||
const promise = executor.execute('test', {
|
||||
cwd: '/tmp/project',
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
controller.abort();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.interrupted).toBe(true);
|
||||
});
|
||||
|
||||
it('開始前に中断済みの signal でも query.interrupt() を呼ぶ', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', {
|
||||
cwd: '/tmp/project',
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.interrupted).toBe(true);
|
||||
});
|
||||
});
|
||||
164
src/__tests__/claude-executor-structured-output.test.ts
Normal file
164
src/__tests__/claude-executor-structured-output.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Claude SDK layer structured output tests.
|
||||
*
|
||||
* Tests two internal components:
|
||||
* 1. SdkOptionsBuilder — outputSchema → outputFormat conversion
|
||||
* 2. QueryExecutor — structured_output extraction from SDK result messages
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ===== SdkOptionsBuilder tests (no mock needed) =====
|
||||
|
||||
import { buildSdkOptions } from '../infra/claude/options-builder.js';
|
||||
|
||||
describe('SdkOptionsBuilder — outputFormat 変換', () => {
|
||||
it('outputSchema が outputFormat に変換される', () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
const sdkOptions = buildSdkOptions({ cwd: '/tmp', outputSchema: schema });
|
||||
|
||||
expect((sdkOptions as Record<string, unknown>).outputFormat).toEqual({
|
||||
type: 'json_schema',
|
||||
schema,
|
||||
});
|
||||
});
|
||||
|
||||
it('outputSchema 未設定なら outputFormat は含まれない', () => {
|
||||
const sdkOptions = buildSdkOptions({ cwd: '/tmp' });
|
||||
expect(sdkOptions).not.toHaveProperty('outputFormat');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== QueryExecutor tests (mock @anthropic-ai/claude-agent-sdk) =====
|
||||
|
||||
const { mockQuery } = vi.hoisted(() => ({
|
||||
mockQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
||||
query: mockQuery,
|
||||
AbortError: class AbortError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'AbortError';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// QueryExecutor は executor.ts 内で query() を使うため、mock 後にインポート
|
||||
const { QueryExecutor } = await import('../infra/claude/executor.js');
|
||||
|
||||
/**
|
||||
* query() が返す Query オブジェクト(async iterable + interrupt)のモック
|
||||
*/
|
||||
function createMockQuery(messages: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const msg of messages) {
|
||||
yield msg;
|
||||
}
|
||||
},
|
||||
interrupt: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('QueryExecutor — structuredOutput 抽出', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('result メッセージの structured_output (snake_case) を抽出する', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{ type: 'result', subtype: 'success', result: 'done', structured_output: { step: 2 } },
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.structuredOutput).toEqual({ step: 2 });
|
||||
});
|
||||
|
||||
it('result メッセージの structuredOutput (camelCase) を抽出する', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{ type: 'result', subtype: 'success', result: 'done', structuredOutput: { step: 3 } },
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.structuredOutput).toEqual({ step: 3 });
|
||||
});
|
||||
|
||||
it('structured_output が snake_case 優先 (snake_case と camelCase 両方ある場合)', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: 'done',
|
||||
structured_output: { step: 1 },
|
||||
structuredOutput: { step: 9 },
|
||||
},
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.structuredOutput).toEqual({ step: 1 });
|
||||
});
|
||||
|
||||
it('structuredOutput がない場合は undefined', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{ type: 'result', subtype: 'success', result: 'plain text' },
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('structured_output が配列の場合は無視する', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{ type: 'result', subtype: 'success', result: 'done', structured_output: [1, 2, 3] },
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('structured_output が null の場合は無視する', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{ type: 'result', subtype: 'success', result: 'done', structured_output: null },
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('assistant テキストと structured_output を同時に取得する', async () => {
|
||||
mockQuery.mockReturnValue(createMockQuery([
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: 'thinking...' }] },
|
||||
},
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: 'final text',
|
||||
structured_output: { step: 1, reason: 'approved' },
|
||||
},
|
||||
]));
|
||||
|
||||
const executor = new QueryExecutor();
|
||||
const result = await executor.execute('test', { cwd: '/tmp' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toBe('final text');
|
||||
expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' });
|
||||
});
|
||||
});
|
||||
52
src/__tests__/claude-provider-abort-signal.test.ts
Normal file
52
src/__tests__/claude-provider-abort-signal.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentSetup } from '../infra/providers/types.js';
|
||||
|
||||
const {
|
||||
mockCallClaude,
|
||||
mockResolveAnthropicApiKey,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCallClaude: vi.fn(),
|
||||
mockResolveAnthropicApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/claude/client.js', () => ({
|
||||
callClaude: mockCallClaude,
|
||||
callClaudeCustom: vi.fn(),
|
||||
callClaudeAgent: vi.fn(),
|
||||
callClaudeSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
resolveAnthropicApiKey: mockResolveAnthropicApiKey,
|
||||
}));
|
||||
|
||||
import { ClaudeProvider } from '../infra/providers/claude.js';
|
||||
|
||||
describe('ClaudeProvider abortSignal wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveAnthropicApiKey.mockReturnValue(undefined);
|
||||
mockCallClaude.mockResolvedValue({
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
content: 'ok',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
it('ProviderCallOptions.abortSignal を Claude call options に渡す', async () => {
|
||||
const provider = new ClaudeProvider();
|
||||
const setup: AgentSetup = { name: 'coder' };
|
||||
const agent = provider.setup(setup);
|
||||
const controller = new AbortController();
|
||||
|
||||
await agent.call('test prompt', {
|
||||
cwd: '/tmp/project',
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(mockCallClaude).toHaveBeenCalledTimes(1);
|
||||
const callOptions = mockCallClaude.mock.calls[0]?.[2];
|
||||
expect(callOptions).toHaveProperty('abortSignal', controller.signal);
|
||||
});
|
||||
});
|
||||
@ -3,10 +3,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectRuleIndex,
|
||||
isRegexSafe,
|
||||
} from '../infra/claude/client.js';
|
||||
import { isRegexSafe } from '../infra/claude/utils.js';
|
||||
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
|
||||
|
||||
describe('isRegexSafe', () => {
|
||||
it('should accept simple patterns', () => {
|
||||
|
||||
@ -207,7 +207,7 @@ describe('branch and worktree path formatting with issue numbers', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('should format branch as takt/#{issue}/{slug} when issue number is provided', () => {
|
||||
it('should format branch as takt/{issue}/{slug} when issue number is provided', () => {
|
||||
// Given: issue number 99 with slug
|
||||
setupMockForPathTest();
|
||||
|
||||
@ -219,7 +219,7 @@ describe('branch and worktree path formatting with issue numbers', () => {
|
||||
});
|
||||
|
||||
// Then: branch should use issue format
|
||||
expect(result.branch).toBe('takt/#99/fix-login-timeout');
|
||||
expect(result.branch).toBe('takt/99/fix-login-timeout');
|
||||
});
|
||||
|
||||
it('should format branch as takt/{timestamp}-{slug} when no issue number', () => {
|
||||
|
||||
152
src/__tests__/codex-structured-output.test.ts
Normal file
152
src/__tests__/codex-structured-output.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Codex SDK layer structured output tests.
|
||||
*
|
||||
* Tests CodexClient's extraction of structuredOutput by parsing
|
||||
* JSON text from agent_message items when outputSchema is provided.
|
||||
*
|
||||
* Codex SDK returns structured output as JSON text in agent_message
|
||||
* items (not via turn.completed.finalResponse which doesn't exist
|
||||
* on TurnCompletedEvent).
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ===== Codex SDK mock =====
|
||||
|
||||
let mockEvents: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock('@openai/codex-sdk', () => {
|
||||
return {
|
||||
Codex: class MockCodex {
|
||||
async startThread() {
|
||||
return {
|
||||
id: 'thread-mock',
|
||||
runStreamed: async () => ({
|
||||
events: (async function* () {
|
||||
for (const event of mockEvents) {
|
||||
yield event;
|
||||
}
|
||||
})(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
async resumeThread() {
|
||||
return this.startThread();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// CodexClient は @openai/codex-sdk をインポートするため、mock 後にインポート
|
||||
const { CodexClient } = await import('../infra/codex/client.js');
|
||||
|
||||
describe('CodexClient — structuredOutput 抽出', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEvents = [];
|
||||
});
|
||||
|
||||
it('outputSchema 指定時に agent_message の JSON テキストを structuredOutput として返す', async () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 2, "reason": "approved"}' },
|
||||
},
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
|
||||
|
||||
expect(result.status).toBe('done');
|
||||
expect(result.structuredOutput).toEqual({ step: 2, reason: 'approved' });
|
||||
});
|
||||
|
||||
it('outputSchema なしの場合はテキストを JSON パースしない', async () => {
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 2}' },
|
||||
},
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', { cwd: '/tmp' });
|
||||
|
||||
expect(result.status).toBe('done');
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('agent_message が JSON でない場合は undefined', async () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: { id: 'msg-1', type: 'agent_message', text: 'plain text response' },
|
||||
},
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
|
||||
|
||||
expect(result.status).toBe('done');
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('JSON が配列の場合は無視する', async () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: { id: 'msg-1', type: 'agent_message', text: '[1, 2, 3]' },
|
||||
},
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
|
||||
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('agent_message がない場合は structuredOutput なし', async () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
|
||||
|
||||
expect(result.status).toBe('done');
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('outputSchema 付きで呼び出して structuredOutput が返る', async () => {
|
||||
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
|
||||
mockEvents = [
|
||||
{ type: 'thread.started', thread_id: 'thread-1' },
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 1}' },
|
||||
},
|
||||
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
|
||||
];
|
||||
|
||||
const client = new CodexClient();
|
||||
const result = await client.call('coder', 'prompt', {
|
||||
cwd: '/tmp',
|
||||
outputSchema: schema,
|
||||
});
|
||||
|
||||
expect(result.structuredOutput).toEqual({ step: 1 });
|
||||
});
|
||||
});
|
||||
@ -76,7 +76,7 @@ describe('createIsolatedEnv', () => {
|
||||
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-');
|
||||
});
|
||||
|
||||
it('should create config.yaml from E2E fixture with notification_sound timing controls', () => {
|
||||
it('should create config.yaml from E2E fixture with notification_sound disabled', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
@ -86,13 +86,13 @@ describe('createIsolatedEnv', () => {
|
||||
expect(config.language).toBe('en');
|
||||
expect(config.log_level).toBe('info');
|
||||
expect(config.default_piece).toBe('default');
|
||||
expect(config.notification_sound).toBe(true);
|
||||
expect(config.notification_sound).toBe(false);
|
||||
expect(config.notification_sound_events).toEqual({
|
||||
iteration_limit: false,
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: true,
|
||||
run_abort: true,
|
||||
run_abort: false,
|
||||
});
|
||||
});
|
||||
|
||||
@ -120,13 +120,13 @@ describe('createIsolatedEnv', () => {
|
||||
|
||||
expect(config.provider).toBe('mock');
|
||||
expect(config.concurrency).toBe(2);
|
||||
expect(config.notification_sound).toBe(true);
|
||||
expect(config.notification_sound).toBe(false);
|
||||
expect(config.notification_sound_events).toEqual({
|
||||
iteration_limit: false,
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: true,
|
||||
run_abort: true,
|
||||
run_abort: false,
|
||||
});
|
||||
expect(config.language).toBe('en');
|
||||
});
|
||||
@ -149,7 +149,7 @@ describe('createIsolatedEnv', () => {
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: false,
|
||||
run_abort: true,
|
||||
run_abort: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
@ -21,7 +21,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async () => {
|
||||
|
||||
@ -23,7 +23,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -36,7 +36,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
import { PieceEngine } from '../core/piece/index.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../core/piece/index.js';
|
||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||
import {
|
||||
makeResponse,
|
||||
makeMovement,
|
||||
|
||||
@ -28,7 +28,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
@ -27,7 +27,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
@ -23,7 +23,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -35,7 +35,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
import { PieceEngine } from '../core/piece/index.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../core/piece/index.js';
|
||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||
import {
|
||||
makeResponse,
|
||||
makeMovement,
|
||||
|
||||
@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
172
src/__tests__/engine-team-leader.test.ts
Normal file
172
src/__tests__/engine-team-leader.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||
import { PieceEngine } from '../core/piece/engine/PieceEngine.js';
|
||||
import { makeMovement, makeRule, makeResponse, createTestTmpDir, applyDefaultMocks } from './engine-test-helpers.js';
|
||||
import type { PieceConfig } from '../core/models/index.js';
|
||||
|
||||
vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
||||
}));
|
||||
|
||||
function buildTeamLeaderConfig(): PieceConfig {
|
||||
return {
|
||||
name: 'team-leader-piece',
|
||||
initialMovement: 'implement',
|
||||
maxMovements: 5,
|
||||
movements: [
|
||||
makeMovement('implement', {
|
||||
instructionTemplate: 'Task: {task}',
|
||||
teamLeader: {
|
||||
persona: '../personas/team-leader.md',
|
||||
maxParts: 3,
|
||||
timeoutMs: 10000,
|
||||
partPersona: '../personas/coder.md',
|
||||
partAllowedTools: ['Read', 'Edit', 'Write'],
|
||||
partEdit: true,
|
||||
partPermissionMode: 'edit',
|
||||
},
|
||||
rules: [makeRule('done', 'COMPLETE')],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('PieceEngine Integration: TeamLeaderRunner', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
applyDefaultMocks();
|
||||
tmpDir = createTestTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tmpDir)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('team leaderが分解したパートを並列実行し集約する', async () => {
|
||||
const config = buildTeamLeaderConfig();
|
||||
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
|
||||
|
||||
vi.mocked(runAgent)
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
persona: 'team-leader',
|
||||
content: [
|
||||
'```json',
|
||||
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
|
||||
'```',
|
||||
].join('\n'),
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'API done' }))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'Tests done' }));
|
||||
|
||||
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(3);
|
||||
const output = state.movementOutputs.get('implement');
|
||||
expect(output).toBeDefined();
|
||||
expect(output!.content).toContain('## decomposition');
|
||||
expect(output!.content).toContain('## part-1: API');
|
||||
expect(output!.content).toContain('API done');
|
||||
expect(output!.content).toContain('## part-2: Test');
|
||||
expect(output!.content).toContain('Tests done');
|
||||
});
|
||||
|
||||
it('全パートが失敗した場合はムーブメント失敗として中断する', async () => {
|
||||
const config = buildTeamLeaderConfig();
|
||||
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
|
||||
|
||||
vi.mocked(runAgent)
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
persona: 'team-leader',
|
||||
content: [
|
||||
'```json',
|
||||
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
|
||||
'```',
|
||||
].join('\n'),
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'api failed' }))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'test failed' }));
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('aborted');
|
||||
});
|
||||
|
||||
it('一部パートが失敗しても成功パートがあれば集約結果は完了する', async () => {
|
||||
const config = buildTeamLeaderConfig();
|
||||
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
|
||||
|
||||
vi.mocked(runAgent)
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
persona: 'team-leader',
|
||||
content: [
|
||||
'```json',
|
||||
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
|
||||
'```',
|
||||
].join('\n'),
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'API done' }))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'test failed' }));
|
||||
|
||||
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
const output = state.movementOutputs.get('implement');
|
||||
expect(output).toBeDefined();
|
||||
expect(output!.content).toContain('## part-1: API');
|
||||
expect(output!.content).toContain('API done');
|
||||
expect(output!.content).toContain('## part-2: Test');
|
||||
expect(output!.content).toContain('[ERROR] test failed');
|
||||
});
|
||||
|
||||
it('パート失敗時にerrorがなくてもcontentの詳細をエラー表示に使う', async () => {
|
||||
const config = buildTeamLeaderConfig();
|
||||
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
|
||||
|
||||
vi.mocked(runAgent)
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
persona: 'team-leader',
|
||||
content: [
|
||||
'```json',
|
||||
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
|
||||
'```',
|
||||
].join('\n'),
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', content: 'api failed from content' }))
|
||||
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'Tests done' }));
|
||||
|
||||
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
|
||||
|
||||
const state = await engine.run();
|
||||
|
||||
expect(state.status).toBe('completed');
|
||||
const output = state.movementOutputs.get('implement');
|
||||
expect(output).toBeDefined();
|
||||
expect(output!.content).toContain('[ERROR] api failed from content');
|
||||
});
|
||||
});
|
||||
@ -10,18 +10,21 @@ import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PieceConfig, PieceMovement, AgentResponse, PieceRule } from '../core/models/index.js';
|
||||
import type { PieceConfig, PieceMovement, AgentResponse } from '../core/models/index.js';
|
||||
import { makeRule } from './test-helpers.js';
|
||||
|
||||
// --- Mock imports (consumers must call vi.mock before importing this) ---
|
||||
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../core/piece/index.js';
|
||||
import type { RuleMatch } from '../core/piece/index.js';
|
||||
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/index.js';
|
||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||
import type { RuleMatch } from '../core/piece/evaluation/index.js';
|
||||
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/phase-runner.js';
|
||||
import { generateReportDir } from '../shared/utils/index.js';
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
export { makeRule };
|
||||
|
||||
export function makeResponse(overrides: Partial<AgentResponse> = {}): AgentResponse {
|
||||
return {
|
||||
persona: 'test-agent',
|
||||
@ -33,10 +36,6 @@ export function makeResponse(overrides: Partial<AgentResponse> = {}): AgentRespo
|
||||
};
|
||||
}
|
||||
|
||||
export function makeRule(condition: string, next: string, extra: Partial<PieceRule> = {}): PieceRule {
|
||||
return { condition, next, ...extra };
|
||||
}
|
||||
|
||||
export function makeMovement(name: string, overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name,
|
||||
@ -174,7 +173,7 @@ export function createTestTmpDir(): string {
|
||||
export function applyDefaultMocks(): void {
|
||||
vi.mocked(needsStatusJudgmentPhase).mockReturnValue(false);
|
||||
vi.mocked(runReportPhase).mockResolvedValue(undefined);
|
||||
vi.mocked(runStatusJudgmentPhase).mockResolvedValue('');
|
||||
vi.mocked(runStatusJudgmentPhase).mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' });
|
||||
vi.mocked(generateReportDir).mockReturnValue('test-report-dir');
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -35,7 +35,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { PieceEngine } from '../core/piece/index.js';
|
||||
import { runReportPhase } from '../core/piece/index.js';
|
||||
import { runReportPhase } from '../core/piece/phase-runner.js';
|
||||
import {
|
||||
makeResponse,
|
||||
makeMovement,
|
||||
|
||||
@ -9,31 +9,7 @@ import {
|
||||
escapeTemplateChars,
|
||||
replaceTemplatePlaceholders,
|
||||
} from '../core/piece/instruction/escape.js';
|
||||
import type { PieceMovement } from '../core/models/types.js';
|
||||
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
|
||||
return {
|
||||
task: 'test task',
|
||||
iteration: 1,
|
||||
maxMovements: 10,
|
||||
movementIteration: 1,
|
||||
cwd: '/tmp/test',
|
||||
projectCwd: '/tmp/project',
|
||||
userInputs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
import { makeMovement, makeInstructionContext } from './test-helpers.js';
|
||||
|
||||
describe('escapeTemplateChars', () => {
|
||||
it('should replace curly braces with full-width equivalents', () => {
|
||||
@ -62,7 +38,7 @@ describe('escapeTemplateChars', () => {
|
||||
describe('replaceTemplatePlaceholders', () => {
|
||||
it('should replace {task} placeholder', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ task: 'implement feature X' });
|
||||
const ctx = makeInstructionContext({ task: 'implement feature X' });
|
||||
const template = 'Your task is: {task}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -71,7 +47,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should escape braces in task content', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ task: 'fix {bug} in code' });
|
||||
const ctx = makeInstructionContext({ task: 'fix {bug} in code' });
|
||||
const template = '{task}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -80,7 +56,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {iteration} and {max_movements}', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ iteration: 3, maxMovements: 20 });
|
||||
const ctx = makeInstructionContext({ iteration: 3, maxMovements: 20 });
|
||||
const template = 'Iteration {iteration}/{max_movements}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -89,7 +65,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {movement_iteration}', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ movementIteration: 5 });
|
||||
const ctx = makeInstructionContext({ movementIteration: 5 });
|
||||
const template = 'Movement run #{movement_iteration}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -98,7 +74,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {previous_response} when passPreviousResponse is true', () => {
|
||||
const step = makeMovement({ passPreviousResponse: true });
|
||||
const ctx = makeContext({
|
||||
const ctx = makeInstructionContext({
|
||||
previousOutput: {
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
@ -114,7 +90,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should prefer preprocessed previous response text when provided', () => {
|
||||
const step = makeMovement({ passPreviousResponse: true });
|
||||
const ctx = makeContext({
|
||||
const ctx = makeInstructionContext({
|
||||
previousOutput: {
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
@ -131,7 +107,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {previous_response} with empty string when no previous output', () => {
|
||||
const step = makeMovement({ passPreviousResponse: true });
|
||||
const ctx = makeContext();
|
||||
const ctx = makeInstructionContext();
|
||||
const template = 'Previous: {previous_response}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -140,7 +116,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should not replace {previous_response} when passPreviousResponse is false', () => {
|
||||
const step = makeMovement({ passPreviousResponse: false });
|
||||
const ctx = makeContext({
|
||||
const ctx = makeInstructionContext({
|
||||
previousOutput: {
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
@ -156,7 +132,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {user_inputs} with joined inputs', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ userInputs: ['input 1', 'input 2', 'input 3'] });
|
||||
const ctx = makeInstructionContext({ userInputs: ['input 1', 'input 2', 'input 3'] });
|
||||
const template = 'Inputs: {user_inputs}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -165,7 +141,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {report_dir} with report directory', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports/run-1' });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports/run-1' });
|
||||
const template = 'Reports: {report_dir}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -174,7 +150,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should replace {report:filename} with full path', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
|
||||
const template = 'Read {report:review.md} and {report:plan.md}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
@ -183,7 +159,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should handle template with multiple different placeholders', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({
|
||||
const ctx = makeInstructionContext({
|
||||
task: 'test task',
|
||||
iteration: 2,
|
||||
maxMovements: 5,
|
||||
@ -198,7 +174,7 @@ describe('replaceTemplatePlaceholders', () => {
|
||||
|
||||
it('should leave unreplaced placeholders when reportDir is undefined', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: undefined });
|
||||
const ctx = makeInstructionContext({ reportDir: undefined });
|
||||
const template = 'Dir: {report_dir} File: {report:test.md}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
|
||||
@ -114,6 +114,7 @@ describe('label integrity', () => {
|
||||
expect(() => getLabel('piece.notifyComplete')).not.toThrow();
|
||||
expect(() => getLabel('piece.notifyAbort')).not.toThrow();
|
||||
expect(() => getLabel('piece.sigintGraceful')).not.toThrow();
|
||||
expect(() => getLabel('piece.sigintTimeout')).not.toThrow();
|
||||
expect(() => getLabel('piece.sigintForce')).not.toThrow();
|
||||
});
|
||||
|
||||
|
||||
@ -10,31 +10,8 @@ import {
|
||||
renderReportContext,
|
||||
renderReportOutputInstruction,
|
||||
} from '../core/piece/instruction/InstructionBuilder.js';
|
||||
import type { PieceMovement, OutputContractEntry } from '../core/models/types.js';
|
||||
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
|
||||
return {
|
||||
task: 'test task',
|
||||
iteration: 1,
|
||||
maxMovements: 10,
|
||||
movementIteration: 1,
|
||||
cwd: '/tmp/test',
|
||||
projectCwd: '/tmp/project',
|
||||
userInputs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
import type { OutputContractEntry } from '../core/models/types.js';
|
||||
import { makeMovement, makeInstructionContext } from './test-helpers.js';
|
||||
|
||||
describe('isOutputContractItem', () => {
|
||||
it('should return true for OutputContractItem (has name)', () => {
|
||||
@ -84,19 +61,19 @@ describe('renderReportContext', () => {
|
||||
describe('renderReportOutputInstruction', () => {
|
||||
it('should return empty string when no output contracts', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
|
||||
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when no reportDir', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext();
|
||||
const ctx = makeInstructionContext();
|
||||
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('should render English single-file instruction', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 2 });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports', movementIteration: 2 });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'en');
|
||||
expect(result).toContain('Report output');
|
||||
@ -108,7 +85,7 @@ describe('renderReportOutputInstruction', () => {
|
||||
const step = makeMovement({
|
||||
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
|
||||
});
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'en');
|
||||
expect(result).toContain('Report Files');
|
||||
@ -116,7 +93,7 @@ describe('renderReportOutputInstruction', () => {
|
||||
|
||||
it('should render Japanese single-file instruction', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 1 });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports', movementIteration: 1 });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'ja');
|
||||
expect(result).toContain('レポート出力');
|
||||
@ -128,7 +105,7 @@ describe('renderReportOutputInstruction', () => {
|
||||
const step = makeMovement({
|
||||
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
|
||||
});
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'ja');
|
||||
expect(result).toContain('Report Files');
|
||||
|
||||
@ -14,7 +14,8 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
|
||||
import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js';
|
||||
import { detectRuleIndex } from '../infra/claude/index.js';
|
||||
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
|
||||
import { makeRule } from './test-helpers.js';
|
||||
import { callAiJudge } from '../agents/ai-judge.js';
|
||||
|
||||
// --- Mocks ---
|
||||
@ -30,7 +31,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -56,10 +57,6 @@ import { PieceEngine } from '../core/piece/index.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function makeRule(condition: string, next: string): PieceRule {
|
||||
return { condition, next };
|
||||
}
|
||||
|
||||
function makeMovement(name: string, agentPath: string, rules: PieceRule[]): PieceMovement {
|
||||
return {
|
||||
name,
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { PieceMovement, PieceRule, AgentResponse } from '../core/models/index.js';
|
||||
import type { PieceMovement, AgentResponse } from '../core/models/index.js';
|
||||
import { makeRule } from './test-helpers.js';
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
@ -34,10 +35,6 @@ function buildStatusJudgmentInstruction(movement: PieceMovement, ctx: StatusJudg
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function makeRule(condition: string, next: string, extra?: Partial<PieceRule>): PieceRule {
|
||||
return { condition, next, ...extra };
|
||||
}
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-step',
|
||||
|
||||
@ -104,8 +104,7 @@ vi.mock('../core/piece/index.js', () => ({
|
||||
PieceEngine: MockPieceEngine,
|
||||
}));
|
||||
|
||||
vi.mock('../infra/claude/index.js', () => ({
|
||||
detectRuleIndex: vi.fn(),
|
||||
vi.mock('../infra/claude/query-manager.js', () => ({
|
||||
interruptAllQueries: mockInterruptAllQueries,
|
||||
}));
|
||||
|
||||
|
||||
@ -15,7 +15,8 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
|
||||
import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js';
|
||||
import { detectRuleIndex } from '../infra/claude/index.js';
|
||||
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
|
||||
import { makeRule } from './test-helpers.js';
|
||||
import { callAiJudge } from '../agents/ai-judge.js';
|
||||
|
||||
// --- Mocks (minimal — only infrastructure, not core logic) ---
|
||||
@ -34,7 +35,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -59,10 +60,6 @@ import { PieceEngine } from '../core/piece/index.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function makeRule(condition: string, next: string): PieceRule {
|
||||
return { condition, next };
|
||||
}
|
||||
|
||||
function makeMovement(name: string, agentPath: string, rules: PieceRule[]): PieceMovement {
|
||||
return {
|
||||
name,
|
||||
|
||||
@ -13,7 +13,7 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
|
||||
import { detectRuleIndex } from '../infra/claude/index.js';
|
||||
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
|
||||
import { callAiJudge } from '../agents/ai-judge.js';
|
||||
|
||||
// --- Mocks ---
|
||||
@ -37,7 +37,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
@ -144,7 +144,7 @@ vi.mock('../shared/prompt/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
@ -125,7 +125,7 @@ vi.mock('../shared/prompt/index.js', () => ({
|
||||
vi.mock('../core/piece/phase-runner.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
||||
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
||||
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { PieceMovement, PieceState, PieceRule, AgentResponse } from '../core/models/index.js';
|
||||
import { makeRule } from './test-helpers.js';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
@ -33,16 +34,13 @@ vi.mock('../infra/config/project/projectConfig.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { detectMatchedRule, evaluateAggregateConditions } from '../core/piece/index.js';
|
||||
import { detectRuleIndex } from '../infra/claude/index.js';
|
||||
import { evaluateAggregateConditions } from '../core/piece/index.js';
|
||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
|
||||
import type { RuleMatch, RuleEvaluatorContext } from '../core/piece/index.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function makeRule(condition: string, next: string, extra?: Partial<PieceRule>): PieceRule {
|
||||
return { condition, next, ...extra };
|
||||
}
|
||||
|
||||
function makeMovement(
|
||||
name: string,
|
||||
rules: PieceRule[],
|
||||
|
||||
@ -74,8 +74,8 @@ vi.mock('../core/piece/index.js', () => ({
|
||||
PieceEngine: MockPieceEngine,
|
||||
}));
|
||||
|
||||
vi.mock('../infra/claude/index.js', () => ({
|
||||
detectRuleIndex: vi.fn(),
|
||||
vi.mock('../infra/claude/query-manager.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
interruptAllQueries: mockInterruptAllQueries,
|
||||
}));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user