@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1
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 +67 -16
- package/dist/application/task-lifecycle/advance.js +309 -9
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/application/task-lifecycle/plan-transitions.js +13 -21
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/dag-pi-executor.js +18 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +98 -44
- 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 +229 -18
- 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/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +1007 -188
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +495 -157
- 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/session-store.js +39 -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/interview/grill-me.js +7 -6
- package/dist/worker/console/operator-actions.js +785 -85
- package/dist/worker/console/prd-intake-bridge.js +393 -0
- package/dist/worker/console/recovery-cta.js +35 -6
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +1 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- 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/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -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/useOverlayFocus.js +84 -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/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -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/observability/read-model.js +3 -0
- 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 +33 -462
- package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
- package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
- 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/failure-category.js +3 -0
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +49 -55
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/retry-policy.js +44 -11
- package/dist/workflows/dag/runner.js +6 -0
- package/dist/workflows/dag/scheduler.js +49 -1
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/validate.js +16 -2
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/agent-dag.schema.json +4 -4
- package/docs/templates/backend-test-dag.json +29 -32
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- 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
|
@@ -6,24 +6,23 @@
|
|
|
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";
|
|
17
|
+
import { cleanChatTitle } from "./session-store.js";
|
|
19
18
|
import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
|
|
20
|
-
import { THINKING_LEVELS,
|
|
19
|
+
import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
|
|
21
20
|
import { ComposerDraftStore } from "./composer-draft-store.js";
|
|
22
21
|
import { walkRepoFiles } from "./repo-walk.js";
|
|
23
22
|
import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
|
|
24
23
|
import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
|
|
25
24
|
import { projectRuntimeContext } from "./runtime-context.js";
|
|
26
|
-
import { patchModelsConfig, readModelsConfig, readPackageInventory,
|
|
25
|
+
import { patchModelsConfig, readModelsConfig, readPackageInventory, } from "./pi-console-config.js";
|
|
27
26
|
import { ChatInterviewAdapter, projectChatInterviewState, } from "./interview-adapter.js";
|
|
28
27
|
import { classifyChatCompactionFailure } from "./compaction-errors.js";
|
|
29
28
|
import { evaluateMutationGate, isMutationMethod } from "../security.js";
|
|
@@ -33,9 +32,24 @@ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
|
|
|
33
32
|
import { projectTaskContext } from "./context-panel.js";
|
|
34
33
|
import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
|
|
35
34
|
import { sendJson } from "../routes.js";
|
|
36
|
-
import { openSseResponse,
|
|
35
|
+
import { openSseResponse, writeSseEvent } from "../operation-sse.js";
|
|
36
|
+
/** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
|
|
37
|
+
* the same wire shape; its prefix intentionally fails the current process
|
|
38
|
+
* epoch comparison and triggers safe snapshot recovery (M5.1). */
|
|
39
|
+
function parseChatEventCursor(header) {
|
|
40
|
+
if (!header?.trim())
|
|
41
|
+
return { epoch: null, seq: 0 };
|
|
42
|
+
const raw = header.trim();
|
|
43
|
+
const colon = raw.lastIndexOf(":");
|
|
44
|
+
const seqPart = colon >= 0 ? raw.slice(colon + 1) : raw;
|
|
45
|
+
const seq = Number.parseInt(seqPart, 10);
|
|
46
|
+
return {
|
|
47
|
+
epoch: colon > 0 ? raw.slice(0, colon) : null,
|
|
48
|
+
seq: Number.isFinite(seq) && seq >= 0 ? seq : 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
37
51
|
function writeChatSse(res, event) {
|
|
38
|
-
// Persisted Chat events carry their own eventId (
|
|
52
|
+
// Persisted Chat events carry their own eventId (`<epoch>:<seq>`); write
|
|
39
53
|
// it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
|
|
40
54
|
if ("seq" in event && event.eventId) {
|
|
41
55
|
res.write(`id: ${event.eventId}\n`);
|
|
@@ -93,21 +107,65 @@ function turnEventToStorePartial(event) {
|
|
|
93
107
|
return undefined;
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
function emitSessionTitle(deps, sessionId, turnId, title, automatic = false) {
|
|
111
|
+
return deps.events.append(sessionId, turnId, {
|
|
112
|
+
kind: "session_title",
|
|
113
|
+
data: { title, ...(automatic ? { automatic: true } : {}) },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function shouldEmitPlaceholder(record) {
|
|
117
|
+
return !record.title &&
|
|
118
|
+
record.messages.filter((message) => message.role === "user").length === 1
|
|
119
|
+
? record.displayTitle
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
|
122
|
+
function scheduleAutomaticTitle(deps, input) {
|
|
123
|
+
void (async () => {
|
|
124
|
+
try {
|
|
125
|
+
const generated = await deps.runtime.generateTitle(input.sessionId, input.firstUserText);
|
|
126
|
+
const title = generated ? cleanChatTitle(generated) : undefined;
|
|
127
|
+
if (!title)
|
|
128
|
+
return;
|
|
129
|
+
const stored = await deps.store.setAutomaticTitle(input.sessionId, title);
|
|
130
|
+
if (!stored)
|
|
131
|
+
return;
|
|
132
|
+
emitSessionTitle(deps, input.sessionId, input.turnId, stored.title, true);
|
|
133
|
+
// Keep the best-effort Pi mirror inside this detached task's catch
|
|
134
|
+
// boundary: both synchronous throws and rejected Promises are absorbed
|
|
135
|
+
// only after durable title/event persistence has completed.
|
|
136
|
+
await deps.runtime.setSessionName(input.sessionId, stored.title);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Background title failures must never affect the settled Chat turn.
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
}
|
|
143
|
+
/** Guarantee durable terminal events for a turn that ended without the SDK
|
|
144
|
+
* emitting them (client abort, pre-prompt persistence failure, runtime
|
|
145
|
+
* error). Without `agent_end`, reconnect replay stops mid-turn; without
|
|
146
|
+
* `agent_settled`, the single-stream UI keeps idle-grace streaming forever
|
|
147
|
+
* (M4.4). Idempotent for normal settled turns that already have both. */
|
|
101
148
|
function ensureAgentEndEvent(deps, sessionId, turnId) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!already) {
|
|
149
|
+
const snap = deps.events.snapshot(sessionId);
|
|
150
|
+
const hasAgentEnd = snap.some((event) => event.turnId === turnId && event.kind === "agent_end");
|
|
151
|
+
if (!hasAgentEnd) {
|
|
106
152
|
deps.events.append(sessionId, turnId, {
|
|
107
153
|
kind: "agent_end",
|
|
108
154
|
data: { willRetry: false },
|
|
109
155
|
});
|
|
110
156
|
}
|
|
157
|
+
// Error/abort paths historically only forced agent_end. After the M4 single
|
|
158
|
+
// stream keeps UI streaming through agent_end for idle grace, a matching
|
|
159
|
+
// agent_settled is required so state reconcile and the applier both finish.
|
|
160
|
+
const hasSettled = deps.events
|
|
161
|
+
.snapshot(sessionId)
|
|
162
|
+
.some((event) => event.turnId === turnId && event.kind === "agent_settled");
|
|
163
|
+
if (!hasSettled) {
|
|
164
|
+
deps.events.append(sessionId, turnId, {
|
|
165
|
+
kind: "agent_settled",
|
|
166
|
+
data: {},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
111
169
|
}
|
|
112
170
|
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
113
171
|
const chunks = [];
|
|
@@ -198,9 +256,53 @@ function gateMutation(req, deps) {
|
|
|
198
256
|
}
|
|
199
257
|
return { ok: true };
|
|
200
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* RF-02: map a RuntimeSnapshotUnavailableError (critical snapshot projection
|
|
261
|
+
* failure — model enumeration / system prompt) to 503 PI_RUNTIME_UNAVAILABLE.
|
|
262
|
+
* Returns true when the response was sent; other errors keep their existing
|
|
263
|
+
* 409/500 branches so the mutation/reload semantics never regress.
|
|
264
|
+
*/
|
|
265
|
+
function sendRuntimeUnavailable(res, error) {
|
|
266
|
+
if (!(error instanceof RuntimeSnapshotUnavailableError))
|
|
267
|
+
return false;
|
|
268
|
+
sendJson(res, 503, {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: { code: error.code, message: error.message },
|
|
271
|
+
});
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Map a failed `reopenSession()` result to HTTP status + response error code.
|
|
276
|
+
* Must stay aligned with `ensureActiveSession` reopen-failure branches so
|
|
277
|
+
* lazy-reopen routes (prompt/turns/reopen/compact/branch) and readiness
|
|
278
|
+
* endpoints share one semantic table.
|
|
279
|
+
*
|
|
280
|
+
* Business conflicts (TURN_ACTIVE, PI_SESSION_BUSY, model switch, …) are NOT
|
|
281
|
+
* handled here — only reopen/init readiness failures.
|
|
282
|
+
*/
|
|
283
|
+
function mapReopenSessionFailure(reopen) {
|
|
284
|
+
if (reopen.code === "NO_SESSION_FILE") {
|
|
285
|
+
return {
|
|
286
|
+
status: 404,
|
|
287
|
+
code: "PI_SESSION_NOT_FOUND",
|
|
288
|
+
message: reopen.message,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (reopen.code === "PI_SESSION_INIT_FAILED") {
|
|
292
|
+
return {
|
|
293
|
+
status: 503,
|
|
294
|
+
code: "PI_SESSION_INIT_FAILED",
|
|
295
|
+
message: reopen.message,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
status: 503,
|
|
300
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
301
|
+
message: reopen.message,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
201
304
|
export async function handleChatCapabilities(_req, res, deps) {
|
|
202
305
|
const toolSchemas = buildModelCallableToolSchemas();
|
|
203
|
-
const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
|
|
204
306
|
sendJson(res, 200, {
|
|
205
307
|
ok: true,
|
|
206
308
|
schemaVersion: 1,
|
|
@@ -208,18 +310,6 @@ export async function handleChatCapabilities(_req, res, deps) {
|
|
|
208
310
|
deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
|
|
209
311
|
deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
|
|
210
312
|
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
313
|
// ADR 0011: full Pi tools including bash are default-active.
|
|
224
314
|
hasBash: true,
|
|
225
315
|
});
|
|
@@ -276,10 +366,8 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
276
366
|
return;
|
|
277
367
|
}
|
|
278
368
|
try {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
? undefined
|
|
282
|
-
: projectCompactSnapshot(snapshotInput);
|
|
369
|
+
const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
|
|
370
|
+
// Fast path: durable shell + Console record before full AgentSession ready.
|
|
283
371
|
const handle = await deps.runtime.createSession({
|
|
284
372
|
...(provider && modelId ? { model: { provider, modelId } } : {}),
|
|
285
373
|
...(snapshot
|
|
@@ -288,7 +376,18 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
288
376
|
}
|
|
289
377
|
: {}),
|
|
290
378
|
});
|
|
291
|
-
|
|
379
|
+
if (!handle.sessionFile) {
|
|
380
|
+
deps.runtime.dispose(handle.sessionId);
|
|
381
|
+
sendJson(res, 500, {
|
|
382
|
+
ok: false,
|
|
383
|
+
error: {
|
|
384
|
+
code: "CHAT_SESSION_CREATE_FAILED",
|
|
385
|
+
message: "chat session shell missing sessionFile",
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const session = await deps.store.create({
|
|
292
391
|
sessionId: handle.sessionId,
|
|
293
392
|
sessionFile: handle.sessionFile,
|
|
294
393
|
modelProvider: handle.model?.provider,
|
|
@@ -299,13 +398,33 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
299
398
|
kind: "compact",
|
|
300
399
|
data: snapshot,
|
|
301
400
|
});
|
|
401
|
+
// Best-effort: after materialization, patch resolved default model onto the
|
|
402
|
+
// durable record. Failures stay on the init fence (PI_SESSION_INIT_FAILED).
|
|
403
|
+
if (typeof deps.runtime.ensureSessionReady === "function") {
|
|
404
|
+
void deps.runtime
|
|
405
|
+
.ensureSessionReady(handle.sessionId)
|
|
406
|
+
.then(async () => {
|
|
407
|
+
if (typeof deps.runtime.getRuntimeSelection !== "function")
|
|
408
|
+
return;
|
|
409
|
+
const selection = deps.runtime.getRuntimeSelection(handle.sessionId);
|
|
410
|
+
if (!selection?.model)
|
|
411
|
+
return;
|
|
412
|
+
await deps.store.setRuntimeSelection(handle.sessionId, {
|
|
413
|
+
modelProvider: selection.model.provider,
|
|
414
|
+
modelId: selection.model.modelId,
|
|
415
|
+
});
|
|
416
|
+
})
|
|
417
|
+
.catch(() => undefined);
|
|
418
|
+
}
|
|
302
419
|
sendJson(res, 201, {
|
|
303
420
|
ok: true,
|
|
304
421
|
sessionId: handle.sessionId,
|
|
305
422
|
sessionFile: handle.sessionFile,
|
|
306
423
|
model: handle.model,
|
|
424
|
+
session,
|
|
307
425
|
activeTools: handle.activeTools,
|
|
308
426
|
hasBash: true,
|
|
427
|
+
...(handle.initializing ? { initializing: true } : {}),
|
|
309
428
|
});
|
|
310
429
|
}
|
|
311
430
|
catch (error) {
|
|
@@ -415,8 +534,17 @@ export async function handleBranchChatSession(req, res, deps, sessionId) {
|
|
|
415
534
|
sessionId,
|
|
416
535
|
sessionFile: record.sessionFile,
|
|
417
536
|
});
|
|
418
|
-
if (!reopened.ok)
|
|
419
|
-
|
|
537
|
+
if (!reopened.ok) {
|
|
538
|
+
const mapped = mapReopenSessionFailure({
|
|
539
|
+
code: reopened.code,
|
|
540
|
+
message: reopened.message,
|
|
541
|
+
});
|
|
542
|
+
sendJson(res, mapped.status, {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: { code: mapped.code, message: mapped.message },
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
420
548
|
}
|
|
421
549
|
const body = await readJsonBody(req);
|
|
422
550
|
if ("clientRequestId" in body || "requestIndex" in body)
|
|
@@ -491,6 +619,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
|
491
619
|
const lastEvent = snapshot.at(-1);
|
|
492
620
|
sendJson(res, 200, {
|
|
493
621
|
ok: true,
|
|
622
|
+
eventEpoch: deps.events.epoch(sessionId),
|
|
494
623
|
session: {
|
|
495
624
|
...record,
|
|
496
625
|
composerDraft,
|
|
@@ -618,9 +747,13 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
|
|
|
618
747
|
: {}),
|
|
619
748
|
});
|
|
620
749
|
if (!result.ok) {
|
|
621
|
-
|
|
750
|
+
const mapped = mapReopenSessionFailure({
|
|
751
|
+
code: result.code,
|
|
752
|
+
message: result.message,
|
|
753
|
+
});
|
|
754
|
+
sendJson(res, mapped.status, {
|
|
622
755
|
ok: false,
|
|
623
|
-
error: { code:
|
|
756
|
+
error: { code: mapped.code, message: mapped.message },
|
|
624
757
|
});
|
|
625
758
|
return;
|
|
626
759
|
}
|
|
@@ -703,9 +836,13 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
703
836
|
: {}),
|
|
704
837
|
});
|
|
705
838
|
if (!reopen.ok) {
|
|
706
|
-
|
|
839
|
+
const mapped = mapReopenSessionFailure({
|
|
840
|
+
code: reopen.code,
|
|
841
|
+
message: reopen.message,
|
|
842
|
+
});
|
|
843
|
+
sendJson(res, mapped.status, {
|
|
707
844
|
ok: false,
|
|
708
|
-
error: { code:
|
|
845
|
+
error: { code: mapped.code, message: mapped.message },
|
|
709
846
|
});
|
|
710
847
|
return;
|
|
711
848
|
}
|
|
@@ -754,6 +891,7 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
754
891
|
});
|
|
755
892
|
// Lazily import to avoid circular module load.
|
|
756
893
|
const { runChatTurn } = await import("./session-store.js");
|
|
894
|
+
let firstUserText;
|
|
757
895
|
try {
|
|
758
896
|
const outcome = await runChatTurn({
|
|
759
897
|
runtime: deps.runtime,
|
|
@@ -763,6 +901,18 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
763
901
|
text,
|
|
764
902
|
images,
|
|
765
903
|
signal: ac.signal,
|
|
904
|
+
onUserMessagePersisted: (record) => {
|
|
905
|
+
const users = record.messages.filter((message) => message.role === "user");
|
|
906
|
+
if (users.length !== 1)
|
|
907
|
+
return;
|
|
908
|
+
firstUserText = users[0]?.text;
|
|
909
|
+
const placeholder = shouldEmitPlaceholder(record);
|
|
910
|
+
if (!placeholder)
|
|
911
|
+
return;
|
|
912
|
+
const persisted = emitSessionTitle(deps, sessionId, turnId, placeholder);
|
|
913
|
+
if (!res.writableEnded)
|
|
914
|
+
writeChatSse(res, persisted);
|
|
915
|
+
},
|
|
766
916
|
onOperationAccepted: (accepted) => deps.operationLinker.link({
|
|
767
917
|
sessionId,
|
|
768
918
|
turnId,
|
|
@@ -813,6 +963,8 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
813
963
|
else {
|
|
814
964
|
ensureAgentEndEvent(deps, sessionId, turnId);
|
|
815
965
|
deps.events.setTurnState(sessionId, turnId, outcome.ok ? "settled" : "failed", outcome.error);
|
|
966
|
+
if (outcome.ok && firstUserText)
|
|
967
|
+
scheduleAutomaticTitle(deps, { sessionId, turnId, firstUserText });
|
|
816
968
|
}
|
|
817
969
|
}
|
|
818
970
|
catch (error) {
|
|
@@ -917,9 +1069,13 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
917
1069
|
: {}),
|
|
918
1070
|
});
|
|
919
1071
|
if (!reopen.ok) {
|
|
920
|
-
|
|
1072
|
+
const mapped = mapReopenSessionFailure({
|
|
1073
|
+
code: reopen.code,
|
|
1074
|
+
message: reopen.message,
|
|
1075
|
+
});
|
|
1076
|
+
sendJson(res, mapped.status, {
|
|
921
1077
|
ok: false,
|
|
922
|
-
error: { code:
|
|
1078
|
+
error: { code: mapped.code, message: mapped.message },
|
|
923
1079
|
});
|
|
924
1080
|
return;
|
|
925
1081
|
}
|
|
@@ -958,6 +1114,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
958
1114
|
// response cannot abort the prompt; clients follow GET /events instead.
|
|
959
1115
|
void (async () => {
|
|
960
1116
|
const { runChatTurn } = await import("./session-store.js");
|
|
1117
|
+
let firstUserText;
|
|
961
1118
|
try {
|
|
962
1119
|
const outcome = await runChatTurn({
|
|
963
1120
|
runtime: deps.runtime,
|
|
@@ -966,6 +1123,15 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
966
1123
|
sessionId,
|
|
967
1124
|
text,
|
|
968
1125
|
images,
|
|
1126
|
+
onUserMessagePersisted: (record) => {
|
|
1127
|
+
const users = record.messages.filter((message) => message.role === "user");
|
|
1128
|
+
if (users.length !== 1)
|
|
1129
|
+
return;
|
|
1130
|
+
firstUserText = users[0]?.text;
|
|
1131
|
+
const placeholder = shouldEmitPlaceholder(record);
|
|
1132
|
+
if (placeholder)
|
|
1133
|
+
emitSessionTitle(deps, sessionId, turn.turnId, placeholder);
|
|
1134
|
+
},
|
|
969
1135
|
onOperationAccepted: (accepted) => deps.operationLinker.link({
|
|
970
1136
|
sessionId,
|
|
971
1137
|
turnId: turn.turnId,
|
|
@@ -982,6 +1148,8 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
982
1148
|
});
|
|
983
1149
|
ensureAgentEndEvent(deps, sessionId, turn.turnId);
|
|
984
1150
|
deps.events.setTurnState(sessionId, turn.turnId, outcome.ok ? "settled" : "failed", outcome.error);
|
|
1151
|
+
if (outcome.ok && firstUserText)
|
|
1152
|
+
scheduleAutomaticTitle(deps, { sessionId, turnId: turn.turnId, firstUserText });
|
|
985
1153
|
}
|
|
986
1154
|
catch (error) {
|
|
987
1155
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1025,23 +1193,31 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
|
|
|
1025
1193
|
}
|
|
1026
1194
|
await deps.operationLinker.recoverSession(sessionId);
|
|
1027
1195
|
openSseResponse(res);
|
|
1028
|
-
// Resume from the client's last seen eventId (
|
|
1196
|
+
// Resume from the client's last seen eventId (`<epoch>:<seq>`). A previous
|
|
1197
|
+
// Console boot/legacy cursor has a different prefix and receives snapshot.
|
|
1029
1198
|
const lastEventIdHeader = req.headers["last-event-id"];
|
|
1030
|
-
const
|
|
1031
|
-
const replay = deps.events.listFrom(sessionId,
|
|
1032
|
-
let lastSeq =
|
|
1199
|
+
const cursor = parseChatEventCursor(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
|
|
1200
|
+
const replay = deps.events.listFrom(sessionId, cursor);
|
|
1201
|
+
let lastSeq = cursor.seq;
|
|
1033
1202
|
if ("error" in replay) {
|
|
1034
1203
|
// Cursor aged out of the bounded ring (T07 fence): tell the client to
|
|
1035
1204
|
// reconcile from a fresh snapshot rather than render a gap as if current.
|
|
1205
|
+
// The frame carries the ring tail (latestEventId) so the client can resume
|
|
1206
|
+
// the replayed snapshot with a fresh Last-Event-ID (recovery cursor).
|
|
1207
|
+
const retained = deps.events.snapshot(sessionId);
|
|
1036
1208
|
writeChatSse(res, {
|
|
1037
1209
|
kind: "reconcile",
|
|
1038
1210
|
sessionId,
|
|
1039
|
-
reason: "
|
|
1211
|
+
reason: replay.error === "CURSOR_EPOCH_MISMATCH"
|
|
1212
|
+
? "epoch_mismatch"
|
|
1213
|
+
: "cursor_expired",
|
|
1040
1214
|
minSeq: replay.minSeq,
|
|
1215
|
+
epoch: replay.epoch,
|
|
1041
1216
|
latestTurnId: deps.events.latestTurnId(sessionId),
|
|
1217
|
+
latestEventId: retained.at(-1)?.eventId ?? null,
|
|
1042
1218
|
});
|
|
1043
1219
|
// Fall back to full retained snapshot so the client can re-render.
|
|
1044
|
-
for (const ev of
|
|
1220
|
+
for (const ev of retained) {
|
|
1045
1221
|
writeChatSse(res, ev);
|
|
1046
1222
|
lastSeq = Math.max(lastSeq, ev.seq);
|
|
1047
1223
|
}
|
|
@@ -1052,6 +1228,15 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
|
|
|
1052
1228
|
lastSeq = Math.max(lastSeq, ev.seq);
|
|
1053
1229
|
}
|
|
1054
1230
|
}
|
|
1231
|
+
// Delimit retained replay from follow mode. The client uses this marker to
|
|
1232
|
+
// ensure its sole GET /events consumer is ready before POST /turns (M4.1).
|
|
1233
|
+
const retainedTail = deps.events.snapshot(sessionId).at(-1);
|
|
1234
|
+
writeChatSse(res, {
|
|
1235
|
+
kind: "stream_ready",
|
|
1236
|
+
sessionId,
|
|
1237
|
+
epoch: deps.events.epoch(sessionId),
|
|
1238
|
+
latestEventId: retainedTail?.eventId ?? null,
|
|
1239
|
+
});
|
|
1055
1240
|
let closed = false;
|
|
1056
1241
|
res.once("close", () => {
|
|
1057
1242
|
closed = true;
|
|
@@ -1211,47 +1396,6 @@ async function handlePiConfig(req, res, deps, resource) {
|
|
|
1211
1396
|
});
|
|
1212
1397
|
return;
|
|
1213
1398
|
}
|
|
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
1399
|
}
|
|
1256
1400
|
catch (error) {
|
|
1257
1401
|
const failure = error;
|
|
@@ -1279,94 +1423,148 @@ async function handleChatFiles(res, deps, sessionId, query) {
|
|
|
1279
1423
|
.map((path) => ({ path }));
|
|
1280
1424
|
sendJson(res, 200, { ok: true, files });
|
|
1281
1425
|
}
|
|
1282
|
-
async function handleRuntimeContext(res, deps, sessionId) {
|
|
1283
|
-
const
|
|
1284
|
-
if (!
|
|
1285
|
-
sendJson(res,
|
|
1426
|
+
export async function handleRuntimeContext(res, deps, sessionId) {
|
|
1427
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1428
|
+
if (!ensured.ok) {
|
|
1429
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
// AC-03: system prompt comes from the REAL Pi session (agent.state.systemPrompt
|
|
1433
|
+
// fallback chain); resources come from that session's ResourceLoader. The raw
|
|
1434
|
+
// prompt stays server-side; the browser only ever receives the scrubbed +
|
|
1435
|
+
// length-limited projection. RF-02: a critical projection failure is 503
|
|
1436
|
+
// PI_RUNTIME_UNAVAILABLE — never a 500 leak or a fake-empty context.
|
|
1437
|
+
let snapshot;
|
|
1438
|
+
try {
|
|
1439
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId, {
|
|
1440
|
+
includeRaw: true,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1445
|
+
return;
|
|
1446
|
+
throw error;
|
|
1447
|
+
}
|
|
1448
|
+
if (!snapshot) {
|
|
1449
|
+
sendJson(res, 503, {
|
|
1450
|
+
ok: false,
|
|
1451
|
+
error: {
|
|
1452
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1453
|
+
message: `chat session not active: ${sessionId}`,
|
|
1454
|
+
},
|
|
1455
|
+
});
|
|
1286
1456
|
return;
|
|
1287
1457
|
}
|
|
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
1458
|
sendJson(res, 200, {
|
|
1293
1459
|
ok: true,
|
|
1294
1460
|
context: projectRuntimeContext({
|
|
1295
|
-
systemPrompt,
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1461
|
+
systemPrompt: snapshot.systemPrompt.raw ?? "",
|
|
1462
|
+
model: snapshot.selection.model
|
|
1463
|
+
? {
|
|
1464
|
+
provider: snapshot.selection.model.provider,
|
|
1465
|
+
modelId: snapshot.selection.model.id,
|
|
1466
|
+
}
|
|
1467
|
+
: undefined,
|
|
1468
|
+
thinkingLevel: snapshot.selection.thinkingSelection === "auto"
|
|
1469
|
+
? snapshot.selection.effectiveThinkingLevel
|
|
1470
|
+
: snapshot.selection.thinkingSelection,
|
|
1471
|
+
activeTools: snapshot.activeTools,
|
|
1472
|
+
contextFiles: snapshot.contextFiles,
|
|
1473
|
+
skills: snapshot.skills,
|
|
1474
|
+
extensions: snapshot.extensions,
|
|
1475
|
+
packages: snapshot.packages.map((pkg) => ({
|
|
1476
|
+
name: pkg.spec,
|
|
1477
|
+
source: pkg.spec,
|
|
1478
|
+
scope: pkg.scope,
|
|
1479
|
+
enabled: pkg.enabled,
|
|
1480
|
+
...(pkg.version ? { version: pkg.version } : {}),
|
|
1481
|
+
...(pkg.path ? { path: pkg.path } : {}),
|
|
1482
|
+
...(pkg.cwd ? { cwd: pkg.cwd } : {}),
|
|
1483
|
+
resolved: pkg.resolved,
|
|
1484
|
+
diagnostics: pkg.diagnostics,
|
|
1301
1485
|
})),
|
|
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,
|
|
1486
|
+
diagnostics: snapshot.diagnostics,
|
|
1308
1487
|
}),
|
|
1309
1488
|
});
|
|
1310
1489
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1490
|
+
// Exported for the direct handler-boundary tests (trace binding for
|
|
1491
|
+
// vt-route-model-errors); router dispatch behavior is unchanged.
|
|
1492
|
+
export async function handleModels(req, res, deps, sessionId) {
|
|
1493
|
+
// AC-01/AC-06: models (and model/thinking mutation) requests reuse the same
|
|
1494
|
+
// activation path. A missing/reopen-failed runtime is a STRUCTURED error —
|
|
1495
|
+
// never a fabricated empty model list.
|
|
1496
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1497
|
+
if (!ensured.ok) {
|
|
1498
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1313
1499
|
return;
|
|
1500
|
+
}
|
|
1314
1501
|
try {
|
|
1315
1502
|
if ((req.method ?? "GET").toUpperCase() === "GET") {
|
|
1503
|
+
let models;
|
|
1504
|
+
try {
|
|
1505
|
+
models = await deps.runtime.listModels(sessionId);
|
|
1506
|
+
}
|
|
1507
|
+
catch (error) {
|
|
1508
|
+
// AC-06: a model-list failure is a STRUCTURED runtime error — never
|
|
1509
|
+
// folded into a fake empty list.
|
|
1510
|
+
sendJson(res, 503, {
|
|
1511
|
+
ok: false,
|
|
1512
|
+
error: {
|
|
1513
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1514
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1515
|
+
},
|
|
1516
|
+
});
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1316
1519
|
sendJson(res, 200, {
|
|
1317
1520
|
ok: true,
|
|
1318
|
-
models:
|
|
1521
|
+
models: models.map((model) => ({
|
|
1522
|
+
provider: model.provider,
|
|
1523
|
+
id: model.id ?? model.modelId ?? "",
|
|
1524
|
+
...(model.name ? { name: model.name } : {}),
|
|
1525
|
+
hasCredentials: model.hasCredentials,
|
|
1526
|
+
})),
|
|
1319
1527
|
thinkingLevels: THINKING_LEVELS,
|
|
1320
1528
|
});
|
|
1321
1529
|
return;
|
|
1322
1530
|
}
|
|
1323
1531
|
const gate = gateMutation(req, deps);
|
|
1324
1532
|
if (!gate.ok) {
|
|
1325
|
-
sendJson(res, gate.status, {
|
|
1326
|
-
ok: false,
|
|
1327
|
-
error: { code: gate.code, message: gate.message },
|
|
1328
|
-
});
|
|
1533
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1329
1534
|
return;
|
|
1330
1535
|
}
|
|
1331
1536
|
const body = await readJsonBody(req);
|
|
1332
1537
|
let updated;
|
|
1333
1538
|
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
|
-
});
|
|
1539
|
+
const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
|
|
1540
|
+
updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
|
|
1342
1541
|
}
|
|
1343
|
-
else if (typeof body.thinkingLevel === "string" &&
|
|
1344
|
-
THINKING_LEVELS.includes(body.thinkingLevel)) {
|
|
1542
|
+
else if (typeof body.thinkingLevel === "string" && (body.thinkingLevel === "auto" || THINKING_LEVELS.includes(body.thinkingLevel))) {
|
|
1345
1543
|
const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
|
|
1346
|
-
updated = await deps.store.setRuntimeSelection(sessionId, {
|
|
1347
|
-
thinkingLevel,
|
|
1348
|
-
});
|
|
1544
|
+
updated = await deps.store.setRuntimeSelection(sessionId, thinkingLevel === "auto" ? {} : { thinkingLevel });
|
|
1349
1545
|
}
|
|
1350
1546
|
else {
|
|
1351
|
-
sendJson(res, 400, {
|
|
1352
|
-
ok: false,
|
|
1353
|
-
error: {
|
|
1354
|
-
code: "INVALID_INPUT",
|
|
1355
|
-
message: "provider/modelId or thinkingLevel is required",
|
|
1356
|
-
},
|
|
1357
|
-
});
|
|
1547
|
+
sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
|
|
1358
1548
|
return;
|
|
1359
1549
|
}
|
|
1360
|
-
|
|
1550
|
+
// Design §5: mutations return the fresh snapshot so the browser can
|
|
1551
|
+
// atomically replace its state (revision increments server-side).
|
|
1552
|
+
// RF-02: the mutation may already be applied server-side, but a failed
|
|
1553
|
+
// snapshot read must surface as 503 PI_RUNTIME_UNAVAILABLE — NOT be
|
|
1554
|
+
// folded into a 409 MODEL_SWITCH_FAILED (the frontend refresh path
|
|
1555
|
+
// re-reads the snapshot to converge on the applied selection).
|
|
1556
|
+
const snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1557
|
+
sendJson(res, 200, { ok: true, session: updated, snapshot });
|
|
1361
1558
|
}
|
|
1362
1559
|
catch (error) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
code:
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
}
|
|
1560
|
+
if (error instanceof RuntimeSnapshotUnavailableError) {
|
|
1561
|
+
sendJson(res, 503, {
|
|
1562
|
+
ok: false,
|
|
1563
|
+
error: { code: error.code, message: error.message },
|
|
1564
|
+
});
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
|
|
1370
1568
|
}
|
|
1371
1569
|
}
|
|
1372
1570
|
async function requireChatSession(res, deps, sessionId) {
|
|
@@ -1381,6 +1579,144 @@ async function requireChatSession(res, deps, sessionId) {
|
|
|
1381
1579
|
});
|
|
1382
1580
|
return false;
|
|
1383
1581
|
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Unified session activation (design §2.1 / AC-01): models, runtime snapshot,
|
|
1584
|
+
* reload and model/thinking mutation requests all flow through this path.
|
|
1585
|
+
*
|
|
1586
|
+
* - runtime already holds the session → ok;
|
|
1587
|
+
* - only a persisted record exists → reopen from record.sessionFile;
|
|
1588
|
+
* - no record / missing session file → 404 PI_SESSION_NOT_FOUND;
|
|
1589
|
+
* - SDK reopen failure → 503 PI_RUNTIME_UNAVAILABLE.
|
|
1590
|
+
*
|
|
1591
|
+
* Never fabricates a fresh session or an empty model list.
|
|
1592
|
+
*/
|
|
1593
|
+
export async function ensureActiveSession(deps, sessionId) {
|
|
1594
|
+
// Wait for create-path materialization before treating the session as missing.
|
|
1595
|
+
if (typeof deps.runtime.ensureSessionReady === "function") {
|
|
1596
|
+
try {
|
|
1597
|
+
await deps.runtime.ensureSessionReady(sessionId);
|
|
1598
|
+
if (deps.runtime.hasSession(sessionId))
|
|
1599
|
+
return { ok: true };
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
const code = error && typeof error === "object" && "code" in error
|
|
1603
|
+
? String(error.code ?? "")
|
|
1604
|
+
: "";
|
|
1605
|
+
if (code === "PI_SESSION_INIT_FAILED") {
|
|
1606
|
+
return {
|
|
1607
|
+
ok: false,
|
|
1608
|
+
status: 503,
|
|
1609
|
+
code: "PI_SESSION_INIT_FAILED",
|
|
1610
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
// Not initializing / not found — fall through to reopen path.
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (deps.runtime.hasSession(sessionId))
|
|
1617
|
+
return { ok: true };
|
|
1618
|
+
const record = await deps.store.get(sessionId);
|
|
1619
|
+
if (!record) {
|
|
1620
|
+
return {
|
|
1621
|
+
ok: false,
|
|
1622
|
+
status: 404,
|
|
1623
|
+
code: "PI_SESSION_NOT_FOUND",
|
|
1624
|
+
message: `chat session not found: ${sessionId}`,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
const reopened = await deps.runtime.reopenSession({
|
|
1628
|
+
sessionId,
|
|
1629
|
+
sessionFile: record.sessionFile,
|
|
1630
|
+
...(record.modelProvider && record.modelId
|
|
1631
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
1632
|
+
: {}),
|
|
1633
|
+
});
|
|
1634
|
+
if (!reopened.ok) {
|
|
1635
|
+
return { ok: false, ...mapReopenSessionFailure(reopened) };
|
|
1636
|
+
}
|
|
1637
|
+
return { ok: true };
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
|
|
1641
|
+
* structured busy/failure semantics. Never aborts, never silently queues.
|
|
1642
|
+
*/
|
|
1643
|
+
export async function handleReloadSession(req, res, deps, sessionId) {
|
|
1644
|
+
const gate = gateMutation(req, deps);
|
|
1645
|
+
if (!gate.ok) {
|
|
1646
|
+
sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1650
|
+
if (!ensured.ok) {
|
|
1651
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const result = await deps.runtime.reloadSession(sessionId);
|
|
1655
|
+
if (!result.ok) {
|
|
1656
|
+
const status = result.code === "PI_SESSION_NOT_FOUND"
|
|
1657
|
+
? 404
|
|
1658
|
+
: result.code === "PI_SESSION_BUSY"
|
|
1659
|
+
? 409
|
|
1660
|
+
: result.code === "PI_SESSION_INIT_FAILED"
|
|
1661
|
+
? 503
|
|
1662
|
+
: 500;
|
|
1663
|
+
sendJson(res, status, { ok: false, error: { code: result.code, message: result.message } });
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
// RF-02: the reload succeeded, but a failed snapshot read is still a
|
|
1667
|
+
// structured 503 — never a 200 with fake-empty resources.
|
|
1668
|
+
let snapshot;
|
|
1669
|
+
try {
|
|
1670
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1671
|
+
}
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1674
|
+
return;
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
sendJson(res, 200, {
|
|
1678
|
+
ok: true,
|
|
1679
|
+
revision: result.revision,
|
|
1680
|
+
snapshot,
|
|
1681
|
+
resourceCounts: snapshot
|
|
1682
|
+
? {
|
|
1683
|
+
skills: snapshot.skills.length,
|
|
1684
|
+
extensions: snapshot.extensions.length,
|
|
1685
|
+
packages: snapshot.packages.length,
|
|
1686
|
+
contextFiles: snapshot.contextFiles.length,
|
|
1687
|
+
}
|
|
1688
|
+
: undefined,
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* GET /sessions/:id/runtime-snapshot (AC-01): the unified live snapshot.
|
|
1693
|
+
*/
|
|
1694
|
+
export async function handleRuntimeSnapshot(_res, res, deps, sessionId) {
|
|
1695
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1696
|
+
if (!ensured.ok) {
|
|
1697
|
+
sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
// RF-02: a critical snapshot projection failure is 503 PI_RUNTIME_UNAVAILABLE
|
|
1701
|
+
// — never a 200 fake-empty snapshot.
|
|
1702
|
+
let snapshot;
|
|
1703
|
+
try {
|
|
1704
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1708
|
+
return;
|
|
1709
|
+
throw error;
|
|
1710
|
+
}
|
|
1711
|
+
if (!snapshot) {
|
|
1712
|
+
sendJson(res, 503, {
|
|
1713
|
+
ok: false,
|
|
1714
|
+
error: { code: "PI_RUNTIME_UNAVAILABLE", message: `chat session not active: ${sessionId}` },
|
|
1715
|
+
});
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
sendJson(res, 200, { ok: true, snapshot });
|
|
1719
|
+
}
|
|
1384
1720
|
async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
|
|
1385
1721
|
const gate = gateMutation(req, deps);
|
|
1386
1722
|
if (!gate.ok) {
|
|
@@ -2060,9 +2396,13 @@ export async function handleChatCompact(req, res, deps, sessionId) {
|
|
|
2060
2396
|
: {}),
|
|
2061
2397
|
});
|
|
2062
2398
|
if (!reopened.ok) {
|
|
2063
|
-
|
|
2399
|
+
const mapped = mapReopenSessionFailure({
|
|
2400
|
+
code: reopened.code,
|
|
2401
|
+
message: reopened.message,
|
|
2402
|
+
});
|
|
2403
|
+
sendJson(res, mapped.status, {
|
|
2064
2404
|
ok: false,
|
|
2065
|
-
error: { code:
|
|
2405
|
+
error: { code: mapped.code, message: mapped.message },
|
|
2066
2406
|
});
|
|
2067
2407
|
return;
|
|
2068
2408
|
}
|
|
@@ -2153,16 +2493,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2153
2493
|
await handlePiConfig(req, res, deps, "packages");
|
|
2154
2494
|
return true;
|
|
2155
2495
|
}
|
|
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
2496
|
if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
|
|
2167
2497
|
await handleChatCapabilities(req, res, deps);
|
|
2168
2498
|
return true;
|
|
@@ -2221,7 +2551,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2221
2551
|
await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
|
|
2222
2552
|
return true;
|
|
2223
2553
|
}
|
|
2224
|
-
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
|
|
2554
|
+
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
|
|
2225
2555
|
if (m4Route) {
|
|
2226
2556
|
const id = decodeURIComponent(m4Route[1]);
|
|
2227
2557
|
const route = m4Route[2];
|
|
@@ -2237,6 +2567,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2237
2567
|
await handleRuntimeContext(res, deps, id);
|
|
2238
2568
|
return true;
|
|
2239
2569
|
}
|
|
2570
|
+
if (route === "runtime-snapshot" && method === "GET") {
|
|
2571
|
+
await handleRuntimeSnapshot(req, res, deps, id);
|
|
2572
|
+
return true;
|
|
2573
|
+
}
|
|
2240
2574
|
if (route === "models" && method === "GET") {
|
|
2241
2575
|
await handleModels(req, res, deps, id);
|
|
2242
2576
|
return true;
|
|
@@ -2245,6 +2579,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2245
2579
|
await handleModels(req, res, deps, id);
|
|
2246
2580
|
return true;
|
|
2247
2581
|
}
|
|
2582
|
+
if (route === "reload" && method === "POST") {
|
|
2583
|
+
await handleReloadSession(req, res, deps, id);
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2248
2586
|
}
|
|
2249
2587
|
const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
|
|
2250
2588
|
if (method === "POST" && sessionMainline) {
|