@frockbot/plugin-shell 0.3.11 → 0.3.12
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/package.json +34 -33
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +6 -6
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/FrockBotApp.vue +405 -75
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +118 -19
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +135 -36
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +11 -2
- package/src/shared.ts +36 -0
- package/tsconfig.json +1 -2
- package/src/client/activity-ring.test.ts +0 -89
- package/src/client/activity-ring.ts +0 -94
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The rule the User stated, checked as arithmetic:
|
|
2
|
+
//
|
|
3
|
+
// "This is one notification per message for a bot that is out of focus. If I
|
|
4
|
+
// have the bot open then that shouldn't raise a notification (or a badge on
|
|
5
|
+
// the list of bots). And when I open a chat that should clear the badge."
|
|
6
|
+
//
|
|
7
|
+
// Everything here composes the real durable pieces — `advanceUnreadActivityV1`
|
|
8
|
+
// for a settled Turn, `projectBotUnreadViewV1` for the count, `markUnreadReadV1`
|
|
9
|
+
// for the read receipt — with the focus rule on top, so a change to either side
|
|
10
|
+
// has to keep the sentence true rather than only its own unit test.
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
isBotFocusedV1,
|
|
14
|
+
readViewerFocusV1,
|
|
15
|
+
shouldNotifyForBotV1,
|
|
16
|
+
suppressUnreadWhileFocusedV1,
|
|
17
|
+
type ViewerFocusV1,
|
|
18
|
+
} from "./focus.js";
|
|
19
|
+
import {
|
|
20
|
+
advanceUnreadActivityV1,
|
|
21
|
+
emptyUnreadStateV1,
|
|
22
|
+
markUnreadReadV1,
|
|
23
|
+
projectBotUnreadViewV1,
|
|
24
|
+
type BotUnreadViewV1,
|
|
25
|
+
type UnreadStateV1,
|
|
26
|
+
} from "./unread.js";
|
|
27
|
+
|
|
28
|
+
const BOT = "alpha";
|
|
29
|
+
const OTHER = "beta";
|
|
30
|
+
|
|
31
|
+
/** A cursor in the exact shape the Bot's admission index writes. */
|
|
32
|
+
function cursor(minute: number): string {
|
|
33
|
+
const at = new Date(Date.UTC(2026, 8, 4, 0, minute, 0)).toISOString();
|
|
34
|
+
return `run-index:${at}:run-${minute}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function at(minute: number): string {
|
|
38
|
+
return new Date(Date.UTC(2026, 8, 4, 0, minute, 0)).toISOString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Newest-first, exactly as the Bot's run index returns it. */
|
|
42
|
+
function index(minutes: readonly number[]): string[] {
|
|
43
|
+
return [...minutes].sort((a, b) => b - a).map(cursor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One settled chat Turn landing on the Bot's durable unread record. */
|
|
47
|
+
function settle(state: UnreadStateV1, minute: number): UnreadStateV1 {
|
|
48
|
+
return advanceUnreadActivityV1(state, {
|
|
49
|
+
cursor: cursor(minute),
|
|
50
|
+
at: at(minute),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the sidebar actually renders for a Bot: the Durable Object's own
|
|
56
|
+
* projection, with the focus rule applied by the client that knows which chat
|
|
57
|
+
* is on screen.
|
|
58
|
+
*/
|
|
59
|
+
function rowFor(
|
|
60
|
+
state: UnreadStateV1,
|
|
61
|
+
minutes: readonly number[],
|
|
62
|
+
focus: ViewerFocusV1,
|
|
63
|
+
botId = BOT,
|
|
64
|
+
): BotUnreadViewV1 {
|
|
65
|
+
const view = projectBotUnreadViewV1(botId, state, index(minutes));
|
|
66
|
+
return isBotFocusedV1(focus, botId)
|
|
67
|
+
? suppressUnreadWhileFocusedV1(view)
|
|
68
|
+
: view;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const OPEN_AND_LOOKING: ViewerFocusV1 = {
|
|
72
|
+
activeBotId: BOT,
|
|
73
|
+
visible: true,
|
|
74
|
+
focused: true,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("what counts as focused", () => {
|
|
78
|
+
test("all three, and nothing less", () => {
|
|
79
|
+
expect(isBotFocusedV1(OPEN_AND_LOOKING, BOT)).toBe(true);
|
|
80
|
+
// A different chat is open.
|
|
81
|
+
expect(isBotFocusedV1(OPEN_AND_LOOKING, OTHER)).toBe(false);
|
|
82
|
+
expect(isBotFocusedV1({ visible: true, focused: true }, BOT)).toBe(false);
|
|
83
|
+
// The chat is open in a tab nobody can see.
|
|
84
|
+
expect(isBotFocusedV1({ ...OPEN_AND_LOOKING, visible: false }, BOT)).toBe(
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
// Visible, but behind another window.
|
|
88
|
+
expect(isBotFocusedV1({ ...OPEN_AND_LOOKING, focused: false }, BOT)).toBe(
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("notifying is exactly the inverse", () => {
|
|
94
|
+
for (const focus of [
|
|
95
|
+
OPEN_AND_LOOKING,
|
|
96
|
+
{ ...OPEN_AND_LOOKING, visible: false },
|
|
97
|
+
{ ...OPEN_AND_LOOKING, focused: false },
|
|
98
|
+
{ visible: true, focused: true },
|
|
99
|
+
] satisfies ViewerFocusV1[]) {
|
|
100
|
+
for (const botId of [BOT, OTHER]) {
|
|
101
|
+
expect(shouldNotifyForBotV1(focus, botId)).toBe(
|
|
102
|
+
!isBotFocusedV1(focus, botId),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a document that cannot be read is not a focused one", () => {
|
|
109
|
+
// Bun has no DOM: the reader answers for the environment it is in rather
|
|
110
|
+
// than assuming the User is watching.
|
|
111
|
+
expect(readViewerFocusV1(BOT)).toEqual({
|
|
112
|
+
activeBotId: BOT,
|
|
113
|
+
visible: false,
|
|
114
|
+
focused: false,
|
|
115
|
+
});
|
|
116
|
+
expect(readViewerFocusV1()).toEqual({ visible: false, focused: false });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("a message while the Bot is focused", () => {
|
|
121
|
+
test("raises no notification and no badge", () => {
|
|
122
|
+
const state = settle(emptyUnreadStateV1(), 1);
|
|
123
|
+
expect(shouldNotifyForBotV1(OPEN_AND_LOOKING, BOT)).toBe(false);
|
|
124
|
+
expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
|
|
125
|
+
count: 0,
|
|
126
|
+
capped: false,
|
|
127
|
+
unread: false,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("and neither does the tenth one", () => {
|
|
132
|
+
let state = emptyUnreadStateV1();
|
|
133
|
+
for (const minute of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
|
|
134
|
+
state = settle(state, minute);
|
|
135
|
+
expect(
|
|
136
|
+
rowFor(state, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], OPEN_AND_LOOKING),
|
|
137
|
+
).toMatchObject({ count: 0, unread: false });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("but a Bot the User marked unread on purpose stays bold", () => {
|
|
142
|
+
// Intent is not arithmetic: suppression clears a count, never a decision.
|
|
143
|
+
const state = { ...settle(emptyUnreadStateV1(), 1), manuallyUnread: true };
|
|
144
|
+
expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
|
|
145
|
+
unread: true,
|
|
146
|
+
manuallyUnread: true,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("a message while the Bot is not focused", () => {
|
|
152
|
+
test("is one badge per message, not one per burst", () => {
|
|
153
|
+
let state = emptyUnreadStateV1();
|
|
154
|
+
const away: ViewerFocusV1 = { ...OPEN_AND_LOOKING, activeBotId: OTHER };
|
|
155
|
+
const settled: number[] = [];
|
|
156
|
+
const counts: number[] = [];
|
|
157
|
+
for (const minute of [1, 2, 3]) {
|
|
158
|
+
settled.push(minute);
|
|
159
|
+
state = settle(state, minute);
|
|
160
|
+
counts.push(rowFor(state, settled, away).count);
|
|
161
|
+
}
|
|
162
|
+
expect(counts).toEqual([1, 2, 3]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("counts for the open chat too when the tab is hidden", () => {
|
|
166
|
+
const state = settle(settle(emptyUnreadStateV1(), 1), 2);
|
|
167
|
+
const backgrounded = { ...OPEN_AND_LOOKING, visible: false };
|
|
168
|
+
expect(shouldNotifyForBotV1(backgrounded, BOT)).toBe(true);
|
|
169
|
+
expect(rowFor(state, [1, 2], backgrounded)).toMatchObject({
|
|
170
|
+
count: 2,
|
|
171
|
+
unread: true,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("and when the window is behind another one", () => {
|
|
176
|
+
const state = settle(emptyUnreadStateV1(), 1);
|
|
177
|
+
const behind = { ...OPEN_AND_LOOKING, focused: false };
|
|
178
|
+
expect(shouldNotifyForBotV1(behind, BOT)).toBe(true);
|
|
179
|
+
expect(rowFor(state, [1], behind)).toMatchObject({ count: 1 });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("opening the chat", () => {
|
|
184
|
+
test("clears the badge, and it stays clear for the next message read there", () => {
|
|
185
|
+
let state = settle(settle(emptyUnreadStateV1(), 1), 2);
|
|
186
|
+
const away = { ...OPEN_AND_LOOKING, activeBotId: OTHER };
|
|
187
|
+
expect(rowFor(state, [1, 2], away).count).toBe(2);
|
|
188
|
+
|
|
189
|
+
// Opening is the authenticated read receipt, up to the cursor the fan-out
|
|
190
|
+
// named. Nothing about focus is stored: the durable record is a cursor.
|
|
191
|
+
const opened = projectBotUnreadViewV1(BOT, state, index([1, 2]));
|
|
192
|
+
state = markUnreadReadV1(state, {
|
|
193
|
+
upToCursor: opened.lastActivityCursor ?? cursor(2),
|
|
194
|
+
at: at(3),
|
|
195
|
+
});
|
|
196
|
+
expect(rowFor(state, [1, 2], OPEN_AND_LOOKING).count).toBe(0);
|
|
197
|
+
// Durable, not remembered: the same record read by any other tab is 0 too.
|
|
198
|
+
expect(projectBotUnreadViewV1(BOT, state, index([1, 2]))).toMatchObject({
|
|
199
|
+
count: 0,
|
|
200
|
+
unread: false,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// A reply that arrives while the chat is open and read renders nothing,
|
|
204
|
+
// and the receipt that follows keeps it that way after the tab is closed.
|
|
205
|
+
state = settle(state, 4);
|
|
206
|
+
expect(rowFor(state, [1, 2, 4], OPEN_AND_LOOKING).count).toBe(0);
|
|
207
|
+
state = markUnreadReadV1(state, { upToCursor: cursor(4), at: at(5) });
|
|
208
|
+
expect(projectBotUnreadViewV1(BOT, state, index([1, 2, 4]))).toMatchObject({
|
|
209
|
+
count: 0,
|
|
210
|
+
unread: false,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("clears a manual unread as well", () => {
|
|
215
|
+
let state = { ...settle(emptyUnreadStateV1(), 1), manuallyUnread: true };
|
|
216
|
+
state = markUnreadReadV1(state, { upToCursor: cursor(1), at: at(2) });
|
|
217
|
+
expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
|
|
218
|
+
unread: false,
|
|
219
|
+
manuallyUnread: false,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "focused", shared by every surface that has to decide
|
|
3
|
+
* whether a Bot's message is something the User is already looking at.
|
|
4
|
+
*
|
|
5
|
+
* The rule, stated once so the Shell and the Flock sidebar cannot drift:
|
|
6
|
+
*
|
|
7
|
+
* > A Bot is **focused** when its chat is the open one, the tab is visible,
|
|
8
|
+
* > and the window holds focus.
|
|
9
|
+
*
|
|
10
|
+
* All three, because each one on its own gets a case wrong. Only the open Bot
|
|
11
|
+
* is being read, so another Bot's reply is news however attentive the User is.
|
|
12
|
+
* A tab in the background is not being read even though its chat is still
|
|
13
|
+
* "open" — `document.visibilityState` is what says so. And a visible tab
|
|
14
|
+
* behind another window is not being read either, which only
|
|
15
|
+
* `document.hasFocus()` can tell: without it, a Bot that replied while the
|
|
16
|
+
* User was in another app stayed silent and left no badge, so the message was
|
|
17
|
+
* never heard about at all.
|
|
18
|
+
*
|
|
19
|
+
* A focused Bot raises no notification and carries no unread badge. Every
|
|
20
|
+
* other message raises exactly one of each.
|
|
21
|
+
*/
|
|
22
|
+
import type { BotUnreadViewV1 } from "./unread.js";
|
|
23
|
+
|
|
24
|
+
/** What the rule needs to know about the browser, captured as plain data. */
|
|
25
|
+
export interface ViewerFocusV1 {
|
|
26
|
+
/** The Bot whose chat is open, if any. */
|
|
27
|
+
activeBotId?: string;
|
|
28
|
+
/** `document.visibilityState === "visible"`. */
|
|
29
|
+
visible: boolean;
|
|
30
|
+
/** `document.hasFocus()`. */
|
|
31
|
+
focused: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the rule's three inputs out of the live document.
|
|
36
|
+
*
|
|
37
|
+
* Outside a browser there is no viewer, so nothing is focused and every
|
|
38
|
+
* message counts — the conservative answer, since a badge can be cleared and
|
|
39
|
+
* an unheard message cannot be un-missed.
|
|
40
|
+
*/
|
|
41
|
+
export function readViewerFocusV1(activeBotId?: string): ViewerFocusV1 {
|
|
42
|
+
const identity = activeBotId === undefined ? {} : { activeBotId };
|
|
43
|
+
if (typeof document === "undefined") {
|
|
44
|
+
return { visible: false, focused: false, ...identity };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
visible: document.visibilityState === "visible",
|
|
48
|
+
// A document without `hasFocus` is not a background window; it is a
|
|
49
|
+
// runtime that does not report focus, and refusing to believe it would
|
|
50
|
+
// badge the chat the User is reading.
|
|
51
|
+
focused:
|
|
52
|
+
typeof document.hasFocus === "function" ? document.hasFocus() : true,
|
|
53
|
+
...identity,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The rule itself. */
|
|
58
|
+
export function isBotFocusedV1(focus: ViewerFocusV1, botId: string): boolean {
|
|
59
|
+
return focus.activeBotId === botId && focus.visible && focus.focused;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a message from this Bot should raise a notification. The inverse of
|
|
64
|
+
* the rule, named so the call site reads as the sentence it implements: one
|
|
65
|
+
* notification per message, for a Bot that is out of focus.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldNotifyForBotV1(
|
|
68
|
+
focus: ViewerFocusV1,
|
|
69
|
+
botId: string,
|
|
70
|
+
): boolean {
|
|
71
|
+
return !isBotFocusedV1(focus, botId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The unread view a focused Bot renders: none.
|
|
76
|
+
*
|
|
77
|
+
* The Bot Durable Object counts every settled Turn, because it cannot know
|
|
78
|
+
* which chat is on screen. A fan-out that returned mid-read would therefore
|
|
79
|
+
* paint a badge on the row the User is looking at, for as long as it took the
|
|
80
|
+
* read receipt to land. The receipt is still sent — "read" is durable, and it
|
|
81
|
+
* is what makes the badge stay gone on the next reload and the next tab — but
|
|
82
|
+
* the row never renders a count it is about to lose.
|
|
83
|
+
*
|
|
84
|
+
* `manuallyUnread` is the exception: a Bot the User deliberately marked unread
|
|
85
|
+
* stays bold while they look at it, because that flag is intent rather than
|
|
86
|
+
* arithmetic, and only opening the Bot again clears it.
|
|
87
|
+
*/
|
|
88
|
+
export function suppressUnreadWhileFocusedV1(
|
|
89
|
+
view: BotUnreadViewV1,
|
|
90
|
+
): BotUnreadViewV1 {
|
|
91
|
+
if (view.manuallyUnread) return view;
|
|
92
|
+
return { ...view, count: 0, capped: false, unread: false };
|
|
93
|
+
}
|
package/src/history.ts
CHANGED
|
@@ -24,6 +24,13 @@ import {
|
|
|
24
24
|
type SessionEvent,
|
|
25
25
|
type TurnTypeV1,
|
|
26
26
|
} from "@frockbot/kernel-contracts";
|
|
27
|
+
import {
|
|
28
|
+
compactionMessageV1,
|
|
29
|
+
compactionStateV1,
|
|
30
|
+
historyCharsV1,
|
|
31
|
+
pruneToolOutputsV1,
|
|
32
|
+
type CompactionStateV1,
|
|
33
|
+
} from "./compaction.js";
|
|
27
34
|
|
|
28
35
|
export { currentTurnV1 };
|
|
29
36
|
|
|
@@ -140,6 +147,68 @@ function budgetedMessagesV1(
|
|
|
140
147
|
: [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
/** The conversation's own messages, after any compaction already recorded. */
|
|
151
|
+
export interface ChatWindowV1 {
|
|
152
|
+
/** Chat-Turn messages the newest compaction does not already cover. */
|
|
153
|
+
messages: LlmMessage[];
|
|
154
|
+
/** The Turn each of those belongs to. */
|
|
155
|
+
turns: number[];
|
|
156
|
+
/** Every chat Turn on the log, in order, covered or not. */
|
|
157
|
+
chatTurns: number[];
|
|
158
|
+
/** What the log says about compaction for this conversation. */
|
|
159
|
+
state: CompactionStateV1;
|
|
160
|
+
/** The newest completed compaction, when there is one. */
|
|
161
|
+
compaction?: CompactionStateV1["compaction"];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The window a chat Turn's request is assembled from, before pruning and
|
|
166
|
+
* before the budget.
|
|
167
|
+
*
|
|
168
|
+
* Shared by request assembly and by the Turn-end evaluation on purpose: what
|
|
169
|
+
* compaction is measured against has to be what the next request would carry,
|
|
170
|
+
* or the trigger fires against a number nobody pays.
|
|
171
|
+
*/
|
|
172
|
+
export function chatWindowV1(
|
|
173
|
+
events: readonly SessionEvent[],
|
|
174
|
+
messages: readonly LlmMessage[],
|
|
175
|
+
): ChatWindowV1 {
|
|
176
|
+
const turns = messageTurnsV1(events);
|
|
177
|
+
const types = turnTypesByTurnV1(events);
|
|
178
|
+
const chat = (turn: number) => (types.get(turn) ?? "chat") === "chat";
|
|
179
|
+
const state = compactionStateV1(events);
|
|
180
|
+
const current = currentTurnV1(events);
|
|
181
|
+
// A compaction never covers the Turn being assembled, whatever the log says:
|
|
182
|
+
// the current Turn is carried whole, as it has been since ADR 0027.
|
|
183
|
+
const compaction =
|
|
184
|
+
state.compaction && state.compaction.throughTurn < current
|
|
185
|
+
? state.compaction
|
|
186
|
+
: undefined;
|
|
187
|
+
const kept: LlmMessage[] = [];
|
|
188
|
+
const keptTurns: number[] = [];
|
|
189
|
+
for (const [index, message] of messages.entries()) {
|
|
190
|
+
const turn = turns[index]!;
|
|
191
|
+
if (!chat(turn)) continue;
|
|
192
|
+
if (compaction && turn <= compaction.throughTurn) continue;
|
|
193
|
+
kept.push(message);
|
|
194
|
+
keptTurns.push(turn);
|
|
195
|
+
}
|
|
196
|
+
const chatTurns = [
|
|
197
|
+
...new Set(
|
|
198
|
+
events.flatMap((event) =>
|
|
199
|
+
event.type === "turn/start" && chat(event.turn) ? [event.turn] : [],
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
];
|
|
203
|
+
return {
|
|
204
|
+
messages: kept,
|
|
205
|
+
turns: keptTurns,
|
|
206
|
+
chatTurns,
|
|
207
|
+
state,
|
|
208
|
+
...(compaction ? { compaction } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
143
212
|
/**
|
|
144
213
|
* The messages one Turn's request may carry, given the whole session log and
|
|
145
214
|
* the messages derived from it.
|
|
@@ -162,15 +231,24 @@ export function turnScopedMessagesV1(
|
|
|
162
231
|
const current = currentTurnV1(input.events);
|
|
163
232
|
const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
|
|
164
233
|
if (chatTurn(current)) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
234
|
+
const window = chatWindowV1(input.events, input.messages);
|
|
235
|
+
// Tier 1 of ADR 0030, and the only one that costs nothing: a tool result
|
|
236
|
+
// older than the newest few Turns keeps its pairing and loses its payload.
|
|
237
|
+
const pruned = pruneToolOutputsV1(window.messages, window.turns);
|
|
238
|
+
const preamble = window.compaction
|
|
239
|
+
? [compactionMessageV1(window.compaction)]
|
|
240
|
+
: [];
|
|
241
|
+
// The summary is never a candidate for eviction — it is what stands in for
|
|
242
|
+
// the Turns eviction would otherwise have dropped — so it is spent from
|
|
243
|
+
// the budget rather than measured against it.
|
|
244
|
+
const budget = Math.max(
|
|
245
|
+
0,
|
|
246
|
+
(input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1) - historyCharsV1(preamble),
|
|
173
247
|
);
|
|
248
|
+
return [
|
|
249
|
+
...preamble,
|
|
250
|
+
...budgetedMessagesV1(pruned, window.turns, current, budget),
|
|
251
|
+
];
|
|
174
252
|
}
|
|
175
253
|
const own = input.messages.filter((_, index) => turns[index] === current);
|
|
176
254
|
const chatTurns = new Set(
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeModelBindingV1,
|
|
4
|
+
decodeUserSettingsViewV1,
|
|
5
|
+
resolveBotModelBindingV1,
|
|
6
|
+
type UserSettingsViewV1,
|
|
7
|
+
} from "@frockbot/configuration-core";
|
|
8
|
+
import { modelRuntimeLabel } from "./client/model-presentation.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bots bound before the provider was renamed carry `@flock/auto` in durable
|
|
12
|
+
* storage. Reading one must land in exactly the same place as reading
|
|
13
|
+
* `@frock/auto`: the same binding, the same resolution, the same line above
|
|
14
|
+
* the composer. Nothing writes the old spelling back.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const FROCK_PACKAGE = {
|
|
18
|
+
packageId: "provider-flock-ai",
|
|
19
|
+
version: "0.0.1",
|
|
20
|
+
settings: [],
|
|
21
|
+
capabilities: [
|
|
22
|
+
{
|
|
23
|
+
id: "flock-ai-models",
|
|
24
|
+
kind: "model" as const,
|
|
25
|
+
connectionTypes: ["flock-ai-account"],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
connectionTypes: [
|
|
29
|
+
{ id: "flock-ai-account", capabilities: ["flock-ai-models"] },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function storedUserSettings(providerModelId: string): Record<string, unknown> {
|
|
34
|
+
return {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
revision: 7,
|
|
37
|
+
profile: { name: "FrockBot user" },
|
|
38
|
+
packages: [
|
|
39
|
+
{ packageId: "provider-flock-ai", version: "0.0.1", state: "installed" },
|
|
40
|
+
],
|
|
41
|
+
connections: [
|
|
42
|
+
{
|
|
43
|
+
connectionId: "flock-ai-ambient",
|
|
44
|
+
packageId: "provider-flock-ai",
|
|
45
|
+
connectionTypeId: "flock-ai-account",
|
|
46
|
+
displayName: "Frock AI",
|
|
47
|
+
state: "ready",
|
|
48
|
+
generation: "flock-ai-ambient-v1",
|
|
49
|
+
providerType: "flock-ai",
|
|
50
|
+
safeMetadata: {},
|
|
51
|
+
modelCatalog: {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
generation: "flock-ai-static-v1",
|
|
54
|
+
state: "fresh",
|
|
55
|
+
models: [
|
|
56
|
+
{
|
|
57
|
+
providerModelId: "@frock/auto",
|
|
58
|
+
displayName: "Auto (recommended)",
|
|
59
|
+
capabilities: { tools: true, vision: false, reasoning: true },
|
|
60
|
+
source: "discovered",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
platformModel: { connectionId: "flock-ai-ambient", providerModelId },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function storedBotModelOverride(
|
|
71
|
+
providerModelId: string,
|
|
72
|
+
): Record<string, unknown> {
|
|
73
|
+
return { connectionId: "flock-ai-ambient", providerModelId };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function composerLine(user: UserSettingsViewV1): string {
|
|
77
|
+
const model = user.platformModel!;
|
|
78
|
+
const binding = resolveBotModelBindingV1({
|
|
79
|
+
model,
|
|
80
|
+
user,
|
|
81
|
+
packages: [FROCK_PACKAGE],
|
|
82
|
+
});
|
|
83
|
+
const connection = binding.connection;
|
|
84
|
+
return modelRuntimeLabel({
|
|
85
|
+
source: "platform",
|
|
86
|
+
modelDisplayName: connection?.modelCatalog?.models.find(
|
|
87
|
+
(candidate) => candidate.providerModelId === model.providerModelId,
|
|
88
|
+
)?.displayName,
|
|
89
|
+
providerModelId: model.providerModelId,
|
|
90
|
+
packageDisplayName: "Frock AI",
|
|
91
|
+
connectionDisplayName: connection?.displayName,
|
|
92
|
+
failure: binding.failure,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("a pre-rename @flock/ model binding", () => {
|
|
97
|
+
test("decodes to the @frock/ spelling", () => {
|
|
98
|
+
expect(decodeUserSettingsViewV1(storedUserSettings("@flock/auto"))).toEqual(
|
|
99
|
+
decodeUserSettingsViewV1(storedUserSettings("@frock/auto")),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
decodeUserSettingsViewV1(storedUserSettings("@flock/auto")).platformModel
|
|
104
|
+
?.providerModelId,
|
|
105
|
+
).toBe("@frock/auto");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("decodes the same way on a Bot's own model override", () => {
|
|
109
|
+
// A Bot override is stored opaquely in `packageValues` and read back
|
|
110
|
+
// through this decoder by `storedModelBindingV1`.
|
|
111
|
+
const legacy = decodeModelBindingV1(storedBotModelOverride("@flock/auto"));
|
|
112
|
+
|
|
113
|
+
expect(legacy).toEqual(
|
|
114
|
+
decodeModelBindingV1(storedBotModelOverride("@frock/auto")),
|
|
115
|
+
);
|
|
116
|
+
expect(legacy.providerModelId).toBe("@frock/auto");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("resolves against the catalog exactly as the new spelling does", () => {
|
|
120
|
+
const legacy = resolveBotModelBindingV1({
|
|
121
|
+
model: decodeUserSettingsViewV1(storedUserSettings("@flock/auto"))
|
|
122
|
+
.platformModel!,
|
|
123
|
+
user: decodeUserSettingsViewV1(storedUserSettings("@flock/auto")),
|
|
124
|
+
packages: [FROCK_PACKAGE],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(legacy.state).toBe("ready");
|
|
128
|
+
expect(legacy).toEqual(
|
|
129
|
+
resolveBotModelBindingV1({
|
|
130
|
+
model: decodeUserSettingsViewV1(storedUserSettings("@frock/auto"))
|
|
131
|
+
.platformModel!,
|
|
132
|
+
user: decodeUserSettingsViewV1(storedUserSettings("@frock/auto")),
|
|
133
|
+
packages: [FROCK_PACKAGE],
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("shows the Frock AI settings line, not a raw legacy id", () => {
|
|
139
|
+
const line = composerLine(
|
|
140
|
+
decodeUserSettingsViewV1(storedUserSettings("@flock/auto")),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(line).toBe("Auto (recommended) · Frock AI");
|
|
144
|
+
expect(line).toBe(
|
|
145
|
+
composerLine(decodeUserSettingsViewV1(storedUserSettings("@frock/auto"))),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -1335,6 +1335,43 @@ describe("client run protocol v1", () => {
|
|
|
1335
1335
|
}),
|
|
1336
1336
|
).toThrow("run list.announcement.at is invalid");
|
|
1337
1337
|
});
|
|
1338
|
+
|
|
1339
|
+
test("announces a compaction without putting its summary on the wire", () => {
|
|
1340
|
+
const announcements = projectClientAnnouncementsV1([
|
|
1341
|
+
{ type: "turn/start", seq: 0, timestamp, turn: 1 },
|
|
1342
|
+
{
|
|
1343
|
+
type: "conversation/compacted",
|
|
1344
|
+
seq: 4,
|
|
1345
|
+
timestamp,
|
|
1346
|
+
effectId: "compaction-1",
|
|
1347
|
+
fromTurn: 1,
|
|
1348
|
+
throughTurn: 6,
|
|
1349
|
+
summary: "## Summary\nsomething private to the model",
|
|
1350
|
+
identifiers: ["applet-9f2c"],
|
|
1351
|
+
provider: "ollama-cloud",
|
|
1352
|
+
model: "kimi-k2",
|
|
1353
|
+
},
|
|
1354
|
+
]);
|
|
1355
|
+
expect(announcements).toEqual([
|
|
1356
|
+
{
|
|
1357
|
+
type: "conversation/compacted",
|
|
1358
|
+
announcementId: "compaction-4",
|
|
1359
|
+
at: timestamp,
|
|
1360
|
+
throughTurn: 6,
|
|
1361
|
+
},
|
|
1362
|
+
]);
|
|
1363
|
+
const page = createClientRunListV1([], { truncated: false }, announcements);
|
|
1364
|
+
expect(JSON.stringify(page)).not.toContain("something private");
|
|
1365
|
+
expect(decodeClientRunPageV1(structuredClone(page)).announcements).toEqual(
|
|
1366
|
+
announcements,
|
|
1367
|
+
);
|
|
1368
|
+
expect(() =>
|
|
1369
|
+
decodeClientRunPageV1({
|
|
1370
|
+
...page,
|
|
1371
|
+
announcements: [{ ...announcements[0], throughTurn: 0 }],
|
|
1372
|
+
}),
|
|
1373
|
+
).toThrow("run list.announcement.throughTurn is invalid");
|
|
1374
|
+
});
|
|
1338
1375
|
});
|
|
1339
1376
|
|
|
1340
1377
|
describe("dispatched subagents in the run projection", () => {
|