gru-ai 0.1.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/skills/brainstorm/SKILL.md +340 -0
- package/.claude/skills/code-review-excellence/SKILL.md +198 -0
- package/.claude/skills/directive/SKILL.md +121 -0
- package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
- package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
- package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
- package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
- package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
- package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
- package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
- package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
- package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
- package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
- package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
- package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
- package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
- package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
- package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
- package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
- package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
- package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
- package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
- package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
- package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
- package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
- package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
- package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
- package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
- package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
- package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
- package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
- package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
- package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
- package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
- package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
- package/.claude/skills/frontend-design/SKILL.md +42 -0
- package/.claude/skills/gruai-agents/SKILL.md +161 -0
- package/.claude/skills/gruai-config/SKILL.md +61 -0
- package/.claude/skills/healthcheck/SKILL.md +216 -0
- package/.claude/skills/report/SKILL.md +380 -0
- package/.claude/skills/scout/SKILL.md +452 -0
- package/.claude/skills/seo-audit/SKILL.md +107 -0
- package/.claude/skills/walkthrough/SKILL.md +274 -0
- package/.claude/skills/webapp-testing/SKILL.md +96 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/cli/templates/CLAUDE.md.template +57 -0
- package/cli/templates/agent-roles/backend.md +47 -0
- package/cli/templates/agent-roles/cmo.md +52 -0
- package/cli/templates/agent-roles/content.md +48 -0
- package/cli/templates/agent-roles/coo.md +66 -0
- package/cli/templates/agent-roles/cpo.md +52 -0
- package/cli/templates/agent-roles/cto.md +63 -0
- package/cli/templates/agent-roles/data.md +46 -0
- package/cli/templates/agent-roles/design.md +46 -0
- package/cli/templates/agent-roles/frontend.md +47 -0
- package/cli/templates/agent-roles/fullstack.md +47 -0
- package/cli/templates/agent-roles/qa.md +46 -0
- package/cli/templates/backlog.json.template +3 -0
- package/cli/templates/directive.json.template +9 -0
- package/cli/templates/directive.md.template +23 -0
- package/cli/templates/goals-index.md +21 -0
- package/cli/templates/gruai.config.json.template +12 -0
- package/cli/templates/lessons.md +16 -0
- package/cli/templates/vision.md +35 -0
- package/cli/templates/welcome-directive/directive.json +9 -0
- package/cli/templates/welcome-directive/directive.md +53 -0
- package/dist/assets/GamePage-C5XQQOQH.js +49 -0
- package/dist/assets/README.md +17 -0
- package/dist/assets/characters/char_0.png +0 -0
- package/dist/assets/characters/char_1.png +0 -0
- package/dist/assets/characters/char_10.png +0 -0
- package/dist/assets/characters/char_11.png +0 -0
- package/dist/assets/characters/char_2.png +0 -0
- package/dist/assets/characters/char_3.png +0 -0
- package/dist/assets/characters/char_4.png +0 -0
- package/dist/assets/characters/char_5.png +0 -0
- package/dist/assets/characters/char_6.png +0 -0
- package/dist/assets/characters/char_7.png +0 -0
- package/dist/assets/characters/char_8.png +0 -0
- package/dist/assets/characters/char_9.png +0 -0
- package/dist/assets/index-CnTPDqpP.js +12 -0
- package/dist/assets/index-gR5q7ikB.css +1 -0
- package/dist/assets/office/furniture.png +0 -0
- package/dist/assets/office/room-builder.png +0 -0
- package/dist/index.html +16 -0
- package/dist-server/scripts/intelligence-trends.d.ts +100 -0
- package/dist-server/scripts/intelligence-trends.js +365 -0
- package/dist-server/server/actions/cleanup.d.ts +4 -0
- package/dist-server/server/actions/cleanup.js +30 -0
- package/dist-server/server/actions/send-input.d.ts +6 -0
- package/dist-server/server/actions/send-input.js +147 -0
- package/dist-server/server/actions/terminal.d.ts +4 -0
- package/dist-server/server/actions/terminal.js +427 -0
- package/dist-server/server/config.d.ts +9 -0
- package/dist-server/server/config.js +217 -0
- package/dist-server/server/db.d.ts +7 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/hooks/event-receiver.d.ts +11 -0
- package/dist-server/server/hooks/event-receiver.js +36 -0
- package/dist-server/server/index.d.ts +1 -0
- package/dist-server/server/index.js +552 -0
- package/dist-server/server/notifications/macos.d.ts +5 -0
- package/dist-server/server/notifications/macos.js +22 -0
- package/dist-server/server/notifications/notifier.d.ts +17 -0
- package/dist-server/server/notifications/notifier.js +110 -0
- package/dist-server/server/parsers/process-discovery.d.ts +39 -0
- package/dist-server/server/parsers/process-discovery.js +776 -0
- package/dist-server/server/parsers/session-scanner.d.ts +56 -0
- package/dist-server/server/parsers/session-scanner.js +390 -0
- package/dist-server/server/parsers/session-state.d.ts +68 -0
- package/dist-server/server/parsers/session-state.js +696 -0
- package/dist-server/server/parsers/session-state.test.d.ts +1 -0
- package/dist-server/server/parsers/session-state.test.js +950 -0
- package/dist-server/server/parsers/task-parser.d.ts +10 -0
- package/dist-server/server/parsers/task-parser.js +97 -0
- package/dist-server/server/parsers/team-parser.d.ts +3 -0
- package/dist-server/server/parsers/team-parser.js +67 -0
- package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
- package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
- package/dist-server/server/platform/claude-code.d.ts +34 -0
- package/dist-server/server/platform/claude-code.js +94 -0
- package/dist-server/server/platform/index.d.ts +5 -0
- package/dist-server/server/platform/index.js +1 -0
- package/dist-server/server/platform/types.d.ts +190 -0
- package/dist-server/server/platform/types.js +9 -0
- package/dist-server/server/state/aggregator.d.ts +42 -0
- package/dist-server/server/state/aggregator.js +1080 -0
- package/dist-server/server/state/work-item-types.d.ts +555 -0
- package/dist-server/server/state/work-item-types.js +168 -0
- package/dist-server/server/types.d.ts +237 -0
- package/dist-server/server/types.js +1 -0
- package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
- package/dist-server/server/watchers/claude-watcher.js +130 -0
- package/dist-server/server/watchers/context-watcher.d.ts +22 -0
- package/dist-server/server/watchers/context-watcher.js +125 -0
- package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
- package/dist-server/server/watchers/directive-watcher.js +497 -0
- package/dist-server/server/watchers/session-watcher.d.ts +18 -0
- package/dist-server/server/watchers/session-watcher.js +126 -0
- package/dist-server/server/watchers/state-watcher.d.ts +36 -0
- package/dist-server/server/watchers/state-watcher.js +369 -0
- package/package.json +68 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { bootstrapFromTail, getFileState, getAllFileStates, removeFileState, getOrBootstrap, processFileUpdate, toSessionActivity, machineStateToLastEntryType, } from './session-state.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
let tmpDir;
|
|
11
|
+
function tmpFile(name) {
|
|
12
|
+
return path.join(tmpDir, name);
|
|
13
|
+
}
|
|
14
|
+
/** Write a JSONL file from an array of entry objects. */
|
|
15
|
+
function writeJsonl(filePath, entries) {
|
|
16
|
+
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
17
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
/** Append JSONL entries to an existing file. */
|
|
20
|
+
function appendJsonl(filePath, entries) {
|
|
21
|
+
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
22
|
+
fs.appendFileSync(filePath, content, 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
// --- Entry factories ---
|
|
25
|
+
const ts = (offset) => `2026-02-23T10:00:${String(offset).padStart(2, '0')}Z`;
|
|
26
|
+
function userPrompt(text, extra = {}) {
|
|
27
|
+
return {
|
|
28
|
+
type: 'user',
|
|
29
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
30
|
+
timestamp: ts(0),
|
|
31
|
+
...extra,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function toolResult(content = 'result', count = 1) {
|
|
35
|
+
const results = Array.from({ length: count }, () => ({ type: 'tool_result', content }));
|
|
36
|
+
return {
|
|
37
|
+
type: 'user',
|
|
38
|
+
message: { role: 'user', content: results },
|
|
39
|
+
timestamp: ts(3),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function assistantText(text, extra = {}) {
|
|
43
|
+
return {
|
|
44
|
+
type: 'assistant',
|
|
45
|
+
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
|
46
|
+
timestamp: ts(1),
|
|
47
|
+
...extra,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function assistantToolUse(toolName, input = {}, extraBlocks = []) {
|
|
51
|
+
return {
|
|
52
|
+
type: 'assistant',
|
|
53
|
+
message: {
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: [
|
|
56
|
+
{ type: 'text', text: 'Let me check.' },
|
|
57
|
+
{ type: 'tool_use', name: toolName, input },
|
|
58
|
+
...extraBlocks,
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
timestamp: ts(2),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function assistantMultiToolUse(tools) {
|
|
65
|
+
const content = [{ type: 'text', text: 'Working on it.' }];
|
|
66
|
+
for (const t of tools) {
|
|
67
|
+
content.push({ type: 'tool_use', name: t.name, input: t.input ?? {} });
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
type: 'assistant',
|
|
71
|
+
message: { role: 'assistant', content },
|
|
72
|
+
timestamp: ts(2),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function turnEnd() {
|
|
76
|
+
return { type: 'system', subtype: 'turn_duration', timestamp: ts(4) };
|
|
77
|
+
}
|
|
78
|
+
function progressEntry() {
|
|
79
|
+
return { type: 'progress' };
|
|
80
|
+
}
|
|
81
|
+
function queueOpEntry() {
|
|
82
|
+
return { type: 'queue-operation' };
|
|
83
|
+
}
|
|
84
|
+
function fileHistoryEntry() {
|
|
85
|
+
return { type: 'file-history-snapshot' };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Bootstrap a temp JSONL file and return its state.
|
|
89
|
+
* Clears any prior cached state for the file first.
|
|
90
|
+
*/
|
|
91
|
+
function bootstrapEntries(name, entries) {
|
|
92
|
+
const fp = tmpFile(name);
|
|
93
|
+
writeJsonl(fp, entries);
|
|
94
|
+
removeFileState(fp);
|
|
95
|
+
return bootstrapFromTail(fp);
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Tests
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
before(() => {
|
|
101
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-state-test-'));
|
|
102
|
+
});
|
|
103
|
+
after(() => {
|
|
104
|
+
// Clean up all cached states
|
|
105
|
+
for (const [key] of getAllFileStates()) {
|
|
106
|
+
removeFileState(key);
|
|
107
|
+
}
|
|
108
|
+
// Remove temp dir
|
|
109
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
110
|
+
});
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
// Clear cached file states between tests for isolation
|
|
113
|
+
for (const [key] of getAllFileStates()) {
|
|
114
|
+
if (key.startsWith(tmpDir)) {
|
|
115
|
+
removeFileState(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// ===========================================================================
|
|
120
|
+
// State Machine Transitions
|
|
121
|
+
// ===========================================================================
|
|
122
|
+
describe('State machine transitions', () => {
|
|
123
|
+
it('1. USER_PROMPT sets state=working, resets counts to 0/0', () => {
|
|
124
|
+
const state = bootstrapEntries('t1.jsonl', [
|
|
125
|
+
userPrompt('Hello', { sessionId: 'sess-1' }),
|
|
126
|
+
]);
|
|
127
|
+
assert.ok(state);
|
|
128
|
+
assert.equal(state.machineState, 'working');
|
|
129
|
+
assert.equal(state.toolUseCount, 0);
|
|
130
|
+
assert.equal(state.toolResultCount, 0);
|
|
131
|
+
});
|
|
132
|
+
it('2. ASSISTANT_TOOL_USE sets state=working, toolUseCount incremented', () => {
|
|
133
|
+
const state = bootstrapEntries('t2.jsonl', [
|
|
134
|
+
userPrompt('Do something'),
|
|
135
|
+
assistantToolUse('Read', { file_path: '/foo/bar.ts' }),
|
|
136
|
+
]);
|
|
137
|
+
assert.ok(state);
|
|
138
|
+
assert.equal(state.machineState, 'working');
|
|
139
|
+
assert.equal(state.toolUseCount, 1);
|
|
140
|
+
assert.equal(state.lastToolName, 'Read');
|
|
141
|
+
});
|
|
142
|
+
it('3. TOOL_RESULT with counts matching stays working (not done)', () => {
|
|
143
|
+
const state = bootstrapEntries('t3.jsonl', [
|
|
144
|
+
userPrompt('Check file'),
|
|
145
|
+
assistantToolUse('Read', { file_path: '/foo.ts' }),
|
|
146
|
+
toolResult('file contents'),
|
|
147
|
+
]);
|
|
148
|
+
assert.ok(state);
|
|
149
|
+
assert.equal(state.machineState, 'working');
|
|
150
|
+
assert.equal(state.toolUseCount, 1);
|
|
151
|
+
assert.equal(state.toolResultCount, 1);
|
|
152
|
+
});
|
|
153
|
+
it('4. TOOL_RESULT with counts not matching stays working', () => {
|
|
154
|
+
const state = bootstrapEntries('t4.jsonl', [
|
|
155
|
+
userPrompt('Check files'),
|
|
156
|
+
assistantMultiToolUse([
|
|
157
|
+
{ name: 'Read', input: { file_path: '/a.ts' } },
|
|
158
|
+
{ name: 'Read', input: { file_path: '/b.ts' } },
|
|
159
|
+
]),
|
|
160
|
+
toolResult('contents of a', 1), // only 1 of 2 resolved
|
|
161
|
+
]);
|
|
162
|
+
assert.ok(state);
|
|
163
|
+
assert.equal(state.machineState, 'working');
|
|
164
|
+
assert.equal(state.toolUseCount, 2);
|
|
165
|
+
assert.equal(state.toolResultCount, 1);
|
|
166
|
+
});
|
|
167
|
+
it('5. ASSISTANT_TEXT (no question, tools resolved) sets state=done', () => {
|
|
168
|
+
const state = bootstrapEntries('t5.jsonl', [
|
|
169
|
+
userPrompt('Explain'),
|
|
170
|
+
assistantText('Here is the explanation.'),
|
|
171
|
+
]);
|
|
172
|
+
assert.ok(state);
|
|
173
|
+
assert.equal(state.machineState, 'done');
|
|
174
|
+
});
|
|
175
|
+
it('6. ASSISTANT_TEXT ending with ? sets state=needs_input', () => {
|
|
176
|
+
const state = bootstrapEntries('t6.jsonl', [
|
|
177
|
+
userPrompt('Do something'),
|
|
178
|
+
assistantText('Would you like me to proceed?'),
|
|
179
|
+
]);
|
|
180
|
+
assert.ok(state);
|
|
181
|
+
assert.equal(state.machineState, 'needs_input');
|
|
182
|
+
});
|
|
183
|
+
it('7. ASSISTANT_TEXT with pendingInputTool sets state=needs_input', () => {
|
|
184
|
+
const state = bootstrapEntries('t7.jsonl', [
|
|
185
|
+
userPrompt('Do something'),
|
|
186
|
+
assistantToolUse('AskUserQuestion', { question: 'Which file?' }),
|
|
187
|
+
toolResult('user answer'),
|
|
188
|
+
// pendingInputTool was set by AskUserQuestion, but cleared when tool resolved
|
|
189
|
+
// Let's test the case where tool is NOT yet resolved:
|
|
190
|
+
]);
|
|
191
|
+
// Actually, to test pendingInputTool with ASSISTANT_TEXT, we need a sequence where
|
|
192
|
+
// AskUserQuestion was issued, result came back (clearing pendingInputTool),
|
|
193
|
+
// but let me re-create: AskUserQuestion issued, NO result yet, then ASSISTANT_TEXT
|
|
194
|
+
const state2 = bootstrapEntries('t7b.jsonl', [
|
|
195
|
+
userPrompt('Do something'),
|
|
196
|
+
assistantToolUse('AskUserQuestion', { question: 'Which file?' }),
|
|
197
|
+
// No tool_result yet — pendingInputTool still true
|
|
198
|
+
assistantText('I need more info.'),
|
|
199
|
+
]);
|
|
200
|
+
assert.ok(state2);
|
|
201
|
+
assert.equal(state2.machineState, 'needs_input');
|
|
202
|
+
assert.equal(state2.pendingInputTool, true);
|
|
203
|
+
});
|
|
204
|
+
it('8. ASSISTANT_TOOL_USE with AskUserQuestion sets state=needs_input with pendingInputTool', () => {
|
|
205
|
+
const state = bootstrapEntries('t8.jsonl', [
|
|
206
|
+
userPrompt('Help me'),
|
|
207
|
+
assistantToolUse('AskUserQuestion', { question: 'What do you need?' }),
|
|
208
|
+
]);
|
|
209
|
+
assert.ok(state);
|
|
210
|
+
assert.equal(state.machineState, 'needs_input');
|
|
211
|
+
assert.equal(state.pendingInputTool, true);
|
|
212
|
+
assert.equal(state.lastToolName, 'AskUserQuestion');
|
|
213
|
+
assert.equal(state.lastToolDetail, 'Waiting for answer');
|
|
214
|
+
});
|
|
215
|
+
it('9. ASSISTANT_TOOL_USE with ExitPlanMode sets state=needs_input', () => {
|
|
216
|
+
const state = bootstrapEntries('t9.jsonl', [
|
|
217
|
+
userPrompt('Plan something'),
|
|
218
|
+
assistantToolUse('ExitPlanMode', {}),
|
|
219
|
+
]);
|
|
220
|
+
assert.ok(state);
|
|
221
|
+
assert.equal(state.machineState, 'needs_input');
|
|
222
|
+
assert.equal(state.pendingInputTool, true);
|
|
223
|
+
assert.equal(state.lastToolDetail, 'Plan ready for review');
|
|
224
|
+
});
|
|
225
|
+
it('10. TURN_END sets state=done', () => {
|
|
226
|
+
const state = bootstrapEntries('t10.jsonl', [
|
|
227
|
+
userPrompt('Do it'),
|
|
228
|
+
assistantText('Done.'),
|
|
229
|
+
turnEnd(),
|
|
230
|
+
]);
|
|
231
|
+
assert.ok(state);
|
|
232
|
+
assert.equal(state.machineState, 'done');
|
|
233
|
+
});
|
|
234
|
+
it('11. TURN_END with pendingInputTool sets state=needs_input', () => {
|
|
235
|
+
const state = bootstrapEntries('t11.jsonl', [
|
|
236
|
+
userPrompt('Help'),
|
|
237
|
+
assistantToolUse('AskUserQuestion', { question: 'Which?' }),
|
|
238
|
+
// No tool result — pendingInputTool stays true
|
|
239
|
+
turnEnd(),
|
|
240
|
+
]);
|
|
241
|
+
assert.ok(state);
|
|
242
|
+
assert.equal(state.machineState, 'needs_input');
|
|
243
|
+
assert.equal(state.pendingInputTool, true);
|
|
244
|
+
});
|
|
245
|
+
it('12. Multiple tool calls: TOOL_USE(2) -> RESULT(1) -> stays working -> RESULT(1) -> stays working', () => {
|
|
246
|
+
const state1 = bootstrapEntries('t12a.jsonl', [
|
|
247
|
+
userPrompt('Read two files'),
|
|
248
|
+
assistantMultiToolUse([
|
|
249
|
+
{ name: 'Read', input: { file_path: '/a.ts' } },
|
|
250
|
+
{ name: 'Read', input: { file_path: '/b.ts' } },
|
|
251
|
+
]),
|
|
252
|
+
toolResult('contents a', 1),
|
|
253
|
+
]);
|
|
254
|
+
assert.ok(state1);
|
|
255
|
+
assert.equal(state1.machineState, 'working');
|
|
256
|
+
assert.equal(state1.toolUseCount, 2);
|
|
257
|
+
assert.equal(state1.toolResultCount, 1);
|
|
258
|
+
const state2 = bootstrapEntries('t12b.jsonl', [
|
|
259
|
+
userPrompt('Read two files'),
|
|
260
|
+
assistantMultiToolUse([
|
|
261
|
+
{ name: 'Read', input: { file_path: '/a.ts' } },
|
|
262
|
+
{ name: 'Read', input: { file_path: '/b.ts' } },
|
|
263
|
+
]),
|
|
264
|
+
toolResult('contents a', 1),
|
|
265
|
+
toolResult('contents b', 1),
|
|
266
|
+
]);
|
|
267
|
+
assert.ok(state2);
|
|
268
|
+
assert.equal(state2.machineState, 'working');
|
|
269
|
+
assert.equal(state2.toolUseCount, 2);
|
|
270
|
+
assert.equal(state2.toolResultCount, 2);
|
|
271
|
+
});
|
|
272
|
+
it('13. Full turn: USER -> TOOL_USE -> RESULT -> TEXT -> done', () => {
|
|
273
|
+
const state = bootstrapEntries('t13.jsonl', [
|
|
274
|
+
userPrompt('Read and summarize'),
|
|
275
|
+
assistantToolUse('Read', { file_path: '/foo.ts' }),
|
|
276
|
+
toolResult('file contents'),
|
|
277
|
+
assistantText('The file contains a function.'),
|
|
278
|
+
turnEnd(),
|
|
279
|
+
]);
|
|
280
|
+
assert.ok(state);
|
|
281
|
+
assert.equal(state.machineState, 'done');
|
|
282
|
+
});
|
|
283
|
+
it('14. Full turn with question: USER -> TEXT ending with ? -> needs_input', () => {
|
|
284
|
+
const state = bootstrapEntries('t14.jsonl', [
|
|
285
|
+
userPrompt('Do something'),
|
|
286
|
+
assistantText('Would you like me to continue with the refactor?'),
|
|
287
|
+
]);
|
|
288
|
+
assert.ok(state);
|
|
289
|
+
assert.equal(state.machineState, 'needs_input');
|
|
290
|
+
});
|
|
291
|
+
it('15. Skip entries (progress, queue-operation, file-history-snapshot) cause no state change', () => {
|
|
292
|
+
const state = bootstrapEntries('t15.jsonl', [
|
|
293
|
+
userPrompt('Start'),
|
|
294
|
+
assistantToolUse('Read', { file_path: '/x.ts' }),
|
|
295
|
+
progressEntry(),
|
|
296
|
+
queueOpEntry(),
|
|
297
|
+
fileHistoryEntry(),
|
|
298
|
+
]);
|
|
299
|
+
assert.ok(state);
|
|
300
|
+
// State should still be working from the tool_use, skip entries don't change it
|
|
301
|
+
assert.equal(state.machineState, 'working');
|
|
302
|
+
assert.equal(state.toolUseCount, 1);
|
|
303
|
+
// messageCount should NOT include skip entries
|
|
304
|
+
assert.equal(state.messageCount, 2); // user + assistant_tool_use
|
|
305
|
+
});
|
|
306
|
+
it('16. Session exit: USER with no response stays working', () => {
|
|
307
|
+
const state = bootstrapEntries('t16.jsonl', [
|
|
308
|
+
userPrompt('Bye!', { sessionId: 'sess-exit' }),
|
|
309
|
+
]);
|
|
310
|
+
assert.ok(state);
|
|
311
|
+
assert.equal(state.machineState, 'working');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
// ===========================================================================
|
|
315
|
+
// Metadata Accumulation
|
|
316
|
+
// ===========================================================================
|
|
317
|
+
describe('Metadata accumulation', () => {
|
|
318
|
+
it('17. sessionId captured from first entry that has it', () => {
|
|
319
|
+
const state = bootstrapEntries('t17.jsonl', [
|
|
320
|
+
userPrompt('Hi', { sessionId: 'first-session-id' }),
|
|
321
|
+
assistantText('Hello.', { sessionId: 'second-session-id' }),
|
|
322
|
+
]);
|
|
323
|
+
assert.ok(state);
|
|
324
|
+
assert.equal(state.sessionId, 'first-session-id');
|
|
325
|
+
});
|
|
326
|
+
it('18. model, cwd, gitBranch, version, slug accumulated (newest wins)', () => {
|
|
327
|
+
const state = bootstrapEntries('t18.jsonl', [
|
|
328
|
+
userPrompt('Hi', {
|
|
329
|
+
sessionId: 'sess-18',
|
|
330
|
+
cwd: '/old/path',
|
|
331
|
+
version: '1.0.0',
|
|
332
|
+
gitBranch: 'main',
|
|
333
|
+
}),
|
|
334
|
+
{
|
|
335
|
+
type: 'assistant',
|
|
336
|
+
message: { role: 'assistant', model: 'claude-opus-4-6', content: [{ type: 'text', text: 'Hi!' }] },
|
|
337
|
+
timestamp: ts(1),
|
|
338
|
+
cwd: '/new/path',
|
|
339
|
+
version: '1.1.0',
|
|
340
|
+
gitBranch: 'feature',
|
|
341
|
+
slug: 'my-slug',
|
|
342
|
+
},
|
|
343
|
+
]);
|
|
344
|
+
assert.ok(state);
|
|
345
|
+
assert.equal(state.model, 'claude-opus-4-6');
|
|
346
|
+
assert.equal(state.cwd, '/new/path');
|
|
347
|
+
assert.equal(state.version, '1.1.0');
|
|
348
|
+
assert.equal(state.gitBranch, 'feature');
|
|
349
|
+
assert.equal(state.slug, 'my-slug');
|
|
350
|
+
});
|
|
351
|
+
it('19. tasksId extracted from entries referencing tasks directories', () => {
|
|
352
|
+
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
353
|
+
const state = bootstrapEntries('t19.jsonl', [
|
|
354
|
+
userPrompt('Check tasks'),
|
|
355
|
+
assistantToolUse('Read', { file_path: `/home/user/.claude/tasks/${uuid}/task-list.json` }),
|
|
356
|
+
]);
|
|
357
|
+
assert.ok(state);
|
|
358
|
+
assert.equal(state.tasksId, uuid);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// ===========================================================================
|
|
362
|
+
// machineStateToLastEntryType mapping
|
|
363
|
+
// ===========================================================================
|
|
364
|
+
describe('machineStateToLastEntryType', () => {
|
|
365
|
+
it('20. working maps to assistant-tool', () => {
|
|
366
|
+
const state = bootstrapEntries('t20.jsonl', [
|
|
367
|
+
userPrompt('Do it'),
|
|
368
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
369
|
+
]);
|
|
370
|
+
assert.ok(state);
|
|
371
|
+
assert.equal(state.machineState, 'working');
|
|
372
|
+
assert.equal(machineStateToLastEntryType(state), 'assistant-tool');
|
|
373
|
+
});
|
|
374
|
+
it('21. needs_input maps to assistant-question', () => {
|
|
375
|
+
const state = bootstrapEntries('t21.jsonl', [
|
|
376
|
+
userPrompt('Do something'),
|
|
377
|
+
assistantText('Should I proceed?'),
|
|
378
|
+
]);
|
|
379
|
+
assert.ok(state);
|
|
380
|
+
assert.equal(state.machineState, 'needs_input');
|
|
381
|
+
assert.equal(machineStateToLastEntryType(state), 'assistant-question');
|
|
382
|
+
});
|
|
383
|
+
it('22. done maps to assistant-text', () => {
|
|
384
|
+
const state = bootstrapEntries('t22.jsonl', [
|
|
385
|
+
userPrompt('Explain'),
|
|
386
|
+
assistantText('Here is the answer.'),
|
|
387
|
+
turnEnd(),
|
|
388
|
+
]);
|
|
389
|
+
assert.ok(state);
|
|
390
|
+
assert.equal(state.machineState, 'done');
|
|
391
|
+
assert.equal(machineStateToLastEntryType(state), 'assistant-text');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
// ===========================================================================
|
|
395
|
+
// toSessionActivity
|
|
396
|
+
// ===========================================================================
|
|
397
|
+
describe('toSessionActivity', () => {
|
|
398
|
+
it('23. Returns null if no sessionId', () => {
|
|
399
|
+
const state = bootstrapEntries('t23.jsonl', [
|
|
400
|
+
// No sessionId in any entry
|
|
401
|
+
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, timestamp: ts(0) },
|
|
402
|
+
]);
|
|
403
|
+
assert.ok(state);
|
|
404
|
+
assert.equal(state.sessionId, undefined);
|
|
405
|
+
const activity = toSessionActivity(state);
|
|
406
|
+
assert.equal(activity, null);
|
|
407
|
+
});
|
|
408
|
+
it('24. Returns activity with tool info when working', () => {
|
|
409
|
+
const state = bootstrapEntries('t24.jsonl', [
|
|
410
|
+
userPrompt('Check file', { sessionId: 'sess-24' }),
|
|
411
|
+
assistantToolUse('Read', { file_path: '/src/index.ts' }),
|
|
412
|
+
]);
|
|
413
|
+
assert.ok(state);
|
|
414
|
+
// Override mtimeMs to make it "active" (recent)
|
|
415
|
+
state.mtimeMs = Date.now();
|
|
416
|
+
const activity = toSessionActivity(state);
|
|
417
|
+
assert.ok(activity);
|
|
418
|
+
assert.equal(activity.sessionId, 'sess-24');
|
|
419
|
+
assert.equal(activity.tool, 'Read');
|
|
420
|
+
assert.equal(activity.detail, 'index.ts');
|
|
421
|
+
assert.equal(activity.active, true);
|
|
422
|
+
assert.equal(activity.thinking, false);
|
|
423
|
+
});
|
|
424
|
+
it('25. thinking=true when working with no tool and counts match', () => {
|
|
425
|
+
const state = bootstrapEntries('t25.jsonl', [
|
|
426
|
+
userPrompt('Think about it', { sessionId: 'sess-25' }),
|
|
427
|
+
]);
|
|
428
|
+
assert.ok(state);
|
|
429
|
+
// working, no lastToolName, toolUseCount === toolResultCount (both 0)
|
|
430
|
+
state.mtimeMs = Date.now();
|
|
431
|
+
const activity = toSessionActivity(state);
|
|
432
|
+
assert.ok(activity);
|
|
433
|
+
assert.equal(activity.thinking, true);
|
|
434
|
+
assert.equal(activity.tool, undefined);
|
|
435
|
+
});
|
|
436
|
+
it('25b. active=false when mtime is old', () => {
|
|
437
|
+
const state = bootstrapEntries('t25b.jsonl', [
|
|
438
|
+
userPrompt('Old session', { sessionId: 'sess-25b' }),
|
|
439
|
+
assistantText('Done.'),
|
|
440
|
+
turnEnd(),
|
|
441
|
+
]);
|
|
442
|
+
assert.ok(state);
|
|
443
|
+
// Make mtime old (> 5 minutes ago)
|
|
444
|
+
state.mtimeMs = Date.now() - 600_000;
|
|
445
|
+
const activity = toSessionActivity(state);
|
|
446
|
+
assert.ok(activity);
|
|
447
|
+
assert.equal(activity.active, false);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
// ===========================================================================
|
|
451
|
+
// Incremental reading (processFileUpdate)
|
|
452
|
+
// ===========================================================================
|
|
453
|
+
describe('processFileUpdate (incremental reading)', () => {
|
|
454
|
+
it('26. New bytes appended reads only new entries', () => {
|
|
455
|
+
const fp = tmpFile('t26.jsonl');
|
|
456
|
+
writeJsonl(fp, [
|
|
457
|
+
userPrompt('Start', { sessionId: 'sess-26' }),
|
|
458
|
+
assistantText('OK.'),
|
|
459
|
+
turnEnd(),
|
|
460
|
+
]);
|
|
461
|
+
removeFileState(fp);
|
|
462
|
+
const initial = bootstrapFromTail(fp);
|
|
463
|
+
assert.ok(initial);
|
|
464
|
+
assert.equal(initial.machineState, 'done');
|
|
465
|
+
assert.equal(initial.messageCount, 3);
|
|
466
|
+
// Append new entries
|
|
467
|
+
appendJsonl(fp, [
|
|
468
|
+
userPrompt('Next question'),
|
|
469
|
+
assistantToolUse('Bash', { command: 'echo hello' }),
|
|
470
|
+
]);
|
|
471
|
+
const updated = processFileUpdate(fp);
|
|
472
|
+
assert.ok(updated);
|
|
473
|
+
assert.equal(updated.machineState, 'working');
|
|
474
|
+
assert.equal(updated.messageCount, 5); // 3 original + 2 new
|
|
475
|
+
assert.equal(updated.lastToolName, 'Bash');
|
|
476
|
+
assert.equal(updated.toolUseCount, 1); // reset by USER_PROMPT then +1
|
|
477
|
+
});
|
|
478
|
+
it('27. File truncated triggers re-bootstrap', () => {
|
|
479
|
+
const fp = tmpFile('t27.jsonl');
|
|
480
|
+
writeJsonl(fp, [
|
|
481
|
+
userPrompt('Start', { sessionId: 'sess-27' }),
|
|
482
|
+
assistantText('Long response with lots of content.'),
|
|
483
|
+
turnEnd(),
|
|
484
|
+
]);
|
|
485
|
+
removeFileState(fp);
|
|
486
|
+
const initial = bootstrapFromTail(fp);
|
|
487
|
+
assert.ok(initial);
|
|
488
|
+
const originalSize = initial.byteOffset;
|
|
489
|
+
// Truncate the file (simulate recreation with smaller content)
|
|
490
|
+
writeJsonl(fp, [
|
|
491
|
+
userPrompt('Fresh start', { sessionId: 'sess-27-new' }),
|
|
492
|
+
]);
|
|
493
|
+
const stat = fs.statSync(fp);
|
|
494
|
+
assert.ok(stat.size < originalSize, 'File should be smaller after truncation');
|
|
495
|
+
const updated = processFileUpdate(fp);
|
|
496
|
+
assert.ok(updated);
|
|
497
|
+
assert.equal(updated.sessionId, 'sess-27-new');
|
|
498
|
+
assert.equal(updated.machineState, 'working');
|
|
499
|
+
});
|
|
500
|
+
it('28. No new data returns null', () => {
|
|
501
|
+
const fp = tmpFile('t28.jsonl');
|
|
502
|
+
writeJsonl(fp, [
|
|
503
|
+
userPrompt('Start', { sessionId: 'sess-28' }),
|
|
504
|
+
assistantText('Done.'),
|
|
505
|
+
]);
|
|
506
|
+
removeFileState(fp);
|
|
507
|
+
bootstrapFromTail(fp);
|
|
508
|
+
// Call processFileUpdate without changing the file
|
|
509
|
+
const result = processFileUpdate(fp);
|
|
510
|
+
assert.equal(result, null);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
// ===========================================================================
|
|
514
|
+
// extractDetail (tested through tool use entries)
|
|
515
|
+
// ===========================================================================
|
|
516
|
+
describe('extractDetail (via tool use entries)', () => {
|
|
517
|
+
it('29. Read/Edit/Write extracts file basename', () => {
|
|
518
|
+
const stateRead = bootstrapEntries('t29a.jsonl', [
|
|
519
|
+
userPrompt('Read file'),
|
|
520
|
+
assistantToolUse('Read', { file_path: '/home/user/project/src/components/Button.tsx' }),
|
|
521
|
+
]);
|
|
522
|
+
assert.ok(stateRead);
|
|
523
|
+
assert.equal(stateRead.lastToolDetail, 'Button.tsx');
|
|
524
|
+
const stateEdit = bootstrapEntries('t29b.jsonl', [
|
|
525
|
+
userPrompt('Edit file'),
|
|
526
|
+
assistantToolUse('Edit', { file_path: '/home/user/server/index.ts' }),
|
|
527
|
+
]);
|
|
528
|
+
assert.ok(stateEdit);
|
|
529
|
+
assert.equal(stateEdit.lastToolDetail, 'index.ts');
|
|
530
|
+
const stateWrite = bootstrapEntries('t29c.jsonl', [
|
|
531
|
+
userPrompt('Write file'),
|
|
532
|
+
assistantToolUse('Write', { file_path: '/tmp/output.json' }),
|
|
533
|
+
]);
|
|
534
|
+
assert.ok(stateWrite);
|
|
535
|
+
assert.equal(stateWrite.lastToolDetail, 'output.json');
|
|
536
|
+
});
|
|
537
|
+
it('30. Bash extracts first 40 chars of command', () => {
|
|
538
|
+
const shortCmd = 'ls -la';
|
|
539
|
+
const state1 = bootstrapEntries('t30a.jsonl', [
|
|
540
|
+
userPrompt('Run command'),
|
|
541
|
+
assistantToolUse('Bash', { command: shortCmd }),
|
|
542
|
+
]);
|
|
543
|
+
assert.ok(state1);
|
|
544
|
+
assert.equal(state1.lastToolDetail, shortCmd);
|
|
545
|
+
const longCmd = 'find /usr/local/lib -name "*.so" -type f -exec ls -la {} \\; | sort -k5 -n -r';
|
|
546
|
+
const state2 = bootstrapEntries('t30b.jsonl', [
|
|
547
|
+
userPrompt('Run long command'),
|
|
548
|
+
assistantToolUse('Bash', { command: longCmd }),
|
|
549
|
+
]);
|
|
550
|
+
assert.ok(state2);
|
|
551
|
+
assert.equal(state2.lastToolDetail, longCmd.slice(0, 40));
|
|
552
|
+
});
|
|
553
|
+
it('31. Grep extracts pattern', () => {
|
|
554
|
+
const state = bootstrapEntries('t31.jsonl', [
|
|
555
|
+
userPrompt('Search'),
|
|
556
|
+
assistantToolUse('Grep', { pattern: 'export function' }),
|
|
557
|
+
]);
|
|
558
|
+
assert.ok(state);
|
|
559
|
+
assert.equal(state.lastToolDetail, 'export function');
|
|
560
|
+
});
|
|
561
|
+
it('32. AskUserQuestion detail is "Waiting for answer"', () => {
|
|
562
|
+
const state = bootstrapEntries('t32.jsonl', [
|
|
563
|
+
userPrompt('Help'),
|
|
564
|
+
assistantToolUse('AskUserQuestion', { question: 'What file?' }),
|
|
565
|
+
]);
|
|
566
|
+
assert.ok(state);
|
|
567
|
+
assert.equal(state.lastToolDetail, 'Waiting for answer');
|
|
568
|
+
});
|
|
569
|
+
it('33. ExitPlanMode detail is "Plan ready for review"', () => {
|
|
570
|
+
const state = bootstrapEntries('t33.jsonl', [
|
|
571
|
+
userPrompt('Plan'),
|
|
572
|
+
assistantToolUse('ExitPlanMode', {}),
|
|
573
|
+
]);
|
|
574
|
+
assert.ok(state);
|
|
575
|
+
assert.equal(state.lastToolDetail, 'Plan ready for review');
|
|
576
|
+
});
|
|
577
|
+
it('33b. EnterPlanMode detail is "Requesting plan mode"', () => {
|
|
578
|
+
const state = bootstrapEntries('t33b.jsonl', [
|
|
579
|
+
userPrompt('Plan'),
|
|
580
|
+
assistantToolUse('EnterPlanMode', {}),
|
|
581
|
+
]);
|
|
582
|
+
assert.ok(state);
|
|
583
|
+
assert.equal(state.lastToolDetail, 'Requesting plan mode');
|
|
584
|
+
assert.equal(state.pendingInputTool, true);
|
|
585
|
+
assert.equal(state.machineState, 'needs_input');
|
|
586
|
+
});
|
|
587
|
+
it('33c. Task tool detail is "Spawned agent"', () => {
|
|
588
|
+
const state = bootstrapEntries('t33c.jsonl', [
|
|
589
|
+
userPrompt('Spawn'),
|
|
590
|
+
assistantToolUse('Task', { prompt: 'Do something' }),
|
|
591
|
+
]);
|
|
592
|
+
assert.ok(state);
|
|
593
|
+
assert.equal(state.lastToolDetail, 'Spawned agent');
|
|
594
|
+
});
|
|
595
|
+
it('33d. Unknown tool returns tool name as detail', () => {
|
|
596
|
+
const state = bootstrapEntries('t33d.jsonl', [
|
|
597
|
+
userPrompt('Custom'),
|
|
598
|
+
assistantToolUse('SomeCustomTool', { data: 'x' }),
|
|
599
|
+
]);
|
|
600
|
+
assert.ok(state);
|
|
601
|
+
assert.equal(state.lastToolDetail, 'SomeCustomTool');
|
|
602
|
+
});
|
|
603
|
+
it('33e. Tool with no input returns tool name', () => {
|
|
604
|
+
const state = bootstrapEntries('t33e.jsonl', [
|
|
605
|
+
userPrompt('Check'),
|
|
606
|
+
{
|
|
607
|
+
type: 'assistant',
|
|
608
|
+
message: {
|
|
609
|
+
role: 'assistant',
|
|
610
|
+
content: [{ type: 'tool_use', name: 'Read' }], // no input field
|
|
611
|
+
},
|
|
612
|
+
timestamp: ts(2),
|
|
613
|
+
},
|
|
614
|
+
]);
|
|
615
|
+
assert.ok(state);
|
|
616
|
+
assert.equal(state.lastToolDetail, 'Read');
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
// ===========================================================================
|
|
620
|
+
// Edge cases and complex scenarios
|
|
621
|
+
// ===========================================================================
|
|
622
|
+
describe('Edge cases', () => {
|
|
623
|
+
it('Empty file returns null', () => {
|
|
624
|
+
const fp = tmpFile('empty.jsonl');
|
|
625
|
+
fs.writeFileSync(fp, '', 'utf-8');
|
|
626
|
+
removeFileState(fp);
|
|
627
|
+
const state = bootstrapFromTail(fp);
|
|
628
|
+
assert.equal(state, null);
|
|
629
|
+
});
|
|
630
|
+
it('Malformed JSON lines are skipped gracefully', () => {
|
|
631
|
+
const fp = tmpFile('malformed.jsonl');
|
|
632
|
+
fs.writeFileSync(fp, [
|
|
633
|
+
JSON.stringify(userPrompt('Hi', { sessionId: 'sess-mal' })),
|
|
634
|
+
'this is not json {{{',
|
|
635
|
+
JSON.stringify(assistantText('Hello.')),
|
|
636
|
+
'',
|
|
637
|
+
].join('\n'), 'utf-8');
|
|
638
|
+
removeFileState(fp);
|
|
639
|
+
const state = bootstrapFromTail(fp);
|
|
640
|
+
assert.ok(state);
|
|
641
|
+
assert.equal(state.sessionId, 'sess-mal');
|
|
642
|
+
assert.equal(state.machineState, 'done');
|
|
643
|
+
assert.equal(state.messageCount, 2); // user + assistant text (malformed skipped)
|
|
644
|
+
});
|
|
645
|
+
it('getOrBootstrap returns cached state on second call', () => {
|
|
646
|
+
const fp = tmpFile('getorboot.jsonl');
|
|
647
|
+
writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-gob' })]);
|
|
648
|
+
removeFileState(fp);
|
|
649
|
+
const first = getOrBootstrap(fp);
|
|
650
|
+
assert.ok(first);
|
|
651
|
+
assert.equal(first.sessionId, 'sess-gob');
|
|
652
|
+
// Modify file but don't clear cache — getOrBootstrap should return cached
|
|
653
|
+
appendJsonl(fp, [assistantText('Bye.')]);
|
|
654
|
+
const second = getOrBootstrap(fp);
|
|
655
|
+
assert.ok(second);
|
|
656
|
+
assert.strictEqual(first, second); // same object reference
|
|
657
|
+
// machineState unchanged because we didn't processFileUpdate
|
|
658
|
+
assert.equal(second.machineState, 'working');
|
|
659
|
+
});
|
|
660
|
+
it('removeFileState clears cached state', () => {
|
|
661
|
+
const fp = tmpFile('remove.jsonl');
|
|
662
|
+
writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-rm' })]);
|
|
663
|
+
removeFileState(fp);
|
|
664
|
+
bootstrapFromTail(fp);
|
|
665
|
+
assert.ok(getFileState(fp));
|
|
666
|
+
removeFileState(fp);
|
|
667
|
+
assert.equal(getFileState(fp), undefined);
|
|
668
|
+
});
|
|
669
|
+
it('processFileUpdate on unknown file bootstraps it', () => {
|
|
670
|
+
const fp = tmpFile('newfile.jsonl');
|
|
671
|
+
writeJsonl(fp, [
|
|
672
|
+
userPrompt('Brand new', { sessionId: 'sess-new' }),
|
|
673
|
+
assistantText('Welcome.'),
|
|
674
|
+
]);
|
|
675
|
+
removeFileState(fp);
|
|
676
|
+
// processFileUpdate should bootstrap since no cached state
|
|
677
|
+
const state = processFileUpdate(fp);
|
|
678
|
+
assert.ok(state);
|
|
679
|
+
assert.equal(state.sessionId, 'sess-new');
|
|
680
|
+
assert.equal(state.machineState, 'done');
|
|
681
|
+
});
|
|
682
|
+
it('processFileUpdate on deleted file returns null and clears state', () => {
|
|
683
|
+
const fp = tmpFile('todelete.jsonl');
|
|
684
|
+
writeJsonl(fp, [userPrompt('Hi', { sessionId: 'sess-del' })]);
|
|
685
|
+
removeFileState(fp);
|
|
686
|
+
bootstrapFromTail(fp);
|
|
687
|
+
assert.ok(getFileState(fp));
|
|
688
|
+
// Delete the file
|
|
689
|
+
fs.unlinkSync(fp);
|
|
690
|
+
const result = processFileUpdate(fp);
|
|
691
|
+
assert.equal(result, null);
|
|
692
|
+
assert.equal(getFileState(fp), undefined);
|
|
693
|
+
});
|
|
694
|
+
it('USER_PROMPT resets tool counts and clears pendingInputTool', () => {
|
|
695
|
+
const state = bootstrapEntries('treset.jsonl', [
|
|
696
|
+
userPrompt('First question', { sessionId: 'sess-reset' }),
|
|
697
|
+
assistantToolUse('AskUserQuestion', { question: 'Which?' }),
|
|
698
|
+
// pendingInputTool = true, toolUseCount = 1
|
|
699
|
+
// Now a new user prompt should reset everything
|
|
700
|
+
userPrompt('Second question'),
|
|
701
|
+
]);
|
|
702
|
+
assert.ok(state);
|
|
703
|
+
assert.equal(state.machineState, 'working');
|
|
704
|
+
assert.equal(state.toolUseCount, 0);
|
|
705
|
+
assert.equal(state.toolResultCount, 0);
|
|
706
|
+
assert.equal(state.pendingInputTool, false);
|
|
707
|
+
assert.equal(state.lastToolName, undefined);
|
|
708
|
+
assert.equal(state.lastToolDetail, undefined);
|
|
709
|
+
});
|
|
710
|
+
it('ASSISTANT_TEXT with tools still running stays working', () => {
|
|
711
|
+
const state = bootstrapEntries('tstillrunning.jsonl', [
|
|
712
|
+
userPrompt('Do two things'),
|
|
713
|
+
assistantMultiToolUse([
|
|
714
|
+
{ name: 'Read', input: { file_path: '/a.ts' } },
|
|
715
|
+
{ name: 'Bash', input: { command: 'echo hi' } },
|
|
716
|
+
]),
|
|
717
|
+
toolResult('contents', 1), // only 1 of 2 resolved
|
|
718
|
+
assistantText('Partial update.'), // text while tools still pending
|
|
719
|
+
]);
|
|
720
|
+
assert.ok(state);
|
|
721
|
+
assert.equal(state.machineState, 'working');
|
|
722
|
+
assert.equal(state.toolUseCount, 2);
|
|
723
|
+
assert.equal(state.toolResultCount, 1);
|
|
724
|
+
});
|
|
725
|
+
it('TOOL_RESULT clears pendingInputTool when all tools resolve', () => {
|
|
726
|
+
const state = bootstrapEntries('tclear.jsonl', [
|
|
727
|
+
userPrompt('Ask something'),
|
|
728
|
+
assistantToolUse('AskUserQuestion', { question: 'What?' }),
|
|
729
|
+
toolResult('user said yes'),
|
|
730
|
+
]);
|
|
731
|
+
assert.ok(state);
|
|
732
|
+
// pendingInputTool should be cleared because toolResultCount >= toolUseCount
|
|
733
|
+
assert.equal(state.pendingInputTool, false);
|
|
734
|
+
assert.equal(state.machineState, 'working');
|
|
735
|
+
});
|
|
736
|
+
it('Multiple tool use blocks in a single message are all counted', () => {
|
|
737
|
+
const state = bootstrapEntries('tmulti.jsonl', [
|
|
738
|
+
userPrompt('Do many things'),
|
|
739
|
+
assistantMultiToolUse([
|
|
740
|
+
{ name: 'Read', input: { file_path: '/a.ts' } },
|
|
741
|
+
{ name: 'Read', input: { file_path: '/b.ts' } },
|
|
742
|
+
{ name: 'Grep', input: { pattern: 'foo' } },
|
|
743
|
+
]),
|
|
744
|
+
]);
|
|
745
|
+
assert.ok(state);
|
|
746
|
+
assert.equal(state.toolUseCount, 3);
|
|
747
|
+
// lastToolName should be the LAST tool in the list
|
|
748
|
+
assert.equal(state.lastToolName, 'Grep');
|
|
749
|
+
assert.equal(state.lastToolDetail, 'foo');
|
|
750
|
+
});
|
|
751
|
+
it('System entry without turn_duration subtype is skipped', () => {
|
|
752
|
+
const state = bootstrapEntries('tsysother.jsonl', [
|
|
753
|
+
userPrompt('Hi', { sessionId: 'sess-sys' }),
|
|
754
|
+
{ type: 'system', subtype: 'something_else', timestamp: ts(4) },
|
|
755
|
+
]);
|
|
756
|
+
assert.ok(state);
|
|
757
|
+
assert.equal(state.machineState, 'working');
|
|
758
|
+
assert.equal(state.messageCount, 1); // only the user prompt counts
|
|
759
|
+
});
|
|
760
|
+
it('ASSISTANT_TEXT question mark detection uses trimEnd', () => {
|
|
761
|
+
// Question with trailing whitespace should still be detected
|
|
762
|
+
const state = bootstrapEntries('ttrimq.jsonl', [
|
|
763
|
+
userPrompt('Check'),
|
|
764
|
+
assistantText('Do you want to continue? '), // trailing spaces
|
|
765
|
+
]);
|
|
766
|
+
assert.ok(state);
|
|
767
|
+
assert.equal(state.machineState, 'needs_input');
|
|
768
|
+
});
|
|
769
|
+
it('ASSISTANT_TEXT uses LAST text block for question detection', () => {
|
|
770
|
+
// First block ends with ?, but last block does not -> should be done
|
|
771
|
+
const state = bootstrapEntries('tlastblock.jsonl', [
|
|
772
|
+
userPrompt('Multi-block'),
|
|
773
|
+
{
|
|
774
|
+
type: 'assistant',
|
|
775
|
+
message: {
|
|
776
|
+
role: 'assistant',
|
|
777
|
+
content: [
|
|
778
|
+
{ type: 'text', text: 'Is this good?' },
|
|
779
|
+
{ type: 'text', text: 'I went ahead and did it.' },
|
|
780
|
+
],
|
|
781
|
+
},
|
|
782
|
+
timestamp: ts(1),
|
|
783
|
+
},
|
|
784
|
+
]);
|
|
785
|
+
assert.ok(state);
|
|
786
|
+
assert.equal(state.machineState, 'done');
|
|
787
|
+
});
|
|
788
|
+
it('ASSISTANT_TEXT with LAST text block ending in ? sets needs_input', () => {
|
|
789
|
+
const state = bootstrapEntries('tlastblockq.jsonl', [
|
|
790
|
+
userPrompt('Multi-block'),
|
|
791
|
+
{
|
|
792
|
+
type: 'assistant',
|
|
793
|
+
message: {
|
|
794
|
+
role: 'assistant',
|
|
795
|
+
content: [
|
|
796
|
+
{ type: 'text', text: 'I did some work.' },
|
|
797
|
+
{ type: 'text', text: 'Should I continue?' },
|
|
798
|
+
],
|
|
799
|
+
},
|
|
800
|
+
timestamp: ts(1),
|
|
801
|
+
},
|
|
802
|
+
]);
|
|
803
|
+
assert.ok(state);
|
|
804
|
+
assert.equal(state.machineState, 'needs_input');
|
|
805
|
+
});
|
|
806
|
+
it('lastActivityAt updated from timestamps', () => {
|
|
807
|
+
const state = bootstrapEntries('tactivity.jsonl', [
|
|
808
|
+
userPrompt('First', { sessionId: 'sess-act' }),
|
|
809
|
+
assistantText('Response.'),
|
|
810
|
+
{ type: 'system', subtype: 'turn_duration', timestamp: '2026-02-23T10:05:00Z' },
|
|
811
|
+
]);
|
|
812
|
+
assert.ok(state);
|
|
813
|
+
assert.equal(state.lastActivityAt, '2026-02-23T10:05:00Z');
|
|
814
|
+
});
|
|
815
|
+
it('messageCount increments only for non-SKIP events', () => {
|
|
816
|
+
const state = bootstrapEntries('tcount.jsonl', [
|
|
817
|
+
userPrompt('Start'), // +1 USER_PROMPT
|
|
818
|
+
progressEntry(), // SKIP
|
|
819
|
+
assistantToolUse('Read', { file_path: '/x.ts' }), // +1 ASSISTANT_TOOL_USE
|
|
820
|
+
queueOpEntry(), // SKIP
|
|
821
|
+
toolResult('contents'), // +1 TOOL_RESULT
|
|
822
|
+
fileHistoryEntry(), // SKIP
|
|
823
|
+
assistantText('Done.'), // +1 ASSISTANT_TEXT
|
|
824
|
+
turnEnd(), // +1 TURN_END
|
|
825
|
+
]);
|
|
826
|
+
assert.ok(state);
|
|
827
|
+
assert.equal(state.messageCount, 5);
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
// ===========================================================================
|
|
831
|
+
// Complex multi-turn scenarios
|
|
832
|
+
// ===========================================================================
|
|
833
|
+
describe('Complex multi-turn scenarios', () => {
|
|
834
|
+
it('Two complete turns with tool usage', () => {
|
|
835
|
+
const state = bootstrapEntries('ttwoturns.jsonl', [
|
|
836
|
+
// Turn 1
|
|
837
|
+
userPrompt('Read a file', { sessionId: 'sess-2t' }),
|
|
838
|
+
assistantToolUse('Read', { file_path: '/src/main.ts' }),
|
|
839
|
+
toolResult('function main() {}'),
|
|
840
|
+
assistantText('The file contains a main function.'),
|
|
841
|
+
turnEnd(),
|
|
842
|
+
// Turn 2
|
|
843
|
+
userPrompt('Now edit it'),
|
|
844
|
+
assistantToolUse('Edit', { file_path: '/src/main.ts' }),
|
|
845
|
+
toolResult('file edited'),
|
|
846
|
+
assistantText('I have edited the file.'),
|
|
847
|
+
turnEnd(),
|
|
848
|
+
]);
|
|
849
|
+
assert.ok(state);
|
|
850
|
+
assert.equal(state.machineState, 'done');
|
|
851
|
+
assert.equal(state.lastToolName, 'Edit');
|
|
852
|
+
// toolUseCount/toolResultCount reflect the latest turn (reset by USER_PROMPT)
|
|
853
|
+
assert.equal(state.toolUseCount, 1);
|
|
854
|
+
assert.equal(state.toolResultCount, 1);
|
|
855
|
+
});
|
|
856
|
+
it('Turn interrupted by new user prompt resets state', () => {
|
|
857
|
+
const state = bootstrapEntries('tinterrupt.jsonl', [
|
|
858
|
+
userPrompt('Start task A', { sessionId: 'sess-int' }),
|
|
859
|
+
assistantToolUse('Bash', { command: 'npm test' }),
|
|
860
|
+
// User interrupts before tool result
|
|
861
|
+
userPrompt('Actually, do task B'),
|
|
862
|
+
assistantText('OK, switching to task B.'),
|
|
863
|
+
]);
|
|
864
|
+
assert.ok(state);
|
|
865
|
+
assert.equal(state.machineState, 'done');
|
|
866
|
+
assert.equal(state.toolUseCount, 0); // reset by second USER_PROMPT
|
|
867
|
+
assert.equal(state.toolResultCount, 0);
|
|
868
|
+
});
|
|
869
|
+
it('Incremental update across multiple calls', () => {
|
|
870
|
+
const fp = tmpFile('tincremental.jsonl');
|
|
871
|
+
// Phase 1: Initial content
|
|
872
|
+
writeJsonl(fp, [
|
|
873
|
+
userPrompt('Begin', { sessionId: 'sess-inc' }),
|
|
874
|
+
]);
|
|
875
|
+
removeFileState(fp);
|
|
876
|
+
const s1 = bootstrapFromTail(fp);
|
|
877
|
+
assert.ok(s1);
|
|
878
|
+
assert.equal(s1.machineState, 'working');
|
|
879
|
+
// Phase 2: Assistant responds with tool use
|
|
880
|
+
appendJsonl(fp, [
|
|
881
|
+
assistantToolUse('Grep', { pattern: 'TODO' }),
|
|
882
|
+
]);
|
|
883
|
+
const s2 = processFileUpdate(fp);
|
|
884
|
+
assert.ok(s2);
|
|
885
|
+
assert.equal(s2.machineState, 'working');
|
|
886
|
+
assert.equal(s2.lastToolName, 'Grep');
|
|
887
|
+
assert.equal(s2.toolUseCount, 1);
|
|
888
|
+
// Phase 3: Tool result
|
|
889
|
+
appendJsonl(fp, [
|
|
890
|
+
toolResult('TODO: fix this'),
|
|
891
|
+
]);
|
|
892
|
+
const s3 = processFileUpdate(fp);
|
|
893
|
+
assert.ok(s3);
|
|
894
|
+
assert.equal(s3.machineState, 'working');
|
|
895
|
+
assert.equal(s3.toolResultCount, 1);
|
|
896
|
+
// Phase 4: Final text + turn end
|
|
897
|
+
appendJsonl(fp, [
|
|
898
|
+
assistantText('Found one TODO.'),
|
|
899
|
+
turnEnd(),
|
|
900
|
+
]);
|
|
901
|
+
const s4 = processFileUpdate(fp);
|
|
902
|
+
assert.ok(s4);
|
|
903
|
+
assert.equal(s4.machineState, 'done');
|
|
904
|
+
assert.equal(s4.messageCount, 5); // user + tool_use + tool_result + text + turn_end
|
|
905
|
+
});
|
|
906
|
+
it('AskUserQuestion -> result -> continue -> done flow', () => {
|
|
907
|
+
const state = bootstrapEntries('taskflow.jsonl', [
|
|
908
|
+
userPrompt('Help me', { sessionId: 'sess-ask' }),
|
|
909
|
+
assistantToolUse('AskUserQuestion', { question: 'Which environment?' }),
|
|
910
|
+
toolResult('production'), // user answered
|
|
911
|
+
assistantToolUse('Bash', { command: 'deploy --env production' }),
|
|
912
|
+
toolResult('deployed successfully'),
|
|
913
|
+
assistantText('Deployed to production.'),
|
|
914
|
+
turnEnd(),
|
|
915
|
+
]);
|
|
916
|
+
assert.ok(state);
|
|
917
|
+
assert.equal(state.machineState, 'done');
|
|
918
|
+
assert.equal(state.pendingInputTool, false);
|
|
919
|
+
assert.equal(state.lastToolName, 'Bash');
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
// ===========================================================================
|
|
923
|
+
// Bash tool detail edge cases
|
|
924
|
+
// ===========================================================================
|
|
925
|
+
describe('Bash detail edge cases', () => {
|
|
926
|
+
it('Bash with no command field returns "bash"', () => {
|
|
927
|
+
const state = bootstrapEntries('tbashno.jsonl', [
|
|
928
|
+
userPrompt('Run'),
|
|
929
|
+
assistantToolUse('Bash', {}), // no command field
|
|
930
|
+
]);
|
|
931
|
+
assert.ok(state);
|
|
932
|
+
assert.equal(state.lastToolDetail, 'bash');
|
|
933
|
+
});
|
|
934
|
+
it('Grep with no pattern field returns "grep"', () => {
|
|
935
|
+
const state = bootstrapEntries('tgrepno.jsonl', [
|
|
936
|
+
userPrompt('Search'),
|
|
937
|
+
assistantToolUse('Grep', {}), // no pattern field
|
|
938
|
+
]);
|
|
939
|
+
assert.ok(state);
|
|
940
|
+
assert.equal(state.lastToolDetail, 'grep');
|
|
941
|
+
});
|
|
942
|
+
it('Read with no file_path returns "Read"', () => {
|
|
943
|
+
const state = bootstrapEntries('treadnofp.jsonl', [
|
|
944
|
+
userPrompt('Read'),
|
|
945
|
+
assistantToolUse('Read', {}), // no file_path
|
|
946
|
+
]);
|
|
947
|
+
assert.ok(state);
|
|
948
|
+
assert.equal(state.lastToolDetail, 'Read');
|
|
949
|
+
});
|
|
950
|
+
});
|