@sna-sdk/core 0.2.3 → 0.4.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/server/ws.js CHANGED
@@ -4,6 +4,8 @@ import { getDb } from "../db/schema.js";
4
4
  import { logger } from "../lib/logger.js";
5
5
  import { runOnce } from "./routes/agent.js";
6
6
  import { wsReply } from "./api-types.js";
7
+ import { buildHistoryFromDb } from "./history-builder.js";
8
+ import { saveImages } from "./image-store.js";
7
9
  function send(ws, data) {
8
10
  if (ws.readyState === ws.OPEN) {
9
11
  ws.send(JSON.stringify(data));
@@ -29,10 +31,16 @@ function attachWebSocket(server, sessionManager) {
29
31
  });
30
32
  wss.on("connection", (ws) => {
31
33
  logger.log("ws", "client connected");
32
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
34
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
33
35
  state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
34
36
  send(ws, { type: "session.lifecycle", ...event });
35
37
  });
38
+ state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
39
+ send(ws, { type: "session.config-changed", ...event });
40
+ });
41
+ state.stateChangedUnsub = sessionManager.onStateChanged((event) => {
42
+ send(ws, { type: "session.state-changed", ...event });
43
+ });
36
44
  ws.on("message", (raw) => {
37
45
  let msg;
38
46
  try {
@@ -61,6 +69,10 @@ function attachWebSocket(server, sessionManager) {
61
69
  state.permissionUnsub = null;
62
70
  state.lifecycleUnsub?.();
63
71
  state.lifecycleUnsub = null;
72
+ state.configChangedUnsub?.();
73
+ state.configChangedUnsub = null;
74
+ state.stateChangedUnsub?.();
75
+ state.stateChangedUnsub = null;
64
76
  });
65
77
  });
66
78
  return wss;
@@ -79,10 +91,16 @@ function handleMessage(ws, msg, sm, state) {
79
91
  return handleAgentStart(ws, msg, sm);
80
92
  case "agent.send":
81
93
  return handleAgentSend(ws, msg, sm);
94
+ case "agent.resume":
95
+ return handleAgentResume(ws, msg, sm);
82
96
  case "agent.restart":
83
97
  return handleAgentRestart(ws, msg, sm);
84
98
  case "agent.interrupt":
85
99
  return handleAgentInterrupt(ws, msg, sm);
100
+ case "agent.set-model":
101
+ return handleAgentSetModel(ws, msg, sm);
102
+ case "agent.set-permission-mode":
103
+ return handleAgentSetPermissionMode(ws, msg, sm);
86
104
  case "agent.kill":
87
105
  return handleAgentKill(ws, msg, sm);
88
106
  case "agent.status":
@@ -184,6 +202,7 @@ function handleAgentStart(ws, msg, sm) {
184
202
  model,
185
203
  permissionMode,
186
204
  env: { SNA_SESSION_ID: sessionId },
205
+ history: msg.history,
187
206
  extraArgs
188
207
  });
189
208
  sm.setProcess(sessionId, proc);
@@ -199,23 +218,79 @@ function handleAgentSend(ws, msg, sm) {
199
218
  if (!session?.process?.alive) {
200
219
  return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
201
220
  }
202
- if (!msg.message) {
203
- return replyError(ws, msg, "message is required");
221
+ const images = msg.images;
222
+ if (!msg.message && !images?.length) {
223
+ return replyError(ws, msg, "message or images required");
224
+ }
225
+ const textContent = msg.message ?? "(image)";
226
+ let meta = msg.meta ? { ...msg.meta } : {};
227
+ if (images?.length) {
228
+ const filenames = saveImages(sessionId, images);
229
+ meta.images = filenames;
204
230
  }
205
231
  try {
206
232
  const db = getDb();
207
233
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
208
- db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, msg.message, msg.meta ? JSON.stringify(msg.meta) : null);
234
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
209
235
  } catch {
210
236
  }
211
- session.state = "processing";
237
+ sm.updateSessionState(sessionId, "processing");
212
238
  sm.touch(sessionId);
213
- session.process.send(msg.message);
239
+ if (images?.length) {
240
+ const content = [
241
+ ...images.map((img) => ({
242
+ type: "image",
243
+ source: { type: "base64", media_type: img.mimeType, data: img.base64 }
244
+ })),
245
+ ...msg.message ? [{ type: "text", text: msg.message }] : []
246
+ ];
247
+ session.process.send(content);
248
+ } else {
249
+ session.process.send(msg.message);
250
+ }
214
251
  wsReply(ws, msg, { status: "sent" });
215
252
  }
253
+ function handleAgentResume(ws, msg, sm) {
254
+ const sessionId = msg.session ?? "default";
255
+ const session = sm.getOrCreateSession(sessionId);
256
+ if (session.process?.alive) {
257
+ return replyError(ws, msg, "Session already running. Use agent.send instead.");
258
+ }
259
+ const history = buildHistoryFromDb(sessionId);
260
+ if (history.length === 0 && !msg.prompt) {
261
+ return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
262
+ }
263
+ const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
264
+ const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
265
+ const permissionMode = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
266
+ const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
267
+ const provider = getProvider(providerName);
268
+ try {
269
+ const proc = provider.spawn({
270
+ cwd: session.cwd,
271
+ prompt: msg.prompt,
272
+ model,
273
+ permissionMode,
274
+ env: { SNA_SESSION_ID: sessionId },
275
+ history: history.length > 0 ? history : void 0,
276
+ extraArgs
277
+ });
278
+ sm.setProcess(sessionId, proc, "resumed");
279
+ sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
280
+ wsReply(ws, msg, {
281
+ status: "resumed",
282
+ provider: providerName,
283
+ sessionId: session.id,
284
+ historyCount: history.length
285
+ });
286
+ } catch (e) {
287
+ replyError(ws, msg, e.message);
288
+ }
289
+ }
216
290
  function handleAgentRestart(ws, msg, sm) {
217
291
  const sessionId = msg.session ?? "default";
218
292
  try {
293
+ const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
219
294
  const { config } = sm.restartSession(
220
295
  sessionId,
221
296
  {
@@ -226,12 +301,13 @@ function handleAgentRestart(ws, msg, sm) {
226
301
  },
227
302
  (cfg) => {
228
303
  const prov = getProvider(cfg.provider);
304
+ const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
229
305
  return prov.spawn({
230
306
  cwd: sm.getSession(sessionId).cwd,
231
307
  model: cfg.model,
232
308
  permissionMode: cfg.permissionMode,
233
309
  env: { SNA_SESSION_ID: sessionId },
234
- extraArgs: [...cfg.extraArgs ?? [], "--resume"]
310
+ extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
235
311
  });
236
312
  }
237
313
  );
@@ -245,6 +321,20 @@ function handleAgentInterrupt(ws, msg, sm) {
245
321
  const interrupted = sm.interruptSession(sessionId);
246
322
  wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
247
323
  }
324
+ function handleAgentSetModel(ws, msg, sm) {
325
+ const sessionId = msg.session ?? "default";
326
+ const model = msg.model;
327
+ if (!model) return replyError(ws, msg, "model is required");
328
+ const updated = sm.setSessionModel(sessionId, model);
329
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
330
+ }
331
+ function handleAgentSetPermissionMode(ws, msg, sm) {
332
+ const sessionId = msg.session ?? "default";
333
+ const permissionMode = msg.permissionMode;
334
+ if (!permissionMode) return replyError(ws, msg, "permissionMode is required");
335
+ const updated = sm.setSessionPermissionMode(sessionId, permissionMode);
336
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode });
337
+ }
248
338
  function handleAgentKill(ws, msg, sm) {
249
339
  const sessionId = msg.session ?? "default";
250
340
  const killed = sm.killSession(sessionId);
@@ -253,10 +343,14 @@ function handleAgentKill(ws, msg, sm) {
253
343
  function handleAgentStatus(ws, msg, sm) {
254
344
  const sessionId = msg.session ?? "default";
255
345
  const session = sm.getSession(sessionId);
346
+ const alive = session?.process?.alive ?? false;
256
347
  wsReply(ws, msg, {
257
- alive: session?.process?.alive ?? false,
348
+ alive,
349
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
258
350
  sessionId: session?.process?.sessionId ?? null,
259
- eventCount: session?.eventCounter ?? 0
351
+ ccSessionId: session?.ccSessionId ?? null,
352
+ eventCount: session?.eventCounter ?? 0,
353
+ config: session?.lastStartConfig ?? null
260
354
  });
261
355
  }
262
356
  async function handleAgentRunOnce(ws, msg, sm) {
@@ -0,0 +1,35 @@
1
+ import http from 'http';
2
+
3
+ /**
4
+ * Mock Anthropic Messages API server for testing.
5
+ *
6
+ * Implements POST /v1/messages with streaming SSE responses.
7
+ * Set ANTHROPIC_BASE_URL=http://localhost:<port> and
8
+ * ANTHROPIC_API_KEY=any-string to redirect Claude Code here.
9
+ *
10
+ * All requests and responses are logged to stdout (captured by sna tu api:up → .sna/mock-api.log).
11
+ *
12
+ * Usage:
13
+ * import { startMockAnthropicServer } from "@sna-sdk/core/testing";
14
+ * const mock = await startMockAnthropicServer();
15
+ * process.env.ANTHROPIC_BASE_URL = `http://localhost:${mock.port}`;
16
+ * process.env.ANTHROPIC_API_KEY = "test-key";
17
+ * // ... spawn claude code, run tests ...
18
+ * mock.close();
19
+ */
20
+
21
+ interface MockServer {
22
+ port: number;
23
+ server: http.Server;
24
+ close: () => void;
25
+ /** Messages received by the mock server */
26
+ requests: Array<{
27
+ model: string;
28
+ messages: any[];
29
+ stream: boolean;
30
+ timestamp: string;
31
+ }>;
32
+ }
33
+ declare function startMockAnthropicServer(): Promise<MockServer>;
34
+
35
+ export { type MockServer, startMockAnthropicServer };
@@ -0,0 +1,160 @@
1
+ import http from "http";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function ts() {
5
+ return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
6
+ }
7
+ async function startMockAnthropicServer() {
8
+ const requests = [];
9
+ const server = http.createServer(async (req, res) => {
10
+ console.log(`[${ts()}] ${req.method} ${req.url} ${req.headers["content-type"] ?? ""}`);
11
+ if (req.method === "OPTIONS") {
12
+ res.writeHead(200, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" });
13
+ res.end();
14
+ return;
15
+ }
16
+ if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
17
+ const chunks = [];
18
+ for await (const chunk of req) chunks.push(chunk);
19
+ const rawBody = Buffer.concat(chunks).toString();
20
+ let body;
21
+ try {
22
+ body = JSON.parse(rawBody);
23
+ } catch (e) {
24
+ console.log(`[${ts()}] ERROR: invalid JSON body: ${rawBody.slice(0, 200)}`);
25
+ res.writeHead(400, { "Content-Type": "application/json" });
26
+ res.end(JSON.stringify({ error: "invalid JSON" }));
27
+ return;
28
+ }
29
+ const entry = { model: body.model, messages: body.messages, stream: body.stream, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
30
+ requests.push(entry);
31
+ const lastUser = body.messages?.filter((m) => m.role === "user").pop();
32
+ let userText = "(no text)";
33
+ if (typeof lastUser?.content === "string") {
34
+ userText = lastUser.content;
35
+ } else if (Array.isArray(lastUser?.content)) {
36
+ const textBlocks = lastUser.content.filter((b) => b.type === "text").map((b) => b.text);
37
+ const realText = textBlocks.find((t) => !t.startsWith("<system-reminder>"));
38
+ userText = realText ?? textBlocks[textBlocks.length - 1] ?? "(no text)";
39
+ }
40
+ console.log(`[${ts()}] BODY KEYS: ${Object.keys(body).join(", ")}`);
41
+ try {
42
+ const dumpPath = path.join(process.cwd(), ".sna/mock-api-last-request.json");
43
+ fs.writeFileSync(dumpPath, JSON.stringify(body, null, 2));
44
+ console.log(`[${ts()}] FULL BODY dumped to .sna/mock-api-last-request.json`);
45
+ } catch {
46
+ }
47
+ if (body.system) {
48
+ const sysText = typeof body.system === "string" ? body.system : JSON.stringify(body.system);
49
+ console.log(`[${ts()}] SYSTEM PROMPT (${sysText.length} chars): ${sysText.slice(0, 300)}...`);
50
+ if (sysText.includes("\uC720\uB2C8") || sysText.includes("\uCEE4\uD53C") || sysText.includes("\uAE30\uC5B5")) {
51
+ console.log(`[${ts()}] *** HISTORY FOUND IN SYSTEM PROMPT ***`);
52
+ for (const keyword of ["\uC720\uB2C8", "\uCEE4\uD53C", "\uAE30\uC5B5"]) {
53
+ const idx = sysText.indexOf(keyword);
54
+ if (idx >= 0) console.log(`[${ts()}] "${keyword}" at pos ${idx}: ...${sysText.slice(Math.max(0, idx - 50), idx + 80)}...`);
55
+ }
56
+ }
57
+ }
58
+ console.log(`[${ts()}] REQ model=${body.model} stream=${body.stream} messages=${body.messages?.length} user="${userText.slice(0, 120)}"`);
59
+ for (let mi = 0; mi < body.messages.length; mi++) {
60
+ const m = body.messages[mi];
61
+ const role = m.role;
62
+ let preview = "";
63
+ if (typeof m.content === "string") {
64
+ preview = m.content.slice(0, 150);
65
+ } else if (Array.isArray(m.content)) {
66
+ preview = m.content.map((b) => {
67
+ if (b.type === "text") return `text:"${b.text.slice(0, 100)}"`;
68
+ if (b.type === "image") return `image:${b.source?.media_type}`;
69
+ return b.type;
70
+ }).join(" | ");
71
+ }
72
+ console.log(`[${ts()}] [${mi}] ${role}: ${preview}`);
73
+ }
74
+ const replyText = [...userText].reverse().join("");
75
+ const messageId = `msg_mock_${Date.now()}`;
76
+ if (body.stream) {
77
+ res.writeHead(200, {
78
+ "Content-Type": "text/event-stream",
79
+ "Cache-Control": "no-cache",
80
+ "Connection": "keep-alive"
81
+ });
82
+ const send = (event, data) => {
83
+ res.write(`event: ${event}
84
+ data: ${JSON.stringify(data)}
85
+
86
+ `);
87
+ };
88
+ send("message_start", {
89
+ type: "message_start",
90
+ message: {
91
+ id: messageId,
92
+ type: "message",
93
+ role: "assistant",
94
+ model: body.model,
95
+ content: [],
96
+ stop_reason: null,
97
+ usage: { input_tokens: 100, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
98
+ }
99
+ });
100
+ send("content_block_start", {
101
+ type: "content_block_start",
102
+ index: 0,
103
+ content_block: { type: "text", text: "" }
104
+ });
105
+ const words = replyText.split(" ");
106
+ for (const word of words) {
107
+ send("content_block_delta", {
108
+ type: "content_block_delta",
109
+ index: 0,
110
+ delta: { type: "text_delta", text: word + " " }
111
+ });
112
+ }
113
+ send("content_block_stop", { type: "content_block_stop", index: 0 });
114
+ send("message_delta", {
115
+ type: "message_delta",
116
+ delta: { stop_reason: "end_turn", stop_sequence: null },
117
+ usage: { output_tokens: words.length * 2 }
118
+ });
119
+ send("message_stop", { type: "message_stop" });
120
+ res.end();
121
+ console.log(`[${ts()}] RES stream complete reply="${replyText.slice(0, 80)}"`);
122
+ } else {
123
+ const response = {
124
+ id: messageId,
125
+ type: "message",
126
+ role: "assistant",
127
+ model: body.model,
128
+ content: [{ type: "text", text: replyText }],
129
+ stop_reason: "end_turn",
130
+ usage: { input_tokens: 100, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
131
+ };
132
+ res.writeHead(200, { "Content-Type": "application/json" });
133
+ res.end(JSON.stringify(response));
134
+ console.log(`[${ts()}] RES json reply="${replyText.slice(0, 80)}"`);
135
+ }
136
+ return;
137
+ }
138
+ console.log(`[${ts()}] 404 ${req.method} ${req.url}`);
139
+ res.writeHead(404);
140
+ res.end(JSON.stringify({ error: "Not found" }));
141
+ });
142
+ return new Promise((resolve) => {
143
+ server.listen(0, () => {
144
+ const port = server.address().port;
145
+ console.log(`[${ts()}] Mock Anthropic API server listening on :${port}`);
146
+ resolve({
147
+ port,
148
+ server,
149
+ close: () => {
150
+ console.log(`[${ts()}] Mock API server shutting down`);
151
+ server.close();
152
+ },
153
+ requests
154
+ });
155
+ });
156
+ });
157
+ }
158
+ export {
159
+ startMockAnthropicServer
160
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,6 +63,11 @@
63
63
  "source": "./src/lib/sna-run.ts",
64
64
  "types": "./dist/lib/sna-run.d.ts",
65
65
  "default": "./dist/lib/sna-run.js"
66
+ },
67
+ "./testing": {
68
+ "source": "./src/testing/mock-api.ts",
69
+ "types": "./dist/testing/mock-api.d.ts",
70
+ "default": "./dist/testing/mock-api.js"
66
71
  }
67
72
  },
68
73
  "engines": {