@yeaft/webchat-agent 0.1.734 → 0.1.738
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/connection/message-router.js +5 -1
- package/package.json +1 -1
- package/unify/conversation/persist.js +74 -0
- package/unify/tools/registry.js +87 -1
- package/unify/tools/types.js +10 -1
- package/unify/web-bridge.js +232 -119
- package/unify/feature-arc.js +0 -437
- package/unify/quick-response.js +0 -229
package/unify/web-bridge.js
CHANGED
|
@@ -50,9 +50,8 @@ import {
|
|
|
50
50
|
compactHistory,
|
|
51
51
|
trimSnapshotForBudget,
|
|
52
52
|
} from './history-compact.js';
|
|
53
|
-
import { createFeatureArc } from './feature-arc.js';
|
|
54
|
-
import { getFeatureStore } from './tools/feature-tools.js';
|
|
55
53
|
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
54
|
+
import { parseSeqFromId } from './conversation/persist.js';
|
|
56
55
|
|
|
57
56
|
/** @type {import('./session.js').Session | null} */
|
|
58
57
|
let session = null;
|
|
@@ -193,6 +192,42 @@ export function broadcastLanguageChange(language) {
|
|
|
193
192
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
194
193
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
195
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Secondary watchdog grace period (ms).
|
|
197
|
+
*
|
|
198
|
+
* After {@link QUERY_TIMEOUT_MS} of silence the per-VP `vpAbort` is fired.
|
|
199
|
+
* That's enough on its own when adapters / tools cooperate with the
|
|
200
|
+
* AbortSignal — the engine throws `AbortError`, runVpTurn's catch emits
|
|
201
|
+
* `result{stopped:true}`, the driver `finally` emits `vp_typing_end`, and
|
|
202
|
+
* the user is unstuck.
|
|
203
|
+
*
|
|
204
|
+
* If a tool ignores `signal` and never resolves, the engine generator's
|
|
205
|
+
* `await tool.execute(...)` is permanently blocked: the abort fires on a
|
|
206
|
+
* controller it never observes, and runVpTurn never returns. The same
|
|
207
|
+
* applies to an adapter `stream()` that ignores `signal` (e.g. a stuck
|
|
208
|
+
* SSE connection) or to tools that legitimately opt out of the per-tool
|
|
209
|
+
* timeout via `timeoutMs <= 0`. The per-tool timeout in
|
|
210
|
+
* {@link import('./tools/registry.js').DEFAULT_TOOL_TIMEOUT_MS}
|
|
211
|
+
* is the primary cure for the tool-ignore-signal case; this bridge-level
|
|
212
|
+
* escalation strictly extends it to cover the adapter and opt-out cases.
|
|
213
|
+
* Without a second-stage escalation the typing dots hang forever —
|
|
214
|
+
* exactly the "halts mid-execution with no turn_end" symptom.
|
|
215
|
+
*
|
|
216
|
+
* The driver loop wraps `await runVpTurn(...)` in a Promise.race against
|
|
217
|
+
* this grace-window timer. If runVpTurn doesn't return within
|
|
218
|
+
* QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS, the driver forces its
|
|
219
|
+
* `finally` block (vp_typing_end + group_message), emits a synthetic
|
|
220
|
+
* `result{stopped:true}` so the frontend leaves its in-flight state,
|
|
221
|
+
* and moves on. The hung tool promise leaks (JS lacks cooperative
|
|
222
|
+
* promise cancellation) but the user-facing turn is closed.
|
|
223
|
+
*
|
|
224
|
+
* 15s is wide enough that legitimate "abort took a moment to propagate"
|
|
225
|
+
* paths (network teardown, finally cleanup) finish first; tight enough
|
|
226
|
+
* that a truly stuck tool doesn't stretch the user-visible stall to
|
|
227
|
+
* minutes.
|
|
228
|
+
*/
|
|
229
|
+
const ESCALATE_AFTER_ABORT_MS = 15_000;
|
|
230
|
+
|
|
196
231
|
/** Virtual conversationId for the Unify session */
|
|
197
232
|
let unifyConversationId = null;
|
|
198
233
|
|
|
@@ -423,7 +458,7 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
423
458
|
} catch { /* never crash WS pipeline */ }
|
|
424
459
|
|
|
425
460
|
try {
|
|
426
|
-
await
|
|
461
|
+
await runVpTurnWithEscalation({
|
|
427
462
|
prompt,
|
|
428
463
|
promptParts,
|
|
429
464
|
groupId,
|
|
@@ -909,15 +944,18 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
909
944
|
*/
|
|
910
945
|
function handleEngineEvent(event, hctx) {
|
|
911
946
|
hctx.resetQueryTimer();
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
|
|
947
|
+
// Sub-agent events may carry their own `featureId` (stamped by the
|
|
948
|
+
// sub-agent runner from the parent's inbound feature scope). Plain
|
|
949
|
+
// VP-turn events have no featureId — auto-feature creation was
|
|
950
|
+
// removed when Track-A / FeatureArc was deleted (2026-05-08).
|
|
951
|
+
const eventFeatureId = typeof event === 'object' && event && typeof event.featureId === 'string'
|
|
952
|
+
? event.featureId
|
|
953
|
+
: null;
|
|
916
954
|
const envelope = {
|
|
917
955
|
groupId: hctx.groupId,
|
|
918
956
|
vpId: hctx.vpId,
|
|
919
957
|
turnId: hctx.turnId,
|
|
920
|
-
...(
|
|
958
|
+
...(eventFeatureId ? { featureId: eventFeatureId } : {}),
|
|
921
959
|
};
|
|
922
960
|
|
|
923
961
|
switch (event.type) {
|
|
@@ -1671,6 +1709,94 @@ async function ensureSessionLoaded() {
|
|
|
1671
1709
|
sendGroupSnapshotBroadcast();
|
|
1672
1710
|
}
|
|
1673
1711
|
|
|
1712
|
+
/**
|
|
1713
|
+
* Wrap {@link runVpTurn} with a hard escalation deadline.
|
|
1714
|
+
*
|
|
1715
|
+
* The first-line defense is the in-turn watchdog inside runVpTurn: at
|
|
1716
|
+
* {@link QUERY_TIMEOUT_MS} of silence it calls `vpAbort.abort()`. When
|
|
1717
|
+
* adapters and tools cooperate with AbortSignal that's enough — the
|
|
1718
|
+
* engine throws AbortError, the catch handler emits `result{stopped:true}`,
|
|
1719
|
+
* and the driver's `finally` emits `vp_typing_end`.
|
|
1720
|
+
*
|
|
1721
|
+
* This wrapper is the second-line defense for the "tool ignores signal"
|
|
1722
|
+
* failure mode. If runVpTurn doesn't return within
|
|
1723
|
+
* QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS we synthesize a clean exit:
|
|
1724
|
+
* emit a synthetic `result{stopped:true}` so the frontend leaves its
|
|
1725
|
+
* in-flight state, log loudly so operators know a tool is stuck, and
|
|
1726
|
+
* resolve. The hung promise leaks (the engine generator is permanently
|
|
1727
|
+
* blocked on a tool that ignores cancellation) but the user-facing turn
|
|
1728
|
+
* is closed and the next message can flow. Resolving the wrapper is
|
|
1729
|
+
* preferred over rejecting because the driver's catch already logs a
|
|
1730
|
+
* warning — we want a single, unambiguous "watchdog escalated" line in
|
|
1731
|
+
* the log instead of layered noise.
|
|
1732
|
+
*
|
|
1733
|
+
* Tool-level timeouts (see registry.js DEFAULT_TOOL_TIMEOUT_MS) are the
|
|
1734
|
+
* real cure: this wrapper should rarely fire because no tool should be
|
|
1735
|
+
* able to block longer than its budget. It exists as belt-and-suspenders
|
|
1736
|
+
* for tools that legitimately disable timeouts (long-running internal
|
|
1737
|
+
* helpers) or for adapter implementations that ignore signal.
|
|
1738
|
+
*/
|
|
1739
|
+
async function runVpTurnWithEscalation(args) {
|
|
1740
|
+
const { groupId, vpId, turnId } = args;
|
|
1741
|
+
const deadlineMs = QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS;
|
|
1742
|
+
await raceWithEscalation(runVpTurn(args), {
|
|
1743
|
+
deadlineMs,
|
|
1744
|
+
onEscalate: () => {
|
|
1745
|
+
console.error(
|
|
1746
|
+
`[Unify] runVpTurn watchdog escalation: VP ${vpId} did not return ${deadlineMs}ms after enqueue — emitting synthetic stop and unblocking driver`,
|
|
1747
|
+
);
|
|
1748
|
+
try {
|
|
1749
|
+
sendUnifyOutput(
|
|
1750
|
+
{ type: 'result', result_text: '', stopped: true },
|
|
1751
|
+
{ groupId, vpId, turnId },
|
|
1752
|
+
);
|
|
1753
|
+
} catch { /* never crash WS pipeline */ }
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Race `inner` against a deadline timer. If `inner` resolves/rejects first,
|
|
1760
|
+
* the timer is cleared and the result of `inner` is returned. If the timer
|
|
1761
|
+
* wins, `onEscalate` is called and the wrapper resolves cleanly — the inner
|
|
1762
|
+
* promise is left dangling (JS has no promise cancellation) but the caller
|
|
1763
|
+
* is unblocked.
|
|
1764
|
+
*
|
|
1765
|
+
* `onEscalate` MUST be synchronous. We swallow synchronous throws so a
|
|
1766
|
+
* torn-down WS pipeline can't crash the watchdog, but a Promise rejection
|
|
1767
|
+
* from an async `onEscalate` would leak past this `catch`.
|
|
1768
|
+
*
|
|
1769
|
+
* Pure helper, no module-level state, exported as `__testRaceWithEscalation`
|
|
1770
|
+
* so the contract can be unit-tested in isolation. Inner errors propagate
|
|
1771
|
+
* (a tool that throws still surfaces through `runVpTurn`'s normal catch).
|
|
1772
|
+
*
|
|
1773
|
+
* @template T
|
|
1774
|
+
* @param {Promise<T>} inner
|
|
1775
|
+
* @param {{ deadlineMs: number, onEscalate: () => void }} opts
|
|
1776
|
+
* @returns {Promise<T|void>}
|
|
1777
|
+
*/
|
|
1778
|
+
async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
|
|
1779
|
+
let escalateTimer = null;
|
|
1780
|
+
const escalation = new Promise((resolve) => {
|
|
1781
|
+
escalateTimer = setTimeout(() => {
|
|
1782
|
+
try { onEscalate(); } catch { /* never throw out of the watchdog */ }
|
|
1783
|
+
resolve();
|
|
1784
|
+
}, deadlineMs);
|
|
1785
|
+
// `unref()` lets a pending escalation timer not hold the Node event
|
|
1786
|
+
// loop open (e.g. during graceful shutdown). Browsers / non-Node
|
|
1787
|
+
// runtimes don't expose it, hence the typeof guard. Node's own
|
|
1788
|
+
// `Timeout.unref()` does not throw, so no try/catch is needed.
|
|
1789
|
+
if (escalateTimer && typeof escalateTimer.unref === 'function') {
|
|
1790
|
+
escalateTimer.unref();
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
try {
|
|
1794
|
+
return await Promise.race([inner, escalation]);
|
|
1795
|
+
} finally {
|
|
1796
|
+
clearTimeout(escalateTimer);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1674
1800
|
/**
|
|
1675
1801
|
* Run a single VP's turn: call engine.query() with the supplied prompt and
|
|
1676
1802
|
* coordinator-bound router, stream events to the frontend, and append the
|
|
@@ -1696,11 +1822,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1696
1822
|
if (!prompt?.trim()) return;
|
|
1697
1823
|
|
|
1698
1824
|
const envelope = { groupId, vpId, turnId };
|
|
1699
|
-
// Arc is declared at outer-try scope so the catch / finally branches
|
|
1700
|
-
// below can call `arc.finalize({status:'aborted'|'error'})` after a
|
|
1701
|
-
// throw escaping the inner try. It's null until the inner try
|
|
1702
|
-
// populates it; all catch-side calls guard with `arc?.finalize?.`.
|
|
1703
|
-
let arc = null;
|
|
1704
1825
|
|
|
1705
1826
|
try {
|
|
1706
1827
|
if (session?.dreamScheduler) {
|
|
@@ -1726,12 +1847,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1726
1847
|
const assistantTextParts = [];
|
|
1727
1848
|
const toolCallsAccum = [];
|
|
1728
1849
|
const toolResultsAccum = [];
|
|
1729
|
-
// PR-4 (review fix): hoist `vpEngine` so the `finally` can clear
|
|
1730
|
-
// the per-turn featureId accessor on the SAME engine instance
|
|
1731
|
-
// we installed it on — even if the VP was kicked or its group
|
|
1732
|
-
// deleted mid-turn (both code paths call `vpEngines.delete(...)`).
|
|
1733
|
-
// Calling `getOrCreateVpEngine` again from `finally` would
|
|
1734
|
-
// resurrect a zombie engine for a VP that no longer exists.
|
|
1735
1850
|
let vpEngine = null;
|
|
1736
1851
|
|
|
1737
1852
|
// task-707: per-VP engine + persistent group coord. The coord is
|
|
@@ -1748,74 +1863,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1748
1863
|
envelope: inboundEnvelope,
|
|
1749
1864
|
});
|
|
1750
1865
|
|
|
1751
|
-
// ── Dual-track feature arc ──
|
|
1752
|
-
// Track A (quick-response) runs concurrently against the same
|
|
1753
|
-
// primary model with a non-looping single call; its preview is
|
|
1754
|
-
// surfaced to the user immediately via `quick_preview` so they
|
|
1755
|
-
// see *something* within ~1s. Three signals (Track A intent,
|
|
1756
|
-
// ≥3 engine loops, key tool call) auto-create a Feature record
|
|
1757
|
-
// and the wire envelope starts tagging emits with `featureId`,
|
|
1758
|
-
// letting the frontend fold subsequent messages into a pill.
|
|
1759
|
-
arc = createFeatureArc({
|
|
1760
|
-
adapter: session?.adapter || null,
|
|
1761
|
-
model: session?.config?.model || null,
|
|
1762
|
-
featureStore: getFeatureStore(),
|
|
1763
|
-
prompt,
|
|
1764
|
-
vpId,
|
|
1765
|
-
groupId: groupId || null,
|
|
1766
|
-
turnId,
|
|
1767
|
-
vpDisplayName: queryOpts?.vpPersona?.displayName || vpId,
|
|
1768
|
-
language: session?.config?.language || 'en',
|
|
1769
|
-
signal: vpAbort.signal,
|
|
1770
|
-
emit: {
|
|
1771
|
-
quickPreview: ({ intent, preview }) => {
|
|
1772
|
-
sendUnifyEvent({
|
|
1773
|
-
type: 'quick_preview',
|
|
1774
|
-
intent,
|
|
1775
|
-
preview,
|
|
1776
|
-
vpId,
|
|
1777
|
-
turnId,
|
|
1778
|
-
}, envelope);
|
|
1779
|
-
},
|
|
1780
|
-
featureStarted: ({ featureId, title, trigger, toolName }) => {
|
|
1781
|
-
sendUnifyEvent({
|
|
1782
|
-
type: 'feature_started',
|
|
1783
|
-
featureId,
|
|
1784
|
-
title,
|
|
1785
|
-
trigger, // 'quick' | 'turns' | 'tool'
|
|
1786
|
-
toolName: toolName || null,
|
|
1787
|
-
vpId,
|
|
1788
|
-
turnId,
|
|
1789
|
-
}, { ...envelope, featureId });
|
|
1790
|
-
},
|
|
1791
|
-
featureCompleted: ({ featureId, summary, status }) => {
|
|
1792
|
-
sendUnifyEvent({
|
|
1793
|
-
type: 'feature_completed',
|
|
1794
|
-
featureId,
|
|
1795
|
-
summary,
|
|
1796
|
-
status, // 'completed' | 'aborted' | 'error'
|
|
1797
|
-
vpId,
|
|
1798
|
-
turnId,
|
|
1799
|
-
}, { ...envelope, featureId });
|
|
1800
|
-
},
|
|
1801
|
-
},
|
|
1802
|
-
});
|
|
1803
|
-
// Fire-and-forget — Track A produces its preview / decision when
|
|
1804
|
-
// ready; the main engine loop must not be held back waiting for it.
|
|
1805
|
-
arc.startTrackA();
|
|
1806
|
-
|
|
1807
|
-
// PR-4: let sub-agents spawned during this turn inherit the
|
|
1808
|
-
// parent's active featureId. Read lazily inside the engine's
|
|
1809
|
-
// parentEngineDeps so a feature that opens AFTER a sub-agent
|
|
1810
|
-
// spawns still tags the sub-agent's later events. Cleared in the
|
|
1811
|
-
// `finally` below so a stale `arc` reference doesn't leak into
|
|
1812
|
-
// the next turn.
|
|
1813
1866
|
vpEngine = getOrCreateVpEngine(groupId, vpId);
|
|
1814
|
-
if (typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
|
|
1815
|
-
vpEngine.setCurrentFeatureIdAccessor(() => {
|
|
1816
|
-
try { return arc?.getFeatureId?.() || null; } catch { return null; }
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
1867
|
|
|
1820
1868
|
const handlerCtx = {
|
|
1821
1869
|
assistantTextParts,
|
|
@@ -1825,9 +1873,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1825
1873
|
groupId,
|
|
1826
1874
|
vpId,
|
|
1827
1875
|
turnId,
|
|
1828
|
-
// Lets handleEngineEvent stamp the latest featureId on each
|
|
1829
|
-
// outgoing envelope; the arc may publish it mid-turn.
|
|
1830
|
-
getFeatureId: () => arc.getFeatureId(),
|
|
1831
1876
|
};
|
|
1832
1877
|
// Always trim the snapshot before passing to engine.query. This is
|
|
1833
1878
|
// the second-line defense (history-compact only fires above 30K
|
|
@@ -1852,26 +1897,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1852
1897
|
...queryOpts,
|
|
1853
1898
|
})) {
|
|
1854
1899
|
resetQueryTimer();
|
|
1855
|
-
// Arc observes BEFORE dispatch so featureId (if just published)
|
|
1856
|
-
// is available when handleEngineEvent stamps the envelope.
|
|
1857
|
-
try { arc.observeEvent(event); } catch (err) {
|
|
1858
|
-
console.warn('[FeatureArc] observe failed:', err?.message || err);
|
|
1859
|
-
}
|
|
1860
1900
|
handleEngineEvent(event, handlerCtx);
|
|
1861
1901
|
}
|
|
1862
1902
|
|
|
1863
1903
|
// Turn completed — atomically append this VP's output to shared history.
|
|
1864
1904
|
appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
|
|
1865
1905
|
|
|
1866
|
-
// Close the arc: if a feature was opened during the turn, run the
|
|
1867
|
-
// summary call and write status='completed' back to FeatureStore.
|
|
1868
|
-
// Awaited so the `feature_completed` event reaches the frontend
|
|
1869
|
-
// before the final 'result' bubble (UI ordering matters: the pill
|
|
1870
|
-
// should reach its done state before the turn is marked done).
|
|
1871
|
-
try { await arc.finalize({ status: 'completed' }); } catch (err) {
|
|
1872
|
-
console.warn('[FeatureArc] finalize failed:', err?.message || err);
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
1906
|
sendUnifyOutput({
|
|
1876
1907
|
type: 'assistant',
|
|
1877
1908
|
message: { content: [] },
|
|
@@ -1882,25 +1913,10 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1882
1913
|
}, envelope);
|
|
1883
1914
|
} finally {
|
|
1884
1915
|
if (queryTimer) clearTimeout(queryTimer);
|
|
1885
|
-
// PR-4 (review fix): clear the accessor on the SAME engine
|
|
1886
|
-
// instance we installed it on. Reusing the captured reference
|
|
1887
|
-
// (instead of calling getOrCreateVpEngine again) avoids
|
|
1888
|
-
// resurrecting a zombie engine if the VP/group was torn down
|
|
1889
|
-
// mid-turn. `vpEngine` is null only when the install path threw
|
|
1890
|
-
// before the engine lookup (very early failure) — in that case
|
|
1891
|
-
// there's nothing to clear.
|
|
1892
|
-
try {
|
|
1893
|
-
if (vpEngine && typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
|
|
1894
|
-
vpEngine.setCurrentFeatureIdAccessor(null);
|
|
1895
|
-
}
|
|
1896
|
-
} catch { /* best-effort */ }
|
|
1897
1916
|
}
|
|
1898
1917
|
} catch (err) {
|
|
1899
1918
|
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
1900
1919
|
if (isAbort) {
|
|
1901
|
-
// Best-effort close: mark the feature aborted so the frontend pill
|
|
1902
|
-
// settles into the right terminal state instead of staying active.
|
|
1903
|
-
try { await arc?.finalize?.({ status: 'aborted' }); } catch { /* ignore */ }
|
|
1904
1920
|
sendUnifyOutput({
|
|
1905
1921
|
type: 'result',
|
|
1906
1922
|
result_text: '',
|
|
@@ -1910,7 +1926,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1910
1926
|
}
|
|
1911
1927
|
|
|
1912
1928
|
console.error('[Unify] query error:', err);
|
|
1913
|
-
try { await arc?.finalize?.({ status: 'error' }); } catch { /* ignore */ }
|
|
1914
1929
|
|
|
1915
1930
|
if (isPermissionErrorMsg(err.message)) {
|
|
1916
1931
|
if (!_permissionDiagnosticSent) {
|
|
@@ -2321,6 +2336,13 @@ export function __testGetRegisteredThreadIds() {
|
|
|
2321
2336
|
return currentAbortCtrl && !currentAbortCtrl.signal.aborted ? ['main'] : [];
|
|
2322
2337
|
}
|
|
2323
2338
|
|
|
2339
|
+
/**
|
|
2340
|
+
* Test-only: expose the bridge-level escalation helper. Lets tests verify
|
|
2341
|
+
* the "tool ignored signal → wrapper escalates" contract without booting a
|
|
2342
|
+
* full session. See `test/agent/unify/web-bridge-escalation.test.js`.
|
|
2343
|
+
*/
|
|
2344
|
+
export const __testRaceWithEscalation = raceWithEscalation;
|
|
2345
|
+
|
|
2324
2346
|
/**
|
|
2325
2347
|
* Manual dream trigger from VP detail page.
|
|
2326
2348
|
*/
|
|
@@ -2594,6 +2616,32 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2594
2616
|
}
|
|
2595
2617
|
}
|
|
2596
2618
|
|
|
2619
|
+
// Compute the pagination cursor for the bootstrap load so the frontend
|
|
2620
|
+
// knows whether a "Load older messages" hint should be shown and where
|
|
2621
|
+
// to start the next page. The cursor is the seq of the oldest replayed
|
|
2622
|
+
// message; `hasMore` is true iff there's an earlier message in the
|
|
2623
|
+
// group that we did NOT replay.
|
|
2624
|
+
let hasMore = false;
|
|
2625
|
+
let oldestSeq = null;
|
|
2626
|
+
if (groupId && messages.length > 0) {
|
|
2627
|
+
const firstId = messages[0].id;
|
|
2628
|
+
const seq = parseSeqFromId(firstId);
|
|
2629
|
+
// Defend against malformed ids: a NaN cursor would round-trip back as
|
|
2630
|
+
// a poison `beforeSeq` and degrade subsequent paginations to "give me
|
|
2631
|
+
// the newest page again". Surface as null instead.
|
|
2632
|
+
oldestSeq = Number.isFinite(seq) ? seq : null;
|
|
2633
|
+
if (oldestSeq != null) {
|
|
2634
|
+
// Consult the store for whether anything older exists in the same
|
|
2635
|
+
// group. Cheap: a single extra `loadOlderByGroup` with turns=1.
|
|
2636
|
+
try {
|
|
2637
|
+
const probe = session.conversationStore.loadOlderByGroup(groupId, oldestSeq, 1);
|
|
2638
|
+
hasMore = probe.messages.length > 0;
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
console.error('[Unify] history-load probe failed:', err.message);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2597
2645
|
sendUnifyEvent({
|
|
2598
2646
|
type: 'history_loaded',
|
|
2599
2647
|
count: messages.length,
|
|
@@ -2601,6 +2649,71 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2601
2649
|
totalHot: session.conversationStore.countHot(),
|
|
2602
2650
|
totalCold: session.conversationStore.countCold(),
|
|
2603
2651
|
groupId,
|
|
2652
|
+
hasMore,
|
|
2653
|
+
oldestSeq,
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* Handle a "load older messages" pagination request. Reads `turns` more
|
|
2659
|
+
* turns of history strictly older than `beforeSeq` for `groupId`, and
|
|
2660
|
+
* emits them in a single `unify_history_chunk` envelope (NOT a
|
|
2661
|
+
* `unify_output` — that pipeline appends, but the frontend needs to
|
|
2662
|
+
* PREPEND these older messages above what it already has).
|
|
2663
|
+
*
|
|
2664
|
+
* Tool replay is NOT included in this PR — same projection as
|
|
2665
|
+
* `handleUnifyLoadHistory` (user / assistant text only). On any internal
|
|
2666
|
+
* failure we still emit an empty chunk so the spinner clears.
|
|
2667
|
+
*
|
|
2668
|
+
* @param {object} msg — { groupId, beforeSeq, turns }
|
|
2669
|
+
*/
|
|
2670
|
+
export async function handleUnifyLoadMoreHistory(msg) {
|
|
2671
|
+
const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
|
|
2672
|
+
const emit = (payload) => sendToServer({
|
|
2673
|
+
type: 'unify_history_chunk',
|
|
2674
|
+
conversationId: unifyConversationId,
|
|
2675
|
+
groupId,
|
|
2676
|
+
...payload,
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
if (!session || !groupId) {
|
|
2680
|
+
emit({ messages: [], oldestSeq: null, hasMore: false });
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
|
|
2685
|
+
const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 20;
|
|
2686
|
+
|
|
2687
|
+
let result;
|
|
2688
|
+
try {
|
|
2689
|
+
result = session.conversationStore.loadOlderByGroup(groupId, beforeSeq, turns);
|
|
2690
|
+
} catch (err) {
|
|
2691
|
+
console.error('[Unify] loadOlderByGroup failed:', err.message);
|
|
2692
|
+
result = { messages: [], oldestSeq: null, hasMore: false };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// Wire shape mirrors handleUnifyLoadHistory's projection: only user /
|
|
2696
|
+
// assistant text rows. Tool_use / tool_result replay is out of scope
|
|
2697
|
+
// for this PR (today's bootstrap path drops them too).
|
|
2698
|
+
//
|
|
2699
|
+
// We intentionally do NOT ship `id` or `time` over the wire:
|
|
2700
|
+
// `handleUnifyHistoryChunk` reads only role / content / groupId. The
|
|
2701
|
+
// pagination cursor (`oldestSeq`) is sent once at the envelope level,
|
|
2702
|
+
// so per-row ids are dead weight. `time` would be useful if we render
|
|
2703
|
+
// "5 days ago" stamps on older history rows — when that ships, add it
|
|
2704
|
+
// back here and consume it in conversationHandler.
|
|
2705
|
+
const projected = (result.messages || [])
|
|
2706
|
+
.filter(m => m && (m.role === 'user' || m.role === 'assistant'))
|
|
2707
|
+
.map(m => ({
|
|
2708
|
+
role: m.role,
|
|
2709
|
+
content: m.content,
|
|
2710
|
+
groupId: m.groupId || null,
|
|
2711
|
+
}));
|
|
2712
|
+
|
|
2713
|
+
emit({
|
|
2714
|
+
messages: projected,
|
|
2715
|
+
oldestSeq: result.oldestSeq,
|
|
2716
|
+
hasMore: !!result.hasMore,
|
|
2604
2717
|
});
|
|
2605
2718
|
}
|
|
2606
2719
|
|