amalgm 0.1.74 → 0.1.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.74",
3
+ "version": "0.1.75",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -102,8 +102,8 @@ class ChatCore {
102
102
  this.turns.mark(entry.turnId, 'cancelling');
103
103
  await this.runtime.stop(sessionId);
104
104
  }
105
- if (entry.finalized && typeof entry.finalized.then === 'function') {
106
- await entry.finalized;
105
+ if (entry.streamCompleted && typeof entry.streamCompleted.then === 'function') {
106
+ await entry.streamCompleted;
107
107
  }
108
108
  return true;
109
109
  }
@@ -196,6 +196,10 @@ class ChatCore {
196
196
  };
197
197
  entry.streamEnded = false;
198
198
  let resolveFinalized = null;
199
+ let resolveStreamCompleted = null;
200
+ entry.streamCompleted = new Promise((resolve) => {
201
+ resolveStreamCompleted = resolve;
202
+ });
199
203
  entry.finalized = new Promise((resolve) => {
200
204
  resolveFinalized = resolve;
201
205
  });
@@ -216,18 +220,15 @@ class ChatCore {
216
220
  let providerSessionId = contract.providerSessionId || null;
217
221
  let stopReason = 'end_turn';
218
222
  let sawDone = false;
219
- let finalDoneEvent = null;
220
- let finalDoneChunk = null;
221
223
 
222
224
  const append = (e, options = {}) => {
223
225
  const emitFrame = options.emitFrame !== false;
224
- const visible = options.visible !== false;
225
226
  const nextProviderSessionId = e.providerSessionId || providerSessionId;
226
227
  const nextUsage = e.type === 'usage.final' ? e.usage : usage;
227
228
  let chunk = null;
228
229
  if (emitFrame) {
229
230
  const frame = frameFor(e, { sessionId: contract.sessionId, providerSessionId: nextProviderSessionId, usage: nextUsage });
230
- chunk = this.turns.addChunk(turnId, (index) => withSseId(frame, index), { visible, seal: e.type === 'done' });
231
+ chunk = this.turns.addChunk(turnId, (index) => withSseId(frame, index), { seal: e.type === 'done' });
231
232
  if (!chunk) throw new Error('Raw stream write failed before event application');
232
233
  }
233
234
  if (e.providerSessionId) providerSessionId = e.providerSessionId;
@@ -238,8 +239,6 @@ class ChatCore {
238
239
  if (e.type === 'done') {
239
240
  sawDone = true;
240
241
  stopReason = e.stopReason || stopReason;
241
- finalDoneEvent = e;
242
- finalDoneChunk = chunk;
243
242
  }
244
243
  parts.apply(e);
245
244
  return chunk;
@@ -248,20 +247,22 @@ class ChatCore {
248
247
  try {
249
248
  try {
250
249
  const stream = await this.runtime.prompt(contract, runtimeInput);
251
- for await (const e of stream) append(e, { visible: e.type !== 'done' });
250
+ for await (const e of stream) append(e);
252
251
  const current = this.turns.get(turnId);
253
252
  if (!sawDone) {
254
253
  append(done({
255
254
  providerSessionId,
256
255
  stopReason: current?.status === 'cancelling' ? 'cancelled' : stopReason,
257
- }), { visible: false });
256
+ }));
258
257
  }
259
258
  } catch (err) {
260
259
  append(errorEvent(err.message, { providerSessionId }));
261
- append(done({ providerSessionId, stopReason: 'error' }), { visible: false });
260
+ append(done({ providerSessionId, stopReason: 'error' }));
262
261
  stopReason = 'error';
263
262
  }
264
263
  entry.streamEnded = true;
264
+ this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
265
+ if (resolveStreamCompleted) resolveStreamCompleted();
265
266
 
266
267
  const savedParts = parts.finalize();
267
268
  this.turns.setParts(turnId, savedParts);
@@ -288,15 +289,10 @@ class ChatCore {
288
289
  this.turns.mark(turnId, 'save_failed');
289
290
  throw err;
290
291
  }
291
- if (finalDoneChunk) {
292
- this.turns.releaseChunk(turnId, finalDoneChunk.index);
293
- } else {
294
- append(finalDoneEvent || done({ providerSessionId, stopReason }));
295
- }
296
- this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
297
292
  this.turns.clear(turnId);
298
293
  return { providerSessionId, usage, parts: savedParts, stopReason };
299
294
  } finally {
295
+ if (resolveStreamCompleted) resolveStreamCompleted();
300
296
  if (resolveFinalized) resolveFinalized();
301
297
  }
302
298
  }
@@ -47,23 +47,10 @@ class TurnStore {
47
47
  if (Number.isInteger(entry.sealedIndex)) return null;
48
48
  const index = entry.chunks.length;
49
49
  const data = typeof frame === 'function' ? frame(index) : frame;
50
- const visible = options.visible !== false;
51
- const chunk = { index, data, timestamp: Date.now(), visible };
50
+ const chunk = { index, data, timestamp: Date.now() };
52
51
  entry.chunks.push(chunk);
53
52
  if (options.seal === true) entry.sealedIndex = index;
54
53
  entry.updatedAt = Date.now();
55
- if (visible) this.emit(turnId, chunk, entry);
56
- return chunk;
57
- }
58
-
59
- releaseChunk(turnId, index) {
60
- const entry = this.turns.get(turnId);
61
- if (!entry || !['streaming', 'cancelling'].includes(entry.status)) return null;
62
- const chunk = entry.chunks.find((candidate) => candidate.index === index);
63
- if (!chunk) return null;
64
- if (chunk.visible !== false) return chunk;
65
- chunk.visible = true;
66
- entry.updatedAt = Date.now();
67
54
  this.emit(turnId, chunk, entry);
68
55
  return chunk;
69
56
  }
@@ -90,12 +77,9 @@ class TurnStore {
90
77
  this.listeners.delete(turnId);
91
78
  }
92
79
 
93
- chunksAfter(turnId, afterIndex = -1, options = {}) {
80
+ chunksAfter(turnId, afterIndex = -1) {
94
81
  const entry = this.turns.get(turnId);
95
- const visibleOnly = options.visibleOnly === true;
96
- return entry
97
- ? entry.chunks.filter((chunk) => chunk.index > afterIndex && (!visibleOnly || chunk.visible !== false))
98
- : [];
82
+ return entry ? entry.chunks.filter((chunk) => chunk.index > afterIndex) : [];
99
83
  }
100
84
 
101
85
  subscribe(turnId, callback) {
@@ -59,10 +59,6 @@ function normalizeAfterIndex(afterIndex) {
59
59
  return Number.isFinite(afterIndex) ? afterIndex : -1;
60
60
  }
61
61
 
62
- function visibleChunks(entry) {
63
- return entry.chunks.filter((chunk) => chunk.visible !== false);
64
- }
65
-
66
62
  function writeTurnTail(core, entry, sse, options = {}) {
67
63
  const format = options.format || 'frame';
68
64
  let lastSent = normalizeAfterIndex(options.afterIndex);
@@ -73,7 +69,7 @@ function writeTurnTail(core, entry, sse, options = {}) {
73
69
  sse.write(namedSse('chunk', {
74
70
  codeSessionId: current.sessionId,
75
71
  chunk,
76
- total: visibleChunks(current).length,
72
+ total: current.chunks.length,
77
73
  }));
78
74
  } else {
79
75
  sse.write(chunk.data);
@@ -85,7 +81,7 @@ function writeTurnTail(core, entry, sse, options = {}) {
85
81
  if (format === 'raw-temp') {
86
82
  sse.write(namedSse('complete', {
87
83
  codeSessionId: current.sessionId,
88
- total: visibleChunks(current).length,
84
+ total: current.chunks.length,
89
85
  }));
90
86
  sse.end();
91
87
  return;
@@ -105,7 +101,7 @@ function writeTurnTail(core, entry, sse, options = {}) {
105
101
  });
106
102
  sse.addCleanup(off);
107
103
 
108
- const existing = visibleChunks(entry).filter((chunk) => chunk.index > lastSent);
104
+ const existing = entry.chunks.filter((chunk) => chunk.index > lastSent);
109
105
  if (format === 'raw-temp') {
110
106
  sse.write(namedSse('existing', {
111
107
  codeSessionId: entry.sessionId,
@@ -88,11 +88,10 @@ test('title generation waits for user save and does not block a fast turn', asyn
88
88
  await waitTick();
89
89
 
90
90
  assert.equal(titleStarted, false, 'title generation should not race ahead of the user message save');
91
- assert.equal(frames.some((frame) => frame.includes('"complete"')), false, 'complete waits for final persistence');
91
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'complete follows raw stream, not final persistence');
92
92
 
93
93
  resolveUserSave();
94
94
  await run;
95
- assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'turn completes after persistence settles');
96
95
  assert.equal(frames.some((frame) => frame.includes('"title_generated"')), false, 'pending title should not hold the stream open');
97
96
  await waitTick();
98
97
  assert.equal(titleStarted, true, 'title generation should start after the user message save settles');
@@ -103,7 +102,7 @@ test('title generation waits for user save and does not block a fast turn', asyn
103
102
  assert.equal(frames.some((frame) => frame.includes('"title_generated"')), false);
104
103
  });
105
104
 
106
- test('complete frame waits for assistant message save', async () => {
105
+ test('complete frame is visible before assistant message save settles', async () => {
107
106
  let resolveAssistantSave;
108
107
  const db = {
109
108
  saveUserMessage: async () => true,
@@ -127,18 +126,16 @@ test('complete frame waits for assistant message save', async () => {
127
126
  await waitTick();
128
127
 
129
128
  assert.equal(frames.some((frame) => frame.includes('agent_message_chunk')), true, 'content should still stream immediately');
130
- assert.equal(frames.some((frame) => frame.includes('"complete"')), false, 'complete must not outrun assistant save');
131
- assert.equal(core.active('session-test').active, true, 'turn remains active while persistence is pending');
129
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'complete follows raw-temp before assistant save');
130
+ assert.equal(core.active('session-test').active, false, 'turn is no longer active once raw stream completes');
132
131
  const pendingEntry = core.turns.latest('session-test');
133
132
  const pendingComplete = pendingEntry.chunks.find((chunk) => chunk.data.includes('"_type":"complete"'));
134
- assert.ok(pendingComplete, 'raw-temp stores final complete before assistant save');
135
- assert.equal(pendingComplete.visible, false, 'final complete is hidden from UI until assistant save succeeds');
133
+ assert.ok(pendingComplete, 'raw-temp stores final complete while assistant save is pending');
136
134
  assert.equal(pendingEntry.sealedIndex, pendingComplete.index, 'final complete seals the raw-temp log');
137
135
 
138
136
  resolveAssistantSave();
139
137
  await run;
140
138
 
141
- assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'complete is emitted after assistant save');
142
139
  assert.equal(core.turns.active('session-test'), null, 'temp/raw state clears after save and complete');
143
140
  });
144
141
 
@@ -203,7 +200,7 @@ test('provider streams while user save is pending but assistant save waits', asy
203
200
  await run;
204
201
  });
205
202
 
206
- test('stop waits for cancelled turn persistence', async () => {
203
+ test('stop waits for cancelled raw stream but not persistence', async () => {
207
204
  let releaseRuntime;
208
205
  let resolveAssistantSave;
209
206
  const fakeRuntime = {
@@ -248,8 +245,8 @@ test('stop waits for cancelled turn persistence', async () => {
248
245
  });
249
246
  await waitTick();
250
247
 
251
- assert.equal(stopResolved, false, 'stop should wait for cancelled turn persistence');
252
- assert.equal(frames.some((frame) => frame.includes('"stopReason":"cancelled"')), false, 'cancelled complete waits for save');
248
+ assert.equal(stopResolved, true, 'stop resolves once cancelled raw stream completes');
249
+ assert.equal(frames.some((frame) => frame.includes('"stopReason":"cancelled"')), true, 'cancelled complete follows raw stream before save');
253
250
 
254
251
  resolveAssistantSave();
255
252
  assert.equal(await stop, true);
@@ -261,7 +258,7 @@ test('stop waits for cancelled turn persistence', async () => {
261
258
  assert.equal(core.turns.latest('session-test'), null, 'cancelled raw-temp clears after save');
262
259
  });
263
260
 
264
- test('new turn waits for active turn finalization before starting', async () => {
261
+ test('new turn can start after prior raw stream completes while save is pending', async () => {
265
262
  let resolveFirstSave;
266
263
  let stopCalls = 0;
267
264
  const prompts = [];
@@ -315,8 +312,8 @@ test('new turn waits for active turn finalization before starting', async () =>
315
312
  }));
316
313
  await waitTick();
317
314
 
318
- assert.deepEqual(prompts, ['first'], 'second provider turn should not start before first finalizes');
319
- assert.equal(dbCalls.some((call) => call[0] === 'saveUserMessage' && call[1] === 'user-test-2'), false, 'second turn persistence waits too');
315
+ assert.deepEqual(prompts, ['first', 'second'], 'second provider turn starts after first raw stream completes');
316
+ assert.equal(dbCalls.some((call) => call[0] === 'saveUserMessage' && call[1] === 'user-test-2'), true, 'second turn persistence starts independently');
320
317
  assert.equal(stopCalls, 0, 'finished stream should be awaited, not stopped during final save');
321
318
 
322
319
  resolveFirstSave();
@@ -324,5 +321,4 @@ test('new turn waits for active turn finalization before starting', async () =>
324
321
  await second;
325
322
 
326
323
  assert.deepEqual(prompts, ['first', 'second']);
327
- assert.equal(dbCalls.some((call) => call[0] === 'saveUserMessage' && call[1] === 'user-test-2'), true);
328
324
  });
@@ -108,9 +108,11 @@ test('POST /chat streams from the same indexed raw chunks as /raw-temp', async (
108
108
  assert.match(rawExisting, /"index":0/);
109
109
  assert.match(rawExisting, /agent_message_chunk/);
110
110
 
111
+ const completeFrame = firstChatFrame.includes('"complete"')
112
+ ? firstChatFrame
113
+ : await readUntil(chatReader, '"complete"');
114
+ assert.match(completeFrame, /"stopReason":"end_turn"/);
111
115
  resolveAssistantSave();
112
- const rest = await readUntil(chatReader, '"complete"');
113
- assert.match(rest, /"stopReason":"end_turn"/);
114
116
  } finally {
115
117
  await new Promise((resolve) => server.close(resolve));
116
118
  }