Skip to content

Commit d4aed26

Browse files
committed
test: audit and fix 18 test quality issues across 15 files
Critical fixes: - wsl-utils: use vi.resetModules for fresh import, error path now tested - workflow-run-store: simplify prune test, assert exactly 20 remain - workflow-store: remove dead testStore.set with matcher-as-key - log-adapters: fresh CodexAdapter per test to prevent state bleed - cost-tracker: assert IPC send instead of weak call count Weak/trivial tests removed: - workflow-engine: delete constant equality + type-only continueOnError tests - skill-scanner: replace no-op cache tests with behavioral verification - project-store: assert decrypted value, not just function call - appStore: replace toBeTruthy with type+format check on session IDs Redundancies consolidated: - agents: remove 3 individual mapping tests (loop test covers all) - node-runners-skill: consolidate 3 identical tests into it.each Edge cases added: - pty-manager: multi-line data chunk for activity line buffer - safeFitAndResize: single-zero-dimension cases (cols=0, rows=0) - workflow-engine-execution: workflow-level event on idle timeout Docs updated: - README: add cost tracking + worktree isolation features, update test count - USER-GUIDE: add cost tracking + worktree isolation sections, bump to v4.8.0 Net: 614 → 610 tests (removed 7 bad, added 3 meaningful) Co-Authored-By: Rooty
1 parent 1407169 commit d4aed26

16 files changed

Lines changed: 207 additions & 132 deletions

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A desktop terminal manager for WSL AI coding agents. Launch, manage, and orchest
66
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react)
77
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript)
88
![License: Elastic-2.0](https://img.shields.io/badge/License-Elastic--2.0-blue.svg)
9-
![Tests](https://img.shields.io/badge/Tests-499_passing-brightgreen)
9+
![Tests](https://img.shields.io/badge/Tests-610_passing-brightgreen)
1010
![CI](https://github.com/Wintersta7e/agentdeck/actions/workflows/ci.yml/badge.svg?branch=main)
1111

1212
## Why AgentDeck?
@@ -28,6 +28,8 @@ AgentDeck provides a unified desktop environment for working with AI coding agen
2828
- **Project Management** - Configure projects with paths, agents, prompt templates, and stack badges
2929
- **Split Terminal Views** - 1/2/3 pane layouts with independent sessions
3030
- **Agentic Workflows** - Visual node-graph editor with conditions, loops, variables, and execution history
31+
- **Cost/Token Tracking** - Live per-session cost and token usage for Claude Code and Codex
32+
- **Git Worktree Isolation** - Per-session git branches with Keep/Discard review flow
3133
- **8 Themes** - 4 dark + 4 light themes with smooth view transitions
3234
- **Command Palette** - Quick access to projects, sessions, templates, and tools
3335

@@ -76,6 +78,21 @@ AgentDeck provides a unified desktop environment for working with AI coding agen
7678
- **Agent Visibility** - Toggle which agents appear on the home screen
7779
- **Keyboard Shortcuts** - Full shortcut reference (Ctrl+/)
7880

81+
### Cost/Token Tracking
82+
83+
- **Live Cost Badge** - Per-session USD cost and token count in the pane topbar
84+
- **Claude Code Support** - Parses JSONL session logs with cache-aware pricing (write 1.25x, read 0.1x)
85+
- **Codex CLI Support** - Parses JSONL rollout logs with per-model pricing maps
86+
- **Tooltip Breakdown** - Hover for input, output, cache read, and cache write token counts
87+
- **Automatic Discovery** - Finds active log files via WSL polling, no configuration needed
88+
89+
### Git Worktree Isolation
90+
91+
- **Per-Session Branches** - Each agent session gets its own git worktree and branch
92+
- **Branch Badge** - Active branch shown in pane topbar
93+
- **Review Flow** - Inspect changes on close, then Keep (merge) / Discard / Cancel
94+
- **Automatic Cleanup** - Orphaned worktrees pruned on startup
95+
7996
### Agent Updates
8097

8198
- **Version Checking** - Startup notification when agent updates are available
@@ -133,7 +150,7 @@ npm run dev
133150
# Build for production (validates TypeScript)
134151
npm run build
135152

136-
# Run tests (499 tests)
153+
# Run tests (610 tests)
137154
npm test
138155

139156
# Lint code (zero-warning policy)
@@ -158,13 +175,17 @@ Output: `dist/AgentDeck-{version}-portable.exe` (~89 MB)
158175
src/
159176
├── main/ # Electron main process
160177
│ ├── index.ts # App lifecycle, IPC handler registration
161-
│ ├── ipc/ # 6 IPC modules (pty, window, agents, projects, workflows, utils)
178+
│ ├── ipc/ # 8 IPC modules (pty, window, agents, projects, workflows, skills, worktree, utils)
162179
│ ├── pty-manager.ts # node-pty: spawn, resize, kill, activity parsing
163180
│ ├── workflow-engine.ts # Edge-activation scheduler DAG execution
164181
│ ├── edge-scheduler.ts # Pure scheduler: ready queue, branching, skip, loop reset
165182
│ ├── variable-substitution.ts # {{VAR}} replacement in workflow nodes
166183
│ ├── workflow-run-store.ts # Execution history persistence
167184
│ ├── agent-updater.ts # Agent version checking and updating via WSL
185+
│ ├── log-adapters.ts # Claude + Codex JSONL cost/token parsing
186+
│ ├── cost-tracker.ts # Log file discovery, tailing, and IPC push
187+
│ ├── git-port.ts # Git command abstraction (WSL)
188+
│ ├── worktree-manager.ts # Per-session git worktree lifecycle
168189
│ └── project-store.ts # electron-store: CRUD + safeStorage for API keys
169190
├── preload/
170191
│ └── index.ts # contextBridge: safe IPC surface (window.agentDeck)
@@ -193,7 +214,7 @@ src/
193214
| [node-pty](https://github.com/microsoft/node-pty) | Pseudo-terminal (WSL sessions) |
194215
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
195216
| [React Flow](https://reactflow.dev) | Visual workflow node editor |
196-
| [Vitest 4](https://vitest.dev) | Testing framework (499 tests) |
217+
| [Vitest 4](https://vitest.dev) | Testing framework (610 tests) |
197218
| [ESLint 9](https://eslint.org) | Linting (flat config, zero-warning policy) |
198219

199220
## Documentation

docs/USER-GUIDE.md

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# AgentDeck User Guide
22

3-
> **Version**: 4.7.0
3+
> **Version**: 4.8.0
44
55
AgentDeck is a desktop command center for managing AI coding agents through WSL2 terminals. This guide covers every feature from first launch to advanced workflow automation.
66

@@ -21,10 +21,12 @@ AgentDeck is a desktop command center for managing AI coding agents through WSL2
2121
11. [Agentic Workflows](#agentic-workflows) (conditions, loops, variables, import/export, history)
2222
12. [Workflow Roles](#workflow-roles)
2323
13. [Command Palette](#command-palette)
24-
14. [Agent Updates](#agent-updates)
25-
15. [Themes](#themes)
26-
16. [Keyboard Shortcuts](#keyboard-shortcuts)
27-
17. [Troubleshooting](#troubleshooting)
24+
14. [Cost/Token Tracking](#costtoken-tracking)
25+
15. [Git Worktree Isolation](#git-worktree-isolation)
26+
16. [Agent Updates](#agent-updates)
27+
17. [Themes](#themes)
28+
18. [Keyboard Shortcuts](#keyboard-shortcuts)
29+
19. [Troubleshooting](#troubleshooting)
2830

2931
---
3032

@@ -541,6 +543,67 @@ In the Themes sub-menu, arrow keys give a live preview of each theme. Press Ente
541543

542544
---
543545

546+
## Cost/Token Tracking
547+
548+
AgentDeck tracks token usage and estimated cost for **Claude Code** and **Codex CLI** sessions in real-time.
549+
550+
### How It Works
551+
552+
When a session starts, AgentDeck discovers the agent's JSONL log file in WSL and tails it every 3 seconds. Token usage and cost are parsed from the log entries and pushed to the UI.
553+
554+
- **Claude Code**: Reads `~/.claude/projects/` session logs. Pricing uses the model ID (opus/sonnet/haiku) with cache-aware rates — cache writes cost 1.25x, cache reads cost 0.1x of the base input rate.
555+
- **Codex CLI**: Reads `~/.codex/sessions/` rollout files. Pricing uses per-model maps (gpt-4o, o3, gpt-5.3, etc.).
556+
557+
### The Cost Badge
558+
559+
A **Zap icon** appears in the pane topbar showing:
560+
- **USD cost** (e.g. `$0.18`) — computed from model pricing
561+
- **Total tokens** (e.g. `28.8k tokens`) — all tokens processed (input + cache + output)
562+
563+
**Hover** the badge for a tooltip breakdown: input, output, cache read, and cache write tokens.
564+
565+
The cost and token count are always consistent — both reflect the same set of tokens, so the numbers make sense together.
566+
567+
### Notes
568+
569+
- Cost tracking is automatic — no configuration needed
570+
- Only Claude Code and Codex are supported (other agents show no badge)
571+
- On the first turn, Claude shows high token counts due to system prompt caching — this drops dramatically on subsequent turns
572+
- Cost data is per-session and resets when the session closes
573+
574+
---
575+
576+
## Git Worktree Isolation
577+
578+
Each agent session can run in its own **git worktree** — an isolated copy of the repository on a separate branch. This prevents agents from interfering with each other or with your working directory.
579+
580+
### How It Works
581+
582+
When you open a session for a project that is a git repository, AgentDeck automatically creates a worktree:
583+
- A new branch is created (e.g. `agentdeck/session-abc`)
584+
- The agent works in the worktree directory, not your main working copy
585+
- Changes are isolated until you decide to keep or discard them
586+
587+
### Branch Badge
588+
589+
When a session has an isolated worktree, a **branch badge** appears in the pane topbar showing the branch name. The status bar also shows a worktree indicator.
590+
591+
### Closing a Session
592+
593+
When you close a session with an active worktree, AgentDeck inspects the worktree for changes:
594+
595+
- **No changes**: Worktree is silently cleaned up
596+
- **Has changes**: A dialog appears with three options:
597+
- **Keep** — Preserve the branch and worktree for later merging
598+
- **Discard** — Delete the worktree and branch (changes are lost)
599+
- **Cancel** — Keep the session open
600+
601+
### Cleanup
602+
603+
Orphaned worktrees (from crashes or abrupt exits) are automatically pruned on startup.
604+
605+
---
606+
544607
## Agent Updates
545608

546609
AgentDeck checks for agent updates on startup and shows a toast notification when updates are available.

src/main/__tests__/node-runners-skill.test.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,12 @@ describe('skill prefix extraction', () => {
1616
expect(extractSkillPrefix('project:deploy', 'codex')).toBe('$deploy ')
1717
})
1818

19-
it('returns null for non-codex agent (claude-code)', () => {
20-
expect(extractSkillPrefix('global:lint-fix', 'claude-code')).toBeNull()
21-
})
22-
23-
it('returns null for non-codex agent (aider)', () => {
24-
expect(extractSkillPrefix('global:lint-fix', 'aider')).toBeNull()
25-
})
26-
27-
it('returns null for non-codex agent (goose)', () => {
28-
expect(extractSkillPrefix('global:lint-fix', 'goose')).toBeNull()
29-
})
19+
it.each(['claude-code', 'aider', 'goose'] as const)(
20+
'returns null for non-codex agent (%s)',
21+
(agent) => {
22+
expect(extractSkillPrefix('global:lint-fix', agent)).toBeNull()
23+
},
24+
)
3025

3126
it('returns null when skillId is undefined', () => {
3227
expect(extractSkillPrefix(undefined, 'codex')).toBeNull()

src/main/__tests__/skill-scanner.test.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
parseFrontmatter,
66
invalidateAllCaches,
77
scanSkillDirectory,
8+
getProjectSkills,
89
} from '../skill-scanner'
910

1011
vi.mock('child_process', () => ({
@@ -259,16 +260,32 @@ describe('parseFrontmatter', () => {
259260
// ── Cache helpers ──────────────────────────────────────────────────
260261

261262
describe('invalidateAllCaches', () => {
262-
it('resets state without errors', () => {
263-
// Should not throw even when caches are already empty
264-
expect(() => invalidateAllCaches()).not.toThrow()
265-
})
263+
it('forces a fresh WSL call after invalidation', async () => {
264+
const output = [
265+
'---SKILL-BLOCK---',
266+
'/home/user/project/.agents/skills/deploy/SKILL.md',
267+
'deploy',
268+
'---',
269+
'name: deploy',
270+
'description: Deploy to prod',
271+
'---',
272+
].join('\n')
266273

267-
it('can be called multiple times', () => {
268-
invalidateAllCaches()
269-
invalidateAllCaches()
274+
mockWslOutput(output)
275+
276+
// First call populates the project cache
277+
await getProjectSkills('/home/user/project', 'Ubuntu')
278+
const callsAfterFirst = vi.mocked(cp.execFile).mock.calls.length
279+
280+
// Second call should hit the cache — no new execFile call
281+
await getProjectSkills('/home/user/project', 'Ubuntu')
282+
expect(vi.mocked(cp.execFile).mock.calls.length).toBe(callsAfterFirst)
283+
284+
// Invalidate and call again — must trigger a fresh WSL call
270285
invalidateAllCaches()
271-
// No error means success
286+
mockWslOutput(output)
287+
await getProjectSkills('/home/user/project', 'Ubuntu')
288+
expect(vi.mocked(cp.execFile).mock.calls.length).toBeGreaterThan(callsAfterFirst)
272289
})
273290
})
274291

src/main/cost-tracker.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,10 @@ describe('file discovery', () => {
211211
// Tailing poll at 3s after discovery
212212
await vi.advanceTimersByTimeAsync(3000)
213213

214-
expect(callCount).toBeGreaterThanOrEqual(3)
214+
expect(win.webContents.send).toHaveBeenCalledWith(
215+
'cost:update',
216+
expect.objectContaining({ sessionId: 's1' }),
217+
)
215218

216219
tracker.destroy()
217220
})

src/main/log-adapters.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, beforeEach } from 'vitest'
22
import {
33
formatTokens,
44
formatCost,
@@ -83,9 +83,7 @@ describe('ClaudeAdapter', () => {
8383
const dirs = adapter.getLogDirs('/home/rooty/my-project')
8484
const second = dirs[1]
8585
if (!second) throw new Error('Expected second dir')
86-
expect(second).toContain('~/.claude/projects/')
87-
// fallback dir should not contain the path slug
88-
expect(second).not.toContain('rooty')
86+
expect(second).toBe('~/.claude/projects/')
8987
})
9088

9189
it('getFilePattern returns "*.jsonl"', () => {
@@ -219,7 +217,11 @@ describe('ClaudeAdapter', () => {
219217
// ---------------------------------------------------------------------------
220218

221219
describe('CodexAdapter', () => {
222-
const adapter = createCodexAdapter()
220+
let adapter: ReturnType<typeof createCodexAdapter>
221+
222+
beforeEach(() => {
223+
adapter = createCodexAdapter()
224+
})
223225

224226
it('has agent "codex"', () => {
225227
expect(adapter.agent).toBe('codex')

src/main/project-store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ describe('createProjectStore', () => {
150150
expect(safeStorage.decryptString).toHaveBeenCalled()
151151
// The value should come back decrypted
152152
expect(projects[0]?.envVars?.[0]?.key).toBe('API_KEY')
153+
expect(projects[0]?.envVars?.[0]?.value).toBe('secret123')
153154
})
154155

155156
it('handles encryption unavailable gracefully', async () => {

src/main/pty-manager.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,23 @@ describe('createPtyManager', () => {
389389
expect.objectContaining({ type: 'think' }),
390390
)
391391
})
392+
393+
it('handles multi-line data chunks correctly', () => {
394+
const win = makeMockWindow()
395+
const mgr = createPtyManager(win)
396+
397+
mgr.spawn('s1', 80, 24)
398+
const cb = ptyInstances[0]?.onData[0]
399+
// Send a chunk with multiple lines at once
400+
cb?.('Read file1.ts\nWrite file2.ts\n')
401+
vi.runAllTimers()
402+
403+
// Should have emitted activity for both lines
404+
const calls = vi
405+
.mocked(win.webContents.send)
406+
.mock.calls.filter((c) => c[0] === 'pty:activity:s1')
407+
expect(calls.length).toBeGreaterThanOrEqual(2)
408+
})
392409
})
393410

394411
describe('line buffer cap', () => {

src/main/workflow-engine-execution.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ describe('concurrent execution', () => {
276276
engine.run(wf)
277277
await tick()
278278

279+
// MAX_TIER_CONCURRENCY = 5 (defined in workflow-engine.ts)
279280
expect(mockSpawn).toHaveBeenCalledTimes(5)
280281

281282
// Close the first child — the 6th node should now spawn
@@ -536,6 +537,10 @@ describe('error scenarios', () => {
536537
const errors = getEvents(sendSpy, 'wf-idle', 'node:error')
537538
expect(errors).toHaveLength(1)
538539
expect(String(errors[0]?.message)).toContain('idle')
540+
541+
// Verify workflow-level terminal event was emitted (stopped due to node failure)
542+
const workflowStopped = getEvents(sendSpy, 'wf-idle', 'workflow:stopped')
543+
expect(workflowStopped).toHaveLength(1)
539544
})
540545

541546
it('kills agent after absolute timeout (node.timeout)', async () => {

src/main/workflow-engine.test.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { describe, it, expect, beforeEach } from 'vitest'
2-
import {
3-
stripAnsi,
4-
shellQuote,
5-
topoSort,
6-
validateWorkflow,
7-
AGENT_IDLE_TIMEOUT,
8-
} from './workflow-engine'
9-
import type { WorkflowNode } from '../shared/types'
2+
import { stripAnsi, shellQuote, topoSort, validateWorkflow } from './workflow-engine'
3+
104
import { makeWorkflowNode, makeWorkflowEdge, makeWorkflow, resetCounter } from '../__test__/helpers'
115

126
beforeEach(() => {
@@ -728,31 +722,3 @@ describe('validateWorkflow', () => {
728722
expect(result.errors.length).toBeGreaterThanOrEqual(2)
729723
})
730724
})
731-
732-
// ── AGENT_IDLE_TIMEOUT ────────────────────────────────────
733-
734-
describe('AGENT_IDLE_TIMEOUT', () => {
735-
it('is exported and equals 300000 (5 minutes)', () => {
736-
expect(AGENT_IDLE_TIMEOUT).toBe(300_000)
737-
})
738-
})
739-
740-
// ── continueOnError type ──────────────────────────────────
741-
742-
describe('WorkflowNode continueOnError', () => {
743-
// T1: Verify the type accepts continueOnError flag
744-
it('accepts continueOnError as optional boolean on WorkflowNode', () => {
745-
const node: WorkflowNode = makeWorkflowNode({
746-
id: 'n-coe',
747-
type: 'shell',
748-
command: 'npm test',
749-
continueOnError: true,
750-
})
751-
expect(node.continueOnError).toBe(true)
752-
})
753-
754-
it('defaults continueOnError to undefined when not set', () => {
755-
const node: WorkflowNode = makeWorkflowNode({ id: 'n-default' })
756-
expect(node.continueOnError).toBeUndefined()
757-
})
758-
})

0 commit comments

Comments
 (0)