@agi-cli/server 0.1.73 → 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": "@agi-cli/server",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.73",
33
- "@agi-cli/database": "0.1.73",
32
+ "@agi-cli/sdk": "0.1.75",
33
+ "@agi-cli/database": "0.1.75",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
@@ -85,6 +85,17 @@ export function registerSessionMessagesRoutes(app: Hono) {
85
85
  const db = await getDb(cfg.projectRoot);
86
86
  const sessionId = c.req.param('id');
87
87
  const body = await c.req.json().catch(() => ({}));
88
+
89
+ // DEBUG: Log received body
90
+ logger.info('[API] Received message request', {
91
+ sessionId,
92
+ hasContent: !!body?.content,
93
+ hasUserContext: !!body?.userContext,
94
+ userContext: body?.userContext
95
+ ? `${String(body.userContext).substring(0, 50)}...`
96
+ : 'NONE',
97
+ });
98
+
88
99
  // Load session to inherit its provider/model/agent by default
89
100
  const sessionRows = await db
90
101
  .select()
@@ -99,6 +110,15 @@ export function registerSessionMessagesRoutes(app: Hono) {
99
110
  const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
100
111
  const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
101
112
  const content = body?.content ?? '';
113
+ const userContext = body?.userContext;
114
+
115
+ // DEBUG: Log extracted userContext
116
+ logger.info('[API] Extracted userContext', {
117
+ userContext: userContext
118
+ ? `${String(userContext).substring(0, 50)}...`
119
+ : 'NONE',
120
+ typeOf: typeof userContext,
121
+ });
102
122
 
103
123
  // Validate model capabilities if tools are allowed for this agent
104
124
  const wantsToolCalls = true; // agent toolset may be non-empty
@@ -131,6 +151,7 @@ export function registerSessionMessagesRoutes(app: Hono) {
131
151
  model: modelName,
132
152
  content,
133
153
  oneShot: Boolean(body?.oneShot),
154
+ userContext,
134
155
  });
135
156
  return c.json({ messageId: assistantMessageId }, 202);
136
157
  } catch (error) {
@@ -70,8 +70,14 @@ export function serializeError(err: unknown): APIErrorResponse {
70
70
  const payload = toErrorPayload(err);
71
71
 
72
72
  // Determine HTTP status code
73
- let status = 500;
74
- if (err && typeof err === 'object') {
73
+ // Default to 400 for generic errors (client errors)
74
+ // Only use 500 if explicitly set or for APIError instances without a status
75
+ let status = 400;
76
+
77
+ // Handle APIError instances first
78
+ if (err instanceof APIError) {
79
+ status = err.status;
80
+ } else if (err && typeof err === 'object') {
75
81
  const errObj = err as Record<string, unknown>;
76
82
  if (typeof errObj.status === 'number') {
77
83
  status = errObj.status;
@@ -86,11 +92,6 @@ export function serializeError(err: unknown): APIErrorResponse {
86
92
  }
87
93
  }
88
94
 
89
- // Handle APIError instances
90
- if (err instanceof APIError) {
91
- status = err.status;
92
- }
93
-
94
95
  // Extract code if available
95
96
  let code: string | undefined;
96
97
  if (err && typeof err === 'object') {
@@ -20,13 +20,29 @@ type DispatchOptions = {
20
20
  model: string;
21
21
  content: string;
22
22
  oneShot?: boolean;
23
+ userContext?: string;
23
24
  };
24
25
 
25
26
  export async function dispatchAssistantMessage(
26
27
  options: DispatchOptions,
27
28
  ): Promise<{ assistantMessageId: string }> {
28
- const { cfg, db, session, agent, provider, model, content, oneShot } =
29
- options;
29
+ const {
30
+ cfg,
31
+ db,
32
+ session,
33
+ agent,
34
+ provider,
35
+ model,
36
+ content,
37
+ oneShot,
38
+ userContext,
39
+ } = options;
40
+
41
+ // DEBUG: Log userContext in dispatch
42
+ debugLog(
43
+ `[MESSAGE_SERVICE] dispatchAssistantMessage called with userContext: ${userContext ? userContext.substring(0, 50) + '...' : 'NONE'}`,
44
+ );
45
+
30
46
  const sessionId = session.id;
31
47
  const now = Date.now();
32
48
  const userMessageId = crypto.randomUUID();
@@ -90,6 +106,11 @@ export async function dispatchAssistantMessage(
90
106
  payload: { id: assistantMessageId, role: 'assistant' },
91
107
  });
92
108
 
109
+ // DEBUG: Log before enqueue
110
+ debugLog(
111
+ `[MESSAGE_SERVICE] Enqueuing assistant run with userContext: ${userContext ? userContext.substring(0, 50) + '...' : 'NONE'}`,
112
+ );
113
+
93
114
  enqueueAssistantRun({
94
115
  sessionId,
95
116
  assistantMessageId,
@@ -99,6 +120,7 @@ export async function dispatchAssistantMessage(
99
120
  model,
100
121
  projectRoot: cfg.projectRoot,
101
122
  oneShot: Boolean(oneShot),
123
+ userContext,
102
124
  });
103
125
 
104
126
  void touchSessionLastActive({ db, sessionId });
@@ -117,21 +139,44 @@ function scheduleSessionTitle(args: {
117
139
  db: DB;
118
140
  sessionId: string;
119
141
  content: unknown;
120
- }): void {
121
- const { sessionId } = args;
122
- if (titleInFlight.has(sessionId)) return;
123
- titleInFlight.add(sessionId);
124
- void (async () => {
142
+ }) {
143
+ const { cfg, db, sessionId, content } = args;
144
+
145
+ if (titleInFlight.has(sessionId) || titlePending.has(sessionId)) {
146
+ return;
147
+ }
148
+
149
+ const processNext = () => {
150
+ if (titleQueue.length === 0) {
151
+ return;
152
+ }
153
+ if (titleActiveCount >= TITLE_CONCURRENCY_LIMIT) {
154
+ return;
155
+ }
156
+ const next = titleQueue.shift();
157
+ if (!next) return;
158
+ titleActiveCount++;
159
+ next();
160
+ };
161
+
162
+ const task = async () => {
163
+ titleInFlight.add(sessionId);
164
+ titlePending.delete(sessionId);
125
165
  try {
126
- const alreadyTitled = await sessionHasTitle(args.db, sessionId);
127
- if (alreadyTitled) return;
128
- await withTitleSlot(() => updateSessionTitle(args));
129
- } catch {
130
- // Swallow title generation errors; they are non-blocking.
166
+ await generateSessionTitle({ cfg, db, sessionId, content });
167
+ } catch (err) {
168
+ debugLog('[TITLE_GEN] Title generation error:');
169
+ debugLog(err);
131
170
  } finally {
132
171
  titleInFlight.delete(sessionId);
172
+ titleActiveCount--;
173
+ processNext();
133
174
  }
134
- })();
175
+ };
176
+
177
+ titlePending.add(sessionId);
178
+ titleQueue.push(task);
179
+ processNext();
135
180
  }
136
181
 
137
182
  function enqueueSessionTitle(args: {
@@ -140,72 +185,54 @@ function enqueueSessionTitle(args: {
140
185
  sessionId: string;
141
186
  content: unknown;
142
187
  }) {
143
- const { sessionId } = args;
144
- if (titlePending.has(sessionId) || titleInFlight.has(sessionId)) return;
145
- titlePending.add(sessionId);
146
- Promise.resolve()
147
- .then(() => {
148
- titlePending.delete(sessionId);
149
- scheduleSessionTitle(args);
150
- })
151
- .catch(() => {
152
- titlePending.delete(sessionId);
153
- });
188
+ scheduleSessionTitle(args);
154
189
  }
155
190
 
156
- async function updateSessionTitle(args: {
191
+ async function generateSessionTitle(args: {
157
192
  cfg: AGIConfig;
158
193
  db: DB;
159
194
  sessionId: string;
160
195
  content: unknown;
161
- }) {
196
+ }): Promise<void> {
162
197
  const { cfg, db, sessionId, content } = args;
198
+
163
199
  try {
164
- const rows = await db
200
+ const existingSession = await db
165
201
  .select()
166
202
  .from(sessions)
167
203
  .where(eq(sessions.id, sessionId));
168
- if (!rows.length) return;
169
- const current = rows[0];
170
- const alreadyHasTitle =
171
- current.title != null && String(current.title).trim().length > 0;
172
- let heuristic = '';
173
- if (!alreadyHasTitle) {
174
- heuristic = deriveTitle(String(content ?? ''));
175
- if (heuristic) {
176
- await db
177
- .update(sessions)
178
- .set({ title: heuristic })
179
- .where(eq(sessions.id, sessionId));
180
- publish({
181
- type: 'session.updated',
182
- sessionId,
183
- payload: { title: heuristic },
184
- });
185
- }
204
+
205
+ if (!existingSession.length) {
206
+ debugLog('[TITLE_GEN] Session not found, aborting');
207
+ return;
208
+ }
209
+
210
+ const sess = existingSession[0];
211
+ if (sess.title && sess.title !== 'New Session') {
212
+ debugLog('[TITLE_GEN] Session already has a title, skipping');
213
+ return;
186
214
  }
187
215
 
188
- const providerId =
189
- (current.provider as ProviderId) ?? cfg.defaults.provider;
190
- const modelId = current.model ?? cfg.defaults.model;
216
+ const provider = sess.provider ?? cfg.defaults.provider;
217
+ const modelName = sess.model ?? cfg.defaults.model;
191
218
 
192
- debugLog('[TITLE_GEN] Starting title generation');
193
- debugLog(`[TITLE_GEN] Provider: ${providerId}, Model: ${modelId}`);
219
+ debugLog('[TITLE_GEN] Generating title for session');
220
+ debugLog(`[TITLE_GEN] Provider: ${provider}, Model: ${modelName}`);
221
+
222
+ const model = await resolveModel(provider, modelName, cfg);
194
223
 
195
- // Check if we need OAuth spoof prompt (same logic as runner)
196
224
  const { getAuth } = await import('@agi-cli/sdk');
197
225
  const { getProviderSpoofPrompt } = await import('./prompt.ts');
198
- const auth = await getAuth(providerId, cfg.projectRoot);
226
+ const auth = await getAuth(provider, cfg.projectRoot);
199
227
  const needsSpoof = auth?.type === 'oauth';
200
228
  const spoofPrompt = needsSpoof
201
- ? getProviderSpoofPrompt(providerId)
229
+ ? getProviderSpoofPrompt(provider)
202
230
  : undefined;
203
231
 
204
- debugLog(`[TITLE_GEN] Auth type: ${auth?.type ?? 'none'}`);
205
- debugLog(`[TITLE_GEN] Needs spoof: ${needsSpoof}`);
206
- debugLog(`[TITLE_GEN] Spoof prompt length: ${spoofPrompt?.length ?? 0}`);
232
+ debugLog(
233
+ `[TITLE_GEN] needsSpoof: ${needsSpoof}, spoofPrompt: ${spoofPrompt || 'NONE'}`,
234
+ );
207
235
 
208
- const model = await resolveModel(providerId, modelId, cfg);
209
236
  const promptText = String(content ?? '').slice(0, 2000);
210
237
 
211
238
  const titlePrompt = [
@@ -270,123 +297,49 @@ async function updateSessionTitle(args: {
270
297
  const sanitized = sanitizeTitle(modelTitle);
271
298
  debugLog(`[TITLE_GEN] After sanitization: "${sanitized}"`);
272
299
 
273
- if (!sanitized) {
274
- debugLog('[TITLE_GEN] Title sanitized to empty, aborting');
275
- return;
276
- }
277
-
278
- modelTitle = sanitized;
279
-
280
- const check = await db
281
- .select()
282
- .from(sessions)
283
- .where(eq(sessions.id, sessionId));
284
- if (!check.length) return;
285
- const currentTitle = String(check[0].title ?? '').trim();
286
- if (currentTitle && currentTitle !== heuristic) {
287
- debugLog(
288
- `[TITLE_GEN] Session already has different title: "${currentTitle}", skipping`,
289
- );
300
+ if (!sanitized || sanitized === 'New Session') {
301
+ debugLog('[TITLE_GEN] Sanitized title is empty or default, aborting');
290
302
  return;
291
303
  }
292
304
 
293
- debugLog(`[TITLE_GEN] Setting final title: "${modelTitle}"`);
294
305
  await db
295
306
  .update(sessions)
296
- .set({ title: modelTitle })
307
+ .set({ title: sanitized, updatedAt: Date.now() })
297
308
  .where(eq(sessions.id, sessionId));
309
+
310
+ debugLog(`[TITLE_GEN] Setting final title: "${sanitized}"`);
311
+
298
312
  publish({
299
313
  type: 'session.updated',
300
314
  sessionId,
301
- payload: { title: modelTitle },
315
+ payload: { id: sessionId, title: sanitized },
302
316
  });
303
317
  } catch (err) {
304
- debugLog('[TITLE_GEN] Fatal error:');
318
+ debugLog('[TITLE_GEN] Error in generateSessionTitle:');
305
319
  debugLog(err);
306
320
  }
307
321
  }
308
322
 
309
- async function withTitleSlot<T>(fn: () => Promise<T>): Promise<T> {
310
- await acquireTitleSlot();
311
- try {
312
- return await fn();
313
- } finally {
314
- releaseTitleSlot();
315
- }
316
- }
317
-
318
- async function sessionHasTitle(db: DB, sessionId: string): Promise<boolean> {
319
- try {
320
- const rows = await db
321
- .select({ title: sessions.title })
322
- .from(sessions)
323
- .where(eq(sessions.id, sessionId))
324
- .limit(1);
325
- if (!rows.length) return false;
326
- const title = rows[0]?.title;
327
- return typeof title === 'string' && title.trim().length > 0;
328
- } catch {
329
- return false;
330
- }
331
- }
332
-
333
- function acquireTitleSlot(): Promise<void> {
334
- if (titleActiveCount < TITLE_CONCURRENCY_LIMIT) {
335
- titleActiveCount += 1;
336
- return Promise.resolve();
337
- }
338
- return new Promise((resolve) => {
339
- titleQueue.push(() => {
340
- titleActiveCount += 1;
341
- resolve();
342
- });
343
- });
344
- }
345
-
346
- function releaseTitleSlot(): void {
347
- if (titleActiveCount > 0) titleActiveCount -= 1;
348
- const next = titleQueue.shift();
349
- if (next) {
350
- next();
351
- }
323
+ function sanitizeTitle(raw: string): string {
324
+ let s = raw.trim();
325
+ s = s.replace(/^["']|["']$/g, '');
326
+ s = s.replace(/[.!?]+$/, '');
327
+ s = s.replace(/\s+/g, ' ');
328
+ if (s.length > 80) s = s.slice(0, 80).trim();
329
+ return s;
352
330
  }
353
331
 
354
- async function touchSessionLastActive(args: { db: DB; sessionId: string }) {
332
+ async function touchSessionLastActive(args: {
333
+ db: DB;
334
+ sessionId: string;
335
+ }): Promise<void> {
355
336
  const { db, sessionId } = args;
356
337
  try {
357
338
  await db
358
339
  .update(sessions)
359
- .set({ lastActiveAt: Date.now() })
340
+ .set({ updatedAt: Date.now() })
360
341
  .where(eq(sessions.id, sessionId));
361
- } catch {}
362
- }
363
-
364
- function deriveTitle(text: string): string {
365
- const cleaned = String(text || '')
366
- .replace(/```[\s\S]*?```/g, ' ')
367
- .replace(/`[^`]*`/g, ' ')
368
- .replace(/\s+/g, ' ')
369
- .trim();
370
- if (!cleaned) return '';
371
- const endIdx = (() => {
372
- const punct = ['? ', '. ', '! ']
373
- .map((p) => cleaned.indexOf(p))
374
- .filter((i) => i > 0);
375
- const idx = Math.min(...(punct.length ? punct : [cleaned.length]));
376
- return Math.min(idx + 1, cleaned.length);
377
- })();
378
- const first = cleaned.slice(0, endIdx).trim();
379
- const maxLen = 64;
380
- const base = first.length > 8 ? first : cleaned;
381
- const truncated =
382
- base.length > maxLen ? `${base.slice(0, maxLen - 1).trimEnd()}…` : base;
383
- return truncated;
384
- }
385
-
386
- function sanitizeTitle(s: string): string {
387
- let t = s.trim();
388
- t = t.replace(/^['"""''()[\]]+|['"""''()[\]]+$/g, '').trim();
389
- t = t.replace(/[\s\-_:–—]+$/g, '').trim();
390
- if (t.length > 64) t = `${t.slice(0, 63).trimEnd()}…`;
391
- return t;
342
+ } catch (err) {
343
+ debugLog('[touchSessionLastActive] Error:', err);
344
+ }
392
345
  }
@@ -20,6 +20,7 @@ export async function composeSystemPrompt(options: {
20
20
  spoofPrompt?: string;
21
21
  includeEnvironment?: boolean;
22
22
  includeProjectTree?: boolean;
23
+ userContext?: string;
23
24
  }): Promise<string> {
24
25
  if (options.spoofPrompt) {
25
26
  return options.spoofPrompt.trim();
@@ -61,6 +62,16 @@ export async function composeSystemPrompt(options: {
61
62
  }
62
63
  }
63
64
 
65
+ // Add user-provided context if present
66
+ if (options.userContext?.trim()) {
67
+ const userContextBlock = [
68
+ '<user-provided-state-context>',
69
+ options.userContext.trim(),
70
+ '</user-provided-state-context>',
71
+ ].join('\n');
72
+ parts.push(userContextBlock);
73
+ }
74
+
64
75
  const composed = parts.filter(Boolean).join('\n\n').trim();
65
76
  if (composed) return composed;
66
77
 
@@ -122,7 +122,18 @@ async function runAssistant(opts: RunOpts) {
122
122
  const history = await buildHistoryMessages(db, opts.sessionId);
123
123
  historyTimer.end({ messages: history.length });
124
124
 
125
- const isFirstMessage = history.length === 0;
125
+ // FIX: For OAuth, we need to check if this is the first ASSISTANT message
126
+ // The user message is already in history by this point, so history.length will be > 0
127
+ // We need to add additionalSystemMessages on the first assistant turn
128
+ const isFirstMessage = !history.some((m) => m.role === 'assistant');
129
+
130
+ debugLog(`[RUNNER] isFirstMessage: ${isFirstMessage}`);
131
+ debugLog(`[RUNNER] userContext provided: ${opts.userContext ? 'YES' : 'NO'}`);
132
+ if (opts.userContext) {
133
+ debugLog(
134
+ `[RUNNER] userContext value: ${opts.userContext.substring(0, 100)}${opts.userContext.length > 100 ? '...' : ''}`,
135
+ );
136
+ }
126
137
 
127
138
  const systemTimer = time('runner:composeSystemPrompt');
128
139
  const { getAuth } = await import('@agi-cli/sdk');
@@ -133,10 +144,14 @@ async function runAssistant(opts: RunOpts) {
133
144
  ? getProviderSpoofPrompt(opts.provider)
134
145
  : undefined;
135
146
 
147
+ debugLog(`[RUNNER] needsSpoof (OAuth): ${needsSpoof}`);
148
+ debugLog(`[RUNNER] spoofPrompt: ${spoofPrompt || 'NONE'}`);
149
+
136
150
  let system: string;
137
151
  let additionalSystemMessages: Array<{ role: 'system'; content: string }> = [];
138
152
 
139
153
  if (spoofPrompt) {
154
+ // OAuth mode: short spoof in system field, full instructions in messages array
140
155
  system = spoofPrompt;
141
156
  const fullPrompt = await composeSystemPrompt({
142
157
  provider: opts.provider,
@@ -146,9 +161,27 @@ async function runAssistant(opts: RunOpts) {
146
161
  oneShot: opts.oneShot,
147
162
  spoofPrompt: undefined,
148
163
  includeProjectTree: isFirstMessage,
164
+ userContext: opts.userContext,
149
165
  });
166
+
167
+ // FIX: Always add the system message for OAuth because:
168
+ // 1. System messages are NOT stored in the database
169
+ // 2. buildHistoryMessages only returns user/assistant messages
170
+ // 3. We need the full instructions on every turn
150
171
  additionalSystemMessages = [{ role: 'system', content: fullPrompt }];
172
+
173
+ debugLog('[RUNNER] OAuth mode: additionalSystemMessages created');
174
+ debugLog(`[RUNNER] fullPrompt length: ${fullPrompt.length}`);
175
+ debugLog(
176
+ `[RUNNER] fullPrompt contains userContext: ${fullPrompt.includes('<user-provided-state-context>') ? 'YES' : 'NO'}`,
177
+ );
178
+ if (opts.userContext && fullPrompt.includes(opts.userContext)) {
179
+ debugLog('[RUNNER] ✅ userContext IS in fullPrompt');
180
+ } else if (opts.userContext) {
181
+ debugLog('[RUNNER] ❌ userContext NOT in fullPrompt!');
182
+ }
151
183
  } else {
184
+ // API key mode: full instructions in system field
152
185
  system = await composeSystemPrompt({
153
186
  provider: opts.provider,
154
187
  model: opts.model,
@@ -157,6 +190,7 @@ async function runAssistant(opts: RunOpts) {
157
190
  oneShot: opts.oneShot,
158
191
  spoofPrompt: undefined,
159
192
  includeProjectTree: isFirstMessage,
193
+ userContext: opts.userContext,
160
194
  });
161
195
  }
162
196
  systemTimer.end();
@@ -172,11 +206,29 @@ async function runAssistant(opts: RunOpts) {
172
206
  'progress_update',
173
207
  ]);
174
208
  const gated = allTools.filter((t) => allowedNames.has(t.name));
209
+
210
+ // FIX: For OAuth, ALWAYS prepend the system message because it's never in history
211
+ // For API key mode, only add on first message (when additionalSystemMessages is empty)
175
212
  const messagesWithSystemInstructions = [
176
- ...(isFirstMessage ? additionalSystemMessages : []),
213
+ ...additionalSystemMessages, // Always add for OAuth, empty for API key mode
177
214
  ...history,
178
215
  ];
179
216
 
217
+ debugLog(
218
+ `[RUNNER] messagesWithSystemInstructions length: ${messagesWithSystemInstructions.length}`,
219
+ );
220
+ debugLog(
221
+ `[RUNNER] additionalSystemMessages length: ${additionalSystemMessages.length}`,
222
+ );
223
+ if (additionalSystemMessages.length > 0) {
224
+ debugLog(
225
+ '[RUNNER] ✅ additionalSystemMessages ADDED to messagesWithSystemInstructions',
226
+ );
227
+ debugLog(
228
+ `[RUNNER] This happens on EVERY turn for OAuth (system messages not stored in DB)`,
229
+ );
230
+ }
231
+
180
232
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
181
233
  opts,
182
234
  db,
@@ -257,9 +309,24 @@ async function runAssistant(opts: RunOpts) {
257
309
  maxToolResults: 30,
258
310
  });
259
311
 
312
+ debugLog(
313
+ `[RUNNER] After optimizeContext: ${contextOptimized.length} messages`,
314
+ );
315
+
260
316
  // 2. Truncate history
261
317
  const truncatedMessages = truncateHistory(contextOptimized, 20);
262
318
 
319
+ debugLog(
320
+ `[RUNNER] After truncateHistory: ${truncatedMessages.length} messages`,
321
+ );
322
+ if (truncatedMessages.length > 0 && truncatedMessages[0].role === 'system') {
323
+ debugLog('[RUNNER] ✅ First message is system message');
324
+ } else if (truncatedMessages.length > 0) {
325
+ debugLog(
326
+ `[RUNNER] ⚠️ First message is NOT system (it's ${truncatedMessages[0].role})`,
327
+ );
328
+ }
329
+
263
330
  // 3. Add cache control
264
331
  const { system: cachedSystem, messages: optimizedMessages } = addCacheControl(
265
332
  opts.provider,
@@ -267,6 +334,13 @@ async function runAssistant(opts: RunOpts) {
267
334
  truncatedMessages,
268
335
  );
269
336
 
337
+ debugLog(
338
+ `[RUNNER] Final optimizedMessages: ${optimizedMessages.length} messages`,
339
+ );
340
+ debugLog(
341
+ `[RUNNER] cachedSystem (spoof): ${typeof cachedSystem === 'string' ? cachedSystem.substring(0, 100) : JSON.stringify(cachedSystem).substring(0, 100)}`,
342
+ );
343
+
270
344
  try {
271
345
  // @ts-expect-error this is fine 🔥
272
346
  const result = streamText({
@@ -310,29 +384,23 @@ async function runAssistant(opts: RunOpts) {
310
384
  await db
311
385
  .update(messageParts)
312
386
  .set({
313
- content: JSON.stringify({
314
- text: accumulated,
315
- error: errorPayload.message,
316
- }),
387
+ content: JSON.stringify({ error: errorPayload }),
317
388
  })
318
- .where(eq(messageParts.messageId, opts.assistantMessageId));
389
+ .where(eq(messageParts.id, currentPartId));
390
+
319
391
  publish({
320
- type: 'error',
392
+ type: 'message.error',
321
393
  sessionId: opts.sessionId,
322
394
  payload: {
323
395
  messageId: opts.assistantMessageId,
324
- error: errorPayload.message,
325
- details: errorPayload.details,
396
+ partId: currentPartId,
397
+ error: errorPayload,
326
398
  },
327
399
  });
328
400
  throw error;
329
401
  } finally {
330
- if (!firstToolSeen()) firstToolTimer.end({ skipped: true });
331
- try {
332
- unsubscribeFinish();
333
- } catch {}
334
- try {
335
- await cleanupEmptyTextParts(opts, db);
336
- } catch {}
402
+ unsubscribeFinish();
403
+ await cleanupEmptyTextParts(db, opts.assistantMessageId);
404
+ firstToolTimer.end({ seen: firstToolSeen });
337
405
  }
338
406
  }
@@ -9,6 +9,7 @@ export type RunOpts = {
9
9
  model: string;
10
10
  projectRoot: string;
11
11
  oneShot?: boolean;
12
+ userContext?: string;
12
13
  abortSignal?: AbortSignal;
13
14
  };
14
15
 
@@ -39,7 +40,8 @@ export function enqueueAssistantRun(
39
40
  }
40
41
 
41
42
  /**
42
- * Aborts all pending operations for a given session.
43
+ * Signals the abort controller for a session.
44
+ * This will trigger the abortSignal in the streamText call.
43
45
  */
44
46
  export function abortSession(sessionId: string) {
45
47
  const controller = sessionAbortControllers.get(sessionId);
@@ -49,34 +51,25 @@ export function abortSession(sessionId: string) {
49
51
  }
50
52
  }
51
53
 
52
- /**
53
- * Gets the current state of a session's queue.
54
- */
55
54
  export function getRunnerState(sessionId: string): RunnerState | undefined {
56
55
  return runners.get(sessionId);
57
56
  }
58
57
 
59
- /**
60
- * Marks a session queue as running.
61
- */
62
58
  export function setRunning(sessionId: string, running: boolean) {
63
59
  const state = runners.get(sessionId);
64
- if (state) {
65
- state.running = running;
66
- }
60
+ if (state) state.running = running;
67
61
  }
68
62
 
69
- /**
70
- * Dequeues the next job from a session's queue.
71
- */
72
63
  export function dequeueJob(sessionId: string): RunOpts | undefined {
73
64
  const state = runners.get(sessionId);
74
65
  return state?.queue.shift();
75
66
  }
76
67
 
77
- /**
78
- * Cleanup abort controller for a session (called when queue is done).
79
- */
80
68
  export function cleanupSession(sessionId: string) {
81
- sessionAbortControllers.delete(sessionId);
69
+ const state = runners.get(sessionId);
70
+ if (state && state.queue.length === 0 && !state.running) {
71
+ runners.delete(sessionId);
72
+ // Clean up any lingering abort controller
73
+ sessionAbortControllers.delete(sessionId);
74
+ }
82
75
  }