@lightupai/polaris 0.0.4 → 0.0.6

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/src/web/views.ts CHANGED
@@ -13,6 +13,7 @@ interface ViewContext {
13
13
  slackConnected: boolean;
14
14
  cliInstalled: boolean;
15
15
  hasConnectedSession: boolean;
16
+ totalPrompts: number;
16
17
  }
17
18
 
18
19
  function navOpts(ctx: ViewContext): NavOpts {
@@ -26,8 +27,7 @@ function copyBlock(text: string): string {
26
27
  return `
27
28
  <div class="mt-3 relative">
28
29
  <code id="${id}" class="block bg-gray-50 border border-gray-200 rounded px-3 py-2 pr-10 text-xs font-mono select-all">${text}</code>
29
- <button onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent);this.innerHTML='<svg class=\\'w-3.5 h-3.5 text-green-500\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M5 13l4 4L19 7\\'/></svg>';setTimeout(()=>this.innerHTML='<svg class=\\'w-3.5 h-3.5\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\\'/></svg>',1500)"
30
- class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 transition rounded" title="Copy">
30
+ <button data-copy="${id}" class="polaris-copy absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 transition rounded" title="Copy">
31
31
  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
32
32
  </button>
33
33
  </div>`;
@@ -64,35 +64,30 @@ function sectionWrap(state: StepState, content: string): string {
64
64
 
65
65
  function renderFloorSection(ctx: ViewContext, compact = false, state: StepState = "done"): string {
66
66
  if (compact && ctx.slackConnected) {
67
+ const promptStat = ctx.totalPrompts > 0
68
+ ? `<span class="text-xs text-gray-400 ml-auto">${ctx.totalPrompts} prompt${ctx.totalPrompts !== 1 ? "s" : ""}</span>`
69
+ : '';
67
70
  return `
68
71
  <div>
69
- ${sectionHeader("Floor")}
72
+ <div class="flex items-baseline gap-2 mb-3">
73
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
74
+ ${statusBadge("Connected", true)}
75
+ </div>
70
76
  <div class="bg-white border border-gray-200 rounded-lg px-5 py-3 flex items-center gap-3">
71
77
  <div class="w-8 h-8 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
72
78
  ${slackIcon.replace('class="w-4 h-4"', 'class="w-4 h-4 text-white"')}
73
79
  </div>
74
80
  <p class="text-sm font-medium text-gray-900">Slack</p>
75
- ${statusBadge("Live", true)}
81
+ ${ctx.orgSlug ? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>` : ''}
82
+ ${promptStat}
76
83
  </div>
77
84
  </div>`;
78
85
  }
79
86
 
80
87
  if (ctx.slackConnected) {
81
- const slugRow = ctx.orgSlug
82
- ? `<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
83
- <div>
84
- <p class="text-xs text-gray-400">Org identifier</p>
85
- <p class="text-sm font-mono text-gray-700">${ctx.orgSlug}</p>
86
- </div>
87
- <form action="/settings/slug" method="POST" class="flex items-center gap-2">
88
- <input type="hidden" name="token" value="${ctx.token}">
89
- <input type="text" name="slug" value="${ctx.orgSlug}" class="px-2 py-1 border border-gray-200 rounded text-xs font-mono w-40 focus:ring-polaris-500 focus:border-polaris-500 outline-none hidden" id="slug-edit-${ctx.token.slice(-6)}">
90
- <button type="button" onclick="const i=this.previousElementSibling;i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.focus()" class="text-xs text-gray-400 hover:text-gray-600">Edit</button>
91
- </form>
92
- </div>`
93
- : `<div class="mt-3 pt-3 border-t border-gray-100">
94
- <p class="text-xs text-gray-400">Org identifier: <span class="text-gray-500">not set (will be set from first floor connection)</span></p>
95
- </div>`;
88
+ const slugLabel = ctx.orgSlug
89
+ ? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>`
90
+ : '';
96
91
 
97
92
  return `
98
93
  <div>
@@ -100,17 +95,12 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
100
95
  <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
101
96
  ${statusBadge("Connected", true)}
102
97
  </div>
103
- <div class="bg-white border border-gray-200 rounded-lg p-5">
104
- <div class="flex items-center gap-3">
105
- <div class="w-10 h-10 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
106
- ${slackIcon.replace('class="w-4 h-4"', 'class="w-5 h-5 text-white"')}
107
- </div>
108
- <div>
109
- <p class="text-sm font-semibold text-gray-900">Slack</p>
110
- <p class="text-sm text-gray-500 mt-0.5">Workspace linked. Channels are auto-created for your projects.</p>
111
- </div>
98
+ <div class="bg-white border border-gray-200 rounded-lg px-5 py-3 flex items-center gap-3">
99
+ <div class="w-8 h-8 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
100
+ ${slackIcon.replace('class="w-4 h-4"', 'class="w-4 h-4 text-white"')}
112
101
  </div>
113
- ${slugRow}
102
+ <p class="text-sm font-medium text-gray-900">Slack</p>
103
+ ${slugLabel}
114
104
  </div>
115
105
  </div>`;
116
106
  }
@@ -153,7 +143,7 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
153
143
  <summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Add another device</summary>
154
144
  <div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
155
145
  <p class="text-sm text-gray-500">Run on any new machine:</p>
156
- ${copyBlock("npx @lightupai/polaris login")}
146
+ ${copyBlock("npx @lightupai/polaris")}
157
147
  </div>
158
148
  </details>
159
149
  <div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
@@ -172,7 +162,7 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
172
162
  <div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
173
163
  <p class="text-sm font-semibold text-gray-900">${ctx.cliInstalled ? "Add another device" : "Set up Polaris on your first device"}</p>
174
164
  <p class="text-sm text-gray-500 mt-1">Run this in your terminal${ctx.cliInstalled ? " on any new machine" : ". Repeat on each machine you work from"}.</p>
175
- ${copyBlock("npx @lightupai/polaris login")}
165
+ ${copyBlock("npx @lightupai/polaris")}
176
166
  </div>
177
167
  </div>`);
178
168
  }
@@ -206,10 +196,14 @@ function renderDeviceRow(device: DeviceFixture): string {
206
196
  // --- Projects & Sessions section ---
207
197
 
208
198
  function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixture[], projects: ProjectFixture[], state: StepState = "done"): string {
209
- if (sessions.length > 0) {
199
+ if (projects.length > 0) {
200
+ const totalSessions = projects.reduce((n, p) => n + p.sessions.length, 0);
210
201
  return `
211
202
  <div>
212
- ${sectionHeader("Projects & Sessions")}
203
+ <div class="flex items-baseline gap-2 mb-3">
204
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Projects & Sessions</h2>
205
+ ${statusBadge(`${totalSessions} active`, true)}
206
+ </div>
213
207
  <details class="mb-3">
214
208
  <summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Join another session</summary>
215
209
  <div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
@@ -217,13 +211,9 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
217
211
  ${copyBlock("/polaris join &lt;project&gt; &lt;session&gt;")}
218
212
  </div>
219
213
  </details>
220
- <div class="space-y-3">
221
- ${sessions.map((s) => renderSessionCard(s, ctx.userName)).join("")}
214
+ <div class="space-y-4">
215
+ ${projects.map((p) => renderProjectCard(p, ctx.userName)).join("")}
222
216
  </div>
223
- ${projects.length > 0 ? `
224
- <div class="mt-4 space-y-3">
225
- ${projects.map((p) => renderProjectCard(p)).join("")}
226
- </div>` : ""}
227
217
  </div>`;
228
218
  }
229
219
 
@@ -246,50 +236,46 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
246
236
  </div>`);
247
237
  }
248
238
 
249
- function renderSessionCard(session: SessionFixture, userName: string): string {
250
- const isDriver = session.participants.some((p) => p.id === `user:${userName.toLowerCase().replace(/\s+/g, ".")}` && p.role === "driver");
251
- const roleBadge = isDriver
252
- ? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">Driver</span>'
253
- : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Advisor</span>';
254
-
255
- const otherParticipants = session.participants
256
- .filter((p) => p.id !== `user:${userName.toLowerCase().replace(/\s+/g, ".")}`)
257
- .map((p) => `<span class="text-xs text-gray-500">${p.id}</span>`)
258
- .join(", ");
259
-
260
- return `
261
- <div class="bg-white border border-gray-200 rounded-lg p-4">
262
- <div class="flex items-center justify-between">
263
- <div class="flex items-center gap-2">
264
- <div class="w-2 h-2 rounded-full bg-green-500"></div>
265
- <p class="text-sm font-semibold text-gray-900">${session.project}/${session.name}</p>
266
- ${roleBadge}
267
- </div>
268
- <span class="text-xs text-gray-400">${session.eventCount} events</span>
269
- </div>
270
- <p class="text-sm text-gray-500 mt-1">${session.description}</p>
271
- <div class="mt-2 flex items-center gap-1">
272
- <span class="text-xs text-gray-400">with</span>
273
- ${otherParticipants}
274
- </div>
275
- </div>`;
276
- }
277
-
278
- function renderProjectCard(project: ProjectFixture): string {
279
- const activeSessions = project.sessions.length;
280
- const drivers = project.sessions.map((s) => s.driver).filter((d, i, a) => a.indexOf(d) === i);
239
+ function renderProjectCard(project: ProjectFixture, userName: string): string {
240
+ const sessionCount = project.sessions.length;
241
+ const participantId = `user:${userName.toLowerCase().replace(/\s+/g, ".")}`;
281
242
 
282
243
  return `
283
- <div class="bg-white border border-gray-200 rounded-lg p-4">
284
- <div class="flex items-center justify-between">
244
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
245
+ <div class="px-4 py-3 flex items-center justify-between border-b border-gray-100">
285
246
  <div class="flex items-center gap-2">
286
247
  <p class="text-sm font-semibold text-gray-900">${project.name}</p>
287
- <span class="text-xs text-gray-400">${project.slackChannel}</span>
248
+ ${project.slackChannel ? `<span class="text-xs text-gray-400">${project.slackChannel}</span>` : ""}
288
249
  </div>
289
- <span class="text-xs text-gray-400">${activeSessions} session${activeSessions !== 1 ? "s" : ""}</span>
250
+ <span class="text-xs text-gray-400">${sessionCount} session${sessionCount !== 1 ? "s" : ""}</span>
290
251
  </div>
291
- <div class="mt-2 flex items-center gap-2 flex-wrap">
292
- ${drivers.map((d) => `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-50 text-gray-600">${d}</span>`).join("")}
252
+ <div class="divide-y divide-gray-50">
253
+ ${project.sessions.map((s) => {
254
+ const isDriver = s.driver === participantId;
255
+ const roleBadge = isDriver
256
+ ? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">Driver</span>'
257
+ : s.driver
258
+ ? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Advisor</span>'
259
+ : '';
260
+ const driverLabel = s.driver && !isDriver
261
+ ? `<span class="text-xs text-gray-400">${s.driver}</span>`
262
+ : '';
263
+ const promptLabel = s.eventCount > 0
264
+ ? `<span class="text-xs text-gray-400">${s.eventCount} prompt${s.eventCount !== 1 ? "s" : ""}</span>`
265
+ : '';
266
+ return `
267
+ <div class="px-4 py-3 flex items-center justify-between">
268
+ <div class="flex items-center gap-2">
269
+ <div class="w-2 h-2 rounded-full bg-green-500"></div>
270
+ <p class="text-sm text-gray-700">${s.name}</p>
271
+ ${roleBadge}
272
+ </div>
273
+ <div class="flex items-center gap-3">
274
+ ${driverLabel}
275
+ ${promptLabel}
276
+ </div>
277
+ </div>`;
278
+ }).join("")}
293
279
  </div>
294
280
  </div>`;
295
281
  }
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
2
+ import { createDb, createOrg, createProject, createSession, getSessionEvents, pushEvent, type Sql } from "../src/service/db";
3
+ import { formatEventForSlack } from "../src/slack/format";
4
+ import type { PolarisEvent } from "../src/types";
5
+ import { resetTestData } from "./helpers";
6
+
7
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
8
+
9
+ let sql: Sql;
10
+
11
+ beforeAll(async () => {
12
+ sql = await createDb(DATABASE_URL);
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await sql.end();
17
+ });
18
+
19
+ beforeEach(async () => {
20
+ await resetTestData(sql);
21
+
22
+ await createOrg(sql, "test-org", "Test Org", "test.com");
23
+ await createProject(sql, "test-org", "myproject");
24
+ await createSession(sql, "test-org", "myproject", "fxm", "user:manu");
25
+ });
26
+
27
+ describe("bridge: Slack → DB injection", () => {
28
+ test("inject message writes to events table", async () => {
29
+ // Simulate what handleSlackMessage does after parsing @fxm content
30
+ const { getSession } = await import("../src/service/db");
31
+
32
+ const session = await getSession(sql, "test-org", "myproject", "fxm");
33
+ expect(session).not.toBeNull();
34
+
35
+ const injectEvent: PolarisEvent = {
36
+ id: crypto.randomUUID(),
37
+ project: "myproject",
38
+ session: "fxm",
39
+ timestamp: new Date().toISOString(),
40
+ source: "inject",
41
+ sender: "slack:manu.bansal" as PolarisEvent["sender"],
42
+ payload: {
43
+ type: "inject" as const,
44
+ content: "add token refresh",
45
+ sender: "slack:manu.bansal" as PolarisEvent["sender"],
46
+ target: "fxm",
47
+ },
48
+ };
49
+
50
+ await pushEvent(sql, "test-org", injectEvent);
51
+
52
+ const events = await getSessionEvents(sql, "test-org", "myproject", "fxm");
53
+ expect(events).toHaveLength(1);
54
+ expect(events[0].source).toBe("inject");
55
+ expect(events[0].sender).toBe("slack:manu.bansal");
56
+ expect((events[0].payload as { content: string }).content).toBe("add token refresh");
57
+ });
58
+
59
+ test("inject with slack: sender prefix validates", async () => {
60
+ const event: PolarisEvent = {
61
+ id: crypto.randomUUID(),
62
+ project: "myproject",
63
+ session: "fxm",
64
+ timestamp: new Date().toISOString(),
65
+ source: "inject",
66
+ sender: "slack:krishna" as PolarisEvent["sender"],
67
+ payload: {
68
+ type: "inject" as const,
69
+ content: "use RS256",
70
+ sender: "slack:krishna" as PolarisEvent["sender"],
71
+ target: "fxm",
72
+ },
73
+ };
74
+
75
+ await pushEvent(sql, "test-org", event);
76
+
77
+ const events = await getSessionEvents(sql, "test-org", "myproject", "fxm");
78
+ expect(events[0].sender).toBe("slack:krishna");
79
+ });
80
+
81
+ test("inject to nonexistent session can auto-create", async () => {
82
+ // Session doesn't exist yet
83
+ let session = await getSessionEvents(sql, "test-org", "myproject", "new-session");
84
+ expect(session).toHaveLength(0);
85
+
86
+ // Create session on the fly (like the bridge would)
87
+ await createSession(sql, "test-org", "myproject", "new-session", null);
88
+
89
+ const event: PolarisEvent = {
90
+ id: crypto.randomUUID(),
91
+ project: "myproject",
92
+ session: "new-session",
93
+ timestamp: new Date().toISOString(),
94
+ source: "inject",
95
+ sender: "slack:advisor" as PolarisEvent["sender"],
96
+ payload: {
97
+ type: "inject" as const,
98
+ content: "advice for new session",
99
+ sender: "slack:advisor" as PolarisEvent["sender"],
100
+ target: "new-session",
101
+ },
102
+ };
103
+
104
+ await pushEvent(sql, "test-org", event);
105
+
106
+ const events = await getSessionEvents(sql, "test-org", "myproject", "new-session");
107
+ expect(events).toHaveLength(1);
108
+ });
109
+ });
110
+
111
+ describe("bridge: DB → Slack formatting", () => {
112
+ test("formats UserPromptSubmit for Slack", () => {
113
+ const event: PolarisEvent = {
114
+ id: crypto.randomUUID(),
115
+ project: "myproject",
116
+ session: "fxm",
117
+ timestamp: new Date().toISOString(),
118
+ source: "hook",
119
+ sender: "user:manu",
120
+ payload: {
121
+ hook_event_name: "UserPromptSubmit",
122
+ session_id: "s1",
123
+ prompt: "build auth middleware",
124
+ },
125
+ };
126
+
127
+ const result = formatEventForSlack(event);
128
+ expect(result).not.toBeNull();
129
+ expect(result!.username).toContain("Manu");
130
+ expect(result!.username).toContain("fxm");
131
+ expect(result!.text).toContain("build auth middleware");
132
+ });
133
+
134
+ test("skips _system events", () => {
135
+ const event: PolarisEvent = {
136
+ id: crypto.randomUUID(),
137
+ project: "_system",
138
+ session: "_system",
139
+ timestamp: new Date().toISOString(),
140
+ source: "hook",
141
+ sender: "user:manu",
142
+ payload: {
143
+ hook_event_name: "Stop",
144
+ session_id: "_system",
145
+ stop_response: "Device connected",
146
+ },
147
+ };
148
+
149
+ // The bridge skips _system in postEventToSlack, not in formatEventForSlack
150
+ // But formatEventForSlack still formats it — the bridge filters upstream
151
+ const result = formatEventForSlack(event);
152
+ expect(result).not.toBeNull(); // formatter doesn't filter _system
153
+ });
154
+
155
+ test("skips tool calls", () => {
156
+ const event: PolarisEvent = {
157
+ id: crypto.randomUUID(),
158
+ project: "myproject",
159
+ session: "fxm",
160
+ timestamp: new Date().toISOString(),
161
+ source: "hook",
162
+ sender: "user:manu",
163
+ payload: {
164
+ hook_event_name: "PreToolUse",
165
+ session_id: "s1",
166
+ tool_name: "Bash",
167
+ tool_input: { command: "ls" },
168
+ },
169
+ };
170
+
171
+ expect(formatEventForSlack(event)).toBeNull();
172
+ });
173
+ });
174
+
175
+ describe("bridge: message parsing", () => {
176
+ test("parses @session content format", () => {
177
+ const text = "@fxm add token refresh";
178
+ const match = text.match(/^@(\S+)\s+(.+)$/s);
179
+ expect(match).not.toBeNull();
180
+ expect(match![1]).toBe("fxm");
181
+ expect(match![2]).toBe("add token refresh");
182
+ });
183
+
184
+ test("parses session: content format", () => {
185
+ const text = "fxm: add token refresh";
186
+ const match = text.match(/^(\S+):\s+(.+)$/s);
187
+ expect(match).not.toBeNull();
188
+ expect(match![1]).toBe("fxm");
189
+ expect(match![2]).toBe("add token refresh");
190
+ });
191
+
192
+ test("no match for plain message", () => {
193
+ const text = "just a regular message";
194
+ const match = text.match(/^@(\S+)\s+(.+)$/s) || text.match(/^(\S+):\s+(.+)$/s);
195
+ expect(match).toBeNull();
196
+ });
197
+
198
+ test("handles multiline content", () => {
199
+ const text = "@fxm here is some advice\nwith multiple lines\nof content";
200
+ const match = text.match(/^@(\S+)\s+(.+)$/s);
201
+ expect(match).not.toBeNull();
202
+ expect(match![1]).toBe("fxm");
203
+ expect(match![2]).toContain("multiple lines");
204
+ });
205
+ });
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
2
2
  import { startServer } from "../src/service/server";
3
3
  import { startDaemon } from "../src/daemon/daemon";
4
4
  import type { Sql } from "../src/service/db";
5
+ import { resetTestData } from "./helpers";
5
6
 
6
7
  const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
8
 
@@ -18,6 +19,7 @@ beforeAll(async () => {
18
19
  serviceUrl = `http://localhost:${s.server.port}`;
19
20
 
20
21
  process.env.POLARIS_SERVICE_URL = serviceUrl;
22
+ process.env.POLARIS_AUTH_TOKEN = "";
21
23
  const d = startDaemon(0);
22
24
  stopDaemon = d.stop;
23
25
  daemonUrl = `http://127.0.0.1:${d.server.port}`;
@@ -29,19 +31,7 @@ afterAll(async () => {
29
31
  });
30
32
 
31
33
  beforeEach(async () => {
32
- await sql`DROP TABLE IF EXISTS events`;
33
- await sql`DROP TABLE IF EXISTS sessions`;
34
- await sql`DROP TABLE IF EXISTS projects`;
35
- await sql`DROP TABLE IF EXISTS users`;
36
- await sql`DROP TABLE IF EXISTS orgs`;
37
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
38
- await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
39
- await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
40
- await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
41
- await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
42
- await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
43
- await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
44
- await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
34
+ await resetTestData(sql);
45
35
  });
46
36
 
47
37
  async function post(base: string, path: string, body: unknown) {
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
2
2
  import { startDaemon } from "../src/daemon/daemon";
3
3
  import { startServer } from "../src/service/server";
4
4
  import type { Sql } from "../src/service/db";
5
+ import { resetTestData } from "./helpers";
5
6
 
6
7
  const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
8
 
@@ -20,6 +21,7 @@ beforeAll(async () => {
20
21
 
21
22
  // Start daemon pointed at the cloud service
22
23
  process.env.POLARIS_SERVICE_URL = serviceUrl;
24
+ process.env.POLARIS_AUTH_TOKEN = "";
23
25
  const d = startDaemon(0);
24
26
  stopDaemon = d.stop;
25
27
  daemonUrl = `http://127.0.0.1:${d.server.port}`;
@@ -31,19 +33,7 @@ afterAll(async () => {
31
33
  });
32
34
 
33
35
  beforeEach(async () => {
34
- await sql`DROP TABLE IF EXISTS events`;
35
- await sql`DROP TABLE IF EXISTS sessions`;
36
- await sql`DROP TABLE IF EXISTS projects`;
37
- await sql`DROP TABLE IF EXISTS users`;
38
- await sql`DROP TABLE IF EXISTS orgs`;
39
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
40
- await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
41
- await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
42
- await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
43
- await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
44
- await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
45
- await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
46
- await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
36
+ await resetTestData(sql);
47
37
  });
48
38
 
49
39
  async function post(base: string, path: string, body: unknown) {
@@ -163,7 +153,8 @@ describe("daemon /events (hook relay)", () => {
163
153
  expect(body[0].payload.prompt).toBe("build auth middleware");
164
154
  });
165
155
 
166
- test("discards events for unconnected sessions", async () => {
156
+ test("discards events when no sessions are connected", async () => {
157
+ await post(daemonUrl, "/disconnect-all", {});
167
158
  const res = await post(daemonUrl, "/events", {
168
159
  session_id: "cc-unknown",
169
160
  hook_event_name: "UserPromptSubmit",
package/tests/e2e.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:tes
2
2
  import { startServer } from "../src/service/server";
3
3
  import { startDaemon } from "../src/daemon/daemon";
4
4
  import type { Sql } from "../src/service/db";
5
+ import { resetTestData } from "./helpers";
5
6
 
6
7
  const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
8
 
@@ -18,6 +19,7 @@ beforeAll(async () => {
18
19
  serviceUrl = `http://localhost:${s.server.port}`;
19
20
 
20
21
  process.env.POLARIS_SERVICE_URL = serviceUrl;
22
+ process.env.POLARIS_AUTH_TOKEN = "";
21
23
  const d = startDaemon(0);
22
24
  stopDaemon = d.stop;
23
25
  daemonUrl = `http://127.0.0.1:${d.server.port}`;
@@ -29,19 +31,7 @@ afterAll(async () => {
29
31
  });
30
32
 
31
33
  beforeEach(async () => {
32
- await sql`DROP TABLE IF EXISTS events`;
33
- await sql`DROP TABLE IF EXISTS sessions`;
34
- await sql`DROP TABLE IF EXISTS projects`;
35
- await sql`DROP TABLE IF EXISTS users`;
36
- await sql`DROP TABLE IF EXISTS orgs`;
37
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
38
- await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
39
- await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
40
- await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
41
- await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
42
- await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
43
- await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
44
- await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
34
+ await resetTestData(sql);
45
35
  });
46
36
 
47
37
  async function post(base: string, path: string, body: unknown) {
@@ -355,6 +345,7 @@ describe("e2e: capture.sh through daemon", () => {
355
345
  });
356
346
 
357
347
  test("hook events for unconnected sessions are silently discarded", async () => {
348
+ await post(daemonUrl, "/disconnect-all", {});
358
349
  const res = await post(daemonUrl, "/events", {
359
350
  session_id: "cc-nobody",
360
351
  hook_event_name: "UserPromptSubmit",
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatEventForSlack } from "../src/slack/format";
3
+ import type { PolarisEvent } from "../src/types";
4
+
5
+ function makeEvent(overrides: Partial<PolarisEvent> = {}): PolarisEvent {
6
+ return {
7
+ id: crypto.randomUUID(),
8
+ project: "pj",
9
+ session: "fxm",
10
+ timestamp: new Date().toISOString(),
11
+ source: "hook",
12
+ sender: "user:manu",
13
+ payload: {
14
+ hook_event_name: "UserPromptSubmit",
15
+ session_id: "s1",
16
+ prompt: "build auth middleware",
17
+ },
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("formatEventForSlack", () => {
23
+ test("user prompt: persona with session, plain message", () => {
24
+ const result = formatEventForSlack(makeEvent());
25
+ expect(result).not.toBeNull();
26
+ expect(result!.username).toBe("Manu (fxm)");
27
+ expect(result!.icon_emoji).toBe(":bust_in_silhouette:");
28
+ expect(result!.text).toContain("build auth middleware");
29
+ expect(result!.blocks).toHaveLength(1);
30
+ });
31
+
32
+ test("agent response: robot persona with session", () => {
33
+ const result = formatEventForSlack(makeEvent({
34
+ payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "Created auth.ts" },
35
+ }));
36
+ expect(result).not.toBeNull();
37
+ expect(result!.username).toBe("Agent (fxm)");
38
+ expect(result!.icon_emoji).toBe(":robot_face:");
39
+ expect(result!.text).toContain("Created auth.ts");
40
+ });
41
+
42
+ test("skips PreToolUse", () => {
43
+ expect(formatEventForSlack(makeEvent({
44
+ payload: { hook_event_name: "PreToolUse", session_id: "s1", tool_name: "Bash", tool_input: { command: "ls" } },
45
+ }))).toBeNull();
46
+ });
47
+
48
+ test("skips PostToolUse", () => {
49
+ expect(formatEventForSlack(makeEvent({
50
+ payload: { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "Read", tool_input: { file_path: "/tmp" }, tool_result: {} },
51
+ }))).toBeNull();
52
+ });
53
+
54
+ test("advisor: persona + target in message", () => {
55
+ const result = formatEventForSlack(makeEvent({
56
+ source: "inject",
57
+ sender: "user:krishna",
58
+ payload: { type: "inject" as const, content: "Use RS256", sender: "user:krishna", target: "fxm" },
59
+ }));
60
+ expect(result).not.toBeNull();
61
+ expect(result!.username).toBe("Krishna");
62
+ expect(result!.text).toContain("Use RS256");
63
+ });
64
+
65
+ test("reply: persona", () => {
66
+ const result = formatEventForSlack(makeEvent({
67
+ source: "reply",
68
+ sender: "user:manu",
69
+ payload: { type: "reply" as const, content: "Done", sender: "user:manu" },
70
+ }));
71
+ expect(result).not.toBeNull();
72
+ expect(result!.username).toBe("Manu");
73
+ expect(result!.text).toContain("Done");
74
+ });
75
+
76
+ test("long messages include full text for bridge threading", () => {
77
+ const result = formatEventForSlack(makeEvent({
78
+ payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "x".repeat(5000) },
79
+ }));
80
+ expect(result).not.toBeNull();
81
+ expect(result!.text!.length).toBe(5000);
82
+ expect(result!.blocks).toBeDefined();
83
+ });
84
+
85
+ test("converts markdown bold to mrkdwn", () => {
86
+ const result = formatEventForSlack(makeEvent({
87
+ payload: { hook_event_name: "Stop", session_id: "s1", stop_response: "This is **bold** text" },
88
+ }));
89
+ expect(result).not.toBeNull();
90
+ expect(result!.text).toContain("*bold*");
91
+ expect(result!.text).not.toContain("**bold**");
92
+ });
93
+
94
+ test("agent: sender persona for agent participants", () => {
95
+ const result = formatEventForSlack(makeEvent({
96
+ sender: "agent:test-writer",
97
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "write tests" },
98
+ }));
99
+ expect(result).not.toBeNull();
100
+ expect(result!.username).toBe("Agent: Test Writer (fxm)");
101
+ expect(result!.icon_emoji).toBe(":robot_face:");
102
+ });
103
+ });