compass-agent 2.0.4
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/.github/workflows/publish.yml +24 -0
- package/README.md +294 -0
- package/bun.lock +326 -0
- package/manifest.yml +66 -0
- package/package.json +25 -0
- package/src/app.ts +786 -0
- package/src/db.ts +398 -0
- package/src/handlers/assistant.ts +332 -0
- package/src/handlers/cwd-modal.test.ts +188 -0
- package/src/handlers/cwd-modal.ts +63 -0
- package/src/handlers/setStatus.test.ts +118 -0
- package/src/handlers/stream.test.ts +137 -0
- package/src/handlers/stream.ts +908 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/thread-context.ts +99 -0
- package/src/lib/worktree.ts +103 -0
- package/src/mcp/server.ts +286 -0
- package/src/types.ts +118 -0
- package/src/ui/blocks.ts +155 -0
- package/tests/blocks.test.ts +73 -0
- package/tests/db.test.ts +261 -0
- package/tests/thread-context.test.ts +183 -0
- package/tests/utils.test.ts +75 -0
- package/tsconfig.json +14 -0
package/src/ui/blocks.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAllActiveSessions, getTeachings, getActiveWorktrees, getRecentUsage,
|
|
3
|
+
} from "../db.ts";
|
|
4
|
+
import type { ActiveProcessMap } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export function buildBlocks(text: string, threadKey: string, showStop: boolean): any[] {
|
|
7
|
+
const blocks: any[] = [
|
|
8
|
+
{ type: "section", text: { type: "mrkdwn", text: text || " " } },
|
|
9
|
+
];
|
|
10
|
+
if (showStop) {
|
|
11
|
+
blocks.push({
|
|
12
|
+
type: "actions",
|
|
13
|
+
elements: [
|
|
14
|
+
{
|
|
15
|
+
type: "button",
|
|
16
|
+
text: { type: "plain_text", text: "Stop" },
|
|
17
|
+
style: "danger",
|
|
18
|
+
action_id: "stop_claude",
|
|
19
|
+
value: threadKey,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return blocks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildStopOnlyBlocks(threadKey: string): any[] {
|
|
28
|
+
return [{
|
|
29
|
+
type: "actions",
|
|
30
|
+
elements: [{
|
|
31
|
+
type: "button",
|
|
32
|
+
text: { type: "plain_text", text: "Stop" },
|
|
33
|
+
style: "danger",
|
|
34
|
+
action_id: "stop_claude",
|
|
35
|
+
value: threadKey,
|
|
36
|
+
}],
|
|
37
|
+
}];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildFeedbackBlock(sessionKey: string): any {
|
|
41
|
+
return {
|
|
42
|
+
type: "context_actions",
|
|
43
|
+
elements: [{
|
|
44
|
+
type: "feedback_buttons",
|
|
45
|
+
action_id: "response_feedback",
|
|
46
|
+
positive_button: {
|
|
47
|
+
text: { type: "plain_text", text: "\ud83d\udc4d" },
|
|
48
|
+
value: `positive:${sessionKey}`,
|
|
49
|
+
},
|
|
50
|
+
negative_button: {
|
|
51
|
+
text: { type: "plain_text", text: "\ud83d\udc4e" },
|
|
52
|
+
value: `negative:${sessionKey}`,
|
|
53
|
+
},
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildDisclaimerBlock(): any {
|
|
59
|
+
return {
|
|
60
|
+
type: "context",
|
|
61
|
+
elements: [{
|
|
62
|
+
type: "mrkdwn",
|
|
63
|
+
text: "Generated by Claude. Verify important information.",
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildSuggestedPrompts(cwd: string | null): { prompts: any[]; title: string } {
|
|
69
|
+
return {
|
|
70
|
+
prompts: [
|
|
71
|
+
{
|
|
72
|
+
title: "Set working directory",
|
|
73
|
+
message: "$cwd",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
title: "Explain this codebase",
|
|
77
|
+
message: "Give me a high-level overview of this project's architecture, key files, and how the code is organized.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
title: "Find and fix bugs",
|
|
81
|
+
message: "Look for potential bugs, race conditions, or error-handling issues in the codebase and suggest fixes.",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
title: "Write tests",
|
|
85
|
+
message: "Identify code lacking test coverage and write comprehensive tests for the most critical paths.",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
title: cwd ? `Working in ${cwd}` : "What would you like to do?",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildHomeBlocks(activeProcesses: ActiveProcessMap): any[] {
|
|
93
|
+
const sessions = getAllActiveSessions();
|
|
94
|
+
const teachings = getTeachings("default");
|
|
95
|
+
const worktrees = getActiveWorktrees();
|
|
96
|
+
const recentUsage = getRecentUsage(5);
|
|
97
|
+
|
|
98
|
+
const blocks: any[] = [
|
|
99
|
+
{ type: "header", text: { type: "plain_text", text: "Claude Code Dashboard" } },
|
|
100
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: `Last updated: ${new Date().toLocaleString()}` }] },
|
|
101
|
+
{ type: "divider" },
|
|
102
|
+
{ type: "section", fields: [
|
|
103
|
+
{ type: "mrkdwn", text: `*Active Sessions:*\n${sessions.length}` },
|
|
104
|
+
{ type: "mrkdwn", text: `*Team Teachings:*\n${teachings.length}` },
|
|
105
|
+
{ type: "mrkdwn", text: `*Active Worktrees:*\n${worktrees.length}` },
|
|
106
|
+
{ type: "mrkdwn", text: `*Running Processes:*\n${activeProcesses.size}` },
|
|
107
|
+
]},
|
|
108
|
+
{ type: "divider" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
if (sessions.length > 0) {
|
|
112
|
+
blocks.push({ type: "header", text: { type: "plain_text", text: "Recent Sessions" } });
|
|
113
|
+
for (const s of sessions.slice(0, 10)) {
|
|
114
|
+
const statusIcon = activeProcesses.has(s.channel_id) ? ":large_green_circle:" : ":white_circle:";
|
|
115
|
+
blocks.push({
|
|
116
|
+
type: "section",
|
|
117
|
+
text: { type: "mrkdwn", text: `${statusIcon} \`${s.cwd || "no cwd"}\`\n_Updated: ${s.updated_at}_` },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
blocks.push({ type: "divider" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (recentUsage.length > 0) {
|
|
124
|
+
blocks.push({ type: "header", text: { type: "plain_text", text: "Recent Activity" } });
|
|
125
|
+
for (const u of recentUsage) {
|
|
126
|
+
blocks.push({
|
|
127
|
+
type: "section",
|
|
128
|
+
text: { type: "mrkdwn", text: `*${u.model || "claude"}* \u2014 ${u.num_turns} turn(s), $${(u.total_cost_usd || 0).toFixed(4)} \u2014 _${u.created_at}_` },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
blocks.push({ type: "divider" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
blocks.push(
|
|
135
|
+
{ type: "header", text: { type: "plain_text", text: "Quick Actions" } },
|
|
136
|
+
{
|
|
137
|
+
type: "actions",
|
|
138
|
+
elements: [
|
|
139
|
+
{
|
|
140
|
+
type: "button",
|
|
141
|
+
text: { type: "plain_text", text: "View Teachings" },
|
|
142
|
+
action_id: "home_view_teachings",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "button",
|
|
146
|
+
text: { type: "plain_text", text: "Add Teaching" },
|
|
147
|
+
action_id: "home_add_teaching",
|
|
148
|
+
style: "primary",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return blocks;
|
|
155
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildBlocks, buildStopOnlyBlocks, buildFeedbackBlock,
|
|
4
|
+
buildDisclaimerBlock, buildSuggestedPrompts,
|
|
5
|
+
} from "../src/ui/blocks.ts";
|
|
6
|
+
|
|
7
|
+
describe("buildBlocks", () => {
|
|
8
|
+
test("renders text section without stop button", () => {
|
|
9
|
+
const blocks = buildBlocks("Hello world", "thread-1", false);
|
|
10
|
+
expect(blocks.length).toBe(1);
|
|
11
|
+
expect(blocks[0].type).toBe("section");
|
|
12
|
+
expect(blocks[0].text.text).toBe("Hello world");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("renders text section with stop button", () => {
|
|
16
|
+
const blocks = buildBlocks("Hello", "thread-1", true);
|
|
17
|
+
expect(blocks.length).toBe(2);
|
|
18
|
+
expect(blocks[1].type).toBe("actions");
|
|
19
|
+
expect(blocks[1].elements[0].action_id).toBe("stop_claude");
|
|
20
|
+
expect(blocks[1].elements[0].value).toBe("thread-1");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("uses space for empty text", () => {
|
|
24
|
+
const blocks = buildBlocks("", "thread-1", false);
|
|
25
|
+
expect(blocks[0].text.text).toBe(" ");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("buildStopOnlyBlocks", () => {
|
|
30
|
+
test("returns actions block with stop button", () => {
|
|
31
|
+
const blocks = buildStopOnlyBlocks("thread-1");
|
|
32
|
+
expect(blocks.length).toBe(1);
|
|
33
|
+
expect(blocks[0].type).toBe("actions");
|
|
34
|
+
expect(blocks[0].elements[0].style).toBe("danger");
|
|
35
|
+
expect(blocks[0].elements[0].value).toBe("thread-1");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("buildFeedbackBlock", () => {
|
|
40
|
+
test("returns context_actions with feedback buttons", () => {
|
|
41
|
+
const block = buildFeedbackBlock("sess-1");
|
|
42
|
+
expect(block.type).toBe("context_actions");
|
|
43
|
+
expect(block.elements[0].positive_button.value).toBe("positive:sess-1");
|
|
44
|
+
expect(block.elements[0].negative_button.value).toBe("negative:sess-1");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("buildDisclaimerBlock", () => {
|
|
49
|
+
test("returns context block with disclaimer text", () => {
|
|
50
|
+
const block = buildDisclaimerBlock();
|
|
51
|
+
expect(block.type).toBe("context");
|
|
52
|
+
expect(block.elements[0].text).toContain("Verify important information");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("buildSuggestedPrompts", () => {
|
|
57
|
+
test("includes 4 prompts", () => {
|
|
58
|
+
const { prompts } = buildSuggestedPrompts(null);
|
|
59
|
+
expect(prompts.length).toBe(4);
|
|
60
|
+
expect(prompts[0].title).toBeDefined();
|
|
61
|
+
expect(prompts[0].message).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("title includes cwd when provided", () => {
|
|
65
|
+
const { title } = buildSuggestedPrompts("/home/project");
|
|
66
|
+
expect(title).toContain("/home/project");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("title uses default when cwd is null", () => {
|
|
70
|
+
const { title } = buildSuggestedPrompts(null);
|
|
71
|
+
expect(title).toBe("What would you like to do?");
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tests/db.test.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { createDatabase } from "../src/db.ts";
|
|
3
|
+
|
|
4
|
+
function freshDb() {
|
|
5
|
+
return createDatabase(":memory:");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("sessions", () => {
|
|
9
|
+
let db: ReturnType<typeof createDatabase>;
|
|
10
|
+
beforeEach(() => { db = freshDb(); });
|
|
11
|
+
|
|
12
|
+
test("getSession returns null for unknown channel", () => {
|
|
13
|
+
expect(db.getSession("C999")).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("upsertSession creates and retrieves a session", () => {
|
|
17
|
+
db.upsertSession("C1", "sess-abc");
|
|
18
|
+
const s = db.getSession("C1");
|
|
19
|
+
expect(s).not.toBeNull();
|
|
20
|
+
expect(s!.channel_id).toBe("C1");
|
|
21
|
+
expect(s!.session_id).toBe("sess-abc");
|
|
22
|
+
expect(s!.persisted).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("upsertSession updates existing session", () => {
|
|
26
|
+
db.upsertSession("C1", "sess-1");
|
|
27
|
+
db.upsertSession("C1", "sess-2");
|
|
28
|
+
const s = db.getSession("C1");
|
|
29
|
+
expect(s!.session_id).toBe("sess-2");
|
|
30
|
+
expect(s!.persisted).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("markPersisted sets persisted flag", () => {
|
|
34
|
+
db.upsertSession("C1", "sess-1");
|
|
35
|
+
db.markPersisted("C1");
|
|
36
|
+
expect(db.getSession("C1")!.persisted).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("upsert after markPersisted resets persisted to 0", () => {
|
|
40
|
+
db.upsertSession("C1", "sess-1");
|
|
41
|
+
db.markPersisted("C1");
|
|
42
|
+
db.upsertSession("C1", "sess-2");
|
|
43
|
+
expect(db.getSession("C1")!.persisted).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("deleteSession removes session", () => {
|
|
47
|
+
db.upsertSession("C1", "sess-1");
|
|
48
|
+
db.deleteSession("C1");
|
|
49
|
+
expect(db.getSession("C1")).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("setCwd updates session cwd", () => {
|
|
53
|
+
db.upsertSession("C1", "sess-1");
|
|
54
|
+
db.setCwd("C1", "/home/project");
|
|
55
|
+
expect(db.getSession("C1")!.cwd).toBe("/home/project");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("getAllActiveSessions returns sessions ordered by updated_at", () => {
|
|
59
|
+
db.upsertSession("C1", "s1");
|
|
60
|
+
db.upsertSession("C2", "s2");
|
|
61
|
+
db.upsertSession("C3", "s3");
|
|
62
|
+
const all = db.getAllActiveSessions();
|
|
63
|
+
expect(all.length).toBe(3);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("cwd_history", () => {
|
|
68
|
+
let db: ReturnType<typeof createDatabase>;
|
|
69
|
+
beforeEach(() => { db = freshDb(); });
|
|
70
|
+
|
|
71
|
+
test("getCwdHistory is empty initially", () => {
|
|
72
|
+
expect(db.getCwdHistory()).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("addCwdHistory adds and retrieves path", () => {
|
|
76
|
+
db.addCwdHistory("/home/project");
|
|
77
|
+
const history = db.getCwdHistory();
|
|
78
|
+
expect(history.length).toBe(1);
|
|
79
|
+
expect(history[0].path).toBe("/home/project");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("addCwdHistory upserts on duplicate path", () => {
|
|
83
|
+
db.addCwdHistory("/home/project");
|
|
84
|
+
db.addCwdHistory("/home/project");
|
|
85
|
+
expect(db.getCwdHistory().length).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("channel_defaults", () => {
|
|
90
|
+
let db: ReturnType<typeof createDatabase>;
|
|
91
|
+
beforeEach(() => { db = freshDb(); });
|
|
92
|
+
|
|
93
|
+
test("getChannelDefault returns null for unknown channel", () => {
|
|
94
|
+
expect(db.getChannelDefault("C999")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("setChannelDefault creates and retrieves default", () => {
|
|
98
|
+
db.setChannelDefault("C1", "/home/project", "U1");
|
|
99
|
+
const d = db.getChannelDefault("C1");
|
|
100
|
+
expect(d!.cwd).toBe("/home/project");
|
|
101
|
+
expect(d!.set_by).toBe("U1");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("setChannelDefault upserts on conflict", () => {
|
|
105
|
+
db.setChannelDefault("C1", "/old", "U1");
|
|
106
|
+
db.setChannelDefault("C1", "/new", "U2");
|
|
107
|
+
const d = db.getChannelDefault("C1");
|
|
108
|
+
expect(d!.cwd).toBe("/new");
|
|
109
|
+
expect(d!.set_by).toBe("U2");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("teachings", () => {
|
|
114
|
+
let db: ReturnType<typeof createDatabase>;
|
|
115
|
+
beforeEach(() => { db = freshDb(); });
|
|
116
|
+
|
|
117
|
+
test("getTeachings returns empty for fresh db", () => {
|
|
118
|
+
expect(db.getTeachings()).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("addTeaching + getTeachings round-trip", () => {
|
|
122
|
+
db.addTeaching("Use bun not node", "U1");
|
|
123
|
+
db.addTeaching("Always test", "U2");
|
|
124
|
+
const teachings = db.getTeachings();
|
|
125
|
+
expect(teachings.length).toBe(2);
|
|
126
|
+
expect(teachings[0].instruction).toBe("Use bun not node");
|
|
127
|
+
expect(teachings[1].instruction).toBe("Always test");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("removeTeaching soft-deletes", () => {
|
|
131
|
+
db.addTeaching("Remove me", "U1");
|
|
132
|
+
const id = db.getTeachings()[0].id;
|
|
133
|
+
db.removeTeaching(id);
|
|
134
|
+
expect(db.getTeachings()).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("getTeachingCount reflects active teachings", () => {
|
|
138
|
+
expect(db.getTeachingCount().count).toBe(0);
|
|
139
|
+
db.addTeaching("one", "U1");
|
|
140
|
+
db.addTeaching("two", "U1");
|
|
141
|
+
expect(db.getTeachingCount().count).toBe(2);
|
|
142
|
+
db.removeTeaching(db.getTeachings()[0].id);
|
|
143
|
+
expect(db.getTeachingCount().count).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("teachings are scoped by workspace_id", () => {
|
|
147
|
+
db.addTeaching("default teaching", "U1", "default");
|
|
148
|
+
db.addTeaching("team teaching", "U1", "team-a");
|
|
149
|
+
expect(db.getTeachings("default").length).toBe(1);
|
|
150
|
+
expect(db.getTeachings("team-a").length).toBe(1);
|
|
151
|
+
expect(db.getTeachings("team-b").length).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("usage_logs", () => {
|
|
156
|
+
let db: ReturnType<typeof createDatabase>;
|
|
157
|
+
beforeEach(() => { db = freshDb(); });
|
|
158
|
+
|
|
159
|
+
test("getRecentUsage returns empty initially", () => {
|
|
160
|
+
expect(db.getRecentUsage()).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("addUsageLog + getRecentUsage round-trip", () => {
|
|
164
|
+
db.addUsageLog("thread-1", "U1", "claude-3", 100, 200, 0.05, 1000, 3);
|
|
165
|
+
const logs = db.getRecentUsage();
|
|
166
|
+
expect(logs.length).toBe(1);
|
|
167
|
+
expect(logs[0].session_key).toBe("thread-1");
|
|
168
|
+
expect(logs[0].input_tokens).toBe(100);
|
|
169
|
+
expect(logs[0].output_tokens).toBe(200);
|
|
170
|
+
expect(logs[0].total_cost_usd).toBeCloseTo(0.05);
|
|
171
|
+
expect(logs[0].num_turns).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("getRecentUsage respects limit", () => {
|
|
175
|
+
for (let i = 0; i < 5; i++) {
|
|
176
|
+
db.addUsageLog(`t-${i}`, "U1", "claude-3", 10, 20, 0.01, 100, 1);
|
|
177
|
+
}
|
|
178
|
+
expect(db.getRecentUsage(3).length).toBe(3);
|
|
179
|
+
expect(db.getRecentUsage(10).length).toBe(5);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("worktrees", () => {
|
|
184
|
+
let db: ReturnType<typeof createDatabase>;
|
|
185
|
+
beforeEach(() => { db = freshDb(); });
|
|
186
|
+
|
|
187
|
+
test("getWorktree returns null for unknown session", () => {
|
|
188
|
+
expect(db.getWorktree("unknown")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("upsertWorktree creates and retrieves worktree", () => {
|
|
192
|
+
db.upsertWorktree("thread-1", "/repo", "/repo/trees/branch", "slack/branch");
|
|
193
|
+
const wt = db.getWorktree("thread-1");
|
|
194
|
+
expect(wt!.repo_path).toBe("/repo");
|
|
195
|
+
expect(wt!.worktree_path).toBe("/repo/trees/branch");
|
|
196
|
+
expect(wt!.branch_name).toBe("slack/branch");
|
|
197
|
+
expect(wt!.cleaned_up).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("upsertWorktree updates existing and resets cleaned_up", () => {
|
|
201
|
+
db.upsertWorktree("thread-1", "/repo", "/old", "old-branch");
|
|
202
|
+
db.markWorktreeCleaned("thread-1");
|
|
203
|
+
expect(db.getWorktree("thread-1")!.cleaned_up).toBe(1);
|
|
204
|
+
|
|
205
|
+
db.upsertWorktree("thread-1", "/repo", "/new", "new-branch");
|
|
206
|
+
const wt = db.getWorktree("thread-1");
|
|
207
|
+
expect(wt!.worktree_path).toBe("/new");
|
|
208
|
+
expect(wt!.cleaned_up).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("getActiveWorktrees excludes cleaned up", () => {
|
|
212
|
+
db.upsertWorktree("t1", "/r", "/w1", "b1");
|
|
213
|
+
db.upsertWorktree("t2", "/r", "/w2", "b2");
|
|
214
|
+
db.markWorktreeCleaned("t1");
|
|
215
|
+
const active = db.getActiveWorktrees();
|
|
216
|
+
expect(active.length).toBe(1);
|
|
217
|
+
expect(active[0].session_key).toBe("t2");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("feedback", () => {
|
|
222
|
+
let db: ReturnType<typeof createDatabase>;
|
|
223
|
+
beforeEach(() => { db = freshDb(); });
|
|
224
|
+
|
|
225
|
+
test("addFeedback inserts without error", () => {
|
|
226
|
+
expect(() => {
|
|
227
|
+
db.addFeedback("thread-1", "U1", "positive", "1234.5678");
|
|
228
|
+
}).not.toThrow();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("reminders", () => {
|
|
233
|
+
let db: ReturnType<typeof createDatabase>;
|
|
234
|
+
beforeEach(() => { db = freshDb(); });
|
|
235
|
+
|
|
236
|
+
test("getActiveReminders returns empty initially", () => {
|
|
237
|
+
expect(db.getActiveReminders("U1")).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("addReminder + getActiveReminders round-trip", () => {
|
|
241
|
+
db.addReminder("C1", "U1", "B1", "standup", "remind standup", "0 9 * * *", 0, "2025-01-01 09:00:00");
|
|
242
|
+
const reminders = db.getActiveReminders("U1");
|
|
243
|
+
expect(reminders.length).toBe(1);
|
|
244
|
+
expect(reminders[0].content).toBe("standup");
|
|
245
|
+
expect(reminders[0].cron_expression).toBe("0 9 * * *");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("deactivateReminder hides from active list", () => {
|
|
249
|
+
db.addReminder("C1", "U1", "B1", "test", "test", null, 1, "2025-01-01 09:00:00");
|
|
250
|
+
const id = db.getActiveReminders("U1")[0].id;
|
|
251
|
+
db.deactivateReminder(id);
|
|
252
|
+
expect(db.getActiveReminders("U1")).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("updateNextTrigger changes trigger time", () => {
|
|
256
|
+
db.addReminder("C1", "U1", "B1", "test", "test", "0 9 * * *", 0, "2025-01-01 09:00:00");
|
|
257
|
+
const id = db.getActiveReminders("U1")[0].id;
|
|
258
|
+
db.updateNextTrigger("2025-01-02 09:00:00", id);
|
|
259
|
+
expect(db.getActiveReminders("U1")[0].next_trigger_at).toBe("2025-01-02 09:00:00");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { extractGapMessages, formatGapMessages, type ThreadMessage } from "../src/lib/thread-context.ts";
|
|
3
|
+
|
|
4
|
+
const BOT_USER_ID = "U_BOT";
|
|
5
|
+
|
|
6
|
+
function msg(user: string, text: string, ts: string, extra?: Partial<ThreadMessage>): ThreadMessage {
|
|
7
|
+
return { user, text, ts, ...extra };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("extractGapMessages", () => {
|
|
11
|
+
test("returns gap messages after bot's last reply", () => {
|
|
12
|
+
const messages: ThreadMessage[] = [
|
|
13
|
+
msg("U1", "Let's refactor auth", "1.0"),
|
|
14
|
+
msg(BOT_USER_ID, "Sure, here's my suggestion", "2.0"),
|
|
15
|
+
msg("U2", "What about JWT?", "3.0"),
|
|
16
|
+
msg("U3", "Yeah JWT makes sense", "4.0"),
|
|
17
|
+
msg("U1", "@bot thoughts on JWT?", "5.0"),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const gap = extractGapMessages(messages, "5.0", BOT_USER_ID);
|
|
21
|
+
expect(gap).toHaveLength(2);
|
|
22
|
+
expect(gap[0].text).toBe("What about JWT?");
|
|
23
|
+
expect(gap[1].text).toBe("Yeah JWT makes sense");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns up to 50 messages when no bot message exists", () => {
|
|
27
|
+
const messages: ThreadMessage[] = [
|
|
28
|
+
msg("U1", "Let's discuss the deploy", "1.0"),
|
|
29
|
+
msg("U2", "I think staging is broken", "2.0"),
|
|
30
|
+
msg("U3", "Let me check", "3.0"),
|
|
31
|
+
msg("U1", "@bot can you help?", "4.0"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const gap = extractGapMessages(messages, "4.0", BOT_USER_ID);
|
|
35
|
+
expect(gap).toHaveLength(3);
|
|
36
|
+
expect(gap[0].text).toBe("Let's discuss the deploy");
|
|
37
|
+
expect(gap[1].text).toBe("I think staging is broken");
|
|
38
|
+
expect(gap[2].text).toBe("Let me check");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns empty when bot message is right before the mention", () => {
|
|
42
|
+
const messages: ThreadMessage[] = [
|
|
43
|
+
msg("U1", "Hey", "1.0"),
|
|
44
|
+
msg(BOT_USER_ID, "Hello!", "2.0"),
|
|
45
|
+
msg("U1", "@bot do more", "3.0"),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const gap = extractGapMessages(messages, "3.0", BOT_USER_ID);
|
|
49
|
+
expect(gap).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("stops at the most recent bot message when multiple exist", () => {
|
|
53
|
+
const messages: ThreadMessage[] = [
|
|
54
|
+
msg("U1", "First question", "1.0"),
|
|
55
|
+
msg(BOT_USER_ID, "First answer", "2.0"),
|
|
56
|
+
msg("U2", "Follow up", "3.0"),
|
|
57
|
+
msg(BOT_USER_ID, "Second answer", "4.0"),
|
|
58
|
+
msg("U3", "Another topic", "5.0"),
|
|
59
|
+
msg("U1", "@bot help", "6.0"),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const gap = extractGapMessages(messages, "6.0", BOT_USER_ID);
|
|
63
|
+
expect(gap).toHaveLength(1);
|
|
64
|
+
expect(gap[0].text).toBe("Another topic");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("caps at 50 gap messages", () => {
|
|
68
|
+
const messages: ThreadMessage[] = [];
|
|
69
|
+
for (let i = 0; i < 60; i++) {
|
|
70
|
+
messages.push(msg("U1", `Message ${i}`, `${i}.0`));
|
|
71
|
+
}
|
|
72
|
+
messages.push(msg("U1", "@bot help", "100.0"));
|
|
73
|
+
|
|
74
|
+
const gap = extractGapMessages(messages, "100.0", BOT_USER_ID);
|
|
75
|
+
expect(gap).toHaveLength(50);
|
|
76
|
+
// Should be the last 50 messages (indices 10-59)
|
|
77
|
+
expect(gap[0].text).toBe("Message 10");
|
|
78
|
+
expect(gap[49].text).toBe("Message 59");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("excludes the current mention message", () => {
|
|
82
|
+
const messages: ThreadMessage[] = [
|
|
83
|
+
msg("U1", "Hey", "1.0"),
|
|
84
|
+
msg("U1", "@bot help me", "2.0"),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const gap = extractGapMessages(messages, "2.0", BOT_USER_ID);
|
|
88
|
+
expect(gap).toHaveLength(1);
|
|
89
|
+
expect(gap[0].text).toBe("Hey");
|
|
90
|
+
// The mention itself should not appear
|
|
91
|
+
expect(gap.find((m) => m.ts === "2.0")).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("identifies bot by bot_id field", () => {
|
|
95
|
+
const messages: ThreadMessage[] = [
|
|
96
|
+
msg("U1", "Start", "1.0"),
|
|
97
|
+
msg("U_OTHER", "Bot reply", "2.0", { bot_id: "B123" }),
|
|
98
|
+
msg("U2", "After bot", "3.0"),
|
|
99
|
+
msg("U1", "@bot hi", "4.0"),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const gap = extractGapMessages(messages, "4.0", BOT_USER_ID);
|
|
103
|
+
expect(gap).toHaveLength(1);
|
|
104
|
+
expect(gap[0].text).toBe("After bot");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("identifies bot by user field matching botUserId", () => {
|
|
108
|
+
const messages: ThreadMessage[] = [
|
|
109
|
+
msg("U1", "Before", "1.0"),
|
|
110
|
+
msg(BOT_USER_ID, "Bot says hi", "2.0"),
|
|
111
|
+
msg("U2", "After", "3.0"),
|
|
112
|
+
msg("U1", "@bot yo", "4.0"),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const gap = extractGapMessages(messages, "4.0", BOT_USER_ID);
|
|
116
|
+
expect(gap).toHaveLength(1);
|
|
117
|
+
expect(gap[0].text).toBe("After");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns empty for empty messages array", () => {
|
|
121
|
+
const gap = extractGapMessages([], "1.0", BOT_USER_ID);
|
|
122
|
+
expect(gap).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("returns empty when thread has only the mention", () => {
|
|
126
|
+
const messages: ThreadMessage[] = [
|
|
127
|
+
msg("U1", "@bot help", "1.0"),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const gap = extractGapMessages(messages, "1.0", BOT_USER_ID);
|
|
131
|
+
expect(gap).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("works when botUserId is null (uses bot_id only)", () => {
|
|
135
|
+
const messages: ThreadMessage[] = [
|
|
136
|
+
msg("U1", "Start", "1.0"),
|
|
137
|
+
msg("U_ANY", "Bot reply", "2.0", { bot_id: "B456" }),
|
|
138
|
+
msg("U2", "Gap msg", "3.0"),
|
|
139
|
+
msg("U1", "@bot hi", "4.0"),
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const gap = extractGapMessages(messages, "4.0", null);
|
|
143
|
+
expect(gap).toHaveLength(1);
|
|
144
|
+
expect(gap[0].text).toBe("Gap msg");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("formatGapMessages", () => {
|
|
149
|
+
test("returns empty string for no gap messages", () => {
|
|
150
|
+
expect(formatGapMessages([])).toBe("");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("formats messages with user mentions and header", () => {
|
|
154
|
+
const gap: ThreadMessage[] = [
|
|
155
|
+
msg("U1", "Use JWT for auth", "1.0"),
|
|
156
|
+
msg("U2", "Agreed", "2.0"),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const result = formatGapMessages(gap);
|
|
160
|
+
expect(result).toContain("[Thread context");
|
|
161
|
+
expect(result).toContain("<@U1>: Use JWT for auth");
|
|
162
|
+
expect(result).toContain("<@U2>: Agreed");
|
|
163
|
+
expect(result).toEndWith("---\n");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("handles missing user with 'unknown'", () => {
|
|
167
|
+
const gap: ThreadMessage[] = [
|
|
168
|
+
{ ts: "1.0", text: "Hello" },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const result = formatGapMessages(gap);
|
|
172
|
+
expect(result).toContain("<@unknown>: Hello");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("handles missing text gracefully", () => {
|
|
176
|
+
const gap: ThreadMessage[] = [
|
|
177
|
+
{ ts: "1.0", user: "U1" },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const result = formatGapMessages(gap);
|
|
181
|
+
expect(result).toContain("<@U1>: ");
|
|
182
|
+
});
|
|
183
|
+
});
|