@tea-agent/loop-agent 0.33.6-beta.0 → 0.33.6
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/CHANGELOG.md +29 -4
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +625 -71
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +324 -146
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
- package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +2 -27
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
- package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +12 -20
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/backend-test-dag.json +10 -10
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* denied. Interview/Official loaders are unchanged.
|
|
9
9
|
*
|
|
10
10
|
* Note: this adapter is NOT the SDK's own ResourceLoader (that is
|
|
11
|
-
* DefaultResourceLoader, created inside createAgentSessionServices with
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* DefaultResourceLoader, created inside createAgentSessionServices with the
|
|
12
|
+
* standard OPEN surface since ADR 0012 — no noContextFiles/noSkills/
|
|
13
|
+
* noExtensions switches). This adapter is the operator-chat declarative
|
|
14
|
+
* surface used for capabilities reporting and red-team checks.
|
|
14
15
|
* The three-gate authorization (authorizeOperatorChatTool) is the actual
|
|
15
16
|
* enforcement; this adapter describes what that gate admits.
|
|
16
17
|
*/
|
|
@@ -29,7 +30,7 @@ export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
|
|
|
29
30
|
export function createOperatorChatResourceLoader() {
|
|
30
31
|
let active = new Set();
|
|
31
32
|
return {
|
|
32
|
-
mode: "operator-chat-
|
|
33
|
+
mode: "operator-chat-open",
|
|
33
34
|
allowedToolIds: OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
34
35
|
deniedToolIds: OPERATOR_CHAT_DENIED_TOOLS,
|
|
35
36
|
deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
|
|
@@ -6,24 +6,22 @@
|
|
|
6
6
|
* GET /sessions/:id — fetch session metadata + messages
|
|
7
7
|
* POST /sessions/:id/prompt — SSE stream: send a prompt, stream events
|
|
8
8
|
* GET /sessions/:id/events — SSE stream: (re)connect to live events
|
|
9
|
-
* GET /capabilities — Chat tool surface
|
|
9
|
+
* GET /capabilities — Chat tool surface
|
|
10
10
|
* DELETE /sessions/:id — dispose a Chat session
|
|
11
11
|
*
|
|
12
12
|
* Mutation routes go through the Console mutation gate (boot token + Origin).
|
|
13
13
|
* Read routes (GET capabilities / sessions) are open to loopback.
|
|
14
14
|
*/
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
16
15
|
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
|
|
17
|
-
import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS, OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
|
|
18
16
|
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
19
17
|
import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
|
|
20
|
-
import { THINKING_LEVELS,
|
|
18
|
+
import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
|
|
21
19
|
import { ComposerDraftStore } from "./composer-draft-store.js";
|
|
22
20
|
import { walkRepoFiles } from "./repo-walk.js";
|
|
23
21
|
import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
|
|
24
22
|
import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
|
|
25
23
|
import { projectRuntimeContext } from "./runtime-context.js";
|
|
26
|
-
import { patchModelsConfig, readModelsConfig, readPackageInventory,
|
|
24
|
+
import { patchModelsConfig, readModelsConfig, readPackageInventory, } from "./pi-console-config.js";
|
|
27
25
|
import { ChatInterviewAdapter, projectChatInterviewState, } from "./interview-adapter.js";
|
|
28
26
|
import { classifyChatCompactionFailure } from "./compaction-errors.js";
|
|
29
27
|
import { evaluateMutationGate, isMutationMethod } from "../security.js";
|
|
@@ -33,9 +31,24 @@ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
|
|
|
33
31
|
import { projectTaskContext } from "./context-panel.js";
|
|
34
32
|
import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
|
|
35
33
|
import { sendJson } from "../routes.js";
|
|
36
|
-
import { openSseResponse,
|
|
34
|
+
import { openSseResponse, writeSseEvent } from "../operation-sse.js";
|
|
35
|
+
/** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
|
|
36
|
+
* the same wire shape; its prefix intentionally fails the current process
|
|
37
|
+
* epoch comparison and triggers safe snapshot recovery (M5.1). */
|
|
38
|
+
function parseChatEventCursor(header) {
|
|
39
|
+
if (!header?.trim())
|
|
40
|
+
return { epoch: null, seq: 0 };
|
|
41
|
+
const raw = header.trim();
|
|
42
|
+
const colon = raw.lastIndexOf(":");
|
|
43
|
+
const seqPart = colon >= 0 ? raw.slice(colon + 1) : raw;
|
|
44
|
+
const seq = Number.parseInt(seqPart, 10);
|
|
45
|
+
return {
|
|
46
|
+
epoch: colon > 0 ? raw.slice(0, colon) : null,
|
|
47
|
+
seq: Number.isFinite(seq) && seq >= 0 ? seq : 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
37
50
|
function writeChatSse(res, event) {
|
|
38
|
-
// Persisted Chat events carry their own eventId (
|
|
51
|
+
// Persisted Chat events carry their own eventId (`<epoch>:<seq>`); write
|
|
39
52
|
// it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
|
|
40
53
|
if ("seq" in event && event.eventId) {
|
|
41
54
|
res.write(`id: ${event.eventId}\n`);
|
|
@@ -93,21 +106,32 @@ function turnEventToStorePartial(event) {
|
|
|
93
106
|
return undefined;
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
|
-
/** Guarantee
|
|
97
|
-
* emitting
|
|
98
|
-
* error). Without
|
|
99
|
-
*
|
|
100
|
-
*
|
|
109
|
+
/** Guarantee durable terminal events for a turn that ended without the SDK
|
|
110
|
+
* emitting them (client abort, pre-prompt persistence failure, runtime
|
|
111
|
+
* error). Without `agent_end`, reconnect replay stops mid-turn; without
|
|
112
|
+
* `agent_settled`, the single-stream UI keeps idle-grace streaming forever
|
|
113
|
+
* (M4.4). Idempotent for normal settled turns that already have both. */
|
|
101
114
|
function ensureAgentEndEvent(deps, sessionId, turnId) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!already) {
|
|
115
|
+
const snap = deps.events.snapshot(sessionId);
|
|
116
|
+
const hasAgentEnd = snap.some((event) => event.turnId === turnId && event.kind === "agent_end");
|
|
117
|
+
if (!hasAgentEnd) {
|
|
106
118
|
deps.events.append(sessionId, turnId, {
|
|
107
119
|
kind: "agent_end",
|
|
108
120
|
data: { willRetry: false },
|
|
109
121
|
});
|
|
110
122
|
}
|
|
123
|
+
// Error/abort paths historically only forced agent_end. After the M4 single
|
|
124
|
+
// stream keeps UI streaming through agent_end for idle grace, a matching
|
|
125
|
+
// agent_settled is required so state reconcile and the applier both finish.
|
|
126
|
+
const hasSettled = deps.events
|
|
127
|
+
.snapshot(sessionId)
|
|
128
|
+
.some((event) => event.turnId === turnId && event.kind === "agent_settled");
|
|
129
|
+
if (!hasSettled) {
|
|
130
|
+
deps.events.append(sessionId, turnId, {
|
|
131
|
+
kind: "agent_settled",
|
|
132
|
+
data: {},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
111
135
|
}
|
|
112
136
|
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
113
137
|
const chunks = [];
|
|
@@ -198,9 +222,23 @@ function gateMutation(req, deps) {
|
|
|
198
222
|
}
|
|
199
223
|
return { ok: true };
|
|
200
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* RF-02: map a RuntimeSnapshotUnavailableError (critical snapshot projection
|
|
227
|
+
* failure — model enumeration / system prompt) to 503 PI_RUNTIME_UNAVAILABLE.
|
|
228
|
+
* Returns true when the response was sent; other errors keep their existing
|
|
229
|
+
* 409/500 branches so the mutation/reload semantics never regress.
|
|
230
|
+
*/
|
|
231
|
+
function sendRuntimeUnavailable(res, error) {
|
|
232
|
+
if (!(error instanceof RuntimeSnapshotUnavailableError))
|
|
233
|
+
return false;
|
|
234
|
+
sendJson(res, 503, {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: { code: error.code, message: error.message },
|
|
237
|
+
});
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
201
240
|
export async function handleChatCapabilities(_req, res, deps) {
|
|
202
241
|
const toolSchemas = buildModelCallableToolSchemas();
|
|
203
|
-
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
204
242
|
sendJson(res, 200, {
|
|
205
243
|
ok: true,
|
|
206
244
|
schemaVersion: 1,
|
|
@@ -208,18 +246,6 @@ export async function handleChatCapabilities(_req, res, deps) {
|
|
|
208
246
|
deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
|
|
209
247
|
deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
|
|
210
248
|
tools: toolSchemas,
|
|
211
|
-
instructionSkills: {
|
|
212
|
-
allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS,
|
|
213
|
-
loaded: skills.loaded.map((s) => ({
|
|
214
|
-
name: s.name,
|
|
215
|
-
description: s.description,
|
|
216
|
-
charCount: s.charCount,
|
|
217
|
-
})),
|
|
218
|
-
failures: skills.failures,
|
|
219
|
-
skipped: skills.skipped,
|
|
220
|
-
promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded)
|
|
221
|
-
.length,
|
|
222
|
-
},
|
|
223
249
|
// ADR 0011: full Pi tools including bash are default-active.
|
|
224
250
|
hasBash: true,
|
|
225
251
|
});
|
|
@@ -276,10 +302,7 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
276
302
|
return;
|
|
277
303
|
}
|
|
278
304
|
try {
|
|
279
|
-
|
|
280
|
-
const snapshot = snapshotInput === undefined
|
|
281
|
-
? undefined
|
|
282
|
-
: projectCompactSnapshot(snapshotInput);
|
|
305
|
+
const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
|
|
283
306
|
const handle = await deps.runtime.createSession({
|
|
284
307
|
...(provider && modelId ? { model: { provider, modelId } } : {}),
|
|
285
308
|
...(snapshot
|
|
@@ -491,6 +514,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
|
491
514
|
const lastEvent = snapshot.at(-1);
|
|
492
515
|
sendJson(res, 200, {
|
|
493
516
|
ok: true,
|
|
517
|
+
eventEpoch: deps.events.epoch(sessionId),
|
|
494
518
|
session: {
|
|
495
519
|
...record,
|
|
496
520
|
composerDraft,
|
|
@@ -1025,23 +1049,31 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
|
|
|
1025
1049
|
}
|
|
1026
1050
|
await deps.operationLinker.recoverSession(sessionId);
|
|
1027
1051
|
openSseResponse(res);
|
|
1028
|
-
// Resume from the client's last seen eventId (
|
|
1052
|
+
// Resume from the client's last seen eventId (`<epoch>:<seq>`). A previous
|
|
1053
|
+
// Console boot/legacy cursor has a different prefix and receives snapshot.
|
|
1029
1054
|
const lastEventIdHeader = req.headers["last-event-id"];
|
|
1030
|
-
const
|
|
1031
|
-
const replay = deps.events.listFrom(sessionId,
|
|
1032
|
-
let lastSeq =
|
|
1055
|
+
const cursor = parseChatEventCursor(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
|
|
1056
|
+
const replay = deps.events.listFrom(sessionId, cursor);
|
|
1057
|
+
let lastSeq = cursor.seq;
|
|
1033
1058
|
if ("error" in replay) {
|
|
1034
1059
|
// Cursor aged out of the bounded ring (T07 fence): tell the client to
|
|
1035
1060
|
// reconcile from a fresh snapshot rather than render a gap as if current.
|
|
1061
|
+
// The frame carries the ring tail (latestEventId) so the client can resume
|
|
1062
|
+
// the replayed snapshot with a fresh Last-Event-ID (recovery cursor).
|
|
1063
|
+
const retained = deps.events.snapshot(sessionId);
|
|
1036
1064
|
writeChatSse(res, {
|
|
1037
1065
|
kind: "reconcile",
|
|
1038
1066
|
sessionId,
|
|
1039
|
-
reason: "
|
|
1067
|
+
reason: replay.error === "CURSOR_EPOCH_MISMATCH"
|
|
1068
|
+
? "epoch_mismatch"
|
|
1069
|
+
: "cursor_expired",
|
|
1040
1070
|
minSeq: replay.minSeq,
|
|
1071
|
+
epoch: replay.epoch,
|
|
1041
1072
|
latestTurnId: deps.events.latestTurnId(sessionId),
|
|
1073
|
+
latestEventId: retained.at(-1)?.eventId ?? null,
|
|
1042
1074
|
});
|
|
1043
1075
|
// Fall back to full retained snapshot so the client can re-render.
|
|
1044
|
-
for (const ev of
|
|
1076
|
+
for (const ev of retained) {
|
|
1045
1077
|
writeChatSse(res, ev);
|
|
1046
1078
|
lastSeq = Math.max(lastSeq, ev.seq);
|
|
1047
1079
|
}
|
|
@@ -1052,6 +1084,15 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
|
|
|
1052
1084
|
lastSeq = Math.max(lastSeq, ev.seq);
|
|
1053
1085
|
}
|
|
1054
1086
|
}
|
|
1087
|
+
// Delimit retained replay from follow mode. The client uses this marker to
|
|
1088
|
+
// ensure its sole GET /events consumer is ready before POST /turns (M4.1).
|
|
1089
|
+
const retainedTail = deps.events.snapshot(sessionId).at(-1);
|
|
1090
|
+
writeChatSse(res, {
|
|
1091
|
+
kind: "stream_ready",
|
|
1092
|
+
sessionId,
|
|
1093
|
+
epoch: deps.events.epoch(sessionId),
|
|
1094
|
+
latestEventId: retainedTail?.eventId ?? null,
|
|
1095
|
+
});
|
|
1055
1096
|
let closed = false;
|
|
1056
1097
|
res.once("close", () => {
|
|
1057
1098
|
closed = true;
|
|
@@ -1211,47 +1252,6 @@ async function handlePiConfig(req, res, deps, resource) {
|
|
|
1211
1252
|
});
|
|
1212
1253
|
return;
|
|
1213
1254
|
}
|
|
1214
|
-
if (req.method === "GET") {
|
|
1215
|
-
const disabledNames = await readSkillPreferences(deps.appData);
|
|
1216
|
-
const loaded = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
1217
|
-
sendJson(res, 200, {
|
|
1218
|
-
ok: true,
|
|
1219
|
-
data: {
|
|
1220
|
-
allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((name) => {
|
|
1221
|
-
const skill = loaded.loaded.find((item) => item.name === name);
|
|
1222
|
-
return {
|
|
1223
|
-
name,
|
|
1224
|
-
description: skill?.description ?? "",
|
|
1225
|
-
contentHash: skill
|
|
1226
|
-
? createHash("sha256").update(skill.bodyText).digest("hex")
|
|
1227
|
-
: undefined,
|
|
1228
|
-
};
|
|
1229
|
-
}),
|
|
1230
|
-
conditional: OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS,
|
|
1231
|
-
denied: OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS,
|
|
1232
|
-
disabledNames,
|
|
1233
|
-
},
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
else {
|
|
1237
|
-
const gate = gateMutation(req, deps);
|
|
1238
|
-
if (!gate.ok) {
|
|
1239
|
-
sendJson(res, gate.status, {
|
|
1240
|
-
ok: false,
|
|
1241
|
-
error: { code: gate.code, message: gate.message },
|
|
1242
|
-
});
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
const body = await readJsonBody(req);
|
|
1246
|
-
const disabledNames = await writeSkillPreferences(deps.appData, Array.isArray(body.disabledNames)
|
|
1247
|
-
? body.disabledNames.filter((name) => typeof name === "string")
|
|
1248
|
-
: []);
|
|
1249
|
-
deps.runtime.setDisabledInstructionSkills(disabledNames);
|
|
1250
|
-
sendJson(res, 200, {
|
|
1251
|
-
ok: true,
|
|
1252
|
-
data: { disabledNames, appliesTo: "new-sessions" },
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
1255
|
}
|
|
1256
1256
|
catch (error) {
|
|
1257
1257
|
const failure = error;
|
|
@@ -1279,94 +1279,148 @@ async function handleChatFiles(res, deps, sessionId, query) {
|
|
|
1279
1279
|
.map((path) => ({ path }));
|
|
1280
1280
|
sendJson(res, 200, { ok: true, files });
|
|
1281
1281
|
}
|
|
1282
|
-
async function handleRuntimeContext(res, deps, sessionId) {
|
|
1283
|
-
const
|
|
1284
|
-
if (!
|
|
1285
|
-
sendJson(res,
|
|
1282
|
+
export async function handleRuntimeContext(res, deps, sessionId) {
|
|
1283
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1284
|
+
if (!ensured.ok) {
|
|
1285
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
// AC-03: system prompt comes from the REAL Pi session (agent.state.systemPrompt
|
|
1289
|
+
// fallback chain); resources come from that session's ResourceLoader. The raw
|
|
1290
|
+
// prompt stays server-side; the browser only ever receives the scrubbed +
|
|
1291
|
+
// length-limited projection. RF-02: a critical projection failure is 503
|
|
1292
|
+
// PI_RUNTIME_UNAVAILABLE — never a 500 leak or a fake-empty context.
|
|
1293
|
+
let snapshot;
|
|
1294
|
+
try {
|
|
1295
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId, {
|
|
1296
|
+
includeRaw: true,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1301
|
+
return;
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
if (!snapshot) {
|
|
1305
|
+
sendJson(res, 503, {
|
|
1306
|
+
ok: false,
|
|
1307
|
+
error: {
|
|
1308
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1309
|
+
message: `chat session not active: ${sessionId}`,
|
|
1310
|
+
},
|
|
1311
|
+
});
|
|
1286
1312
|
return;
|
|
1287
1313
|
}
|
|
1288
|
-
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
1289
|
-
const selection = deps.runtime.getRuntimeSelection(sessionId);
|
|
1290
|
-
const skillFragment = composeInstructionSkillsPrompt(skills.loaded);
|
|
1291
|
-
const systemPrompt = composeOperatorChatSystemPrompt({ skillFragment });
|
|
1292
1314
|
sendJson(res, 200, {
|
|
1293
1315
|
ok: true,
|
|
1294
1316
|
context: projectRuntimeContext({
|
|
1295
|
-
systemPrompt,
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1317
|
+
systemPrompt: snapshot.systemPrompt.raw ?? "",
|
|
1318
|
+
model: snapshot.selection.model
|
|
1319
|
+
? {
|
|
1320
|
+
provider: snapshot.selection.model.provider,
|
|
1321
|
+
modelId: snapshot.selection.model.id,
|
|
1322
|
+
}
|
|
1323
|
+
: undefined,
|
|
1324
|
+
thinkingLevel: snapshot.selection.thinkingSelection === "auto"
|
|
1325
|
+
? snapshot.selection.effectiveThinkingLevel
|
|
1326
|
+
: snapshot.selection.thinkingSelection,
|
|
1327
|
+
activeTools: snapshot.activeTools,
|
|
1328
|
+
contextFiles: snapshot.contextFiles,
|
|
1329
|
+
skills: snapshot.skills,
|
|
1330
|
+
extensions: snapshot.extensions,
|
|
1331
|
+
packages: snapshot.packages.map((pkg) => ({
|
|
1332
|
+
name: pkg.spec,
|
|
1333
|
+
source: pkg.spec,
|
|
1334
|
+
scope: pkg.scope,
|
|
1335
|
+
enabled: pkg.enabled,
|
|
1336
|
+
...(pkg.version ? { version: pkg.version } : {}),
|
|
1337
|
+
...(pkg.path ? { path: pkg.path } : {}),
|
|
1338
|
+
...(pkg.cwd ? { cwd: pkg.cwd } : {}),
|
|
1339
|
+
resolved: pkg.resolved,
|
|
1340
|
+
diagnostics: pkg.diagnostics,
|
|
1301
1341
|
})),
|
|
1302
|
-
|
|
1303
|
-
(record.modelProvider && record.modelId
|
|
1304
|
-
? { provider: record.modelProvider, modelId: record.modelId }
|
|
1305
|
-
: undefined),
|
|
1306
|
-
thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel,
|
|
1307
|
-
activeTools: selection.activeTools,
|
|
1342
|
+
diagnostics: snapshot.diagnostics,
|
|
1308
1343
|
}),
|
|
1309
1344
|
});
|
|
1310
1345
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1346
|
+
// Exported for the direct handler-boundary tests (trace binding for
|
|
1347
|
+
// vt-route-model-errors); router dispatch behavior is unchanged.
|
|
1348
|
+
export async function handleModels(req, res, deps, sessionId) {
|
|
1349
|
+
// AC-01/AC-06: models (and model/thinking mutation) requests reuse the same
|
|
1350
|
+
// activation path. A missing/reopen-failed runtime is a STRUCTURED error —
|
|
1351
|
+
// never a fabricated empty model list.
|
|
1352
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1353
|
+
if (!ensured.ok) {
|
|
1354
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1313
1355
|
return;
|
|
1356
|
+
}
|
|
1314
1357
|
try {
|
|
1315
1358
|
if ((req.method ?? "GET").toUpperCase() === "GET") {
|
|
1359
|
+
let models;
|
|
1360
|
+
try {
|
|
1361
|
+
models = await deps.runtime.listModels(sessionId);
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
// AC-06: a model-list failure is a STRUCTURED runtime error — never
|
|
1365
|
+
// folded into a fake empty list.
|
|
1366
|
+
sendJson(res, 503, {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
error: {
|
|
1369
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1370
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1371
|
+
},
|
|
1372
|
+
});
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1316
1375
|
sendJson(res, 200, {
|
|
1317
1376
|
ok: true,
|
|
1318
|
-
models:
|
|
1377
|
+
models: models.map((model) => ({
|
|
1378
|
+
provider: model.provider,
|
|
1379
|
+
id: model.id ?? model.modelId ?? "",
|
|
1380
|
+
...(model.name ? { name: model.name } : {}),
|
|
1381
|
+
hasCredentials: model.hasCredentials,
|
|
1382
|
+
})),
|
|
1319
1383
|
thinkingLevels: THINKING_LEVELS,
|
|
1320
1384
|
});
|
|
1321
1385
|
return;
|
|
1322
1386
|
}
|
|
1323
1387
|
const gate = gateMutation(req, deps);
|
|
1324
1388
|
if (!gate.ok) {
|
|
1325
|
-
sendJson(res, gate.status, {
|
|
1326
|
-
ok: false,
|
|
1327
|
-
error: { code: gate.code, message: gate.message },
|
|
1328
|
-
});
|
|
1389
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1329
1390
|
return;
|
|
1330
1391
|
}
|
|
1331
1392
|
const body = await readJsonBody(req);
|
|
1332
1393
|
let updated;
|
|
1333
1394
|
if (typeof body.provider === "string" && typeof body.modelId === "string") {
|
|
1334
|
-
const model = await deps.runtime.applyModel(sessionId, {
|
|
1335
|
-
|
|
1336
|
-
modelId: body.modelId,
|
|
1337
|
-
});
|
|
1338
|
-
updated = await deps.store.setRuntimeSelection(sessionId, {
|
|
1339
|
-
modelProvider: model.provider,
|
|
1340
|
-
modelId: model.modelId,
|
|
1341
|
-
});
|
|
1395
|
+
const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
|
|
1396
|
+
updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
|
|
1342
1397
|
}
|
|
1343
|
-
else if (typeof body.thinkingLevel === "string" &&
|
|
1344
|
-
THINKING_LEVELS.includes(body.thinkingLevel)) {
|
|
1398
|
+
else if (typeof body.thinkingLevel === "string" && (body.thinkingLevel === "auto" || THINKING_LEVELS.includes(body.thinkingLevel))) {
|
|
1345
1399
|
const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
|
|
1346
|
-
updated = await deps.store.setRuntimeSelection(sessionId, {
|
|
1347
|
-
thinkingLevel,
|
|
1348
|
-
});
|
|
1400
|
+
updated = await deps.store.setRuntimeSelection(sessionId, thinkingLevel === "auto" ? {} : { thinkingLevel });
|
|
1349
1401
|
}
|
|
1350
1402
|
else {
|
|
1351
|
-
sendJson(res, 400, {
|
|
1352
|
-
ok: false,
|
|
1353
|
-
error: {
|
|
1354
|
-
code: "INVALID_INPUT",
|
|
1355
|
-
message: "provider/modelId or thinkingLevel is required",
|
|
1356
|
-
},
|
|
1357
|
-
});
|
|
1403
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
|
|
1358
1404
|
return;
|
|
1359
1405
|
}
|
|
1360
|
-
|
|
1406
|
+
// Design §5: mutations return the fresh snapshot so the browser can
|
|
1407
|
+
// atomically replace its state (revision increments server-side).
|
|
1408
|
+
// RF-02: the mutation may already be applied server-side, but a failed
|
|
1409
|
+
// snapshot read must surface as 503 PI_RUNTIME_UNAVAILABLE — NOT be
|
|
1410
|
+
// folded into a 409 MODEL_SWITCH_FAILED (the frontend refresh path
|
|
1411
|
+
// re-reads the snapshot to converge on the applied selection).
|
|
1412
|
+
const snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1413
|
+
sendJson(res, 200, { ok: true, session: updated, snapshot });
|
|
1361
1414
|
}
|
|
1362
1415
|
catch (error) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
code:
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
}
|
|
1416
|
+
if (error instanceof RuntimeSnapshotUnavailableError) {
|
|
1417
|
+
sendJson(res, 503, {
|
|
1418
|
+
ok: false,
|
|
1419
|
+
error: { code: error.code, message: error.message },
|
|
1420
|
+
});
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1370
1424
|
}
|
|
1371
1425
|
}
|
|
1372
1426
|
async function requireChatSession(res, deps, sessionId) {
|
|
@@ -1381,6 +1435,132 @@ async function requireChatSession(res, deps, sessionId) {
|
|
|
1381
1435
|
});
|
|
1382
1436
|
return false;
|
|
1383
1437
|
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Unified session activation (design §2.1 / AC-01): models, runtime snapshot,
|
|
1440
|
+
* reload and model/thinking mutation requests all flow through this path.
|
|
1441
|
+
*
|
|
1442
|
+
* - runtime already holds the session → ok;
|
|
1443
|
+
* - only a persisted record exists → reopen from record.sessionFile;
|
|
1444
|
+
* - no record / missing session file → 404 PI_SESSION_NOT_FOUND;
|
|
1445
|
+
* - SDK reopen failure → 503 PI_RUNTIME_UNAVAILABLE.
|
|
1446
|
+
*
|
|
1447
|
+
* Never fabricates a fresh session or an empty model list.
|
|
1448
|
+
*/
|
|
1449
|
+
export async function ensureActiveSession(deps, sessionId) {
|
|
1450
|
+
if (deps.runtime.hasSession(sessionId))
|
|
1451
|
+
return { ok: true };
|
|
1452
|
+
const record = await deps.store.get(sessionId);
|
|
1453
|
+
if (!record) {
|
|
1454
|
+
return {
|
|
1455
|
+
ok: false,
|
|
1456
|
+
status: 404,
|
|
1457
|
+
code: "PI_SESSION_NOT_FOUND",
|
|
1458
|
+
message: `chat session not found: ${sessionId}`,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const reopened = await deps.runtime.reopenSession({
|
|
1462
|
+
sessionId,
|
|
1463
|
+
sessionFile: record.sessionFile,
|
|
1464
|
+
...(record.modelProvider && record.modelId
|
|
1465
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
1466
|
+
: {}),
|
|
1467
|
+
});
|
|
1468
|
+
if (!reopened.ok) {
|
|
1469
|
+
return reopened.code === "NO_SESSION_FILE"
|
|
1470
|
+
? {
|
|
1471
|
+
ok: false,
|
|
1472
|
+
status: 404,
|
|
1473
|
+
code: "PI_SESSION_NOT_FOUND",
|
|
1474
|
+
message: reopened.message,
|
|
1475
|
+
}
|
|
1476
|
+
: {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
status: 503,
|
|
1479
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1480
|
+
message: reopened.message,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
return { ok: true };
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
|
|
1487
|
+
* structured busy/failure semantics. Never aborts, never silently queues.
|
|
1488
|
+
*/
|
|
1489
|
+
export async function handleReloadSession(req, res, deps, sessionId) {
|
|
1490
|
+
const gate = gateMutation(req, deps);
|
|
1491
|
+
if (!gate.ok) {
|
|
1492
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1496
|
+
if (!ensured.ok) {
|
|
1497
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const result = await deps.runtime.reloadSession(sessionId);
|
|
1501
|
+
if (!result.ok) {
|
|
1502
|
+
const status = result.code === "PI_SESSION_NOT_FOUND"
|
|
1503
|
+
? 404
|
|
1504
|
+
: result.code === "PI_SESSION_BUSY"
|
|
1505
|
+
? 409
|
|
1506
|
+
: 500;
|
|
1507
|
+
sendJson(res, status, { ok: false, error: { code: result.code, message: result.message } });
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// RF-02: the reload succeeded, but a failed snapshot read is still a
|
|
1511
|
+
// structured 503 — never a 200 with fake-empty resources.
|
|
1512
|
+
let snapshot;
|
|
1513
|
+
try {
|
|
1514
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1515
|
+
}
|
|
1516
|
+
catch (error) {
|
|
1517
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1518
|
+
return;
|
|
1519
|
+
throw error;
|
|
1520
|
+
}
|
|
1521
|
+
sendJson(res, 200, {
|
|
1522
|
+
ok: true,
|
|
1523
|
+
revision: result.revision,
|
|
1524
|
+
snapshot,
|
|
1525
|
+
resourceCounts: snapshot
|
|
1526
|
+
? {
|
|
1527
|
+
skills: snapshot.skills.length,
|
|
1528
|
+
extensions: snapshot.extensions.length,
|
|
1529
|
+
packages: snapshot.packages.length,
|
|
1530
|
+
contextFiles: snapshot.contextFiles.length,
|
|
1531
|
+
}
|
|
1532
|
+
: undefined,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* GET /sessions/:id/runtime-snapshot (AC-01): the unified live snapshot.
|
|
1537
|
+
*/
|
|
1538
|
+
export async function handleRuntimeSnapshot(_res, res, deps, sessionId) {
|
|
1539
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1540
|
+
if (!ensured.ok) {
|
|
1541
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
// RF-02: a critical snapshot projection failure is 503 PI_RUNTIME_UNAVAILABLE
|
|
1545
|
+
// — never a 200 fake-empty snapshot.
|
|
1546
|
+
let snapshot;
|
|
1547
|
+
try {
|
|
1548
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1549
|
+
}
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1552
|
+
return;
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
if (!snapshot) {
|
|
1556
|
+
sendJson(res, 503, {
|
|
1557
|
+
ok: false,
|
|
1558
|
+
error: { code: "PI_RUNTIME_UNAVAILABLE", message: `chat session not active: ${sessionId}` },
|
|
1559
|
+
});
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
sendJson(res, 200, { ok: true, snapshot });
|
|
1563
|
+
}
|
|
1384
1564
|
async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
|
|
1385
1565
|
const gate = gateMutation(req, deps);
|
|
1386
1566
|
if (!gate.ok) {
|
|
@@ -2153,16 +2333,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2153
2333
|
await handlePiConfig(req, res, deps, "packages");
|
|
2154
2334
|
return true;
|
|
2155
2335
|
}
|
|
2156
|
-
if (pathname === "/api/operator/v1/chat/instruction-skills" &&
|
|
2157
|
-
method === "GET") {
|
|
2158
|
-
await handlePiConfig(req, res, deps, "skills");
|
|
2159
|
-
return true;
|
|
2160
|
-
}
|
|
2161
|
-
if (pathname === "/api/operator/v1/chat/instruction-skills/preferences" &&
|
|
2162
|
-
method === "PUT") {
|
|
2163
|
-
await handlePiConfig(req, res, deps, "skills");
|
|
2164
|
-
return true;
|
|
2165
|
-
}
|
|
2166
2336
|
if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
|
|
2167
2337
|
await handleChatCapabilities(req, res, deps);
|
|
2168
2338
|
return true;
|
|
@@ -2221,7 +2391,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2221
2391
|
await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
|
|
2222
2392
|
return true;
|
|
2223
2393
|
}
|
|
2224
|
-
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
|
|
2394
|
+
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
|
|
2225
2395
|
if (m4Route) {
|
|
2226
2396
|
const id = decodeURIComponent(m4Route[1]);
|
|
2227
2397
|
const route = m4Route[2];
|
|
@@ -2237,6 +2407,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2237
2407
|
await handleRuntimeContext(res, deps, id);
|
|
2238
2408
|
return true;
|
|
2239
2409
|
}
|
|
2410
|
+
if (route === "runtime-snapshot" && method === "GET") {
|
|
2411
|
+
await handleRuntimeSnapshot(req, res, deps, id);
|
|
2412
|
+
return true;
|
|
2413
|
+
}
|
|
2240
2414
|
if (route === "models" && method === "GET") {
|
|
2241
2415
|
await handleModels(req, res, deps, id);
|
|
2242
2416
|
return true;
|
|
@@ -2245,6 +2419,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2245
2419
|
await handleModels(req, res, deps, id);
|
|
2246
2420
|
return true;
|
|
2247
2421
|
}
|
|
2422
|
+
if (route === "reload" && method === "POST") {
|
|
2423
|
+
await handleReloadSession(req, res, deps, id);
|
|
2424
|
+
return true;
|
|
2425
|
+
}
|
|
2248
2426
|
}
|
|
2249
2427
|
const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
|
|
2250
2428
|
if (method === "POST" && sessionMainline) {
|