@castlekit/castle 0.1.5 → 0.3.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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
package/src/lib/config.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import { platform } from "os";
|
|
4
5
|
import JSON5 from "json5";
|
|
5
6
|
|
|
6
7
|
export interface CastleConfig {
|
|
7
8
|
openclaw: {
|
|
8
9
|
gateway_port: number;
|
|
9
10
|
gateway_token?: string;
|
|
11
|
+
gateway_url?: string; // Full WebSocket URL for remote Gateways (e.g. ws://192.168.1.50:18789)
|
|
12
|
+
is_remote?: boolean; // True when connecting to a non-local Gateway
|
|
10
13
|
primary_agent?: string;
|
|
11
14
|
};
|
|
12
15
|
server: {
|
|
@@ -34,11 +37,28 @@ export function getConfigPath(): string {
|
|
|
34
37
|
export function ensureCastleDir(): void {
|
|
35
38
|
const dir = getCastleDir();
|
|
36
39
|
if (!existsSync(dir)) {
|
|
37
|
-
mkdirSync(dir, { recursive: true });
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
// Tighten existing directories that may have been created with default perms
|
|
43
|
+
if (platform() !== "win32") {
|
|
44
|
+
try { chmodSync(dir, 0o700); } catch { /* ignore */ }
|
|
38
45
|
}
|
|
39
46
|
const dataDir = join(dir, "data");
|
|
40
47
|
if (!existsSync(dataDir)) {
|
|
41
|
-
mkdirSync(dataDir, { recursive: true });
|
|
48
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
49
|
+
}
|
|
50
|
+
const logsDir = join(dir, "logs");
|
|
51
|
+
if (!existsSync(logsDir)) {
|
|
52
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
53
|
+
}
|
|
54
|
+
// Tighten log file permissions — LaunchAgent creates them with default umask
|
|
55
|
+
if (platform() !== "win32") {
|
|
56
|
+
for (const logFile of ["server.log", "server.err"]) {
|
|
57
|
+
const logPath = join(logsDir, logFile);
|
|
58
|
+
if (existsSync(logPath)) {
|
|
59
|
+
try { chmodSync(logPath, 0o600); } catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
}
|
|
43
63
|
}
|
|
44
64
|
|
|
@@ -70,6 +90,14 @@ export function writeConfig(config: CastleConfig): void {
|
|
|
70
90
|
const configPath = getConfigPath();
|
|
71
91
|
const content = JSON5.stringify(config, null, 2);
|
|
72
92
|
writeFileSync(configPath, content, "utf-8");
|
|
93
|
+
// Restrict permissions — castle.json may contain gateway_token
|
|
94
|
+
if (platform() !== "win32") {
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(configPath, 0o600);
|
|
97
|
+
} catch {
|
|
98
|
+
// May fail on some filesystems
|
|
99
|
+
}
|
|
100
|
+
}
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
/**
|
|
@@ -177,13 +205,17 @@ export function readOpenClawPort(): number | null {
|
|
|
177
205
|
|
|
178
206
|
/**
|
|
179
207
|
* Get the Gateway WebSocket URL.
|
|
180
|
-
*
|
|
208
|
+
* Priority: env var > config gateway_url > config port on localhost.
|
|
181
209
|
*/
|
|
182
210
|
export function getGatewayUrl(): string {
|
|
183
211
|
if (process.env.OPENCLAW_GATEWAY_URL) {
|
|
184
212
|
return process.env.OPENCLAW_GATEWAY_URL;
|
|
185
213
|
}
|
|
186
214
|
const config = readConfig();
|
|
215
|
+
// Explicit URL takes priority (remote Gateways, Tailscale, etc.)
|
|
216
|
+
if (config.openclaw.gateway_url) {
|
|
217
|
+
return config.openclaw.gateway_url;
|
|
218
|
+
}
|
|
187
219
|
return `ws://127.0.0.1:${config.openclaw.gateway_port}`;
|
|
188
220
|
}
|
|
189
221
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Shared date formatting — consistent across the app
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Formats:
|
|
6
|
+
// formatDate(ts) → "9 February 2026"
|
|
7
|
+
// formatDateTime(ts) → "9 February 2026 at 2:24 am"
|
|
8
|
+
// formatTime(ts) → "2:24 am"
|
|
9
|
+
// formatTimeAgo(ts) → "3 hours ago", "2 days ago", etc.
|
|
10
|
+
// formatDateShort(ts) → "9 Feb" (for compact UI like search results)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "9 February 2026"
|
|
14
|
+
*/
|
|
15
|
+
export function formatDate(timestamp: number): string {
|
|
16
|
+
return new Date(timestamp).toLocaleDateString("en-GB", {
|
|
17
|
+
day: "numeric",
|
|
18
|
+
month: "long",
|
|
19
|
+
year: "numeric",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "9 February 2026 at 2:24 am"
|
|
25
|
+
*/
|
|
26
|
+
export function formatDateTime(timestamp: number): string {
|
|
27
|
+
const date = new Date(timestamp);
|
|
28
|
+
const datePart = date.toLocaleDateString("en-GB", {
|
|
29
|
+
day: "numeric",
|
|
30
|
+
month: "long",
|
|
31
|
+
year: "numeric",
|
|
32
|
+
});
|
|
33
|
+
const timePart = date.toLocaleTimeString("en-GB", {
|
|
34
|
+
hour: "numeric",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
hour12: true,
|
|
37
|
+
});
|
|
38
|
+
return `${datePart} at ${timePart}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* "2:24 am"
|
|
43
|
+
*/
|
|
44
|
+
export function formatTime(timestamp: number): string {
|
|
45
|
+
return new Date(timestamp).toLocaleTimeString("en-GB", {
|
|
46
|
+
hour: "numeric",
|
|
47
|
+
minute: "2-digit",
|
|
48
|
+
hour12: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* "3 hours ago", "2 days ago", "just now", etc.
|
|
54
|
+
*/
|
|
55
|
+
export function formatTimeAgo(timestamp: number): string {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const diff = now - timestamp;
|
|
58
|
+
|
|
59
|
+
const seconds = Math.floor(diff / 1000);
|
|
60
|
+
if (seconds < 60) return "just now";
|
|
61
|
+
|
|
62
|
+
const minutes = Math.floor(seconds / 60);
|
|
63
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
64
|
+
|
|
65
|
+
const hours = Math.floor(minutes / 60);
|
|
66
|
+
if (hours < 24) return `${hours}h ago`;
|
|
67
|
+
|
|
68
|
+
const days = Math.floor(hours / 24);
|
|
69
|
+
if (days < 7) return `${days}d ago`;
|
|
70
|
+
|
|
71
|
+
const weeks = Math.floor(days / 7);
|
|
72
|
+
if (weeks < 5) return `${weeks}w ago`;
|
|
73
|
+
|
|
74
|
+
const months = Math.floor(days / 30);
|
|
75
|
+
if (months < 12) return `${months}mo ago`;
|
|
76
|
+
|
|
77
|
+
const years = Math.floor(days / 365);
|
|
78
|
+
return `${years}y ago`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
4
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
import * as schema from "../schema";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Test-specific DB setup (in-memory or temp file)
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
const TABLE_SQL = `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
default_agent_id TEXT NOT NULL,
|
|
20
|
+
created_at INTEGER NOT NULL,
|
|
21
|
+
updated_at INTEGER
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
24
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
25
|
+
agent_id TEXT NOT NULL,
|
|
26
|
+
PRIMARY KEY (channel_id, agent_id)
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
31
|
+
session_key TEXT,
|
|
32
|
+
started_at INTEGER NOT NULL,
|
|
33
|
+
ended_at INTEGER,
|
|
34
|
+
summary TEXT,
|
|
35
|
+
total_input_tokens INTEGER DEFAULT 0,
|
|
36
|
+
total_output_tokens INTEGER DEFAULT 0
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
41
|
+
session_id TEXT REFERENCES sessions(id),
|
|
42
|
+
sender_type TEXT NOT NULL,
|
|
43
|
+
sender_id TEXT NOT NULL,
|
|
44
|
+
sender_name TEXT,
|
|
45
|
+
content TEXT NOT NULL DEFAULT '',
|
|
46
|
+
status TEXT NOT NULL DEFAULT 'complete',
|
|
47
|
+
mentioned_agent_id TEXT,
|
|
48
|
+
run_id TEXT,
|
|
49
|
+
session_key TEXT,
|
|
50
|
+
input_tokens INTEGER,
|
|
51
|
+
output_tokens INTEGER,
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_messages_run_id ON messages(run_id);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS message_attachments (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
59
|
+
attachment_type TEXT NOT NULL,
|
|
60
|
+
file_path TEXT NOT NULL,
|
|
61
|
+
mime_type TEXT,
|
|
62
|
+
file_size INTEGER,
|
|
63
|
+
original_name TEXT,
|
|
64
|
+
created_at INTEGER NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS message_reactions (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
69
|
+
agent_id TEXT,
|
|
70
|
+
emoji TEXT NOT NULL,
|
|
71
|
+
emoji_char TEXT NOT NULL,
|
|
72
|
+
created_at INTEGER NOT NULL
|
|
73
|
+
);
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const FTS5_SQL = `
|
|
77
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
|
|
78
|
+
USING fts5(content, tokenize='unicode61');
|
|
79
|
+
|
|
80
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
|
81
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
82
|
+
END;
|
|
83
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
|
84
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
|
|
85
|
+
END;
|
|
86
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE OF content ON messages BEGIN
|
|
87
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
|
|
88
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
89
|
+
END;
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
function createTestDb() {
|
|
93
|
+
tmpDir = mkdtempSync(join(tmpdir(), "castle-test-"));
|
|
94
|
+
const dbPath = join(tmpDir, "test.db");
|
|
95
|
+
const sqlite = new Database(dbPath);
|
|
96
|
+
sqlite.pragma("journal_mode = WAL");
|
|
97
|
+
sqlite.pragma("foreign_keys = ON");
|
|
98
|
+
sqlite.exec(TABLE_SQL);
|
|
99
|
+
sqlite.exec(FTS5_SQL);
|
|
100
|
+
return { db: drizzle(sqlite, { schema }), sqlite, dbPath };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Tests
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
describe("Database Queries", () => {
|
|
108
|
+
let db: ReturnType<typeof drizzle>;
|
|
109
|
+
let sqlite: Database.Database;
|
|
110
|
+
|
|
111
|
+
beforeAll(() => {
|
|
112
|
+
const setup = createTestDb();
|
|
113
|
+
db = setup.db;
|
|
114
|
+
sqlite = setup.sqlite;
|
|
115
|
+
|
|
116
|
+
// Override the global DB for our query functions
|
|
117
|
+
const DB_KEY = "__castle_db__";
|
|
118
|
+
(globalThis as Record<string, unknown>)[DB_KEY] = db;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(() => {
|
|
122
|
+
sqlite.close();
|
|
123
|
+
try { rmSync(tmpDir, { recursive: true }); } catch { /* ignore */ }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- Channels ----
|
|
127
|
+
|
|
128
|
+
describe("Channels", () => {
|
|
129
|
+
it("should create and retrieve a channel", async () => {
|
|
130
|
+
const { createChannel, getChannel, getChannels } = await import("../queries");
|
|
131
|
+
|
|
132
|
+
const channel = createChannel("Test Channel", "agent-1", ["agent-1", "agent-2"]);
|
|
133
|
+
expect(channel.name).toBe("Test Channel");
|
|
134
|
+
expect(channel.defaultAgentId).toBe("agent-1");
|
|
135
|
+
expect(channel.agents).toContain("agent-1");
|
|
136
|
+
expect(channel.agents).toContain("agent-2");
|
|
137
|
+
|
|
138
|
+
const fetched = getChannel(channel.id);
|
|
139
|
+
expect(fetched).not.toBeNull();
|
|
140
|
+
expect(fetched!.name).toBe("Test Channel");
|
|
141
|
+
|
|
142
|
+
const all = getChannels();
|
|
143
|
+
expect(all.length).toBeGreaterThanOrEqual(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should update a channel name", async () => {
|
|
147
|
+
const { createChannel, updateChannel, getChannel } = await import("../queries");
|
|
148
|
+
|
|
149
|
+
const channel = createChannel("Original", "agent-1");
|
|
150
|
+
updateChannel(channel.id, { name: "Updated" });
|
|
151
|
+
|
|
152
|
+
const fetched = getChannel(channel.id);
|
|
153
|
+
expect(fetched!.name).toBe("Updated");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should delete a channel", async () => {
|
|
157
|
+
const { createChannel, deleteChannel, getChannel } = await import("../queries");
|
|
158
|
+
|
|
159
|
+
const channel = createChannel("To Delete", "agent-1");
|
|
160
|
+
const deleted = deleteChannel(channel.id);
|
|
161
|
+
expect(deleted).toBe(true);
|
|
162
|
+
|
|
163
|
+
const fetched = getChannel(channel.id);
|
|
164
|
+
expect(fetched).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---- Messages ----
|
|
169
|
+
|
|
170
|
+
describe("Messages", () => {
|
|
171
|
+
it("should create and retrieve messages", async () => {
|
|
172
|
+
const { createChannel, createMessage, getMessagesByChannel } = await import("../queries");
|
|
173
|
+
|
|
174
|
+
const channel = createChannel("Msg Test", "agent-1");
|
|
175
|
+
|
|
176
|
+
createMessage({
|
|
177
|
+
channelId: channel.id,
|
|
178
|
+
senderType: "user",
|
|
179
|
+
senderId: "local-user",
|
|
180
|
+
senderName: "You",
|
|
181
|
+
content: "Hello there",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
createMessage({
|
|
185
|
+
channelId: channel.id,
|
|
186
|
+
senderType: "agent",
|
|
187
|
+
senderId: "agent-1",
|
|
188
|
+
senderName: "Agent",
|
|
189
|
+
content: "Hi! How can I help?",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const messages = getMessagesByChannel(channel.id);
|
|
193
|
+
expect(messages.length).toBe(2);
|
|
194
|
+
expect(messages[0].content).toBe("Hello there");
|
|
195
|
+
expect(messages[1].content).toBe("Hi! How can I help?");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should support pagination with before cursor", async () => {
|
|
199
|
+
const { createChannel, createMessage, getMessagesByChannel } = await import("../queries");
|
|
200
|
+
|
|
201
|
+
const channel = createChannel("Pagination Test", "agent-1");
|
|
202
|
+
|
|
203
|
+
// Create 5 messages with slight time gaps
|
|
204
|
+
const ids: string[] = [];
|
|
205
|
+
for (let i = 0; i < 5; i++) {
|
|
206
|
+
const msg = createMessage({
|
|
207
|
+
channelId: channel.id,
|
|
208
|
+
senderType: "user",
|
|
209
|
+
senderId: "local-user",
|
|
210
|
+
content: `Message ${i}`,
|
|
211
|
+
});
|
|
212
|
+
ids.push(msg.id);
|
|
213
|
+
// Small delay to ensure different timestamps
|
|
214
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Get latest 3
|
|
218
|
+
const latest = getMessagesByChannel(channel.id, 3);
|
|
219
|
+
expect(latest.length).toBe(3);
|
|
220
|
+
|
|
221
|
+
// Get messages before the oldest of the latest
|
|
222
|
+
const older = getMessagesByChannel(channel.id, 3, latest[0].id);
|
|
223
|
+
expect(older.length).toBe(2);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("should update message status", async () => {
|
|
227
|
+
const { createChannel, createMessage, updateMessage, getMessagesByChannel } = await import("../queries");
|
|
228
|
+
|
|
229
|
+
const channel = createChannel("Status Test", "agent-1");
|
|
230
|
+
const msg = createMessage({
|
|
231
|
+
channelId: channel.id,
|
|
232
|
+
senderType: "agent",
|
|
233
|
+
senderId: "agent-1",
|
|
234
|
+
content: "Partial response...",
|
|
235
|
+
status: "complete",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
updateMessage(msg.id, { status: "interrupted" });
|
|
239
|
+
|
|
240
|
+
const messages = getMessagesByChannel(channel.id);
|
|
241
|
+
expect(messages[0].status).toBe("interrupted");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---- Search (FTS5) ----
|
|
246
|
+
|
|
247
|
+
describe("Search (FTS5)", () => {
|
|
248
|
+
it("should find messages by content", async () => {
|
|
249
|
+
const { createChannel, createMessage, searchMessages } = await import("../queries");
|
|
250
|
+
|
|
251
|
+
const channel = createChannel("Search Test", "agent-1");
|
|
252
|
+
|
|
253
|
+
createMessage({
|
|
254
|
+
channelId: channel.id,
|
|
255
|
+
senderType: "user",
|
|
256
|
+
senderId: "local-user",
|
|
257
|
+
content: "The quick brown fox jumps over the lazy dog",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
createMessage({
|
|
261
|
+
channelId: channel.id,
|
|
262
|
+
senderType: "agent",
|
|
263
|
+
senderId: "agent-1",
|
|
264
|
+
content: "Here is some completely different text about cats",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const results = searchMessages("brown fox");
|
|
268
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
269
|
+
expect(results[0].content).toContain("brown fox");
|
|
270
|
+
|
|
271
|
+
const noResults = searchMessages("nonexistent_xyz_term");
|
|
272
|
+
expect(noResults.length).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("should reject overly long queries", async () => {
|
|
276
|
+
const { searchMessages } = await import("../queries");
|
|
277
|
+
const longQuery = "a".repeat(501);
|
|
278
|
+
const results = searchMessages(longQuery);
|
|
279
|
+
expect(results).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---- Sessions ----
|
|
284
|
+
|
|
285
|
+
describe("Sessions", () => {
|
|
286
|
+
it("should create and retrieve sessions", async () => {
|
|
287
|
+
const { createChannel, createSession, getSessionsByChannel, getLatestSessionKey } = await import("../queries");
|
|
288
|
+
|
|
289
|
+
const channel = createChannel("Session Test", "agent-1");
|
|
290
|
+
|
|
291
|
+
const session = createSession({
|
|
292
|
+
channelId: channel.id,
|
|
293
|
+
sessionKey: "sk_test_123",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(session.sessionKey).toBe("sk_test_123");
|
|
297
|
+
|
|
298
|
+
const sessions = getSessionsByChannel(channel.id);
|
|
299
|
+
expect(sessions.length).toBe(1);
|
|
300
|
+
|
|
301
|
+
const key = getLatestSessionKey(channel.id);
|
|
302
|
+
expect(key).toBe("sk_test_123");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---- Storage Stats ----
|
|
307
|
+
|
|
308
|
+
describe("Storage Stats", () => {
|
|
309
|
+
it("should return correct counts", async () => {
|
|
310
|
+
const { getStorageStats } = await import("../queries");
|
|
311
|
+
|
|
312
|
+
const stats = getStorageStats();
|
|
313
|
+
expect(stats.messages).toBeGreaterThan(0);
|
|
314
|
+
expect(stats.channels).toBeGreaterThan(0);
|
|
315
|
+
expect(typeof stats.totalAttachmentBytes).toBe("number");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|