rafcode 2.4.0 → 2.5.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/CLAUDE.md +7 -5
- package/RAF/ahwidh-quick-fix-gremlin/decisions.md +37 -0
- package/RAF/ahwidh-quick-fix-gremlin/input.md +35 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/01-fix-name-generation-prompt.md +33 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/02-fix-amend-commit-scope.md +43 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/03-fix-diverged-main-branch-sync.md +32 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/04-wire-rate-limit-to-do-command.md +61 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/05-add-config-get-set-flags.md +125 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/06-sync-worktree-branch-before-execution.md +96 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/07-update-frontmatter-format.md +107 -0
- package/RAF/ahwidh-quick-fix-gremlin/outcomes/08-remove-plan-token-report.md +76 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/01-fix-name-generation-prompt.md +52 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/02-fix-amend-commit-scope.md +48 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/03-fix-diverged-main-branch-sync.md +49 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/04-wire-rate-limit-to-do-command.md +78 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/05-add-config-get-set-flags.md +101 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/06-sync-worktree-branch-before-execution.md +92 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/07-update-frontmatter-format.md +105 -0
- package/RAF/ahwidh-quick-fix-gremlin/plans/08-remove-plan-token-report.md +50 -0
- package/RAF/ahwqwq-model-whisperer/decisions.md +22 -0
- package/RAF/ahwqwq-model-whisperer/input.md +5 -0
- package/RAF/ahwqwq-model-whisperer/outcomes/01-show-model-on-task-line.md +49 -0
- package/RAF/ahwqwq-model-whisperer/outcomes/02-use-claude-cost-estimation.md +107 -0
- package/RAF/ahwqwq-model-whisperer/outcomes/03-add-plan-resume-flag.md +87 -0
- package/RAF/ahwqwq-model-whisperer/plans/01-show-model-on-task-line.md +45 -0
- package/RAF/ahwqwq-model-whisperer/plans/02-use-claude-cost-estimation.md +115 -0
- package/RAF/ahwqwq-model-whisperer/plans/03-add-plan-resume-flag.md +70 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +209 -1
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +37 -8
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +92 -54
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts +8 -6
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +73 -5
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/worktree.d.ts +12 -0
- package/dist/core/worktree.d.ts.map +1 -1
- package/dist/core/worktree.js +33 -1
- package/dist/core/worktree.js.map +1 -1
- package/dist/parsers/stream-renderer.d.ts +2 -0
- package/dist/parsers/stream-renderer.d.ts.map +1 -1
- package/dist/parsers/stream-renderer.js +2 -0
- package/dist/parsers/stream-renderer.js.map +1 -1
- package/dist/prompts/amend.d.ts.map +1 -1
- package/dist/prompts/amend.js +3 -1
- package/dist/prompts/amend.js.map +1 -1
- package/dist/prompts/planning.d.ts.map +1 -1
- package/dist/prompts/planning.js +3 -1
- package/dist/prompts/planning.js.map +1 -1
- package/dist/types/config.d.ts +4 -24
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +0 -24
- package/dist/types/config.js.map +1 -1
- package/dist/utils/config.d.ts +1 -26
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +2 -98
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +13 -3
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +40 -10
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/name-generator.d.ts.map +1 -1
- package/dist/utils/name-generator.js +7 -16
- package/dist/utils/name-generator.js.map +1 -1
- package/dist/utils/terminal-symbols.d.ts +7 -16
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +16 -42
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/dist/utils/token-tracker.d.ts +4 -30
- package/dist/utils/token-tracker.d.ts.map +1 -1
- package/dist/utils/token-tracker.js +17 -98
- package/dist/utils/token-tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/config.ts +242 -0
- package/src/commands/do.ts +39 -7
- package/src/commands/plan.ts +101 -58
- package/src/core/claude-runner.ts +82 -12
- package/src/core/worktree.ts +37 -1
- package/src/parsers/stream-renderer.ts +4 -0
- package/src/prompts/amend.ts +3 -1
- package/src/prompts/config-docs.md +1 -72
- package/src/prompts/planning.ts +3 -1
- package/src/types/config.ts +4 -52
- package/src/utils/config.ts +2 -112
- package/src/utils/frontmatter.ts +41 -11
- package/src/utils/name-generator.ts +7 -16
- package/src/utils/terminal-symbols.ts +16 -46
- package/src/utils/token-tracker.ts +19 -113
- package/tests/unit/claude-runner.test.ts +1 -0
- package/tests/unit/commit-planning-artifacts-worktree.test.ts +6 -14
- package/tests/unit/commit-planning-artifacts.test.ts +4 -12
- package/tests/unit/config-command.test.ts +161 -0
- package/tests/unit/config.test.ts +6 -148
- package/tests/unit/frontmatter.test.ts +95 -1
- package/tests/unit/name-generator.test.ts +1 -1
- package/tests/unit/post-execution-picker.test.ts +1 -0
- package/tests/unit/stream-renderer.test.ts +82 -0
- package/tests/unit/terminal-symbols.test.ts +86 -124
- package/tests/unit/token-tracker.test.ts +159 -679
- package/tests/unit/worktree.test.ts +68 -1
- package/src/utils/session-parser.ts +0 -161
- package/tests/unit/session-parser.test.ts +0 -301
|
@@ -46,6 +46,7 @@ const {
|
|
|
46
46
|
detectMainBranch,
|
|
47
47
|
pullMainBranch,
|
|
48
48
|
pushMainBranch,
|
|
49
|
+
rebaseOntoMain,
|
|
49
50
|
} = await import('../../src/core/worktree.js');
|
|
50
51
|
|
|
51
52
|
const HOME = os.homedir();
|
|
@@ -729,7 +730,7 @@ describe('worktree utilities', () => {
|
|
|
729
730
|
|
|
730
731
|
const result = pullMainBranch();
|
|
731
732
|
|
|
732
|
-
expect(result.success).toBe(
|
|
733
|
+
expect(result.success).toBe(false);
|
|
733
734
|
expect(result.mainBranch).toBe('main');
|
|
734
735
|
expect(result.hadChanges).toBe(false);
|
|
735
736
|
expect(result.error).toContain('diverged');
|
|
@@ -806,6 +807,72 @@ describe('worktree utilities', () => {
|
|
|
806
807
|
});
|
|
807
808
|
});
|
|
808
809
|
|
|
810
|
+
describe('rebaseOntoMain', () => {
|
|
811
|
+
it('should successfully rebase onto main branch', () => {
|
|
812
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
813
|
+
const cmdStr = cmd as string;
|
|
814
|
+
if (cmdStr.includes('git rebase main')) return '';
|
|
815
|
+
return '';
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const result = rebaseOntoMain('main', '/path/to/worktree');
|
|
819
|
+
|
|
820
|
+
expect(result.success).toBe(true);
|
|
821
|
+
expect(result.error).toBeUndefined();
|
|
822
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
823
|
+
'git rebase main',
|
|
824
|
+
expect.objectContaining({
|
|
825
|
+
cwd: '/path/to/worktree',
|
|
826
|
+
})
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('should abort rebase and return failure on conflict', () => {
|
|
831
|
+
const commands: string[] = [];
|
|
832
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
833
|
+
const cmdStr = cmd as string;
|
|
834
|
+
commands.push(cmdStr);
|
|
835
|
+
if (cmdStr.includes('git rebase main')) {
|
|
836
|
+
throw new Error('CONFLICT: Merge conflict in file.ts');
|
|
837
|
+
}
|
|
838
|
+
if (cmdStr.includes('git rebase --abort')) return '';
|
|
839
|
+
return '';
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const result = rebaseOntoMain('main', '/path/to/worktree');
|
|
843
|
+
|
|
844
|
+
expect(result.success).toBe(false);
|
|
845
|
+
expect(result.error).toContain('CONFLICT');
|
|
846
|
+
expect(commands).toContain('git rebase main');
|
|
847
|
+
expect(commands).toContain('git rebase --abort');
|
|
848
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
849
|
+
'git rebase --abort',
|
|
850
|
+
expect.objectContaining({
|
|
851
|
+
cwd: '/path/to/worktree',
|
|
852
|
+
})
|
|
853
|
+
);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('should handle rebase abort failure gracefully', () => {
|
|
857
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
858
|
+
const cmdStr = cmd as string;
|
|
859
|
+
if (cmdStr.includes('git rebase main')) {
|
|
860
|
+
throw new Error('CONFLICT: Merge conflict');
|
|
861
|
+
}
|
|
862
|
+
if (cmdStr.includes('git rebase --abort')) {
|
|
863
|
+
throw new Error('abort failed');
|
|
864
|
+
}
|
|
865
|
+
return '';
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const result = rebaseOntoMain('main', '/path/to/worktree');
|
|
869
|
+
|
|
870
|
+
// Should still return failure even if abort fails
|
|
871
|
+
expect(result.success).toBe(false);
|
|
872
|
+
expect(result.error).toContain('CONFLICT');
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
809
876
|
describe('pushMainBranch', () => {
|
|
810
877
|
it('should return error when main branch cannot be detected', () => {
|
|
811
878
|
mockExecSync.mockImplementation(() => {
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utilities for parsing Claude CLI session files to extract token usage data.
|
|
3
|
-
* Claude CLI saves session data to ~/.claude/projects/<escaped-path>/<session-id>.jsonl
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import * as fs from 'node:fs';
|
|
7
|
-
import * as path from 'node:path';
|
|
8
|
-
import * as os from 'node:os';
|
|
9
|
-
import type { UsageData } from '../types/config.js';
|
|
10
|
-
|
|
11
|
-
/** Raw usage structure from Claude session JSONL assistant message entries. */
|
|
12
|
-
interface SessionMessageUsage {
|
|
13
|
-
input_tokens?: number;
|
|
14
|
-
output_tokens?: number;
|
|
15
|
-
cache_read_input_tokens?: number;
|
|
16
|
-
cache_creation_input_tokens?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Structure of an assistant message entry in the session JSONL. */
|
|
20
|
-
interface SessionMessageEntry {
|
|
21
|
-
type: 'assistant';
|
|
22
|
-
message?: {
|
|
23
|
-
usage?: SessionMessageUsage;
|
|
24
|
-
model?: string;
|
|
25
|
-
};
|
|
26
|
-
costUSD?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Result of parsing a session file. */
|
|
30
|
-
export interface SessionParseResult {
|
|
31
|
-
/** Accumulated usage data from all assistant messages. */
|
|
32
|
-
usage: UsageData;
|
|
33
|
-
/** Whether parsing was successful. */
|
|
34
|
-
success: boolean;
|
|
35
|
-
/** Error message if parsing failed. */
|
|
36
|
-
error?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Escape a path for use in Claude's project directory naming scheme.
|
|
41
|
-
* Claude escapes `/` to `-` in project paths.
|
|
42
|
-
*/
|
|
43
|
-
export function escapeProjectPath(projectPath: string): string {
|
|
44
|
-
// Remove leading slash and replace remaining slashes with dashes
|
|
45
|
-
return projectPath.replace(/^\//, '').replace(/\//g, '-');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Compute the expected session file path for a given session ID and working directory.
|
|
50
|
-
*
|
|
51
|
-
* @param sessionId - The UUID session ID passed to --session-id
|
|
52
|
-
* @param cwd - The working directory where Claude was run (project path)
|
|
53
|
-
* @returns The expected path to the session JSONL file
|
|
54
|
-
*/
|
|
55
|
-
export function getSessionFilePath(sessionId: string, cwd: string): string {
|
|
56
|
-
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
57
|
-
const escapedPath = escapeProjectPath(cwd);
|
|
58
|
-
return path.join(claudeDir, escapedPath, `${sessionId}.jsonl`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Parse a Claude session JSONL file and extract accumulated token usage data.
|
|
63
|
-
*
|
|
64
|
-
* @param sessionFilePath - Path to the session JSONL file
|
|
65
|
-
* @returns Parsed usage data or error information
|
|
66
|
-
*/
|
|
67
|
-
export function parseSessionFile(sessionFilePath: string): SessionParseResult {
|
|
68
|
-
const emptyUsage: UsageData = {
|
|
69
|
-
inputTokens: 0,
|
|
70
|
-
outputTokens: 0,
|
|
71
|
-
cacheReadInputTokens: 0,
|
|
72
|
-
cacheCreationInputTokens: 0,
|
|
73
|
-
modelUsage: {},
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
if (!fs.existsSync(sessionFilePath)) {
|
|
77
|
-
return {
|
|
78
|
-
usage: emptyUsage,
|
|
79
|
-
success: false,
|
|
80
|
-
error: `Session file not found: ${sessionFilePath}`,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
86
|
-
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
87
|
-
|
|
88
|
-
const accumulated: UsageData = { ...emptyUsage, modelUsage: {} };
|
|
89
|
-
|
|
90
|
-
for (const line of lines) {
|
|
91
|
-
try {
|
|
92
|
-
const entry = JSON.parse(line) as Record<string, unknown>;
|
|
93
|
-
|
|
94
|
-
// Only process assistant message entries
|
|
95
|
-
if (entry.type !== 'assistant') continue;
|
|
96
|
-
|
|
97
|
-
const assistantEntry = entry as unknown as SessionMessageEntry;
|
|
98
|
-
const usage = assistantEntry.message?.usage;
|
|
99
|
-
const model = assistantEntry.message?.model;
|
|
100
|
-
|
|
101
|
-
if (!usage) continue;
|
|
102
|
-
|
|
103
|
-
const inputTokens = usage.input_tokens ?? 0;
|
|
104
|
-
const outputTokens = usage.output_tokens ?? 0;
|
|
105
|
-
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
106
|
-
const cacheCreateTokens = usage.cache_creation_input_tokens ?? 0;
|
|
107
|
-
|
|
108
|
-
// Accumulate aggregate totals
|
|
109
|
-
accumulated.inputTokens += inputTokens;
|
|
110
|
-
accumulated.outputTokens += outputTokens;
|
|
111
|
-
accumulated.cacheReadInputTokens += cacheReadTokens;
|
|
112
|
-
accumulated.cacheCreationInputTokens += cacheCreateTokens;
|
|
113
|
-
|
|
114
|
-
// Accumulate per-model usage
|
|
115
|
-
if (model) {
|
|
116
|
-
const existing = accumulated.modelUsage[model];
|
|
117
|
-
if (existing) {
|
|
118
|
-
existing.inputTokens += inputTokens;
|
|
119
|
-
existing.outputTokens += outputTokens;
|
|
120
|
-
existing.cacheReadInputTokens += cacheReadTokens;
|
|
121
|
-
existing.cacheCreationInputTokens += cacheCreateTokens;
|
|
122
|
-
} else {
|
|
123
|
-
accumulated.modelUsage[model] = {
|
|
124
|
-
inputTokens,
|
|
125
|
-
outputTokens,
|
|
126
|
-
cacheReadInputTokens: cacheReadTokens,
|
|
127
|
-
cacheCreationInputTokens: cacheCreateTokens,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// Skip malformed lines
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
usage: accumulated,
|
|
139
|
-
success: true,
|
|
140
|
-
};
|
|
141
|
-
} catch (error) {
|
|
142
|
-
return {
|
|
143
|
-
usage: emptyUsage,
|
|
144
|
-
success: false,
|
|
145
|
-
error: `Failed to parse session file: ${error}`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Parse a Claude session by session ID and working directory.
|
|
152
|
-
* Convenience wrapper around getSessionFilePath + parseSessionFile.
|
|
153
|
-
*
|
|
154
|
-
* @param sessionId - The UUID session ID passed to --session-id
|
|
155
|
-
* @param cwd - The working directory where Claude was run
|
|
156
|
-
* @returns Parsed usage data or error information
|
|
157
|
-
*/
|
|
158
|
-
export function parseSessionById(sessionId: string, cwd: string): SessionParseResult {
|
|
159
|
-
const filePath = getSessionFilePath(sessionId, cwd);
|
|
160
|
-
return parseSessionFile(filePath);
|
|
161
|
-
}
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import * as os from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
escapeProjectPath,
|
|
6
|
-
getSessionFilePath,
|
|
7
|
-
parseSessionFile,
|
|
8
|
-
parseSessionById,
|
|
9
|
-
} from '../../src/utils/session-parser.js';
|
|
10
|
-
|
|
11
|
-
describe('Session Parser', () => {
|
|
12
|
-
let tempDir: string;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-session-parser-test-'));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('escapeProjectPath', () => {
|
|
23
|
-
it('should replace slashes with dashes', () => {
|
|
24
|
-
expect(escapeProjectPath('/Users/test/project')).toBe('Users-test-project');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('should handle single leading slash', () => {
|
|
28
|
-
expect(escapeProjectPath('/project')).toBe('project');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('should handle no leading slash', () => {
|
|
32
|
-
expect(escapeProjectPath('Users/test/project')).toBe('Users-test-project');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should handle multiple slashes', () => {
|
|
36
|
-
expect(escapeProjectPath('/a/b/c/d')).toBe('a-b-c-d');
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('getSessionFilePath', () => {
|
|
41
|
-
it('should construct correct session file path', () => {
|
|
42
|
-
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
|
43
|
-
const cwd = '/Users/test/myproject';
|
|
44
|
-
|
|
45
|
-
const result = getSessionFilePath(sessionId, cwd);
|
|
46
|
-
|
|
47
|
-
expect(result).toBe(
|
|
48
|
-
path.join(os.homedir(), '.claude', 'projects', 'Users-test-myproject', '550e8400-e29b-41d4-a716-446655440000.jsonl')
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe('parseSessionFile', () => {
|
|
54
|
-
it('should return error when file does not exist', () => {
|
|
55
|
-
const result = parseSessionFile(path.join(tempDir, 'nonexistent.jsonl'));
|
|
56
|
-
|
|
57
|
-
expect(result.success).toBe(false);
|
|
58
|
-
expect(result.error).toContain('not found');
|
|
59
|
-
expect(result.usage.inputTokens).toBe(0);
|
|
60
|
-
expect(result.usage.outputTokens).toBe(0);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should parse empty session file', () => {
|
|
64
|
-
const filePath = path.join(tempDir, 'empty.jsonl');
|
|
65
|
-
fs.writeFileSync(filePath, '');
|
|
66
|
-
|
|
67
|
-
const result = parseSessionFile(filePath);
|
|
68
|
-
|
|
69
|
-
expect(result.success).toBe(true);
|
|
70
|
-
expect(result.usage.inputTokens).toBe(0);
|
|
71
|
-
expect(result.usage.outputTokens).toBe(0);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should parse single assistant message entry', () => {
|
|
75
|
-
const filePath = path.join(tempDir, 'single.jsonl');
|
|
76
|
-
const entry = {
|
|
77
|
-
type: 'assistant',
|
|
78
|
-
message: {
|
|
79
|
-
model: 'claude-sonnet-4-5',
|
|
80
|
-
usage: {
|
|
81
|
-
input_tokens: 1000,
|
|
82
|
-
output_tokens: 500,
|
|
83
|
-
cache_read_input_tokens: 100,
|
|
84
|
-
cache_creation_input_tokens: 50,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
fs.writeFileSync(filePath, JSON.stringify(entry) + '\n');
|
|
89
|
-
|
|
90
|
-
const result = parseSessionFile(filePath);
|
|
91
|
-
|
|
92
|
-
expect(result.success).toBe(true);
|
|
93
|
-
expect(result.usage.inputTokens).toBe(1000);
|
|
94
|
-
expect(result.usage.outputTokens).toBe(500);
|
|
95
|
-
expect(result.usage.cacheReadInputTokens).toBe(100);
|
|
96
|
-
expect(result.usage.cacheCreationInputTokens).toBe(50);
|
|
97
|
-
expect(result.usage.modelUsage['claude-sonnet-4-5']?.inputTokens).toBe(1000);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('should accumulate multiple assistant messages', () => {
|
|
101
|
-
const filePath = path.join(tempDir, 'multiple.jsonl');
|
|
102
|
-
const entry1 = {
|
|
103
|
-
type: 'assistant',
|
|
104
|
-
message: {
|
|
105
|
-
model: 'claude-sonnet-4-5',
|
|
106
|
-
usage: {
|
|
107
|
-
input_tokens: 1000,
|
|
108
|
-
output_tokens: 500,
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
const entry2 = {
|
|
113
|
-
type: 'assistant',
|
|
114
|
-
message: {
|
|
115
|
-
model: 'claude-sonnet-4-5',
|
|
116
|
-
usage: {
|
|
117
|
-
input_tokens: 2000,
|
|
118
|
-
output_tokens: 1000,
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
fs.writeFileSync(filePath, JSON.stringify(entry1) + '\n' + JSON.stringify(entry2) + '\n');
|
|
123
|
-
|
|
124
|
-
const result = parseSessionFile(filePath);
|
|
125
|
-
|
|
126
|
-
expect(result.success).toBe(true);
|
|
127
|
-
expect(result.usage.inputTokens).toBe(3000);
|
|
128
|
-
expect(result.usage.outputTokens).toBe(1500);
|
|
129
|
-
expect(result.usage.modelUsage['claude-sonnet-4-5']?.inputTokens).toBe(3000);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should handle different models in same session', () => {
|
|
133
|
-
const filePath = path.join(tempDir, 'multi-model.jsonl');
|
|
134
|
-
const entry1 = {
|
|
135
|
-
type: 'assistant',
|
|
136
|
-
message: {
|
|
137
|
-
model: 'claude-sonnet-4-5',
|
|
138
|
-
usage: {
|
|
139
|
-
input_tokens: 1000,
|
|
140
|
-
output_tokens: 500,
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
const entry2 = {
|
|
145
|
-
type: 'assistant',
|
|
146
|
-
message: {
|
|
147
|
-
model: 'claude-haiku-4-5',
|
|
148
|
-
usage: {
|
|
149
|
-
input_tokens: 500,
|
|
150
|
-
output_tokens: 200,
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
fs.writeFileSync(filePath, JSON.stringify(entry1) + '\n' + JSON.stringify(entry2) + '\n');
|
|
155
|
-
|
|
156
|
-
const result = parseSessionFile(filePath);
|
|
157
|
-
|
|
158
|
-
expect(result.success).toBe(true);
|
|
159
|
-
expect(result.usage.inputTokens).toBe(1500);
|
|
160
|
-
expect(result.usage.outputTokens).toBe(700);
|
|
161
|
-
expect(result.usage.modelUsage['claude-sonnet-4-5']?.inputTokens).toBe(1000);
|
|
162
|
-
expect(result.usage.modelUsage['claude-haiku-4-5']?.inputTokens).toBe(500);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('should skip non-assistant entries', () => {
|
|
166
|
-
const filePath = path.join(tempDir, 'mixed.jsonl');
|
|
167
|
-
const userEntry = { type: 'user', message: { content: 'hello' } };
|
|
168
|
-
const assistantEntry = {
|
|
169
|
-
type: 'assistant',
|
|
170
|
-
message: {
|
|
171
|
-
model: 'claude-sonnet-4-5',
|
|
172
|
-
usage: {
|
|
173
|
-
input_tokens: 1000,
|
|
174
|
-
output_tokens: 500,
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
const systemEntry = { type: 'system', data: {} };
|
|
179
|
-
fs.writeFileSync(
|
|
180
|
-
filePath,
|
|
181
|
-
JSON.stringify(userEntry) + '\n' + JSON.stringify(assistantEntry) + '\n' + JSON.stringify(systemEntry) + '\n'
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const result = parseSessionFile(filePath);
|
|
185
|
-
|
|
186
|
-
expect(result.success).toBe(true);
|
|
187
|
-
expect(result.usage.inputTokens).toBe(1000);
|
|
188
|
-
expect(result.usage.outputTokens).toBe(500);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should skip malformed JSON lines', () => {
|
|
192
|
-
const filePath = path.join(tempDir, 'malformed.jsonl');
|
|
193
|
-
const goodEntry = {
|
|
194
|
-
type: 'assistant',
|
|
195
|
-
message: {
|
|
196
|
-
model: 'claude-sonnet-4-5',
|
|
197
|
-
usage: {
|
|
198
|
-
input_tokens: 1000,
|
|
199
|
-
output_tokens: 500,
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
fs.writeFileSync(
|
|
204
|
-
filePath,
|
|
205
|
-
'not valid json\n' + JSON.stringify(goodEntry) + '\n' + '{ broken }\n'
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const result = parseSessionFile(filePath);
|
|
209
|
-
|
|
210
|
-
expect(result.success).toBe(true);
|
|
211
|
-
expect(result.usage.inputTokens).toBe(1000);
|
|
212
|
-
expect(result.usage.outputTokens).toBe(500);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('should handle entries without usage data', () => {
|
|
216
|
-
const filePath = path.join(tempDir, 'no-usage.jsonl');
|
|
217
|
-
const entryWithUsage = {
|
|
218
|
-
type: 'assistant',
|
|
219
|
-
message: {
|
|
220
|
-
model: 'claude-sonnet-4-5',
|
|
221
|
-
usage: {
|
|
222
|
-
input_tokens: 1000,
|
|
223
|
-
output_tokens: 500,
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
const entryWithoutUsage = {
|
|
228
|
-
type: 'assistant',
|
|
229
|
-
message: {
|
|
230
|
-
model: 'claude-sonnet-4-5',
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
fs.writeFileSync(
|
|
234
|
-
filePath,
|
|
235
|
-
JSON.stringify(entryWithUsage) + '\n' + JSON.stringify(entryWithoutUsage) + '\n'
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
const result = parseSessionFile(filePath);
|
|
239
|
-
|
|
240
|
-
expect(result.success).toBe(true);
|
|
241
|
-
expect(result.usage.inputTokens).toBe(1000);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('should handle entries without model', () => {
|
|
245
|
-
const filePath = path.join(tempDir, 'no-model.jsonl');
|
|
246
|
-
const entry = {
|
|
247
|
-
type: 'assistant',
|
|
248
|
-
message: {
|
|
249
|
-
usage: {
|
|
250
|
-
input_tokens: 1000,
|
|
251
|
-
output_tokens: 500,
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
fs.writeFileSync(filePath, JSON.stringify(entry) + '\n');
|
|
256
|
-
|
|
257
|
-
const result = parseSessionFile(filePath);
|
|
258
|
-
|
|
259
|
-
expect(result.success).toBe(true);
|
|
260
|
-
expect(result.usage.inputTokens).toBe(1000);
|
|
261
|
-
expect(result.usage.outputTokens).toBe(500);
|
|
262
|
-
// modelUsage should be empty since no model specified
|
|
263
|
-
expect(Object.keys(result.usage.modelUsage)).toHaveLength(0);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
describe('parseSessionById', () => {
|
|
268
|
-
it('should combine getSessionFilePath and parseSessionFile', () => {
|
|
269
|
-
// Create the expected directory structure
|
|
270
|
-
const sessionId = 'test-session-id';
|
|
271
|
-
const cwd = tempDir;
|
|
272
|
-
const escapedPath = cwd.replace(/^\//, '').replace(/\//g, '-');
|
|
273
|
-
const projectsDir = path.join(tempDir, '.claude', 'projects', escapedPath);
|
|
274
|
-
fs.mkdirSync(projectsDir, { recursive: true });
|
|
275
|
-
|
|
276
|
-
// Create a session file
|
|
277
|
-
const entry = {
|
|
278
|
-
type: 'assistant',
|
|
279
|
-
message: {
|
|
280
|
-
model: 'claude-opus-4-6',
|
|
281
|
-
usage: {
|
|
282
|
-
input_tokens: 5000,
|
|
283
|
-
output_tokens: 2000,
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
fs.writeFileSync(path.join(projectsDir, `${sessionId}.jsonl`), JSON.stringify(entry) + '\n');
|
|
288
|
-
|
|
289
|
-
// Mock os.homedir to return tempDir
|
|
290
|
-
// Since we can't easily mock, we'll test parseSessionFile directly
|
|
291
|
-
// and just verify parseSessionById calls it correctly by testing with the expected path
|
|
292
|
-
const expectedPath = getSessionFilePath(sessionId, cwd);
|
|
293
|
-
|
|
294
|
-
// This will fail because the path is relative to actual homedir, but demonstrates the logic
|
|
295
|
-
const result = parseSessionById(sessionId, cwd);
|
|
296
|
-
|
|
297
|
-
// Will return error since file doesn't exist at real homedir path
|
|
298
|
-
expect(result.success).toBe(false);
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
});
|