@pi-chamber/web 0.3.0 → 0.3.1
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/dist/assets/{CommitSection-BpbMC0et.js → CommitSection-C336377Z.js} +1 -1
- package/dist/assets/{DiagramEditor-dFTMKHuO.js → DiagramEditor-gHLCXHtr.js} +1 -1
- package/dist/assets/{DiagramView-DVw_8Px1.js → DiagramView-DlHzRRBc.js} +1 -1
- package/dist/assets/{DiffView-CkoY1oAM.js → DiffView-5wHwK-lg.js} +1 -1
- package/dist/assets/{FilesView-C0hyg2pa.js → FilesView-5bswI3JZ.js} +1 -1
- package/dist/assets/{GitView-D36hpDsb.js → GitView-BfykGpGh.js} +3 -3
- package/dist/assets/{JsonTreeView-BF8S-bPN.js → JsonTreeView-HVwQS7qG.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BVNChdig.js → MarkdownRendererImpl-kjd4e0N8.js} +1 -1
- package/dist/assets/{PatchDiff-B--OaNB1.js → PatchDiff-Dlm4RU8n.js} +1 -1
- package/dist/assets/{PierreDiffViewer-0Qkp9ICt.js → PierreDiffViewer-St32rXlD.js} +1 -1
- package/dist/assets/{SessionSwitcherDropdown-cFWgy1td.js → SessionSwitcherDropdown-D5bWfZNA.js} +92 -92
- package/dist/assets/{SettingsView-D3ZZyjU3.js → SettingsView-FGe5TEQq.js} +3 -3
- package/dist/assets/{SettingsWindow-BuJCvYoR.js → SettingsWindow-Ck_uhTnx.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Bi9BoEe7.js → ToolOutputDialog-BCONUv3r.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-DGX7N4zr.js → ToolPartDiffPreview-BdVuk2kc.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-CxraYoDZ.js → VirtualizedFileDiff-BsAZM0jo.js} +1 -1
- package/dist/assets/{collapsible-d4-HChej.js → collapsible-Wnmq-13H.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BuD14qFL.js → computeVirtualFileMetrics-skbyL2Az.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-DirV-hCs.js → contextFileOpenGuard-BsxQITMZ.js} +1 -1
- package/dist/assets/{index-DEYblXCW.js → index-8TL2HlaR.js} +4 -4
- package/dist/assets/{index-BWmmf5yJ.js → index-Bfp6eYap.js} +1 -1
- package/dist/assets/{index-DLKBsLKY.css → index-riABQmQ_.css} +1 -1
- package/dist/assets/{isDiffPlainText-LoMmNzmX.js → isDiffPlainText-dZkfk3yG.js} +1 -1
- package/dist/assets/{isFilePlainText-3Hj9i02t.js → isFilePlainText-UL_tMQZx.js} +1 -1
- package/dist/assets/{main-BsszlAdQ.js → main-CHQfoyRP.js} +2 -2
- package/dist/assets/{main-CUvZIN16.js → main-DbnYXZeK.js} +4 -4
- package/dist/assets/{markdownCore-sGxk8jrc.js → markdownCore-qX3mJFkL.js} +2 -2
- package/dist/assets/{miniChat-C6SDQL4G.js → miniChat-3QtCNwHl.js} +2 -2
- package/dist/assets/{mobile-DBW3XDmZ.js → mobile-B_CyfRAX.js} +2 -2
- package/dist/assets/{renderElectronMiniChatApp-B1_fTdN-.js → renderElectronMiniChatApp-OToslrv-.js} +1 -1
- package/dist/assets/{renderMobileApp-CFxZc_1a.js → renderMobileApp-BD_wep_I.js} +4 -4
- package/dist/assets/{runtimeSurface-9aJdtQbh.js → runtimeSurface-CkdOpqo7.js} +1 -1
- package/dist/assets/{useInlineCommentController-Cl--s6vE.js → useInlineCommentController-DVif5Fr8.js} +2 -2
- package/dist/assets/{usePanelSlide-D5ffPTGM.js → usePanelSlide-C_PJmPXW.js} +2 -2
- package/dist/assets/{useWindowTitle-C-AZD713.js → useWindowTitle-BZVlLMCy.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-HC9JT4I3.js → vendor-codemirror-language-data-C-924Z4o.js} +2 -2
- package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
- package/dist/assets/{web-components-skJ_VpnT.js → web-components-BG-6PEN4.js} +4 -4
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/pi/routes.js +20 -2
- package/server/lib/pi/routes.test.js +14 -6
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
- package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
- package/server/lib/pi/session-daemon/session-daemon.js +66 -38
- package/server/lib/pi/session-daemon/session-daemon.test.js +289 -6
- package/server/lib/pi/session-daemon/supervisor.js +20 -1
- package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
|
|
19
19
|
import { createPiModelConfigStore } from '../model-config-store.js';
|
|
20
20
|
import { clampThinkingLevel, getSupportedThinkingLevels, isPiThinkingLevel } from '../thinking-levels.js';
|
|
21
|
+
import { createMessageEntryAliases } from './message-entry-aliases.js';
|
|
21
22
|
import { createSessionRuntimeRegistry } from './runtime-registry.js';
|
|
22
23
|
import {
|
|
23
24
|
findPiSessionJsonlById,
|
|
@@ -136,9 +137,11 @@ export function createSessionDaemon({
|
|
|
136
137
|
const messageStartedAt = new Map();
|
|
137
138
|
const toolStartedAt = new Map();
|
|
138
139
|
const latestUserMessageIds = new Map();
|
|
140
|
+
const retryStateBySession = new Map();
|
|
139
141
|
const sendGenerationBySession = new Map();
|
|
140
142
|
const streamingRedactionBuffers = new Map();
|
|
141
143
|
const loginAttempts = new Map();
|
|
144
|
+
const messageEntryAliases = createMessageEntryAliases();
|
|
142
145
|
const MAX_REPLAY_EVENTS = 1_024;
|
|
143
146
|
|
|
144
147
|
const validateDirectoryPath = async (dir) => {
|
|
@@ -238,6 +241,7 @@ export function createSessionDaemon({
|
|
|
238
241
|
: getSessionState();
|
|
239
242
|
if (requestedSessionId && requestedSessionId !== session.sessionId) return;
|
|
240
243
|
const activeSession = targetRuntime?.session || runtime?.session;
|
|
244
|
+
const retry = session.sessionId ? retryStateBySession.get(session.sessionId) : undefined;
|
|
241
245
|
const targetDirectory = targetRuntime?.cwd || activeDirectory || cwd;
|
|
242
246
|
const messages = activeSession ? projectMessageEntries(activeSession, targetDirectory) : [];
|
|
243
247
|
const lastAssistant = [...messages].reverse().find((entry) => entry.message.role === 'assistant')?.message;
|
|
@@ -252,7 +256,8 @@ export function createSessionDaemon({
|
|
|
252
256
|
...(session.sessionId ? { sessionId: session.sessionId } : {}),
|
|
253
257
|
directory: targetDirectory,
|
|
254
258
|
isStreaming: session.isStreaming ?? false,
|
|
255
|
-
lifecycle: session.isStreaming ? 'busy' : 'idle',
|
|
259
|
+
lifecycle: retry ? 'retry' : session.isStreaming ? 'busy' : 'idle',
|
|
260
|
+
...(retry ? { retry } : {}),
|
|
256
261
|
queue: activeSession ? {
|
|
257
262
|
steering: activeSession.getSteeringMessages?.().length ?? 0,
|
|
258
263
|
followUp: activeSession.getFollowUpMessages?.().length ?? 0,
|
|
@@ -727,6 +732,7 @@ export function createSessionDaemon({
|
|
|
727
732
|
?? (model?.provider && model?.id ? { providerId: model.provider, modelId: model.id } : undefined);
|
|
728
733
|
const sessionThinking = lastAssistant?.thinkingLevel || session.thinkingLevel;
|
|
729
734
|
const isStreaming = session.isStreaming === true;
|
|
735
|
+
const retry = retryStateBySession.get(session.sessionId);
|
|
730
736
|
return {
|
|
731
737
|
session: {
|
|
732
738
|
id: session.sessionId, directory: targetDir, createdAt, updatedAt: createdAt,
|
|
@@ -738,7 +744,8 @@ export function createSessionDaemon({
|
|
|
738
744
|
messages,
|
|
739
745
|
lastSequence: sequence,
|
|
740
746
|
isStreaming,
|
|
741
|
-
lifecycle: isStreaming ? 'busy' : 'idle',
|
|
747
|
+
lifecycle: retry ? 'retry' : isStreaming ? 'busy' : 'idle',
|
|
748
|
+
...(retry ? { retry } : {}),
|
|
742
749
|
};
|
|
743
750
|
};
|
|
744
751
|
|
|
@@ -1602,6 +1609,10 @@ export function createSessionDaemon({
|
|
|
1602
1609
|
targetDir = directory;
|
|
1603
1610
|
await rm(target.path, { force: false });
|
|
1604
1611
|
}
|
|
1612
|
+
messageEntryAliases.clearSession({ cwd: active?.cwd || targetDir, sessionId });
|
|
1613
|
+
retryStateBySession.delete(sessionId);
|
|
1614
|
+
latestUserMessageIds.delete(sessionId);
|
|
1615
|
+
latestAssistantMessageIds.delete(sessionId);
|
|
1605
1616
|
publish('session.lifecycle', { state: 'idle', deleted: true }, sessionId, targetDir);
|
|
1606
1617
|
};
|
|
1607
1618
|
|
|
@@ -1616,6 +1627,7 @@ export function createSessionDaemon({
|
|
|
1616
1627
|
? content.filter((part) => part?.type === 'text').map((part) => part.text).join('')
|
|
1617
1628
|
: '');
|
|
1618
1629
|
const messageId = `user-${sessionId}-${sequence + 1}`;
|
|
1630
|
+
messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
|
|
1619
1631
|
latestUserMessageIds.set(sessionId, messageId);
|
|
1620
1632
|
publish('assistant.message.start', {
|
|
1621
1633
|
messageId,
|
|
@@ -1625,6 +1637,7 @@ export function createSessionDaemon({
|
|
|
1625
1637
|
}, sessionId, directory);
|
|
1626
1638
|
} else if (event.message?.role === 'assistant') {
|
|
1627
1639
|
const messageId = `assistant-${sessionId}-${sequence + 1}`;
|
|
1640
|
+
messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
|
|
1628
1641
|
clearStreamingRedactionBuffers(sessionId);
|
|
1629
1642
|
streamingMessageIds.set(sessionId, messageId);
|
|
1630
1643
|
latestAssistantMessageIds.set(sessionId, messageId);
|
|
@@ -1653,6 +1666,19 @@ export function createSessionDaemon({
|
|
|
1653
1666
|
break;
|
|
1654
1667
|
}
|
|
1655
1668
|
case 'message_end': {
|
|
1669
|
+
const eventRuntime = runtimeRegistry?.get({ cwd: directory, sessionId });
|
|
1670
|
+
const syntheticMessageId = event.message?.role === 'assistant'
|
|
1671
|
+
? streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId)
|
|
1672
|
+
: event.message?.role === 'user'
|
|
1673
|
+
? latestUserMessageIds.get(sessionId)
|
|
1674
|
+
: undefined;
|
|
1675
|
+
messageEntryAliases.observeMessageEnd({
|
|
1676
|
+
cwd: directory,
|
|
1677
|
+
sessionId,
|
|
1678
|
+
syntheticMessageId,
|
|
1679
|
+
message: event.message,
|
|
1680
|
+
sessionManager: eventRuntime?.session?.sessionManager,
|
|
1681
|
+
});
|
|
1656
1682
|
if (event.message?.role === 'assistant') {
|
|
1657
1683
|
const content = Array.isArray(event.message.content) ? event.message.content : [];
|
|
1658
1684
|
const messageId = streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId) ?? `assistant-${sessionId}`;
|
|
@@ -1665,6 +1691,7 @@ export function createSessionDaemon({
|
|
|
1665
1691
|
text: redactAttachmentPaths(content.filter((part) => part?.type === 'text').map((part) => part.text).join('')),
|
|
1666
1692
|
thinking: redactAttachmentPaths(content.filter((part) => part?.type === 'thinking').map((part) => part.thinking).join('')),
|
|
1667
1693
|
durationMs,
|
|
1694
|
+
...(content.some((part) => part?.type === 'toolCall') ? { continuing: true } : {}),
|
|
1668
1695
|
...(event.message.errorMessage ? { error: { code: 'ASSISTANT_ERROR', message: redactAttachmentPaths(event.message.errorMessage) } } : {}),
|
|
1669
1696
|
...(usage ? { usage } : {}),
|
|
1670
1697
|
}, sessionId, directory);
|
|
@@ -1728,11 +1755,10 @@ export function createSessionDaemon({
|
|
|
1728
1755
|
break;
|
|
1729
1756
|
case 'agent_start':
|
|
1730
1757
|
clearIdleDisposal(sessionId);
|
|
1758
|
+
retryStateBySession.delete(sessionId);
|
|
1731
1759
|
publish('session.lifecycle', { state: 'busy' }, sessionId, directory);
|
|
1732
1760
|
break;
|
|
1733
1761
|
case 'agent_end': {
|
|
1734
|
-
latestUserMessageIds.delete(sessionId);
|
|
1735
|
-
latestAssistantMessageIds.delete(sessionId);
|
|
1736
1762
|
const finalMessage = event.messages?.at?.(-1);
|
|
1737
1763
|
if (finalMessage?.role === 'assistant' && finalMessage.stopReason === 'aborted') {
|
|
1738
1764
|
publish('session.interrupted', { reason: 'user-abort', streaming: false }, sessionId, directory);
|
|
@@ -1741,15 +1767,23 @@ export function createSessionDaemon({
|
|
|
1741
1767
|
}
|
|
1742
1768
|
break;
|
|
1743
1769
|
}
|
|
1744
|
-
case 'auto_retry_start':
|
|
1745
|
-
|
|
1746
|
-
state: 'retry',
|
|
1770
|
+
case 'auto_retry_start': {
|
|
1771
|
+
const retry = {
|
|
1747
1772
|
attempt: event.attempt,
|
|
1748
1773
|
next: Date.now() + event.delayMs,
|
|
1749
1774
|
message: redactAttachmentPaths(event.errorMessage),
|
|
1750
|
-
}
|
|
1775
|
+
};
|
|
1776
|
+
retryStateBySession.set(sessionId, retry);
|
|
1777
|
+
publish('session.lifecycle', { state: 'retry', ...retry }, sessionId, directory);
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
case 'auto_retry_end':
|
|
1781
|
+
retryStateBySession.delete(sessionId);
|
|
1751
1782
|
break;
|
|
1752
1783
|
case 'agent_settled':
|
|
1784
|
+
retryStateBySession.delete(sessionId);
|
|
1785
|
+
latestUserMessageIds.delete(sessionId);
|
|
1786
|
+
latestAssistantMessageIds.delete(sessionId);
|
|
1753
1787
|
publish('session.lifecycle', { state: 'idle' }, sessionId, directory);
|
|
1754
1788
|
scheduleIdleDisposal(sessionId);
|
|
1755
1789
|
break;
|
|
@@ -1924,35 +1958,16 @@ export function createSessionDaemon({
|
|
|
1924
1958
|
}
|
|
1925
1959
|
case 'sessions.navigate': {
|
|
1926
1960
|
const activeRuntime = await activateSession(message.payload?.sessionId, message.payload?.directory || message.payload?.cwd);
|
|
1927
|
-
|
|
1928
|
-
if (typeof
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
}
|
|
1961
|
+
const requestedId = message.payload?.messageId;
|
|
1962
|
+
if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested tree entry is invalid.');
|
|
1963
|
+
const messageId = messageEntryAliases.resolve({
|
|
1964
|
+
cwd: activeRuntime.cwd,
|
|
1965
|
+
sessionId: message.payload.sessionId,
|
|
1966
|
+
requestedId,
|
|
1967
|
+
sessionManager: activeRuntime.session.sessionManager,
|
|
1968
|
+
});
|
|
1936
1969
|
const previousLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
|
|
1937
|
-
|
|
1938
|
-
let lastError;
|
|
1939
|
-
for (const tryId of tryIds) {
|
|
1940
|
-
try {
|
|
1941
|
-
result = await activeRuntime.session.navigateTree(tryId);
|
|
1942
|
-
messageId = tryId;
|
|
1943
|
-
lastError = undefined;
|
|
1944
|
-
break;
|
|
1945
|
-
} catch (error) {
|
|
1946
|
-
lastError = error;
|
|
1947
|
-
// Only retry on "Entry not found" with a stripped id; otherwise surface immediately.
|
|
1948
|
-
const isEntryNotFound = String(error?.message ?? '').includes('not found');
|
|
1949
|
-
if (!isEntryNotFound || tryId === tryIds[tryIds.length - 1]) {
|
|
1950
|
-
console.error(`[pi:navigate] target=${tryId} leaf=${previousLeafId} branch=${activeRuntime.session.sessionManager?.getBranch?.()?.map((e) => e.id).join(',') ?? 'unknown'} error=${error?.message ?? String(error)}`);
|
|
1951
|
-
throw error;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
if (lastError) throw lastError;
|
|
1970
|
+
const result = await activeRuntime.session.navigateTree(messageId);
|
|
1956
1971
|
if (result?.cancelled) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'Pi cancelled tree navigation.');
|
|
1957
1972
|
const newLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
|
|
1958
1973
|
const navigation = {
|
|
@@ -1967,8 +1982,16 @@ export function createSessionDaemon({
|
|
|
1967
1982
|
case 'sessions.fork':
|
|
1968
1983
|
case 'sessions.clone': {
|
|
1969
1984
|
const activeRuntime = await activateSession(message.payload?.sessionId, message.payload?.directory || message.payload?.cwd);
|
|
1970
|
-
const
|
|
1971
|
-
if (typeof
|
|
1985
|
+
const requestedId = message.command === 'sessions.fork' ? message.payload?.messageId : activeRuntime.session.sessionManager?.getLeafId?.();
|
|
1986
|
+
if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'The Pi session has no fork point.');
|
|
1987
|
+
const entryId = message.command === 'sessions.fork'
|
|
1988
|
+
? messageEntryAliases.resolve({
|
|
1989
|
+
cwd: activeRuntime.cwd,
|
|
1990
|
+
sessionId: message.payload.sessionId,
|
|
1991
|
+
requestedId,
|
|
1992
|
+
sessionManager: activeRuntime.session.sessionManager,
|
|
1993
|
+
})
|
|
1994
|
+
: requestedId;
|
|
1972
1995
|
const result = await activeRuntime.fork(entryId, { position: 'at' });
|
|
1973
1996
|
if (result.cancelled) throw new SessionDaemonProtocolError('SESSION_CREATE_CANCELLED', 'Pi cancelled session creation.');
|
|
1974
1997
|
rememberRuntimeSession();
|
|
@@ -2136,6 +2159,7 @@ export function createSessionDaemon({
|
|
|
2136
2159
|
if (platform !== 'win32') await chmod(endpoint, 0o600);
|
|
2137
2160
|
started = true;
|
|
2138
2161
|
} catch (error) {
|
|
2162
|
+
messageEntryAliases.clear();
|
|
2139
2163
|
await disposeRuntime();
|
|
2140
2164
|
server = undefined;
|
|
2141
2165
|
throw error;
|
|
@@ -2149,8 +2173,12 @@ export function createSessionDaemon({
|
|
|
2149
2173
|
clearTimeout(attempt.expiry);
|
|
2150
2174
|
}
|
|
2151
2175
|
loginAttempts.clear();
|
|
2176
|
+
retryStateBySession.clear();
|
|
2177
|
+
latestUserMessageIds.clear();
|
|
2178
|
+
latestAssistantMessageIds.clear();
|
|
2152
2179
|
for (const client of clients) client.destroy();
|
|
2153
2180
|
clients.clear();
|
|
2181
|
+
messageEntryAliases.clear();
|
|
2154
2182
|
await new Promise((resolve, reject) => {
|
|
2155
2183
|
server.close((error) => error ? reject(error) : resolve());
|
|
2156
2184
|
});
|
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
8
|
|
|
9
|
+
import { createMessageEntryAliases } from './message-entry-aliases.js';
|
|
9
10
|
import { createSessionDaemon, isLocalSessionDaemonEndpoint } from './session-daemon.js';
|
|
10
11
|
|
|
11
12
|
const credential = 'a-private-daemon-credential';
|
|
@@ -42,6 +43,7 @@ class FakeSession {
|
|
|
42
43
|
getSessionFile: () => sessionFile,
|
|
43
44
|
getHeader: () => ({ timestamp: '2026-01-01T00:00:00.000Z' }),
|
|
44
45
|
getEntries: () => this.entries,
|
|
46
|
+
getEntry: (id) => this.entries.find((entry) => entry.id === id),
|
|
45
47
|
getLeafId: () => 'fake-entry',
|
|
46
48
|
getTree: () => [{ entry: { id: 'fake-entry', parentId: undefined, timestamp: '2026-01-01T00:00:00.000Z' }, children: [] }],
|
|
47
49
|
appendSessionInfo: (name) => this.names.push(name),
|
|
@@ -315,6 +317,59 @@ describe('Pi session daemon spike', () => {
|
|
|
315
317
|
await stale.close();
|
|
316
318
|
});
|
|
317
319
|
|
|
320
|
+
it('keeps existing and late-joining device streams independent during one turn', async () => {
|
|
321
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-multi-client-'));
|
|
322
|
+
const endpoint = testDaemonEndpoint(root);
|
|
323
|
+
const session = new FakeSession();
|
|
324
|
+
session.isStreaming = true;
|
|
325
|
+
session.messages = [{
|
|
326
|
+
role: 'assistant',
|
|
327
|
+
content: [{ type: 'text', text: 'half' }],
|
|
328
|
+
provider: 'test',
|
|
329
|
+
model: 'model',
|
|
330
|
+
timestamp: 1_000,
|
|
331
|
+
}];
|
|
332
|
+
daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
|
|
333
|
+
await daemon.start();
|
|
334
|
+
|
|
335
|
+
const first = connectClient(endpoint);
|
|
336
|
+
await first.authenticate();
|
|
337
|
+
await first.request('sessions.create', { cwd: root });
|
|
338
|
+
const detail = await first.request('sessions.open', { sessionId: 'pi-session-1', cwd: root });
|
|
339
|
+
expect(detail.result).toMatchObject({
|
|
340
|
+
isStreaming: true,
|
|
341
|
+
lifecycle: 'busy',
|
|
342
|
+
lastSequence: expect.any(Number),
|
|
343
|
+
messages: [expect.objectContaining({ parts: [expect.objectContaining({ text: 'half' })] })],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const late = connectClient(endpoint);
|
|
347
|
+
await late.authenticate(credential, {
|
|
348
|
+
sessionId: 'pi-session-1',
|
|
349
|
+
fromSequence: detail.result.lastSequence,
|
|
350
|
+
});
|
|
351
|
+
const firstDelta = first.next((frame) => frame.event === 'assistant.message.delta');
|
|
352
|
+
const lateDelta = late.next((frame) => frame.event === 'assistant.message.delta');
|
|
353
|
+
session.emit({
|
|
354
|
+
type: 'message_update',
|
|
355
|
+
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: ' plus the rest' },
|
|
356
|
+
});
|
|
357
|
+
const [firstDeltaFrame, lateDeltaFrame] = await Promise.all([firstDelta, lateDelta]);
|
|
358
|
+
expect(firstDeltaFrame.sequence).toBe(lateDeltaFrame.sequence);
|
|
359
|
+
expect(lateDeltaFrame.payload.delta).toBe(' plus the rest');
|
|
360
|
+
|
|
361
|
+
const firstIdle = first.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
|
|
362
|
+
const lateIdle = late.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
|
|
363
|
+
session.isStreaming = false;
|
|
364
|
+
session.emit({ type: 'agent_settled' });
|
|
365
|
+
const [firstIdleFrame, lateIdleFrame] = await Promise.all([firstIdle, lateIdle]);
|
|
366
|
+
expect(firstIdleFrame.sequence).toBe(lateIdleFrame.sequence);
|
|
367
|
+
expect(firstIdleFrame.sequence).toBeGreaterThan(firstDeltaFrame.sequence);
|
|
368
|
+
|
|
369
|
+
await late.close();
|
|
370
|
+
await first.close();
|
|
371
|
+
});
|
|
372
|
+
|
|
318
373
|
it('lists only validated cwd-scoped sessions without exposing Pi JSONL paths', async () => {
|
|
319
374
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
320
375
|
const endpoint = testDaemonEndpoint(root);
|
|
@@ -611,6 +666,174 @@ describe('Pi session daemon spike', () => {
|
|
|
611
666
|
await client.close();
|
|
612
667
|
});
|
|
613
668
|
|
|
669
|
+
it('resolves live message ids to Pi entry ids for navigation and forking', async () => {
|
|
670
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-'));
|
|
671
|
+
const endpoint = testDaemonEndpoint(root);
|
|
672
|
+
const sessionFile = join(root, 'fixture.jsonl');
|
|
673
|
+
await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
674
|
+
const session = new FakeSession('fixture-session', sessionFile);
|
|
675
|
+
const hydratedMessage = { role: 'user', timestamp: 1, content: 'm3' };
|
|
676
|
+
session.entries.push({ type: 'message', id: 'm3-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: hydratedMessage });
|
|
677
|
+
const navigated = [];
|
|
678
|
+
session.navigateTree = async (entryId) => {
|
|
679
|
+
if (!session.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
|
|
680
|
+
navigated.push(entryId);
|
|
681
|
+
return { cancelled: false };
|
|
682
|
+
};
|
|
683
|
+
const forked = [];
|
|
684
|
+
const runtime = new FakeRuntime({ cwd: root, session });
|
|
685
|
+
runtime.fork = async (entryId) => {
|
|
686
|
+
if (!session.sessionManager.getEntry(entryId)) throw new Error('Invalid entry ID for forking');
|
|
687
|
+
forked.push(entryId);
|
|
688
|
+
return { cancelled: false };
|
|
689
|
+
};
|
|
690
|
+
daemon = createSessionDaemon({
|
|
691
|
+
endpoint,
|
|
692
|
+
credential,
|
|
693
|
+
cwd: root,
|
|
694
|
+
createRuntime: async () => runtime,
|
|
695
|
+
listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
|
|
696
|
+
});
|
|
697
|
+
await daemon.start();
|
|
698
|
+
|
|
699
|
+
const client = connectClient(endpoint);
|
|
700
|
+
await client.authenticate();
|
|
701
|
+
await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
|
|
702
|
+
|
|
703
|
+
await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: 'm3-entry' })).resolves.toMatchObject({
|
|
704
|
+
result: { navigation: { targetEntryId: 'm3-entry' } },
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const replacementUser = { role: 'user', timestamp: 2, content: 'm4 replacement' };
|
|
708
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user' && frame.payload?.text === 'm4 replacement');
|
|
709
|
+
session.emit({ type: 'message_start', message: replacementUser });
|
|
710
|
+
session.emit({ type: 'message_end', message: replacementUser });
|
|
711
|
+
session.entries.push({ type: 'message', id: 'm4-rev-entry', parentId: 'm3-entry', timestamp: '2026-01-01T00:00:02.000Z', message: replacementUser });
|
|
712
|
+
const publishedUserId = (await userStartPromise).payload.messageId;
|
|
713
|
+
await Promise.resolve();
|
|
714
|
+
|
|
715
|
+
await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({
|
|
716
|
+
result: { navigation: { targetEntryId: 'm4-rev-entry' } },
|
|
717
|
+
});
|
|
718
|
+
await expect(client.request('sessions.fork', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({ result: expect.any(Object) });
|
|
719
|
+
|
|
720
|
+
// Pi agent-core shallow-copies the streaming start; message_end carries
|
|
721
|
+
// the distinct finalized object that SessionManager persists.
|
|
722
|
+
const assistantStartMessage = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [] };
|
|
723
|
+
const replacementAssistant = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [{ type: 'text', text: 'replacement answer' }] };
|
|
724
|
+
const assistantStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
725
|
+
session.emit({ type: 'message_start', message: assistantStartMessage });
|
|
726
|
+
session.emit({ type: 'message_end', message: replacementAssistant });
|
|
727
|
+
session.entries.push({ type: 'message', id: 'm4-assistant-entry', parentId: 'm4-rev-entry', timestamp: '2026-01-01T00:00:03.000Z', message: replacementAssistant });
|
|
728
|
+
const publishedAssistantId = (await assistantStartPromise).payload.messageId;
|
|
729
|
+
await Promise.resolve();
|
|
730
|
+
|
|
731
|
+
await expect(client.request('sessions.navigate', {
|
|
732
|
+
sessionId: 'fixture-session',
|
|
733
|
+
directory: root,
|
|
734
|
+
messageId: `${publishedAssistantId}:text:0`,
|
|
735
|
+
})).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'm4-assistant-entry' } } });
|
|
736
|
+
await expect(client.request('sessions.fork', {
|
|
737
|
+
sessionId: 'fixture-session',
|
|
738
|
+
directory: root,
|
|
739
|
+
messageId: `${publishedAssistantId}:text:0`,
|
|
740
|
+
})).resolves.toMatchObject({ result: expect.any(Object) });
|
|
741
|
+
|
|
742
|
+
expect(navigated).toEqual(['m3-entry', 'm4-rev-entry', 'm4-assistant-entry']);
|
|
743
|
+
expect(forked).toEqual(['m4-rev-entry', 'm4-assistant-entry']);
|
|
744
|
+
await expect(client.request('sessions.fork', {
|
|
745
|
+
sessionId: 'fixture-session',
|
|
746
|
+
directory: root,
|
|
747
|
+
messageId: 'user-fixture-session-unknown',
|
|
748
|
+
})).rejects.toThrow('Daemon connection closed');
|
|
749
|
+
|
|
750
|
+
const navigationClient = connectClient(endpoint);
|
|
751
|
+
await navigationClient.authenticate();
|
|
752
|
+
await expect(navigationClient.request('sessions.navigate', {
|
|
753
|
+
sessionId: 'fixture-session',
|
|
754
|
+
directory: root,
|
|
755
|
+
messageId: 'user-fixture-session-unknown',
|
|
756
|
+
})).rejects.toThrow('Daemon connection closed');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('keeps live message aliases across idle disposal and runtime reopening', async () => {
|
|
760
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-idle-'));
|
|
761
|
+
const endpoint = testDaemonEndpoint(root);
|
|
762
|
+
const sessionFile = join(root, 'fixture.jsonl');
|
|
763
|
+
await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
764
|
+
const liveMessage = { role: 'user', timestamp: 1, content: 'persist me' };
|
|
765
|
+
const persistedEntry = { type: 'message', id: 'persisted-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: liveMessage };
|
|
766
|
+
const firstSession = new FakeSession('fixture-session', sessionFile);
|
|
767
|
+
const firstRuntime = new FakeRuntime({ cwd: root, session: firstSession });
|
|
768
|
+
const reopenedSession = new FakeSession('fixture-session', sessionFile);
|
|
769
|
+
reopenedSession.entries.push({ ...persistedEntry, message: { ...liveMessage } });
|
|
770
|
+
reopenedSession.navigateTree = async (entryId) => {
|
|
771
|
+
if (!reopenedSession.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
|
|
772
|
+
reopenedSession.navigatedTo = entryId;
|
|
773
|
+
return { cancelled: false };
|
|
774
|
+
};
|
|
775
|
+
const reopenedRuntime = new FakeRuntime({ cwd: root, session: reopenedSession });
|
|
776
|
+
let runtimeCount = 0;
|
|
777
|
+
daemon = createSessionDaemon({
|
|
778
|
+
endpoint,
|
|
779
|
+
credential,
|
|
780
|
+
cwd: root,
|
|
781
|
+
idleTimeoutMs: 10,
|
|
782
|
+
listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
|
|
783
|
+
createRuntime: async () => (++runtimeCount === 1 ? firstRuntime : reopenedRuntime),
|
|
784
|
+
});
|
|
785
|
+
await daemon.start();
|
|
786
|
+
|
|
787
|
+
const client = connectClient(endpoint);
|
|
788
|
+
await client.authenticate();
|
|
789
|
+
await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
|
|
790
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
791
|
+
firstSession.emit({ type: 'message_start', message: liveMessage });
|
|
792
|
+
firstSession.emit({ type: 'message_end', message: liveMessage });
|
|
793
|
+
firstSession.entries.push(persistedEntry);
|
|
794
|
+
const publishedId = (await userStartPromise).payload.messageId;
|
|
795
|
+
await Promise.resolve();
|
|
796
|
+
firstSession.emit({ type: 'agent_settled' });
|
|
797
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
798
|
+
expect(firstRuntime.disposed).toBe(true);
|
|
799
|
+
|
|
800
|
+
await expect(client.request('sessions.navigate', {
|
|
801
|
+
sessionId: 'fixture-session',
|
|
802
|
+
directory: root,
|
|
803
|
+
messageId: publishedId,
|
|
804
|
+
})).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'persisted-entry' } } });
|
|
805
|
+
expect(reopenedSession.navigatedTo).toBe('persisted-entry');
|
|
806
|
+
await client.close();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('scopes live message aliases by directory and session id', () => {
|
|
810
|
+
const aliases = createMessageEntryAliases({ scheduleMicrotask: (callback) => callback() });
|
|
811
|
+
const messageA = { role: 'user', content: 'same' };
|
|
812
|
+
const messageB = { role: 'user', content: 'same' };
|
|
813
|
+
const managerA = {
|
|
814
|
+
getEntry: () => undefined,
|
|
815
|
+
getEntries: () => [{ type: 'message', id: 'entry-a', message: messageA }],
|
|
816
|
+
};
|
|
817
|
+
const managerB = {
|
|
818
|
+
getEntry: () => undefined,
|
|
819
|
+
getEntries: () => [{ type: 'message', id: 'entry-b', message: messageB }],
|
|
820
|
+
};
|
|
821
|
+
aliases.retain({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA });
|
|
822
|
+
aliases.retain({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB });
|
|
823
|
+
aliases.observeMessageEnd({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA, sessionManager: managerA });
|
|
824
|
+
aliases.observeMessageEnd({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB, sessionManager: managerB });
|
|
825
|
+
|
|
826
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('entry-a');
|
|
827
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('entry-b');
|
|
828
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-unknown', sessionManager: managerA })).toBe('user-same-session-unknown');
|
|
829
|
+
|
|
830
|
+
aliases.clearSession({ cwd: '/project-a', sessionId: 'same-session' });
|
|
831
|
+
expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('user-same-session-1');
|
|
832
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerB })).toBe('entry-b');
|
|
833
|
+
aliases.clear();
|
|
834
|
+
expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('user-same-session-1');
|
|
835
|
+
});
|
|
836
|
+
|
|
614
837
|
it('disposes an idle runtime without deleting its Pi JSONL and restores it on demand', async () => {
|
|
615
838
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
616
839
|
const endpoint = testDaemonEndpoint(root);
|
|
@@ -971,7 +1194,7 @@ describe('Pi session daemon spike', () => {
|
|
|
971
1194
|
await client.close();
|
|
972
1195
|
});
|
|
973
1196
|
|
|
974
|
-
it('keeps
|
|
1197
|
+
it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
|
|
975
1198
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
|
|
976
1199
|
const endpoint = testDaemonEndpoint(root);
|
|
977
1200
|
const session = new FakeSession('pi-session-retry');
|
|
@@ -981,6 +1204,15 @@ describe('Pi session daemon spike', () => {
|
|
|
981
1204
|
await client.authenticate();
|
|
982
1205
|
await client.request('sessions.create', { cwd: root });
|
|
983
1206
|
|
|
1207
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1208
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'recover this turn', timestamp: 1_000 } });
|
|
1209
|
+
const userStart = await userStartPromise;
|
|
1210
|
+
|
|
1211
|
+
const failedStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
1212
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_100 } });
|
|
1213
|
+
const failedStart = await failedStartPromise;
|
|
1214
|
+
session.emit({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Rate limit exceeded' } });
|
|
1215
|
+
|
|
984
1216
|
const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
|
|
985
1217
|
const prematureError = client.next((frame) => frame.event === 'session.error');
|
|
986
1218
|
session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
|
|
@@ -989,11 +1221,35 @@ describe('Pi session daemon spike', () => {
|
|
|
989
1221
|
await expect(retryLifecycle).resolves.toMatchObject({
|
|
990
1222
|
payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
|
|
991
1223
|
});
|
|
1224
|
+
|
|
1225
|
+
const retryObserver = connectClient(endpoint);
|
|
1226
|
+
const retrySnapshot = await retryObserver.authenticate();
|
|
1227
|
+
expect(retrySnapshot.payload).toMatchObject({
|
|
1228
|
+
lifecycle: 'retry',
|
|
1229
|
+
retry: { attempt: 1, message: 'Rate limit exceeded' },
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
session.emit({ type: 'agent_start' });
|
|
1233
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1234
|
+
&& frame.payload?.role === 'assistant'
|
|
1235
|
+
&& frame.payload.messageId !== failedStart.payload.messageId);
|
|
1236
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_200 } });
|
|
1237
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1238
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1239
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1240
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered' } });
|
|
1241
|
+
|
|
1242
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered' } });
|
|
1243
|
+
expect(failedStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1244
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
992
1245
|
await expect(prematureError).rejects.toThrow(/Timed out/);
|
|
1246
|
+
|
|
1247
|
+
session.emit({ type: 'agent_settled' });
|
|
1248
|
+
await retryObserver.close();
|
|
993
1249
|
await client.close();
|
|
994
1250
|
});
|
|
995
1251
|
|
|
996
|
-
it('
|
|
1252
|
+
it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
|
|
997
1253
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
998
1254
|
const endpoint = testDaemonEndpoint(root);
|
|
999
1255
|
const session = new FakeSession('pi-session-tool-seq');
|
|
@@ -1003,16 +1259,28 @@ describe('Pi session daemon spike', () => {
|
|
|
1003
1259
|
await client.authenticate();
|
|
1004
1260
|
await client.request('sessions.create', { cwd: root });
|
|
1005
1261
|
|
|
1006
|
-
const
|
|
1262
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1263
|
+
const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
1007
1264
|
const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
|
|
1008
1265
|
const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
|
|
1009
1266
|
const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
|
|
1010
1267
|
|
|
1268
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
|
|
1011
1269
|
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 1 } });
|
|
1012
|
-
session.emit({
|
|
1270
|
+
session.emit({
|
|
1271
|
+
type: 'message_end',
|
|
1272
|
+
message: {
|
|
1273
|
+
role: 'assistant',
|
|
1274
|
+
content: [
|
|
1275
|
+
{ type: 'text', text: 'Calling tool' },
|
|
1276
|
+
{ type: 'toolCall', id: 'tool-call-1', name: 'bash', arguments: { command: 'echo hi' } },
|
|
1277
|
+
],
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1013
1280
|
session.emit({ type: 'tool_execution_start', toolCallId: 'tool-call-1', toolName: 'bash', args: { command: 'echo hi' } });
|
|
1014
|
-
session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: '
|
|
1281
|
+
session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'command failed' }] }, isError: true });
|
|
1015
1282
|
|
|
1283
|
+
const userStart = await userStartPromise;
|
|
1016
1284
|
const messageStart = await messageStartPromise;
|
|
1017
1285
|
const messageEnd = await messageEndPromise;
|
|
1018
1286
|
const toolStart = await toolStartPromise;
|
|
@@ -1020,11 +1288,26 @@ describe('Pi session daemon spike', () => {
|
|
|
1020
1288
|
|
|
1021
1289
|
expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
|
|
1022
1290
|
expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1291
|
+
expect(messageEnd.payload.continuing).toBe(true);
|
|
1023
1292
|
expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1024
1293
|
expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1025
1294
|
expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1026
1295
|
expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1027
|
-
|
|
1296
|
+
expect(toolEnd.payload).toMatchObject({ state: 'error', isError: true, error: 'command failed' });
|
|
1297
|
+
|
|
1298
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1299
|
+
&& frame.payload?.role === 'assistant'
|
|
1300
|
+
&& frame.payload.messageId !== messageStart.payload.messageId);
|
|
1301
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 2 } });
|
|
1302
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1303
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1304
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1305
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered from tool failure' } });
|
|
1306
|
+
|
|
1307
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered from tool failure' } });
|
|
1308
|
+
expect(messageStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1309
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1310
|
+
session.emit({ type: 'agent_settled' });
|
|
1028
1311
|
await client.close();
|
|
1029
1312
|
});
|
|
1030
1313
|
|
|
@@ -24,6 +24,20 @@ class PiSessionDaemonUnavailableError extends Error {
|
|
|
24
24
|
|
|
25
25
|
const delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
26
26
|
|
|
27
|
+
const rejectAfter = (promise, timeoutMs, code) => new Promise((resolvePromise, rejectPromise) => {
|
|
28
|
+
const timer = setTimeout(() => rejectPromise(new SessionDaemonClientError(code)), timeoutMs);
|
|
29
|
+
promise.then(
|
|
30
|
+
(value) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolvePromise(value);
|
|
33
|
+
},
|
|
34
|
+
(error) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
rejectPromise(error);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
27
41
|
const getWindowsOwnerKey = () => {
|
|
28
42
|
try {
|
|
29
43
|
return createHash('sha256').update(userInfo().username).digest('hex').slice(0, 16);
|
|
@@ -253,7 +267,12 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
253
267
|
throw new PiSessionDaemonUnavailableError('DAEMON_UNAVAILABLE');
|
|
254
268
|
}
|
|
255
269
|
try {
|
|
256
|
-
const health = await request({
|
|
270
|
+
const health = await rejectAfter(request({
|
|
271
|
+
endpoint: paths.endpoint,
|
|
272
|
+
credential,
|
|
273
|
+
command: 'runtime.health',
|
|
274
|
+
timeoutMs: startupTimeoutMs,
|
|
275
|
+
}), startupTimeoutMs, 'DAEMON_UNAVAILABLE');
|
|
257
276
|
if (health?.state !== 'ready' || health.daemonPid !== state.pid) {
|
|
258
277
|
throw new PiSessionDaemonUnavailableError('DAEMON_IDENTITY_MISMATCH');
|
|
259
278
|
}
|