@frockbot/plugin-shell 0.3.11 → 0.3.13
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 +35 -33
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +130 -2
- package/src/backend-configuration.test.ts +26 -26
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/AppletCanvas.vue +19 -6
- 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/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +128 -21
- package/src/client/index.ts +359 -114
- 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 +69 -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/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +50 -7
- package/src/run-protocol.ts +152 -43
- 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,96 @@
|
|
|
1
|
+
// A compaction that outlives the Turn, and yields to the next one (ADR 0030).
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
compactionInFlightV1,
|
|
5
|
+
compactionWorkV1,
|
|
6
|
+
whenCompactionSettledV1,
|
|
7
|
+
yieldCompactionWorkV1,
|
|
8
|
+
} from "./compaction-scheduler.js";
|
|
9
|
+
|
|
10
|
+
function stall(signal: AbortSignal): Promise<never> {
|
|
11
|
+
return new Promise((_resolve, reject) => {
|
|
12
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
13
|
+
once: true,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A promise and the function that settles it, for ordering without timers. */
|
|
19
|
+
function gate(): { promise: Promise<void>; open: () => void } {
|
|
20
|
+
let open = () => {};
|
|
21
|
+
const promise = new Promise<void>((resolve) => {
|
|
22
|
+
open = resolve;
|
|
23
|
+
});
|
|
24
|
+
return { promise, open };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("detached compaction", () => {
|
|
28
|
+
test("starting it does not wait for it", async () => {
|
|
29
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
30
|
+
let finished = false;
|
|
31
|
+
const running = gate();
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
compactionWorkV1(session).start(async (signal) => {
|
|
34
|
+
running.open();
|
|
35
|
+
await stall(signal).catch(() => {});
|
|
36
|
+
finished = true;
|
|
37
|
+
});
|
|
38
|
+
// The claim the defect got wrong: control is back immediately.
|
|
39
|
+
expect(Date.now() - started).toBeLessThan(50);
|
|
40
|
+
expect(finished).toBe(false);
|
|
41
|
+
expect(compactionInFlightV1(session)).toBe(true);
|
|
42
|
+
await running.promise;
|
|
43
|
+
await yieldCompactionWorkV1(session);
|
|
44
|
+
expect(finished).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a newly admitted Turn aborts it rather than queueing behind it", async () => {
|
|
48
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
49
|
+
let reason: unknown;
|
|
50
|
+
const running = gate();
|
|
51
|
+
compactionWorkV1(session).start(async (signal) => {
|
|
52
|
+
running.open();
|
|
53
|
+
try {
|
|
54
|
+
await stall(signal);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reason = error;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
await running.promise;
|
|
60
|
+
await yieldCompactionWorkV1(session);
|
|
61
|
+
expect(compactionInFlightV1(session)).toBe(false);
|
|
62
|
+
expect(String((reason as Error).message)).toContain("yielded");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a failure is nobody's problem, and never leaves work in flight", async () => {
|
|
66
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
67
|
+
compactionWorkV1(session).start(async () => {
|
|
68
|
+
throw new Error("the summariser fell over");
|
|
69
|
+
});
|
|
70
|
+
await whenCompactionSettledV1(session);
|
|
71
|
+
expect(compactionInFlightV1(session)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("two compactions on one conversation never run beside each other", async () => {
|
|
75
|
+
const session = `session-${crypto.randomUUID()}`;
|
|
76
|
+
const order: string[] = [];
|
|
77
|
+
const release = gate();
|
|
78
|
+
compactionWorkV1(session).start(async () => {
|
|
79
|
+
order.push("first:start");
|
|
80
|
+
await release.promise;
|
|
81
|
+
order.push("first:end");
|
|
82
|
+
});
|
|
83
|
+
compactionWorkV1(session).start(async () => {
|
|
84
|
+
order.push("second:start");
|
|
85
|
+
});
|
|
86
|
+
release.open();
|
|
87
|
+
await whenCompactionSettledV1(session);
|
|
88
|
+
expect(order).toEqual(["first:start", "first:end", "second:start"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("yielding costs nothing when no compaction is running", async () => {
|
|
92
|
+
await expect(
|
|
93
|
+
yieldCompactionWorkV1(`session-${crypto.randomUUID()}`),
|
|
94
|
+
).resolves.toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Where a compaction actually runs, now that it no longer runs in the Turn.
|
|
2
|
+
//
|
|
3
|
+
// ADR 0030 always meant the summariser to cost a person nothing: it is
|
|
4
|
+
// evaluated at Turn end, after `turn/end` is journaled, precisely so the Turn
|
|
5
|
+
// they were waiting on is already over. The Turn-end hook honoured the *order*
|
|
6
|
+
// and not the *waiting*: `agent/turn-stopping` is a serial hook the agent loop
|
|
7
|
+
// awaits inside `#runTurn`'s `finally`, so `whenIdle` — and therefore the run's
|
|
8
|
+
// terminal record, the `runs` broadcast, and the HTTP response — all sat behind
|
|
9
|
+
// a 40-second model call. The client stayed busy the whole time.
|
|
10
|
+
//
|
|
11
|
+
// So the summariser is detached from the Turn here. The hook hands the work to
|
|
12
|
+
// this scheduler and returns; the Turn ends, the run settles, the response goes
|
|
13
|
+
// out, and the compaction carries on afterwards on the Composition the Turn
|
|
14
|
+
// mounted, which is disposed when it finishes rather than when the Turn does.
|
|
15
|
+
//
|
|
16
|
+
// **It yields to admission.** The alternative — letting a compaction hold the
|
|
17
|
+
// next Turn behind it — is the very latency this removes, one message later. So
|
|
18
|
+
// the next admission aborts it and waits only for that abort to settle, which
|
|
19
|
+
// keeps every write to the session log serialised behind exactly one owner. An
|
|
20
|
+
// aborted compaction leaves an intent with no outcome, which is the case ADR
|
|
21
|
+
// 0028 already covers: the next Turn end settles it as a failure and backoff
|
|
22
|
+
// picks the range up again. Nothing is corrupted by losing one, and nobody
|
|
23
|
+
// waits for one.
|
|
24
|
+
//
|
|
25
|
+
// Keyed by session id and held for the lifetime of the isolate, because that is
|
|
26
|
+
// exactly the scope the work has: a Durable Object holds one conversation, and
|
|
27
|
+
// work detached from one Turn has to be findable from the next.
|
|
28
|
+
|
|
29
|
+
/** One conversation's detached compaction, at most one at a time. */
|
|
30
|
+
class CompactionWork {
|
|
31
|
+
#controller: AbortController | undefined;
|
|
32
|
+
#settled: Promise<void> = Promise.resolve();
|
|
33
|
+
|
|
34
|
+
get inFlight(): boolean {
|
|
35
|
+
return this.#controller !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Starts work that outlives the Turn. Returns as soon as the work has begun,
|
|
40
|
+
* never when it has finished — that is the whole point.
|
|
41
|
+
*/
|
|
42
|
+
start(run: (signal: AbortSignal) => Promise<unknown>): void {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const previous = this.#settled;
|
|
45
|
+
this.#controller = controller;
|
|
46
|
+
this.#settled = (async () => {
|
|
47
|
+
// Serialised rather than concurrent: two compactions writing to one
|
|
48
|
+
// session log is the one thing detaching them could get wrong.
|
|
49
|
+
await previous;
|
|
50
|
+
try {
|
|
51
|
+
// Aborted before it ever began — a Turn was admitted in the same tick.
|
|
52
|
+
if (!controller.signal.aborted) await run(controller.signal);
|
|
53
|
+
} catch {
|
|
54
|
+
// A compaction that fails is a conversation that carries on under ADR
|
|
55
|
+
// 0027's eviction. There is nobody to tell.
|
|
56
|
+
} finally {
|
|
57
|
+
if (this.#controller === controller) this.#controller = undefined;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Waits for the work without hurrying it. For tests and for shutdown. */
|
|
63
|
+
whenSettled(): Promise<void> {
|
|
64
|
+
return this.#settled;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hands the conversation back. Aborts anything in flight and waits for it to
|
|
69
|
+
* finish unwinding, so the next Turn is never writing to the log beside it.
|
|
70
|
+
*/
|
|
71
|
+
async yieldToTurn(): Promise<void> {
|
|
72
|
+
this.#controller?.abort(
|
|
73
|
+
new Error("A new Turn was admitted, so the compaction yielded to it."),
|
|
74
|
+
);
|
|
75
|
+
await this.#settled;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const work = new Map<string, CompactionWork>();
|
|
80
|
+
|
|
81
|
+
/** The detached compaction for one conversation, created on first use. */
|
|
82
|
+
export function compactionWorkV1(sessionId: string): CompactionWork {
|
|
83
|
+
const existing = work.get(sessionId);
|
|
84
|
+
if (existing) return existing;
|
|
85
|
+
const created = new CompactionWork();
|
|
86
|
+
work.set(sessionId, created);
|
|
87
|
+
return created;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Called on the admission path, before a Turn reads the session log. Costs
|
|
92
|
+
* nothing when no compaction is in flight, and an abort when one is.
|
|
93
|
+
*/
|
|
94
|
+
export async function yieldCompactionWorkV1(sessionId: string): Promise<void> {
|
|
95
|
+
await work.get(sessionId)?.yieldToTurn();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a conversation has a compaction still running. */
|
|
99
|
+
export function compactionInFlightV1(sessionId: string): boolean {
|
|
100
|
+
return work.get(sessionId)?.inFlight ?? false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Awaits a detached compaction without aborting it. Tests and shutdown only. */
|
|
104
|
+
export async function whenCompactionSettledV1(
|
|
105
|
+
sessionId: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
await work.get(sessionId)?.whenSettled();
|
|
108
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Where the compaction marker sits in the transcript (ADR 0030).
|
|
2
|
+
//
|
|
3
|
+
// The marker is one system line, and the only thing it has to get right is
|
|
4
|
+
// *where*: it says the Turns above it are what the model now carries a summary
|
|
5
|
+
// of. Dated by when the summariser ran, it landed under the newest reply,
|
|
6
|
+
// which says the opposite of what it means.
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
9
|
+
import { projectClientAnnouncementsV1 } from "./run-protocol.js";
|
|
10
|
+
import { projectAnnouncements } from "./client/index.js";
|
|
11
|
+
import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "./compaction.js";
|
|
12
|
+
import type { WebChatMessage } from "./shared.js";
|
|
13
|
+
|
|
14
|
+
const at = (minute: number) =>
|
|
15
|
+
new Date(Date.UTC(2026, 0, 1, 0, minute)).toISOString();
|
|
16
|
+
|
|
17
|
+
/** Three Turns, each a minute apart, and a compaction covering the first two. */
|
|
18
|
+
function log(): SessionEvent[] {
|
|
19
|
+
const events: SessionEvent[] = [];
|
|
20
|
+
let seq = 0;
|
|
21
|
+
for (const turn of [1, 2, 3]) {
|
|
22
|
+
events.push(
|
|
23
|
+
{ type: "turn/start", seq: seq++, timestamp: at(turn * 2), turn },
|
|
24
|
+
{
|
|
25
|
+
type: "turn/end",
|
|
26
|
+
seq: seq++,
|
|
27
|
+
timestamp: at(turn * 2 + 1),
|
|
28
|
+
turn,
|
|
29
|
+
outcome: "completed",
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
events.push({
|
|
34
|
+
type: "conversation/compacted",
|
|
35
|
+
seq: seq++,
|
|
36
|
+
// Written at the end of Turn 3, which is the Turn that crossed the
|
|
37
|
+
// threshold — and nowhere near the range it covers.
|
|
38
|
+
timestamp: at(7),
|
|
39
|
+
effectId: "compaction-1",
|
|
40
|
+
fromTurn: 1,
|
|
41
|
+
throughTurn: 2,
|
|
42
|
+
summary: "## Summary\nprivate to the model",
|
|
43
|
+
identifiers: [],
|
|
44
|
+
provider: "ollama-cloud",
|
|
45
|
+
model: "kimi-k2",
|
|
46
|
+
});
|
|
47
|
+
return events;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function message(
|
|
51
|
+
id: string,
|
|
52
|
+
role: WebChatMessage["role"],
|
|
53
|
+
timestamp?: string,
|
|
54
|
+
): WebChatMessage {
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
runId: id.split(":")[0]!,
|
|
58
|
+
role,
|
|
59
|
+
text: id,
|
|
60
|
+
...(timestamp ? { at: timestamp } : {}),
|
|
61
|
+
status: "completed",
|
|
62
|
+
tools: [],
|
|
63
|
+
sends: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The thread as `projectDurableRuns` leaves it: only user lines are dated. */
|
|
68
|
+
function thread(turns: readonly number[]): WebChatMessage[] {
|
|
69
|
+
return turns.flatMap((turn) => [
|
|
70
|
+
message(`run-${turn}:user`, "user", at(turn * 2)),
|
|
71
|
+
message(`run-${turn}:assistant`, "assistant"),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("the compaction marker sits where the summary ends", () => {
|
|
76
|
+
test("is dated by the end of the range it covers, not by when it was written", () => {
|
|
77
|
+
const announcements = projectClientAnnouncementsV1(
|
|
78
|
+
log().filter((event) => event.type === "conversation/compacted"),
|
|
79
|
+
log(),
|
|
80
|
+
);
|
|
81
|
+
expect(announcements).toEqual([
|
|
82
|
+
{
|
|
83
|
+
type: "conversation/compacted",
|
|
84
|
+
announcementId: "compaction-6",
|
|
85
|
+
// `turn/end` for Turn 2, not the compaction's own timestamp of 00:07.
|
|
86
|
+
at: at(5),
|
|
87
|
+
throughTurn: 2,
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
// Without the session log there is nothing to anchor to, and the old
|
|
91
|
+
// behaviour stands rather than a wrong claim being invented.
|
|
92
|
+
expect(
|
|
93
|
+
projectClientAnnouncementsV1(
|
|
94
|
+
log().filter((event) => event.type === "conversation/compacted"),
|
|
95
|
+
)[0]?.at,
|
|
96
|
+
).toBe(at(7));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("lands between the last compacted Turn and the first verbatim one, and stays there", () => {
|
|
100
|
+
const announcements = projectClientAnnouncementsV1(
|
|
101
|
+
log().filter((event) => event.type === "conversation/compacted"),
|
|
102
|
+
log(),
|
|
103
|
+
);
|
|
104
|
+
const messages = thread([1, 2, 3]);
|
|
105
|
+
projectAnnouncements(messages, announcements);
|
|
106
|
+
expect(messages.map((entry) => entry.id)).toEqual([
|
|
107
|
+
"run-1:user",
|
|
108
|
+
"run-1:assistant",
|
|
109
|
+
"run-2:user",
|
|
110
|
+
"run-2:assistant",
|
|
111
|
+
"compaction-6",
|
|
112
|
+
"run-3:user",
|
|
113
|
+
"run-3:assistant",
|
|
114
|
+
]);
|
|
115
|
+
expect(messages[4]?.text).toBe(COMPACTED_ANNOUNCEMENT_TEXT_V1);
|
|
116
|
+
expect(messages[4]?.role).toBe("system");
|
|
117
|
+
|
|
118
|
+
// A newer Turn arrives and the marker does not follow it down.
|
|
119
|
+
messages.push(
|
|
120
|
+
message("run-4:user", "user", at(8)),
|
|
121
|
+
message("run-4:assistant", "assistant"),
|
|
122
|
+
);
|
|
123
|
+
projectAnnouncements(messages, announcements);
|
|
124
|
+
expect(messages.map((entry) => entry.id)).toEqual([
|
|
125
|
+
"run-1:user",
|
|
126
|
+
"run-1:assistant",
|
|
127
|
+
"run-2:user",
|
|
128
|
+
"run-2:assistant",
|
|
129
|
+
"compaction-6",
|
|
130
|
+
"run-3:user",
|
|
131
|
+
"run-3:assistant",
|
|
132
|
+
"run-4:user",
|
|
133
|
+
"run-4:assistant",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("sits at the top of what remains when the covered Turns have scrolled away", () => {
|
|
138
|
+
const announcements = projectClientAnnouncementsV1(
|
|
139
|
+
log().filter((event) => event.type === "conversation/compacted"),
|
|
140
|
+
log(),
|
|
141
|
+
);
|
|
142
|
+
const messages = thread([3]);
|
|
143
|
+
projectAnnouncements(messages, announcements);
|
|
144
|
+
expect(messages.map((entry) => entry.id)).toEqual([
|
|
145
|
+
"compaction-6",
|
|
146
|
+
"run-3:user",
|
|
147
|
+
"run-3:assistant",
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a rename still sorts among the Turns it happened between", () => {
|
|
152
|
+
const renamed = projectClientAnnouncementsV1([
|
|
153
|
+
{
|
|
154
|
+
type: "bot/renamed",
|
|
155
|
+
seq: 3,
|
|
156
|
+
timestamp: at(5),
|
|
157
|
+
from: "Housework",
|
|
158
|
+
to: "Atlas",
|
|
159
|
+
namedBy: "bot",
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
const messages = thread([1, 2, 3]);
|
|
163
|
+
projectAnnouncements(messages, renamed);
|
|
164
|
+
expect(messages.map((entry) => entry.id)).toEqual([
|
|
165
|
+
"run-1:user",
|
|
166
|
+
"run-1:assistant",
|
|
167
|
+
"run-2:user",
|
|
168
|
+
"run-2:assistant",
|
|
169
|
+
"announcement-3",
|
|
170
|
+
"run-3:user",
|
|
171
|
+
"run-3:assistant",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|