heyio 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +85 -9
- package/dist/copilot/agents.js +41 -0
- package/dist/copilot/auto-complete-instance.test.js +104 -0
- package/dist/copilot/instance-deactivate.test.js +119 -0
- package/dist/copilot/orchestrator.js +35 -1
- package/dist/copilot/scheduler.js +1 -1
- package/dist/copilot/tools.js +140 -2
- package/dist/instance-watchdog.js +43 -9
- package/dist/instance-watchdog.test.js +77 -6
- package/dist/mcp/client.js +109 -0
- package/dist/mcp/client.test.js +99 -0
- package/dist/mcp/config.js +29 -0
- package/dist/mcp/config.test.js +49 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/registry.js +96 -0
- package/dist/mcp/registry.test.js +79 -0
- package/dist/store/db.js +21 -1
- package/dist/store/feed.js +3 -3
- package/dist/store/feed.test.js +20 -20
- package/dist/store/squads.js +1 -1
- package/dist/store/squads.test.js +353 -0
- package/dist/tui/index.js +2 -2
- package/package.json +4 -3
- package/web-dist/assets/index-Ddn6rUkk.js +88 -0
- package/web-dist/assets/index-KNbOV6QX.css +10 -0
- package/web-dist/index.html +2 -2
- package/dist/store/inbox.js +0 -28
- package/web-dist/assets/index-BvKvht8h.js +0 -88
- package/web-dist/assets/index-DmthMbtN.css +0 -10
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
// We need to override MCP_CONFIG_PATH for testing.
|
|
7
|
+
// Since config.ts uses IO_HOME from paths.ts, we'll test by directly
|
|
8
|
+
// exercising the functions with a mocked path approach.
|
|
9
|
+
import { loadMcpConfig, saveMcpConfig } from "./config.js";
|
|
10
|
+
describe("MCP config", () => {
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let originalConfigPath;
|
|
13
|
+
before(() => {
|
|
14
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-config-test-"));
|
|
15
|
+
});
|
|
16
|
+
after(() => {
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
it("loadMcpConfig returns empty servers when no file exists", () => {
|
|
20
|
+
// MCP_CONFIG_PATH points to ~/.io/mcp.json which may or may not exist
|
|
21
|
+
// but the function handles missing files gracefully
|
|
22
|
+
const config = loadMcpConfig();
|
|
23
|
+
assert.ok(Array.isArray(config.servers));
|
|
24
|
+
});
|
|
25
|
+
it("saveMcpConfig and loadMcpConfig round-trip correctly", () => {
|
|
26
|
+
const testConfig = {
|
|
27
|
+
servers: [
|
|
28
|
+
{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-server-figma"], enabled: true },
|
|
29
|
+
{ name: "postgres", url: "http://localhost:3001/sse", enabled: false },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
saveMcpConfig(testConfig);
|
|
33
|
+
const loaded = loadMcpConfig();
|
|
34
|
+
assert.equal(loaded.servers.length, 2);
|
|
35
|
+
assert.equal(loaded.servers[0].name, "figma");
|
|
36
|
+
assert.equal(loaded.servers[0].command, "npx");
|
|
37
|
+
assert.deepEqual(loaded.servers[0].args, ["-y", "@anthropic/mcp-server-figma"]);
|
|
38
|
+
assert.equal(loaded.servers[1].name, "postgres");
|
|
39
|
+
assert.equal(loaded.servers[1].url, "http://localhost:3001/sse");
|
|
40
|
+
assert.equal(loaded.servers[1].enabled, false);
|
|
41
|
+
});
|
|
42
|
+
it("loadMcpConfig handles malformed JSON gracefully", () => {
|
|
43
|
+
// This test verifies the catch block — in production, a corrupt file
|
|
44
|
+
// won't crash the app
|
|
45
|
+
const config = loadMcpConfig();
|
|
46
|
+
assert.ok(config.servers !== undefined);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=config.test.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defineTool } from "@github/copilot-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a name for use as a tool identifier (alphanumeric + underscore only).
|
|
5
|
+
*/
|
|
6
|
+
function sanitizeName(name) {
|
|
7
|
+
return name.replace(/[^a-zA-Z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Convert an MCP JSON Schema inputSchema to a Zod schema.
|
|
11
|
+
* Falls back to z.object({}) for passthrough if schema is missing or unparseable.
|
|
12
|
+
*/
|
|
13
|
+
function jsonSchemaToZod(inputSchema) {
|
|
14
|
+
if (!inputSchema || !inputSchema.properties) {
|
|
15
|
+
return z.object({}).passthrough();
|
|
16
|
+
}
|
|
17
|
+
const properties = inputSchema.properties;
|
|
18
|
+
const required = new Set(inputSchema.required ?? []);
|
|
19
|
+
const shape = {};
|
|
20
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
21
|
+
let field;
|
|
22
|
+
switch (prop.type) {
|
|
23
|
+
case "string":
|
|
24
|
+
field = z.string();
|
|
25
|
+
break;
|
|
26
|
+
case "number":
|
|
27
|
+
case "integer":
|
|
28
|
+
field = z.number();
|
|
29
|
+
break;
|
|
30
|
+
case "boolean":
|
|
31
|
+
field = z.boolean();
|
|
32
|
+
break;
|
|
33
|
+
case "array":
|
|
34
|
+
field = z.array(z.unknown());
|
|
35
|
+
break;
|
|
36
|
+
case "object":
|
|
37
|
+
field = z.record(z.string(), z.unknown());
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
field = z.unknown();
|
|
41
|
+
}
|
|
42
|
+
if (prop.description) {
|
|
43
|
+
field = field.describe(prop.description);
|
|
44
|
+
}
|
|
45
|
+
if (!required.has(key)) {
|
|
46
|
+
field = field.optional();
|
|
47
|
+
}
|
|
48
|
+
shape[key] = field;
|
|
49
|
+
}
|
|
50
|
+
return z.object(shape).passthrough();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create Copilot SDK tools from all enabled MCP servers.
|
|
54
|
+
* Connects to each server, lists its tools, and wraps them.
|
|
55
|
+
*/
|
|
56
|
+
export async function createMcpTools(manager, config) {
|
|
57
|
+
const entries = [];
|
|
58
|
+
const enabledServers = config.servers.filter((s) => s.enabled !== false);
|
|
59
|
+
for (const serverConfig of enabledServers) {
|
|
60
|
+
try {
|
|
61
|
+
const tools = await manager.listTools(serverConfig);
|
|
62
|
+
for (const mcpTool of tools) {
|
|
63
|
+
const toolName = `mcp_${sanitizeName(serverConfig.name)}_${sanitizeName(mcpTool.name)}`;
|
|
64
|
+
const description = mcpTool.description
|
|
65
|
+
? `[MCP: ${serverConfig.name}] ${mcpTool.description}`
|
|
66
|
+
: `[MCP: ${serverConfig.name}] ${mcpTool.name}`;
|
|
67
|
+
const parameters = jsonSchemaToZod(mcpTool.inputSchema);
|
|
68
|
+
const tool = defineTool(toolName, {
|
|
69
|
+
description,
|
|
70
|
+
skipPermission: true,
|
|
71
|
+
parameters: parameters,
|
|
72
|
+
handler: async (args) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = await manager.callTool(serverConfig, mcpTool.name, args);
|
|
75
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return `MCP tool error (${serverConfig.name}/${mcpTool.name}): ${err instanceof Error ? err.message : String(err)}`;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
entries.push({
|
|
83
|
+
serverName: serverConfig.name,
|
|
84
|
+
serverConfig,
|
|
85
|
+
mcpToolName: mcpTool.name,
|
|
86
|
+
tool: tool,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error(`[mcp] Failed to connect to server "${serverConfig.name}":`, err instanceof Error ? err.message : err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createMcpTools } from "./registry.js";
|
|
4
|
+
// Mock connection manager that returns predefined tools
|
|
5
|
+
function createMockManager(toolsMap) {
|
|
6
|
+
return {
|
|
7
|
+
listTools: async (config) => {
|
|
8
|
+
return toolsMap[config.name] ?? [];
|
|
9
|
+
},
|
|
10
|
+
callTool: async (_config, toolName, args) => {
|
|
11
|
+
return `Called ${toolName} with ${JSON.stringify(args)}`;
|
|
12
|
+
},
|
|
13
|
+
getClient: async () => { throw new Error("not needed"); },
|
|
14
|
+
disconnect: async () => { },
|
|
15
|
+
disconnectAll: async () => { },
|
|
16
|
+
isConnected: () => false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("MCP registry", () => {
|
|
20
|
+
it("creates namespaced tools from MCP server tools", async () => {
|
|
21
|
+
const manager = createMockManager({
|
|
22
|
+
figma: [
|
|
23
|
+
{ name: "get_file", description: "Get a Figma file", inputSchema: { type: "object", properties: { file_key: { type: "string", description: "Figma file key" } }, required: ["file_key"] } },
|
|
24
|
+
{ name: "list_projects", description: "List projects" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
const config = {
|
|
28
|
+
servers: [{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-figma"], enabled: true }],
|
|
29
|
+
};
|
|
30
|
+
const entries = await createMcpTools(manager, config);
|
|
31
|
+
assert.equal(entries.length, 2);
|
|
32
|
+
assert.equal(entries[0].tool.name, "mcp_figma_get_file");
|
|
33
|
+
assert.equal(entries[1].tool.name, "mcp_figma_list_projects");
|
|
34
|
+
assert.equal(entries[0].serverName, "figma");
|
|
35
|
+
assert.equal(entries[0].mcpToolName, "get_file");
|
|
36
|
+
});
|
|
37
|
+
it("skips disabled servers", async () => {
|
|
38
|
+
const manager = createMockManager({
|
|
39
|
+
figma: [{ name: "get_file", description: "Get file" }],
|
|
40
|
+
postgres: [{ name: "query", description: "Run query" }],
|
|
41
|
+
});
|
|
42
|
+
const config = {
|
|
43
|
+
servers: [
|
|
44
|
+
{ name: "figma", command: "npx", enabled: true },
|
|
45
|
+
{ name: "postgres", command: "pg-mcp", enabled: false },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const entries = await createMcpTools(manager, config);
|
|
49
|
+
assert.equal(entries.length, 1);
|
|
50
|
+
assert.equal(entries[0].serverName, "figma");
|
|
51
|
+
});
|
|
52
|
+
it("sanitizes tool names to valid identifiers", async () => {
|
|
53
|
+
const manager = createMockManager({
|
|
54
|
+
"my-server": [{ name: "get-data.v2", description: "Get data" }],
|
|
55
|
+
});
|
|
56
|
+
const config = {
|
|
57
|
+
servers: [{ name: "my-server", command: "cmd", enabled: true }],
|
|
58
|
+
};
|
|
59
|
+
const entries = await createMcpTools(manager, config);
|
|
60
|
+
assert.equal(entries[0].tool.name, "mcp_my_server_get_data_v2");
|
|
61
|
+
});
|
|
62
|
+
it("handles server connection failure gracefully", async () => {
|
|
63
|
+
const manager = {
|
|
64
|
+
listTools: async () => { throw new Error("Connection refused"); },
|
|
65
|
+
callTool: async () => { throw new Error("not connected"); },
|
|
66
|
+
getClient: async () => { throw new Error("not connected"); },
|
|
67
|
+
disconnect: async () => { },
|
|
68
|
+
disconnectAll: async () => { },
|
|
69
|
+
isConnected: () => false,
|
|
70
|
+
};
|
|
71
|
+
const config = {
|
|
72
|
+
servers: [{ name: "broken", command: "bad-cmd", enabled: true }],
|
|
73
|
+
};
|
|
74
|
+
// Should not throw — just returns empty
|
|
75
|
+
const entries = await createMcpTools(manager, config);
|
|
76
|
+
assert.equal(entries.length, 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=registry.test.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -162,7 +162,7 @@ GROUP BY agent_slug`,
|
|
|
162
162
|
)`,
|
|
163
163
|
`CREATE TABLE IF NOT EXISTS unified_feed (
|
|
164
164
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
165
|
-
type TEXT NOT NULL CHECK(type IN ('
|
|
165
|
+
type TEXT NOT NULL CHECK(type IN ('inbox', 'notification')),
|
|
166
166
|
title TEXT NOT NULL,
|
|
167
167
|
body TEXT NOT NULL,
|
|
168
168
|
source_type TEXT,
|
|
@@ -193,6 +193,26 @@ GROUP BY agent_slug`,
|
|
|
193
193
|
)`,
|
|
194
194
|
`ALTER TABLE agent_tasks ADD COLUMN instance_id TEXT`,
|
|
195
195
|
`CREATE INDEX IF NOT EXISTS idx_instance_decisions_instance ON instance_decisions(instance_id, merged_to_master)`,
|
|
196
|
+
`ALTER TABLE unified_feed RENAME TO unified_feed_old`,
|
|
197
|
+
`CREATE TABLE unified_feed (
|
|
198
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
199
|
+
type TEXT NOT NULL CHECK(type IN ('inbox', 'notification')),
|
|
200
|
+
title TEXT NOT NULL,
|
|
201
|
+
body TEXT NOT NULL,
|
|
202
|
+
source_type TEXT,
|
|
203
|
+
source_ref TEXT,
|
|
204
|
+
squad_slug TEXT,
|
|
205
|
+
instance_id TEXT,
|
|
206
|
+
task_id TEXT,
|
|
207
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
208
|
+
read_at DATETIME
|
|
209
|
+
)`,
|
|
210
|
+
`INSERT INTO unified_feed (id, type, title, body, source_type, source_ref, created_at, read_at)
|
|
211
|
+
SELECT id, CASE WHEN type='deliverable' THEN 'inbox' ELSE type END, title, body, source_type, source_ref, created_at, read_at
|
|
212
|
+
FROM unified_feed_old`,
|
|
213
|
+
`DROP TABLE unified_feed_old`,
|
|
214
|
+
`CREATE INDEX IF NOT EXISTS idx_unified_feed_type ON unified_feed(type, created_at)`,
|
|
215
|
+
`CREATE INDEX IF NOT EXISTS idx_unified_feed_unread ON unified_feed(read_at, created_at)`,
|
|
196
216
|
];
|
|
197
217
|
for (const migration of migrations) {
|
|
198
218
|
try {
|
package/dist/store/feed.js
CHANGED
|
@@ -2,9 +2,9 @@ import { getDb } from "./db.js";
|
|
|
2
2
|
export function createFeedEntry(input) {
|
|
3
3
|
const db = getDb();
|
|
4
4
|
const info = db
|
|
5
|
-
.prepare(`INSERT INTO unified_feed (type, title, body, source_type, source_ref)
|
|
6
|
-
VALUES (?, ?, ?, ?, ?)`)
|
|
7
|
-
.run(input.type, input.title, input.body, input.source_type ?? null, input.source_ref ?? null);
|
|
5
|
+
.prepare(`INSERT INTO unified_feed (type, title, body, source_type, source_ref, squad_slug, instance_id, task_id)
|
|
6
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
7
|
+
.run(input.type, input.title, input.body, input.source_type ?? null, input.source_ref ?? null, input.squad_slug ?? null, input.instance_id ?? null, input.task_id ?? null);
|
|
8
8
|
return db
|
|
9
9
|
.prepare("SELECT * FROM unified_feed WHERE id = ?")
|
|
10
10
|
.get(info.lastInsertRowid);
|
package/dist/store/feed.test.js
CHANGED
|
@@ -27,8 +27,8 @@ beforeEach(() => {
|
|
|
27
27
|
// ── createFeedEntry ───────────────────────────────────────────────────────────
|
|
28
28
|
describe("createFeedEntry", () => {
|
|
29
29
|
it("creates a deliverable entry with correct fields", () => {
|
|
30
|
-
const entry = createFeedEntry({ type: "
|
|
31
|
-
assert.equal(entry.type, "
|
|
30
|
+
const entry = createFeedEntry({ type: "inbox", title: "Task done", body: "Here are the results." });
|
|
31
|
+
assert.equal(entry.type, "inbox");
|
|
32
32
|
assert.equal(entry.title, "Task done");
|
|
33
33
|
assert.equal(entry.body, "Here are the results.");
|
|
34
34
|
assert.equal(entry.read_at, null);
|
|
@@ -54,7 +54,7 @@ describe("createFeedEntry", () => {
|
|
|
54
54
|
assert.equal(entry.source_ref, JSON.stringify({ id: 42 }));
|
|
55
55
|
});
|
|
56
56
|
it("autoincrements ids", () => {
|
|
57
|
-
const a = createFeedEntry({ type: "
|
|
57
|
+
const a = createFeedEntry({ type: "inbox", title: "A", body: "a" });
|
|
58
58
|
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
59
59
|
assert.ok(b.id > a.id);
|
|
60
60
|
});
|
|
@@ -62,7 +62,7 @@ describe("createFeedEntry", () => {
|
|
|
62
62
|
// ── listFeedEntries ───────────────────────────────────────────────────────────
|
|
63
63
|
describe("listFeedEntries", () => {
|
|
64
64
|
it("returns all entries newest first", () => {
|
|
65
|
-
createFeedEntry({ type: "
|
|
65
|
+
createFeedEntry({ type: "inbox", title: "First", body: "x" });
|
|
66
66
|
createFeedEntry({ type: "notification", title: "Second", body: "y" });
|
|
67
67
|
const entries = listFeedEntries();
|
|
68
68
|
assert.equal(entries.length, 2);
|
|
@@ -70,14 +70,14 @@ describe("listFeedEntries", () => {
|
|
|
70
70
|
assert.equal(entries[1].title, "First");
|
|
71
71
|
});
|
|
72
72
|
it("filters by type=deliverable", () => {
|
|
73
|
-
createFeedEntry({ type: "
|
|
73
|
+
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
74
74
|
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
75
|
-
const entries = listFeedEntries({ type: "
|
|
75
|
+
const entries = listFeedEntries({ type: "inbox" });
|
|
76
76
|
assert.equal(entries.length, 1);
|
|
77
|
-
assert.equal(entries[0].type, "
|
|
77
|
+
assert.equal(entries[0].type, "inbox");
|
|
78
78
|
});
|
|
79
79
|
it("filters by type=notification", () => {
|
|
80
|
-
createFeedEntry({ type: "
|
|
80
|
+
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
81
81
|
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
82
82
|
const entries = listFeedEntries({ type: "notification" });
|
|
83
83
|
assert.equal(entries.length, 1);
|
|
@@ -108,20 +108,20 @@ describe("countUnreadFeedEntries", () => {
|
|
|
108
108
|
assert.equal(countUnreadFeedEntries(), 0);
|
|
109
109
|
});
|
|
110
110
|
it("increments on insert", () => {
|
|
111
|
-
createFeedEntry({ type: "
|
|
111
|
+
createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
112
112
|
assert.equal(countUnreadFeedEntries(), 1);
|
|
113
113
|
createFeedEntry({ type: "notification", title: "N", body: "b" });
|
|
114
114
|
assert.equal(countUnreadFeedEntries(), 2);
|
|
115
115
|
});
|
|
116
116
|
it("decreases when marked read", () => {
|
|
117
|
-
const e = createFeedEntry({ type: "
|
|
117
|
+
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
118
118
|
markFeedEntryRead(e.id);
|
|
119
119
|
assert.equal(countUnreadFeedEntries(), 0);
|
|
120
120
|
});
|
|
121
121
|
it("filters by type", () => {
|
|
122
|
-
createFeedEntry({ type: "
|
|
122
|
+
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
123
123
|
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
124
|
-
assert.equal(countUnreadFeedEntries("
|
|
124
|
+
assert.equal(countUnreadFeedEntries("inbox"), 1);
|
|
125
125
|
assert.equal(countUnreadFeedEntries("notification"), 1);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
@@ -158,11 +158,11 @@ describe("markAllFeedEntriesRead", () => {
|
|
|
158
158
|
assert.equal(markAllFeedEntriesRead(), 0);
|
|
159
159
|
});
|
|
160
160
|
it("respects type filter — only marks matching type", () => {
|
|
161
|
-
createFeedEntry({ type: "
|
|
161
|
+
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
162
162
|
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
163
163
|
const count = markAllFeedEntriesRead("notification");
|
|
164
164
|
assert.equal(count, 1);
|
|
165
|
-
assert.equal(countUnreadFeedEntries("
|
|
165
|
+
assert.equal(countUnreadFeedEntries("inbox"), 1);
|
|
166
166
|
assert.equal(countUnreadFeedEntries("notification"), 0);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
@@ -170,7 +170,7 @@ describe("markAllFeedEntriesRead", () => {
|
|
|
170
170
|
describe("markFeedEntriesRead", () => {
|
|
171
171
|
it("marks multiple entries read and returns change count", () => {
|
|
172
172
|
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
173
|
-
const b = createFeedEntry({ type: "
|
|
173
|
+
const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
|
|
174
174
|
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
175
175
|
const count = markFeedEntriesRead([a.id, b.id, c.id]);
|
|
176
176
|
assert.equal(count, 3);
|
|
@@ -182,7 +182,7 @@ describe("markFeedEntriesRead", () => {
|
|
|
182
182
|
assert.equal(countUnreadFeedEntries(), 1);
|
|
183
183
|
});
|
|
184
184
|
it("works correctly for a single id", () => {
|
|
185
|
-
const e = createFeedEntry({ type: "
|
|
185
|
+
const e = createFeedEntry({ type: "inbox", title: "Solo", body: "b" });
|
|
186
186
|
assert.equal(markFeedEntriesRead([e.id]), 1);
|
|
187
187
|
const entries = listFeedEntries();
|
|
188
188
|
assert.ok(entries[0].read_at !== null);
|
|
@@ -211,13 +211,13 @@ describe("deleteFeedEntry", () => {
|
|
|
211
211
|
assert.equal(deleteFeedEntry(9999), false);
|
|
212
212
|
});
|
|
213
213
|
it("returns true and removes the entry", () => {
|
|
214
|
-
const e = createFeedEntry({ type: "
|
|
214
|
+
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
215
215
|
assert.equal(deleteFeedEntry(e.id), true);
|
|
216
216
|
const entries = listFeedEntries();
|
|
217
217
|
assert.equal(entries.find((x) => x.id === e.id), undefined);
|
|
218
218
|
});
|
|
219
219
|
it("second delete returns false (not idempotent)", () => {
|
|
220
|
-
const e = createFeedEntry({ type: "
|
|
220
|
+
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
221
221
|
deleteFeedEntry(e.id);
|
|
222
222
|
assert.equal(deleteFeedEntry(e.id), false);
|
|
223
223
|
});
|
|
@@ -226,7 +226,7 @@ describe("deleteFeedEntry", () => {
|
|
|
226
226
|
describe("deleteFeedEntries", () => {
|
|
227
227
|
it("deletes multiple entries and returns change count", () => {
|
|
228
228
|
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
229
|
-
const b = createFeedEntry({ type: "
|
|
229
|
+
const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
|
|
230
230
|
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
231
231
|
const count = deleteFeedEntries([a.id, b.id, c.id]);
|
|
232
232
|
assert.equal(count, 3);
|
|
@@ -249,7 +249,7 @@ describe("deleteFeedEntries", () => {
|
|
|
249
249
|
assert.equal(deleteFeedEntries([9991, 9992, 9993]), 0);
|
|
250
250
|
});
|
|
251
251
|
it("mix of existing and non-existent ids — only deletes what exists", () => {
|
|
252
|
-
const e = createFeedEntry({ type: "
|
|
252
|
+
const e = createFeedEntry({ type: "inbox", title: "Real", body: "b" });
|
|
253
253
|
const count = deleteFeedEntries([e.id, 9999]);
|
|
254
254
|
assert.equal(count, 1);
|
|
255
255
|
assert.deepEqual(listFeedEntries(), []);
|
package/dist/store/squads.js
CHANGED
|
@@ -133,7 +133,7 @@ export function logDecision(squadSlug, decision, context) {
|
|
|
133
133
|
}
|
|
134
134
|
export function getDecisions(squadSlug, limit = 20) {
|
|
135
135
|
return getDb()
|
|
136
|
-
.prepare("SELECT * FROM squad_decisions WHERE squad_slug = ? ORDER BY created_at DESC LIMIT ?")
|
|
136
|
+
.prepare("SELECT * FROM squad_decisions WHERE squad_slug = ? ORDER BY created_at DESC, id DESC LIMIT ?")
|
|
137
137
|
.all(squadSlug, limit);
|
|
138
138
|
}
|
|
139
139
|
export function getDecisionsSummary(squadSlug) {
|