@bastani/atomic 0.6.4 → 0.6.5-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/.agents/skills/create-spec/SKILL.md +6 -3
- package/.agents/skills/tdd/SKILL.md +107 -0
- package/.agents/skills/tdd/deep-modules.md +33 -0
- package/.agents/skills/tdd/interface-design.md +31 -0
- package/.agents/skills/tdd/mocking.md +59 -0
- package/.agents/skills/tdd/refactoring.md +10 -0
- package/.agents/skills/tdd/tests.md +61 -0
- package/.agents/skills/workflow-creator/SKILL.md +550 -0
- package/.agents/skills/workflow-creator/references/agent-sessions.md +891 -0
- package/.agents/skills/workflow-creator/references/agent-setup-recipe.md +266 -0
- package/.agents/skills/workflow-creator/references/computation-and-validation.md +201 -0
- package/.agents/skills/workflow-creator/references/control-flow.md +470 -0
- package/.agents/skills/workflow-creator/references/failure-modes.md +1014 -0
- package/.agents/skills/workflow-creator/references/getting-started.md +392 -0
- package/.agents/skills/workflow-creator/references/registry-and-validation.md +141 -0
- package/.agents/skills/workflow-creator/references/running-workflows.md +418 -0
- package/.agents/skills/workflow-creator/references/session-config.md +384 -0
- package/.agents/skills/workflow-creator/references/state-and-data-flow.md +356 -0
- package/.agents/skills/workflow-creator/references/user-input.md +234 -0
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +392 -0
- package/.claude/agents/debugger.md +2 -2
- package/.claude/agents/reviewer.md +1 -1
- package/.claude/agents/worker.md +2 -2
- package/.github/agents/debugger.md +1 -1
- package/.github/agents/worker.md +1 -1
- package/.mcp.json +5 -1
- package/.opencode/agents/debugger.md +1 -1
- package/.opencode/agents/worker.md +1 -1
- package/README.md +236 -201
- package/dist/sdk/define-workflow.d.ts +11 -6
- package/dist/sdk/define-workflow.d.ts.map +1 -1
- package/dist/sdk/errors.d.ts +10 -0
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +21 -9
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/primitives/inputs.d.ts +36 -0
- package/dist/sdk/primitives/inputs.d.ts.map +1 -0
- package/dist/sdk/primitives/metadata.d.ts +40 -0
- package/dist/sdk/primitives/metadata.d.ts.map +1 -0
- package/dist/sdk/primitives/run.d.ts +57 -0
- package/dist/sdk/primitives/run.d.ts.map +1 -0
- package/dist/sdk/primitives/sessions.d.ts +128 -0
- package/dist/sdk/primitives/sessions.d.ts.map +1 -0
- package/dist/sdk/runtime/executor.d.ts +24 -56
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/orchestrator-entry.d.ts +26 -0
- package/dist/sdk/runtime/orchestrator-entry.d.ts.map +1 -0
- package/dist/sdk/runtime/tmux.d.ts +20 -0
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/dist/sdk/types.d.ts +26 -86
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +20 -12
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/dist/services/config/additional-instructions.d.ts +1 -1
- package/dist/services/config/additional-instructions.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/cli.ts +39 -56
- package/src/commands/builtin-registry.ts +37 -0
- package/src/commands/cli/chat/index.ts +1 -3
- package/src/{sdk → commands/cli}/management-commands.ts +15 -55
- package/src/commands/cli/session.ts +1 -1
- package/src/commands/cli/workflow-command.test.ts +250 -16
- package/src/commands/cli/workflow-inputs.test.ts +1 -0
- package/src/commands/cli/workflow-inputs.ts +13 -3
- package/src/commands/cli/workflow-list.test.ts +1 -0
- package/src/commands/cli/workflow-list.ts +0 -0
- package/src/commands/cli/workflow-status.ts +1 -1
- package/src/commands/cli/workflow.ts +191 -11
- package/src/sdk/define-workflow.test.ts +47 -16
- package/src/sdk/define-workflow.ts +24 -6
- package/src/sdk/errors.test.ts +11 -0
- package/src/sdk/errors.ts +13 -0
- package/src/sdk/index.test.ts +92 -0
- package/src/sdk/index.ts +71 -15
- package/src/sdk/primitives/inputs.ts +48 -0
- package/src/sdk/primitives/metadata.ts +63 -0
- package/src/sdk/primitives/run.ts +81 -0
- package/src/sdk/primitives/sessions.test.ts +594 -0
- package/src/sdk/primitives/sessions.ts +328 -0
- package/src/sdk/runtime/executor.ts +36 -115
- package/src/sdk/runtime/orchestrator-entry.ts +110 -0
- package/src/sdk/runtime/tmux.ts +33 -0
- package/src/sdk/types.ts +26 -91
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -0
- package/src/sdk/workflows/index.ts +68 -51
- package/src/services/config/additional-instructions.ts +1 -1
- package/.agents/skills/test-driven-development/SKILL.md +0 -371
- package/.agents/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/dist/commands/cli/session.d.ts +0 -67
- package/dist/commands/cli/session.d.ts.map +0 -1
- package/dist/commands/cli/workflow-status.d.ts +0 -63
- package/dist/commands/cli/workflow-status.d.ts.map +0 -1
- package/dist/sdk/commander.d.ts +0 -74
- package/dist/sdk/commander.d.ts.map +0 -1
- package/dist/sdk/management-commands.d.ts +0 -42
- package/dist/sdk/management-commands.d.ts.map +0 -1
- package/dist/sdk/workflow-cli.d.ts +0 -103
- package/dist/sdk/workflow-cli.d.ts.map +0 -1
- package/dist/sdk/workflows/builtin-registry.d.ts +0 -113
- package/dist/sdk/workflows/builtin-registry.d.ts.map +0 -1
- package/src/sdk/commander.ts +0 -161
- package/src/sdk/workflow-cli.ts +0 -409
- package/src/sdk/workflows/builtin-registry.ts +0 -23
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `src/sdk/primitives/sessions.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Each function accepts an optional `deps` parameter, so these tests
|
|
5
|
+
* inject in-memory fakes instead of using `mock.module` (which leaks
|
|
6
|
+
* across the parallel test run). Filesystem-backed paths
|
|
7
|
+
* (`getSessionStatus`, `getSessionTranscript`) write fixtures into a
|
|
8
|
+
* fresh `mkdtempSync` dir and pass the dir via `deps.sessionsBaseDir`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test";
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
attachSession,
|
|
17
|
+
detachSession,
|
|
18
|
+
getSession,
|
|
19
|
+
getSessionStatus,
|
|
20
|
+
getSessionTranscript,
|
|
21
|
+
gotoOrchestrator,
|
|
22
|
+
listSessions,
|
|
23
|
+
nextWindow,
|
|
24
|
+
previousWindow,
|
|
25
|
+
stopSession,
|
|
26
|
+
type SessionPrimitiveDeps,
|
|
27
|
+
} from "./sessions.ts";
|
|
28
|
+
import { MissingDependencyError, SessionNotFoundError } from "../errors.ts";
|
|
29
|
+
import type { TmuxSession } from "../runtime/tmux.ts";
|
|
30
|
+
import type { WorkflowStatusSnapshot } from "../runtime/status-writer.ts";
|
|
31
|
+
|
|
32
|
+
// ─── Test deps factory ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface DepsOverrides {
|
|
35
|
+
isTmuxInstalled?: SessionPrimitiveDeps["isTmuxInstalled"];
|
|
36
|
+
listAllTmuxSessions?: SessionPrimitiveDeps["listAllTmuxSessions"];
|
|
37
|
+
killSession?: SessionPrimitiveDeps["killSession"];
|
|
38
|
+
attachSession?: SessionPrimitiveDeps["attachSession"];
|
|
39
|
+
detachClients?: SessionPrimitiveDeps["detachClients"];
|
|
40
|
+
nextWindow?: SessionPrimitiveDeps["nextWindow"];
|
|
41
|
+
previousWindow?: SessionPrimitiveDeps["previousWindow"];
|
|
42
|
+
selectWindow?: SessionPrimitiveDeps["selectWindow"];
|
|
43
|
+
readSnapshot?: SessionPrimitiveDeps["readSnapshot"];
|
|
44
|
+
sessionsBaseDir?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeDeps(overrides: DepsOverrides = {}): SessionPrimitiveDeps {
|
|
48
|
+
return {
|
|
49
|
+
isTmuxInstalled: overrides.isTmuxInstalled ?? (() => true),
|
|
50
|
+
listAllTmuxSessions: overrides.listAllTmuxSessions ?? (() => []),
|
|
51
|
+
killSession: overrides.killSession ?? (() => {}),
|
|
52
|
+
attachSession: overrides.attachSession ?? (() => {}),
|
|
53
|
+
detachClients: overrides.detachClients ?? (() => {}),
|
|
54
|
+
nextWindow: overrides.nextWindow ?? (() => {}),
|
|
55
|
+
previousWindow: overrides.previousWindow ?? (() => {}),
|
|
56
|
+
selectWindow: overrides.selectWindow ?? (() => {}),
|
|
57
|
+
readSnapshot: overrides.readSnapshot ?? (async () => null),
|
|
58
|
+
sessionsBaseDir: overrides.sessionsBaseDir ?? "/tmp/atomic-sessions-test-fallback",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const NOW = "2026-04-27T00:00:00.000Z";
|
|
63
|
+
|
|
64
|
+
function fakeSession(partial: Partial<TmuxSession> & { name: string }): TmuxSession {
|
|
65
|
+
return {
|
|
66
|
+
windows: 1,
|
|
67
|
+
created: NOW,
|
|
68
|
+
attached: false,
|
|
69
|
+
...partial,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── listSessions ───────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("listSessions", () => {
|
|
76
|
+
test("returns [] when tmux is not installed", () => {
|
|
77
|
+
const result = listSessions(
|
|
78
|
+
{},
|
|
79
|
+
makeDeps({ isTmuxInstalled: () => false }),
|
|
80
|
+
);
|
|
81
|
+
expect(result).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns [] when no tmux sessions exist", () => {
|
|
85
|
+
const result = listSessions({}, makeDeps());
|
|
86
|
+
expect(result).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("maps TmuxSession to SessionInfo and preserves all fields", () => {
|
|
90
|
+
const tmuxSession = fakeSession({
|
|
91
|
+
name: "atomic-chat-claude-aaa11111",
|
|
92
|
+
type: "chat",
|
|
93
|
+
agent: "claude",
|
|
94
|
+
attached: true,
|
|
95
|
+
});
|
|
96
|
+
const result = listSessions(
|
|
97
|
+
{},
|
|
98
|
+
makeDeps({ listAllTmuxSessions: () => [tmuxSession] }),
|
|
99
|
+
);
|
|
100
|
+
expect(result).toEqual([
|
|
101
|
+
{
|
|
102
|
+
id: "atomic-chat-claude-aaa11111",
|
|
103
|
+
type: "chat",
|
|
104
|
+
agent: "claude",
|
|
105
|
+
created: NOW,
|
|
106
|
+
attached: true,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("scope='chat' excludes workflow sessions", () => {
|
|
112
|
+
const sessions = [
|
|
113
|
+
fakeSession({ name: "c", type: "chat", agent: "claude" }),
|
|
114
|
+
fakeSession({ name: "w", type: "workflow", agent: "claude" }),
|
|
115
|
+
];
|
|
116
|
+
const result = listSessions(
|
|
117
|
+
{ scope: "chat" },
|
|
118
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
119
|
+
);
|
|
120
|
+
expect(result).toHaveLength(1);
|
|
121
|
+
expect(result[0]!.id).toBe("c");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("scope='workflow' excludes chat sessions", () => {
|
|
125
|
+
const sessions = [
|
|
126
|
+
fakeSession({ name: "c", type: "chat", agent: "claude" }),
|
|
127
|
+
fakeSession({ name: "w", type: "workflow", agent: "claude" }),
|
|
128
|
+
];
|
|
129
|
+
const result = listSessions(
|
|
130
|
+
{ scope: "workflow" },
|
|
131
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
132
|
+
);
|
|
133
|
+
expect(result).toHaveLength(1);
|
|
134
|
+
expect(result[0]!.id).toBe("w");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("scope defaults to 'all'", () => {
|
|
138
|
+
const sessions = [
|
|
139
|
+
fakeSession({ name: "c", type: "chat", agent: "claude" }),
|
|
140
|
+
fakeSession({ name: "w", type: "workflow", agent: "claude" }),
|
|
141
|
+
];
|
|
142
|
+
const result = listSessions(
|
|
143
|
+
{},
|
|
144
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
145
|
+
);
|
|
146
|
+
expect(result).toHaveLength(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("agent filter accepts a single AgentType", () => {
|
|
150
|
+
const sessions = [
|
|
151
|
+
fakeSession({ name: "a", type: "chat", agent: "claude" }),
|
|
152
|
+
fakeSession({ name: "b", type: "chat", agent: "copilot" }),
|
|
153
|
+
];
|
|
154
|
+
const result = listSessions(
|
|
155
|
+
{ agent: "claude" },
|
|
156
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
157
|
+
);
|
|
158
|
+
expect(result).toHaveLength(1);
|
|
159
|
+
expect(result[0]!.agent).toBe("claude");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("agent filter accepts a readonly array of AgentTypes", () => {
|
|
163
|
+
const sessions = [
|
|
164
|
+
fakeSession({ name: "a", type: "chat", agent: "claude" }),
|
|
165
|
+
fakeSession({ name: "b", type: "chat", agent: "copilot" }),
|
|
166
|
+
fakeSession({ name: "c", type: "chat", agent: "opencode" }),
|
|
167
|
+
];
|
|
168
|
+
const result = listSessions(
|
|
169
|
+
{ agent: ["claude", "opencode"] as const },
|
|
170
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
171
|
+
);
|
|
172
|
+
expect(result).toHaveLength(2);
|
|
173
|
+
expect(result.map((s) => s.agent).sort()).toEqual(["claude", "opencode"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("agent filter excludes sessions with no agent field", () => {
|
|
177
|
+
const sessions = [
|
|
178
|
+
fakeSession({ name: "a", type: "chat", agent: "claude" }),
|
|
179
|
+
fakeSession({ name: "b", type: "chat" }),
|
|
180
|
+
];
|
|
181
|
+
const result = listSessions(
|
|
182
|
+
{ agent: "claude" },
|
|
183
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
184
|
+
);
|
|
185
|
+
expect(result).toHaveLength(1);
|
|
186
|
+
expect(result[0]!.id).toBe("a");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("scope + agent filters compose", () => {
|
|
190
|
+
const sessions = [
|
|
191
|
+
fakeSession({ name: "wfc", type: "workflow", agent: "claude" }),
|
|
192
|
+
fakeSession({ name: "wfo", type: "workflow", agent: "opencode" }),
|
|
193
|
+
fakeSession({ name: "chc", type: "chat", agent: "claude" }),
|
|
194
|
+
];
|
|
195
|
+
const result = listSessions(
|
|
196
|
+
{ scope: "workflow", agent: "claude" },
|
|
197
|
+
makeDeps({ listAllTmuxSessions: () => sessions }),
|
|
198
|
+
);
|
|
199
|
+
expect(result).toHaveLength(1);
|
|
200
|
+
expect(result[0]!.id).toBe("wfc");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ─── getSession ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe("getSession", () => {
|
|
207
|
+
test("returns undefined when tmux is not installed", () => {
|
|
208
|
+
const result = getSession(
|
|
209
|
+
"atomic-chat-claude-aaa11111",
|
|
210
|
+
makeDeps({ isTmuxInstalled: () => false }),
|
|
211
|
+
);
|
|
212
|
+
expect(result).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("returns undefined when no session matches the id", () => {
|
|
216
|
+
const result = getSession(
|
|
217
|
+
"missing",
|
|
218
|
+
makeDeps({
|
|
219
|
+
listAllTmuxSessions: () => [
|
|
220
|
+
fakeSession({ name: "atomic-chat-claude-aaa11111", type: "chat", agent: "claude" }),
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
expect(result).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("returns SessionInfo when the session exists", () => {
|
|
228
|
+
const target = fakeSession({
|
|
229
|
+
name: "atomic-chat-claude-aaa11111",
|
|
230
|
+
type: "chat",
|
|
231
|
+
agent: "claude",
|
|
232
|
+
});
|
|
233
|
+
const result = getSession(
|
|
234
|
+
"atomic-chat-claude-aaa11111",
|
|
235
|
+
makeDeps({ listAllTmuxSessions: () => [target] }),
|
|
236
|
+
);
|
|
237
|
+
expect(result).toBeDefined();
|
|
238
|
+
expect(result!.id).toBe("atomic-chat-claude-aaa11111");
|
|
239
|
+
expect(result!.agent).toBe("claude");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── stopSession ────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("stopSession", () => {
|
|
246
|
+
test("returns silently when tmux is not installed", async () => {
|
|
247
|
+
const killSpy = mock<(id: string) => void>(() => {});
|
|
248
|
+
await stopSession(
|
|
249
|
+
"atomic-chat-claude-aaa11111",
|
|
250
|
+
makeDeps({ isTmuxInstalled: () => false, killSession: killSpy }),
|
|
251
|
+
);
|
|
252
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("calls killSession when tmux is installed", async () => {
|
|
256
|
+
const killSpy = mock<(id: string) => void>(() => {});
|
|
257
|
+
await stopSession("atomic-chat-claude-aaa11111", makeDeps({ killSession: killSpy }));
|
|
258
|
+
expect(killSpy).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(killSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("swallows errors from killSession (best-effort stop)", async () => {
|
|
263
|
+
const killSpy = mock<(id: string) => void>(() => {
|
|
264
|
+
throw new Error("session not found");
|
|
265
|
+
});
|
|
266
|
+
// Must not throw — sessions that are already gone should resolve cleanly.
|
|
267
|
+
await stopSession("ghost", makeDeps({ killSession: killSpy }));
|
|
268
|
+
expect(killSpy).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ─── detachSession ──────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe("detachSession", () => {
|
|
275
|
+
test("returns silently when tmux is not installed", async () => {
|
|
276
|
+
const detachSpy = mock<(id: string) => void>(() => {});
|
|
277
|
+
await detachSession(
|
|
278
|
+
"atomic-chat-claude-aaa11111",
|
|
279
|
+
makeDeps({ isTmuxInstalled: () => false, detachClients: detachSpy }),
|
|
280
|
+
);
|
|
281
|
+
expect(detachSpy).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("calls detachClients when tmux is installed", async () => {
|
|
285
|
+
const detachSpy = mock<(id: string) => void>(() => {});
|
|
286
|
+
await detachSession(
|
|
287
|
+
"atomic-chat-claude-aaa11111",
|
|
288
|
+
makeDeps({ detachClients: detachSpy }),
|
|
289
|
+
);
|
|
290
|
+
expect(detachSpy).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(detachSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("swallows errors from detachClients (best-effort detach)", async () => {
|
|
295
|
+
const detachSpy = mock<(id: string) => void>(() => {
|
|
296
|
+
throw new Error("session not found");
|
|
297
|
+
});
|
|
298
|
+
// Must not throw — detaching from a session that's already gone or has
|
|
299
|
+
// no clients attached should resolve cleanly.
|
|
300
|
+
await detachSession("ghost", makeDeps({ detachClients: detachSpy }));
|
|
301
|
+
expect(detachSpy).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ─── attachSession ──────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe("attachSession", () => {
|
|
308
|
+
test("throws MissingDependencyError when tmux is not installed", async () => {
|
|
309
|
+
await expect(
|
|
310
|
+
attachSession(
|
|
311
|
+
"atomic-chat-claude-aaa11111",
|
|
312
|
+
makeDeps({ isTmuxInstalled: () => false }),
|
|
313
|
+
),
|
|
314
|
+
).rejects.toBeInstanceOf(MissingDependencyError);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("delegates to deps.attachSession when tmux is installed", async () => {
|
|
318
|
+
const attachSpy = mock<(id: string) => void>(() => {});
|
|
319
|
+
await attachSession(
|
|
320
|
+
"atomic-chat-claude-aaa11111",
|
|
321
|
+
makeDeps({ attachSession: attachSpy }),
|
|
322
|
+
);
|
|
323
|
+
expect(attachSpy).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(attachSpy).toHaveBeenCalledWith("atomic-chat-claude-aaa11111");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ─── nextWindow / previousWindow / gotoOrchestrator ────────────────────────
|
|
329
|
+
//
|
|
330
|
+
// All three navigation primitives share the same shape:
|
|
331
|
+
// 1. throw when tmux is not installed
|
|
332
|
+
// 2. throw when the session does not exist
|
|
333
|
+
// 3. invoke the underlying tmux verb against the session
|
|
334
|
+
// 4. NEVER attach — navigation is silent. Callers compose
|
|
335
|
+
// `nextWindow(id) + attachSession(id)` if they want navigate-then-attach.
|
|
336
|
+
//
|
|
337
|
+
// The shared describe block keeps the preamble contract explicit and pins
|
|
338
|
+
// the no-auto-attach guarantee; per-primitive blocks below pin the exact
|
|
339
|
+
// tmux verb each one routes to.
|
|
340
|
+
|
|
341
|
+
interface NavCase {
|
|
342
|
+
label: string;
|
|
343
|
+
call: (id: string, deps: SessionPrimitiveDeps) => Promise<void>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const NAV_CASES: NavCase[] = [
|
|
347
|
+
{ label: "nextWindow", call: nextWindow },
|
|
348
|
+
{ label: "previousWindow", call: previousWindow },
|
|
349
|
+
{ label: "gotoOrchestrator", call: gotoOrchestrator },
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
describe.each(NAV_CASES)("$label — shared contract", ({ call }) => {
|
|
353
|
+
test("throws MissingDependencyError when tmux is not installed", async () => {
|
|
354
|
+
await expect(
|
|
355
|
+
call("atomic-wf-claude-ralph-deadbeef", makeDeps({ isTmuxInstalled: () => false })),
|
|
356
|
+
).rejects.toBeInstanceOf(MissingDependencyError);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("throws SessionNotFoundError when the session id is not found", async () => {
|
|
360
|
+
const promise = call("ghost", makeDeps({ listAllTmuxSessions: () => [] }));
|
|
361
|
+
await expect(promise).rejects.toBeInstanceOf(SessionNotFoundError);
|
|
362
|
+
// Carry the id so callers can render it without parsing message text.
|
|
363
|
+
await expect(promise).rejects.toMatchObject({ id: "ghost" });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("never attaches, regardless of whether a client is watching", async () => {
|
|
367
|
+
const attachSpy = mock<(id: string) => void>(() => {});
|
|
368
|
+
const detached = fakeSession({
|
|
369
|
+
name: "atomic-wf-claude-ralph-detached",
|
|
370
|
+
type: "workflow",
|
|
371
|
+
agent: "claude",
|
|
372
|
+
attached: false,
|
|
373
|
+
});
|
|
374
|
+
const attached = fakeSession({
|
|
375
|
+
name: "atomic-wf-claude-ralph-attached",
|
|
376
|
+
type: "workflow",
|
|
377
|
+
agent: "claude",
|
|
378
|
+
attached: true,
|
|
379
|
+
});
|
|
380
|
+
await call(
|
|
381
|
+
"atomic-wf-claude-ralph-detached",
|
|
382
|
+
makeDeps({ listAllTmuxSessions: () => [detached], attachSession: attachSpy }),
|
|
383
|
+
);
|
|
384
|
+
await call(
|
|
385
|
+
"atomic-wf-claude-ralph-attached",
|
|
386
|
+
makeDeps({ listAllTmuxSessions: () => [attached], attachSession: attachSpy }),
|
|
387
|
+
);
|
|
388
|
+
expect(attachSpy).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("nextWindow", () => {
|
|
393
|
+
test("invokes tmux next-window against the session id", async () => {
|
|
394
|
+
const nextSpy = mock<(id: string) => void>(() => {});
|
|
395
|
+
const sess = fakeSession({ name: "s" });
|
|
396
|
+
await nextWindow("s", makeDeps({ listAllTmuxSessions: () => [sess], nextWindow: nextSpy }));
|
|
397
|
+
expect(nextSpy).toHaveBeenCalledTimes(1);
|
|
398
|
+
expect(nextSpy).toHaveBeenCalledWith("s");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("previousWindow", () => {
|
|
403
|
+
test("invokes tmux previous-window against the session id", async () => {
|
|
404
|
+
const prevSpy = mock<(id: string) => void>(() => {});
|
|
405
|
+
const sess = fakeSession({ name: "s" });
|
|
406
|
+
await previousWindow(
|
|
407
|
+
"s",
|
|
408
|
+
makeDeps({ listAllTmuxSessions: () => [sess], previousWindow: prevSpy }),
|
|
409
|
+
);
|
|
410
|
+
expect(prevSpy).toHaveBeenCalledTimes(1);
|
|
411
|
+
expect(prevSpy).toHaveBeenCalledWith("s");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("gotoOrchestrator", () => {
|
|
416
|
+
test("selects window 0 of the target session", async () => {
|
|
417
|
+
const selectSpy = mock<(target: string) => void>(() => {});
|
|
418
|
+
const sess = fakeSession({ name: "s" });
|
|
419
|
+
await gotoOrchestrator(
|
|
420
|
+
"s",
|
|
421
|
+
makeDeps({ listAllTmuxSessions: () => [sess], selectWindow: selectSpy }),
|
|
422
|
+
);
|
|
423
|
+
expect(selectSpy).toHaveBeenCalledTimes(1);
|
|
424
|
+
expect(selectSpy).toHaveBeenCalledWith("s:0");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ─── getSessionStatus ───────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
describe("getSessionStatus", () => {
|
|
431
|
+
test("returns null for an id that doesn't match the workflow tmux pattern", async () => {
|
|
432
|
+
const readSpy = mock(async () => null);
|
|
433
|
+
const result = await getSessionStatus(
|
|
434
|
+
"atomic-chat-claude-aaa11111",
|
|
435
|
+
makeDeps({ readSnapshot: readSpy }),
|
|
436
|
+
);
|
|
437
|
+
expect(result).toBeNull();
|
|
438
|
+
// Bail-out should happen before the snapshot reader is consulted.
|
|
439
|
+
expect(readSpy).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("returns null for a name with no 8-hex run-id suffix", async () => {
|
|
443
|
+
const readSpy = mock(async () => null);
|
|
444
|
+
const result = await getSessionStatus(
|
|
445
|
+
"atomic-wf-claude-ralph-shortid",
|
|
446
|
+
makeDeps({ readSnapshot: readSpy }),
|
|
447
|
+
);
|
|
448
|
+
expect(result).toBeNull();
|
|
449
|
+
expect(readSpy).not.toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("returns null when the snapshot reader returns null", async () => {
|
|
453
|
+
const result = await getSessionStatus(
|
|
454
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
455
|
+
makeDeps({ readSnapshot: async () => null }),
|
|
456
|
+
);
|
|
457
|
+
expect(result).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("returns the snapshot when the reader yields one", async () => {
|
|
461
|
+
const snapshot: WorkflowStatusSnapshot = {
|
|
462
|
+
schemaVersion: 1,
|
|
463
|
+
workflowRunId: "deadbeef",
|
|
464
|
+
tmuxSession: "atomic-wf-claude-ralph-deadbeef",
|
|
465
|
+
workflowName: "ralph",
|
|
466
|
+
agent: "claude",
|
|
467
|
+
prompt: "fix the auth bug",
|
|
468
|
+
overall: "in_progress",
|
|
469
|
+
completionReached: false,
|
|
470
|
+
fatalError: null,
|
|
471
|
+
updatedAt: NOW,
|
|
472
|
+
sessions: [],
|
|
473
|
+
};
|
|
474
|
+
const readSpy = mock(async () => snapshot);
|
|
475
|
+
const result = await getSessionStatus(
|
|
476
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
477
|
+
makeDeps({ readSnapshot: readSpy, sessionsBaseDir: "/fake/base" }),
|
|
478
|
+
);
|
|
479
|
+
expect(result).toEqual(snapshot);
|
|
480
|
+
expect(readSpy).toHaveBeenCalledTimes(1);
|
|
481
|
+
expect(readSpy).toHaveBeenCalledWith("/fake/base/deadbeef");
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ─── getSessionTranscript ───────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
describe("getSessionTranscript", () => {
|
|
488
|
+
let baseDir: string;
|
|
489
|
+
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
baseDir = mkdtempSync(join(tmpdir(), "atomic-sessions-test-"));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
afterEach(() => {
|
|
495
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("returns [] for an id that doesn't match the workflow tmux pattern", async () => {
|
|
499
|
+
const result = await getSessionTranscript(
|
|
500
|
+
"atomic-chat-claude-aaa11111",
|
|
501
|
+
"stage-1",
|
|
502
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
503
|
+
);
|
|
504
|
+
expect(result).toEqual([]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("returns [] when the messages file does not exist", async () => {
|
|
508
|
+
const result = await getSessionTranscript(
|
|
509
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
510
|
+
"stage-1",
|
|
511
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
512
|
+
);
|
|
513
|
+
expect(result).toEqual([]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("returns parsed messages with valid provider entries", async () => {
|
|
517
|
+
const runId = "deadbeef";
|
|
518
|
+
const stageDir = join(baseDir, runId, "stage-1");
|
|
519
|
+
mkdirSync(stageDir, { recursive: true });
|
|
520
|
+
const messages = [
|
|
521
|
+
{ provider: "claude", data: { kind: "assistant", text: "hello" } },
|
|
522
|
+
{ provider: "copilot", data: { type: "tool" } },
|
|
523
|
+
{ provider: "opencode", data: { info: {}, parts: [] } },
|
|
524
|
+
];
|
|
525
|
+
writeFileSync(join(stageDir, "messages.json"), JSON.stringify(messages));
|
|
526
|
+
|
|
527
|
+
const result = await getSessionTranscript(
|
|
528
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
529
|
+
"stage-1",
|
|
530
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
531
|
+
);
|
|
532
|
+
expect(result).toHaveLength(3);
|
|
533
|
+
expect(result.map((m) => m.provider).sort()).toEqual([
|
|
534
|
+
"claude",
|
|
535
|
+
"copilot",
|
|
536
|
+
"opencode",
|
|
537
|
+
]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("filters out array entries with unknown provider field", async () => {
|
|
541
|
+
const runId = "deadbeef";
|
|
542
|
+
const stageDir = join(baseDir, runId, "stage-1");
|
|
543
|
+
mkdirSync(stageDir, { recursive: true });
|
|
544
|
+
writeFileSync(
|
|
545
|
+
join(stageDir, "messages.json"),
|
|
546
|
+
JSON.stringify([
|
|
547
|
+
{ provider: "claude", data: {} },
|
|
548
|
+
{ provider: "bogus", data: {} },
|
|
549
|
+
null,
|
|
550
|
+
"string-entry",
|
|
551
|
+
42,
|
|
552
|
+
]),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const result = await getSessionTranscript(
|
|
556
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
557
|
+
"stage-1",
|
|
558
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
559
|
+
);
|
|
560
|
+
expect(result).toHaveLength(1);
|
|
561
|
+
expect(result[0]!.provider).toBe("claude");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("returns [] when the messages file is invalid JSON", async () => {
|
|
565
|
+
const runId = "deadbeef";
|
|
566
|
+
const stageDir = join(baseDir, runId, "stage-1");
|
|
567
|
+
mkdirSync(stageDir, { recursive: true });
|
|
568
|
+
writeFileSync(join(stageDir, "messages.json"), "{not-json");
|
|
569
|
+
|
|
570
|
+
const result = await getSessionTranscript(
|
|
571
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
572
|
+
"stage-1",
|
|
573
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
574
|
+
);
|
|
575
|
+
expect(result).toEqual([]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("returns [] when the messages file parses to a non-array", async () => {
|
|
579
|
+
const runId = "deadbeef";
|
|
580
|
+
const stageDir = join(baseDir, runId, "stage-1");
|
|
581
|
+
mkdirSync(stageDir, { recursive: true });
|
|
582
|
+
writeFileSync(
|
|
583
|
+
join(stageDir, "messages.json"),
|
|
584
|
+
JSON.stringify({ provider: "claude" }),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const result = await getSessionTranscript(
|
|
588
|
+
"atomic-wf-claude-ralph-deadbeef",
|
|
589
|
+
"stage-1",
|
|
590
|
+
makeDeps({ sessionsBaseDir: baseDir }),
|
|
591
|
+
);
|
|
592
|
+
expect(result).toEqual([]);
|
|
593
|
+
});
|
|
594
|
+
});
|