@a-t-h-i/bot-lobby 0.6.12 → 0.6.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/README.md +11 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +4 -3
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- package/webui/dist/vite.svg +1 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixture-backed `LobbyService` for `npm run web:dev`: the real server
|
|
3
|
+
* answers the protocol calls from one scenario's fixtures, so the page
|
|
4
|
+
* builds with no Pi. Dev-only; never imported by production code or tests
|
|
5
|
+
* (tests use `test/webui-fake.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import { resolveConfig, type BotLobbyConfig } from "../../schemas/configuration.ts";
|
|
8
|
+
import { LobbyFeed } from "../../lobby/feed.ts";
|
|
9
|
+
import { promptHub } from "../../lobby/prompt-hub.ts";
|
|
10
|
+
import { lobbyTopics } from "../../lobby/topics.ts";
|
|
11
|
+
import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
|
|
12
|
+
import type { LobbyService } from "../../lobby/service.ts";
|
|
13
|
+
import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
|
|
14
|
+
|
|
15
|
+
interface StreamState {
|
|
16
|
+
step?: ReturnType<typeof setTimeout>;
|
|
17
|
+
followUp?: ReturnType<typeof setTimeout>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A planning session as canned fixture data: the same fields the planner calls read. */
|
|
21
|
+
function fakePlanner(entry: NonNullable<ScenarioFixture["mockPlanner"]>): Record<string, unknown> & { seats: Set<string> } {
|
|
22
|
+
const seats = new Set<string>(entry.seats);
|
|
23
|
+
const session = {
|
|
24
|
+
seats,
|
|
25
|
+
members: entry.members.map((member) => ({ ...member })),
|
|
26
|
+
messages: entry.messages.map((message) => ({ ...message })),
|
|
27
|
+
reply: entry.draft ? { status: "grilling", questions: [], plan: entry.draft } : undefined,
|
|
28
|
+
questions: entry.questions.map((question) => ({ ...question })),
|
|
29
|
+
notes: entry.notes.map((note) => ({ ...note })),
|
|
30
|
+
turns: entry.round,
|
|
31
|
+
busy: false,
|
|
32
|
+
awaitingAnswers: entry.questions.length > 0,
|
|
33
|
+
retryable: entry.retryable,
|
|
34
|
+
lineComments: [] as Array<{ line: string; text: string }>,
|
|
35
|
+
toggle(member: string): boolean {
|
|
36
|
+
if (seats.has(member)) seats.delete(member);
|
|
37
|
+
else seats.add(member);
|
|
38
|
+
return seats.has(member);
|
|
39
|
+
},
|
|
40
|
+
async send(text: string): Promise<void> {
|
|
41
|
+
(session.messages as Array<Record<string, unknown>>).push({ role: "you", text, at: Date.now() });
|
|
42
|
+
},
|
|
43
|
+
async retry(): Promise<void> {},
|
|
44
|
+
commentOnLine(line: string, text: string): boolean {
|
|
45
|
+
session.lineComments.push({ line, text });
|
|
46
|
+
return false;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return session;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultPlanner(): NonNullable<ScenarioFixture["mockPlanner"]> {
|
|
53
|
+
return { seats: ["backend", "designer", "qa", "researcher"], members: [], messages: [], questions: [], notes: [], round: 0, retryable: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FakeQuickFixJob {
|
|
57
|
+
id: string;
|
|
58
|
+
prompt: string;
|
|
59
|
+
status: string;
|
|
60
|
+
createdAt: number;
|
|
61
|
+
steps: Array<{ at: number; text: string; pending: boolean }>;
|
|
62
|
+
tools: number;
|
|
63
|
+
turns: number;
|
|
64
|
+
note?: string;
|
|
65
|
+
report?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fakeQuickFixJobs(entries: Array<Record<string, unknown>>): FakeQuickFixJob[] {
|
|
69
|
+
return entries.map((job, index) => ({
|
|
70
|
+
id: typeof job.id === "string" ? job.id : `QF-${index + 1}`,
|
|
71
|
+
prompt: typeof job.prompt === "string" ? job.prompt : "",
|
|
72
|
+
status: typeof job.status === "string" ? job.status : "success",
|
|
73
|
+
createdAt: typeof job.createdAt === "number" ? job.createdAt : Date.now(),
|
|
74
|
+
steps: [],
|
|
75
|
+
tools: 0,
|
|
76
|
+
turns: 0,
|
|
77
|
+
...(typeof job.note === "string" ? { note: job.note } : {}),
|
|
78
|
+
...(typeof job.report === "string" ? { report: job.report } : {}),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DETAIL_ONLY = ["body", "state", "mergeable", "files", "notes", "comments"] as const;
|
|
83
|
+
|
|
84
|
+
/** A list row: the detail fixture without the fields only a detail carries. */
|
|
85
|
+
function summaryOf(entry: Record<string, unknown>): Record<string, unknown> {
|
|
86
|
+
const row: Record<string, unknown> = { ...entry };
|
|
87
|
+
for (const key of DETAIL_ONLY) delete row[key];
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The Git tab's `PullsState` as canned fixture data (`gh` is never run). */
|
|
92
|
+
function fakePulls(entries: Array<Record<string, unknown>>) {
|
|
93
|
+
const details = new Map<number, Record<string, unknown>>();
|
|
94
|
+
const state = {
|
|
95
|
+
pulls: [] as Array<Record<string, unknown>>,
|
|
96
|
+
details,
|
|
97
|
+
loading: false,
|
|
98
|
+
loaded: false,
|
|
99
|
+
error: undefined as string | undefined,
|
|
100
|
+
async refresh() {
|
|
101
|
+
state.pulls = entries.map(summaryOf);
|
|
102
|
+
state.loaded = true;
|
|
103
|
+
lobbyTopics.bump("git");
|
|
104
|
+
},
|
|
105
|
+
async detail(number: number) {
|
|
106
|
+
const found = entries.find((entry) => entry.number === number);
|
|
107
|
+
if (!found) {
|
|
108
|
+
state.error = "GraphQL: Could not resolve to a PullRequest with the number of " + number + ".";
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
state.error = undefined;
|
|
112
|
+
details.set(number, found);
|
|
113
|
+
return { ...found };
|
|
114
|
+
},
|
|
115
|
+
forget() {
|
|
116
|
+
details.clear();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Reviews and Jev's reads as canned data; a started review finishes after a moment. */
|
|
123
|
+
function fakeReviews(saved: Array<Record<string, unknown>>, savedReads: Array<Record<string, unknown>>) {
|
|
124
|
+
const reviews = new Map<number, Record<string, unknown>>(saved.map((review) => [review.number as number, { ...review }]));
|
|
125
|
+
const reads = new Map<number, Record<string, unknown>>(savedReads.map((read) => [read.number as number, { ...read }]));
|
|
126
|
+
const finish = (after: () => void): void => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
after();
|
|
129
|
+
lobbyTopics.bump("git");
|
|
130
|
+
}, 400);
|
|
131
|
+
timer.unref?.();
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
reviews,
|
|
135
|
+
reads,
|
|
136
|
+
review: (number: number) => reviews.get(number),
|
|
137
|
+
running: (number: number) => reviews.get(number)?.status === "running",
|
|
138
|
+
async start(number: number, options: { focus?: string } = {}) {
|
|
139
|
+
const review: Record<string, unknown> = { number, status: "running", startedAt: Date.now(), steps: ["reading the pull request from GitHub"], ...(options.focus ? { focus: options.focus } : {}) };
|
|
140
|
+
reviews.set(number, review);
|
|
141
|
+
lobbyTopics.bump("git");
|
|
142
|
+
finish(() => Object.assign(review, { status: "done", verdict: "comment", finishedAt: Date.now(), model: "mock/qa", thinking: "medium", text: "## Verdict\nCOMMENT\n\n## Summary\nA canned review from the mock.\n\n## Findings\nNone." }));
|
|
143
|
+
return review;
|
|
144
|
+
},
|
|
145
|
+
cancel(number: number): boolean {
|
|
146
|
+
const review = reviews.get(number);
|
|
147
|
+
if (review?.status !== "running") return false;
|
|
148
|
+
review.status = "cancelled";
|
|
149
|
+
review.finishedAt = Date.now();
|
|
150
|
+
lobbyTopics.bump("git");
|
|
151
|
+
return true;
|
|
152
|
+
},
|
|
153
|
+
async readWithJev(number: number) {
|
|
154
|
+
const read: Record<string, unknown> = { number, status: "running" };
|
|
155
|
+
reads.set(number, read);
|
|
156
|
+
lobbyTopics.bump("git");
|
|
157
|
+
finish(() => Object.assign(read, { status: "done", line: "small · low risk", read: { size: "small", sizeConfidence: 0.9, risky: 0.1, breaking: 0.05, security: 0.02, testsMissing: 0.2, model: "mock/jev", ms: 200 } }));
|
|
158
|
+
return read;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The Issues tab's `IssuesState` as canned fixture data. */
|
|
164
|
+
function fakeIssues(entries: Array<Record<string, unknown>>) {
|
|
165
|
+
const details = new Map<number, Record<string, unknown>>();
|
|
166
|
+
const state = {
|
|
167
|
+
issues: [] as Array<Record<string, unknown>>,
|
|
168
|
+
details,
|
|
169
|
+
loading: false,
|
|
170
|
+
loaded: false,
|
|
171
|
+
error: undefined as string | undefined,
|
|
172
|
+
notice: undefined as string | undefined,
|
|
173
|
+
async refresh() {
|
|
174
|
+
state.issues = entries.map(summaryOf);
|
|
175
|
+
state.loaded = true;
|
|
176
|
+
lobbyTopics.bump("issues");
|
|
177
|
+
},
|
|
178
|
+
async detail(number: number) {
|
|
179
|
+
const found = entries.find((entry) => entry.number === number);
|
|
180
|
+
if (!found) {
|
|
181
|
+
state.error = "GraphQL: Could not resolve to an Issue with the number of " + number + ".";
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return { ...found };
|
|
185
|
+
},
|
|
186
|
+
async create(text: string) {
|
|
187
|
+
const title = text.trim().split("\n")[0]!.replace(/^#+\s*/, "");
|
|
188
|
+
const number = Math.max(0, ...entries.map((entry) => entry.number as number)) + 1;
|
|
189
|
+
entries.unshift({ number, title, labels: [], body: text.trim().split("\n").slice(1).join("\n").trim(), state: "OPEN", comments: [] });
|
|
190
|
+
state.notice = `created #${number} — https://github.com/example/bot-lobby/issues/${number}`;
|
|
191
|
+
await state.refresh();
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
return state;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The models the mock offers, unless a scenario's settings name its own. */
|
|
198
|
+
const DEFAULT_MODELS: Array<{ id: string; label: string; thinkingLevels: string[] }> = [
|
|
199
|
+
{ id: "mock/gpt-5", label: "Mock GPT-5", thinkingLevels: ["low", "medium", "high"] },
|
|
200
|
+
{ id: "mock/gpt-5-mini", label: "Mock GPT-5 mini", thinkingLevels: ["minimal", "low", "medium"] },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const streams = new WeakMap<object, StreamState>();
|
|
204
|
+
const opened = new WeakMap<object, string[]>();
|
|
205
|
+
|
|
206
|
+
function track(service: object, id: string): void {
|
|
207
|
+
const list = opened.get(service) ?? [];
|
|
208
|
+
list.push(id);
|
|
209
|
+
opened.set(service, list);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function unref(timer: ReturnType<typeof setTimeout>): void {
|
|
213
|
+
timer.unref?.();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stopStream(service: object): void {
|
|
217
|
+
const state = streams.get(service);
|
|
218
|
+
if (!state) return;
|
|
219
|
+
if (state.step) clearTimeout(state.step);
|
|
220
|
+
if (state.followUp) clearTimeout(state.followUp);
|
|
221
|
+
streams.delete(service);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function seedFeed(feed: LobbyFeed, fixture: ScenarioFixture): void {
|
|
225
|
+
feed.seedChat(fixture.feed.chat);
|
|
226
|
+
feed.chatOlder = fixture.feed.chatOlder;
|
|
227
|
+
for (const entry of fixture.feed.activity) {
|
|
228
|
+
if (entry.pending) feed.begin(entry.source, entry.text);
|
|
229
|
+
else feed.log(entry.source, entry.text, entry.kind);
|
|
230
|
+
}
|
|
231
|
+
for (const entry of fixture.feed.thoughts) feed.thought(entry.source, entry.text);
|
|
232
|
+
if (fixture.feed.reply) feed.replyDelta(fixture.feed.reply);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function seedPrompts(service: object, fixture: ScenarioFixture): void {
|
|
236
|
+
for (const prompt of fixture.prompts) track(service, promptHub.open(prompt.kind, prompt.from, prompt.payload).id);
|
|
237
|
+
for (const settled of fixture.settled) promptHub.answer(promptHub.open(settled.kind, settled.from, settled.payload).id, settled.answer);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A background session as live fixture data: its own feed, dialogs, and canned answers. */
|
|
241
|
+
function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]>[number]): BackgroundSession {
|
|
242
|
+
const feed = new LobbyFeed();
|
|
243
|
+
let status = entry.status;
|
|
244
|
+
const dialogs = ((entry.dialogs ?? []) as unknown as SessionDialog[]).map((dialog) => ({ ...dialog }));
|
|
245
|
+
const session = {
|
|
246
|
+
key: entry.key,
|
|
247
|
+
name: entry.name,
|
|
248
|
+
get status() {
|
|
249
|
+
return status;
|
|
250
|
+
},
|
|
251
|
+
set status(next: string) {
|
|
252
|
+
status = next;
|
|
253
|
+
},
|
|
254
|
+
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
255
|
+
...(entry.planId ? { planId: entry.planId } : {}),
|
|
256
|
+
feed,
|
|
257
|
+
dialogs,
|
|
258
|
+
get alive() {
|
|
259
|
+
return status !== "exited";
|
|
260
|
+
},
|
|
261
|
+
get busy() {
|
|
262
|
+
return status === "working";
|
|
263
|
+
},
|
|
264
|
+
send(text: string) {
|
|
265
|
+
feed.say("you", text);
|
|
266
|
+
},
|
|
267
|
+
stop() {
|
|
268
|
+
status = "exited";
|
|
269
|
+
dialogs.length = 0;
|
|
270
|
+
},
|
|
271
|
+
answer(id: string, _answer: DialogAnswer) {
|
|
272
|
+
const index = dialogs.findIndex((dialog) => dialog.id === id);
|
|
273
|
+
if (index >= 0) dialogs.splice(index, 1);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
return session as unknown as BackgroundSession;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function sliceReply(text: string): string[] {
|
|
280
|
+
const parts: string[] = [];
|
|
281
|
+
for (let index = 0; index < text.length; index += 120) parts.push(text.slice(index, index + 120));
|
|
282
|
+
return parts;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Stream the scripted reply, then record it; a scripted question follows a send when the fixture has one. */
|
|
286
|
+
function streamReply(service: object, feed: LobbyFeed, fixture: ScenarioFixture): void {
|
|
287
|
+
stopStream(service);
|
|
288
|
+
const parts = sliceReply(fixture.oracleReply);
|
|
289
|
+
const state: StreamState = {};
|
|
290
|
+
streams.set(service, state);
|
|
291
|
+
const step = (): void => {
|
|
292
|
+
const next = parts.shift();
|
|
293
|
+
if (next === undefined) {
|
|
294
|
+
feed.replyEnd();
|
|
295
|
+
feed.say("oracle", fixture.oracleReply);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
feed.replyDelta(next);
|
|
299
|
+
state.step = setTimeout(step, 25);
|
|
300
|
+
unref(state.step);
|
|
301
|
+
};
|
|
302
|
+
state.step = setTimeout(step, 25);
|
|
303
|
+
unref(state.step);
|
|
304
|
+
const followUp = fixture.questionAfterSend;
|
|
305
|
+
if (followUp) {
|
|
306
|
+
state.followUp = setTimeout(() => {
|
|
307
|
+
track(service, promptHub.open(followUp.kind, followUp.from, followUp.payload).id);
|
|
308
|
+
}, 150);
|
|
309
|
+
unref(state.followUp);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A `LobbyService` answering from the `name` scenario's fixtures. */
|
|
314
|
+
export function createFixtureService(name: string, feed = new LobbyFeed()): LobbyService {
|
|
315
|
+
const fixture = loadScenario(name);
|
|
316
|
+
seedFeed(feed, fixture);
|
|
317
|
+
const history = fixture.history.map((entry, index) => ({ id: index + 1, at: Date.now() - (fixture.history.length - index) * 1000, role: entry.role, text: entry.text }));
|
|
318
|
+
const whole = (task: Record<string, unknown>): Record<string, unknown> => ({ amendments: [], blockers: [], approvals: [], ...task });
|
|
319
|
+
const tasks = (fixture.mockTasks ?? []).map(whole);
|
|
320
|
+
const archived = (fixture.mockArchived ?? []).map(whole);
|
|
321
|
+
const plans = (fixture.mockPlans ?? []).map((plan) => ({ ...plan, status: "pending" as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }));
|
|
322
|
+
const auto = new Set(fixture.autoTasks ?? []);
|
|
323
|
+
const comments = new Map<string, Array<Record<string, unknown>>>(Object.entries(fixture.taskComments ?? {}));
|
|
324
|
+
const backgrounds = (fixture.backgroundSessions ?? []).map(fakeBackground);
|
|
325
|
+
let planning = fakePlanner(fixture.mockPlanner ?? defaultPlanner());
|
|
326
|
+
let config: BotLobbyConfig = resolveConfig(fixture.settings?.config ?? {});
|
|
327
|
+
const models = fixture.settings?.models ?? DEFAULT_MODELS;
|
|
328
|
+
const metrics = (fixture.mockMetrics ?? []).map((record) => ({ ...record }));
|
|
329
|
+
const classifierMetrics = (fixture.mockClassifierMetrics ?? []).map((record) => ({ ...record }));
|
|
330
|
+
const knowledgeFiles = (fixture.mockKnowledge ?? []).map((file) => ({ ...file }));
|
|
331
|
+
const knowledgeView = fixture.mockKnowledgeView ? { ...fixture.mockKnowledgeView, entries: [...fixture.mockKnowledgeView.entries], attached: [...fixture.mockKnowledgeView.attached], detached: [...fixture.mockKnowledgeView.detached] } : undefined;
|
|
332
|
+
const xSessions = (fixture.mockExcalidraw ?? []).map((session) => ({ ...session, agents: [...session.agents] }));
|
|
333
|
+
const xLinks = new Map<string, string>();
|
|
334
|
+
if (xSessions[0] && fixture.mockExcalidrawLink) xLinks.set(xSessions[0].id, fixture.mockExcalidrawLink);
|
|
335
|
+
const xLinkOf = (id: string): string => xLinks.get(id) ?? `https://whiteboard.example/#room=${id},AAAAAAAAAAAAAAAAAAAAAA`;
|
|
336
|
+
const knowledge = {
|
|
337
|
+
files: () => knowledgeFiles.map((file) => ({ ...file })),
|
|
338
|
+
open: (agent: string, file: string) => {
|
|
339
|
+
if (knowledgeView && knowledgeView.agent === agent && knowledgeView.file === file) return { ...knowledgeView, entries: [...knowledgeView.entries], attached: [...knowledgeView.attached], detached: [...knowledgeView.detached] };
|
|
340
|
+
return { agent, file, label: file, chars: 0, over: false, notes: 0, content: "", entries: [], attached: [], detached: [] };
|
|
341
|
+
},
|
|
342
|
+
edit: () => "saved mock/agent/mock.md — the version before is in archive/Mock",
|
|
343
|
+
add: () => "saved mock/agent/mock.md — the version before is in archive/Mock",
|
|
344
|
+
remove: () => "saved mock/agent/mock.md — the version before is in archive/Mock",
|
|
345
|
+
replaceFile: () => "saved mock/agent/mock.md — the version before is in archive/Mock",
|
|
346
|
+
comment: () => "note saved — every agent reads it under this entry",
|
|
347
|
+
unnote: () => "note removed",
|
|
348
|
+
};
|
|
349
|
+
const excalidraw = {
|
|
350
|
+
list: () => xSessions.map((session) => ({ id: session.id, name: session.name, link: xLinkOf(session.id), agents: [...session.agents], contribute: session.contribute, addedAt: session.addedAt })),
|
|
351
|
+
add: (link: string, name?: string) => {
|
|
352
|
+
if (!link.includes("#room=")) return { notice: "that is not an Excalidraw room link — it looks like https://excalidraw.com/#room=<id>,<key>" };
|
|
353
|
+
const session = { id: `x${xSessions.length + 1}`, name: name || `Session ${xSessions.length + 1}`, link, agents: [] as string[], contribute: true, addedAt: new Date().toISOString() };
|
|
354
|
+
xSessions.push({ id: session.id, name: session.name, masked: "", agents: [], contribute: true, addedAt: session.addedAt });
|
|
355
|
+
xLinks.set(session.id, link);
|
|
356
|
+
return { notice: `added “${session.name}” — assign it to agents with enter`, session };
|
|
357
|
+
},
|
|
358
|
+
create: (name?: string) => {
|
|
359
|
+
const id = `x${xSessions.length + 1}`;
|
|
360
|
+
const link = `https://whiteboard.example/#room=mock${xSessions.length + 1},AAAAAAAAAAAAAAAAAAAAAA`;
|
|
361
|
+
const session = { id, name: name || `Session ${xSessions.length + 1}`, link, agents: [] as string[], contribute: true, addedAt: new Date().toISOString() };
|
|
362
|
+
xSessions.push({ id, name: session.name, masked: "", agents: [], contribute: true, addedAt: session.addedAt });
|
|
363
|
+
xLinks.set(id, link);
|
|
364
|
+
return { notice: `added “${session.name}” — assign it to agents with enter`, session };
|
|
365
|
+
},
|
|
366
|
+
remove: (id: string) => {
|
|
367
|
+
const index = xSessions.findIndex((session) => session.id === id);
|
|
368
|
+
if (index < 0) return "no such session";
|
|
369
|
+
const [gone] = xSessions.splice(index, 1);
|
|
370
|
+
return `removed “${gone!.name}”`;
|
|
371
|
+
},
|
|
372
|
+
rename: (id: string, name: string) => {
|
|
373
|
+
const session = xSessions.find((entry) => entry.id === id);
|
|
374
|
+
if (!session) return "no such session";
|
|
375
|
+
if (!name.trim()) return "a session needs a name";
|
|
376
|
+
session.name = name.trim().slice(0, 40);
|
|
377
|
+
return `renamed to “${session.name}”`;
|
|
378
|
+
},
|
|
379
|
+
toggleAgent: (id: string, agent: string) => {
|
|
380
|
+
const session = xSessions.find((entry) => entry.id === id);
|
|
381
|
+
if (!session) return "no such session";
|
|
382
|
+
const at = session.agents.indexOf(agent);
|
|
383
|
+
if (at >= 0) session.agents.splice(at, 1);
|
|
384
|
+
else session.agents.push(agent);
|
|
385
|
+
return at >= 0 ? `${agent} no longer has this session` : `${agent} has this session now`;
|
|
386
|
+
},
|
|
387
|
+
toggleAll: (id: string) => {
|
|
388
|
+
const session = xSessions.find((entry) => entry.id === id);
|
|
389
|
+
if (!session) return "no such session";
|
|
390
|
+
session.agents = session.agents.length > 0 ? [] : ["master"];
|
|
391
|
+
return session.agents.length > 0 ? "assigned to every agent" : "taken back from every agent";
|
|
392
|
+
},
|
|
393
|
+
toggleContribute: (id: string) => {
|
|
394
|
+
const session = xSessions.find((entry) => entry.id === id);
|
|
395
|
+
if (!session) return "no such session";
|
|
396
|
+
session.contribute = !session.contribute;
|
|
397
|
+
return session.contribute ? "agents may draw in this session" : "agents may only look at this session";
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
const qfJobs = fakeQuickFixJobs(fixture.mockQuickfix ?? []);
|
|
401
|
+
let qfCounter = qfJobs.length;
|
|
402
|
+
const quickfix = {
|
|
403
|
+
jobs: qfJobs,
|
|
404
|
+
get running() {
|
|
405
|
+
return qfJobs.find((job) => job.status === "running");
|
|
406
|
+
},
|
|
407
|
+
submit(prompt: string) {
|
|
408
|
+
const job: FakeQuickFixJob = { id: `QF-${(qfCounter += 1)}`, prompt: prompt.trim(), status: "queued", createdAt: Date.now(), steps: [], tools: 0, turns: 0 };
|
|
409
|
+
qfJobs.push(job);
|
|
410
|
+
return job;
|
|
411
|
+
},
|
|
412
|
+
cancel(id: string): boolean {
|
|
413
|
+
const job = qfJobs.find((entry) => entry.id === id);
|
|
414
|
+
if (!job || (job.status !== "queued" && job.status !== "running")) return false;
|
|
415
|
+
job.status = "cancelled";
|
|
416
|
+
return true;
|
|
417
|
+
},
|
|
418
|
+
runAnyway(id: string): boolean {
|
|
419
|
+
const job = qfJobs.find((entry) => entry.id === id);
|
|
420
|
+
if (!job || job.status !== "held") return false;
|
|
421
|
+
job.status = "queued";
|
|
422
|
+
delete job.note;
|
|
423
|
+
return true;
|
|
424
|
+
},
|
|
425
|
+
movedToTask(id: string): boolean {
|
|
426
|
+
const job = qfJobs.find((entry) => entry.id === id);
|
|
427
|
+
if (!job || job.status !== "held") return false;
|
|
428
|
+
job.status = "cancelled";
|
|
429
|
+
job.note = "started as a task in a new session";
|
|
430
|
+
return true;
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
let commentSeq = 0;
|
|
434
|
+
const service = {
|
|
435
|
+
sessionId: () => fixture.status.sessionId,
|
|
436
|
+
sessionName: () => fixture.status.sessionName,
|
|
437
|
+
masterBusy: () => fixture.status.busy,
|
|
438
|
+
config: () => config,
|
|
439
|
+
saveConfig: (next: BotLobbyConfig) => {
|
|
440
|
+
config = next;
|
|
441
|
+
},
|
|
442
|
+
configChanged: () => {},
|
|
443
|
+
models: () => models.map((model) => ({ id: model.id, label: model.label, thinkingLevels: [...model.thinkingLevels] })),
|
|
444
|
+
issuesEnabled: () => fixture.status.issuesEnabled,
|
|
445
|
+
terminalDialog: () => fixture.status.terminalDialog,
|
|
446
|
+
workspace: () => ({ ...fixture.status.workspace }),
|
|
447
|
+
zen: () => ({ ...(fixture.zen.task ? { task: { ...fixture.zen.task } } : {}), runs: [...fixture.zen.runs] }),
|
|
448
|
+
feed,
|
|
449
|
+
toOracle: (text: string) => {
|
|
450
|
+
feed.say("you", text);
|
|
451
|
+
streamReply(service, feed, fixture);
|
|
452
|
+
return undefined;
|
|
453
|
+
},
|
|
454
|
+
abortMaster: () => {
|
|
455
|
+
stopStream(service);
|
|
456
|
+
feed.replyEnd();
|
|
457
|
+
},
|
|
458
|
+
chatHistory: () => [...history],
|
|
459
|
+
tasks: () => [...tasks],
|
|
460
|
+
plans: () => [...plans],
|
|
461
|
+
metrics: () => metrics.map((record) => ({ ...record })),
|
|
462
|
+
classifierMetrics: () => classifierMetrics.map((record) => ({ ...record })),
|
|
463
|
+
knowledge,
|
|
464
|
+
excalidraw,
|
|
465
|
+
checkExcalidraw: async () => ({ ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" }),
|
|
466
|
+
comments: (taskId: string) => [...(comments.get(taskId) ?? [])],
|
|
467
|
+
comment: (taskId: string, text: string) => {
|
|
468
|
+
const entry = { id: `C-mock-${(commentSeq += 1)}`, taskId, text, createdAt: new Date().toISOString(), status: "open" };
|
|
469
|
+
comments.set(taskId, [...(comments.get(taskId) ?? []), entry]);
|
|
470
|
+
return "comment sent to the oracle — it will amend the plan";
|
|
471
|
+
},
|
|
472
|
+
startPlanned: (plan: { id: string }) => `starting ${plan.id} here — its agreed plan needs no approval…`,
|
|
473
|
+
discardPlan: (id: string) => {
|
|
474
|
+
const index = plans.findIndex((plan) => plan.id === id);
|
|
475
|
+
if (index >= 0) plans.splice(index, 1);
|
|
476
|
+
},
|
|
477
|
+
archivedTasks: () => [...archived],
|
|
478
|
+
archiveTask: (taskId: string) => {
|
|
479
|
+
const index = tasks.findIndex((task) => (task as { id: string }).id === taskId);
|
|
480
|
+
if (index < 0) return `no task ${taskId}`;
|
|
481
|
+
archived.unshift(tasks.splice(index, 1)[0]!);
|
|
482
|
+
return `archived ${taskId} — v shows archived tasks, a restores one`;
|
|
483
|
+
},
|
|
484
|
+
restoreTask: (taskId: string) => {
|
|
485
|
+
const index = archived.findIndex((task) => (task as { id: string }).id === taskId);
|
|
486
|
+
if (index < 0) return `no archived task ${taskId}`;
|
|
487
|
+
tasks.unshift(archived.splice(index, 1)[0]!);
|
|
488
|
+
return `restored ${taskId} to the task list`;
|
|
489
|
+
},
|
|
490
|
+
deleteTask: (taskId: string, where: "list" | "archive") => {
|
|
491
|
+
const list = where === "list" ? tasks : archived;
|
|
492
|
+
const index = list.findIndex((task) => (task as { id: string }).id === taskId);
|
|
493
|
+
if (index < 0) return `no task ${taskId}`;
|
|
494
|
+
list.splice(index, 1);
|
|
495
|
+
return `deleted ${taskId} for good`;
|
|
496
|
+
},
|
|
497
|
+
isAuto: (taskId: string) => auto.has(taskId),
|
|
498
|
+
setAuto: (taskId: string, on: boolean) => {
|
|
499
|
+
if (on) auto.add(taskId);
|
|
500
|
+
else auto.delete(taskId);
|
|
501
|
+
},
|
|
502
|
+
sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
|
|
503
|
+
sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
|
|
504
|
+
sessions: () => [...backgrounds],
|
|
505
|
+
startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
|
|
506
|
+
const key = `S${backgrounds.length + 1}`;
|
|
507
|
+
const session = fakeBackground({ key, name: start.plan?.title ?? start.request?.trim().slice(0, 40) ?? "mock session", status: "starting", ...(start.plan ? { planId: start.plan.id } : {}) });
|
|
508
|
+
backgrounds.push(session);
|
|
509
|
+
return session;
|
|
510
|
+
},
|
|
511
|
+
liveSessions: () => [...(fixture.liveSessions ?? [])],
|
|
512
|
+
planner: () => planning,
|
|
513
|
+
newPlanner: (seed?: { issue: { number: number; title: string; url?: string }; body: string }, seats?: string[]) => {
|
|
514
|
+
planning = fakePlanner({ ...defaultPlanner(), ...(seed ? { messages: [{ role: "you", text: seed.body, at: Date.now() }] } : {}), ...(seats ? { seats } : {}) });
|
|
515
|
+
return planning;
|
|
516
|
+
},
|
|
517
|
+
answerPanel: async () => {
|
|
518
|
+
planning.questions = [];
|
|
519
|
+
planning.awaitingAnswers = false;
|
|
520
|
+
return "answers sent — the panel is on the next round";
|
|
521
|
+
},
|
|
522
|
+
savePlan: async () => {
|
|
523
|
+
const plan = (planning.reply as { plan?: string } | undefined)?.plan;
|
|
524
|
+
if (!plan) throw new Error("there is no draft plan to save yet");
|
|
525
|
+
return "saved PLAN-mock-1 to the pending tasks — start it from the Tasks tab";
|
|
526
|
+
},
|
|
527
|
+
defaultPanel: () => ["backend", "designer", "qa", "researcher"],
|
|
528
|
+
planningRounds: () => 5,
|
|
529
|
+
quickfix,
|
|
530
|
+
pulls: fakePulls(fixture.mockPulls ?? []),
|
|
531
|
+
reviews: fakeReviews(fixture.mockReviews ?? [], fixture.mockReads ?? []),
|
|
532
|
+
issues: fakeIssues((fixture.mockIssues ?? []).map((issue) => ({ ...issue }))),
|
|
533
|
+
sessionChat: () => [],
|
|
534
|
+
hasOlderChat: () => false,
|
|
535
|
+
switchTo: async (target: { name: string }) => `switching this window to ${target.name}…`,
|
|
536
|
+
} as unknown as LobbyService;
|
|
537
|
+
seedPrompts(service, fixture);
|
|
538
|
+
return service;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Dismiss what `createFixtureService` opened and stop its stream. */
|
|
542
|
+
export function disposeFixtureService(service: LobbyService): void {
|
|
543
|
+
stopStream(service);
|
|
544
|
+
for (const id of opened.get(service) ?? []) promptHub.dismiss(id);
|
|
545
|
+
opened.delete(service);
|
|
546
|
+
}
|