@pi-chamber/web 0.3.0 → 0.3.2
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/bin/lib/cli-startup.js +53 -10
- package/bin/lib/cli-startup.test.js +42 -0
- package/dist/assets/{CommitSection-BpbMC0et.js → CommitSection-DLIvjYbM.js} +1 -1
- package/dist/assets/{DiagramEditor-dFTMKHuO.js → DiagramEditor-Tw3ZWHML.js} +1 -1
- package/dist/assets/{DiagramView-DVw_8Px1.js → DiagramView-CK6R7taL.js} +1 -1
- package/dist/assets/{DiffView-CkoY1oAM.js → DiffView-vrxi5oWp.js} +2 -2
- package/dist/assets/{FilesView-C0hyg2pa.js → FilesView-BD_h3EVr.js} +6 -6
- package/dist/assets/{GitView-D36hpDsb.js → GitView-BwNKqK0K.js} +4 -4
- package/dist/assets/{JsonTreeView-BF8S-bPN.js → JsonTreeView-CmY7QgEX.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BVNChdig.js → MarkdownRendererImpl-CpI-25Xk.js} +1 -1
- package/dist/assets/{PatchDiff-B--OaNB1.js → PatchDiff-CrIWbYj7.js} +1 -1
- package/dist/assets/{PierreDiffViewer-0Qkp9ICt.js → PierreDiffViewer-B410mdbq.js} +2 -2
- package/dist/assets/{SessionSwitcherDropdown-cFWgy1td.js → SessionSwitcherDropdown-DROjbyIu.js} +100 -100
- package/dist/assets/SettingsView-CsJItYZh.js +4 -0
- package/dist/assets/{SettingsWindow-BuJCvYoR.js → SettingsWindow-C9s4AX72.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Bi9BoEe7.js → ToolOutputDialog-b_R4tAZu.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-DGX7N4zr.js → ToolPartDiffPreview-CfnliaE9.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-CxraYoDZ.js → VirtualizedFileDiff-CQz2fSZI.js} +4 -4
- package/dist/assets/{collapsible-d4-HChej.js → collapsible-CR4U9tjw.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BuD14qFL.js → computeVirtualFileMetrics-C7vXPUYT.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-DirV-hCs.js → contextFileOpenGuard-DtTHRnq1.js} +1 -1
- package/dist/assets/{index-DEYblXCW.js → index-BAqpUCjs.js} +5 -5
- package/dist/assets/{index-BWmmf5yJ.js → index-DZG82DE1.js} +1 -1
- package/dist/assets/{index-DLKBsLKY.css → index-riABQmQ_.css} +1 -1
- package/dist/assets/{isDiffPlainText-LoMmNzmX.js → isDiffPlainText-zTq-4A04.js} +1 -1
- package/dist/assets/{isFilePlainText-3Hj9i02t.js → isFilePlainText-CLgIdzE5.js} +1 -1
- package/dist/assets/{main-BsszlAdQ.js → main-BVWtJYSk.js} +2 -2
- package/dist/assets/main-DXYHsqzA.js +133 -0
- package/dist/assets/{markdownCore-sGxk8jrc.js → markdownCore-CN0pApQE.js} +2 -2
- package/dist/assets/{miniChat-C6SDQL4G.js → miniChat-BrbRq5NQ.js} +2 -2
- package/dist/assets/{mobile-DBW3XDmZ.js → mobile-DTPtCn5N.js} +2 -2
- package/dist/assets/{renderElectronMiniChatApp-B1_fTdN-.js → renderElectronMiniChatApp-CM8qBaye.js} +1 -1
- package/dist/assets/{renderMobileApp-CFxZc_1a.js → renderMobileApp-KS_pt-qc.js} +4 -4
- package/dist/assets/{runtimeSurface-9aJdtQbh.js → runtimeSurface-CkHYYg0E.js} +1 -1
- package/dist/assets/{useInlineCommentController-Cl--s6vE.js → useInlineCommentController-CwNNjgc7.js} +2 -2
- package/dist/assets/usePanelSlide-DkorgQJ5.js +56 -0
- package/dist/assets/{useWindowTitle-C-AZD713.js → useWindowTitle-B9wt_3ZQ.js} +3 -3
- package/dist/assets/{vendor-codemirror-language-data-HC9JT4I3.js → vendor-codemirror-language-data-x89A7Wy0.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-BEPfE4kU.js} +2 -2
- 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/index.js +33 -0
- package/server/lib/pi/routes.js +24 -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 +176 -46
- package/server/lib/pi/session-daemon/session-daemon.test.js +364 -6
- package/server/lib/pi/session-daemon/supervisor.js +20 -1
- package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
- package/server/lib/pi/settings-store.js +17 -0
- package/server/lib/pi/settings-store.test.js +33 -0
- package/server/lib/server/core-routes.js +36 -4
- package/server/lib/server/tunnel-service.DOCUMENTATION.md +74 -0
- package/server/lib/server/tunnel-service.js +228 -0
- package/server/lib/server/tunnel-service.test.js +61 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +2 -0
- package/server/lib/ui-auth/ui-auth.js +5 -1
- package/server/lib/ui-auth/ui-auth.test.js +17 -0
- package/dist/assets/SettingsView-D3ZZyjU3.js +0 -4
- package/dist/assets/main-CUvZIN16.js +0 -133
- package/dist/assets/usePanelSlide-D5ffPTGM.js +0 -56
|
@@ -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);
|
|
@@ -793,6 +1016,81 @@ describe('Pi session daemon spike', () => {
|
|
|
793
1016
|
await client.close();
|
|
794
1017
|
});
|
|
795
1018
|
|
|
1019
|
+
it('projects large tool payloads without blocking attachment redaction', async () => {
|
|
1020
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-large-payload-'));
|
|
1021
|
+
const endpoint = testDaemonEndpoint(root);
|
|
1022
|
+
const persistedSessionFile = join(root, 'persisted.jsonl');
|
|
1023
|
+
await writeFile(persistedSessionFile, `{"type":"session","id":"pi-session-large","cwd":"${root}"}\n`);
|
|
1024
|
+
const session = new FakeSession('pi-session-large', persistedSessionFile);
|
|
1025
|
+
const largeEncodedValue = 'A'.repeat(80_000);
|
|
1026
|
+
session.entries = [{
|
|
1027
|
+
type: 'message',
|
|
1028
|
+
id: 'assistant-large-tool',
|
|
1029
|
+
timestamp: '2026-01-01T00:00:02.000Z',
|
|
1030
|
+
message: {
|
|
1031
|
+
role: 'assistant',
|
|
1032
|
+
provider: 'test',
|
|
1033
|
+
model: 'model',
|
|
1034
|
+
content: [{ type: 'toolCall', id: 'large-tool', name: 'read', arguments: { encoded: largeEncodedValue } }],
|
|
1035
|
+
},
|
|
1036
|
+
}, {
|
|
1037
|
+
type: 'message',
|
|
1038
|
+
id: 'large-tool-result',
|
|
1039
|
+
timestamp: '2026-01-01T00:00:03.000Z',
|
|
1040
|
+
message: {
|
|
1041
|
+
role: 'toolResult',
|
|
1042
|
+
toolCallId: 'large-tool',
|
|
1043
|
+
toolName: 'read',
|
|
1044
|
+
content: [{ type: 'text', text: largeEncodedValue }],
|
|
1045
|
+
details: {
|
|
1046
|
+
encoded: largeEncodedValue,
|
|
1047
|
+
windowsPath: 'C:\\Temp\\pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png',
|
|
1048
|
+
bracketed: '[Attachment report.png is available at /tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png]',
|
|
1049
|
+
punctuated: 'before,/tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png;after',
|
|
1050
|
+
unicodePrefix: 'İ /tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png',
|
|
1051
|
+
},
|
|
1052
|
+
isError: false,
|
|
1053
|
+
},
|
|
1054
|
+
}];
|
|
1055
|
+
const runtime = new FakeRuntime({ cwd: root, session });
|
|
1056
|
+
daemon = createSessionDaemon({
|
|
1057
|
+
endpoint,
|
|
1058
|
+
credential,
|
|
1059
|
+
cwd: root,
|
|
1060
|
+
createRuntime: async () => runtime,
|
|
1061
|
+
listSessions: async () => [{
|
|
1062
|
+
path: persistedSessionFile,
|
|
1063
|
+
id: 'pi-session-large',
|
|
1064
|
+
cwd: root,
|
|
1065
|
+
created: new Date('2026-01-01T00:00:00.000Z'),
|
|
1066
|
+
modified: new Date('2026-01-01T00:00:01.000Z'),
|
|
1067
|
+
messageCount: 2,
|
|
1068
|
+
}],
|
|
1069
|
+
});
|
|
1070
|
+
await daemon.start();
|
|
1071
|
+
const client = connectClient(endpoint);
|
|
1072
|
+
await client.authenticate();
|
|
1073
|
+
|
|
1074
|
+
const startedAt = performance.now();
|
|
1075
|
+
const opened = await client.request('sessions.open', { sessionId: 'pi-session-large', directory: root });
|
|
1076
|
+
const elapsedMs = performance.now() - startedAt;
|
|
1077
|
+
|
|
1078
|
+
expect(elapsedMs).toBeLessThan(900);
|
|
1079
|
+
expect(opened.result.messages[0].parts[0]).toMatchObject({
|
|
1080
|
+
input: { encoded: largeEncodedValue },
|
|
1081
|
+
output: largeEncodedValue,
|
|
1082
|
+
metadata: {
|
|
1083
|
+
encoded: largeEncodedValue,
|
|
1084
|
+
windowsPath: '[attachment]',
|
|
1085
|
+
bracketed: '[attachment]',
|
|
1086
|
+
punctuated: 'before,[attachment];after',
|
|
1087
|
+
unicodePrefix: 'İ [attachment]',
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
expect(JSON.stringify(opened.result)).not.toContain('pi-clipboard-');
|
|
1091
|
+
await client.close();
|
|
1092
|
+
}, 2_000);
|
|
1093
|
+
|
|
796
1094
|
it('keeps Pi global/project defaults and trust decisions authoritative', async () => {
|
|
797
1095
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
|
|
798
1096
|
const cwd = join(root, 'project');
|
|
@@ -971,7 +1269,7 @@ describe('Pi session daemon spike', () => {
|
|
|
971
1269
|
await client.close();
|
|
972
1270
|
});
|
|
973
1271
|
|
|
974
|
-
it('keeps
|
|
1272
|
+
it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
|
|
975
1273
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
|
|
976
1274
|
const endpoint = testDaemonEndpoint(root);
|
|
977
1275
|
const session = new FakeSession('pi-session-retry');
|
|
@@ -981,6 +1279,15 @@ describe('Pi session daemon spike', () => {
|
|
|
981
1279
|
await client.authenticate();
|
|
982
1280
|
await client.request('sessions.create', { cwd: root });
|
|
983
1281
|
|
|
1282
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1283
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'recover this turn', timestamp: 1_000 } });
|
|
1284
|
+
const userStart = await userStartPromise;
|
|
1285
|
+
|
|
1286
|
+
const failedStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
1287
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_100 } });
|
|
1288
|
+
const failedStart = await failedStartPromise;
|
|
1289
|
+
session.emit({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Rate limit exceeded' } });
|
|
1290
|
+
|
|
984
1291
|
const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
|
|
985
1292
|
const prematureError = client.next((frame) => frame.event === 'session.error');
|
|
986
1293
|
session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
|
|
@@ -989,11 +1296,35 @@ describe('Pi session daemon spike', () => {
|
|
|
989
1296
|
await expect(retryLifecycle).resolves.toMatchObject({
|
|
990
1297
|
payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
|
|
991
1298
|
});
|
|
1299
|
+
|
|
1300
|
+
const retryObserver = connectClient(endpoint);
|
|
1301
|
+
const retrySnapshot = await retryObserver.authenticate();
|
|
1302
|
+
expect(retrySnapshot.payload).toMatchObject({
|
|
1303
|
+
lifecycle: 'retry',
|
|
1304
|
+
retry: { attempt: 1, message: 'Rate limit exceeded' },
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
session.emit({ type: 'agent_start' });
|
|
1308
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1309
|
+
&& frame.payload?.role === 'assistant'
|
|
1310
|
+
&& frame.payload.messageId !== failedStart.payload.messageId);
|
|
1311
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_200 } });
|
|
1312
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1313
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1314
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1315
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered' } });
|
|
1316
|
+
|
|
1317
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered' } });
|
|
1318
|
+
expect(failedStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1319
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
992
1320
|
await expect(prematureError).rejects.toThrow(/Timed out/);
|
|
1321
|
+
|
|
1322
|
+
session.emit({ type: 'agent_settled' });
|
|
1323
|
+
await retryObserver.close();
|
|
993
1324
|
await client.close();
|
|
994
1325
|
});
|
|
995
1326
|
|
|
996
|
-
it('
|
|
1327
|
+
it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
|
|
997
1328
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
|
|
998
1329
|
const endpoint = testDaemonEndpoint(root);
|
|
999
1330
|
const session = new FakeSession('pi-session-tool-seq');
|
|
@@ -1003,16 +1334,28 @@ describe('Pi session daemon spike', () => {
|
|
|
1003
1334
|
await client.authenticate();
|
|
1004
1335
|
await client.request('sessions.create', { cwd: root });
|
|
1005
1336
|
|
|
1006
|
-
const
|
|
1337
|
+
const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
|
|
1338
|
+
const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
|
|
1007
1339
|
const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
|
|
1008
1340
|
const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
|
|
1009
1341
|
const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
|
|
1010
1342
|
|
|
1343
|
+
session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
|
|
1011
1344
|
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 1 } });
|
|
1012
|
-
session.emit({
|
|
1345
|
+
session.emit({
|
|
1346
|
+
type: 'message_end',
|
|
1347
|
+
message: {
|
|
1348
|
+
role: 'assistant',
|
|
1349
|
+
content: [
|
|
1350
|
+
{ type: 'text', text: 'Calling tool' },
|
|
1351
|
+
{ type: 'toolCall', id: 'tool-call-1', name: 'bash', arguments: { command: 'echo hi' } },
|
|
1352
|
+
],
|
|
1353
|
+
},
|
|
1354
|
+
});
|
|
1013
1355
|
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: '
|
|
1356
|
+
session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'command failed' }] }, isError: true });
|
|
1015
1357
|
|
|
1358
|
+
const userStart = await userStartPromise;
|
|
1016
1359
|
const messageStart = await messageStartPromise;
|
|
1017
1360
|
const messageEnd = await messageEndPromise;
|
|
1018
1361
|
const toolStart = await toolStartPromise;
|
|
@@ -1020,11 +1363,26 @@ describe('Pi session daemon spike', () => {
|
|
|
1020
1363
|
|
|
1021
1364
|
expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
|
|
1022
1365
|
expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1366
|
+
expect(messageEnd.payload.continuing).toBe(true);
|
|
1023
1367
|
expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1024
1368
|
expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1025
1369
|
expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
|
|
1026
1370
|
expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
|
|
1027
|
-
|
|
1371
|
+
expect(toolEnd.payload).toMatchObject({ state: 'error', isError: true, error: 'command failed' });
|
|
1372
|
+
|
|
1373
|
+
const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
|
|
1374
|
+
&& frame.payload?.role === 'assistant'
|
|
1375
|
+
&& frame.payload.messageId !== messageStart.payload.messageId);
|
|
1376
|
+
session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 2 } });
|
|
1377
|
+
const recoveredStart = await recoveredStartPromise;
|
|
1378
|
+
const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
|
|
1379
|
+
&& frame.payload?.messageId === recoveredStart.payload.messageId);
|
|
1380
|
+
session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered from tool failure' } });
|
|
1381
|
+
|
|
1382
|
+
await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered from tool failure' } });
|
|
1383
|
+
expect(messageStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1384
|
+
expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
|
|
1385
|
+
session.emit({ type: 'agent_settled' });
|
|
1028
1386
|
await client.close();
|
|
1029
1387
|
});
|
|
1030
1388
|
|
|
@@ -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
|
}
|
|
@@ -194,6 +194,49 @@ describe('Pi session daemon supervisor', () => {
|
|
|
194
194
|
expect(windowsSupervisor.paths.endpoint).toMatch(/^\\\\\.\\pipe\\pichamber-pi-session-daemon-[0-9a-f]{16}$/);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it('bounds an unresponsive Windows daemon probe by the supervisor operation timeout', async () => {
|
|
198
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-hung-win-'));
|
|
199
|
+
const dataDir = join(root, 'data');
|
|
200
|
+
const piDataDir = join(dataDir, 'pi');
|
|
201
|
+
await mkdir(piDataDir, { recursive: true });
|
|
202
|
+
const daemonPid = 7_331;
|
|
203
|
+
const processLike = {
|
|
204
|
+
pid: 4_242,
|
|
205
|
+
execPath: process.execPath,
|
|
206
|
+
versions: { electron: '41.2.1' },
|
|
207
|
+
kill(pid, signal) {
|
|
208
|
+
if (pid === daemonPid && signal === 0) return;
|
|
209
|
+
const error = new Error('ESRCH');
|
|
210
|
+
error.code = 'ESRCH';
|
|
211
|
+
throw error;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
const probeTimeoutMs = 20;
|
|
215
|
+
const windowsSupervisor = createPiSessionDaemonSupervisor({
|
|
216
|
+
env: { PICHAMBER_DATA_DIR: dataDir },
|
|
217
|
+
cwd: root,
|
|
218
|
+
dataDir,
|
|
219
|
+
platform: 'win32',
|
|
220
|
+
processLike,
|
|
221
|
+
startupTimeoutMs: probeTimeoutMs,
|
|
222
|
+
request: () => new Promise(() => {}),
|
|
223
|
+
});
|
|
224
|
+
await writeFile(windowsSupervisor.paths.credentialFile, `${'a'.repeat(64)}\n`);
|
|
225
|
+
await writeFile(windowsSupervisor.paths.stateFile, JSON.stringify({
|
|
226
|
+
protocolVersion: 1,
|
|
227
|
+
pid: daemonPid,
|
|
228
|
+
endpoint: windowsSupervisor.paths.endpoint,
|
|
229
|
+
startedAt: new Date().toISOString(),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
const startedAt = Date.now();
|
|
233
|
+
await expect(windowsSupervisor.health()).resolves.toMatchObject({
|
|
234
|
+
state: 'unavailable',
|
|
235
|
+
error: { code: 'DAEMON_UNAVAILABLE' },
|
|
236
|
+
});
|
|
237
|
+
expect(Date.now() - startedAt).toBeLessThan(probeTimeoutMs * 5);
|
|
238
|
+
});
|
|
239
|
+
|
|
197
240
|
it('recovers from a leftover empty daemon lock instead of failing as unavailable', async () => {
|
|
198
241
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-empty-lock-'));
|
|
199
242
|
const cwd = join(root, 'project');
|
|
@@ -63,6 +63,16 @@ const mergeThinkingByModel = (current, incoming) => {
|
|
|
63
63
|
return next;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
const DEFAULT_RETRY_LIMIT = 3;
|
|
67
|
+
const MIN_RETRY_LIMIT = 0;
|
|
68
|
+
const MAX_RETRY_LIMIT = 10;
|
|
69
|
+
|
|
70
|
+
const normalizeRetryLimit = (value) => {
|
|
71
|
+
if (value === undefined || value === null) return undefined;
|
|
72
|
+
if (!Number.isInteger(value) || value < MIN_RETRY_LIMIT || value > MAX_RETRY_LIMIT) throw invalidSettings();
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
|
|
66
76
|
const normalize = (value) => {
|
|
67
77
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalidSettings();
|
|
68
78
|
const model = normalizeModel(value.defaultModel);
|
|
@@ -77,6 +87,7 @@ const normalize = (value) => {
|
|
|
77
87
|
if (!thinkingByModel[key]) thinkingByModel[key] = fallbackThinking;
|
|
78
88
|
fallbackThinking = undefined;
|
|
79
89
|
}
|
|
90
|
+
const retryLimit = normalizeRetryLimit(value.defaultRetryLimit);
|
|
80
91
|
return {
|
|
81
92
|
version: 1,
|
|
82
93
|
...(model ? { defaultModel: model } : {}),
|
|
@@ -84,6 +95,7 @@ const normalize = (value) => {
|
|
|
84
95
|
...(walkthroughModel ? { walkthroughModel } : {}),
|
|
85
96
|
...(Object.keys(thinkingByModel).length > 0 ? { defaultThinkingByModel: thinkingByModel } : {}),
|
|
86
97
|
...(fallbackThinking ? { defaultThinking: fallbackThinking } : {}),
|
|
98
|
+
...(retryLimit !== undefined ? { defaultRetryLimit: retryLimit } : {}),
|
|
87
99
|
};
|
|
88
100
|
};
|
|
89
101
|
|
|
@@ -92,6 +104,10 @@ const normalize = (value) => {
|
|
|
92
104
|
* Pi's settings.json: Pi remains authoritative when no PiChamber override is
|
|
93
105
|
* configured, and credentials never enter this sidecar.
|
|
94
106
|
*/
|
|
107
|
+
export const PI_DEFAULT_RETRY_LIMIT = DEFAULT_RETRY_LIMIT;
|
|
108
|
+
export const PI_MIN_RETRY_LIMIT = MIN_RETRY_LIMIT;
|
|
109
|
+
export const PI_MAX_RETRY_LIMIT = MAX_RETRY_LIMIT;
|
|
110
|
+
|
|
95
111
|
export const createPiSettingsStore = ({ file = join(resolvePiChamberDataDir(), 'pi', 'settings.json') } = {}) => {
|
|
96
112
|
let writeChain = Promise.resolve();
|
|
97
113
|
|
|
@@ -115,6 +131,7 @@ export const createPiSettingsStore = ({ file = join(resolvePiChamberDataDir(), '
|
|
|
115
131
|
...(Object.hasOwn(patch, 'defaultThinking') ? { defaultThinking: patch.defaultThinking } : {}),
|
|
116
132
|
...(Object.hasOwn(patch, 'smallModel') ? { smallModel: patch.smallModel } : {}),
|
|
117
133
|
...(Object.hasOwn(patch, 'walkthroughModel') ? { walkthroughModel: patch.walkthroughModel } : {}),
|
|
134
|
+
...(Object.hasOwn(patch, 'defaultRetryLimit') ? { defaultRetryLimit: patch.defaultRetryLimit } : {}),
|
|
118
135
|
defaultThinkingByModel: Object.hasOwn(patch, 'defaultThinkingByModel')
|
|
119
136
|
? mergeThinkingByModel(current.defaultThinkingByModel ?? {}, patch.defaultThinkingByModel)
|
|
120
137
|
: (current.defaultThinkingByModel ?? {}),
|
|
@@ -110,4 +110,37 @@ describe('Pi settings sidecar', () => {
|
|
|
110
110
|
await store.update({ defaultThinkingByModel: { 'provider/a': 'low' } });
|
|
111
111
|
await expect(store.update({ defaultThinkingByModel: null })).resolves.toEqual({ version: 1 });
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
it('round-trips default retry limit with validation and persistence', async () => {
|
|
115
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
|
|
116
|
+
const file = join(root, 'pi', 'settings.json');
|
|
117
|
+
const store = createPiSettingsStore({ file });
|
|
118
|
+
await expect(store.update({ defaultRetryLimit: 5 })).resolves.toEqual({ version: 1, defaultRetryLimit: 5 });
|
|
119
|
+
await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 5 });
|
|
120
|
+
await expect(store.update({ defaultRetryLimit: 0 })).resolves.toEqual({ version: 1, defaultRetryLimit: 0 });
|
|
121
|
+
await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects invalid retry limits without erasing existing value', async () => {
|
|
125
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
|
|
126
|
+
const file = join(root, 'pi', 'settings.json');
|
|
127
|
+
const store = createPiSettingsStore({ file });
|
|
128
|
+
await store.update({ defaultRetryLimit: 3 });
|
|
129
|
+
await expect(store.update({ defaultRetryLimit: -1 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
|
|
130
|
+
await expect(store.update({ defaultRetryLimit: 11 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
|
|
131
|
+
await expect(store.update({ defaultRetryLimit: 2.5 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
|
|
132
|
+
await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 3 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('preserves explicit per-run retry over stored default', async () => {
|
|
136
|
+
const resolveEffective = (explicit, stored) => {
|
|
137
|
+
if (Number.isInteger(explicit)) return explicit;
|
|
138
|
+
if (Number.isInteger(stored)) return stored;
|
|
139
|
+
return 3;
|
|
140
|
+
};
|
|
141
|
+
expect(resolveEffective(undefined, 5)).toBe(5);
|
|
142
|
+
expect(resolveEffective(7, 5)).toBe(7);
|
|
143
|
+
expect(resolveEffective(undefined, undefined)).toBe(3);
|
|
144
|
+
expect(resolveEffective(0, 5)).toBe(0);
|
|
145
|
+
});
|
|
113
146
|
});
|