copilot-operator 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/main.js +2 -104
- package/dist/src/api/operator.controller.js +18 -1
- package/dist/src/api/operator.service.js +15 -2
- package/dist/src/api/server.js +114 -0
- package/dist/src/config/schema.js +5 -0
- package/dist/src/exec/checks.js +17 -0
- package/dist/src/orchestrator/progress.js +106 -0
- package/dist/src/orchestrator/taskRunner.js +110 -10
- package/dist/src/plan/brief.js +2 -0
- package/dist/src/plan/importPlan.js +1 -0
- package/dist/src/plan/schema.js +7 -0
- package/dist/src/plan/systemGuide.js +45 -6
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +5 -0
- package/dist/src/session/handoff.js +42 -0
- package/dist/src/session/metrics.js +95 -0
- package/dist/src/session/store.js +14 -0
- package/dist/src/transport/chatTransport.js +32 -0
- package/dist/src/vcs/contentIntegrity.js +174 -0
- package/dist/src/vcs/git.js +42 -0
- package/dist/src/vcs/scope.js +129 -0
- package/dist/src/vcs/taskVcs.js +43 -2
- package/dist/web/404.html +4 -3
- package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
- package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
- package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
- package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
- package/dist/web/appearance.html +4 -3
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +4 -3
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +4 -3
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +4 -3
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +4 -3
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +4 -3
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +4 -3
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +4 -3
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +4 -3
- package/dist/web/system.txt +12 -12
- package/package.json +13 -4
- package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
- package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
- package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
- package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
- package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
- package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → auD7lTmj-JDMJIQ4TVivR}/_ssgManifest.js +0 -0
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { createTransport } from '../transport/chatTransport.js';
|
|
17
17
|
import { buildChatName, chatCode, savePointer } from '../transport/chatSession.js';
|
|
18
18
|
import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
|
|
19
19
|
import { resolveDeviations, describeDeviations, mergeDisputes, describeDisputes } from '../protocol/replySchema.js';
|
|
20
20
|
import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
|
|
21
21
|
import { runStep } from '../exec/runner.js';
|
|
22
22
|
import { availableShells, detectShells, effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
|
|
23
|
-
import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK } from '../exec/checks.js';
|
|
23
|
+
import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
|
|
24
24
|
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing } from './derivedChecks.js';
|
|
25
25
|
import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
|
|
26
26
|
import { redactSecrets } from '../exec/redaction.js';
|
|
@@ -38,6 +38,10 @@ import { writeReport } from '../exec/reportFile.js';
|
|
|
38
38
|
import { Pacer } from '../util/pacing.js';
|
|
39
39
|
import { RunLog } from '../log/runLog.js';
|
|
40
40
|
import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
|
|
41
|
+
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
42
|
+
import { composeHandoff } from '../session/handoff.js';
|
|
43
|
+
import { ProgressWatch } from './progress.js';
|
|
44
|
+
import { treeFingerprint } from '../vcs/git.js';
|
|
41
45
|
import { mirrorProject, describeMirror } from '../context/projectMirror.js';
|
|
42
46
|
import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
43
47
|
import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
|
|
@@ -162,7 +166,7 @@ class Sink {
|
|
|
162
166
|
* holding the profile.
|
|
163
167
|
*/
|
|
164
168
|
export async function openBrowser(cfg, bus, transportDir, sessionId) {
|
|
165
|
-
const transport =
|
|
169
|
+
const transport = createTransport({
|
|
166
170
|
profileDir: cfg.resolved.profileDir,
|
|
167
171
|
transportDir,
|
|
168
172
|
chatUrl: cfg.copilot.url,
|
|
@@ -319,6 +323,13 @@ export async function runTask(transport, session, task, deps) {
|
|
|
319
323
|
const taskLogPath = log.path('task-log.txt');
|
|
320
324
|
await mkdir(artifactsDir, { recursive: true });
|
|
321
325
|
await mkdir(repliesDir, { recursive: true });
|
|
326
|
+
/*
|
|
327
|
+
* Made here, not left to the first step report. The report writer creates it as a side effect, so
|
|
328
|
+
* a chat that answered "done" before running a single step reached the failed-checks report with
|
|
329
|
+
* no folder to write it into, and the task ended "failed" on an ENOENT about checks-1.txt instead
|
|
330
|
+
* of telling the chat which check failed. Found by the end-to-end checks (test/e2e-run.check.ts).
|
|
331
|
+
*/
|
|
332
|
+
await mkdir(reportsDir, { recursive: true });
|
|
322
333
|
/** The consolidated, human-readable record of the whole task, appended as it happens. */
|
|
323
334
|
const record = async (heading, body) => {
|
|
324
335
|
await appendFile(taskLogPath, `\n${'='.repeat(78)}\n${heading}\n${'='.repeat(78)}\n${body.trimEnd()}\n`, 'utf8');
|
|
@@ -347,6 +358,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
347
358
|
let disputes = [];
|
|
348
359
|
/** What the runner tells the model in its next message about the reply just processed. */
|
|
349
360
|
let runnerNotes = [];
|
|
361
|
+
/** Steps the chat sent that were not run, for the handoff at the end. */
|
|
362
|
+
const notRun = [];
|
|
363
|
+
/** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
|
|
364
|
+
const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
|
|
365
|
+
/** Loops the older guards do not see; see `progress.ts`. */
|
|
366
|
+
const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
|
|
367
|
+
/** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
|
|
368
|
+
let noProgressReason = null;
|
|
350
369
|
/**
|
|
351
370
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
352
371
|
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
@@ -447,6 +466,11 @@ export async function runTask(transport, session, task, deps) {
|
|
|
447
466
|
if (reviewChecks.length > 0)
|
|
448
467
|
t.reviewChecks = reviewChecks;
|
|
449
468
|
t.logFile = 'task-log.txt';
|
|
469
|
+
t.stats = stats;
|
|
470
|
+
});
|
|
471
|
+
// The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
|
|
472
|
+
await setTask((t) => {
|
|
473
|
+
t.handoff = composeHandoff(t, notRun);
|
|
450
474
|
});
|
|
451
475
|
sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
|
|
452
476
|
await log.close();
|
|
@@ -576,6 +600,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
576
600
|
? `not active: ${prepared.vcs.problem}`
|
|
577
601
|
: `branch ${prepared.vcs.branch}, from ${prepared.vcs.baseCommit ?? '(no commits yet)'}`);
|
|
578
602
|
}
|
|
603
|
+
/*
|
|
604
|
+
* The paths this task may change. Enforced only on a branch of the task's own, which is what
|
|
605
|
+
* makes "put back" safe: the tree was clean when the branch was made, so every change in it
|
|
606
|
+
* is the task's, and the starting commit is what a change goes back to.
|
|
607
|
+
*/
|
|
608
|
+
const scope = task.scope ?? [];
|
|
609
|
+
const scopeEnforced = scope.length > 0 && !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
|
|
610
|
+
/** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
|
|
611
|
+
const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
|
|
579
612
|
// --- project mirror, attached to the first message of the task ---------------------
|
|
580
613
|
let mirrorFiles = [];
|
|
581
614
|
if (session.mirror.enabled && session.mirror.rootDir) {
|
|
@@ -630,6 +663,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
630
663
|
workDirNote: workingDirNote(work),
|
|
631
664
|
vcsNote: prepared.note,
|
|
632
665
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
666
|
+
scopeNote: scopeNote(scope, scopeEnforced),
|
|
633
667
|
shellNote: shellNote(shells, defaultShell),
|
|
634
668
|
});
|
|
635
669
|
await setTask((t) => {
|
|
@@ -694,7 +728,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
694
728
|
* task refusing to start over it.
|
|
695
729
|
*/
|
|
696
730
|
const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
|
|
697
|
-
|
|
731
|
+
/** The runner's own checks that have been pointed out to the chat once already. */
|
|
732
|
+
const pointedOut = new Set();
|
|
698
733
|
/**
|
|
699
734
|
* Whether "done" is accepted, decided by the operator's checks rather than by the reply.
|
|
700
735
|
*
|
|
@@ -705,7 +740,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
705
740
|
* happened, and a task cannot be trusted to answer that about itself.
|
|
706
741
|
*/
|
|
707
742
|
const gateOnChecks = async () => {
|
|
708
|
-
const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK] : [])];
|
|
743
|
+
const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
|
|
709
744
|
if (checks.length === 0)
|
|
710
745
|
return 'accept';
|
|
711
746
|
// Counted only once the round turns out to have been a real attempt. A round that died of
|
|
@@ -726,19 +761,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
726
761
|
deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd }),
|
|
727
762
|
roots: confinement.roots,
|
|
728
763
|
repoDir: willCommit ? repoDirOf(session) : undefined,
|
|
764
|
+
baseCommit: prepared.vcs.baseCommit,
|
|
729
765
|
// The same shell a step that named none is given, so the gate and the work it judges
|
|
730
766
|
// cannot have been read by different interpreters.
|
|
731
767
|
defaultShell,
|
|
732
768
|
}));
|
|
769
|
+
/*
|
|
770
|
+
* The runner's own checks are advice given once, not a gate: a second "done" with the finding
|
|
771
|
+
* still there is committed and the finding stays on the task. Refusing instead would leave the
|
|
772
|
+
* tree dirty and the next task unable to start. Each check is pointed out once on its own, so a
|
|
773
|
+
* content problem found after the paths were settled still gets its one mention.
|
|
774
|
+
*/
|
|
733
775
|
for (const o of outcomes) {
|
|
734
|
-
if (o.check.expect
|
|
776
|
+
if (!RUNNER_CHECK_KINDS.has(o.check.expect) || o.passed)
|
|
735
777
|
continue;
|
|
736
|
-
if (
|
|
778
|
+
if (pointedOut.has(o.check.expect)) {
|
|
737
779
|
o.passed = true;
|
|
738
|
-
o.detail = `still there after being pointed out once; committed and
|
|
780
|
+
o.detail = `still there after being pointed out once; committed and kept on the task — ${o.detail}`;
|
|
739
781
|
}
|
|
740
782
|
else {
|
|
741
|
-
|
|
783
|
+
pointedOut.add(o.check.expect);
|
|
742
784
|
}
|
|
743
785
|
}
|
|
744
786
|
await setTask((t) => {
|
|
@@ -769,8 +811,17 @@ export async function runTask(transport, session, task, deps) {
|
|
|
769
811
|
sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
|
|
770
812
|
return 'accept';
|
|
771
813
|
}
|
|
814
|
+
stats.doneRejected += 1;
|
|
772
815
|
if (deps.signal?.aborted)
|
|
773
816
|
return 'give-up';
|
|
817
|
+
// The same failures as last time, and not a file changed since they were reported.
|
|
818
|
+
const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
|
|
819
|
+
if (stuck) {
|
|
820
|
+
noProgressReason = stuck;
|
|
821
|
+
stats.stoppedFor = 'no-progress';
|
|
822
|
+
sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
|
|
823
|
+
return 'give-up';
|
|
824
|
+
}
|
|
774
825
|
if (checkRounds > maxCheckRounds) {
|
|
775
826
|
/*
|
|
776
827
|
* Only checks from earlier reviews are failing. A reviewer's check is outranked by the
|
|
@@ -941,6 +992,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
941
992
|
* task; the ones suspended by a dispute or by spent rounds come back if the finding was
|
|
942
993
|
* raised again, and go if it was not.
|
|
943
994
|
*/
|
|
995
|
+
if (outcome.verdict === 'fail')
|
|
996
|
+
stats.reviewRejections += 1;
|
|
944
997
|
if (outcome.verdict === 'pass' || outcome.verdict === 'fail') {
|
|
945
998
|
for (const d of outcome.derivedChecks ?? []) {
|
|
946
999
|
reviewChecks = [...reviewChecks, { check: d.check, findingId: d.findingId, what: d.what, where: d.where, round: reviewRounds, attempt, state: 'active' }];
|
|
@@ -1100,6 +1153,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1100
1153
|
});
|
|
1101
1154
|
if (!parsed.ok) {
|
|
1102
1155
|
formatRetries += 1;
|
|
1156
|
+
stats.formatErrors += 1;
|
|
1103
1157
|
sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
|
|
1104
1158
|
if (formatRetries > cfg.limits.maxFormatRetries) {
|
|
1105
1159
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
@@ -1187,6 +1241,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1187
1241
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1188
1242
|
}
|
|
1189
1243
|
if (verdict === 'give-up') {
|
|
1244
|
+
if (noProgressReason)
|
|
1245
|
+
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1190
1246
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1191
1247
|
}
|
|
1192
1248
|
if (verdict === 'accept') {
|
|
@@ -1246,6 +1302,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1246
1302
|
const ran = seen.get(key)?.sameInARow ?? 0;
|
|
1247
1303
|
if (ran >= maxCommandRepeats) {
|
|
1248
1304
|
repeatsRefused += 1;
|
|
1305
|
+
stats.repeatsRefused += 1;
|
|
1249
1306
|
sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
|
|
1250
1307
|
results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
|
|
1251
1308
|
continue;
|
|
@@ -1312,11 +1369,42 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1312
1369
|
}
|
|
1313
1370
|
await pacer.settle();
|
|
1314
1371
|
}
|
|
1372
|
+
for (const r of results) {
|
|
1373
|
+
if (r.outcome !== 'refused' && r.outcome !== 'aborted')
|
|
1374
|
+
continue;
|
|
1375
|
+
if (r.outcome === 'refused')
|
|
1376
|
+
stats.stepsRefused += 1;
|
|
1377
|
+
else
|
|
1378
|
+
stats.operatorStops += 1;
|
|
1379
|
+
const why = (r.stderr.split('\n')[0] ?? '').replace(/^\[policy\] step not executed: /, '').trim();
|
|
1380
|
+
notRun.push({ command: r.command.slice(0, 200), why: why.slice(0, 300) || r.outcome });
|
|
1381
|
+
}
|
|
1315
1382
|
// Ended before the report is written and sent: there is nobody to send it to who could
|
|
1316
1383
|
// do anything with it, and the reason on the task says what to install or set instead.
|
|
1317
1384
|
if (environmentProblem) {
|
|
1318
1385
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1319
1386
|
}
|
|
1387
|
+
/*
|
|
1388
|
+
* The task's scope, as a rule rather than a sentence (see `vcs/scope.ts`): whatever this round
|
|
1389
|
+
* changed outside it is put back now, before the chat reads the results and builds on them,
|
|
1390
|
+
* and the chat is told in the same message.
|
|
1391
|
+
*/
|
|
1392
|
+
if (scopeEnforced) {
|
|
1393
|
+
const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
|
|
1394
|
+
outside: [],
|
|
1395
|
+
reverted: [],
|
|
1396
|
+
failed: [{ path: '(the working tree)', why: e.message }],
|
|
1397
|
+
}));
|
|
1398
|
+
if (check.outside.length > 0 || check.failed.length > 0) {
|
|
1399
|
+
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
|
|
1400
|
+
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
1401
|
+
runnerNotes.push(scopeMessage(check, scope));
|
|
1402
|
+
stats.scopeReverts += check.reverted.length;
|
|
1403
|
+
await setTask((t) => {
|
|
1404
|
+
t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1320
1408
|
// --- report back ---------------------------------------------------------------
|
|
1321
1409
|
const report = await writeReport(results, {
|
|
1322
1410
|
runId,
|
|
@@ -1336,6 +1424,14 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1336
1424
|
}
|
|
1337
1425
|
if (aborted)
|
|
1338
1426
|
return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
|
|
1427
|
+
// A loop in which every round looks new: see `progress.ts`. After the report is written, so
|
|
1428
|
+
// the evidence the diagnosis points at is on disk.
|
|
1429
|
+
const stuck = progress.afterRound(await treeNow(), results);
|
|
1430
|
+
if (stuck) {
|
|
1431
|
+
stats.stoppedFor = 'no-progress';
|
|
1432
|
+
sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
|
|
1433
|
+
return await finish('blocked', stuck, reply.summary, lastMarkdown);
|
|
1434
|
+
}
|
|
1339
1435
|
/*
|
|
1340
1436
|
* Nothing this iteration did anything.
|
|
1341
1437
|
*
|
|
@@ -1436,6 +1532,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1436
1532
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1437
1533
|
}
|
|
1438
1534
|
if (verdict === 'give-up') {
|
|
1535
|
+
if (noProgressReason)
|
|
1536
|
+
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1439
1537
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1440
1538
|
}
|
|
1441
1539
|
if (verdict === 'accept') {
|
|
@@ -1562,8 +1660,10 @@ export async function runSession(sessionId, deps) {
|
|
|
1562
1660
|
s.chat = undefined;
|
|
1563
1661
|
s.contractSent = false;
|
|
1564
1662
|
const again = s.tasks.find((x) => x.id === task.id);
|
|
1565
|
-
if (again)
|
|
1663
|
+
if (again) {
|
|
1566
1664
|
again.autoRetries = (again.autoRetries ?? 0) + 1;
|
|
1665
|
+
again.freshRetry = true;
|
|
1666
|
+
}
|
|
1567
1667
|
});
|
|
1568
1668
|
await chat.newChat();
|
|
1569
1669
|
/*
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -316,6 +316,7 @@ const FIELD_ROWS_EN = [
|
|
|
316
316
|
],
|
|
317
317
|
['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
|
|
318
318
|
['readOnly', 'task', 'no', '`true` for a task that must not change files — an audit, a smoke test, a report. The runner fails it if the tree changed, and still commits the change on its branch so nothing is lost.'],
|
|
319
|
+
['scope', 'task', 'no', 'The paths the task may change: files, folders (`tests/e2e/`) or `*`/`**` patterns. With version control on, the runner puts back any change outside them. Give it when the task is about one suite or module.'],
|
|
319
320
|
['mirror', 'session', 'no, but ask', 'Whether project files are copied and attached to the first message as context, and which: root, directories in and out, gitignore, env files. Off when left out.'],
|
|
320
321
|
['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
|
|
321
322
|
['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
|
|
@@ -380,6 +381,7 @@ const FIELD_ROWS_BG = [
|
|
|
380
381
|
],
|
|
381
382
|
['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
|
|
382
383
|
['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
|
|
384
|
+
['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
|
|
383
385
|
['mirror', 'сесия', 'не, но питай', 'Дали файлове от проекта се копират и прикачат към първото съобщение като контекст, и кои: корен, директории вътре и вън, gitignore, env файлове. Изключено, когато липсва.'],
|
|
384
386
|
['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
|
|
385
387
|
['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -207,6 +207,13 @@ const TaskInput = z.object({
|
|
|
207
207
|
* branch so nothing is lost and the next task starts clean.
|
|
208
208
|
*/
|
|
209
209
|
readOnly: z.boolean().default(false),
|
|
210
|
+
/**
|
|
211
|
+
* The paths this task may change, repository-relative: a file, a folder (`tests/e2e/`), or a
|
|
212
|
+
* pattern with `*` and `**`. Absent or empty means anywhere in the project. Enforced by the runner
|
|
213
|
+
* after every round of steps when version control is on — a change outside is put back and the
|
|
214
|
+
* chat told — which a sentence in the prompt cannot be. See `vcs/scope.ts`.
|
|
215
|
+
*/
|
|
216
|
+
scope: z.array(z.string().trim().min(1, 'A scope entry needs a path or a pattern.')).default([]),
|
|
210
217
|
/**
|
|
211
218
|
* What earlier attempts of this task were asked, written by the plan export for a task that has
|
|
212
219
|
* been run more than once (`earlierPlans` in `session/exports.ts`). History, not instruction:
|
|
@@ -278,6 +278,22 @@ export const SYSTEM_GUIDE = [
|
|
|
278
278
|
doesEn: 'Puts the whole brief on the clipboard; paste it into a fresh chat conversation.',
|
|
279
279
|
doesBg: 'Слага цялото задание в клипборда; поставете го в нов разговор с чат модел.',
|
|
280
280
|
},
|
|
281
|
+
{
|
|
282
|
+
key: 'plan.saveBrief',
|
|
283
|
+
kind: 'button',
|
|
284
|
+
en: 'Save the brief as a file',
|
|
285
|
+
bg: 'Запази заданието като файл',
|
|
286
|
+
doesEn: 'Saves the brief as a .txt to attach to a fresh chat; the way when it is over about 116 000 characters.',
|
|
287
|
+
doesBg: 'Запазва заданието като .txt за прикачване в нов разговор; начинът, когато е над около 116 000 знака.',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
key: 'plan.copyBriefNote',
|
|
291
|
+
kind: 'button',
|
|
292
|
+
en: 'Copy the message for the file',
|
|
293
|
+
bg: 'Копирай съобщението към файла',
|
|
294
|
+
doesEn: 'Only when the brief is too long to paste: the one line to send with the attached file.',
|
|
295
|
+
doesBg: 'Само когато заданието е твърде дълго за поставяне: едното изречение, което се праща с прикачения файл.',
|
|
296
|
+
},
|
|
281
297
|
{
|
|
282
298
|
key: 'plan.softwarePart',
|
|
283
299
|
kind: 'disclosure',
|
|
@@ -2571,6 +2587,13 @@ export const SYSTEM_GUIDE = [
|
|
|
2571
2587
|
},
|
|
2572
2588
|
],
|
|
2573
2589
|
},
|
|
2590
|
+
{
|
|
2591
|
+
headingKey: 'metrics.title',
|
|
2592
|
+
en: 'How the bot is doing',
|
|
2593
|
+
bg: 'Как се справя ботът',
|
|
2594
|
+
// A table of figures at the foot of the register, read-only; the heading says what it is.
|
|
2595
|
+
controls: [],
|
|
2596
|
+
},
|
|
2574
2597
|
],
|
|
2575
2598
|
},
|
|
2576
2599
|
{
|
|
@@ -3075,29 +3098,45 @@ export const SYSTEM_GUIDE = [
|
|
|
3075
3098
|
en: 'Text size',
|
|
3076
3099
|
bg: 'Размер на текста',
|
|
3077
3100
|
controls: [
|
|
3101
|
+
{
|
|
3102
|
+
key: 'ap.sizeSmall',
|
|
3103
|
+
kind: 'button',
|
|
3104
|
+
en: 'Small',
|
|
3105
|
+
bg: 'Малък',
|
|
3106
|
+
doesEn: 'One step below normal.',
|
|
3107
|
+
doesBg: 'Една степен под нормалния.',
|
|
3108
|
+
},
|
|
3078
3109
|
{
|
|
3079
3110
|
key: 'ap.sizeNormal',
|
|
3080
3111
|
kind: 'button',
|
|
3081
3112
|
en: 'Normal',
|
|
3082
3113
|
bg: 'Нормален',
|
|
3083
|
-
doesEn: 'Normal
|
|
3084
|
-
doesBg: 'Нормален
|
|
3114
|
+
doesEn: 'Normal size.',
|
|
3115
|
+
doesBg: 'Нормален размер.',
|
|
3085
3116
|
},
|
|
3086
3117
|
{
|
|
3087
3118
|
key: 'ap.sizeLarge',
|
|
3088
3119
|
kind: 'button',
|
|
3089
3120
|
en: 'Large',
|
|
3090
3121
|
bg: 'Голям',
|
|
3091
|
-
doesEn: '
|
|
3092
|
-
doesBg: '
|
|
3122
|
+
doesEn: 'One step above normal.',
|
|
3123
|
+
doesBg: 'Една степен над нормалния.',
|
|
3093
3124
|
},
|
|
3094
3125
|
{
|
|
3095
3126
|
key: 'ap.sizeHuge',
|
|
3096
3127
|
kind: 'button',
|
|
3128
|
+
en: 'Larger',
|
|
3129
|
+
bg: 'По-голям',
|
|
3130
|
+
doesEn: 'Two steps above normal.',
|
|
3131
|
+
doesBg: 'Две степени над нормалния.',
|
|
3132
|
+
},
|
|
3133
|
+
{
|
|
3134
|
+
key: 'ap.sizeGiant',
|
|
3135
|
+
kind: 'button',
|
|
3097
3136
|
en: 'Largest',
|
|
3098
3137
|
bg: 'Най-голям',
|
|
3099
|
-
doesEn: '
|
|
3100
|
-
doesBg: '
|
|
3138
|
+
doesEn: 'Three steps above normal.',
|
|
3139
|
+
doesBg: 'Три степени над нормалния.',
|
|
3101
3140
|
},
|
|
3102
3141
|
],
|
|
3103
3142
|
},
|
|
@@ -11,7 +11,7 @@ const TASK_HEADER = '## Task';
|
|
|
11
11
|
* and a note that is empty simply leaves nothing behind.
|
|
12
12
|
*/
|
|
13
13
|
function runnerBlock(input) {
|
|
14
|
-
const notes = [input.workDirNote, input.readOnlyNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
14
|
+
const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
15
15
|
return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
@@ -168,6 +168,8 @@ export function buildPlanExport(scope) {
|
|
|
168
168
|
task.review = false;
|
|
169
169
|
if (t.readOnly)
|
|
170
170
|
task.readOnly = true;
|
|
171
|
+
if (t.scope && t.scope.length > 0)
|
|
172
|
+
task.scope = [...t.scope];
|
|
171
173
|
const earlier = earlierPlans(t, level2);
|
|
172
174
|
if (earlier.length > 0)
|
|
173
175
|
task.earlierAttempts = earlier;
|
|
@@ -394,6 +396,9 @@ async function domainTask(session, task, runsDir) {
|
|
|
394
396
|
title: task.title,
|
|
395
397
|
attempt: task.attempt ?? 1,
|
|
396
398
|
readOnly: task.readOnly ?? false,
|
|
399
|
+
scope: task.scope ?? [],
|
|
400
|
+
scopeReverted: task.scopeReverted ?? [],
|
|
401
|
+
handoff: task.handoff ?? null,
|
|
397
402
|
prompt: p.prompt,
|
|
398
403
|
expected: p.expected,
|
|
399
404
|
level2: task.level2,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function composeHandoff(task, notRun = []) {
|
|
2
|
+
const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
|
|
3
|
+
const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
|
|
4
|
+
const vcs = task.vcs ?? {};
|
|
5
|
+
const knownIssues = [
|
|
6
|
+
...failedChecks.map((c) => `check failed: ${c.name} — ${c.detail}`),
|
|
7
|
+
...openFindings.map((f) => `review finding${f.id ? ` ${f.id}` : ''}: ${f.what}${f.where ? ` (${f.where})` : ''}`),
|
|
8
|
+
...(task.checkResults ?? [])
|
|
9
|
+
.filter((c) => c.passed && /still there after being pointed out once/.test(c.detail))
|
|
10
|
+
.map((c) => `committed with a finding left in place: ${c.name}`),
|
|
11
|
+
...(vcs.suspicious ?? []).map((s) => `committed although it looks like ${s.reason}: ${s.path}`),
|
|
12
|
+
...(vcs.foreignCommits ?? []).map((c) => `a commit on the branch the runner did not make: ${c}`),
|
|
13
|
+
...(task.scopeReverted?.length ? [`put back because outside the task's scope: ${task.scopeReverted.join(', ')}`] : []),
|
|
14
|
+
...(task.deviations ?? []).map((d) => `did not follow "${d.instruction}" as written: ${d.did}`),
|
|
15
|
+
];
|
|
16
|
+
const manual = [];
|
|
17
|
+
if (vcs.problem)
|
|
18
|
+
manual.push(`version control: ${vcs.problem}`);
|
|
19
|
+
if (vcs.commit && vcs.branch)
|
|
20
|
+
manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
|
|
21
|
+
if (task.status === 'blocked')
|
|
22
|
+
manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
|
|
23
|
+
if (task.status === 'limit-reached' || task.status === 'aborted')
|
|
24
|
+
manual.push('continue it in the same chat from the register, or run it again');
|
|
25
|
+
if (task.status === 'failed')
|
|
26
|
+
manual.push('read why it failed, fix the prompt or the checks, and queue it again');
|
|
27
|
+
if (openFindings.length > 0)
|
|
28
|
+
manual.push(`${openFindings.length} review finding(s) are open`);
|
|
29
|
+
if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
|
|
30
|
+
manual.push('look at the committed files and commits listed above');
|
|
31
|
+
return {
|
|
32
|
+
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
|
|
33
|
+
changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
|
|
34
|
+
validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
35
|
+
...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
|
|
36
|
+
knownIssues,
|
|
37
|
+
evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
|
|
38
|
+
vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
|
|
39
|
+
manual,
|
|
40
|
+
notExecuted: notRun.slice(0, 30).map((n) => `${n.command} — ${n.why}`),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const ENDED = ['done', 'failed', 'blocked', 'aborted', 'limit-reached'];
|
|
2
|
+
function attemptsOf(task) {
|
|
3
|
+
const all = [...(task.attempts ?? [])];
|
|
4
|
+
if (ENDED.includes(task.status))
|
|
5
|
+
all.push(task);
|
|
6
|
+
return all.filter((a) => ENDED.includes(a.status));
|
|
7
|
+
}
|
|
8
|
+
function modelOf(session) {
|
|
9
|
+
return (session.modelInUse ?? '').trim() || (session.model ?? '').trim() || '(the chat default)';
|
|
10
|
+
}
|
|
11
|
+
function row(group, tasks) {
|
|
12
|
+
const r = {
|
|
13
|
+
group,
|
|
14
|
+
tasks: 0,
|
|
15
|
+
attempts: 0,
|
|
16
|
+
firstPass: { n: 0, of: 0 },
|
|
17
|
+
doneInTheEnd: { n: 0, of: 0 },
|
|
18
|
+
ended: {},
|
|
19
|
+
freshRetries: 0,
|
|
20
|
+
manualInterventions: 0,
|
|
21
|
+
falseCompletion: { n: 0, of: 0 },
|
|
22
|
+
reviewRejection: { n: 0, of: 0 },
|
|
23
|
+
repeatedCommands: { n: 0, of: 0 },
|
|
24
|
+
scopeViolation: { n: 0, of: 0 },
|
|
25
|
+
noProgress: { n: 0, of: 0 },
|
|
26
|
+
unrelatedDiff: { n: 0, of: 0 },
|
|
27
|
+
resumed: { n: 0, of: 0 },
|
|
28
|
+
withStats: 0,
|
|
29
|
+
};
|
|
30
|
+
for (const task of tasks) {
|
|
31
|
+
const list = attemptsOf(task);
|
|
32
|
+
if (list.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
r.tasks += 1;
|
|
35
|
+
r.attempts += list.length;
|
|
36
|
+
r.firstPass.of += 1;
|
|
37
|
+
if (list[0].status === 'done')
|
|
38
|
+
r.firstPass.n += 1;
|
|
39
|
+
const last = list[list.length - 1];
|
|
40
|
+
r.doneInTheEnd.of += 1;
|
|
41
|
+
if (last.status === 'done')
|
|
42
|
+
r.doneInTheEnd.n += 1;
|
|
43
|
+
r.ended[last.status] = (r.ended[last.status] ?? 0) + 1;
|
|
44
|
+
list.forEach((a, i) => {
|
|
45
|
+
if (a.freshRetry)
|
|
46
|
+
r.freshRetries += 1;
|
|
47
|
+
else if (i > 0)
|
|
48
|
+
r.manualInterventions += 1;
|
|
49
|
+
r.unrelatedDiff.of += 1;
|
|
50
|
+
if ((a.vcs?.suspicious?.length ?? 0) > 0 || (a.vcs?.foreignCommits?.length ?? 0) > 0)
|
|
51
|
+
r.unrelatedDiff.n += 1;
|
|
52
|
+
if ((a.scope?.length ?? 0) > 0) {
|
|
53
|
+
r.scopeViolation.of += 1;
|
|
54
|
+
if ((a.scopeReverted?.length ?? 0) > 0)
|
|
55
|
+
r.scopeViolation.n += 1;
|
|
56
|
+
}
|
|
57
|
+
if (a.continuing) {
|
|
58
|
+
r.resumed.of += 1;
|
|
59
|
+
if (a.status === 'done')
|
|
60
|
+
r.resumed.n += 1;
|
|
61
|
+
}
|
|
62
|
+
if (a.review && a.review.verdict !== 'skipped') {
|
|
63
|
+
r.reviewRejection.of += 1;
|
|
64
|
+
if ((a.stats?.reviewRejections ?? 0) > 0 || a.review.verdict === 'fail' || (a.review.rounds ?? 0) > 1)
|
|
65
|
+
r.reviewRejection.n += 1;
|
|
66
|
+
}
|
|
67
|
+
if (!a.stats)
|
|
68
|
+
return;
|
|
69
|
+
r.withStats += 1;
|
|
70
|
+
r.manualInterventions += a.stats.operatorStops;
|
|
71
|
+
r.falseCompletion.of += 1;
|
|
72
|
+
if (a.stats.doneRejected > 0)
|
|
73
|
+
r.falseCompletion.n += 1;
|
|
74
|
+
r.repeatedCommands.of += 1;
|
|
75
|
+
if (a.stats.repeatsRefused > 0)
|
|
76
|
+
r.repeatedCommands.n += 1;
|
|
77
|
+
r.noProgress.of += 1;
|
|
78
|
+
if (a.stats.stoppedFor === 'no-progress')
|
|
79
|
+
r.noProgress.n += 1;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return r;
|
|
83
|
+
}
|
|
84
|
+
/** All tasks, then one row per model the sessions ran on, busiest first. */
|
|
85
|
+
export function computeMetrics(sessions, now = new Date()) {
|
|
86
|
+
const all = sessions.flatMap((s) => s.tasks);
|
|
87
|
+
const byModel = new Map();
|
|
88
|
+
for (const s of sessions)
|
|
89
|
+
byModel.set(modelOf(s), [...(byModel.get(modelOf(s)) ?? []), ...s.tasks]);
|
|
90
|
+
const models = [...byModel.entries()]
|
|
91
|
+
.map(([group, tasks]) => row(group, tasks))
|
|
92
|
+
.filter((r) => r.tasks > 0)
|
|
93
|
+
.sort((a, b) => b.tasks - a.tasks);
|
|
94
|
+
return { rows: [row('all', all), ...(models.length > 1 ? models : [])], computedAt: now.toISOString() };
|
|
95
|
+
}
|
|
@@ -251,6 +251,8 @@ export class SessionStore {
|
|
|
251
251
|
...(input.reviewEnabled === false ? { reviewEnabled: false } : {}),
|
|
252
252
|
// Only `true` is worth storing: a task may change files unless a plan said otherwise.
|
|
253
253
|
...(input.readOnly === true ? { readOnly: true } : {}),
|
|
254
|
+
// Stored only when it limits something: absent means anywhere, as before scopes existed.
|
|
255
|
+
...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
|
|
254
256
|
};
|
|
255
257
|
await this.updateSession(sessionId, (s) => {
|
|
256
258
|
s.tasks.push(task);
|
|
@@ -352,6 +354,12 @@ export class SessionStore {
|
|
|
352
354
|
checks: t.checks,
|
|
353
355
|
vcsPlan: t.vcsPlan,
|
|
354
356
|
review: t.review,
|
|
357
|
+
stats: t.stats,
|
|
358
|
+
continuing: t.continuing,
|
|
359
|
+
buildsOn: t.buildsOn,
|
|
360
|
+
freshRetry: t.freshRetry,
|
|
361
|
+
scope: t.scope,
|
|
362
|
+
scopeReverted: t.scopeReverted,
|
|
355
363
|
vcs: t.vcs,
|
|
356
364
|
},
|
|
357
365
|
];
|
|
@@ -390,6 +398,12 @@ export class SessionStore {
|
|
|
390
398
|
// the one thing a new attempt should inherit.
|
|
391
399
|
t.deviations = undefined;
|
|
392
400
|
t.disputes = undefined;
|
|
401
|
+
// Counts, the ending and what was put back belong to the attempt above too. A fresh-chat retry
|
|
402
|
+
// marks itself again after this, in the runner.
|
|
403
|
+
t.stats = undefined;
|
|
404
|
+
t.handoff = undefined;
|
|
405
|
+
t.scopeReverted = undefined;
|
|
406
|
+
t.freshRetry = undefined;
|
|
393
407
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
394
408
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
395
409
|
t.vcs = undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the runner, the reviewer and the API need from a chat, and where that chat comes from.
|
|
3
|
+
*
|
|
4
|
+
* Everything that talks to Copilot goes through `CopilotTransport`, which drives a real Edge window
|
|
5
|
+
* signed in to Microsoft 365. That is the product, and it is also why nothing above the transport
|
|
6
|
+
* could be tested end to end: every run opened a browser, signed in and spent the operator's hourly
|
|
7
|
+
* message allowance. The loop itself — send, read the reply, run the steps, report back, stop at the
|
|
8
|
+
* limit, carry on after an interruption, commit on the right branch — never touches the page, only
|
|
9
|
+
* these few methods. So the rest of the code asks for a `ChatTransport` and gets one from
|
|
10
|
+
* `createTransport`, and the automated checks put a scripted chat in its place (test/support/fakeChat.ts)
|
|
11
|
+
* that answers from a script and never opens a window.
|
|
12
|
+
*
|
|
13
|
+
* The type is a `Pick` of the real class rather than an interface written out by hand, so the two
|
|
14
|
+
* cannot drift apart: a method that changes its signature in `CopilotTransport` changes it here, and
|
|
15
|
+
* the scripted chat stops compiling instead of passing checks against a shape the product no longer has.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately, nothing outside the code can swap the chat: no environment variable, no setting, no
|
|
18
|
+
* command-line flag. A scripted chat hands the runner steps to execute, and a switch that a file or
|
|
19
|
+
* an environment could flip would be a way to make the bot run commands nobody reviewed. Only code
|
|
20
|
+
* running in this process — a check script — can call `setTransportFactory`.
|
|
21
|
+
*/
|
|
22
|
+
import { CopilotTransport } from './copilotTransport.js';
|
|
23
|
+
const realChat = (opts) => new CopilotTransport(opts);
|
|
24
|
+
let factory = realChat;
|
|
25
|
+
/** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
|
|
26
|
+
export function createTransport(opts) {
|
|
27
|
+
return factory(opts);
|
|
28
|
+
}
|
|
29
|
+
/** For checks only: every chat opened from now on comes from `f`. `null` puts the real one back. */
|
|
30
|
+
export function setTransportFactory(f) {
|
|
31
|
+
factory = f ?? realChat;
|
|
32
|
+
}
|