@fixy/core 0.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/dist/__tests__/diff-parser.test.d.ts +2 -0
- package/dist/__tests__/diff-parser.test.d.ts.map +1 -0
- package/dist/__tests__/diff-parser.test.js +89 -0
- package/dist/__tests__/diff-parser.test.js.map +1 -0
- package/dist/__tests__/fixy-commands.test.d.ts +2 -0
- package/dist/__tests__/fixy-commands.test.d.ts.map +1 -0
- package/dist/__tests__/fixy-commands.test.js +176 -0
- package/dist/__tests__/fixy-commands.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +66 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +77 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/smoke.test.d.ts +2 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +7 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/__tests__/store.test.d.ts +2 -0
- package/dist/__tests__/store.test.d.ts.map +1 -0
- package/dist/__tests__/store.test.js +121 -0
- package/dist/__tests__/store.test.js.map +1 -0
- package/dist/__tests__/turn.test.d.ts +2 -0
- package/dist/__tests__/turn.test.d.ts.map +1 -0
- package/dist/__tests__/turn.test.js +194 -0
- package/dist/__tests__/turn.test.js.map +1 -0
- package/dist/__tests__/worktree.test.d.ts +2 -0
- package/dist/__tests__/worktree.test.d.ts.map +1 -0
- package/dist/__tests__/worktree.test.js +119 -0
- package/dist/__tests__/worktree.test.js.map +1 -0
- package/dist/adapter.d.ts +75 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +3 -0
- package/dist/adapter.js.map +1 -0
- package/dist/diff-parser.d.ts +3 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +38 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/fixy-commands.d.ts +25 -0
- package/dist/fixy-commands.d.ts.map +1 -0
- package/dist/fixy-commands.js +154 -0
- package/dist/fixy-commands.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +36 -0
- package/dist/paths.js.map +1 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +32 -0
- package/dist/registry.js.map +1 -0
- package/dist/router.d.ts +21 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +36 -0
- package/dist/router.js.map +1 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +127 -0
- package/dist/store.js.map +1 -0
- package/dist/thread.d.ts +47 -0
- package/dist/thread.d.ts.map +1 -0
- package/dist/thread.js +3 -0
- package/dist/thread.js.map +1 -0
- package/dist/turn.d.ts +20 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +130 -0
- package/dist/turn.js.map +1 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +91 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/diff-parser.test.ts +99 -0
- package/src/__tests__/fixy-commands.test.ts +231 -0
- package/src/__tests__/registry.test.ts +79 -0
- package/src/__tests__/router.test.ts +91 -0
- package/src/__tests__/smoke.test.ts +7 -0
- package/src/__tests__/store.test.ts +151 -0
- package/src/__tests__/turn.test.ts +266 -0
- package/src/__tests__/worktree.test.ts +155 -0
- package/src/adapter.ts +84 -0
- package/src/diff-parser.ts +46 -0
- package/src/fixy-commands.ts +201 -0
- package/src/index.ts +40 -0
- package/src/paths.ts +43 -0
- package/src/registry.ts +40 -0
- package/src/router.ts +49 -0
- package/src/store.ts +164 -0
- package/src/thread.ts +50 -0
- package/src/turn.ts +165 -0
- package/src/worktree.ts +119 -0
- package/tsconfig.json +9 -0
package/dist/turn.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FixyThread } from './thread.js';
|
|
2
|
+
import type { AdapterRegistry } from './registry.js';
|
|
3
|
+
import type { LocalThreadStore } from './store.js';
|
|
4
|
+
import { WorktreeManager } from './worktree.js';
|
|
5
|
+
export interface TurnParams {
|
|
6
|
+
thread: FixyThread;
|
|
7
|
+
input: string;
|
|
8
|
+
registry: AdapterRegistry;
|
|
9
|
+
store: LocalThreadStore;
|
|
10
|
+
onLog: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
worktreeManager?: WorktreeManager;
|
|
13
|
+
}
|
|
14
|
+
export declare class TurnController {
|
|
15
|
+
runTurn(params: TurnParams): Promise<void>;
|
|
16
|
+
private _findLastAgentId;
|
|
17
|
+
private _dispatchToAdapter;
|
|
18
|
+
private _appendSystemMessage;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=turn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn.d.ts","sourceRoot":"","sources":["../src/turn.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAe,UAAU,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,gBAAgB,CAAC;IACxB,KAAK,EAAE,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5D,MAAM,EAAE,WAAW,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED,qBAAa,cAAc;IACnB,OAAO,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAsEhD,OAAO,CAAC,gBAAgB;YAUV,kBAAkB;YA+ClB,oBAAoB;CAcnC"}
|
package/dist/turn.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// packages/core/src/turn.ts
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { Router } from './router.js';
|
|
4
|
+
import { FixyCommandRunner } from './fixy-commands.js';
|
|
5
|
+
import { WorktreeManager } from './worktree.js';
|
|
6
|
+
export class TurnController {
|
|
7
|
+
async runTurn(params) {
|
|
8
|
+
const { thread, input, store } = params;
|
|
9
|
+
const router = new Router(params.registry);
|
|
10
|
+
const parsed = router.parse(input);
|
|
11
|
+
let dispatchedTo;
|
|
12
|
+
if (parsed.kind === 'mention') {
|
|
13
|
+
dispatchedTo = parsed.agentIds;
|
|
14
|
+
}
|
|
15
|
+
else if (parsed.kind === 'bare') {
|
|
16
|
+
const lastAgent = this._findLastAgentId(thread);
|
|
17
|
+
dispatchedTo = [lastAgent ?? thread.workerModel];
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
dispatchedTo = [];
|
|
21
|
+
}
|
|
22
|
+
const userMsg = {
|
|
23
|
+
id: randomUUID(),
|
|
24
|
+
createdAt: new Date().toISOString(),
|
|
25
|
+
role: 'user',
|
|
26
|
+
agentId: null,
|
|
27
|
+
content: input,
|
|
28
|
+
runId: null,
|
|
29
|
+
dispatchedTo,
|
|
30
|
+
patches: [],
|
|
31
|
+
warnings: [],
|
|
32
|
+
};
|
|
33
|
+
await store.appendMessage(thread.id, thread.projectRoot, userMsg);
|
|
34
|
+
switch (parsed.kind) {
|
|
35
|
+
case 'mention': {
|
|
36
|
+
if (parsed.agentIds.length > 3) {
|
|
37
|
+
await this._appendSystemMessage('maximum 3 adapters per turn', params);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const agentId of parsed.agentIds) {
|
|
41
|
+
await this._dispatchToAdapter(agentId, parsed.body, params);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case 'fixy': {
|
|
46
|
+
const runner = new FixyCommandRunner();
|
|
47
|
+
await runner.run({
|
|
48
|
+
thread: params.thread,
|
|
49
|
+
rest: parsed.rest,
|
|
50
|
+
store: params.store,
|
|
51
|
+
registry: params.registry,
|
|
52
|
+
worktreeManager: params.worktreeManager ?? new WorktreeManager(),
|
|
53
|
+
onLog: params.onLog,
|
|
54
|
+
signal: params.signal,
|
|
55
|
+
});
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'bare': {
|
|
59
|
+
const lastAgent = this._findLastAgentId(thread);
|
|
60
|
+
const resolvedAgentId = lastAgent ?? thread.workerModel;
|
|
61
|
+
await this._dispatchToAdapter(resolvedAgentId, parsed.body, params);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'error': {
|
|
65
|
+
await this._appendSystemMessage(parsed.reason, params);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
_findLastAgentId(thread) {
|
|
71
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
72
|
+
const msg = thread.messages[i];
|
|
73
|
+
if (msg.role === 'agent' && msg.agentId !== null) {
|
|
74
|
+
return msg.agentId;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
async _dispatchToAdapter(agentId, body, params) {
|
|
80
|
+
const freshThread = await params.store.getThread(params.thread.id, params.thread.projectRoot);
|
|
81
|
+
const adapter = params.registry.require(agentId);
|
|
82
|
+
const runId = randomUUID();
|
|
83
|
+
const ctx = {
|
|
84
|
+
runId,
|
|
85
|
+
agent: { id: adapter.id, name: adapter.name },
|
|
86
|
+
threadContext: {
|
|
87
|
+
threadId: freshThread.id,
|
|
88
|
+
projectRoot: freshThread.projectRoot,
|
|
89
|
+
worktreePath: freshThread.projectRoot,
|
|
90
|
+
repoRef: null,
|
|
91
|
+
},
|
|
92
|
+
messages: freshThread.messages,
|
|
93
|
+
prompt: body,
|
|
94
|
+
session: freshThread.agentSessions[agentId] ?? null,
|
|
95
|
+
onLog: params.onLog,
|
|
96
|
+
onMeta: () => { },
|
|
97
|
+
onSpawn: () => { },
|
|
98
|
+
signal: params.signal,
|
|
99
|
+
};
|
|
100
|
+
const result = await adapter.execute(ctx);
|
|
101
|
+
const agentMsg = {
|
|
102
|
+
id: randomUUID(),
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
role: 'agent',
|
|
105
|
+
agentId: adapter.id,
|
|
106
|
+
content: result.summary,
|
|
107
|
+
runId,
|
|
108
|
+
dispatchedTo: [],
|
|
109
|
+
patches: result.patches,
|
|
110
|
+
warnings: result.warnings,
|
|
111
|
+
};
|
|
112
|
+
await params.store.appendMessage(params.thread.id, params.thread.projectRoot, agentMsg);
|
|
113
|
+
params.thread.agentSessions[agentId] = result.session;
|
|
114
|
+
}
|
|
115
|
+
async _appendSystemMessage(content, params) {
|
|
116
|
+
const msg = {
|
|
117
|
+
id: randomUUID(),
|
|
118
|
+
createdAt: new Date().toISOString(),
|
|
119
|
+
role: 'system',
|
|
120
|
+
agentId: null,
|
|
121
|
+
content,
|
|
122
|
+
runId: null,
|
|
123
|
+
dispatchedTo: [],
|
|
124
|
+
patches: [],
|
|
125
|
+
warnings: [],
|
|
126
|
+
};
|
|
127
|
+
await params.store.appendMessage(params.thread.id, params.thread.projectRoot, msg);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=turn.js.map
|
package/dist/turn.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn.js","sourceRoot":"","sources":["../src/turn.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FixyPatch } from './thread.js';
|
|
2
|
+
export interface WorktreeHandle {
|
|
3
|
+
/** Absolute path to the worktree directory. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Branch name, e.g. "fixy/<threadId>-<agentId>" */
|
|
6
|
+
branch: string;
|
|
7
|
+
agentId: string;
|
|
8
|
+
threadId: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class WorktreeManager {
|
|
11
|
+
#private;
|
|
12
|
+
/**
|
|
13
|
+
* Idempotent — returns an existing worktree handle or creates a new one.
|
|
14
|
+
*/
|
|
15
|
+
ensure(projectRoot: string, threadId: string, agentId: string): Promise<WorktreeHandle>;
|
|
16
|
+
/**
|
|
17
|
+
* Runs `git diff --no-color` in the worktree and parses the output into
|
|
18
|
+
* FixyPatch objects. Returns an empty array when there are no changes.
|
|
19
|
+
*/
|
|
20
|
+
collectPatches(handle: WorktreeHandle): Promise<FixyPatch[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Removes the worktree and its branch, then re-provisions a fresh one.
|
|
23
|
+
*/
|
|
24
|
+
reset(handle: WorktreeHandle, projectRoot: string): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Permanently removes the worktree and its tracking branch.
|
|
27
|
+
* Does NOT re-provision.
|
|
28
|
+
*/
|
|
29
|
+
remove(handle: WorktreeHandle, projectRoot: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Lists all WorktreeHandles for the given threadId by reading the worktrees
|
|
32
|
+
* directory on disk. Returns an empty array when no worktrees exist yet.
|
|
33
|
+
*/
|
|
34
|
+
list(threadId: string): Promise<WorktreeHandle[]>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=worktree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree.d.ts","sourceRoot":"","sources":["../src/worktree.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI7C,MAAM,WAAW,cAAc;IAC7B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,eAAe;;IAC1B;;OAEG;IACG,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAmB7F;;;OAGG;IACG,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAUlE;;OAEG;IACG,KAAK,CAAC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvE;;;OAGG;IACG,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxE;;;OAGG;IACG,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;CAuCxD"}
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// packages/core/src/worktree.ts
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { access, mkdir, readdir } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { parseUnifiedDiff } from './diff-parser.js';
|
|
7
|
+
import { getWorktreesDir } from './paths.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export class WorktreeManager {
|
|
10
|
+
/**
|
|
11
|
+
* Idempotent — returns an existing worktree handle or creates a new one.
|
|
12
|
+
*/
|
|
13
|
+
async ensure(projectRoot, threadId, agentId) {
|
|
14
|
+
const worktreePath = join(getWorktreesDir(threadId), agentId);
|
|
15
|
+
const branch = `fixy/${threadId}-${agentId}`;
|
|
16
|
+
const exists = await this.#pathExists(worktreePath);
|
|
17
|
+
if (!exists) {
|
|
18
|
+
// Ensure parent directories exist before running git worktree add.
|
|
19
|
+
// git worktree add creates the leaf directory itself — only create the parent.
|
|
20
|
+
await mkdir(dirname(worktreePath), { recursive: true });
|
|
21
|
+
await execFileAsync('git', ['worktree', 'add', worktreePath, '-b', branch], {
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return { path: worktreePath, branch, agentId, threadId };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Runs `git diff --no-color` in the worktree and parses the output into
|
|
29
|
+
* FixyPatch objects. Returns an empty array when there are no changes.
|
|
30
|
+
*/
|
|
31
|
+
async collectPatches(handle) {
|
|
32
|
+
const { stdout } = await execFileAsync('git', ['diff', '--no-color'], { cwd: handle.path });
|
|
33
|
+
if (!stdout || stdout.trim().length === 0) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return parseUnifiedDiff(stdout, handle.path);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Removes the worktree and its branch, then re-provisions a fresh one.
|
|
40
|
+
*/
|
|
41
|
+
async reset(handle, projectRoot) {
|
|
42
|
+
await this.#removeWorktree(handle, projectRoot);
|
|
43
|
+
await this.ensure(projectRoot, handle.threadId, handle.agentId);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Permanently removes the worktree and its tracking branch.
|
|
47
|
+
* Does NOT re-provision.
|
|
48
|
+
*/
|
|
49
|
+
async remove(handle, projectRoot) {
|
|
50
|
+
await this.#removeWorktree(handle, projectRoot);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Lists all WorktreeHandles for the given threadId by reading the worktrees
|
|
54
|
+
* directory on disk. Returns an empty array when no worktrees exist yet.
|
|
55
|
+
*/
|
|
56
|
+
async list(threadId) {
|
|
57
|
+
const dir = getWorktreesDir(threadId);
|
|
58
|
+
const dirExists = await this.#pathExists(dir);
|
|
59
|
+
if (!dirExists) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
63
|
+
return entries
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => ({
|
|
66
|
+
path: join(dir, e.name),
|
|
67
|
+
branch: `fixy/${threadId}-${e.name}`,
|
|
68
|
+
agentId: e.name,
|
|
69
|
+
threadId,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Private helpers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
async #removeWorktree(handle, projectRoot) {
|
|
76
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', handle.path], {
|
|
77
|
+
cwd: projectRoot,
|
|
78
|
+
});
|
|
79
|
+
await execFileAsync('git', ['branch', '-D', handle.branch], { cwd: projectRoot });
|
|
80
|
+
}
|
|
81
|
+
async #pathExists(p) {
|
|
82
|
+
try {
|
|
83
|
+
await access(p);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worktree.js","sourceRoot":"","sources":["../src/worktree.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fixy/core",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20.0.0"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// packages/core/src/__tests__/diff-parser.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { parseUnifiedDiff } from '../diff-parser.js';
|
|
4
|
+
|
|
5
|
+
describe('parseUnifiedDiff', () => {
|
|
6
|
+
it('parses a single file diff correctly', () => {
|
|
7
|
+
const diff = `diff --git a/src/index.ts b/src/index.ts
|
|
8
|
+
index abc1234..def5678 100644
|
|
9
|
+
--- a/src/index.ts
|
|
10
|
+
+++ b/src/index.ts
|
|
11
|
+
@@ -1,3 +1,4 @@
|
|
12
|
+
import { foo } from './foo';
|
|
13
|
+
+import { bar } from './bar';
|
|
14
|
+
|
|
15
|
+
export function main() {
|
|
16
|
+
- return foo();
|
|
17
|
+
+ return bar(foo());
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
const patches = parseUnifiedDiff(diff, '/tmp/worktree');
|
|
21
|
+
|
|
22
|
+
expect(patches).toHaveLength(1);
|
|
23
|
+
expect(patches[0].relativePath).toBe('src/index.ts');
|
|
24
|
+
expect(patches[0].filePath).toBe('/tmp/worktree/src/index.ts');
|
|
25
|
+
expect(patches[0].stats.additions).toBe(2);
|
|
26
|
+
expect(patches[0].stats.deletions).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('parses a multi-file diff correctly', () => {
|
|
30
|
+
const diff = `diff --git a/file1.ts b/file1.ts
|
|
31
|
+
index abc..def 100644
|
|
32
|
+
--- a/file1.ts
|
|
33
|
+
+++ b/file1.ts
|
|
34
|
+
@@ -1,2 +1,3 @@
|
|
35
|
+
const a = 1;
|
|
36
|
+
+const b = 2;
|
|
37
|
+
export { a };
|
|
38
|
+
diff --git a/src/file2.ts b/src/file2.ts
|
|
39
|
+
index 111..222 100644
|
|
40
|
+
--- a/src/file2.ts
|
|
41
|
+
+++ b/src/file2.ts
|
|
42
|
+
@@ -1,3 +1,2 @@
|
|
43
|
+
const x = 1;
|
|
44
|
+
-const y = 2;
|
|
45
|
+
export { x };
|
|
46
|
+
`;
|
|
47
|
+
const patches = parseUnifiedDiff(diff, '/tmp/worktree');
|
|
48
|
+
|
|
49
|
+
expect(patches).toHaveLength(2);
|
|
50
|
+
|
|
51
|
+
expect(patches[0].relativePath).toBe('file1.ts');
|
|
52
|
+
expect(patches[0].stats.additions).toBe(1);
|
|
53
|
+
expect(patches[0].stats.deletions).toBe(0);
|
|
54
|
+
|
|
55
|
+
expect(patches[1].relativePath).toBe('src/file2.ts');
|
|
56
|
+
expect(patches[1].stats.additions).toBe(0);
|
|
57
|
+
expect(patches[1].stats.deletions).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns an empty array for an empty diff', () => {
|
|
61
|
+
expect(parseUnifiedDiff('', '/tmp/worktree')).toEqual([]);
|
|
62
|
+
expect(parseUnifiedDiff(' \n \n', '/tmp/worktree')).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('parses a new file diff with only additions', () => {
|
|
66
|
+
const diff = `diff --git a/new-file.ts b/new-file.ts
|
|
67
|
+
new file mode 100644
|
|
68
|
+
index 0000000..abc1234
|
|
69
|
+
--- /dev/null
|
|
70
|
+
+++ b/new-file.ts
|
|
71
|
+
@@ -0,0 +1,3 @@
|
|
72
|
+
+const a = 1;
|
|
73
|
+
+const b = 2;
|
|
74
|
+
+export { a, b };
|
|
75
|
+
`;
|
|
76
|
+
const patches = parseUnifiedDiff(diff, '/tmp/worktree');
|
|
77
|
+
|
|
78
|
+
expect(patches).toHaveLength(1);
|
|
79
|
+
expect(patches[0].stats.additions).toBe(3);
|
|
80
|
+
expect(patches[0].stats.deletions).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('parses a deleted file diff with only deletions', () => {
|
|
84
|
+
const diff = `diff --git a/removed.ts b/removed.ts
|
|
85
|
+
deleted file mode 100644
|
|
86
|
+
index abc1234..0000000
|
|
87
|
+
--- a/removed.ts
|
|
88
|
+
+++ /dev/null
|
|
89
|
+
@@ -1,2 +0,0 @@
|
|
90
|
+
-const old = true;
|
|
91
|
+
-export { old };
|
|
92
|
+
`;
|
|
93
|
+
const patches = parseUnifiedDiff(diff, '/tmp/worktree');
|
|
94
|
+
|
|
95
|
+
expect(patches).toHaveLength(1);
|
|
96
|
+
expect(patches[0].stats.additions).toBe(0);
|
|
97
|
+
expect(patches[0].stats.deletions).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { FixyCommandRunner } from '../fixy-commands.js';
|
|
7
|
+
import type { FixyCommandContext } from '../fixy-commands.js';
|
|
8
|
+
import { AdapterRegistry } from '../registry.js';
|
|
9
|
+
import { LocalThreadStore } from '../store.js';
|
|
10
|
+
import type { WorktreeManager } from '../worktree.js';
|
|
11
|
+
import type { FixyAdapter, FixyExecutionContext, FixyExecutionResult } from '../adapter.js';
|
|
12
|
+
import type { FixyThread } from '../thread.js';
|
|
13
|
+
import { getThreadFile } from '../paths.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function createStubAdapter(
|
|
20
|
+
id: string,
|
|
21
|
+
name: string,
|
|
22
|
+
executeFn?: (ctx: FixyExecutionContext) => Promise<FixyExecutionResult>,
|
|
23
|
+
): FixyAdapter {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
probe: async () => ({
|
|
28
|
+
available: true,
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
authStatus: 'ok' as const,
|
|
31
|
+
detail: null,
|
|
32
|
+
}),
|
|
33
|
+
execute:
|
|
34
|
+
executeFn ??
|
|
35
|
+
(async () => ({
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
signal: null,
|
|
38
|
+
timedOut: false,
|
|
39
|
+
summary: `response from ${id}`,
|
|
40
|
+
session: null,
|
|
41
|
+
patches: [],
|
|
42
|
+
warnings: [],
|
|
43
|
+
errorMessage: null,
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stubWorktreeManager = {
|
|
49
|
+
ensure: vi.fn(async () => ({ path: '', branch: '', agentId: '', threadId: '' })),
|
|
50
|
+
collectPatches: vi.fn(async () => []),
|
|
51
|
+
reset: vi.fn(async () => {}),
|
|
52
|
+
remove: vi.fn(async () => {}),
|
|
53
|
+
list: vi.fn(async () => []),
|
|
54
|
+
} as unknown as WorktreeManager;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Suite
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('FixyCommandRunner', () => {
|
|
61
|
+
let tmpDir: string;
|
|
62
|
+
let store: LocalThreadStore;
|
|
63
|
+
let thread: FixyThread;
|
|
64
|
+
let registry: AdapterRegistry;
|
|
65
|
+
let runner: FixyCommandRunner;
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'fixy-cmd-test-'));
|
|
69
|
+
process.env['FIXY_HOME'] = tmpDir;
|
|
70
|
+
store = new LocalThreadStore();
|
|
71
|
+
await store.init();
|
|
72
|
+
thread = await store.createThread('/tmp/fake-project');
|
|
73
|
+
registry = new AdapterRegistry();
|
|
74
|
+
runner = new FixyCommandRunner();
|
|
75
|
+
|
|
76
|
+
// Reset all vi.fn() call history between tests
|
|
77
|
+
vi.clearAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
delete process.env['FIXY_HOME'];
|
|
82
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function makeCtx(overrides?: Partial<FixyCommandContext>): FixyCommandContext {
|
|
86
|
+
return {
|
|
87
|
+
thread,
|
|
88
|
+
rest: '',
|
|
89
|
+
store,
|
|
90
|
+
registry,
|
|
91
|
+
worktreeManager: stubWorktreeManager,
|
|
92
|
+
onLog: () => {},
|
|
93
|
+
signal: AbortSignal.timeout(5000),
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
// Test 1: /worker <id> — persists workerModel change
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
it('/worker codex — persists workerModel change and appends system message', async () => {
|
|
102
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
103
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
104
|
+
|
|
105
|
+
expect(thread.workerModel).toBe('claude');
|
|
106
|
+
|
|
107
|
+
await runner.run(makeCtx({ rest: '/worker codex' }));
|
|
108
|
+
|
|
109
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
110
|
+
expect(fresh.workerModel).toBe('codex');
|
|
111
|
+
|
|
112
|
+
const sysMsg = fresh.messages.find(
|
|
113
|
+
(m) => m.role === 'system' && m.content === 'worker set to codex',
|
|
114
|
+
);
|
|
115
|
+
expect(sysMsg).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Test 2: /worker unknown — throws for unknown adapter
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
it('/worker unknown — throws for unknown adapter', async () => {
|
|
122
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
123
|
+
|
|
124
|
+
await expect(runner.run(makeCtx({ rest: '/worker unknown' }))).rejects.toThrow(
|
|
125
|
+
/Unknown adapter/,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
// Test 3: /all build something — returns stub message
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
it('/all build something — returns stub collaboration message', async () => {
|
|
133
|
+
await runner.run(makeCtx({ rest: '/all build something' }));
|
|
134
|
+
|
|
135
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
136
|
+
const sysMsg = fresh.messages.find(
|
|
137
|
+
(m) => m.role === 'system' && m.content.includes('collaboration engine not yet implemented'),
|
|
138
|
+
);
|
|
139
|
+
expect(sysMsg).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Test 4: /settings — returns stub message
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
it('/settings — returns stub message', async () => {
|
|
146
|
+
await runner.run(makeCtx({ rest: '/settings' }));
|
|
147
|
+
|
|
148
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
149
|
+
const sysMsg = fresh.messages.find(
|
|
150
|
+
(m) => m.role === 'system' && m.content === 'settings command not yet implemented',
|
|
151
|
+
);
|
|
152
|
+
expect(sysMsg).toBeDefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// Test 5: /reset — clears agentSessions
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
it('/reset — clears agentSessions and appends confirmation message', async () => {
|
|
159
|
+
// Mutate the in-memory thread to have an active session.
|
|
160
|
+
thread.agentSessions = { claude: { sessionId: 'sess-1', params: {} } };
|
|
161
|
+
thread.worktrees = { claude: '/tmp/worktree-claude' };
|
|
162
|
+
|
|
163
|
+
// Persist the modified thread to disk so store.getThread returns it.
|
|
164
|
+
const threadFilePath = getThreadFile(thread.projectRoot, thread.id);
|
|
165
|
+
await writeFile(threadFilePath, JSON.stringify(thread, null, 2), 'utf8');
|
|
166
|
+
|
|
167
|
+
await runner.run(makeCtx({ rest: '/reset' }));
|
|
168
|
+
|
|
169
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
170
|
+
expect(fresh.agentSessions).toEqual({});
|
|
171
|
+
|
|
172
|
+
const sysMsg = fresh.messages.find(
|
|
173
|
+
(m) => m.role === 'system' && m.content.includes('thread reset'),
|
|
174
|
+
);
|
|
175
|
+
expect(sysMsg).toBeDefined();
|
|
176
|
+
|
|
177
|
+
// Verify worktreeManager.reset was called for the claude worktree entry.
|
|
178
|
+
expect(stubWorktreeManager.reset as ReturnType<typeof vi.fn>).toHaveBeenCalledOnce();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// Test 6: /status — shows adapter probe results
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
it('/status — lists adapter probe results including current workerModel', async () => {
|
|
185
|
+
registry.register(createStubAdapter('claude', 'Claude'));
|
|
186
|
+
registry.register(createStubAdapter('codex', 'Codex'));
|
|
187
|
+
|
|
188
|
+
await runner.run(makeCtx({ rest: '/status' }));
|
|
189
|
+
|
|
190
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
191
|
+
expect(fresh.messages.length).toBeGreaterThan(0);
|
|
192
|
+
|
|
193
|
+
const sysMsg = fresh.messages[fresh.messages.length - 1];
|
|
194
|
+
expect(sysMsg?.role).toBe('system');
|
|
195
|
+
|
|
196
|
+
const content = sysMsg?.content ?? '';
|
|
197
|
+
expect(content).toContain('claude');
|
|
198
|
+
expect(content).toContain('codex');
|
|
199
|
+
expect(content).toContain('yes'); // available: yes
|
|
200
|
+
expect(content).toContain('1.0.0'); // version
|
|
201
|
+
expect(content).toContain(thread.workerModel); // current workerModel line
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
// Test 7: Bare @fixy prompt — routes through worker adapter
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
it('bare prompt — routes through worker adapter and appends agent message with agentId=fixy', async () => {
|
|
208
|
+
const customExecute = async (_ctx: FixyExecutionContext): Promise<FixyExecutionResult> => ({
|
|
209
|
+
exitCode: 0,
|
|
210
|
+
signal: null,
|
|
211
|
+
timedOut: false,
|
|
212
|
+
summary: 'worker response here',
|
|
213
|
+
session: { sessionId: 'new-sess', params: {} },
|
|
214
|
+
patches: [],
|
|
215
|
+
warnings: [],
|
|
216
|
+
errorMessage: null,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
registry.register(createStubAdapter('claude', 'Claude', customExecute));
|
|
220
|
+
thread.workerModel = 'claude';
|
|
221
|
+
|
|
222
|
+
await runner.run(makeCtx({ rest: 'explain this' }));
|
|
223
|
+
|
|
224
|
+
const fresh = await store.getThread(thread.id, thread.projectRoot);
|
|
225
|
+
const agentMsg = fresh.messages.find((m) => m.role === 'agent');
|
|
226
|
+
|
|
227
|
+
expect(agentMsg).toBeDefined();
|
|
228
|
+
expect(agentMsg?.agentId).toBe('fixy');
|
|
229
|
+
expect(agentMsg?.content).toBe('worker response here');
|
|
230
|
+
});
|
|
231
|
+
});
|