@yeaft/webchat-agent 1.0.172 → 1.0.174

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": "@yeaft/webchat-agent",
3
- "version": "1.0.172",
3
+ "version": "1.0.174",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -524,6 +524,9 @@ export class Engine {
524
524
  /** @type {Array<{content:string|Array, preview:string}>} */
525
525
  #pendingUserMessages = [];
526
526
 
527
+ /** True after the bridge queued input in its external per-thread buffer. */
528
+ #externalUserWakePending = false;
529
+
527
530
  /**
528
531
  * Tasks (background bash, sub-agent spawns) that were launched DURING
529
532
  * the currently-running query() and have NOT terminated yet. The query
@@ -1654,6 +1657,7 @@ export class Engine {
1654
1657
 
1655
1658
  #drainPendingUserMessages(drainPendingUserMessages) {
1656
1659
  const pending = [];
1660
+ this.#externalUserWakePending = false;
1657
1661
  if (typeof drainPendingUserMessages === 'function') {
1658
1662
  try {
1659
1663
  const drained = drainPendingUserMessages();
@@ -1809,6 +1813,7 @@ export class Engine {
1809
1813
  this.#abortReason = null;
1810
1814
  this.#currentThreadId = MAIN_THREAD_ID;
1811
1815
  this.#pendingUserMessages.length = 0;
1816
+ this.#externalUserWakePending = false;
1812
1817
  this.#asyncTaskToolMeta.clear();
1813
1818
  this.#pendingTaskResultMessages.length = 0;
1814
1819
  this.#pendingTaskResultUpdates.length = 0;
@@ -2955,7 +2960,8 @@ export class Engine {
2955
2960
  while (!signal?.aborted
2956
2961
  && this.#pendingTaskResultMessages.length === 0
2957
2962
  && this.#pendingTaskResultUpdates.length === 0
2958
- && this.#pendingUserMessages.length === 0) {
2963
+ && this.#pendingUserMessages.length === 0
2964
+ && !this.#externalUserWakePending) {
2959
2965
  if (this.#pendingAsyncTaskIds.size === 0) break;
2960
2966
  await this.#waitForAsyncWake(signal);
2961
2967
  }
@@ -3680,6 +3686,20 @@ export class Engine {
3680
3686
  return true;
3681
3687
  }
3682
3688
 
3689
+ /**
3690
+ * Wake a query whose external pending-message hook has received input.
3691
+ * The bridge keeps the full message (attachments and provenance included)
3692
+ * in its thread queue; this method only releases an async-task wait so the
3693
+ * normal drain callback can consume it at the next safe adapter boundary.
3694
+ * @returns {boolean}
3695
+ */
3696
+ wakeForPendingUserMessage() {
3697
+ if (!this.isRunning) return false;
3698
+ this.#externalUserWakePending = true;
3699
+ this.#wakeAsyncTaskWaiters();
3700
+ return true;
3701
+ }
3702
+
3683
3703
  /**
3684
3704
  * Install the coordinator that lets an external dispatcher (web-bridge)
3685
3705
  * route a background task's terminal event back to THIS engine while
@@ -3851,6 +3871,7 @@ export class Engine {
3851
3871
  if (this.#pendingTaskResultMessages.length > 0) return resolve();
3852
3872
  if (this.#pendingTaskResultUpdates.length > 0) return resolve();
3853
3873
  if (this.#pendingUserMessages.length > 0) return resolve();
3874
+ if (this.#externalUserWakePending) return resolve();
3854
3875
  if (signal?.aborted) return resolve();
3855
3876
  this.#asyncTaskWaiters.push(resolve);
3856
3877
  if (signal && typeof signal.addEventListener === 'function') {
@@ -66,6 +66,13 @@ Guidelines:
66
66
  }
67
67
 
68
68
  const answers = await ctx.askUser({ question, options });
69
+ if (answers?.__yeaftTimedOut) {
70
+ return JSON.stringify({
71
+ question,
72
+ timedOut: true,
73
+ message: 'The user did not answer before the request expired. Continue without this input.',
74
+ });
75
+ }
69
76
  return JSON.stringify({ question, answers });
70
77
  },
71
78
  });
@@ -80,7 +80,27 @@ const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
80
80
  const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
81
81
  const BASE_RUNTIME_KEY = '__base__';
82
82
 
83
- /** @type {Map<string, {resolve:Function, sessionId:string, vpId:string, threadId:string, turnId:string, signal?:AbortSignal, onAbort?:Function}>} */
83
+ /**
84
+ * Live AskUser requests. They are Session-scoped runtime state rather than a
85
+ * turn lock: every connected client may answer the same request, while the
86
+ * first valid answer wins. Pending requests are replayed when another device
87
+ * loads the Session.
88
+ * @type {Map<string, {
89
+ * resolve:Function,
90
+ * reject?:Function,
91
+ * sessionId:string,
92
+ * vpId:string,
93
+ * threadId:string,
94
+ * turnId:string,
95
+ * question:string,
96
+ * options:Array<string>,
97
+ * createdAt:number,
98
+ * expiresAt:number,
99
+ * timer?:NodeJS.Timeout|null,
100
+ * resumeQueryTimer?:Function,
101
+ * signal?:AbortSignal,
102
+ * onAbort?:Function,
103
+ * }>} */
84
104
  const pendingUserPrompts = new Map();
85
105
 
86
106
  /** @type {import('./session.js').Session | null} */
@@ -212,6 +232,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
212
232
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
213
233
  tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
214
234
  }, { sessionId });
235
+ if (sessionId) replayPendingUserPrompts(sessionId);
215
236
  sendSessionSnapshotBroadcast();
216
237
  if (sessionId && session === replaySession) {
217
238
  sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
@@ -796,6 +817,8 @@ export function broadcastLanguageChange(language) {
796
817
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
797
818
  const QUERY_TIMEOUT_MS = 120_000;
798
819
  const HIGH_REASONING_QUERY_TIMEOUT_MS = 300_000;
820
+ /** AskUser is human-paced but must never pin a VP turn forever. */
821
+ const ASK_USER_TIMEOUT_MS = 10 * 60_000;
799
822
 
800
823
  function isHighReasoningEffort(effort) {
801
824
  const value = typeof effort === 'string' ? effort.trim().toLowerCase() : '';
@@ -1378,6 +1401,12 @@ export function __testGetVpThreads(sessionId, vpId) {
1378
1401
  }));
1379
1402
  }
1380
1403
 
1404
+ /** Test-only: attach an Engine-like wake target to a seeded VP thread. */
1405
+ export function __testSetVpThreadEngine({ sessionId, vpId, threadId, engine }) {
1406
+ const thread = getOrCreateVpThread({ sessionId, vpId, threadId });
1407
+ thread.engine = engine || null;
1408
+ }
1409
+
1381
1410
  /** Test-only: seed a VP thread without starting its engine driver. */
1382
1411
  export function __testSeedVpThread({ sessionId, vpId, threadId, title = 'test thread', status = 'queued' }) {
1383
1412
  const thread = getOrCreateVpThread({ sessionId, vpId, threadId, title });
@@ -1725,31 +1754,43 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1725
1754
  senderVpId: isInternalAppend ? (envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null) : null,
1726
1755
  sourceThreadId: isInternalAppend ? visibleInboundThreadId(envelope, thread.threadId) : null,
1727
1756
  });
1728
- persistInboundMessageOnceByMsgId({
1729
- msgId: envelope?.msg?.id,
1730
- text,
1731
- sessionId,
1732
- threadId: visibleInboundThreadId(envelope, thread.threadId),
1733
- role: isInternalAppend ? 'assistant' : 'user',
1734
- speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1735
- attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1736
- internal: isInternalAppend,
1737
- ts: envelope?.msg?.ts || null,
1738
- clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1739
- });
1740
- thread.updatedAt = Date.now();
1741
- try {
1742
- sendSessionEvent({
1743
- type: 'vp_thread_user_appended',
1757
+ // A thread parked on a background task has no adapter activity to poll this
1758
+ // queue. Wake its Engine explicitly so the new user message is consumed
1759
+ // immediately while the task remains tracked in TaskManager. If the Engine
1760
+ // is not actually running, this was a stale classifier hit; remove the
1761
+ // append and fall through to a normal queued turn instead of orphaning it.
1762
+ let appendedToRunningThread = false;
1763
+ try { appendedToRunningThread = thread.engine?.wakeForPendingUserMessage?.() === true; } catch { /* best-effort wake */ }
1764
+ if (!appendedToRunningThread) {
1765
+ thread.pendingQueries.pop();
1766
+ related = false;
1767
+ } else {
1768
+ persistInboundMessageOnceByMsgId({
1769
+ msgId: envelope?.msg?.id,
1770
+ text,
1744
1771
  sessionId,
1745
- vpId,
1746
- threadId: thread.threadId,
1747
- title: thread.title,
1748
- turnId,
1749
- ts: Date.now(),
1750
- }, { sessionId, vpId, threadId: thread.threadId, turnId });
1751
- } catch { /* never crash WS pipeline */ }
1752
- return thread.threadId;
1772
+ threadId: visibleInboundThreadId(envelope, thread.threadId),
1773
+ role: isInternalAppend ? 'assistant' : 'user',
1774
+ speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1775
+ attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1776
+ internal: isInternalAppend,
1777
+ ts: envelope?.msg?.ts || null,
1778
+ clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1779
+ });
1780
+ thread.updatedAt = Date.now();
1781
+ try {
1782
+ sendSessionEvent({
1783
+ type: 'vp_thread_user_appended',
1784
+ sessionId,
1785
+ vpId,
1786
+ threadId: thread.threadId,
1787
+ title: thread.title,
1788
+ turnId,
1789
+ ts: Date.now(),
1790
+ }, { sessionId, vpId, threadId: thread.threadId, turnId });
1791
+ } catch { /* never crash WS pipeline */ }
1792
+ return thread.threadId;
1793
+ }
1753
1794
  }
1754
1795
 
1755
1796
  const key = threadKey(sessionId, vpId, thread.threadId);
@@ -2335,6 +2376,60 @@ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, pe
2335
2376
  });
2336
2377
  }
2337
2378
 
2379
+ function pendingUserPromptEvent(requestId, pending, extra = {}) {
2380
+ return {
2381
+ type: 'ask_user_question',
2382
+ requestId,
2383
+ questions: [{
2384
+ question: pending.question,
2385
+ options: pending.options.map(label => ({ label, description: '' })),
2386
+ multiSelect: false,
2387
+ }],
2388
+ createdAt: pending.createdAt,
2389
+ expiresAt: pending.expiresAt,
2390
+ ...extra,
2391
+ };
2392
+ }
2393
+
2394
+ function sendPendingUserPrompt(requestId, pending, extra = {}) {
2395
+ sendSessionEvent(pendingUserPromptEvent(requestId, pending, extra), {
2396
+ sessionId: pending.sessionId,
2397
+ vpId: pending.vpId,
2398
+ threadId: pending.threadId,
2399
+ turnId: pending.turnId,
2400
+ });
2401
+ }
2402
+
2403
+ function replayPendingUserPrompts(sessionId) {
2404
+ const now = Date.now();
2405
+ for (const [requestId, pending] of pendingUserPrompts) {
2406
+ if (pending.sessionId !== sessionId || pending.expiresAt <= now) continue;
2407
+ sendPendingUserPrompt(requestId, pending, { replay: true });
2408
+ }
2409
+ }
2410
+
2411
+ function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false } = {}) {
2412
+ if (pendingUserPrompts.get(requestId) !== pending) return false;
2413
+ pendingUserPrompts.delete(requestId);
2414
+ if (pending.timer) clearTimeout(pending.timer);
2415
+ if (pending.signal && pending.onAbort) {
2416
+ try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
2417
+ }
2418
+ try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
2419
+ sendSessionEvent({
2420
+ type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
2421
+ requestId,
2422
+ ...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
2423
+ }, {
2424
+ sessionId: pending.sessionId,
2425
+ vpId: pending.vpId,
2426
+ threadId: pending.threadId,
2427
+ turnId: pending.turnId,
2428
+ });
2429
+ pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
2430
+ return true;
2431
+ }
2432
+
2338
2433
  function configuredVpPaths() {
2339
2434
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2340
2435
  if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return {};
@@ -4560,40 +4655,44 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
4560
4655
  askUser: ({ question, options }) => new Promise((resolve, reject) => {
4561
4656
  const requestId = `ask_${randomUUID()}`;
4562
4657
  const signal = vpAbort.signal;
4563
- // User think time is not engine silence. Pause the query watchdog until
4564
- // the card is answered; the normal tool/LLM budget resumes afterward.
4658
+ const createdAt = Date.now();
4659
+ // Human think time is not engine silence. Pause the query watchdog,
4660
+ // but keep a separate bounded AskUser lifetime so an abandoned card
4661
+ // cannot pin the VP forever.
4565
4662
  if (queryTimer) {
4566
4663
  clearTimeout(queryTimer);
4567
4664
  queryTimer = null;
4568
4665
  }
4569
- const onAbort = () => {
4570
- pendingUserPrompts.delete(requestId);
4571
- reject(new Error('aborted'));
4572
- };
4573
- pendingUserPrompts.set(requestId, {
4574
- resolve: answers => {
4575
- resetQueryTimer();
4576
- resolve(answers);
4577
- },
4666
+ const pending = {
4667
+ resolve,
4668
+ reject,
4578
4669
  sessionId,
4579
4670
  vpId,
4580
4671
  threadId,
4581
4672
  turnId,
4673
+ question,
4674
+ options: Array.isArray(options) ? options.filter(label => typeof label === 'string') : [],
4675
+ createdAt,
4676
+ expiresAt: createdAt + ASK_USER_TIMEOUT_MS,
4677
+ timer: null,
4678
+ resumeQueryTimer: resetQueryTimer,
4582
4679
  signal,
4583
- onAbort,
4584
- });
4680
+ onAbort: null,
4681
+ };
4682
+ const onAbort = () => {
4683
+ if (pendingUserPrompts.get(requestId) !== pending) return;
4684
+ pendingUserPrompts.delete(requestId);
4685
+ if (pending.timer) clearTimeout(pending.timer);
4686
+ reject(new Error('aborted'));
4687
+ };
4688
+ pending.onAbort = onAbort;
4689
+ pending.timer = setTimeout(() => {
4690
+ settlePendingUserPrompt(requestId, pending, { timedOut: true });
4691
+ }, ASK_USER_TIMEOUT_MS);
4692
+ if (typeof pending.timer.unref === 'function') pending.timer.unref();
4693
+ pendingUserPrompts.set(requestId, pending);
4585
4694
  signal.addEventListener('abort', onAbort, { once: true });
4586
- sendSessionEvent({
4587
- type: 'ask_user_question',
4588
- requestId,
4589
- questions: [{
4590
- question,
4591
- options: Array.isArray(options)
4592
- ? options.map(label => ({ label, description: '' }))
4593
- : [],
4594
- multiSelect: false,
4595
- }],
4596
- }, envelope);
4695
+ sendPendingUserPrompt(requestId, pending);
4597
4696
  }),
4598
4697
  threadId,
4599
4698
  vpTurnId: turnId,
@@ -5639,12 +5738,7 @@ export function handleYeaftAskUserAnswer(msg) {
5639
5738
  if (msg.turnId && msg.turnId !== pending.turnId) return false;
5640
5739
  if (msg.threadId && msg.threadId !== pending.threadId) return false;
5641
5740
 
5642
- pendingUserPrompts.delete(requestId);
5643
- if (pending.signal && pending.onAbort) {
5644
- try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
5645
- }
5646
- pending.resolve(msg.answers || {});
5647
- return true;
5741
+ return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
5648
5742
  }
5649
5743
 
5650
5744
  export async function handleYeaftSessionSend(msg) {
@@ -6543,13 +6637,21 @@ export const __testHooks = {
6543
6637
  vpAborts.clear();
6544
6638
  vpInboxes.clear();
6545
6639
  },
6546
- seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
6640
+ seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
6547
6641
  let resolved;
6548
6642
  const promise = new Promise(resolve => { resolved = resolve; });
6549
- pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId });
6643
+ pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, question, options, createdAt, expiresAt, timer: null });
6550
6644
  return promise;
6551
6645
  },
6646
+ replayPendingUserPrompts,
6647
+ settlePendingUserPromptForTest(requestId, opts = {}) {
6648
+ const pending = pendingUserPrompts.get(requestId);
6649
+ return pending ? settlePendingUserPrompt(requestId, pending, opts) : false;
6650
+ },
6552
6651
  resetPendingUserPrompts() {
6652
+ for (const pending of pendingUserPrompts.values()) {
6653
+ if (pending.timer) clearTimeout(pending.timer);
6654
+ }
6553
6655
  pendingUserPrompts.clear();
6554
6656
  },
6555
6657
  resetVpStatusBroker() {
@@ -18,14 +18,19 @@ let shuttingDown = false;
18
18
  let shutdownPromise = null;
19
19
  let serviceFactory = null;
20
20
 
21
- const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
21
+ const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'action_input', 'guide', 'retry']);
22
+ const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
22
23
  // `files` is an internal server-to-Agent field. The browser relay rejects any
23
24
  // client-supplied value and only emits files resolved from owned upload ids.
24
25
  const BROWSER_FILE_FIELDS = Object.freeze({
25
26
  create: [
26
27
  'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
27
28
  ],
29
+ action_input: ['id', 'text', 'actionId', 'revision', 'files'],
28
30
  guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
31
+ get_action_messages: ['id', 'actionId', 'cursor', 'limit'],
32
+ get_action_requests: ['id', 'actionId'],
33
+ get_action_request: ['id', 'actionId', 'runId', 'requestId'],
29
34
  });
30
35
 
31
36
  function browserFilePayload(op, value) {
@@ -100,6 +105,7 @@ async function createDefaultService() {
100
105
  },
101
106
  policyProvider: async () => readWorkCenterSettings(yeaftDir),
102
107
  attachmentRoot: join(yeaftDir, 'work-center', 'attachments'),
108
+ yeaftDir,
103
109
  actionWorktreeRoot: join(yeaftDir, 'work-center', 'worktrees'),
104
110
  registry: defaultRegistry,
105
111
  store: null,
@@ -184,7 +190,7 @@ export async function handleWorkCenterRequest(msg) {
184
190
  const workCenter = await ensureWorkCenter();
185
191
  const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
186
192
  ? browserFilePayload(op, msg.payload)
187
- : (msg.payload || {});
193
+ : (BROWSER_ACTION_DEBUG_OPS.has(op) ? browserFilePayload(op, msg.payload) : (msg.payload || {}));
188
194
  data = await workCenter.handle(op, payload);
189
195
  }
190
196
  if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
@@ -210,16 +210,54 @@ export class WorkflowController {
210
210
  instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
211
211
  maxAttempts: previous.maxAttempts || 2,
212
212
  };
213
- }, input.attachments);
213
+ }, input.attachments, input.addedAttachments);
214
214
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
215
215
  return detail;
216
216
  }
217
217
 
218
+ input(id, input = {}) {
219
+ const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
220
+ const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
221
+ if (!text && addedAttachmentCount === 0) throw new Error('Action input or attachments are required');
222
+ const workItem = this.store.getWorkItem(id);
223
+ if (!workItem) throw new Error(`WorkItem not found: ${id}`);
224
+ if (['ready', 'running'].includes(workItem.status)) {
225
+ if (workItem.currentActionId !== input.actionId || workItem.revision !== input.revision) {
226
+ throw new Error('Action changed before input was applied; refresh and try again');
227
+ }
228
+ return this.guide(id, {
229
+ guidance: text,
230
+ actionId: input.actionId,
231
+ revision: input.revision,
232
+ addedAttachmentCount,
233
+ addedAttachments: input.addedAttachments,
234
+ attachments: input.attachments,
235
+ });
236
+ }
237
+ if (!['waiting', 'needs_attention'].includes(workItem.status)) {
238
+ throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
239
+ }
240
+ if (workItem.currentActionId !== input.actionId || workItem.revision !== input.revision) {
241
+ throw new Error('Action changed before input was applied; refresh and try again');
242
+ }
243
+ return this.retry(id, {
244
+ answer: text,
245
+ addedAttachmentCount,
246
+ expected: { actionId: input.actionId, revision: input.revision },
247
+ attachments: input.attachments,
248
+ inputEvent: {
249
+ text: text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`,
250
+ attachments: input.addedAttachments,
251
+ },
252
+ });
253
+ }
254
+
218
255
  retry(id, input = {}) {
219
256
  const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
257
+ const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
220
258
  const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
221
- if (workItem.status === 'waiting' && !answer) {
222
- throw new Error('answer is required to resume a waiting WorkItem');
259
+ if (workItem.status === 'waiting' && !answer && addedAttachmentCount === 0) {
260
+ throw new Error('answer or attachments are required to resume a waiting WorkItem');
223
261
  }
224
262
  const step = previous
225
263
  ? {
@@ -244,7 +282,9 @@ export class WorkflowController {
244
282
  summary: previousRun.summary || '',
245
283
  evidence: normalizeEvidence(previousRun.evidence),
246
284
  waitingReason: previousRun.waitingReason || null,
247
- answer: answer || null,
285
+ answer: answer || (addedAttachmentCount > 0
286
+ ? `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`
287
+ : null),
248
288
  });
249
289
  }
250
290
  return {
@@ -253,6 +293,10 @@ export class WorkflowController {
253
293
  instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
254
294
  maxAttempts: previous?.maxAttempts || 2,
255
295
  };
296
+ }, {
297
+ expected: input.expected || null,
298
+ attachments: input.attachments,
299
+ inputEvent: input.inputEvent || null,
256
300
  });
257
301
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
258
302
  return detail;