@moxxy/sdk 0.43.0 → 0.45.0
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/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +23 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +122 -81
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +37 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +23 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +140 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +118 -1
- package/src/mode/project-messages.ts +136 -77
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { assertDefined } from './assert.js';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ATTACHMENT_READ_CHUNK_CHARS,
|
|
7
|
+
CLIENT_INLINE_ATTACHMENT_CHARS,
|
|
8
|
+
CLIENT_INLINE_EVENT_CHARS,
|
|
9
|
+
HISTORY_PAGE_MAX_CHARS,
|
|
10
|
+
attachmentBytes,
|
|
11
|
+
eventForClient,
|
|
12
|
+
findAttachment,
|
|
13
|
+
historyPageForClient,
|
|
14
|
+
listAttachments,
|
|
15
|
+
readAttachmentChunk,
|
|
16
|
+
restoredAttachmentRef,
|
|
17
|
+
} from './attachment-payload.js';
|
|
18
|
+
import type { MoxxyEvent, UserPromptAttachment, UserPromptEvent } from './events.js';
|
|
19
|
+
|
|
20
|
+
function prompt(attachments: ReadonlyArray<UserPromptAttachment>): UserPromptEvent {
|
|
21
|
+
return {
|
|
22
|
+
type: 'user_prompt',
|
|
23
|
+
seq: 7,
|
|
24
|
+
ts: 0,
|
|
25
|
+
turnId: 't1',
|
|
26
|
+
text: 'look at these',
|
|
27
|
+
attachments,
|
|
28
|
+
} as unknown as UserPromptEvent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const base64Of = (chars: number): string => 'A'.repeat(chars);
|
|
32
|
+
|
|
33
|
+
function firstAttachment(event: UserPromptEvent): UserPromptAttachment {
|
|
34
|
+
const first = event.attachments?.[0];
|
|
35
|
+
assertDefined(first, 'first attachment');
|
|
36
|
+
return first;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('eventForClient', () => {
|
|
40
|
+
it('leaves an event without large attachments untouched (same object)', () => {
|
|
41
|
+
const event = prompt([{ kind: 'image', content: base64Of(1000), mediaType: 'image/png', name: 'a.png' }]);
|
|
42
|
+
expect(eventForClient(event)).toBe(event);
|
|
43
|
+
const chunk = { type: 'assistant_chunk', seq: 1, ts: 0, turnId: 't1', delta: 'hi' } as unknown as MoxxyEvent;
|
|
44
|
+
expect(eventForClient(chunk)).toBe(chunk);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('replaces the bytes of a large attachment with its size, keeping name and type', () => {
|
|
48
|
+
const big = base64Of(CLIENT_INLINE_ATTACHMENT_CHARS + 4);
|
|
49
|
+
const event = prompt([{ kind: 'document', content: big, mediaType: 'application/pdf', name: 'scan.pdf' }]);
|
|
50
|
+
const slim = eventForClient(event) as UserPromptEvent;
|
|
51
|
+
expect(slim).not.toBe(event);
|
|
52
|
+
expect(slim.attachments).toEqual([
|
|
53
|
+
{ kind: 'document', content: '', mediaType: 'application/pdf', name: 'scan.pdf', omittedBytes: attachmentBytes(firstAttachment(event)) },
|
|
54
|
+
]);
|
|
55
|
+
// The original event keeps its bytes — the runner's log is never changed.
|
|
56
|
+
expect(firstAttachment(event).content).toBe(big);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('caps the bytes one event carries even when each attachment is small', () => {
|
|
60
|
+
const each = Math.floor(CLIENT_INLINE_ATTACHMENT_CHARS * 0.9);
|
|
61
|
+
const count = Math.ceil(CLIENT_INLINE_EVENT_CHARS / each) + 1;
|
|
62
|
+
const event = prompt(
|
|
63
|
+
Array.from({ length: count }, (_, i) => ({ kind: 'image' as const, content: base64Of(each), mediaType: 'image/png', name: `${i}.png` })),
|
|
64
|
+
);
|
|
65
|
+
const slim = eventForClient(event) as UserPromptEvent;
|
|
66
|
+
const sent = slim.attachments ?? [];
|
|
67
|
+
const inline = sent.reduce((n, a) => n + a.content.length, 0);
|
|
68
|
+
expect(inline).toBeLessThanOrEqual(CLIENT_INLINE_EVENT_CHARS);
|
|
69
|
+
expect(firstAttachment(slim).content.length).toBe(each);
|
|
70
|
+
expect(sent.at(-1)?.omittedBytes).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('attachmentBytes', () => {
|
|
75
|
+
it('decodes base64 length for binary kinds and reports the omitted size when present', () => {
|
|
76
|
+
expect(attachmentBytes({ kind: 'image', content: 'QUJD', mediaType: 'image/png' })).toBe(3);
|
|
77
|
+
expect(attachmentBytes({ kind: 'image', content: 'QUI=', mediaType: 'image/png' })).toBe(2);
|
|
78
|
+
expect(attachmentBytes({ kind: 'file', content: 'hello' })).toBe(5);
|
|
79
|
+
expect(attachmentBytes({ kind: 'document', content: '', omittedBytes: 1234 })).toBe(1234);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('readAttachmentChunk', () => {
|
|
84
|
+
it('returns one bounded slice and the total, so a client can rebuild the content', () => {
|
|
85
|
+
const content = base64Of(ATTACHMENT_READ_CHUNK_CHARS * 2 + 10);
|
|
86
|
+
const event = prompt([{ kind: 'document', content, mediaType: 'application/pdf' }]);
|
|
87
|
+
let rebuilt = '';
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (;;) {
|
|
90
|
+
const chunk = readAttachmentChunk(event, 0, offset);
|
|
91
|
+
expect(chunk.total).toBe(content.length);
|
|
92
|
+
expect(chunk.data.length).toBeLessThanOrEqual(ATTACHMENT_READ_CHUNK_CHARS);
|
|
93
|
+
rebuilt += chunk.data;
|
|
94
|
+
offset += chunk.data.length;
|
|
95
|
+
if (offset >= chunk.total) break;
|
|
96
|
+
}
|
|
97
|
+
expect(rebuilt).toBe(content);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('throws for an event or index that has no such attachment', () => {
|
|
101
|
+
const chunk = { type: 'assistant_chunk', seq: 1, ts: 0, turnId: 't1', delta: 'hi' } as unknown as MoxxyEvent;
|
|
102
|
+
expect(() => readAttachmentChunk(chunk, 0, 0)).toThrow(/no attachments/);
|
|
103
|
+
expect(() => readAttachmentChunk(prompt([]), 0, 0)).toThrow(/no attachment 0/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('historyPageForClient', () => {
|
|
108
|
+
const text = (seq: number, chars: number): MoxxyEvent =>
|
|
109
|
+
({ type: 'assistant_message', seq, ts: 0, turnId: 't1', content: 'x'.repeat(chars) }) as unknown as MoxxyEvent;
|
|
110
|
+
|
|
111
|
+
it('passes a small page through with its cursor', () => {
|
|
112
|
+
const page = { events: [text(3, 10), text(4, 10)], prevCursor: 3 };
|
|
113
|
+
expect(historyPageForClient(page)).toEqual(page);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('drops the oldest events until the page fits, pointing the cursor at the oldest kept', () => {
|
|
117
|
+
const quarter = Math.floor(HISTORY_PAGE_MAX_CHARS / 4);
|
|
118
|
+
const page = historyPageForClient({
|
|
119
|
+
events: [0, 1, 2, 3, 4, 5].map((seq) => text(seq, quarter)),
|
|
120
|
+
prevCursor: null,
|
|
121
|
+
});
|
|
122
|
+
expect(page.events.map((e) => e.seq)).toEqual([3, 4, 5]);
|
|
123
|
+
expect(page.prevCursor).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('always keeps the newest event so paging advances', () => {
|
|
127
|
+
const page = historyPageForClient({
|
|
128
|
+
events: [text(8, 10), text(9, HISTORY_PAGE_MAX_CHARS * 2)],
|
|
129
|
+
prevCursor: 8,
|
|
130
|
+
});
|
|
131
|
+
expect(page.events.map((e) => e.seq)).toEqual([9]);
|
|
132
|
+
expect(page.prevCursor).toBe(9);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('finding an earlier attachment', () => {
|
|
137
|
+
const at = (seq: number, attachments: ReadonlyArray<UserPromptAttachment>): MoxxyEvent =>
|
|
138
|
+
({ ...prompt(attachments), seq }) as MoxxyEvent;
|
|
139
|
+
const events = [
|
|
140
|
+
at(2, [{ kind: 'document', content: 'OLD', mediaType: 'application/pdf', name: 'umowa.pdf' }]),
|
|
141
|
+
at(5, [
|
|
142
|
+
{ kind: 'image', content: 'IMG', mediaType: 'image/png', name: 'shot.png' },
|
|
143
|
+
{ kind: 'document', content: 'NEW', mediaType: 'application/pdf', name: 'Umowa.pdf' },
|
|
144
|
+
]),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
it('finds one by its key or, newest first, by its name whatever the case', () => {
|
|
148
|
+
expect(findAttachment(events, '2:0')).toMatchObject({ seq: 2, index: 0, attachment: { content: 'OLD' } });
|
|
149
|
+
expect(findAttachment(events, 'umowa.pdf')).toMatchObject({ seq: 5, index: 1, attachment: { content: 'NEW' } });
|
|
150
|
+
expect(findAttachment(events, 'shot.png')).toMatchObject({ seq: 5, index: 0 });
|
|
151
|
+
expect(findAttachment(events, 'missing.pdf')).toBeNull();
|
|
152
|
+
expect(findAttachment(events, '9:0')).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('lists every attachment of the conversation by key and name', () => {
|
|
156
|
+
expect(listAttachments(events)).toEqual(['2:0 umowa.pdf', '5:0 shot.png', '5:1 Umowa.pdf']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reads the reference out of a restoring recall result, and nothing out of anything else', () => {
|
|
160
|
+
expect(restoredAttachmentRef({ forModel: 'restored', restoredAttachment: { seq: 2, index: 0 } })).toEqual({ seq: 2, index: 0 });
|
|
161
|
+
expect(restoredAttachmentRef('text')).toBeNull();
|
|
162
|
+
expect(restoredAttachmentRef({ restoredAttachment: { seq: '2', index: 0 } })).toBeNull();
|
|
163
|
+
expect(restoredAttachmentRef(null)).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { MoxxyEvent, UserPromptAttachment } from './events.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attachment bytes travel two very different roads. The model needs them in
|
|
5
|
+
* full; a client that only DRAWS the conversation (desktop, a channel
|
|
6
|
+
* bot) does not, and pushing a scanned 30 MB PDF through the client link on
|
|
7
|
+
* every reconnect is what once wedged a chat in a reconnect loop. So the runner
|
|
8
|
+
* sends clients {@link eventForClient} — large payloads swapped for their size —
|
|
9
|
+
* and a client that wants the bytes (an image preview) reads them back in
|
|
10
|
+
* bounded slices with {@link readAttachmentChunk}. The log itself never changes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The most attachment payload (base64 characters) one model request carries.
|
|
15
|
+
* Every request resends the whole conversation, so without a cap a chat with a
|
|
16
|
+
* few scanned PDFs outgrows the provider's request limit (Anthropic: 32 MB) and
|
|
17
|
+
* EVERY later turn fails. The newest attachments go in full; older ones that no
|
|
18
|
+
* longer fit become a note naming them.
|
|
19
|
+
*/
|
|
20
|
+
export const MODEL_ATTACHMENT_BUDGET_CHARS = 20 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** One attachment's payload above this travels to clients by size only. */
|
|
23
|
+
export const CLIENT_INLINE_ATTACHMENT_CHARS = 512 * 1024;
|
|
24
|
+
/** All of one event's inline attachment payloads together stay under this. */
|
|
25
|
+
export const CLIENT_INLINE_EVENT_CHARS = 1024 * 1024;
|
|
26
|
+
/** The largest slice one attachment read returns. */
|
|
27
|
+
export const ATTACHMENT_READ_CHUNK_CHARS = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Whether `content` is base64 bytes rather than text. Raw audio carries a media type; a transcript does not. */
|
|
30
|
+
function isBase64Payload(att: UserPromptAttachment): boolean {
|
|
31
|
+
return att.kind === 'image' || att.kind === 'document' || (att.kind === 'audio' && att.mediaType !== undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The attachment's size in bytes — its real size even on a client copy whose bytes were left out. */
|
|
35
|
+
export function attachmentBytes(att: UserPromptAttachment): number {
|
|
36
|
+
if (att.omittedBytes !== undefined) return att.omittedBytes;
|
|
37
|
+
if (!isBase64Payload(att)) return att.content.length;
|
|
38
|
+
const padding = att.content.endsWith('==') ? 2 : att.content.endsWith('=') ? 1 : 0;
|
|
39
|
+
return Math.max(0, Math.floor((att.content.length * 3) / 4) - padding);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The copy of `event` a client receives: every attachment payload too large for
|
|
44
|
+
* the client link becomes `content: ''` plus `omittedBytes`. Returns `event`
|
|
45
|
+
* itself when nothing had to change, so the common case allocates nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function eventForClient(event: MoxxyEvent): MoxxyEvent {
|
|
48
|
+
if (event.type !== 'user_prompt' || !event.attachments || event.attachments.length === 0) return event;
|
|
49
|
+
let room = CLIENT_INLINE_EVENT_CHARS;
|
|
50
|
+
let changed = false;
|
|
51
|
+
const attachments = event.attachments.map((att) => {
|
|
52
|
+
const size = att.content.length;
|
|
53
|
+
if (size <= CLIENT_INLINE_ATTACHMENT_CHARS && size <= room) {
|
|
54
|
+
room -= size;
|
|
55
|
+
return att;
|
|
56
|
+
}
|
|
57
|
+
changed = true;
|
|
58
|
+
return { ...att, content: '', omittedBytes: attachmentBytes(att) };
|
|
59
|
+
});
|
|
60
|
+
return changed ? { ...event, attachments } : event;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The most one history page may weigh on the wire, in JSON characters. Paging
|
|
65
|
+
* counts events, and a few hundred events with screenshots or attachments can
|
|
66
|
+
* add up to tens of megabytes — one frame the client has to swallow whole.
|
|
67
|
+
*/
|
|
68
|
+
export const HISTORY_PAGE_MAX_CHARS = 4 * 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
/** One newest-first history page: ascending events plus the cursor to the next older page. */
|
|
71
|
+
export interface HistoryPage {
|
|
72
|
+
readonly events: ReadonlyArray<MoxxyEvent>;
|
|
73
|
+
readonly prevCursor: number | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The page as a client receives it: every event in its client form, and the
|
|
78
|
+
* oldest events dropped until the page fits {@link HISTORY_PAGE_MAX_CHARS}. The
|
|
79
|
+
* newest event always stays, so paging always advances; the dropped events
|
|
80
|
+
* come back on the next older page.
|
|
81
|
+
*/
|
|
82
|
+
export function historyPageForClient(page: HistoryPage): HistoryPage {
|
|
83
|
+
const events = page.events.map(eventForClient);
|
|
84
|
+
let room = HISTORY_PAGE_MAX_CHARS;
|
|
85
|
+
let start = events.length;
|
|
86
|
+
while (start > 0) {
|
|
87
|
+
const event = events[start - 1];
|
|
88
|
+
if (event === undefined) break;
|
|
89
|
+
const size = JSON.stringify(event).length + 1;
|
|
90
|
+
if (size > room && start < events.length) break;
|
|
91
|
+
room -= size;
|
|
92
|
+
start -= 1;
|
|
93
|
+
}
|
|
94
|
+
if (start === 0) return { events, prevCursor: page.prevCursor };
|
|
95
|
+
const kept = events.slice(start);
|
|
96
|
+
const oldest = kept[0];
|
|
97
|
+
return { events: kept, prevCursor: oldest === undefined ? page.prevCursor : oldest.seq };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One slice of an attachment's `content`, starting at `offset` (in characters). */
|
|
101
|
+
export interface AttachmentChunk {
|
|
102
|
+
readonly data: string;
|
|
103
|
+
/** The full `content` length, so the reader knows when it has everything. */
|
|
104
|
+
readonly total: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Slice attachment `index` of `event`. Slices start on a multiple of four
|
|
109
|
+
* characters when `offset` does, so base64 slices decode on their own too.
|
|
110
|
+
*/
|
|
111
|
+
export function readAttachmentChunk(event: MoxxyEvent, index: number, offset: number): AttachmentChunk {
|
|
112
|
+
if (event.type !== 'user_prompt' || !event.attachments) {
|
|
113
|
+
throw new Error(`event ${event.seq} has no attachments`);
|
|
114
|
+
}
|
|
115
|
+
const att = event.attachments[index];
|
|
116
|
+
if (!att) throw new Error(`event ${event.seq} has no attachment ${index}`);
|
|
117
|
+
const start = Math.max(0, Math.floor(offset));
|
|
118
|
+
return { data: att.content.slice(start, start + ATTACHMENT_READ_CHUNK_CHARS), total: att.content.length };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How an attachment is named across the conversation: `seq:index` of the prompt that carried it. */
|
|
122
|
+
export function attachmentKey(seq: number, index: number): string {
|
|
123
|
+
return `${seq}:${index}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One attachment found in the log, with where it sits. */
|
|
127
|
+
export interface FoundAttachment {
|
|
128
|
+
readonly seq: number;
|
|
129
|
+
readonly index: number;
|
|
130
|
+
readonly attachment: UserPromptAttachment;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function* attachmentsNewestFirst(events: ReadonlyArray<MoxxyEvent>): Generator<FoundAttachment> {
|
|
134
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
135
|
+
const e = events[i];
|
|
136
|
+
if (!e || e.type !== 'user_prompt' || !e.attachments) continue;
|
|
137
|
+
for (let index = e.attachments.length - 1; index >= 0; index -= 1) {
|
|
138
|
+
const attachment = e.attachments[index];
|
|
139
|
+
if (attachment) yield { seq: e.seq, index, attachment };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The attachment `query` names: its key (`seq:index`) or its file name, the
|
|
146
|
+
* newest one of that name, whatever the case. Null when none matches.
|
|
147
|
+
*/
|
|
148
|
+
export function findAttachment(events: ReadonlyArray<MoxxyEvent>, query: string): FoundAttachment | null {
|
|
149
|
+
const wanted = query.trim();
|
|
150
|
+
const byKey = /^(\d+):(\d+)$/u.exec(wanted);
|
|
151
|
+
const name = wanted.toLowerCase();
|
|
152
|
+
for (const found of attachmentsNewestFirst(events)) {
|
|
153
|
+
if (byKey) {
|
|
154
|
+
if (found.seq === Number(byKey[1]) && found.index === Number(byKey[2])) return found;
|
|
155
|
+
} else if (found.attachment.name?.toLowerCase() === name) {
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every attachment of the conversation, oldest first, as `seq:index name`. */
|
|
163
|
+
export function listAttachments(events: ReadonlyArray<MoxxyEvent>): string[] {
|
|
164
|
+
return [...attachmentsNewestFirst(events)]
|
|
165
|
+
.reverse()
|
|
166
|
+
.map(({ seq, index, attachment }) => `${attachmentKey(seq, index)} ${attachment.name ?? attachment.kind}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What `recall({ attachment })` returns for an image or a document: a pointer,
|
|
171
|
+
* not the bytes, which stay once in the log. The projection puts the
|
|
172
|
+
* attachment itself beside this result, so the model sees the original again
|
|
173
|
+
* even after its turn was summarized away.
|
|
174
|
+
*/
|
|
175
|
+
export interface RestoredAttachmentOutput {
|
|
176
|
+
readonly forModel: string;
|
|
177
|
+
readonly restoredAttachment: { readonly seq: number; readonly index: number };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The attachment a recall result restores, or null for any other output. */
|
|
181
|
+
export function restoredAttachmentRef(output: unknown): { seq: number; index: number } | null {
|
|
182
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
183
|
+
const ref = (output as { restoredAttachment?: unknown }).restoredAttachment;
|
|
184
|
+
if (typeof ref !== 'object' || ref === null) return null;
|
|
185
|
+
const { seq, index } = ref as { seq?: unknown; index?: unknown };
|
|
186
|
+
return typeof seq === 'number' && typeof index === 'number' ? { seq, index } : null;
|
|
187
|
+
}
|
package/src/auto-approve.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { AUTO_APPROVE_PLUGIN_ID, AUTO_APPROVE_SUBTYPE, autoApproveFromEvents, autoApproveSwitch } from './auto-approve.js';
|
|
2
|
+
import { AUTO_APPROVE_PLUGIN_ID, AUTO_APPROVE_SUBTYPE, autoApproveFromAgent, autoApproveFromEvents, autoApproveRecorded, autoApproveSwitch } from './auto-approve.js';
|
|
3
3
|
import type { MoxxyEvent } from './events.js';
|
|
4
4
|
|
|
5
5
|
let seq = 0;
|
|
6
6
|
const base = () => ({ id: `e${seq}`, seq: seq++, ts: 0, sessionId: 's', turnId: 't' }) as const;
|
|
7
7
|
const switched = (enabled: unknown): MoxxyEvent =>
|
|
8
8
|
({ ...base(), type: 'plugin_event', source: 'user', pluginId: AUTO_APPROVE_PLUGIN_ID, subtype: AUTO_APPROVE_SUBTYPE, payload: { enabled } }) as unknown as MoxxyEvent;
|
|
9
|
+
const fromSystem = (enabled: boolean): MoxxyEvent => ({ ...switched(enabled), source: 'system' }) as MoxxyEvent;
|
|
9
10
|
const prompt = (): MoxxyEvent => ({ ...base(), type: 'user_prompt', source: 'user', text: 'hi' }) as unknown as MoxxyEvent;
|
|
10
11
|
const otherPlugin = (): MoxxyEvent =>
|
|
11
12
|
({ ...base(), type: 'plugin_event', source: 'plugin', pluginId: 'x', subtype: AUTO_APPROVE_SUBTYPE, payload: { enabled: true } }) as unknown as MoxxyEvent;
|
|
@@ -26,4 +27,15 @@ describe('auto-approve fold', () => {
|
|
|
26
27
|
expect(autoApproveSwitch(otherPlugin())).toBeNull();
|
|
27
28
|
expect(autoApproveSwitch(switched('yes'))).toBeNull();
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
it('says whether the conversation recorded a switch at all, even one back to off', () => {
|
|
32
|
+
expect(autoApproveRecorded([prompt(), otherPlugin()])).toBe(false);
|
|
33
|
+
expect(autoApproveRecorded([switched(true), prompt(), switched(false)])).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("says whether the switch in force is the agent's own setup Moxxy took on, not the person's choice", () => {
|
|
37
|
+
expect(autoApproveFromAgent([prompt()])).toBe(false);
|
|
38
|
+
expect(autoApproveFromAgent([fromSystem(true), prompt()])).toBe(true);
|
|
39
|
+
expect(autoApproveFromAgent([fromSystem(true), switched(false), switched(true)])).toBe(false);
|
|
40
|
+
});
|
|
29
41
|
});
|
package/src/auto-approve.ts
CHANGED
|
@@ -9,6 +9,11 @@ import { asPluginId } from './ids.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export const AUTO_APPROVE_PLUGIN_ID = asPluginId('@moxxy/auto-approve');
|
|
11
11
|
export const AUTO_APPROVE_SUBTYPE = 'auto_approve';
|
|
12
|
+
/**
|
|
13
|
+
* An agent connection undid a change made outside Moxxy that let the agent act
|
|
14
|
+
* without asking: a note for the person, not a switch (the state stays as it was).
|
|
15
|
+
*/
|
|
16
|
+
export const AUTO_APPROVE_RESTORED_SUBTYPE = 'auto_approve_restored';
|
|
12
17
|
|
|
13
18
|
/** The on/off an event records, or null when it isn't an auto-approve switch. */
|
|
14
19
|
export function autoApproveSwitch(event: MoxxyEvent): boolean | null {
|
|
@@ -27,3 +32,17 @@ export function autoApproveFromEvents(events: ReadonlyArray<MoxxyEvent>): boolea
|
|
|
27
32
|
}
|
|
28
33
|
return false;
|
|
29
34
|
}
|
|
35
|
+
|
|
36
|
+
/** Whether the conversation recorded any switch (the person's, or the agent's own setup Moxxy took on): until then its agent's own setup shows. */
|
|
37
|
+
export function autoApproveRecorded(events: ReadonlyArray<MoxxyEvent>): boolean {
|
|
38
|
+
return events.some((event) => autoApproveSwitch(event) !== null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether the switch in force is the agent's own setup Moxxy took on (recorded by the system), not the person's choice. */
|
|
42
|
+
export function autoApproveFromAgent(events: ReadonlyArray<MoxxyEvent>): boolean {
|
|
43
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
44
|
+
const event = events[i];
|
|
45
|
+
if (event && autoApproveSwitch(event) !== null) return event.source !== 'user';
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/src/channel-auth.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* cannot set an `Authorization` header (browser / React Native), so the
|
|
15
15
|
* secret stays out of the URL (query strings leak via logs and QR/stdout).
|
|
16
16
|
*
|
|
17
|
-
* Channels that expose a network surface (
|
|
17
|
+
* Channels that expose a network surface (HTTP, web) should
|
|
18
18
|
* resolve their token with the former and gate connections with the latter,
|
|
19
19
|
* so the `channels.<name>.token` config + `MOXXY_<NAME>_TOKEN` env conventions
|
|
20
20
|
* are uniform across every channel.
|
|
@@ -30,9 +30,9 @@ import { bearerTokenMatches } from './http-utils.js';
|
|
|
30
30
|
export interface ChannelTokenOptions {
|
|
31
31
|
/** Explicit token from channel config (`channels.<name>.token`). */
|
|
32
32
|
readonly configured?: string;
|
|
33
|
-
/** Env var checked first, e.g. `
|
|
33
|
+
/** Env var checked first, e.g. `MOXXY_HTTP_TOKEN`. */
|
|
34
34
|
readonly envVar?: string;
|
|
35
|
-
/** File to persist a generated token, e.g. `
|
|
35
|
+
/** File to persist a generated token, e.g. `http-token`. */
|
|
36
36
|
readonly fileName: string;
|
|
37
37
|
/** Directory the token file lives under. Defaults to the moxxy home dir;
|
|
38
38
|
* the desktop bridge passes its Electron `userData` dir here instead. */
|
package/src/channel.ts
CHANGED
|
@@ -241,7 +241,7 @@ export interface ChannelConnectStep {
|
|
|
241
241
|
/**
|
|
242
242
|
* Presentation of the channel's runtime connect value:
|
|
243
243
|
* - `qr`: render a QR of the value plus the value itself (e.g. scan/open a
|
|
244
|
-
* `t.me/<bot>` link
|
|
244
|
+
* `t.me/<bot>` link).
|
|
245
245
|
* - `url`: show the value as a copyable URL the user pastes elsewhere
|
|
246
246
|
* (Slack's Events Request URL).
|
|
247
247
|
* - `instructions`: static `steps`, no runtime value.
|
package/src/client-session.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { CommandDef } from './command.js';
|
|
|
3
3
|
import type { ModeDef } from './mode.js';
|
|
4
4
|
import type { LLMProvider, ProviderDef } from './provider.js';
|
|
5
5
|
import type { RequirementCheck, MoxxyRequirement } from './requirements.js';
|
|
6
|
-
import type {
|
|
6
|
+
import type { StandingAllowRule } from './permission.js';
|
|
7
|
+
import type { SessionInfo, SessionLike, SessionLogReader } from './session-like.js';
|
|
7
8
|
import type { Skill } from './skill.js';
|
|
8
9
|
import type { ToolDef } from './tool.js';
|
|
9
10
|
import type { Transcriber } from './transcriber.js';
|
|
@@ -80,7 +81,7 @@ export interface RequirementsClientView {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export interface PermissionsClientView {
|
|
83
|
-
addAllow(rule:
|
|
84
|
+
addAllow(rule: StandingAllowRule): Promise<void>;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export interface ClientSession extends SessionLike {
|
|
@@ -99,4 +100,10 @@ export interface ClientSession extends SessionLike {
|
|
|
99
100
|
/** Switch the conversation's auto-approve (read it from `getInfo().autoApprove`).
|
|
100
101
|
* Optional: a client of a runner without the shared switch has none. */
|
|
101
102
|
setAutoApprove?(enabled: boolean): Promise<void>;
|
|
103
|
+
/** Stop one work the agent runs in the background (`AgentBackgroundWork.id`); `false` when it runs none such.
|
|
104
|
+
* Optional: a client of a runner that cannot pass it on has none. */
|
|
105
|
+
stopBackgroundWork?(workId: string): Promise<boolean>;
|
|
106
|
+
/** Called with the new info when it changes outside a turn (e.g. an agent CLI
|
|
107
|
+
* was found outdated); returns the unsubscribe. */
|
|
108
|
+
onInfoChanged?(listener: (info: SessionInfo) => void): () => void;
|
|
102
109
|
}
|
package/src/computer-control.ts
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** The conversation as Moxxy keeps it: what a new agent session starts from, and what `recall` searches. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { conversationTurns, renderEarlierConversation, renderMissedTurns, searchConversation } from './conversation-history.js';
|
|
4
|
+
import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from './events.js';
|
|
5
|
+
import { asEventId, asSessionId, asToolCallId, asTurnId, type TurnId } from './ids.js';
|
|
6
|
+
import type { EventLogReader } from './log.js';
|
|
7
|
+
|
|
8
|
+
const sid = asSessionId('s1');
|
|
9
|
+
|
|
10
|
+
function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
|
11
|
+
return {
|
|
12
|
+
length: events.length,
|
|
13
|
+
at: (seq) => events[seq],
|
|
14
|
+
slice: (from = 0, to = events.length) => events.slice(from, to),
|
|
15
|
+
ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
|
|
16
|
+
events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
|
|
17
|
+
byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
|
|
18
|
+
toJSON: () => events,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A log built turn by turn: what the person said, what the agent did and answered. */
|
|
23
|
+
function conversation(
|
|
24
|
+
turns: ReadonlyArray<{ said: string; reply?: string; tool?: { name: string; input: unknown }; checkpoint?: string; provider?: string }>,
|
|
25
|
+
): MoxxyEvent[] {
|
|
26
|
+
const events: MoxxyEvent[] = [];
|
|
27
|
+
const push = (turnId: TurnId, partial: Record<string, unknown>) => {
|
|
28
|
+
const seq = events.length;
|
|
29
|
+
events.push({ id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, turnId, ...partial } as MoxxyEvent);
|
|
30
|
+
};
|
|
31
|
+
turns.forEach((turn, index) => {
|
|
32
|
+
const turnId = asTurnId(`t${index + 1}`);
|
|
33
|
+
push(turnId, { type: 'user_prompt', source: 'user', text: turn.said });
|
|
34
|
+
if (turn.provider) push(turnId, { type: 'provider_request', source: 'system', provider: turn.provider, model: 'm' });
|
|
35
|
+
if (turn.tool) {
|
|
36
|
+
push(turnId, { type: 'tool_call_requested', source: 'model', callId: asToolCallId(`c${index}`), name: turn.tool.name, input: turn.tool.input });
|
|
37
|
+
push(turnId, { type: 'tool_result', source: 'tool', callId: asToolCallId(`c${index}`), ok: true, output: 'SECRET TOOL OUTPUT' });
|
|
38
|
+
}
|
|
39
|
+
if (turn.checkpoint) push(turnId, { type: 'user_prompt', source: 'system', text: turn.checkpoint, origin: { kind: 'checkpoint', name: 'x' } });
|
|
40
|
+
if (turn.reply) push(turnId, { type: 'assistant_message', source: 'model', content: turn.reply, stopReason: 'end_turn' });
|
|
41
|
+
});
|
|
42
|
+
return events;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('the turns of a conversation', () => {
|
|
46
|
+
it('reads each turn the person started: their words, the replies and the tools, never tool output or checkpoints', () => {
|
|
47
|
+
const log = reader(
|
|
48
|
+
conversation([
|
|
49
|
+
{ said: 'Set the database to port 5433', tool: { name: 'Edit', input: { file_path: 'db/config.ts' } }, checkpoint: 'Continue.', reply: 'Done: 5433.' },
|
|
50
|
+
{ said: 'Thanks' },
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const turns = conversationTurns(log);
|
|
55
|
+
|
|
56
|
+
expect(turns.map((turn) => turn.number)).toEqual([1, 2]);
|
|
57
|
+
expect(turns[0]).toMatchObject({ said: ['Set the database to port 5433'], replies: ['Done: 5433.'], tools: ['Edit db/config.ts'] });
|
|
58
|
+
expect(turns[0]?.text).not.toContain('SECRET TOOL OUTPUT');
|
|
59
|
+
expect(turns[0]?.text).not.toContain('Continue.');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('what a new agent session gets of the conversation', () => {
|
|
64
|
+
it('is nothing on the first turn', () => {
|
|
65
|
+
const log = reader(conversation([{ said: 'Hello' }]));
|
|
66
|
+
|
|
67
|
+
expect(renderEarlierConversation(log, asTurnId('t1'))).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('gives the earlier turns word for word, as history and not a request, and leaves out the turn running now', () => {
|
|
71
|
+
const log = reader(conversation([{ said: 'Use port 5433', reply: 'OK, 5433.' }, { said: 'What port did we pick?' }]));
|
|
72
|
+
|
|
73
|
+
const text = renderEarlierConversation(log, asTurnId('t2')) ?? '';
|
|
74
|
+
|
|
75
|
+
expect(text).toContain('not a new request');
|
|
76
|
+
expect(text).toContain('### Turn 1\nPerson: Use port 5433\nAgent: OK, 5433.');
|
|
77
|
+
expect(text).not.toContain('What port did we pick?');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps within its budget on a long conversation: the latest turns whole, a line for each older one', () => {
|
|
81
|
+
const turns = Array.from({ length: 300 }, (_, i) => ({ said: `Request ${i + 1}: ${'detail '.repeat(40)}`, reply: 'answer '.repeat(200) }));
|
|
82
|
+
turns[11] = { said: 'Set the database to port 5433', reply: 'Done.' };
|
|
83
|
+
const log = reader(conversation([...turns, { said: 'What port?' }]));
|
|
84
|
+
|
|
85
|
+
const text = renderEarlierConversation(log, asTurnId('t301'), 64_000) ?? '';
|
|
86
|
+
|
|
87
|
+
expect(text.length).toBeLessThan(66_000);
|
|
88
|
+
expect(text).toContain('### Turn 300');
|
|
89
|
+
expect(text).toContain('call recall before you answer');
|
|
90
|
+
expect(text).toContain('Never say you do not know, or offer to look, without searching first.');
|
|
91
|
+
expect(text).not.toContain('### Turn 12\n');
|
|
92
|
+
expect(text).toContain('- turn 1: Request 1');
|
|
93
|
+
expect(text).toMatch(/- turns \d+–\d+: not listed here; recall with a query finds them/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('cuts one turn too long for the budget and says how to read the rest', () => {
|
|
97
|
+
const log = reader(conversation([{ said: 'x'.repeat(50_000) }, { said: 'next' }]));
|
|
98
|
+
|
|
99
|
+
const text = renderEarlierConversation(log, asTurnId('t2'), 2_000) ?? '';
|
|
100
|
+
|
|
101
|
+
expect(text.length).toBeLessThan(2_600);
|
|
102
|
+
expect(text).toContain('recall({ turn: 1 })');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('what an agent coming back to the conversation gets of the turns it missed', () => {
|
|
107
|
+
it('is nothing when it never ran in this conversation: a new session of its own gets the whole conversation instead', () => {
|
|
108
|
+
const log = reader(conversation([{ said: 'Use port 5433', provider: 'codex-agent' }, { said: 'Which port?' }]));
|
|
109
|
+
|
|
110
|
+
expect(renderMissedTurns(log, 'claude-code', asTurnId('t2'))).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is nothing when it ran the turn before this one', () => {
|
|
114
|
+
const log = reader(conversation([{ said: 'Use port 5433', provider: 'claude-code' }, { said: 'Which port?', provider: 'claude-code' }]));
|
|
115
|
+
|
|
116
|
+
expect(renderMissedTurns(log, 'claude-code', asTurnId('t2'))).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('gives, word for word and as history, only the turns another connection ran since its own last turn', () => {
|
|
120
|
+
const log = reader(
|
|
121
|
+
conversation([
|
|
122
|
+
{ said: 'Use port 5433', reply: 'OK.', provider: 'claude-code' },
|
|
123
|
+
{ said: 'Rename the table to orders', reply: 'Renamed it to orders.', provider: 'codex-agent' },
|
|
124
|
+
{ said: 'And add an index', reply: 'Index added.', provider: 'anthropic' },
|
|
125
|
+
{ said: 'What did Codex rename?', provider: 'claude-code' },
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const text = renderMissedTurns(log, 'claude-code', asTurnId('t4')) ?? '';
|
|
130
|
+
|
|
131
|
+
expect(text).toContain('another connection');
|
|
132
|
+
expect(text).toContain('not a new request');
|
|
133
|
+
expect(text).toContain('### Turn 2\nPerson: Rename the table to orders\nAgent: Renamed it to orders.');
|
|
134
|
+
expect(text).toContain('### Turn 3\nPerson: And add an index\nAgent: Index added.');
|
|
135
|
+
expect(text).not.toContain('Use port 5433');
|
|
136
|
+
expect(text).not.toContain('What did Codex rename?');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ends by saying the record is over and the person's newest message comes next, so it is not taken for part of the record", () => {
|
|
140
|
+
const log = reader(
|
|
141
|
+
conversation([
|
|
142
|
+
{ said: 'Use port 5433', reply: 'OK.', provider: 'claude-code' },
|
|
143
|
+
{ said: 'Answer with OK', reply: 'OK', provider: 'codex-agent' },
|
|
144
|
+
{ said: 'Create notes.txt', provider: 'claude-code' },
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
for (const text of [renderMissedTurns(log, 'claude-code', asTurnId('t3')), renderEarlierConversation(log, asTurnId('t3'))]) {
|
|
149
|
+
expect(text).toMatch(/End of Moxxy's record\. The person's newest message comes next: that is the request to act on\.$/);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('searching the conversation', () => {
|
|
155
|
+
const log = reader(
|
|
156
|
+
conversation([
|
|
157
|
+
{ said: 'Ustawmy bazę na porcie 5433', reply: 'Zrobione.' },
|
|
158
|
+
{ said: 'Add a login page', tool: { name: 'Write', input: { file_path: 'src/login.tsx', content: 'export function Login() {}' } }, reply: 'Added.' },
|
|
159
|
+
{ said: 'jaki port bazy ustaliliśmy?' },
|
|
160
|
+
]),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it('finds a turn by words in another form (port, porcie; bazy, bazę)', () => {
|
|
164
|
+
const [first] = searchConversation(log, 'port bazy', { exceptTurnId: asTurnId('t3') });
|
|
165
|
+
|
|
166
|
+
expect(first?.turn.number).toBe(1);
|
|
167
|
+
expect(first?.snippets.join(' ')).toContain('5433');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('shows words that stand close together in one snippet', () => {
|
|
171
|
+
const [first] = searchConversation(log, 'baza port 5433', { exceptTurnId: asTurnId('t3') });
|
|
172
|
+
|
|
173
|
+
expect(first?.snippets).toHaveLength(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('finds what a tool was given, such as a file it wrote', () => {
|
|
177
|
+
expect(searchConversation(log, 'login.tsx')[0]?.turn.number).toBe(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('leaves out the turn asking and finds nothing for words never said', () => {
|
|
181
|
+
expect(searchConversation(log, 'ustaliliśmy', { exceptTurnId: asTurnId('t3') })).toEqual([]);
|
|
182
|
+
expect(searchConversation(log, 'kubernetes')).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|