@sna-sdk/core 0.2.3 → 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/dist/server/ws.js CHANGED
@@ -4,6 +4,7 @@ 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 { saveImages } from "./image-store.js";
7
8
  function send(ws, data) {
8
9
  if (ws.readyState === ws.OPEN) {
9
10
  ws.send(JSON.stringify(data));
@@ -29,10 +30,13 @@ function attachWebSocket(server, sessionManager) {
29
30
  });
30
31
  wss.on("connection", (ws) => {
31
32
  logger.log("ws", "client connected");
32
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
33
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
33
34
  state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
34
35
  send(ws, { type: "session.lifecycle", ...event });
35
36
  });
37
+ state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
38
+ send(ws, { type: "session.config-changed", ...event });
39
+ });
36
40
  ws.on("message", (raw) => {
37
41
  let msg;
38
42
  try {
@@ -61,6 +65,8 @@ function attachWebSocket(server, sessionManager) {
61
65
  state.permissionUnsub = null;
62
66
  state.lifecycleUnsub?.();
63
67
  state.lifecycleUnsub = null;
68
+ state.configChangedUnsub?.();
69
+ state.configChangedUnsub = null;
64
70
  });
65
71
  });
66
72
  return wss;
@@ -83,6 +89,10 @@ function handleMessage(ws, msg, sm, state) {
83
89
  return handleAgentRestart(ws, msg, sm);
84
90
  case "agent.interrupt":
85
91
  return handleAgentInterrupt(ws, msg, sm);
92
+ case "agent.set-model":
93
+ return handleAgentSetModel(ws, msg, sm);
94
+ case "agent.set-permission-mode":
95
+ return handleAgentSetPermissionMode(ws, msg, sm);
86
96
  case "agent.kill":
87
97
  return handleAgentKill(ws, msg, sm);
88
98
  case "agent.status":
@@ -184,6 +194,7 @@ function handleAgentStart(ws, msg, sm) {
184
194
  model,
185
195
  permissionMode,
186
196
  env: { SNA_SESSION_ID: sessionId },
197
+ history: msg.history,
187
198
  extraArgs
188
199
  });
189
200
  sm.setProcess(sessionId, proc);
@@ -199,23 +210,42 @@ function handleAgentSend(ws, msg, sm) {
199
210
  if (!session?.process?.alive) {
200
211
  return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
201
212
  }
202
- if (!msg.message) {
203
- return replyError(ws, msg, "message is required");
213
+ const images = msg.images;
214
+ if (!msg.message && !images?.length) {
215
+ return replyError(ws, msg, "message or images required");
216
+ }
217
+ const textContent = msg.message ?? "(image)";
218
+ let meta = msg.meta ? { ...msg.meta } : {};
219
+ if (images?.length) {
220
+ const filenames = saveImages(sessionId, images);
221
+ meta.images = filenames;
204
222
  }
205
223
  try {
206
224
  const db = getDb();
207
225
  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);
226
+ 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
227
  } catch {
210
228
  }
211
229
  session.state = "processing";
212
230
  sm.touch(sessionId);
213
- session.process.send(msg.message);
231
+ if (images?.length) {
232
+ const content = [
233
+ ...images.map((img) => ({
234
+ type: "image",
235
+ source: { type: "base64", media_type: img.mimeType, data: img.base64 }
236
+ })),
237
+ ...msg.message ? [{ type: "text", text: msg.message }] : []
238
+ ];
239
+ session.process.send(content);
240
+ } else {
241
+ session.process.send(msg.message);
242
+ }
214
243
  wsReply(ws, msg, { status: "sent" });
215
244
  }
216
245
  function handleAgentRestart(ws, msg, sm) {
217
246
  const sessionId = msg.session ?? "default";
218
247
  try {
248
+ const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
219
249
  const { config } = sm.restartSession(
220
250
  sessionId,
221
251
  {
@@ -226,12 +256,13 @@ function handleAgentRestart(ws, msg, sm) {
226
256
  },
227
257
  (cfg) => {
228
258
  const prov = getProvider(cfg.provider);
259
+ const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
229
260
  return prov.spawn({
230
261
  cwd: sm.getSession(sessionId).cwd,
231
262
  model: cfg.model,
232
263
  permissionMode: cfg.permissionMode,
233
264
  env: { SNA_SESSION_ID: sessionId },
234
- extraArgs: [...cfg.extraArgs ?? [], "--resume"]
265
+ extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
235
266
  });
236
267
  }
237
268
  );
@@ -245,6 +276,20 @@ function handleAgentInterrupt(ws, msg, sm) {
245
276
  const interrupted = sm.interruptSession(sessionId);
246
277
  wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
247
278
  }
279
+ function handleAgentSetModel(ws, msg, sm) {
280
+ const sessionId = msg.session ?? "default";
281
+ const model = msg.model;
282
+ if (!model) return replyError(ws, msg, "model is required");
283
+ const updated = sm.setSessionModel(sessionId, model);
284
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
285
+ }
286
+ function handleAgentSetPermissionMode(ws, msg, sm) {
287
+ const sessionId = msg.session ?? "default";
288
+ const permissionMode = msg.permissionMode;
289
+ if (!permissionMode) return replyError(ws, msg, "permissionMode is required");
290
+ const updated = sm.setSessionPermissionMode(sessionId, permissionMode);
291
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode });
292
+ }
248
293
  function handleAgentKill(ws, msg, sm) {
249
294
  const sessionId = msg.session ?? "default";
250
295
  const killed = sm.killSession(sessionId);
@@ -256,7 +301,9 @@ function handleAgentStatus(ws, msg, sm) {
256
301
  wsReply(ws, msg, {
257
302
  alive: session?.process?.alive ?? false,
258
303
  sessionId: session?.process?.sessionId ?? null,
259
- eventCount: session?.eventCounter ?? 0
304
+ ccSessionId: session?.ccSessionId ?? null,
305
+ eventCount: session?.eventCounter ?? 0,
306
+ config: session?.lastStartConfig ?? null
260
307
  });
261
308
  }
262
309
  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,140 @@
1
+ import http from "http";
2
+ function ts() {
3
+ return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
4
+ }
5
+ async function startMockAnthropicServer() {
6
+ const requests = [];
7
+ const server = http.createServer(async (req, res) => {
8
+ console.log(`[${ts()}] ${req.method} ${req.url} ${req.headers["content-type"] ?? ""}`);
9
+ if (req.method === "OPTIONS") {
10
+ res.writeHead(200, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" });
11
+ res.end();
12
+ return;
13
+ }
14
+ if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
15
+ const chunks = [];
16
+ for await (const chunk of req) chunks.push(chunk);
17
+ const rawBody = Buffer.concat(chunks).toString();
18
+ let body;
19
+ try {
20
+ body = JSON.parse(rawBody);
21
+ } catch (e) {
22
+ console.log(`[${ts()}] ERROR: invalid JSON body: ${rawBody.slice(0, 200)}`);
23
+ res.writeHead(400, { "Content-Type": "application/json" });
24
+ res.end(JSON.stringify({ error: "invalid JSON" }));
25
+ return;
26
+ }
27
+ const entry = { model: body.model, messages: body.messages, stream: body.stream, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
28
+ requests.push(entry);
29
+ const lastUser = body.messages?.filter((m) => m.role === "user").pop();
30
+ let userText = "(no text)";
31
+ if (typeof lastUser?.content === "string") {
32
+ userText = lastUser.content;
33
+ } else if (Array.isArray(lastUser?.content)) {
34
+ const textBlocks = lastUser.content.filter((b) => b.type === "text").map((b) => b.text);
35
+ const realText = textBlocks.find((t) => !t.startsWith("<system-reminder>"));
36
+ userText = realText ?? textBlocks[textBlocks.length - 1] ?? "(no text)";
37
+ }
38
+ console.log(`[${ts()}] REQ model=${body.model} stream=${body.stream} messages=${body.messages?.length} user="${userText.slice(0, 120)}"`);
39
+ for (let mi = 0; mi < body.messages.length; mi++) {
40
+ const m = body.messages[mi];
41
+ const role = m.role;
42
+ let preview = "";
43
+ if (typeof m.content === "string") {
44
+ preview = m.content.slice(0, 150);
45
+ } else if (Array.isArray(m.content)) {
46
+ preview = m.content.map((b) => {
47
+ if (b.type === "text") return `text:"${b.text.slice(0, 100)}"`;
48
+ if (b.type === "image") return `image:${b.source?.media_type}`;
49
+ return b.type;
50
+ }).join(" | ");
51
+ }
52
+ console.log(`[${ts()}] [${mi}] ${role}: ${preview}`);
53
+ }
54
+ const replyText = [...userText].reverse().join("");
55
+ const messageId = `msg_mock_${Date.now()}`;
56
+ if (body.stream) {
57
+ res.writeHead(200, {
58
+ "Content-Type": "text/event-stream",
59
+ "Cache-Control": "no-cache",
60
+ "Connection": "keep-alive"
61
+ });
62
+ const send = (event, data) => {
63
+ res.write(`event: ${event}
64
+ data: ${JSON.stringify(data)}
65
+
66
+ `);
67
+ };
68
+ send("message_start", {
69
+ type: "message_start",
70
+ message: {
71
+ id: messageId,
72
+ type: "message",
73
+ role: "assistant",
74
+ model: body.model,
75
+ content: [],
76
+ stop_reason: null,
77
+ usage: { input_tokens: 100, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
78
+ }
79
+ });
80
+ send("content_block_start", {
81
+ type: "content_block_start",
82
+ index: 0,
83
+ content_block: { type: "text", text: "" }
84
+ });
85
+ const words = replyText.split(" ");
86
+ for (const word of words) {
87
+ send("content_block_delta", {
88
+ type: "content_block_delta",
89
+ index: 0,
90
+ delta: { type: "text_delta", text: word + " " }
91
+ });
92
+ }
93
+ send("content_block_stop", { type: "content_block_stop", index: 0 });
94
+ send("message_delta", {
95
+ type: "message_delta",
96
+ delta: { stop_reason: "end_turn", stop_sequence: null },
97
+ usage: { output_tokens: words.length * 2 }
98
+ });
99
+ send("message_stop", { type: "message_stop" });
100
+ res.end();
101
+ console.log(`[${ts()}] RES stream complete reply="${replyText.slice(0, 80)}"`);
102
+ } else {
103
+ const response = {
104
+ id: messageId,
105
+ type: "message",
106
+ role: "assistant",
107
+ model: body.model,
108
+ content: [{ type: "text", text: replyText }],
109
+ stop_reason: "end_turn",
110
+ usage: { input_tokens: 100, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
111
+ };
112
+ res.writeHead(200, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify(response));
114
+ console.log(`[${ts()}] RES json reply="${replyText.slice(0, 80)}"`);
115
+ }
116
+ return;
117
+ }
118
+ console.log(`[${ts()}] 404 ${req.method} ${req.url}`);
119
+ res.writeHead(404);
120
+ res.end(JSON.stringify({ error: "Not found" }));
121
+ });
122
+ return new Promise((resolve) => {
123
+ server.listen(0, () => {
124
+ const port = server.address().port;
125
+ console.log(`[${ts()}] Mock Anthropic API server listening on :${port}`);
126
+ resolve({
127
+ port,
128
+ server,
129
+ close: () => {
130
+ console.log(`[${ts()}] Mock API server shutting down`);
131
+ server.close();
132
+ },
133
+ requests
134
+ });
135
+ });
136
+ });
137
+ }
138
+ export {
139
+ startMockAnthropicServer
140
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.2.3",
3
+ "version": "0.3.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": {