@gswangg/duncan-cc 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/README.md +110 -0
- package/SPEC.md +195 -0
- package/package.json +39 -0
- package/src/content-replacements.ts +185 -0
- package/src/discovery.ts +340 -0
- package/src/mcp-server.ts +356 -0
- package/src/normalize.ts +702 -0
- package/src/parser.ts +257 -0
- package/src/pipeline.ts +274 -0
- package/src/query.ts +626 -0
- package/src/system-prompt.ts +408 -0
- package/src/tree.ts +371 -0
- package/tests/_skip-if-no-corpus.ts +12 -0
- package/tests/compaction.test.ts +205 -0
- package/tests/content-replacements.test.ts +214 -0
- package/tests/discovery.test.ts +129 -0
- package/tests/normalize.test.ts +192 -0
- package/tests/parity.test.ts +226 -0
- package/tests/parser-tree.test.ts +268 -0
- package/tests/pipeline.test.ts +174 -0
- package/tests/self-exclusion.test.ts +272 -0
- package/tests/system-prompt.test.ts +238 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for self-exclusion — finding and excluding the calling session
|
|
3
|
+
* by scanning for toolUseId in session file tails.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { mkdirSync, writeFileSync, rmSync, statSync, existsSync } from "node:fs";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
findCallingSession,
|
|
11
|
+
resolveSessionFilesExcludingSelf,
|
|
12
|
+
listSessionFiles,
|
|
13
|
+
type SessionFileInfo,
|
|
14
|
+
} from "../src/discovery.js";
|
|
15
|
+
|
|
16
|
+
const TESTDATA = join(import.meta.dirname, "..", "testdata", "projects");
|
|
17
|
+
const TMPDIR = join(import.meta.dirname, "..", "testdata", ".tmp-self-exclusion");
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
|
|
22
|
+
function assert(condition: boolean, msg: string) {
|
|
23
|
+
if (condition) { passed++; }
|
|
24
|
+
else { failed++; console.error(` ✗ ${msg}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ok(msg: string) { passed++; console.log(` ✓ ${msg}`); }
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Helpers
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/** Create a minimal JSONL session file with a tool_use block near the end. */
|
|
34
|
+
function createSessionFile(dir: string, sessionId: string, toolUseId: string, paddingKB = 0): string {
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
const path = join(dir, `${sessionId}.jsonl`);
|
|
37
|
+
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
|
|
40
|
+
// Optional padding to push the tool_use away from file start
|
|
41
|
+
if (paddingKB > 0) {
|
|
42
|
+
const padding = "x".repeat(1024);
|
|
43
|
+
for (let i = 0; i < paddingKB; i++) {
|
|
44
|
+
lines.push(JSON.stringify({
|
|
45
|
+
type: "user", uuid: randomUUID(), parentUuid: null,
|
|
46
|
+
session_id: sessionId, timestamp: new Date().toISOString(),
|
|
47
|
+
message: { role: "user", content: [{ type: "text", text: padding }] },
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// User message
|
|
53
|
+
lines.push(JSON.stringify({
|
|
54
|
+
type: "user", uuid: randomUUID(), parentUuid: null,
|
|
55
|
+
session_id: sessionId, timestamp: new Date().toISOString(),
|
|
56
|
+
message: { role: "user", content: [{ type: "text", text: "query something" }] },
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// Assistant message with tool_use
|
|
60
|
+
lines.push(JSON.stringify({
|
|
61
|
+
type: "assistant", uuid: randomUUID(), parentUuid: null,
|
|
62
|
+
session_id: sessionId, timestamp: new Date().toISOString(),
|
|
63
|
+
message: {
|
|
64
|
+
role: "assistant",
|
|
65
|
+
content: [{
|
|
66
|
+
type: "tool_use", id: toolUseId, name: "duncan_query",
|
|
67
|
+
input: { question: "test", mode: "project" },
|
|
68
|
+
}],
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
writeFileSync(path, lines.join("\n") + "\n");
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fileInfo(path: string, sessionId: string, projectDir: string): SessionFileInfo {
|
|
77
|
+
const stat = statSync(path);
|
|
78
|
+
return { path, sessionId, mtime: stat.mtime, size: stat.size, projectDir };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Setup / Teardown
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
rmSync(TMPDIR, { recursive: true, force: true });
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Tests: findCallingSession
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
console.log("\n--- findCallingSession: basic ---");
|
|
92
|
+
{
|
|
93
|
+
const dir = join(TMPDIR, "basic");
|
|
94
|
+
const toolUseId = "toolu_01TestBasicId0000000000";
|
|
95
|
+
const callingPath = createSessionFile(dir, "calling-session", toolUseId);
|
|
96
|
+
const otherPath = createSessionFile(dir, "other-session", "toolu_01OtherToolId0000000000");
|
|
97
|
+
|
|
98
|
+
const candidates = [
|
|
99
|
+
fileInfo(callingPath, "calling-session", dir),
|
|
100
|
+
fileInfo(otherPath, "other-session", dir),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const result = findCallingSession(toolUseId, candidates);
|
|
104
|
+
assert(result === "calling-session", `found calling session: ${result}`);
|
|
105
|
+
ok("identifies correct session by toolUseId");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log("\n--- findCallingSession: not found ---");
|
|
109
|
+
{
|
|
110
|
+
const dir = join(TMPDIR, "notfound");
|
|
111
|
+
const otherPath = createSessionFile(dir, "other-session", "toolu_01OtherToolId1111111111");
|
|
112
|
+
|
|
113
|
+
const candidates = [fileInfo(otherPath, "other-session", dir)];
|
|
114
|
+
|
|
115
|
+
const result = findCallingSession("toolu_01NonexistentId999999999", candidates);
|
|
116
|
+
assert(result === null, `returns null when not found: ${result}`);
|
|
117
|
+
ok("returns null for missing toolUseId");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log("\n--- findCallingSession: empty toolUseId ---");
|
|
121
|
+
{
|
|
122
|
+
const result = findCallingSession("", []);
|
|
123
|
+
assert(result === null, `returns null for empty ID: ${result}`);
|
|
124
|
+
ok("returns null for empty toolUseId");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log("\n--- findCallingSession: large file (tail scan) ---");
|
|
128
|
+
{
|
|
129
|
+
const dir = join(TMPDIR, "largefile");
|
|
130
|
+
const toolUseId = "toolu_01LargeFileTest000000000";
|
|
131
|
+
// Create a ~40KB file — tool_use at the end, within 32KB tail window
|
|
132
|
+
const callingPath = createSessionFile(dir, "large-session", toolUseId, 40);
|
|
133
|
+
|
|
134
|
+
const candidates = [fileInfo(callingPath, "large-session", dir)];
|
|
135
|
+
|
|
136
|
+
const result = findCallingSession(toolUseId, candidates);
|
|
137
|
+
assert(result === "large-session", `found in large file: ${result}`);
|
|
138
|
+
ok("finds toolUseId in tail of large file");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log("\n--- findCallingSession: tool_use beyond tail scan window ---");
|
|
142
|
+
{
|
|
143
|
+
const dir = join(TMPDIR, "beyond-tail");
|
|
144
|
+
// The tool_use is at ~40KB but we only scan last 32KB
|
|
145
|
+
// However, the tool_use entry itself is near the END (after padding)
|
|
146
|
+
// So this should still work — the padding is before the tool_use
|
|
147
|
+
const toolUseId = "toolu_01BeyondTailTest00000000";
|
|
148
|
+
const callingPath = createSessionFile(dir, "far-session", toolUseId, 50);
|
|
149
|
+
|
|
150
|
+
const candidates = [fileInfo(callingPath, "far-session", dir)];
|
|
151
|
+
|
|
152
|
+
const result = findCallingSession(toolUseId, candidates);
|
|
153
|
+
// The tool_use is the last entry, so it's within the 32KB tail window
|
|
154
|
+
// even though the file is 50KB+ total
|
|
155
|
+
assert(result === "far-session", `found despite large file: ${result}`);
|
|
156
|
+
ok("tool_use at end of large file is within tail scan window");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log("\n--- findCallingSession: multiple sessions, correct one identified ---");
|
|
160
|
+
{
|
|
161
|
+
const dir = join(TMPDIR, "multi");
|
|
162
|
+
const targetId = "toolu_01MultiTargetId000000000";
|
|
163
|
+
|
|
164
|
+
const paths = [];
|
|
165
|
+
for (let i = 0; i < 5; i++) {
|
|
166
|
+
const sid = `session-${i}`;
|
|
167
|
+
const tid = i === 3 ? targetId : `toolu_01Other${i}Pad00000000000`;
|
|
168
|
+
paths.push({ path: createSessionFile(dir, sid, tid), sid });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const candidates = paths.map((p) => fileInfo(p.path, p.sid, dir));
|
|
172
|
+
|
|
173
|
+
const result = findCallingSession(targetId, candidates);
|
|
174
|
+
assert(result === "session-3", `found session-3 among 5: ${result}`);
|
|
175
|
+
ok("finds correct session among multiple candidates");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Tests: findCallingSession on real test data
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
if (existsSync(TESTDATA)) {
|
|
183
|
+
console.log("\n--- findCallingSession: real session data ---");
|
|
184
|
+
{
|
|
185
|
+
const codexDir = join(TESTDATA, "-Users-wednesdayniemeyer-Documents-gniemeyer-Projects-codex");
|
|
186
|
+
const sessions = listSessionFiles(codexDir);
|
|
187
|
+
assert(sessions.length > 0, `have codex sessions: ${sessions.length}`);
|
|
188
|
+
|
|
189
|
+
// Pick a real tool_use ID from the test data
|
|
190
|
+
const targetId = "toolu_01ApH1X3AiGqZw7C9QjUXeGp";
|
|
191
|
+
const sourceDir = join(TESTDATA, "-Users-wednesdayniemeyer--claude-skills-inspect-claude-source");
|
|
192
|
+
const sourceSessions = listSessionFiles(sourceDir);
|
|
193
|
+
|
|
194
|
+
// Search all test sessions — should find it in the inspect-claude-source project
|
|
195
|
+
const allCandidates = [...sessions, ...sourceSessions];
|
|
196
|
+
const result = findCallingSession(targetId, allCandidates);
|
|
197
|
+
assert(result === "28e532ae-cb50-4f6f-9f08-914cbf6563b7", `found real session: ${result}`);
|
|
198
|
+
ok("finds toolUseId in real CC session data");
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
console.log("\n--- findCallingSession: real session data (skipped, no corpus) ---");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// Tests: resolveSessionFilesExcludingSelf
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
console.log("\n--- resolveSessionFilesExcludingSelf: excludes calling session ---");
|
|
209
|
+
{
|
|
210
|
+
const dir = join(TMPDIR, "resolve-exclude");
|
|
211
|
+
const toolUseId = "toolu_01ResolveExclude000000000";
|
|
212
|
+
|
|
213
|
+
createSessionFile(dir, "active-session", toolUseId);
|
|
214
|
+
createSessionFile(dir, "dormant-session-1", "toolu_01Dormant1Pad0000000000");
|
|
215
|
+
createSessionFile(dir, "dormant-session-2", "toolu_01Dormant2Pad0000000000");
|
|
216
|
+
|
|
217
|
+
const result = resolveSessionFilesExcludingSelf({
|
|
218
|
+
mode: "project",
|
|
219
|
+
projectDir: dir,
|
|
220
|
+
toolUseId,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
assert(result.excludedSessionId === "active-session", `excluded: ${result.excludedSessionId}`);
|
|
224
|
+
assert(result.sessions.length === 2, `2 sessions remain: ${result.sessions.length}`);
|
|
225
|
+
assert(result.totalCount === 2, `totalCount adjusted: ${result.totalCount}`);
|
|
226
|
+
assert(
|
|
227
|
+
result.sessions.every((s) => s.sessionId !== "active-session"),
|
|
228
|
+
"active session not in results",
|
|
229
|
+
);
|
|
230
|
+
ok("excludes calling session, returns others");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log("\n--- resolveSessionFilesExcludingSelf: no toolUseId → no exclusion ---");
|
|
234
|
+
{
|
|
235
|
+
const dir = join(TMPDIR, "resolve-no-id");
|
|
236
|
+
createSessionFile(dir, "session-a", "toolu_01SessionA000000000000");
|
|
237
|
+
createSessionFile(dir, "session-b", "toolu_01SessionB000000000000");
|
|
238
|
+
|
|
239
|
+
const result = resolveSessionFilesExcludingSelf({
|
|
240
|
+
mode: "project",
|
|
241
|
+
projectDir: dir,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
assert(result.excludedSessionId === null, `no exclusion: ${result.excludedSessionId}`);
|
|
245
|
+
assert(result.sessions.length === 2, `all sessions returned: ${result.sessions.length}`);
|
|
246
|
+
ok("no exclusion when toolUseId not provided");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log("\n--- resolveSessionFilesExcludingSelf: toolUseId not found → no exclusion ---");
|
|
250
|
+
{
|
|
251
|
+
const dir = join(TMPDIR, "resolve-miss");
|
|
252
|
+
createSessionFile(dir, "session-x", "toolu_01SessionX000000000000");
|
|
253
|
+
|
|
254
|
+
const result = resolveSessionFilesExcludingSelf({
|
|
255
|
+
mode: "project",
|
|
256
|
+
projectDir: dir,
|
|
257
|
+
toolUseId: "toolu_01CompletelyUnknown000000",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
assert(result.excludedSessionId === null, `no exclusion: ${result.excludedSessionId}`);
|
|
261
|
+
assert(result.sessions.length === 1, `all sessions returned: ${result.sessions.length}`);
|
|
262
|
+
ok("no exclusion when toolUseId not found in any file");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Cleanup & Summary
|
|
267
|
+
// ============================================================================
|
|
268
|
+
|
|
269
|
+
rmSync(TMPDIR, { recursive: true, force: true });
|
|
270
|
+
|
|
271
|
+
console.log(`\n✅ Self-exclusion tests: ${passed} passed, ${failed} failed`);
|
|
272
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for system prompt + context reconstruction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { parseSession } from "../src/parser.js";
|
|
8
|
+
import { buildRawChain } from "../src/tree.js";
|
|
9
|
+
import { normalizeMessages } from "../src/normalize.js";
|
|
10
|
+
import { buildSystemPrompt, buildSystemPromptString, injectUserContext } from "../src/system-prompt.js";
|
|
11
|
+
import { requireCorpus } from "./_skip-if-no-corpus.js";
|
|
12
|
+
|
|
13
|
+
const TESTDATA = requireCorpus();
|
|
14
|
+
|
|
15
|
+
function findSessionFiles(): string[] {
|
|
16
|
+
const files: string[] = [];
|
|
17
|
+
function walk(dir: string) {
|
|
18
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const full = join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory() && entry.name !== "subagents") walk(full);
|
|
21
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl") && !dir.includes("subagents")) files.push(full);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
walk(TESTDATA);
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let passed = 0;
|
|
29
|
+
let failed = 0;
|
|
30
|
+
|
|
31
|
+
function assert(condition: boolean, msg: string) {
|
|
32
|
+
if (condition) { passed++; }
|
|
33
|
+
else { failed++; console.error(` ✗ ${msg}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ok(msg: string) { passed++; console.log(` ✓ ${msg}`); }
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
console.log("\n--- System Prompt: base construction ---");
|
|
41
|
+
{
|
|
42
|
+
const prompt = buildSystemPromptString({
|
|
43
|
+
cwd: "/workspace",
|
|
44
|
+
modelId: "claude-sonnet-4-20250514",
|
|
45
|
+
toolNames: new Set(["Read", "Edit", "Write", "Bash", "Grep", "Glob"]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert(prompt.includes("interactive agent"), "includes identity intro");
|
|
49
|
+
assert(prompt.includes("software engineering"), "includes SE context");
|
|
50
|
+
assert(prompt.includes("/workspace"), "includes cwd");
|
|
51
|
+
assert(prompt.includes("claude-sonnet"), "includes model name");
|
|
52
|
+
assert(prompt.includes("Platform:"), "includes platform");
|
|
53
|
+
assert(prompt.includes("# System"), "includes system section");
|
|
54
|
+
assert(prompt.includes("# Doing tasks"), "includes doing tasks section");
|
|
55
|
+
assert(prompt.includes("# Executing actions with care"), "includes careful actions");
|
|
56
|
+
assert(prompt.includes("# Using your tools"), "includes tool usage");
|
|
57
|
+
assert(prompt.includes("# Tone and style"), "includes tone section");
|
|
58
|
+
assert(prompt.includes("# Output efficiency"), "includes output efficiency");
|
|
59
|
+
assert(prompt.includes("Read instead of cat"), "includes tool-specific instructions");
|
|
60
|
+
ok(`system prompt: ${prompt.length} chars`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log("\n--- System Prompt: sections ---");
|
|
64
|
+
{
|
|
65
|
+
const sections = buildSystemPrompt({
|
|
66
|
+
cwd: "/workspace",
|
|
67
|
+
modelId: "claude-opus-4-6",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert(sections.length >= 7, `has ${sections.length} sections (need >= 7)`);
|
|
71
|
+
assert(sections[0].includes("interactive agent"), "section 0: identity");
|
|
72
|
+
assert(sections[1].includes("# System"), "section 1: system rules");
|
|
73
|
+
assert(sections.some(s => s.includes("<env>")), "has environment section");
|
|
74
|
+
ok("sections structured correctly");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log("\n--- userContext injection ---");
|
|
78
|
+
{
|
|
79
|
+
const files = findSessionFiles();
|
|
80
|
+
const file = files.find((f) => {
|
|
81
|
+
const content = readFileSync(f, "utf-8");
|
|
82
|
+
return content.length > 10000;
|
|
83
|
+
}) ?? files[0];
|
|
84
|
+
|
|
85
|
+
const content = readFileSync(file, "utf-8");
|
|
86
|
+
const parsed = parseSession(content);
|
|
87
|
+
const chain = buildRawChain(parsed);
|
|
88
|
+
const name = basename(file, ".jsonl").slice(0, 12);
|
|
89
|
+
|
|
90
|
+
if (chain.length > 0) {
|
|
91
|
+
const normalized = normalizeMessages(chain);
|
|
92
|
+
const withContext = injectUserContext(normalized, "/workspace");
|
|
93
|
+
|
|
94
|
+
// If first message was user, context is merged in (same count)
|
|
95
|
+
// If first message was assistant, context is prepended (+1)
|
|
96
|
+
const firstWasUser = normalized.length > 0 && normalized[0].type === "user";
|
|
97
|
+
const expectedLen = firstWasUser ? normalized.length : normalized.length + 1;
|
|
98
|
+
assert(
|
|
99
|
+
withContext.length === expectedLen,
|
|
100
|
+
`${name}: correct length after context injection (${withContext.length} vs ${expectedLen})`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// First message should contain the system-reminder
|
|
104
|
+
const first = withContext[0];
|
|
105
|
+
assert(first.type === "user", "first message is user");
|
|
106
|
+
|
|
107
|
+
const content = typeof first.message.content === "string"
|
|
108
|
+
? first.message.content
|
|
109
|
+
: JSON.stringify(first.message.content);
|
|
110
|
+
assert(content.includes("system-reminder"), "has system-reminder");
|
|
111
|
+
assert(content.includes("currentDate"), "has currentDate");
|
|
112
|
+
|
|
113
|
+
ok(`${name}: context injected, ${content.length} chars`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log("\n--- userContext: CLAUDE.md loading from cwd ---");
|
|
118
|
+
{
|
|
119
|
+
// Test with a directory that has CLAUDE.md (if any exist in testdata)
|
|
120
|
+
// Since our testdata is CC sessions, not project files, just verify the function doesn't crash
|
|
121
|
+
const withContext = injectUserContext([], "/nonexistent/path");
|
|
122
|
+
assert(withContext.length === 1, "injects context even for empty messages");
|
|
123
|
+
assert(
|
|
124
|
+
typeof withContext[0].message.content === "string" &&
|
|
125
|
+
withContext[0].message.content.includes("currentDate"),
|
|
126
|
+
"includes currentDate even without CLAUDE.md",
|
|
127
|
+
);
|
|
128
|
+
ok("handles missing CLAUDE.md gracefully");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log("\n--- System Prompt: tool name extraction ---");
|
|
132
|
+
{
|
|
133
|
+
const file = join(
|
|
134
|
+
TESTDATA,
|
|
135
|
+
"-Users-wednesdayniemeyer-Documents-gniemeyer-Projects-codex",
|
|
136
|
+
"630fd2b9-d94d-4287-8c24-e225fbedfc5c.jsonl"
|
|
137
|
+
);
|
|
138
|
+
const content = readFileSync(file, "utf-8");
|
|
139
|
+
const parsed = parseSession(content);
|
|
140
|
+
const chain = buildRawChain(parsed);
|
|
141
|
+
|
|
142
|
+
const { extractToolNames } = await import("../src/system-prompt.js");
|
|
143
|
+
const toolNames = extractToolNames(chain);
|
|
144
|
+
assert(toolNames.size > 0, `found ${toolNames.size} tool names`);
|
|
145
|
+
// CC sessions use standard tools
|
|
146
|
+
const hasStandard = toolNames.has("Read") || toolNames.has("Bash") || toolNames.has("Grep");
|
|
147
|
+
assert(hasStandard, `has standard tools: ${[...toolNames].join(", ")}`);
|
|
148
|
+
ok(`extracted ${toolNames.size} tools: ${[...toolNames].slice(0, 5).join(", ")}...`);
|
|
149
|
+
|
|
150
|
+
// System prompt should include tool-specific instructions when tools are provided
|
|
151
|
+
const prompt = buildSystemPromptString({
|
|
152
|
+
cwd: "/workspace",
|
|
153
|
+
toolNames,
|
|
154
|
+
});
|
|
155
|
+
if (toolNames.has("Read")) {
|
|
156
|
+
assert(prompt.includes("Read instead of cat"), "prompt has Read-specific instructions");
|
|
157
|
+
}
|
|
158
|
+
if (toolNames.has("Bash")) {
|
|
159
|
+
assert(prompt.includes("Bash exclusively for system commands"), "prompt has Bash-specific instructions");
|
|
160
|
+
}
|
|
161
|
+
ok("tool-specific instructions present in system prompt");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log("\n--- System Prompt: memory from project dir ---");
|
|
165
|
+
{
|
|
166
|
+
// The ideas project has memory/MEMORY.md
|
|
167
|
+
const projectDir = join(TESTDATA, "-Users-wednesdayniemeyer-Documents-gniemeyer-Projects-ideas");
|
|
168
|
+
const prompt = buildSystemPromptString({
|
|
169
|
+
cwd: "/workspace",
|
|
170
|
+
projectDir,
|
|
171
|
+
});
|
|
172
|
+
assert(prompt.includes("Memory"), "includes memory section header");
|
|
173
|
+
assert(prompt.includes("Gears Project Memory") || prompt.includes("MEMORY"), "includes memory content");
|
|
174
|
+
ok("memory loaded from project dir");
|
|
175
|
+
|
|
176
|
+
// No memory project dir
|
|
177
|
+
const promptNoMem = buildSystemPromptString({
|
|
178
|
+
cwd: "/workspace",
|
|
179
|
+
projectDir: join(TESTDATA, "-Users-wednesdayniemeyer-Documents-gniemeyer-Projects-codex"),
|
|
180
|
+
});
|
|
181
|
+
assert(!promptNoMem.includes("# Memory"), "no memory section when none exists");
|
|
182
|
+
ok("no memory section for projects without MEMORY.md");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log("\n--- System Prompt: language ---");
|
|
186
|
+
{
|
|
187
|
+
const prompt = buildSystemPromptString({
|
|
188
|
+
cwd: "/workspace",
|
|
189
|
+
language: "Japanese",
|
|
190
|
+
});
|
|
191
|
+
assert(prompt.includes("# Language"), "has language section");
|
|
192
|
+
assert(prompt.includes("Japanese"), "includes language name");
|
|
193
|
+
ok("language section present");
|
|
194
|
+
|
|
195
|
+
const promptNoLang = buildSystemPromptString({ cwd: "/workspace" });
|
|
196
|
+
assert(!promptNoLang.includes("# Language"), "no language section by default");
|
|
197
|
+
ok("no language section when not specified");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log("\n--- System Prompt: model extraction from session ---");
|
|
201
|
+
{
|
|
202
|
+
// Extract model from a real session's assistant messages
|
|
203
|
+
const file = join(
|
|
204
|
+
TESTDATA,
|
|
205
|
+
"-Users-wednesdayniemeyer-Documents-gniemeyer-Projects-codex",
|
|
206
|
+
"630fd2b9-d94d-4287-8c24-e225fbedfc5c.jsonl"
|
|
207
|
+
);
|
|
208
|
+
const content = readFileSync(file, "utf-8");
|
|
209
|
+
const parsed = parseSession(content);
|
|
210
|
+
const chain = buildRawChain(parsed);
|
|
211
|
+
|
|
212
|
+
// Find model from assistant messages
|
|
213
|
+
const assistant = chain.find((m) => m.type === "assistant" && m.message.model);
|
|
214
|
+
assert(assistant !== undefined, "found assistant with model");
|
|
215
|
+
if (assistant) {
|
|
216
|
+
const model = assistant.message.model!;
|
|
217
|
+
ok(`extracted model: ${model}`);
|
|
218
|
+
|
|
219
|
+
// Build system prompt with this model
|
|
220
|
+
const prompt = buildSystemPromptString({
|
|
221
|
+
cwd: "/Users/wednesdayniemeyer/Documents/gniemeyer/Projects/codex",
|
|
222
|
+
modelId: model,
|
|
223
|
+
modelName: model,
|
|
224
|
+
});
|
|
225
|
+
assert(prompt.includes(model), "system prompt includes session model");
|
|
226
|
+
ok("system prompt uses session's model");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
233
|
+
if (failed > 0) {
|
|
234
|
+
console.log("❌ Some tests failed");
|
|
235
|
+
process.exit(1);
|
|
236
|
+
} else {
|
|
237
|
+
console.log("✅ All tests passed");
|
|
238
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
14
|
+
}
|