@tea-agent/loop-agent 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scrubSecrets } from "./explore-tools.js";
3
+ function summary(text, max = 320) {
4
+ if (!text)
5
+ return undefined;
6
+ return scrubSecrets(text).scrubbed
7
+ .replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
8
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED]")
9
+ .slice(0, max);
10
+ }
11
+ export function projectRuntimeContext(input) {
12
+ return {
13
+ readOnly: true,
14
+ systemPrompt: {
15
+ hash: createHash("sha256").update(input.systemPrompt).digest("hex"),
16
+ summary: summary(input.systemPrompt),
17
+ },
18
+ skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
19
+ resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: false, activeToolCount: input.activeTools.length },
20
+ model: input.model,
21
+ thinkingLevel: input.thinkingLevel,
22
+ suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
23
+ };
24
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure runtime-selection helpers shared by the Operator Chat UI and tests.
3
+ *
4
+ * These functions encode the merge/match semantics between persisted session
5
+ * runtime state (model + thinking level) and the read-only runtime context
6
+ * snapshot. They are intentionally side-effect-free so they can be unit-tested
7
+ * in a plain Node environment (the React component owns the network race
8
+ * guards; these helpers only define what "matches" and "merged" mean).
9
+ */
10
+ /** Format a model reference for `<option value>` keys and equality checks. */
11
+ export function formatModelRef(provider, modelId) {
12
+ return `${provider}/${modelId}`;
13
+ }
14
+ /** Normalize a server record into the optional model/thinking patch fields. */
15
+ export function runtimeSelectionFromRecord(record) {
16
+ return {
17
+ ...(record.modelProvider && record.modelId
18
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
19
+ : {}),
20
+ ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel } : {}),
21
+ };
22
+ }
23
+ /** Merge a patch onto a session, preferring patch values and keeping the id. */
24
+ export function mergeSessionRuntime(current, patch) {
25
+ return {
26
+ ...current,
27
+ model: patch.model ?? current.model,
28
+ thinkingLevel: patch.thinkingLevel ?? current.thinkingLevel,
29
+ };
30
+ }
31
+ /** True when `current` already carries the values in `patch`. */
32
+ export function sessionRuntimeMatches(current, patch) {
33
+ const next = mergeSessionRuntime(current, patch);
34
+ return (next.model?.provider === current.model?.provider &&
35
+ next.model?.modelId === current.model?.modelId &&
36
+ next.thinkingLevel === current.thinkingLevel);
37
+ }
@@ -11,9 +11,12 @@
11
11
  * operator action call. Every invocation is re-authorized here.
12
12
  */
13
13
  import path from "node:path";
14
+ import { readdir, rm } from "node:fs/promises";
14
15
  import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
15
16
  import { dispatchOperatorAction } from "../operator-actions.js";
16
17
  import { authorizeOperatorChatTool } from "./tools.js";
18
+ import { projectOperationForChat } from "./chat-event-store.js";
19
+ import { scrubSecrets } from "./explore-tools.js";
17
20
  function recordPath(appData, sessionId) {
18
21
  return path.join(appData.chats, `${sessionId}.json`);
19
22
  }
@@ -28,8 +31,60 @@ export class ChatSessionStore {
28
31
  constructor(appData) {
29
32
  this.appData = appData;
30
33
  }
34
+ /** Read + migrate legacy V1 records into the V2 in-memory shape. */
31
35
  async get(sessionId) {
32
- return readJsonIfExists(recordPath(this.appData, sessionId));
36
+ const raw = await readJsonIfExists(recordPath(this.appData, sessionId));
37
+ if (!raw || raw.sessionId !== sessionId)
38
+ return undefined;
39
+ return {
40
+ schemaVersion: 2,
41
+ sessionId,
42
+ repoFingerprint: raw.repoFingerprint ?? this.appData.fingerprint,
43
+ repoRootDisplay: raw.repoRootDisplay ?? this.appData.repoRoot,
44
+ state: raw.state === "archived" ? "archived" : "active",
45
+ ...(typeof raw.title === "string" ? { title: raw.title } : {}),
46
+ ...(typeof raw.sessionFile === "string" ? { sessionFile: raw.sessionFile } : {}),
47
+ createdAt: raw.createdAt ?? new Date(0).toISOString(),
48
+ updatedAt: raw.updatedAt ?? raw.createdAt ?? new Date(0).toISOString(),
49
+ ...(typeof raw.modelProvider === "string" ? { modelProvider: raw.modelProvider } : {}),
50
+ ...(typeof raw.modelId === "string" ? { modelId: raw.modelId } : {}),
51
+ ...(typeof raw.thinkingLevel === "string" ? { thinkingLevel: raw.thinkingLevel } : {}),
52
+ messages: Array.isArray(raw.messages) ? raw.messages : [],
53
+ toolInvocations: Array.isArray(raw.toolInvocations) ? raw.toolInvocations : [],
54
+ };
55
+ }
56
+ async listMessages(sessionId, options = {}) {
57
+ const record = await this.get(sessionId);
58
+ if (!record)
59
+ throw new Error(`chat session not found: ${sessionId}`);
60
+ const limit = Math.max(1, Math.min(50, Math.floor(options.limit ?? 30)));
61
+ const beforeIndex = options.beforeId
62
+ ? record.messages.findIndex((message) => message.id === options.beforeId)
63
+ : record.messages.length;
64
+ const end = beforeIndex < 0 ? record.messages.length : beforeIndex;
65
+ const start = Math.max(0, end - limit);
66
+ const messages = record.messages.slice(start, end);
67
+ return {
68
+ messages,
69
+ hasMore: start > 0,
70
+ ...(messages[0] ? { nextCursor: messages[0].id } : {}),
71
+ };
72
+ }
73
+ /** List repo-bound sessions, newest first. Deleted records are absent. */
74
+ async list(options) {
75
+ let names;
76
+ try {
77
+ names = await readdir(this.appData.chats);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const records = await Promise.all(names
83
+ .filter((name) => name.endsWith(".json"))
84
+ .map((name) => this.get(name.slice(0, -".json".length))));
85
+ return records
86
+ .filter((record) => Boolean(record) && (options?.includeArchived || record.state === "active"))
87
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
33
88
  }
34
89
  /** Serialize a mutating op per session. */
35
90
  serialize(sessionId, op) {
@@ -44,8 +99,12 @@ export class ChatSessionStore {
44
99
  return this.serialize(input.sessionId, async () => {
45
100
  const now = new Date().toISOString();
46
101
  const record = {
47
- schemaVersion: 1,
102
+ schemaVersion: 2,
48
103
  sessionId: input.sessionId,
104
+ repoFingerprint: this.appData.fingerprint,
105
+ repoRootDisplay: this.appData.repoRoot,
106
+ state: "active",
107
+ ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
49
108
  createdAt: now,
50
109
  updatedAt: now,
51
110
  modelProvider: input.modelProvider,
@@ -57,6 +116,73 @@ export class ChatSessionStore {
57
116
  return record;
58
117
  });
59
118
  }
119
+ async forkFrom(input) {
120
+ const source = await this.get(input.sourceSessionId);
121
+ if (!source)
122
+ throw new Error(`chat session not found: ${input.sourceSessionId}`);
123
+ return this.serialize(input.targetSessionId, async () => {
124
+ const now = new Date().toISOString();
125
+ const record = {
126
+ ...source,
127
+ sessionId: input.targetSessionId,
128
+ state: "active",
129
+ ...(input.targetSessionFile ? { sessionFile: input.targetSessionFile } : { sessionFile: undefined }),
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ messages: structuredClone(source.messages),
133
+ toolInvocations: structuredClone(source.toolInvocations),
134
+ };
135
+ await writeSecureJson(recordPath(this.appData, input.targetSessionId), record);
136
+ return record;
137
+ });
138
+ }
139
+ async rename(sessionId, title) {
140
+ return this.serialize(sessionId, async () => {
141
+ const record = await this.get(sessionId);
142
+ if (!record)
143
+ throw new Error(`chat session not found: ${sessionId}`);
144
+ record.title = title.trim().slice(0, 160) || undefined;
145
+ record.updatedAt = new Date().toISOString();
146
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
147
+ return record;
148
+ });
149
+ }
150
+ async setRuntimeSelection(sessionId, selection) {
151
+ return this.serialize(sessionId, async () => {
152
+ const record = await this.get(sessionId);
153
+ if (!record)
154
+ throw new Error(`chat session not found: ${sessionId}`);
155
+ if (selection.modelProvider !== undefined)
156
+ record.modelProvider = selection.modelProvider;
157
+ if (selection.modelId !== undefined)
158
+ record.modelId = selection.modelId;
159
+ if (selection.thinkingLevel !== undefined)
160
+ record.thinkingLevel = selection.thinkingLevel;
161
+ record.updatedAt = new Date().toISOString();
162
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
163
+ return record;
164
+ });
165
+ }
166
+ async setState(sessionId, state) {
167
+ return this.serialize(sessionId, async () => {
168
+ const record = await this.get(sessionId);
169
+ if (!record)
170
+ throw new Error(`chat session not found: ${sessionId}`);
171
+ record.state = state;
172
+ record.updatedAt = new Date().toISOString();
173
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
174
+ return record;
175
+ });
176
+ }
177
+ async remove(sessionId) {
178
+ return this.serialize(sessionId, async () => {
179
+ const record = await this.get(sessionId);
180
+ if (!record)
181
+ return false;
182
+ await rm(recordPath(this.appData, sessionId), { force: true });
183
+ return true;
184
+ });
185
+ }
60
186
  async appendMessage(sessionId, message) {
61
187
  return this.serialize(sessionId, async () => {
62
188
  const record = await this.get(sessionId);
@@ -94,6 +220,16 @@ export class ChatSessionStore {
94
220
  * High-risk actions (contractApply / runDag / etc.) are NOT registered as
95
221
  * tools, so they never reach here; but we still deny defensively.
96
222
  */
223
+ function redactHumanGateToken(value) {
224
+ const clone = structuredClone(value);
225
+ const confirmation = clone.confirmation;
226
+ if (confirmation && typeof confirmation === "object") {
227
+ delete confirmation.humanGateToken;
228
+ delete confirmation.dagBytesPath;
229
+ }
230
+ delete clone.humanGateToken;
231
+ return clone;
232
+ }
97
233
  export async function dispatchChatToolCall(ctx, toolName, args, clientRequestId) {
98
234
  const decision = authorizeOperatorChatTool(toolName);
99
235
  if (!decision.ok) {
@@ -110,25 +246,31 @@ export async function dispatchChatToolCall(ctx, toolName, args, clientRequestId)
110
246
  clientRequestId,
111
247
  });
112
248
  if (result.kind === "accepted") {
113
- // Long-running is not in the whitelist, but accepted ops (none currently)
114
- // surface a pointer rather than inline data.
249
+ const canonical = await ctx.operations.get(result.body.operationId);
115
250
  return {
116
251
  ok: true,
117
252
  toolName: decision.toolId,
118
253
  result: {
254
+ schemaVersion: 1,
255
+ accepted: true,
119
256
  operationId: result.body.operationId,
120
257
  state: result.body.state,
121
- note: "operation accepted; poll /api/operator/v1/operations/:id",
258
+ action: result.body.action,
259
+ ...(canonical ? { operation: projectOperationForChat(canonical) } : {}),
260
+ note: "operation accepted; a durable Chat operation reference will follow canonical status",
122
261
  },
123
262
  };
124
263
  }
125
264
  const body = result.body;
126
265
  const ok = body.ok === true;
127
266
  if (ok) {
267
+ const raw = body.result ?? body;
128
268
  return {
129
269
  ok: true,
130
270
  toolName: decision.toolId,
131
- result: body.result ?? body,
271
+ result: decision.toolId === "prepareDagConfirmation" || decision.toolId === "prepareMutationGate"
272
+ ? redactHumanGateToken(raw)
273
+ : raw,
132
274
  };
133
275
  }
134
276
  const error = body.error ?? {};
@@ -168,9 +310,18 @@ export async function runChatTurn(options) {
168
310
  // chat history / audit trail is durably written (also makes the outcome
169
311
  // observable to tests and to a reconnecting client reading the record).
170
312
  const pending = [];
313
+ let settledEvent;
171
314
  try {
172
315
  await runtime.prompt(sessionId, text, (event) => {
173
- options.onEvent?.(event);
316
+ if (event.type === "agent_settled") {
317
+ // The durable operation-ref linker may perform an async canonical
318
+ // operation read. Hold settled until those writes finish so replay
319
+ // never observes agent_settled before its accepted operation ref.
320
+ settledEvent = event;
321
+ }
322
+ else {
323
+ options.onEvent?.(event);
324
+ }
174
325
  switch (event.type) {
175
326
  case "message_update":
176
327
  case "message_end":
@@ -188,6 +339,15 @@ export async function runChatTurn(options) {
188
339
  // that would double-execute every tool call (P0.2).
189
340
  break;
190
341
  case "tool_result": {
342
+ const acceptedOperationId = acceptedOperationIdFromResult(event.toolName, event.result);
343
+ if (acceptedOperationId && options.onOperationAccepted) {
344
+ pending.push(Promise.resolve(options.onOperationAccepted({
345
+ sessionId,
346
+ toolCallId: event.toolCallId,
347
+ toolName: event.toolName,
348
+ operationId: acceptedOperationId,
349
+ })));
350
+ }
191
351
  // tool_execution_end carries the real result of the tool call.
192
352
  // Persist exactly once (the tool message + audit trail). This is
193
353
  // the single point of record for tool outcomes (P0.2).
@@ -212,7 +372,7 @@ export async function runChatTurn(options) {
212
372
  turnError = { code: "CHAT_TURN_ERROR", message: event.message };
213
373
  break;
214
374
  }
215
- }, { signal: options.signal });
375
+ }, { signal: options.signal, images: options.images });
216
376
  }
217
377
  catch (error) {
218
378
  await Promise.allSettled(pending);
@@ -220,6 +380,8 @@ export async function runChatTurn(options) {
220
380
  return { ok: false, error: { code: "CHAT_TURN_FAILED", message } };
221
381
  }
222
382
  await Promise.allSettled(pending);
383
+ if (settledEvent)
384
+ options.onEvent?.(settledEvent);
223
385
  return { ok: !turnError, error: turnError };
224
386
  }
225
387
  /**
@@ -227,12 +389,49 @@ export async function runChatTurn(options) {
227
389
  * huge operator-action payload (e.g. a full dagReport) cannot bloat the chat
228
390
  * history record.
229
391
  */
392
+ function acceptedOperationIdFromResult(toolName, result) {
393
+ const candidates = [result];
394
+ if (result && typeof result === "object") {
395
+ const record = result;
396
+ candidates.push(record.details);
397
+ if (Array.isArray(record.content)) {
398
+ for (const item of record.content) {
399
+ if (!item || typeof item !== "object")
400
+ continue;
401
+ const text = item.text;
402
+ if (typeof text !== "string" || text.length > 16_000)
403
+ continue;
404
+ try {
405
+ candidates.push(JSON.parse(text));
406
+ }
407
+ catch {
408
+ // Only the controlled JSON tool-result envelope is accepted.
409
+ }
410
+ }
411
+ }
412
+ }
413
+ for (const candidate of candidates) {
414
+ if (!candidate || typeof candidate !== "object")
415
+ continue;
416
+ const accepted = candidate;
417
+ if (accepted.schemaVersion === 1 &&
418
+ accepted.accepted === true &&
419
+ accepted.action === toolName &&
420
+ typeof accepted.operationId === "string" &&
421
+ accepted.operationId.trim()) {
422
+ return accepted.operationId.trim();
423
+ }
424
+ }
425
+ return undefined;
426
+ }
230
427
  function safeStringifyResult(result) {
231
428
  try {
232
- const str = JSON.stringify(result ?? {});
233
- return str.length > 4000 ? `${str.slice(0, 4000)}…[truncated]` : str;
429
+ const raw = JSON.stringify(result ?? {});
430
+ const { scrubbed } = scrubSecrets(raw);
431
+ return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
234
432
  }
235
433
  catch {
236
- return String(result);
434
+ const { scrubbed } = scrubSecrets(String(result));
435
+ return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
237
436
  }
238
437
  }
@@ -0,0 +1,15 @@
1
+ export const SHORTCUTS = [
2
+ { command: "/task", label: "Task context", description: "查看当前任务、Draft 与 assessment", action: "focus-panel:task" },
3
+ { command: "/contract", label: "Contract", description: "查看 contract diff 与 apply gate", action: "focus-panel:contract" },
4
+ { command: "/dag", label: "DAG", description: "查看 DAG spine、确认与运行状态", action: "focus-panel:dag" },
5
+ { command: "/interview", label: "Interview", description: "打开 Requirement Interview", action: "focus:interview" },
6
+ { command: "/help", label: "Shortcuts", description: "显示可用 shortcut", action: "show-help" },
7
+ ];
8
+ /** Pure navigation/display resolver. It deliberately has no mutation decisions. */
9
+ export function resolveShortcut(input) {
10
+ const token = input.trim().split(/\s+/, 1)[0]?.toLowerCase();
11
+ if (!token?.startsWith("/"))
12
+ return undefined;
13
+ const found = SHORTCUTS.find((shortcut) => shortcut.command === token);
14
+ return found ? { command: found.command, action: found.action } : { command: token, action: "unknown" };
15
+ }