dsh-monkeyai-sessions 0.1.1 → 0.1.3
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/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/host/monkeyai-remote.js +205 -23
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ All three request `device:connect`, `session:read`, and `session:control` throug
|
|
|
39
39
|
|
|
40
40
|
## Sessions
|
|
41
41
|
|
|
42
|
-
Full conversation history is loaded over the runtime's HTTP read channel (`POST {http_base}/read/session` with `_monkeyai/session/messages`, paginated by cursor) instead of the bounded ACP `session/load` replay, so long sessions show every turn including the latest replies. While a turn is running the composer shows a stop button that sends `session/cancel`; the model switcher uses `_monkeyai/session/model/set` on the live session connection. Assistant messages render lightweight markdown (code blocks, inline code, bold, headings, lists).
|
|
42
|
+
Full conversation history is loaded over the runtime's HTTP read channel (`POST {http_base}/read/session` with `_monkeyai/session/messages`, paginated by cursor) instead of the bounded ACP `session/load` replay, so long sessions show every turn including the latest replies. After mounting the session with the same `historyHTTP` capabilities as the MonkeyAI client, the panel subscribes to ACP live updates: messages and assistant chunks started in the MonkeyAI desktop client appear as they stream, without refreshing. For large replies persisted by the runtime, `history_changed` triggers a cursor-based HTTP delta refresh and replaces any partial snapshot. While a turn is running the composer shows a stop button that sends `session/cancel`; the model switcher uses `_monkeyai/session/model/set` on the live session connection. Assistant messages render lightweight markdown (code blocks, inline code, bold, headings, lists).
|
|
43
43
|
|
|
44
44
|
## Installation
|
|
45
45
|
|
|
@@ -61,7 +61,7 @@ From a checkout:
|
|
|
61
61
|
npm install
|
|
62
62
|
npm run build
|
|
63
63
|
npm pack
|
|
64
|
-
dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.
|
|
64
|
+
dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.3.tgz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Verified against a real DSH `0.2.2-rc.1` web profile and a live MonkeyAI runtime: authorization, full history loading, prompt delivery, model switching, and turn cancellation all work end to end.
|
package/README.zh.md
CHANGED
|
@@ -28,7 +28,7 @@ https://artenx.github.io/dsh-monkeyai-sessions/index.json
|
|
|
28
28
|
npm install
|
|
29
29
|
npm run build
|
|
30
30
|
npm pack
|
|
31
|
-
dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.
|
|
31
|
+
dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.3.tgz
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
已在真实的 DSH `0.2.2-rc.1` web profile 与在线 MonkeyAI runtime 上端到端验证:授权、完整历史加载、指令下发、模型切换、回合取消均正常工作。
|
|
@@ -45,7 +45,7 @@ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.1.tgz
|
|
|
45
45
|
|
|
46
46
|
## 会话能力
|
|
47
47
|
|
|
48
|
-
完整对话历史通过 runtime 的 HTTP 读取通道加载(`POST {http_base}/read/session`,方法 `_monkeyai/session/messages`,按游标翻页),而不是 ACP `session/load`
|
|
48
|
+
完整对话历史通过 runtime 的 HTTP 读取通道加载(`POST {http_base}/read/session`,方法 `_monkeyai/session/messages`,按游标翻页),而不是 ACP `session/load` 的有界回放,因此长会话能看到每一轮内容,包括最新回复。会话使用与 MonkeyAI 客户端相同的 `historyHTTP` 能力挂载后,面板会订阅 ACP 实时更新:在 MonkeyAI 桌面客户端发起的用户消息和助手回复都会边生成边显示,无需刷新。对于 runtime 先持久化再通知的超大回复,收到 `history_changed` 后会按游标增量读取,并用完整快照补齐部分内容。
|
|
49
49
|
|
|
50
50
|
回合执行期间输入框旁会显示停止按钮,点击发送 `session/cancel`;模型切换在会话自己的连接上调用 `_monkeyai/session/model/set`。助手消息支持轻量 Markdown 渲染(代码块、行内代码、粗体、标题、列表)。
|
|
51
51
|
|
package/package.json
CHANGED
|
@@ -390,6 +390,45 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
390
390
|
return this.exchangeAuthorization(auth, callbackUrl)
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// The runtime requires an expectedRevision on every configuration write and
|
|
394
|
+
// rejects a stale one with configuration_conflict. The MonkeyAI client (or
|
|
395
|
+
// another panel) can advance the revision at any time, so read the current
|
|
396
|
+
// value from the conflict payload — or from the HTTP session state when we
|
|
397
|
+
// have none — and retry instead of failing the switch.
|
|
398
|
+
async setSessionModel({ acp, sessionId, model, httpBase, ticket, revision, signal, onRevision }) {
|
|
399
|
+
let expected = revision
|
|
400
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
401
|
+
if (!Number.isSafeInteger(expected)) {
|
|
402
|
+
expected = await readSessionConfigurationRevision({ httpBase, ticket, sessionId, signal })
|
|
403
|
+
}
|
|
404
|
+
if (!Number.isSafeInteger(expected)) throw new Error('无法读取会话配置版本,暂时不能切换模型')
|
|
405
|
+
try {
|
|
406
|
+
const result = await acp.request('_monkeyai/session/model/set', {
|
|
407
|
+
sessionId,
|
|
408
|
+
model,
|
|
409
|
+
_meta: { expectedRevision: expected },
|
|
410
|
+
}, { signal, timeoutMs: 120_000 })
|
|
411
|
+
const next = result?._meta?.configurationRevision
|
|
412
|
+
if (Number.isSafeInteger(next)) onRevision(next)
|
|
413
|
+
return typeof result?.model === 'string' ? result.model : model
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (signal?.aborted) throw error
|
|
416
|
+
const current = error?.data?.configurationRevision
|
|
417
|
+
if (error?.data?.reason === 'configuration_conflict' && Number.isSafeInteger(current) && current !== expected) {
|
|
418
|
+
expected = current
|
|
419
|
+
onRevision(current)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
if (error?.data?.reason === 'configuration_version_required') {
|
|
423
|
+
expected = undefined
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
throw error
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
throw new Error('会话配置持续被其他客户端修改,请重试切换模型')
|
|
430
|
+
}
|
|
431
|
+
|
|
393
432
|
async exchangeAuthorization(auth, callbackUrl) {
|
|
394
433
|
const callback = parseAuthorizationCallback(callbackUrl, auth)
|
|
395
434
|
const tokens = await requestJson(auth.serverOrigin, '/oauth/token', {
|
|
@@ -650,6 +689,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
650
689
|
let reconnectsUsed = 0
|
|
651
690
|
const MAX_REPLAY_RECONNECTS = 32
|
|
652
691
|
let ticketValue
|
|
692
|
+
let ticketHttpBase
|
|
653
693
|
let loadCompleted = false
|
|
654
694
|
let currentLoadedModel = undefined
|
|
655
695
|
const aborted = new Promise(resolve => {
|
|
@@ -687,10 +727,12 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
687
727
|
// for close detection but suppressed from the event stream to avoid
|
|
688
728
|
// duplicating text the projection already folded in.
|
|
689
729
|
const httpHistoryMessageIds = new Set()
|
|
690
|
-
//
|
|
691
|
-
//
|
|
730
|
+
// The full history was loaded over HTTP. Keep the message ids so ACP
|
|
731
|
+
// replay duplicates can be suppressed and history_changed notifications
|
|
732
|
+
// can fetch only newly persisted snapshots.
|
|
692
733
|
let httpHistoryComplete = false
|
|
693
734
|
let httpConfigurationRevision
|
|
735
|
+
let historyRefreshPromise = Promise.resolve()
|
|
694
736
|
// history_loaded can be triggered by both the HTTP history channel and
|
|
695
737
|
// the ACP load path; the stream must carry it exactly once.
|
|
696
738
|
let historyLoadedEmitted = false
|
|
@@ -699,6 +741,26 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
699
741
|
historyLoadedEmitted = true
|
|
700
742
|
emit({ type: 'history_loaded', sessionId: selectedSession, ...(model === undefined ? {} : { model }) })
|
|
701
743
|
}
|
|
744
|
+
const refreshPersistedHistory = async changedMessageId => {
|
|
745
|
+
if (!ticketHttpBase || !ticketValue) return
|
|
746
|
+
const delta = await fetchSessionHistoryDelta({
|
|
747
|
+
httpBase: ticketHttpBase,
|
|
748
|
+
ticket: ticketValue,
|
|
749
|
+
sessionId: selectedSession,
|
|
750
|
+
signal,
|
|
751
|
+
knownMessageIds: httpHistoryMessageIds,
|
|
752
|
+
changedMessageId,
|
|
753
|
+
})
|
|
754
|
+
if (Number.isSafeInteger(delta.configurationRevision)) {
|
|
755
|
+
httpConfigurationRevision = delta.configurationRevision
|
|
756
|
+
}
|
|
757
|
+
for (const message of delta.messages) {
|
|
758
|
+
httpHistoryMessageIds.add(message.id)
|
|
759
|
+
for (const frame of historyMessageToFrames(selectedSession, message, delta.toolCalls, { snapshot: true })) {
|
|
760
|
+
await emit(frame)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
702
764
|
|
|
703
765
|
function replayKey(params) {
|
|
704
766
|
const update = params?.update
|
|
@@ -745,6 +807,22 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
745
807
|
data: event.params,
|
|
746
808
|
})
|
|
747
809
|
} else {
|
|
810
|
+
if (event.method === '_monkeyai/session/history_changed' && httpHistoryComplete && ticketHttpBase) {
|
|
811
|
+
// Large live message bodies are persisted instead of streamed as
|
|
812
|
+
// chunks. Match the desktop client: refresh the newest HTTP pages
|
|
813
|
+
// and merge the resulting message snapshots into the projection.
|
|
814
|
+
historyRefreshPromise = historyRefreshPromise.then(() => refreshPersistedHistory(
|
|
815
|
+
typeof event.params?.messageId === 'string' ? event.params.messageId : undefined,
|
|
816
|
+
)).catch(error => {
|
|
817
|
+
if (!signal.aborted) emit({
|
|
818
|
+
type: 'error',
|
|
819
|
+
sessionId: selectedSession,
|
|
820
|
+
code: 'history_refresh_failed',
|
|
821
|
+
message: error instanceof Error ? error.message : '刷新实时会话内容失败',
|
|
822
|
+
})
|
|
823
|
+
})
|
|
824
|
+
return
|
|
825
|
+
}
|
|
748
826
|
if (withLoad && !loadCompleted) {
|
|
749
827
|
const key = replayKey(event.params)
|
|
750
828
|
if (key !== null) {
|
|
@@ -756,7 +834,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
756
834
|
}
|
|
757
835
|
// Suppress replayed messages already delivered over HTTP, on every
|
|
758
836
|
// connection generation (re-mounts replay the same window again).
|
|
759
|
-
if (withLoad) {
|
|
837
|
+
if (withLoad && event.params?._meta?.replay === true) {
|
|
760
838
|
const replayMessageId = (typeof event.params?._meta?.messageId === 'string' && event.params._meta.messageId !== '')
|
|
761
839
|
? event.params._meta.messageId
|
|
762
840
|
: (typeof event.params?.update?.messageId === 'string' ? event.params.update.messageId : '')
|
|
@@ -848,7 +926,22 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
848
926
|
const initialized = await acp.request('initialize', {
|
|
849
927
|
protocolVersion: 1,
|
|
850
928
|
clientInfo: ACP_CLIENT_INFO,
|
|
851
|
-
|
|
929
|
+
// Match the MonkeyAI desktop client. historyHTTP opts this connection
|
|
930
|
+
// into HTTP-backed snapshots and history_changed notifications;
|
|
931
|
+
// session/load then mounts the live subscription without replaying the
|
|
932
|
+
// oversized history over ACP.
|
|
933
|
+
clientCapabilities: {
|
|
934
|
+
_meta: {
|
|
935
|
+
messageSnapshots: true,
|
|
936
|
+
interactionReplies: true,
|
|
937
|
+
historyHTTP: true,
|
|
938
|
+
queueHTTP: true,
|
|
939
|
+
historyRefresh: true,
|
|
940
|
+
configHTTP: true,
|
|
941
|
+
stateHTTP: true,
|
|
942
|
+
interactionHTTP: true,
|
|
943
|
+
},
|
|
944
|
+
},
|
|
852
945
|
}, { signal })
|
|
853
946
|
if (initialized?.agentCapabilities?.loadSession !== true) {
|
|
854
947
|
throw new Error('MonkeyAI runtime does not advertise ACP session history loading')
|
|
@@ -859,14 +952,11 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
859
952
|
return
|
|
860
953
|
}
|
|
861
954
|
emit({ type: 'ready', sessionId: selectedSession })
|
|
862
|
-
if (httpHistoryComplete) {
|
|
863
|
-
// History came over HTTP; prompts and model switches work on a plain
|
|
864
|
-
// initialized connection. Skip session/load: its bounded replay
|
|
865
|
-
// always dies mid-stream and would churn reconnects for nothing.
|
|
866
|
-
loadCompleted = true
|
|
867
|
-
return
|
|
868
|
-
}
|
|
869
955
|
try {
|
|
956
|
+
// With historyHTTP advertised this mounts the session for live
|
|
957
|
+
// notifications while leaving persisted history reads to the HTTP
|
|
958
|
+
// cursor API above. Older runtimes may still replay a bounded window;
|
|
959
|
+
// the message-id filter suppresses those duplicate snapshots.
|
|
870
960
|
const loadResult = await acp.request('session/load', {
|
|
871
961
|
sessionId: selectedSession,
|
|
872
962
|
cwd,
|
|
@@ -875,6 +965,13 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
875
965
|
loadCompleted = true
|
|
876
966
|
currentLoadedModel = typeof loadResult?._meta?.model === 'string' ? loadResult._meta.model : currentLoadedModel
|
|
877
967
|
emitHistoryLoaded(currentLoadedModel)
|
|
968
|
+
// Close the gap between the initial full-history read and mounting the
|
|
969
|
+
// live subscription. A turn can start during that interval; snapshots
|
|
970
|
+
// replace any partial chunks that arrived before session/load resolved.
|
|
971
|
+
if (httpHistoryComplete) {
|
|
972
|
+
historyRefreshPromise = historyRefreshPromise.then(() => refreshPersistedHistory())
|
|
973
|
+
await historyRefreshPromise
|
|
974
|
+
}
|
|
878
975
|
} catch (error) {
|
|
879
976
|
if (signal?.aborted) throw error
|
|
880
977
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -919,6 +1016,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
919
1016
|
const token = await this.refreshAccessToken(accountId, signal)
|
|
920
1017
|
const ticket = await this.requestConnectionTicket(accountId, grantRef, token, target, { signal })
|
|
921
1018
|
ticketValue = ticket.ticket
|
|
1019
|
+
ticketHttpBase = ticket.httpBase
|
|
922
1020
|
if (signal.aborted) throw signal.reason ?? new Error('远程会话已取消')
|
|
923
1021
|
|
|
924
1022
|
// Load the full history over the runtime's HTTP read channel before the
|
|
@@ -977,14 +1075,16 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
|
|
|
977
1075
|
|
|
978
1076
|
if (command.type === 'switch_model') {
|
|
979
1077
|
try {
|
|
980
|
-
const
|
|
1078
|
+
const model = await this.setSessionModel({
|
|
1079
|
+
acp: currentAcp,
|
|
981
1080
|
sessionId: selectedSession,
|
|
982
1081
|
model: command.model,
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1082
|
+
httpBase: ticket.httpBase,
|
|
1083
|
+
ticket: ticketValue,
|
|
1084
|
+
revision: httpConfigurationRevision,
|
|
1085
|
+
signal,
|
|
1086
|
+
onRevision: value => { httpConfigurationRevision = value },
|
|
1087
|
+
})
|
|
988
1088
|
currentLoadedModel = model
|
|
989
1089
|
emit({ type: 'model_switched', sessionId: selectedSession, data: { model } })
|
|
990
1090
|
} catch (switchError) {
|
|
@@ -1223,6 +1323,16 @@ async function readHistoryBodyText(response, maxBytes = HISTORY_PAGE_MAX_BYTES)
|
|
|
1223
1323
|
return new TextDecoder().decode(joined)
|
|
1224
1324
|
}
|
|
1225
1325
|
|
|
1326
|
+
// Reads the session's current configuration revision over the HTTP read
|
|
1327
|
+
// channel. Configuration writes must carry it, and the value changes whenever
|
|
1328
|
+
// any client edits the session.
|
|
1329
|
+
async function readSessionConfigurationRevision({ httpBase, ticket, sessionId, signal }) {
|
|
1330
|
+
const timeout = AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])])
|
|
1331
|
+
const result = await readSessionHistoryPage({ httpBase, ticket, sessionId, limit: 1, signal: timeout })
|
|
1332
|
+
const revision = result.session?.configurationRevision
|
|
1333
|
+
return Number.isSafeInteger(revision) ? revision : undefined
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1226
1336
|
async function readSessionHistoryPage({ httpBase, ticket, sessionId, cursor, direction, limit = 50, signal }) {
|
|
1227
1337
|
const params = { sessionId, limit, ...(cursor ? { cursor, direction } : {}) }
|
|
1228
1338
|
const response = await fetch(`${httpBase.endsWith('/') ? httpBase : `${httpBase}/`}read/session`, {
|
|
@@ -1261,7 +1371,7 @@ function historyMessageText(parts) {
|
|
|
1261
1371
|
// Converts one history message snapshot into the same notification frame
|
|
1262
1372
|
// shape the ACP replay produces, so the shared projection layer renders both
|
|
1263
1373
|
// identically. Message ids match ACP replay messageIds (u-turn-N / a-turn-N).
|
|
1264
|
-
function historyMessageToFrames(sessionId, message, toolCallsById) {
|
|
1374
|
+
function historyMessageToFrames(sessionId, message, toolCallsById, { snapshot = false } = {}) {
|
|
1265
1375
|
const frames = []
|
|
1266
1376
|
const meta = { messageId: message.id, replay: true }
|
|
1267
1377
|
const emitUpdate = update => {
|
|
@@ -1275,16 +1385,47 @@ function historyMessageToFrames(sessionId, message, toolCallsById) {
|
|
|
1275
1385
|
const content = fileLinks.length > 0
|
|
1276
1386
|
? [...(text === '' ? [] : [{ type: 'text', text }]), ...fileLinks]
|
|
1277
1387
|
: (text === '' ? undefined : { type: 'text', text })
|
|
1278
|
-
if (content !== undefined) emitUpdate({ sessionUpdate: 'user_message_chunk', content })
|
|
1388
|
+
if (content !== undefined) emitUpdate({ sessionUpdate: snapshot ? 'user_message' : 'user_message_chunk', content })
|
|
1279
1389
|
return frames
|
|
1280
1390
|
}
|
|
1281
|
-
|
|
1391
|
+
const parts = Array.isArray(message.parts) ? message.parts : []
|
|
1392
|
+
const snapshotThought = snapshot
|
|
1393
|
+
? parts.filter(part => part?.type === 'thought' && typeof part.text === 'string').map(part => part.text).join('')
|
|
1394
|
+
: ''
|
|
1395
|
+
const snapshotText = snapshot
|
|
1396
|
+
? parts.filter(part => part?.type === 'text' && typeof part.text === 'string').map(part => part.text)
|
|
1397
|
+
.concat(parts.filter(part => part?.type === 'error' && typeof part.reason === 'string').map(part => `[error] ${part.reason}`)).join('')
|
|
1398
|
+
: ''
|
|
1399
|
+
let snapshotThoughtEmitted = false
|
|
1400
|
+
let snapshotTextEmitted = false
|
|
1401
|
+
for (const part of parts) {
|
|
1282
1402
|
if (part?.type === 'thought' && typeof part.text === 'string' && part.text !== '') {
|
|
1283
|
-
|
|
1403
|
+
if (snapshot) {
|
|
1404
|
+
if (!snapshotThoughtEmitted && snapshotThought !== '') {
|
|
1405
|
+
emitUpdate({ sessionUpdate: 'agent_thought', content: { type: 'text', text: snapshotThought } })
|
|
1406
|
+
snapshotThoughtEmitted = true
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
emitUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: part.text } })
|
|
1410
|
+
}
|
|
1284
1411
|
} else if (part?.type === 'text' && typeof part.text === 'string' && part.text !== '') {
|
|
1285
|
-
|
|
1412
|
+
if (snapshot) {
|
|
1413
|
+
if (!snapshotTextEmitted && snapshotText !== '') {
|
|
1414
|
+
emitUpdate({ sessionUpdate: 'agent_message', content: { type: 'text', text: snapshotText } })
|
|
1415
|
+
snapshotTextEmitted = true
|
|
1416
|
+
}
|
|
1417
|
+
} else {
|
|
1418
|
+
emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: part.text } })
|
|
1419
|
+
}
|
|
1286
1420
|
} else if (part?.type === 'error' && typeof part.reason === 'string' && part.reason !== '') {
|
|
1287
|
-
|
|
1421
|
+
if (snapshot) {
|
|
1422
|
+
if (!snapshotTextEmitted && snapshotText !== '') {
|
|
1423
|
+
emitUpdate({ sessionUpdate: 'agent_message', content: { type: 'text', text: snapshotText } })
|
|
1424
|
+
snapshotTextEmitted = true
|
|
1425
|
+
}
|
|
1426
|
+
} else {
|
|
1427
|
+
emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: `[error] ${part.reason}` } })
|
|
1428
|
+
}
|
|
1288
1429
|
} else if (part?.type === 'file' && typeof part.uri === 'string') {
|
|
1289
1430
|
emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'resource_link', name: part.name, uri: part.uri } })
|
|
1290
1431
|
} else if (part?.type === 'tool_call' && typeof part.toolCallId === 'string') {
|
|
@@ -1338,6 +1479,47 @@ async function fetchFullSessionHistory({ httpBase, ticket, sessionId, signal, on
|
|
|
1338
1479
|
return { messageIds: [...seenIds], count: seenIds.size, configurationRevision }
|
|
1339
1480
|
}
|
|
1340
1481
|
|
|
1482
|
+
// Refresh only the newest pages after a _monkeyai/session/history_changed
|
|
1483
|
+
// notification. Pages are oldest-to-newest internally and arrive newest-first;
|
|
1484
|
+
// stop once a known boundary is reached, retaining a changed message id even
|
|
1485
|
+
// when it was already present (the runtime may have replaced a partial body).
|
|
1486
|
+
async function fetchSessionHistoryDelta({ httpBase, ticket, sessionId, signal, knownMessageIds, changedMessageId, maxPages = 200 }) {
|
|
1487
|
+
const pages = []
|
|
1488
|
+
const seenIds = new Set()
|
|
1489
|
+
const toolCalls = {}
|
|
1490
|
+
let cursor
|
|
1491
|
+
let configurationRevision
|
|
1492
|
+
let foundBoundary = false
|
|
1493
|
+
let foundChangedMessage = !changedMessageId
|
|
1494
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
1495
|
+
const timeout = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])])
|
|
1496
|
+
const result = await readSessionHistoryPage({ httpBase, ticket, sessionId, cursor, direction: 'older', signal: timeout })
|
|
1497
|
+
if (Number.isSafeInteger(result.session?.configurationRevision)) configurationRevision = result.session.configurationRevision
|
|
1498
|
+
const pageMessages = []
|
|
1499
|
+
for (const message of result.messages) {
|
|
1500
|
+
if (typeof message?.id !== 'string' || message.id === '') continue
|
|
1501
|
+
const known = knownMessageIds.has(message.id)
|
|
1502
|
+
const changed = message.id === changedMessageId
|
|
1503
|
+
if (changed) foundChangedMessage = true
|
|
1504
|
+
if ((!known || changed) && !seenIds.has(message.id)) {
|
|
1505
|
+
seenIds.add(message.id)
|
|
1506
|
+
pageMessages.push(message)
|
|
1507
|
+
}
|
|
1508
|
+
if (known && !changed) foundBoundary = true
|
|
1509
|
+
}
|
|
1510
|
+
pages.push(pageMessages)
|
|
1511
|
+
Object.assign(toolCalls, result.toolCalls ?? {})
|
|
1512
|
+
if (foundBoundary && foundChangedMessage) break
|
|
1513
|
+
cursor = typeof result.olderCursor === 'string' && result.olderCursor !== '' ? result.olderCursor : undefined
|
|
1514
|
+
if (!cursor || result.messages.length === 0) break
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
messages: pages.reverse().flat(),
|
|
1518
|
+
toolCalls,
|
|
1519
|
+
configurationRevision,
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1341
1523
|
function validateResponse(method, params, result) {
|
|
1342
1524
|
if (!isObject(result)) throw new TypeError('ACP response must be a JSON object')
|
|
1343
1525
|
if (method === 'session/request_permission') {
|