@yeaft/webchat-agent 0.1.497 → 0.1.499
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/crew/routing.js +47 -5
- package/crew/task-files.js +29 -3
- package/package.json +1 -1
- package/unify/web-bridge.js +107 -49
package/crew/routing.js
CHANGED
|
@@ -235,7 +235,7 @@ export function resolveRoleName(to, session, fromRole) {
|
|
|
235
235
|
* @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
|
|
236
236
|
*/
|
|
237
237
|
export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
238
|
-
|
|
238
|
+
let { to, summary, taskId, taskTitle } = route;
|
|
239
239
|
|
|
240
240
|
// Auto-resume: paused/stopped → running (route execution means work should continue)
|
|
241
241
|
if (session.status === 'paused' || session.status === 'stopped') {
|
|
@@ -244,14 +244,51 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
|
244
244
|
sendStatusUpdate(session);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// ─── task-321: taskId fallback chain ─────────────────────────────
|
|
248
|
+
// When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
|
|
249
|
+
// PM forgetting the field), fall back to:
|
|
250
|
+
// (a) the sender's currentTask.taskId
|
|
251
|
+
// (b) the most recent non-system entry in session.messageHistory
|
|
252
|
+
// This keeps prev-* / designer / architect / shorthand messages from
|
|
253
|
+
// becoming taskId=null orphans that never appear on any feature card.
|
|
254
|
+
if (!taskId) {
|
|
255
|
+
const fromRoleState = session.roleStates?.get(fromRole);
|
|
256
|
+
if (fromRoleState?.currentTask?.taskId) {
|
|
257
|
+
taskId = fromRoleState.currentTask.taskId;
|
|
258
|
+
taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
|
|
259
|
+
} else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
|
|
260
|
+
for (let i = session.messageHistory.length - 1; i >= 0; i--) {
|
|
261
|
+
const h = session.messageHistory[i];
|
|
262
|
+
if (h && h.from !== 'system' && h.taskId) {
|
|
263
|
+
taskId = h.taskId;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Mirror the fallback back into the route object so downstream
|
|
269
|
+
// consumers (dispatchToRole / sendCrewOutput) see the inferred id.
|
|
270
|
+
if (taskId) {
|
|
271
|
+
route.taskId = taskId;
|
|
272
|
+
if (taskTitle) route.taskTitle = taskTitle;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
247
276
|
// Task 文件自动管理(fire-and-forget)
|
|
248
277
|
if (taskId && summary) {
|
|
249
278
|
const fromRoleConfig = session.roles.get(fromRole);
|
|
250
|
-
|
|
251
|
-
|
|
279
|
+
// task-321: Auto-create feature file even when a non-PM role is the
|
|
280
|
+
// first to mention the taskId. Any role carrying a taskId (PM, devs,
|
|
281
|
+
// reviewers, designer, architect) now triggers creation — not just PM
|
|
282
|
+
// with explicit taskTitle. appendTaskRecord itself also creates the
|
|
283
|
+
// file if missing, so this is a best-effort fast path.
|
|
284
|
+
const effectiveTitle = taskTitle
|
|
285
|
+
|| session.features?.get(taskId)?.taskTitle
|
|
286
|
+
|| null;
|
|
287
|
+
if (effectiveTitle && to !== 'human') {
|
|
288
|
+
ensureTaskFile(session, taskId, effectiveTitle, fromRoleConfig?.isDecisionMaker ? to : fromRole, summary)
|
|
252
289
|
.catch(e => console.warn(`[Crew] Failed to create task file ${taskId}:`, e.message));
|
|
253
290
|
}
|
|
254
|
-
appendTaskRecord(session, taskId, fromRole, summary)
|
|
291
|
+
appendTaskRecord(session, taskId, fromRole, summary, { taskTitle: effectiveTitle })
|
|
255
292
|
.catch(e => console.warn(`[Crew] Failed to append task record ${taskId}:`, e.message));
|
|
256
293
|
|
|
257
294
|
// 更新工作看板:推断状态
|
|
@@ -375,8 +412,13 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
|
|
|
375
412
|
}
|
|
376
413
|
|
|
377
414
|
// 设置 task
|
|
415
|
+
// task-321: keep currentTask sticky. A new taskId updates it; a dispatch
|
|
416
|
+
// without taskId preserves the previous currentTask so subsequent
|
|
417
|
+
// sendCrewOutput calls (which read roleState.currentTask.taskId) keep
|
|
418
|
+
// attaching to the right feature card — instead of falling back to null
|
|
419
|
+
// the moment the sender omits the `task:` field.
|
|
378
420
|
if (taskId) {
|
|
379
|
-
roleState.currentTask = { taskId, taskTitle };
|
|
421
|
+
roleState.currentTask = { taskId, taskTitle: taskTitle || roleState.currentTask?.taskTitle || null };
|
|
380
422
|
}
|
|
381
423
|
|
|
382
424
|
// Task 上下文注入
|
package/crew/task-files.js
CHANGED
|
@@ -59,15 +59,41 @@ ${m.workRecord}
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* 追加工作记录到 task 文件
|
|
62
|
+
*
|
|
63
|
+
* task-321: auto-create the feature file if it doesn't exist yet. Previously
|
|
64
|
+
* a missing file silently dropped the record, which meant that whenever a
|
|
65
|
+
* non-PM role was first to mention a taskId, the file was never created and
|
|
66
|
+
* every subsequent record — including from PM — would also be dropped. We
|
|
67
|
+
* now recover a title from opts.taskTitle → session.features cache → taskId
|
|
68
|
+
* itself, and create the file on the fly.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} session
|
|
71
|
+
* @param {string} taskId
|
|
72
|
+
* @param {string} roleName
|
|
73
|
+
* @param {string} summary
|
|
74
|
+
* @param {{ taskTitle?: string|null, assignee?: string|null }} [opts]
|
|
62
75
|
*/
|
|
63
|
-
export async function appendTaskRecord(session, taskId, roleName, summary) {
|
|
76
|
+
export async function appendTaskRecord(session, taskId, roleName, summary, opts = {}) {
|
|
64
77
|
const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
|
|
65
78
|
|
|
79
|
+
let exists = true;
|
|
66
80
|
try {
|
|
67
81
|
await fs.access(filePath);
|
|
68
82
|
} catch {
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
exists = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!exists) {
|
|
87
|
+
const recoveredTitle = opts.taskTitle
|
|
88
|
+
|| session.features?.get(taskId)?.taskTitle
|
|
89
|
+
|| taskId;
|
|
90
|
+
const assignee = opts.assignee || roleName;
|
|
91
|
+
try {
|
|
92
|
+
await ensureTaskFile(session, taskId, recoveredTitle, assignee, summary);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.warn(`[Crew] Failed to auto-create task file ${taskId} on append:`, e.message);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
const role = session.roles.get(roleName);
|
package/package.json
CHANGED
package/unify/web-bridge.js
CHANGED
|
@@ -16,13 +16,22 @@
|
|
|
16
16
|
import { loadSession } from './session.js';
|
|
17
17
|
import { sendToServer } from '../connection/buffer.js';
|
|
18
18
|
import ctx from '../context.js';
|
|
19
|
-
import { getThreadStore } from './threads/store.js';
|
|
19
|
+
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
20
20
|
|
|
21
21
|
/** @type {import('./session.js').Session | null} */
|
|
22
22
|
let session = null;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* task-320: per-thread in-flight AbortController registry.
|
|
26
|
+
*
|
|
27
|
+
* A new message only cancels the prior round on the SAME thread; a message
|
|
28
|
+
* routed to a different thread runs concurrently without aliasing. Keyed by
|
|
29
|
+
* the resolved `targetThreadId` from the dispatcher's `routing_decision`
|
|
30
|
+
* event (we don't know the thread until the router has classified).
|
|
31
|
+
*
|
|
32
|
+
* @type {Map<string, AbortController>}
|
|
33
|
+
*/
|
|
34
|
+
const abortByThread = new Map();
|
|
26
35
|
|
|
27
36
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
28
37
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
@@ -30,10 +39,22 @@ const QUERY_TIMEOUT_MS = 120_000;
|
|
|
30
39
|
/** Virtual conversationId for the Unify session */
|
|
31
40
|
let unifyConversationId = null;
|
|
32
41
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
42
|
+
/**
|
|
43
|
+
* task-320: per-thread accumulated conversation messages for context
|
|
44
|
+
* continuity. Previously a single flat array — which cross-contaminated
|
|
45
|
+
* history across threads. Keyed by threadId. Cleared on session reset or
|
|
46
|
+
* by a `consolidate` event for that thread only.
|
|
47
|
+
*
|
|
48
|
+
* @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
|
|
49
|
+
*/
|
|
50
|
+
const messagesByThread = new Map();
|
|
51
|
+
|
|
52
|
+
function getThreadMessages(threadId) {
|
|
53
|
+
if (!threadId) return [];
|
|
54
|
+
let arr = messagesByThread.get(threadId);
|
|
55
|
+
if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
|
|
56
|
+
return arr;
|
|
57
|
+
}
|
|
37
58
|
|
|
38
59
|
/** Whether we've already sent a permission warning to the UI */
|
|
39
60
|
let _permissionDiagnosticSent = false;
|
|
@@ -395,8 +416,13 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
395
416
|
break;
|
|
396
417
|
|
|
397
418
|
case 'consolidate':
|
|
398
|
-
// Engine compressed the context — clear our accumulated history
|
|
399
|
-
|
|
419
|
+
// Engine compressed the context — clear our accumulated history for
|
|
420
|
+
// THIS thread only (task-320: per-thread history map).
|
|
421
|
+
if (threadId) {
|
|
422
|
+
messagesByThread.set(threadId, []);
|
|
423
|
+
} else {
|
|
424
|
+
messagesByThread.clear();
|
|
425
|
+
}
|
|
400
426
|
sendUnifyEvent({
|
|
401
427
|
type: 'consolidate',
|
|
402
428
|
archivedCount: event.archivedCount,
|
|
@@ -512,11 +538,16 @@ export async function handleUnifyChat(msg) {
|
|
|
512
538
|
// Create a stable conversationId for the Unify session
|
|
513
539
|
unifyConversationId = `unify-${Date.now()}`;
|
|
514
540
|
|
|
515
|
-
// Restore
|
|
541
|
+
// Restore per-thread history from persisted conversation store.
|
|
542
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
543
|
+
messagesByThread.clear();
|
|
516
544
|
const recent = session.conversationStore.loadRecent(50);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
545
|
+
for (const m of recent) {
|
|
546
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
547
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
548
|
+
const bucket = getThreadMessages(tid);
|
|
549
|
+
bucket.push({ role: m.role, content: m.content });
|
|
550
|
+
}
|
|
520
551
|
|
|
521
552
|
// Notify UI: session is ready with model info + conversationId
|
|
522
553
|
sendUnifyEvent({
|
|
@@ -533,13 +564,14 @@ export async function handleUnifyChat(msg) {
|
|
|
533
564
|
sendThreadListUpdate();
|
|
534
565
|
}
|
|
535
566
|
|
|
536
|
-
// ───
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
567
|
+
// ─── Per-call AbortController (task-320) ──
|
|
568
|
+
// Each call owns its own controller. Only once the router resolves the
|
|
569
|
+
// target thread do we register it into `abortByThread` and abort any
|
|
570
|
+
// prior controller on THAT same thread. Messages routed to different
|
|
571
|
+
// threads never alias each other's signals.
|
|
572
|
+
const abortCtrl = new AbortController();
|
|
573
|
+
/** @type {string | null} — set on routing_decision */
|
|
574
|
+
let resolvedThreadId = null;
|
|
543
575
|
|
|
544
576
|
// ─── Timeout guard: abort query if LLM hangs beyond threshold ──
|
|
545
577
|
// Resets on every event — fires only after prolonged silence.
|
|
@@ -547,9 +579,9 @@ export async function handleUnifyChat(msg) {
|
|
|
547
579
|
const resetQueryTimer = () => {
|
|
548
580
|
if (queryTimer) clearTimeout(queryTimer);
|
|
549
581
|
queryTimer = setTimeout(() => {
|
|
550
|
-
if (
|
|
582
|
+
if (!abortCtrl.signal.aborted) {
|
|
551
583
|
console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
|
|
552
|
-
|
|
584
|
+
abortCtrl.abort();
|
|
553
585
|
}
|
|
554
586
|
}, QUERY_TIMEOUT_MS);
|
|
555
587
|
};
|
|
@@ -590,17 +622,31 @@ export async function handleUnifyChat(msg) {
|
|
|
590
622
|
onError: (err) => { throw err; },
|
|
591
623
|
};
|
|
592
624
|
|
|
593
|
-
for await (const pev of session.dispatcher.drain({ signal:
|
|
625
|
+
for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
|
|
594
626
|
resetQueryTimer();
|
|
627
|
+
// task-320: on routing_decision, bind this abort controller to the
|
|
628
|
+
// resolved target thread and abort any prior in-flight controller
|
|
629
|
+
// owned by that thread. Different threads don't alias.
|
|
630
|
+
if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
|
|
631
|
+
resolvedThreadId = pev.targetThreadId;
|
|
632
|
+
const prior = abortByThread.get(resolvedThreadId);
|
|
633
|
+
if (prior && prior !== abortCtrl) {
|
|
634
|
+
prior.abort();
|
|
635
|
+
}
|
|
636
|
+
abortByThread.set(resolvedThreadId, abortCtrl);
|
|
637
|
+
}
|
|
595
638
|
forwardPipelineEvent(pev, pipelineCtx);
|
|
596
639
|
}
|
|
597
640
|
|
|
598
641
|
// ─── Query complete — accumulate messages for context continuity ──
|
|
599
|
-
|
|
642
|
+
// task-320: per-thread history (no cross-thread contamination).
|
|
643
|
+
const historyThread = resolvedThreadId || MAIN_THREAD_ID;
|
|
644
|
+
const threadMessages = getThreadMessages(historyThread);
|
|
645
|
+
threadMessages.push({ role: 'user', content: cleanedPrompt });
|
|
600
646
|
|
|
601
647
|
const fullText = assistantTextParts.join('');
|
|
602
648
|
if (fullText) {
|
|
603
|
-
|
|
649
|
+
threadMessages.push({ role: 'assistant', content: fullText });
|
|
604
650
|
}
|
|
605
651
|
|
|
606
652
|
// ─── Signal turn end to UI ──
|
|
@@ -621,17 +667,14 @@ export async function handleUnifyChat(msg) {
|
|
|
621
667
|
}
|
|
622
668
|
|
|
623
669
|
} catch (err) {
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}],
|
|
633
|
-
},
|
|
634
|
-
});
|
|
670
|
+
// task-320: classify both DOM AbortError and LLMAbortError as
|
|
671
|
+
// "aborted" — LLMAbortError is thrown by the LLM adapters when the
|
|
672
|
+
// signal trips and must NOT render as a session error bubble.
|
|
673
|
+
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
674
|
+
if (isAbort) {
|
|
675
|
+
// Silent abort — the new in-flight round (on the same thread) will
|
|
676
|
+
// produce its own output. Still send `result` so the frontend's
|
|
677
|
+
// processing spinner for this exact send clears.
|
|
635
678
|
sendUnifyOutput({
|
|
636
679
|
type: 'result',
|
|
637
680
|
result_text: '',
|
|
@@ -672,7 +715,12 @@ export async function handleUnifyChat(msg) {
|
|
|
672
715
|
result_text: '',
|
|
673
716
|
});
|
|
674
717
|
} finally {
|
|
675
|
-
|
|
718
|
+
// task-320: only clear the per-thread slot if THIS controller is still
|
|
719
|
+
// the registered one. If a newer message already overwrote it, leaving
|
|
720
|
+
// the newer controller in the map is the correct state.
|
|
721
|
+
if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
|
|
722
|
+
abortByThread.delete(resolvedThreadId);
|
|
723
|
+
}
|
|
676
724
|
}
|
|
677
725
|
}
|
|
678
726
|
|
|
@@ -863,11 +911,16 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
863
911
|
|
|
864
912
|
unifyConversationId = `unify-${Date.now()}`;
|
|
865
913
|
|
|
866
|
-
// Restore
|
|
914
|
+
// Restore per-thread history from persisted conversation store.
|
|
915
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
916
|
+
messagesByThread.clear();
|
|
867
917
|
const recent = session.conversationStore.loadRecent(50);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
918
|
+
for (const m of recent) {
|
|
919
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
920
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
921
|
+
const bucket = getThreadMessages(tid);
|
|
922
|
+
bucket.push({ role: m.role, content: m.content });
|
|
923
|
+
}
|
|
871
924
|
|
|
872
925
|
sendUnifyEvent({
|
|
873
926
|
type: 'session_ready',
|
|
@@ -915,16 +968,19 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
915
968
|
* session_ready so the frontend picks up updated models/config.
|
|
916
969
|
*/
|
|
917
970
|
export async function resetUnifySession() {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
971
|
+
// task-320: abort ALL in-flight controllers (every thread) before
|
|
972
|
+
// tearing down the session. Leaves no dangling round still writing
|
|
973
|
+
// to stdout after shutdown.
|
|
974
|
+
for (const ctrl of abortByThread.values()) {
|
|
975
|
+
try { ctrl.abort(); } catch { /* ignore */ }
|
|
921
976
|
}
|
|
977
|
+
abortByThread.clear();
|
|
922
978
|
if (session) {
|
|
923
979
|
await session.shutdown();
|
|
924
980
|
session = null;
|
|
925
981
|
}
|
|
926
982
|
unifyConversationId = null;
|
|
927
|
-
|
|
983
|
+
messagesByThread.clear();
|
|
928
984
|
|
|
929
985
|
// Re-initialize session immediately so frontend gets updated config
|
|
930
986
|
try {
|
|
@@ -944,11 +1000,13 @@ export async function resetUnifySession() {
|
|
|
944
1000
|
|
|
945
1001
|
unifyConversationId = `unify-${Date.now()}`;
|
|
946
1002
|
|
|
947
|
-
// Restore
|
|
1003
|
+
// Restore per-thread history for LLM context (task-320).
|
|
948
1004
|
const recent = session.conversationStore.loadRecent(50);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1005
|
+
for (const m of recent) {
|
|
1006
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
1007
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
1008
|
+
getThreadMessages(tid).push({ role: m.role, content: m.content });
|
|
1009
|
+
}
|
|
952
1010
|
|
|
953
1011
|
sendUnifyEvent({
|
|
954
1012
|
type: 'session_ready',
|