@qwen-code/arena-agent 0.0.8-dev.5 → 0.0.9-dev.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.
Files changed (60) hide show
  1. package/.env.example +41 -15
  2. package/README.md +128 -29
  3. package/dist/agent.d.ts +8 -17
  4. package/dist/agent.js +60 -75
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli.js +12 -4
  7. package/dist/cli.js.map +1 -1
  8. package/dist/compaction.js +2 -1
  9. package/dist/compaction.js.map +1 -1
  10. package/dist/config.d.ts +11 -0
  11. package/dist/config.js +73 -9
  12. package/dist/config.js.map +1 -1
  13. package/dist/conversation-metrics.d.ts +61 -0
  14. package/dist/conversation-metrics.js +433 -0
  15. package/dist/conversation-metrics.js.map +1 -0
  16. package/dist/document-reader.js +9 -0
  17. package/dist/document-reader.js.map +1 -1
  18. package/dist/history.d.ts +31 -2
  19. package/dist/history.js +53 -3
  20. package/dist/history.js.map +1 -1
  21. package/dist/metrics.d.ts +61 -0
  22. package/dist/metrics.js +578 -0
  23. package/dist/metrics.js.map +1 -0
  24. package/dist/mock.js +153 -0
  25. package/dist/mock.js.map +1 -1
  26. package/dist/protocol.d.ts +2 -3
  27. package/dist/protocol.js +9 -5
  28. package/dist/protocol.js.map +1 -1
  29. package/dist/provider.js +22 -8
  30. package/dist/provider.js.map +1 -1
  31. package/dist/runtime-assets.d.ts +3 -0
  32. package/dist/runtime-assets.js +16 -0
  33. package/dist/runtime-assets.js.map +1 -0
  34. package/dist/sdk.d.ts +28 -1
  35. package/dist/sdk.js +38 -0
  36. package/dist/sdk.js.map +1 -1
  37. package/dist/server.js +486 -36
  38. package/dist/server.js.map +1 -1
  39. package/dist/side-query.d.ts +31 -0
  40. package/dist/side-query.js +138 -0
  41. package/dist/side-query.js.map +1 -0
  42. package/dist/spec.d.ts +1 -1
  43. package/dist/spec.js +6 -4
  44. package/dist/spec.js.map +1 -1
  45. package/dist/tools.d.ts +3 -8
  46. package/dist/tools.js +149 -200
  47. package/dist/tools.js.map +1 -1
  48. package/dist/tui.js +4 -3
  49. package/dist/tui.js.map +1 -1
  50. package/dist/types.d.ts +155 -5
  51. package/dist/web/app.d.ts +1 -0
  52. package/dist/web/app.js +333 -10
  53. package/dist/web/app.js.map +1 -1
  54. package/docs/SDK.md +42 -0
  55. package/package.json +10 -20
  56. package/web/index.html +25 -0
  57. package/web/styles.css +109 -3
  58. package/dist/index.d.ts +0 -9
  59. package/dist/index.js +0 -9
  60. package/dist/index.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,14 +1,20 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import { cp, mkdir, readFile, readdir, realpath, rename, stat, writeFile, } from "node:fs/promises";
3
+ import { cp, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
4
4
  import http from "node:http";
5
5
  import path from "node:path";
6
6
  import { Agent } from "./agent.js";
7
7
  import { attachmentMimeType, attachmentUserMessage, maxAttachmentBytesPerMessage, maxAttachmentsPerMessage, maxUploadBytes, mimeTypes, saveAttachment, uploadsDirectory, } from "./attachments.js";
8
8
  import { configFromEnv } from "./config.js";
9
- import { loadStoredHistory, saveStoredHistory, sessionStorageRoot, } from "./history.js";
9
+ import { historyMetadata, loadStoredHistory, loadStoredHistoryState, saveStoredHistory, sessionStorageRoot, } from "./history.js";
10
+ import { ModelBashMetricJudge, SessionMetricsAnalyzer, } from "./metrics.js";
11
+ import { ModelConversationMetricJudge } from "./conversation-metrics.js";
12
+ import { MockModelClient } from "./mock.js";
10
13
  import { EventSink } from "./protocol.js";
11
- import { projectRoot } from "./spec.js";
14
+ import { HttpModelClient } from "./provider.js";
15
+ import { embeddedAsset } from "./runtime-assets.js";
16
+ import { StructuredSideQueryRunner } from "./side-query.js";
17
+ import { projectRoot, toolDefinitions } from "./spec.js";
12
18
  const sessionSeedExcludes = new Set([
13
19
  ".arena",
14
20
  ".cache",
@@ -154,6 +160,69 @@ function sessionHistoryPath(session) {
154
160
  function sessionMetadataPath(session) {
155
161
  return path.join(session.root, "session.json");
156
162
  }
163
+ function sessionUndoRoot(session) {
164
+ return path.join(session.root, "undo");
165
+ }
166
+ function sessionUndoMetadataPath(session) {
167
+ return path.join(sessionUndoRoot(session), "checkpoint.json");
168
+ }
169
+ function sessionUndoWorkspacePath(session) {
170
+ return path.join(sessionUndoRoot(session), "workspace");
171
+ }
172
+ function sessionRetryPath(session) {
173
+ return path.join(session.root, "model-retry.json");
174
+ }
175
+ function historyHash(messages) {
176
+ return crypto
177
+ .createHash("sha256")
178
+ .update(JSON.stringify({ messages }))
179
+ .digest("hex");
180
+ }
181
+ async function loadModelRetry(session) {
182
+ try {
183
+ const value = JSON.parse(await readFile(sessionRetryPath(session), "utf8"));
184
+ if (value.version !== 1 ||
185
+ value.sessionId !== session.id ||
186
+ typeof value.messageId !== "string" ||
187
+ typeof value.prompt !== "string" ||
188
+ typeof value.failedAt !== "number" ||
189
+ typeof value.error !== "string" ||
190
+ typeof value.historyHash !== "string" ||
191
+ !/^[a-f0-9]{64}$/.test(value.historyHash)) {
192
+ throw new Error("invalid persisted model retry state");
193
+ }
194
+ return value;
195
+ }
196
+ catch (error) {
197
+ if (error.code === "ENOENT")
198
+ return undefined;
199
+ throw error;
200
+ }
201
+ }
202
+ async function saveModelRetry(session, retry) {
203
+ const target = sessionRetryPath(session);
204
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`;
205
+ await writeFile(temporary, JSON.stringify(retry), "utf8");
206
+ await rename(temporary, target);
207
+ }
208
+ async function clearModelRetry(session) {
209
+ await rm(sessionRetryPath(session), { force: true });
210
+ }
211
+ async function modelRetryForHistory(session, messages) {
212
+ let retry;
213
+ try {
214
+ retry = await loadModelRetry(session);
215
+ }
216
+ catch {
217
+ await clearModelRetry(session);
218
+ return undefined;
219
+ }
220
+ if (retry && historyHash(messages) !== retry.historyHash) {
221
+ await clearModelRetry(session);
222
+ return undefined;
223
+ }
224
+ return retry;
225
+ }
157
226
  function hashToken(token) {
158
227
  return crypto.createHash("sha256").update(token).digest("hex");
159
228
  }
@@ -206,6 +275,120 @@ async function saveSessionMetadata(session) {
206
275
  await writeFile(temporary, JSON.stringify(metadata), "utf8");
207
276
  await rename(temporary, target);
208
277
  }
278
+ async function loadUndoCheckpoint(session) {
279
+ try {
280
+ const checkpoint = JSON.parse(await readFile(sessionUndoMetadataPath(session), "utf8"));
281
+ if (checkpoint.version !== 1 ||
282
+ typeof checkpoint.messageId !== "string" ||
283
+ typeof checkpoint.input !== "string" ||
284
+ !Array.isArray(checkpoint.attachments) ||
285
+ !Array.isArray(checkpoint.messages) ||
286
+ typeof checkpoint.createdAt !== "number") {
287
+ throw new Error("invalid persisted undo checkpoint");
288
+ }
289
+ const workspace = await stat(sessionUndoWorkspacePath(session));
290
+ if (!workspace.isDirectory()) {
291
+ throw new Error("undo checkpoint workspace is not a directory");
292
+ }
293
+ return checkpoint;
294
+ }
295
+ catch (error) {
296
+ if (error.code === "ENOENT")
297
+ return null;
298
+ throw error;
299
+ }
300
+ }
301
+ function snapshotIncludes(root, source) {
302
+ const relative = path.relative(root, source);
303
+ if (!relative)
304
+ return true;
305
+ return !relative
306
+ .split(path.sep)
307
+ .some((part) => sessionSeedExcludes.has(part));
308
+ }
309
+ function checkpointAttachment(attachment) {
310
+ return {
311
+ name: attachment.name,
312
+ path: attachment.path,
313
+ mimeType: attachment.mimeType,
314
+ size: attachment.size,
315
+ ...(attachment.imageMimeType
316
+ ? { imageMimeType: attachment.imageMimeType }
317
+ : {}),
318
+ ...(attachment.sourceUrl ? { sourceUrl: attachment.sourceUrl } : {}),
319
+ };
320
+ }
321
+ async function installDirectory(staging, target) {
322
+ const previous = `${target}.${crypto.randomUUID()}.previous`;
323
+ let movedPrevious = false;
324
+ try {
325
+ await rename(target, previous);
326
+ movedPrevious = true;
327
+ }
328
+ catch (error) {
329
+ if (error.code !== "ENOENT")
330
+ throw error;
331
+ }
332
+ try {
333
+ await rename(staging, target);
334
+ }
335
+ catch (error) {
336
+ if (movedPrevious)
337
+ await rename(previous, target).catch(() => undefined);
338
+ throw error;
339
+ }
340
+ if (movedPrevious) {
341
+ await rm(previous, { recursive: true, force: true }).catch(() => undefined);
342
+ }
343
+ }
344
+ async function prepareUndoCheckpoint(session, messageId, input, attachments) {
345
+ const storedHistory = await loadStoredHistoryState(sessionHistoryPath(session));
346
+ const checkpoint = {
347
+ version: 1,
348
+ messageId,
349
+ input,
350
+ attachments: attachments.map(checkpointAttachment),
351
+ messages: structuredClone((await readHistory(session)) || []),
352
+ ...(storedHistory?.metrics
353
+ ? { metrics: structuredClone(storedHistory.metrics) }
354
+ : {}),
355
+ createdAt: Date.now(),
356
+ };
357
+ const target = sessionUndoRoot(session);
358
+ const staging = `${target}.${crypto.randomUUID()}.tmp`;
359
+ try {
360
+ await mkdir(staging, { recursive: true });
361
+ await cp(session.workspace, path.join(staging, "workspace"), {
362
+ recursive: true,
363
+ mode: fs.constants.COPYFILE_FICLONE,
364
+ filter: (source) => snapshotIncludes(session.workspace, source),
365
+ verbatimSymlinks: true,
366
+ });
367
+ await writeFile(path.join(staging, "checkpoint.json"), JSON.stringify(checkpoint), "utf8");
368
+ await installDirectory(staging, target);
369
+ session.undoCheckpoint = checkpoint;
370
+ }
371
+ catch (error) {
372
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
373
+ throw error;
374
+ }
375
+ }
376
+ async function restoreUndoWorkspace(session) {
377
+ const source = sessionUndoWorkspacePath(session);
378
+ const staging = `${session.workspace}.${crypto.randomUUID()}.restore`;
379
+ try {
380
+ await cp(source, staging, {
381
+ recursive: true,
382
+ mode: fs.constants.COPYFILE_FICLONE,
383
+ verbatimSymlinks: true,
384
+ });
385
+ await installDirectory(staging, session.workspace);
386
+ }
387
+ catch (error) {
388
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
389
+ throw error;
390
+ }
391
+ }
209
392
  async function seedSessionWorkspace(context, target) {
210
393
  await mkdir(target, { recursive: true });
211
394
  if (process.env.ARENA_SESSION_WORKSPACE_MODE === "empty") {
@@ -246,10 +429,13 @@ async function createSession(context, clientContext) {
246
429
  root: paths.root,
247
430
  workspace: paths.workspace,
248
431
  artifacts: paths.artifacts,
432
+ history: null,
433
+ undoCheckpoint: null,
249
434
  events: [],
250
435
  nextEventId: 1,
251
436
  subscribers: new Set(),
252
437
  running: null,
438
+ preparingTurn: false,
253
439
  pendingInput: null,
254
440
  pendingInterruption: false,
255
441
  interruptionGeneration: 0,
@@ -304,10 +490,13 @@ async function getSession(context, sessionId) {
304
490
  root: paths.root,
305
491
  workspace: paths.workspace,
306
492
  artifacts: paths.artifacts,
493
+ history: null,
494
+ undoCheckpoint: null,
307
495
  events: [],
308
496
  nextEventId: 1,
309
497
  subscribers: new Set(),
310
498
  running: null,
499
+ preparingTurn: false,
311
500
  pendingInput: null,
312
501
  pendingInterruption: metadata.pendingInterruption === true,
313
502
  interruptionGeneration: typeof metadata.interruptionGeneration === "number" &&
@@ -327,6 +516,7 @@ async function getSession(context, sessionId) {
327
516
  mkdir(loaded.workspace, { recursive: true }),
328
517
  mkdir(loaded.artifacts, { recursive: true }),
329
518
  ]);
519
+ loaded.undoCheckpoint = await loadUndoCheckpoint(loaded);
330
520
  const raced = context.sessions.get(sessionId);
331
521
  if (raced)
332
522
  return raced;
@@ -546,7 +736,7 @@ function createUiStream(session, messageId) {
546
736
  };
547
737
  emit(session, { type: "start", messageId });
548
738
  return {
549
- consume(event) {
739
+ consume(event, resultOptions = {}) {
550
740
  if (event.type === "model.start") {
551
741
  state.toolParts = new Map();
552
742
  emit(session, {
@@ -737,6 +927,8 @@ function createUiStream(session, messageId) {
737
927
  type: "trigger:turn-complete",
738
928
  status: event.status,
739
929
  error: event.error,
930
+ messageId: state.messageId,
931
+ retryable: resultOptions.retryable === true,
740
932
  });
741
933
  state.completed = true;
742
934
  }
@@ -757,16 +949,56 @@ function createUiStream(session, messageId) {
757
949
  };
758
950
  }
759
951
  async function readHistory(session) {
760
- return loadStoredHistory(sessionHistoryPath(session));
952
+ if (session.history)
953
+ return structuredClone(session.history);
954
+ const messages = await loadStoredHistory(sessionHistoryPath(session));
955
+ if (messages)
956
+ session.history = structuredClone(messages);
957
+ return messages ? structuredClone(messages) : undefined;
958
+ }
959
+ async function saveHistory(session, messages, config, metrics) {
960
+ await saveStoredHistory(sessionHistoryPath(session), messages, historyMetadata(config, session.id), metrics);
961
+ session.history = structuredClone([...messages]);
761
962
  }
762
- async function saveHistory(session, messages) {
763
- await saveStoredHistory(sessionHistoryPath(session), messages);
963
+ function sessionMetricsAnalyzer(config, signal) {
964
+ const sideQueryModel = config.metricsSideQueryModel || config.model;
965
+ const runner = config.metricsSideQueryEnabled
966
+ ? new StructuredSideQueryRunner(sideQueryModel.startsWith("mock/")
967
+ ? new MockModelClient()
968
+ : new HttpModelClient(config), {
969
+ model: sideQueryModel,
970
+ maxTokens: config.metricsSideQueryMaxTokens,
971
+ maxAttempts: config.metricsSideQueryMaxAttempts,
972
+ enableThinking: config.metricsSideQueryEnableThinking,
973
+ thinkingBudget: config.metricsSideQueryThinkingBudget,
974
+ signal,
975
+ })
976
+ : undefined;
977
+ return new SessionMetricsAnalyzer({
978
+ fallbackOfferedToolNames: toolDefinitions().map((definition) => definition.function.name),
979
+ ...(runner ? { judge: new ModelBashMetricJudge(runner) } : {}),
980
+ ...(runner
981
+ ? { conversationJudge: new ModelConversationMetricJudge(runner) }
982
+ : {}),
983
+ });
984
+ }
985
+ function lastAssistantText(messages) {
986
+ if (!messages)
987
+ return "";
988
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
989
+ const message = messages[index];
990
+ if (message?.role === "assistant")
991
+ return message.content;
992
+ }
993
+ return "";
764
994
  }
765
- async function runAgent(context, session, prompt, messageId, interruptionGeneration, userMessage) {
995
+ async function runAgent(context, session, prompt, messageId, controller, interruptionGeneration, userMessage, resumeFromHistory = false) {
766
996
  const ui = createUiStream(session, messageId);
767
- const controller = new AbortController();
768
- session.running = { controller };
769
- let pendingResult = null;
997
+ let failedModelCheckpoint;
998
+ let failedModelError;
999
+ let currentOfferedToolNames = [];
1000
+ const offeredToolNamesByCallId = new Map();
1001
+ const currentTurnMessages = [];
770
1002
  let sink;
771
1003
  const askUserHandler = (toolCallId, questions) => new Promise((resolve, reject) => {
772
1004
  if (controller.signal.aborted) {
@@ -785,10 +1017,20 @@ async function runAgent(context, session, prompt, messageId, interruptionGenerat
785
1017
  sink = new EventSink({
786
1018
  format: "silent",
787
1019
  onEvent: (event) => {
788
- if (event.type === "result") {
789
- pendingResult = event;
1020
+ if (event.type === "model.start") {
1021
+ currentOfferedToolNames = [...(event.offered_tool_names || [])];
790
1022
  }
791
- else {
1023
+ else if (event.type === "message" && event.message?.role === "assistant") {
1024
+ for (const call of event.message.tool_calls || []) {
1025
+ if (call.id) {
1026
+ offeredToolNamesByCallId.set(call.id, [...currentOfferedToolNames]);
1027
+ }
1028
+ }
1029
+ }
1030
+ if (event.type === "message" && event.message) {
1031
+ currentTurnMessages.push(structuredClone(event.message));
1032
+ }
1033
+ if (event.type !== "result") {
792
1034
  ui.consume(event);
793
1035
  }
794
1036
  },
@@ -798,12 +1040,17 @@ async function runAgent(context, session, prompt, messageId, interruptionGenerat
798
1040
  workspace: session.workspace,
799
1041
  };
800
1042
  try {
801
- const history = await readHistory(session);
1043
+ const storedHistory = await loadStoredHistoryState(sessionHistoryPath(session));
1044
+ const history = storedHistory?.messages;
802
1045
  const result = await new Agent(sessionConfig, sink, {
803
1046
  askUserHandler,
804
1047
  sessionId: session.id,
805
1048
  signal: controller.signal,
806
- onCheckpoint: (messages) => saveHistory(session, messages),
1049
+ onCheckpoint: (messages) => saveHistory(session, messages, sessionConfig),
1050
+ onModelFailure: (messages, error) => {
1051
+ failedModelCheckpoint = structuredClone([...messages]);
1052
+ failedModelError = error;
1053
+ },
807
1054
  clientContext: {
808
1055
  currentDate: session.currentDate,
809
1056
  timezone: session.timezone,
@@ -812,10 +1059,42 @@ async function runAgent(context, session, prompt, messageId, interruptionGenerat
812
1059
  }).run(prompt, history, {
813
1060
  interrupted: interruptionGeneration !== undefined,
814
1061
  userMessage,
1062
+ resumeFromHistory,
815
1063
  });
816
- const shouldPersistHistory = result.status === "success" || controller.signal.aborted;
817
- if (shouldPersistHistory) {
818
- await saveHistory(session, result.messages);
1064
+ let deliveredResult = result;
1065
+ let retryable = false;
1066
+ if (result.status === "error" &&
1067
+ !controller.signal.aborted &&
1068
+ failedModelCheckpoint) {
1069
+ const checkpoint = structuredClone(failedModelCheckpoint);
1070
+ await saveHistory(session, checkpoint, sessionConfig);
1071
+ await saveModelRetry(session, {
1072
+ version: 1,
1073
+ sessionId: session.id,
1074
+ messageId,
1075
+ prompt,
1076
+ failedAt: Date.now(),
1077
+ error: errorMessage(failedModelError ?? result.error ?? "model failed"),
1078
+ historyHash: historyHash(checkpoint),
1079
+ });
1080
+ deliveredResult = { ...result, messages: checkpoint };
1081
+ retryable = true;
1082
+ }
1083
+ else {
1084
+ await clearModelRetry(session);
1085
+ }
1086
+ const metrics = await sessionMetricsAnalyzer(sessionConfig, controller.signal).update({
1087
+ messages: deliveredResult.messages,
1088
+ previous: storedHistory?.metrics,
1089
+ throughTurnId: messageId,
1090
+ turnStatus: deliveredResult.status,
1091
+ offeredToolNamesByCallId,
1092
+ ...(!resumeFromHistory ? { currentUserText: prompt } : {}),
1093
+ previousAssistantText: lastAssistantText(history),
1094
+ currentTurnMessages,
1095
+ });
1096
+ await saveHistory(session, deliveredResult.messages, sessionConfig, metrics);
1097
+ if (result.status === "success" || controller.signal.aborted) {
819
1098
  if (interruptionGeneration !== undefined &&
820
1099
  session.interruptionGeneration === interruptionGeneration) {
821
1100
  session.pendingInterruption = false;
@@ -823,7 +1102,11 @@ async function runAgent(context, session, prompt, messageId, interruptionGenerat
823
1102
  }
824
1103
  session.touchedAt = Date.now();
825
1104
  await saveSessionMetadata(session);
826
- ui.consume(pendingResult || result);
1105
+ emit(session, {
1106
+ type: "data-session-metrics",
1107
+ data: { metrics },
1108
+ });
1109
+ ui.consume(deliveredResult, { retryable });
827
1110
  }
828
1111
  catch (error) {
829
1112
  session.touchedAt = Date.now();
@@ -916,7 +1199,9 @@ async function triggerToken(context, request, response) {
916
1199
  async function uploadAttachment(request, response, session) {
917
1200
  if (!authorize(request, response, session))
918
1201
  return;
919
- if (session.running) {
1202
+ if (session.running ||
1203
+ session.preparingTurn ||
1204
+ session.activePrompt !== null) {
920
1205
  writeJson(response, 409, {
921
1206
  error: "attachments cannot be uploaded while a turn is running",
922
1207
  });
@@ -949,7 +1234,9 @@ async function uploadAttachment(request, response, session) {
949
1234
  async function appendMessage(context, request, response, session) {
950
1235
  if (!authorize(request, response, session))
951
1236
  return;
952
- if (session.running) {
1237
+ if (session.running ||
1238
+ session.preparingTurn ||
1239
+ session.activePrompt !== null) {
953
1240
  writeJson(response, 409, { error: "a turn is already running" });
954
1241
  return;
955
1242
  }
@@ -1001,6 +1288,22 @@ async function appendMessage(context, request, response, session) {
1001
1288
  ? attachmentUserMessage(prompt, attachments)
1002
1289
  : undefined;
1003
1290
  const messageId = String(payload.messageId || message.id || crypto.randomUUID());
1291
+ if (session.running ||
1292
+ session.preparingTurn ||
1293
+ session.activePrompt !== null) {
1294
+ writeJson(response, 409, { error: "a turn is already running" });
1295
+ return;
1296
+ }
1297
+ const controller = new AbortController();
1298
+ session.preparingTurn = true;
1299
+ try {
1300
+ await prepareUndoCheckpoint(session, messageId, prompt, attachments);
1301
+ await clearModelRetry(session);
1302
+ session.running = { controller };
1303
+ }
1304
+ finally {
1305
+ session.preparingTurn = false;
1306
+ }
1004
1307
  const interruptionGeneration = session.pendingInterruption
1005
1308
  ? session.interruptionGeneration
1006
1309
  : undefined;
@@ -1019,19 +1322,144 @@ async function appendMessage(context, request, response, session) {
1019
1322
  partId: request.headers["x-part-id"] || null,
1020
1323
  });
1021
1324
  queueMicrotask(() => {
1022
- void runAgent(context, session, prompt, messageId, interruptionGeneration, userMessage);
1325
+ void runAgent(context, session, prompt, messageId, controller, interruptionGeneration, userMessage);
1023
1326
  });
1024
1327
  }
1328
+ async function retryModelTurn(context, request, response, session) {
1329
+ if (!authorize(request, response, session))
1330
+ return;
1331
+ const body = await readJson(request);
1332
+ if (body.messageId !== undefined && typeof body.messageId !== "string") {
1333
+ writeJson(response, 400, { error: "messageId must be a string" });
1334
+ return;
1335
+ }
1336
+ if (session.running ||
1337
+ session.preparingTurn ||
1338
+ session.activePrompt !== null) {
1339
+ writeJson(response, 409, { error: "a turn is already running" });
1340
+ return;
1341
+ }
1342
+ const controller = new AbortController();
1343
+ session.running = { controller };
1344
+ const releaseReservation = () => {
1345
+ if (session.running?.controller === controller)
1346
+ session.running = null;
1347
+ };
1348
+ try {
1349
+ const currentHistory = (await readHistory(session)) || [];
1350
+ const retry = await modelRetryForHistory(session, currentHistory);
1351
+ if (!retry) {
1352
+ releaseReservation();
1353
+ writeJson(response, 409, {
1354
+ error: "no failed model request is available",
1355
+ });
1356
+ return;
1357
+ }
1358
+ if (body.messageId && body.messageId !== retry.messageId) {
1359
+ releaseReservation();
1360
+ writeJson(response, 409, {
1361
+ error: "the requested failed turn is no longer retryable",
1362
+ });
1363
+ return;
1364
+ }
1365
+ const messageId = crypto.randomUUID();
1366
+ session.turnStartEventId = session.events.at(-1)?.id || "0";
1367
+ session.activePrompt = retry.prompt;
1368
+ session.touchedAt = Date.now();
1369
+ writeJson(response, 202, {
1370
+ accepted: true,
1371
+ sessionId: session.id,
1372
+ messageId,
1373
+ retryOf: retry.messageId,
1374
+ });
1375
+ queueMicrotask(() => {
1376
+ void runAgent(context, session, retry.prompt, messageId, controller, undefined, undefined, true);
1377
+ });
1378
+ }
1379
+ catch (error) {
1380
+ releaseReservation();
1381
+ throw error;
1382
+ }
1383
+ }
1025
1384
  async function sessionMessages(context, request, response, session) {
1026
1385
  if (!authorize(request, response, session))
1027
1386
  return;
1028
- const messages = (await readHistory(session)) || [];
1387
+ const stored = await loadStoredHistoryState(sessionHistoryPath(session));
1388
+ const messages = stored?.messages || [];
1389
+ const turnActive = Boolean(session.running || session.preparingTurn || session.activePrompt !== null);
1390
+ const retry = turnActive
1391
+ ? undefined
1392
+ : await modelRetryForHistory(session, messages);
1029
1393
  writeJson(response, 200, {
1030
1394
  messages,
1031
1395
  lastEventId: session.running
1032
1396
  ? session.turnStartEventId
1033
1397
  : session.events.at(-1)?.id || "0",
1034
1398
  activePrompt: session.activePrompt,
1399
+ canUndo: session.undoCheckpoint !== null,
1400
+ retryable: Boolean(retry),
1401
+ retryMessageId: retry?.messageId || null,
1402
+ metrics: stored?.metrics || null,
1403
+ });
1404
+ }
1405
+ async function undoLastTurn(config, request, response, session) {
1406
+ if (!authorize(request, response, session))
1407
+ return;
1408
+ if (session.running || session.preparingTurn || session.activePrompt !== null) {
1409
+ writeJson(response, 409, {
1410
+ error: "the active turn must finish or be cancelled before it can be undone",
1411
+ });
1412
+ return;
1413
+ }
1414
+ const checkpoint = session.undoCheckpoint;
1415
+ if (!checkpoint) {
1416
+ writeJson(response, 200, {
1417
+ undone: false,
1418
+ messages: (await readHistory(session)) || [],
1419
+ lastEventId: session.events.at(-1)?.id || "0",
1420
+ canUndo: false,
1421
+ });
1422
+ return;
1423
+ }
1424
+ await restoreUndoWorkspace(session);
1425
+ await saveHistory(session, checkpoint.messages, config, checkpoint.metrics ?? null);
1426
+ await clearModelRetry(session);
1427
+ session.pendingInterruption = false;
1428
+ session.activePrompt = null;
1429
+ session.touchedAt = Date.now();
1430
+ await saveSessionMetadata(session);
1431
+ let turnStart = -1;
1432
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
1433
+ if (session.events[index]?.data.type === "start") {
1434
+ turnStart = index;
1435
+ break;
1436
+ }
1437
+ }
1438
+ if (turnStart >= 0)
1439
+ session.events.splice(turnStart);
1440
+ session.turnStartEventId = session.events.at(-1)?.id || "0";
1441
+ const event = emit(session, {
1442
+ type: "data-turn-undone",
1443
+ data: {
1444
+ messageId: checkpoint.messageId,
1445
+ input: checkpoint.input,
1446
+ attachments: checkpoint.attachments,
1447
+ messages: checkpoint.messages,
1448
+ metrics: checkpoint.metrics ?? null,
1449
+ canUndo: false,
1450
+ },
1451
+ });
1452
+ session.undoCheckpoint = null;
1453
+ await rm(sessionUndoRoot(session), { recursive: true, force: true });
1454
+ writeJson(response, 200, {
1455
+ undone: true,
1456
+ messageId: checkpoint.messageId,
1457
+ input: checkpoint.input,
1458
+ attachments: checkpoint.attachments,
1459
+ messages: checkpoint.messages,
1460
+ metrics: checkpoint.metrics ?? null,
1461
+ lastEventId: event.id,
1462
+ canUndo: false,
1035
1463
  });
1036
1464
  }
1037
1465
  function normalizeInputAnswers(pending, submitted) {
@@ -1208,18 +1636,26 @@ async function safeWorkspaceFile(session, encodedPath) {
1208
1636
  throw error;
1209
1637
  }
1210
1638
  }
1211
- async function serveFile(response, filePath, cache = false, isolated = false) {
1212
- const info = await stat(filePath);
1213
- if (!info.isFile()) {
1214
- const error = new Error("not found");
1215
- error.code = "ENOENT";
1216
- throw error;
1639
+ async function serveFile(response, filePath, cache = false, isolated = false, assetName) {
1640
+ const asset = assetName ? embeddedAsset(assetName) : undefined;
1641
+ let size;
1642
+ if (asset) {
1643
+ size = asset.byteLength;
1644
+ }
1645
+ else {
1646
+ const info = await stat(filePath);
1647
+ if (!info.isFile()) {
1648
+ const error = new Error("not found");
1649
+ error.code = "ENOENT";
1650
+ throw error;
1651
+ }
1652
+ size = info.size;
1217
1653
  }
1218
1654
  response.writeHead(200, {
1219
1655
  ...corsHeaders(),
1220
1656
  "content-type": mimeTypes[path.extname(filePath).toLowerCase()] ||
1221
1657
  "application/octet-stream",
1222
- "content-length": info.size,
1658
+ "content-length": size,
1223
1659
  "cache-control": cache ? "public, max-age=300" : "no-cache",
1224
1660
  "x-content-type-options": "nosniff",
1225
1661
  ...(isolated
@@ -1229,7 +1665,12 @@ async function serveFile(response, filePath, cache = false, isolated = false) {
1229
1665
  }
1230
1666
  : {}),
1231
1667
  });
1232
- fs.createReadStream(filePath).pipe(response);
1668
+ if (asset) {
1669
+ response.end(asset);
1670
+ }
1671
+ else {
1672
+ fs.createReadStream(filePath).pipe(response);
1673
+ }
1233
1674
  }
1234
1675
  function sessionRoute(pathname, suffix) {
1235
1676
  return pathname.match(new RegExp(`^/ai-proxy/realtime/v1/sessions/([A-Za-z0-9-]{1,128})/${suffix}$`));
@@ -1251,7 +1692,9 @@ function createRequestHandler(context) {
1251
1692
  const outputMatch = sessionRoute(url.pathname, "out");
1252
1693
  const appendMatch = sessionRoute(url.pathname, "in/append");
1253
1694
  const messagesMatch = apiSessionRoute(url.pathname, "messages");
1695
+ const retryMatch = apiSessionRoute(url.pathname, "retry");
1254
1696
  const cancelMatch = apiSessionRoute(url.pathname, "cancel");
1697
+ const undoMatch = apiSessionRoute(url.pathname, "undo");
1255
1698
  const inputMatch = apiSessionRoute(url.pathname, "input-response");
1256
1699
  const uploadMatch = apiSessionRoute(url.pathname, "uploads");
1257
1700
  const workspaceMatch = workspaceRoute(url.pathname);
@@ -1289,9 +1732,15 @@ function createRequestHandler(context) {
1289
1732
  else if (messagesMatch && request.method === "GET") {
1290
1733
  await sessionMessages(context, request, response, await getSession(context, messagesMatch[1] || ""));
1291
1734
  }
1735
+ else if (retryMatch && request.method === "POST") {
1736
+ await retryModelTurn(context, request, response, await getSession(context, retryMatch[1] || ""));
1737
+ }
1292
1738
  else if (cancelMatch && request.method === "POST") {
1293
1739
  await cancelTurn(request, response, await getSession(context, cancelMatch[1] || ""));
1294
1740
  }
1741
+ else if (undoMatch && request.method === "POST") {
1742
+ await undoLastTurn(context.config, request, response, await getSession(context, undoMatch[1] || ""));
1743
+ }
1295
1744
  else if (inputMatch && request.method === "POST") {
1296
1745
  await respondToInput(request, response, await getSession(context, inputMatch[1] || ""));
1297
1746
  }
@@ -1318,16 +1767,16 @@ function createRequestHandler(context) {
1318
1767
  }
1319
1768
  else if (["/", "/index.html"].includes(url.pathname) ||
1320
1769
  /^\/agent\/[A-Za-z0-9-]{1,128}$/.test(url.pathname)) {
1321
- await serveFile(response, path.join(context.webRoot, "index.html"));
1770
+ await serveFile(response, path.join(context.webRoot, "index.html"), false, false, "web/index.html");
1322
1771
  }
1323
1772
  else if (url.pathname === "/app.js") {
1324
- await serveFile(response, context.appScript, true);
1773
+ await serveFile(response, context.appScript, true, false, "dist/web/app.js");
1325
1774
  }
1326
1775
  else if (url.pathname === "/sdk.js") {
1327
- await serveFile(response, path.join(projectRoot(), "dist", "sdk.js"), true);
1776
+ await serveFile(response, path.join(projectRoot(), "dist", "sdk.js"), true, false, "dist/sdk.js");
1328
1777
  }
1329
1778
  else if (["/styles.css", "/fonts.css", "/arena-logo.svg"].includes(url.pathname)) {
1330
- await serveFile(response, path.join(context.webRoot, url.pathname.slice(1)), true);
1779
+ await serveFile(response, path.join(context.webRoot, url.pathname.slice(1)), true, false, `web/${url.pathname.slice(1)}`);
1331
1780
  }
1332
1781
  else {
1333
1782
  writeJson(response, 404, { error: "not found" });
@@ -1379,6 +1828,7 @@ export async function startServer(options = {}) {
1379
1828
  const cutoff = Date.now() - 6 * 60 * 60 * 1000;
1380
1829
  for (const [sessionId, session] of context.sessions) {
1381
1830
  if (!session.running &&
1831
+ !session.preparingTurn &&
1382
1832
  !session.subscribers.size &&
1383
1833
  session.touchedAt < cutoff) {
1384
1834
  context.sessions.delete(sessionId);