@xingular/harness 0.1.7 → 2.1.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.
- package/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +8 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -11,9 +11,10 @@ import type { ToolSession } from '../tools/types'
|
|
|
11
11
|
import * as blocks from './blocks'
|
|
12
12
|
import { memoryEdit, propose } from './api'
|
|
13
13
|
import { enqueueCuration } from './curation'
|
|
14
|
-
import { findMemory } from './facts'
|
|
14
|
+
import { findMemory, saveAsUser } from './facts'
|
|
15
15
|
import * as registry from '../skills/registry'
|
|
16
16
|
import * as attachments from '../plugins/attachments'
|
|
17
|
+
import { listInbox } from './operations'
|
|
17
18
|
import { wikiDir } from './store'
|
|
18
19
|
|
|
19
20
|
const opened: Harness[] = []
|
|
@@ -57,6 +58,48 @@ test('a shared fact is proposed without writing, then validated against its orig
|
|
|
57
58
|
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
|
|
58
59
|
})
|
|
59
60
|
|
|
61
|
+
test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
|
|
62
|
+
const h = setup(); await origin(h)
|
|
63
|
+
propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
64
|
+
propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
65
|
+
const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
|
|
66
|
+
const batchCurator = { ...curator, maintenanceJobIds: ids }
|
|
67
|
+
expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
|
|
68
|
+
|
|
69
|
+
const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
|
|
70
|
+
pages: [],
|
|
71
|
+
memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
72
|
+
blocks: [],
|
|
73
|
+
flags: []
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(applied.operationId).toMatch(/^batch-/)
|
|
77
|
+
expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
|
|
78
|
+
expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
|
|
79
|
+
expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
|
|
80
|
+
expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a batch parks updates whose captured Wiki target changed before review', async () => {
|
|
84
|
+
const h = setup(); await origin(h)
|
|
85
|
+
const wiki = wikiDir(h.env.root)
|
|
86
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
|
|
87
|
+
propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
88
|
+
const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
|
|
89
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
|
|
90
|
+
|
|
91
|
+
const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
|
|
92
|
+
pages: [],
|
|
93
|
+
memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
94
|
+
blocks: [],
|
|
95
|
+
flags: []
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(result.jobs[0].status).toBe('needs_user')
|
|
99
|
+
expect(result.operationId).toBeNull()
|
|
100
|
+
expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
|
|
101
|
+
})
|
|
102
|
+
|
|
60
103
|
test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
|
|
61
104
|
const h = setup(); await origin(h)
|
|
62
105
|
propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
|
package/src/memory/dispatch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { launchText, load, render } from '../prompt/compile'
|
|
|
7
7
|
import * as api from './api'
|
|
8
8
|
import type { CallerContext } from './caller'
|
|
9
9
|
import * as recall from './recall'
|
|
10
|
+
import * as trajectory from './trajectory'
|
|
10
11
|
import { reindexQuietly } from './wikiIndex'
|
|
11
12
|
|
|
12
13
|
export type DispatchEnv = HarnessEnv & { bus?: Bus }
|
|
@@ -22,6 +23,10 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
|
|
|
22
23
|
return recall.searchTool(env, context, params)
|
|
23
24
|
case 'recall.read':
|
|
24
25
|
return recall.readTool(env, context, params)
|
|
26
|
+
case 'trajectory.search':
|
|
27
|
+
return trajectory.searchTool(env, context, params)
|
|
28
|
+
case 'trajectory.read':
|
|
29
|
+
return trajectory.readTool(env, context, params)
|
|
25
30
|
case 'memory.append':
|
|
26
31
|
case 'memory.replace':
|
|
27
32
|
case 'memory.rethink': {
|
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
5
5
|
|
|
6
6
|
import { memorySecretStore } from '../accounts/secrets'
|
|
7
7
|
import { writeJson } from '../core/fs'
|
|
8
|
-
import type
|
|
8
|
+
import { setLogSink, type Notice } from '../core/bus'
|
|
9
9
|
import { createHarness, type Harness } from '../harness'
|
|
10
10
|
import type { PiSessionLike } from '../sessions/runner'
|
|
11
11
|
import * as blocks from './blocks'
|
|
@@ -132,6 +132,22 @@ describe('the memory lifecycle', () => {
|
|
|
132
132
|
expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('a code run outside any project is not maintained, and it leaves no log line', async () => {
|
|
136
|
+
const lines: string[] = []
|
|
137
|
+
setLogSink((text) => void lines.push(text))
|
|
138
|
+
try {
|
|
139
|
+
let jobs = 0
|
|
140
|
+
const { harness } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
|
|
141
|
+
await harness.sessions.run({ run: { id: 'code-1', sessionId: 'nora', mode: 'code' },cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Code' }, tools: { allow: [] } })
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
143
|
+
await harness.maintenance.settled()
|
|
144
|
+
expect(jobs).toBe(0)
|
|
145
|
+
expect(lines.filter((line) => line.includes('maintenance'))).toEqual([])
|
|
146
|
+
} finally {
|
|
147
|
+
setLogSink(null)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
135
151
|
it('an imported source is read once and its proposals are checked against its text', async () => {
|
|
136
152
|
const { harness, wiki } = setup(async ({ kind, collect }) => ({
|
|
137
153
|
prompt: async () => {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// their limit are condensed.
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
8
9
|
|
|
9
10
|
import type { Accounts } from '../accounts/accounts'
|
|
10
11
|
import { log, type Bus } from '../core/bus'
|
|
@@ -20,7 +21,7 @@ import { callerBlocks, type CallerContext } from './caller'
|
|
|
20
21
|
import * as facts from './facts'
|
|
21
22
|
import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingMemory, type ExistingPage, type MaintainerFactory, type MaintenanceRequest } from './maintainer'
|
|
22
23
|
import { inbox, undo } from './operations'
|
|
23
|
-
import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
|
|
24
|
+
import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type ProposalSet, type SourceRef, type Trigger } from './proposals'
|
|
24
25
|
import { looksSecret } from './secrets'
|
|
25
26
|
import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
|
|
26
27
|
import * as registry from '../skills/registry'
|
|
@@ -46,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
|
|
|
46
47
|
return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** A run that automatic maintenance leaves alone for a settled reason: a setting, a pause, or no project. Not an error. */
|
|
51
|
+
class NotEligible extends Error {}
|
|
52
|
+
|
|
49
53
|
/** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
|
|
50
54
|
function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
|
|
51
55
|
const run = findRun(root, runId)
|
|
52
56
|
if (!run) throw new Error('Run was not found.')
|
|
53
57
|
const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
|
|
54
|
-
if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new
|
|
58
|
+
if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new NotEligible('run excluded in settings')
|
|
55
59
|
const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
|
|
56
60
|
let namespace: string
|
|
57
61
|
if (projectId) {
|
|
58
62
|
const project = findProject(root, projectId)
|
|
59
|
-
if (!project.autoMaintenance) throw new
|
|
63
|
+
if (!project.autoMaintenance) throw new NotEligible('automatic updates are paused for this project')
|
|
60
64
|
namespace = project.wikiNamespace
|
|
61
65
|
} else if (run.mode === 'chat') {
|
|
62
|
-
if (chatUpdatesPaused(settings)) throw new
|
|
66
|
+
if (chatUpdatesPaused(settings)) throw new NotEligible('automatic updates are paused for Chat')
|
|
63
67
|
namespace = store.LIBRARY_NAMESPACE
|
|
64
68
|
} else {
|
|
65
|
-
throw new
|
|
69
|
+
throw new NotEligible('run has no project')
|
|
66
70
|
}
|
|
67
71
|
const text = transcript(run.goal ?? '', readEvents(root, runId))
|
|
68
|
-
if (!text.includes('Assistant:')) throw new
|
|
69
|
-
if (looksSecret(text)) throw new
|
|
72
|
+
if (!text.includes('Assistant:')) throw new NotEligible('no recorded assistant output to use as evidence')
|
|
73
|
+
if (looksSecret(text)) throw new NotEligible('the run contains text that looks like a credential')
|
|
70
74
|
return { namespace, transcript: text }
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -144,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
|
|
|
144
148
|
|
|
145
149
|
const blockValues = (found: readonly blocks.Block[]): ExistingBlock[] => found.map((block) => ({ id: block.id, label: block.front.label, description: block.front.description, limit: block.front.limit, body: block.body }))
|
|
146
150
|
|
|
151
|
+
export interface MaintenanceBatchDecision {
|
|
152
|
+
id: string
|
|
153
|
+
decision: 'apply' | 'reject' | 'needs_user'
|
|
154
|
+
reason: string
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
export interface MaintenanceOptions {
|
|
148
158
|
env: HarnessEnv
|
|
149
159
|
bus: Bus
|
|
@@ -208,7 +218,12 @@ export class Maintenance {
|
|
|
208
218
|
if (this.idle) clearTimeout(this.idle)
|
|
209
219
|
if (this.options.external) {
|
|
210
220
|
if (job.kind === 'run') {
|
|
211
|
-
try {
|
|
221
|
+
try {
|
|
222
|
+
runEvidence(this.options.env.root, job.runId)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!(error instanceof NotEligible)) log('maintenance source skipped', error instanceof Error ? error.message : String(error))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
212
227
|
const run = findRun(this.options.env.root, job.runId)
|
|
213
228
|
const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
|
|
214
229
|
enqueueCuration(this.options.env.root, { ...job, namespace })
|
|
@@ -233,6 +248,150 @@ export class Maintenance {
|
|
|
233
248
|
|
|
234
249
|
jobs(): CurationJob[] { return listCuration(this.options.env.root) }
|
|
235
250
|
|
|
251
|
+
/** Reads every request frozen into the current host-assigned review batch. */
|
|
252
|
+
readBatch(context: CallerContext): { jobs: unknown[] } {
|
|
253
|
+
requireCurator(context)
|
|
254
|
+
const ids = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
|
|
255
|
+
return {
|
|
256
|
+
jobs: ids.map((id) => {
|
|
257
|
+
try {
|
|
258
|
+
return this.readJob(context, id)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return { id, status: 'failed', error: error instanceof Error ? error.message : String(error) }
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Applies one evidence-checked Wiki change set for all accepted Wiki requests in a batch. */
|
|
267
|
+
applyBatch(context: CallerContext, decisions: readonly MaintenanceBatchDecision[], replacement: unknown): { operationId: string | null; jobs: CurationJob[] } {
|
|
268
|
+
requireCurator(context)
|
|
269
|
+
if (!decisions.length) throw invalid('Decide every assigned request in the batch.')
|
|
270
|
+
const ids = decisions.map((decision) => decision.id)
|
|
271
|
+
if (new Set(ids).size !== ids.length) throw invalid('Each request can appear only once in a batch decision.')
|
|
272
|
+
const assigned = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
|
|
273
|
+
const pending = assigned.map((id) => curationJob(this.options.env.root, id)).filter((job) => !['completed', 'rejected', 'needs_user'].includes(job.status))
|
|
274
|
+
if (pending.some((job) => !ids.includes(job.id))) throw invalid('Decide every assigned request in one batch.')
|
|
275
|
+
|
|
276
|
+
const entries = decisions.map((decision) => {
|
|
277
|
+
if (!decision.reason.trim()) throw invalid('Explain every batch decision.')
|
|
278
|
+
if (!['apply', 'reject', 'needs_user'].includes(decision.decision)) throw invalid('Choose apply, reject, or needs_user for every request.')
|
|
279
|
+
const job = curationJob(this.options.env.root, decision.id)
|
|
280
|
+
if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(decision.id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
|
|
281
|
+
if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
|
|
282
|
+
return { decision, job }
|
|
283
|
+
})
|
|
284
|
+
const wikiEntries = entries.filter(({ decision, job }) => decision.decision === 'apply' && !['registry', 'plugin'].includes(job.input.kind) && !['completed', 'rejected', 'needs_user'].includes(job.status))
|
|
285
|
+
if (wikiEntries.length && replacement === undefined) throw invalid('Provide one combined, evidence-backed Wiki change set for the approved requests.')
|
|
286
|
+
const proposals: ProposalSet | null = wikiEntries.length ? parseProposalSet(replacement) : null
|
|
287
|
+
const operationId = wikiEntries.length ? `batch-${createHash('sha256').update(wikiEntries.map(({ job }) => job.id).sort().join('\0')).digest('hex').slice(0, 32)}` : null
|
|
288
|
+
const results = new Map<string, CurationJob>()
|
|
289
|
+
|
|
290
|
+
if (wikiEntries.length) {
|
|
291
|
+
const prepared = wikiEntries.map(({ job }) => this.claim(context, job.id))
|
|
292
|
+
const root = this.options.env.root
|
|
293
|
+
const wiki = store.wikiDir(root)
|
|
294
|
+
const evidence: Evidence = { runs: {}, sources: {}, blocks: [] }
|
|
295
|
+
const blocksSeen = new Set<string>()
|
|
296
|
+
const captured = new Map<string, string>()
|
|
297
|
+
const conflicts = new Set<string>()
|
|
298
|
+
for (const job of prepared) {
|
|
299
|
+
const input = job.input
|
|
300
|
+
for (const id of job.prepared?.evidence.blocks ?? (input.kind === 'proposal' ? input.proposals.blocks.map((proposal) => proposal.block) : [])) blocksSeen.add(id)
|
|
301
|
+
for (const [id, hash] of job.hashes) {
|
|
302
|
+
const previous = captured.get(id)
|
|
303
|
+
if (previous !== undefined && previous !== hash) conflicts.add(id)
|
|
304
|
+
else captured.set(id, hash)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const id of conflicts) captured.set(id, `conflicting-batch-snapshot:${id}`)
|
|
308
|
+
evidence.blocks = [...blocksSeen]
|
|
309
|
+
|
|
310
|
+
const refs: SourceRef[] = [...proposals!.pages, ...proposals!.memories, ...proposals!.blocks].flatMap((proposal) => proposal.sourceRefs)
|
|
311
|
+
const excluded = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)?.excludedSourceIds
|
|
312
|
+
const excludedIds = Array.isArray(excluded) ? excluded.filter((id): id is string => typeof id === 'string') : []
|
|
313
|
+
for (const ref of refs) {
|
|
314
|
+
if (ref.kind === 'run' && findRun(root, ref.ref)) {
|
|
315
|
+
const caller = readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)
|
|
316
|
+
if (!excludedIds.includes(ref.ref) && caller?.vaultAccess !== 'curator') {
|
|
317
|
+
const text = evidenceText(root, ref.ref)
|
|
318
|
+
if (!looksSecret(text)) evidence.runs![ref.ref] = takeChars(text, 60_000)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (ref.kind === 'source') {
|
|
322
|
+
const source = store.listSources(wiki).find((item) => item.id === ref.ref)
|
|
323
|
+
if (source) {
|
|
324
|
+
try {
|
|
325
|
+
const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
|
|
326
|
+
if (!looksSecret(text)) evidence.sources![ref.ref] = takeChars(text, 60_000)
|
|
327
|
+
} catch {
|
|
328
|
+
// Missing or unreadable evidence is rejected by the proposal validator.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const namespaceFor = (sourceRefs: readonly SourceRef[]): string => {
|
|
335
|
+
for (const ref of sourceRefs) {
|
|
336
|
+
const sourceJob = prepared.find((job) => {
|
|
337
|
+
if (job.input.kind === 'proposal') return job.input.caller.runId === ref.ref
|
|
338
|
+
if (job.input.kind === 'run') return job.input.runId === ref.ref
|
|
339
|
+
if (job.input.kind === 'ingest') return job.input.sourceId === ref.ref
|
|
340
|
+
return false
|
|
341
|
+
})
|
|
342
|
+
const namespace = sourceJob?.prepared?.namespace ?? (sourceJob?.input.kind === 'proposal' ? sourceJob.input.namespace : undefined)
|
|
343
|
+
if (namespace) return namespace
|
|
344
|
+
}
|
|
345
|
+
return prepared[0]?.prepared?.namespace ?? (prepared[0]?.input.kind === 'proposal' ? prepared[0].input.namespace : undefined) ?? store.LIBRARY_NAMESPACE
|
|
346
|
+
}
|
|
347
|
+
const batchProposals: ProposalSet = {
|
|
348
|
+
...proposals!,
|
|
349
|
+
pages: proposals!.pages.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) })),
|
|
350
|
+
memories: proposals!.memories.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) }))
|
|
351
|
+
}
|
|
352
|
+
const trigger: Trigger = prepared.some((job) => job.input.kind === 'consolidate')
|
|
353
|
+
? { kind: 'consolidate' }
|
|
354
|
+
: prepared.some((job) => job.input.kind === 'run')
|
|
355
|
+
? { kind: 'run', runId: (prepared.find((job) => job.input.kind === 'run')!.input as Extract<Job, { kind: 'run' }>).runId }
|
|
356
|
+
: prepared.some((job) => job.input.kind === 'ingest')
|
|
357
|
+
? (prepared.find((job) => job.input.kind === 'ingest')!.prepared?.trigger ?? { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' })
|
|
358
|
+
: { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' }
|
|
359
|
+
const outcome = apply(wiki, namespaceFor(refs), trigger, batchProposals, captured, evidence, operationId!, context.agentName ?? 'Curator')
|
|
360
|
+
if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
|
|
361
|
+
reindexQuietly(root)
|
|
362
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
363
|
+
let operationAssigned = false
|
|
364
|
+
for (const { decision, job: original } of wikiEntries) {
|
|
365
|
+
const job = curationJob(root, original.id)
|
|
366
|
+
const jobReason = decision.reason.trim()
|
|
367
|
+
job.status = outcome.parked ? 'needs_user' : 'completed'
|
|
368
|
+
job.reason = jobReason
|
|
369
|
+
job.receipt = {
|
|
370
|
+
operationId: outcome.operation && !operationAssigned ? outcome.operation.id : null,
|
|
371
|
+
notes: [...outcome.notes, ...(outcome.operation ? [`Combined batch operation ${outcome.operation.id}.`] : ['The combined review produced no Wiki changes.'])],
|
|
372
|
+
parked: outcome.parked,
|
|
373
|
+
flagged: outcome.flagged
|
|
374
|
+
}
|
|
375
|
+
if (outcome.operation && !operationAssigned) operationAssigned = true
|
|
376
|
+
results.set(job.id, saveCuration(root, job))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const { decision, job } of entries) {
|
|
381
|
+
if (results.has(job.id) || ['completed', 'rejected', 'needs_user'].includes(job.status)) {
|
|
382
|
+
results.set(job.id, curationJob(this.options.env.root, job.id))
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
if (decision.decision === 'apply') {
|
|
386
|
+
results.set(job.id, this.applyJob(context, job.id, undefined, decision.reason))
|
|
387
|
+
} else {
|
|
388
|
+
results.set(job.id, this.decide(context, job.id, decision.decision, decision.reason))
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const receiptOperationId = [...results.values()].find((job) => job.receipt?.operationId)?.receipt?.operationId ?? null
|
|
392
|
+
return { operationId: receiptOperationId, jobs: decisions.map(({ id }) => results.get(id) ?? curationJob(this.options.env.root, id)) }
|
|
393
|
+
}
|
|
394
|
+
|
|
236
395
|
claim(context: CallerContext, id: string, prepare = true): CurationJob {
|
|
237
396
|
requireCurator(context)
|
|
238
397
|
const root = this.options.env.root
|
|
@@ -448,6 +607,8 @@ export class Maintenance {
|
|
|
448
607
|
const wiki = store.wikiDir(root)
|
|
449
608
|
let prepared: ReturnType<Maintenance['prepare']>
|
|
450
609
|
try { prepared = this.prepare(job) } catch (error) {
|
|
610
|
+
// A run that is not eligible is an expected outcome, not a problem to report.
|
|
611
|
+
if (error instanceof NotEligible) return
|
|
451
612
|
const message = error instanceof Error ? error.message : String(error)
|
|
452
613
|
if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
|
|
453
614
|
else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
|
package/src/memory/recall.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and events stay canonical; the recall index is rebuilt from `runs/`.
|
|
4
4
|
import type { DatabaseSync } from 'node:sqlite'
|
|
5
5
|
|
|
6
|
+
import { logLine } from '../core/bus'
|
|
6
7
|
import type { HarnessEnv } from '../core/env'
|
|
7
8
|
import { invalid, notFound } from '../core/errors'
|
|
8
9
|
import { all, run as execute, transaction, withIndex } from '../core/index'
|
|
@@ -67,7 +68,7 @@ export function indexRun(root: string, runId: string): void {
|
|
|
67
68
|
try {
|
|
68
69
|
withIndex(root, (database) => writeRun(database, run, found))
|
|
69
70
|
} catch (error) {
|
|
70
|
-
|
|
71
|
+
logLine(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}`)
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createEnv } from '../core/env'
|
|
7
|
+
import { closeIndex } from '../core/index'
|
|
8
|
+
import { writeJson } from '../core/fs'
|
|
9
|
+
import { runRecordSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
|
|
10
|
+
import { appendEvent, saveRun } from '../sessions/runStore'
|
|
11
|
+
import { WIKI_AGENT_TOOLS } from '../tools/definitions'
|
|
12
|
+
import type { CallerContext } from './caller'
|
|
13
|
+
import { dispatch } from './dispatch'
|
|
14
|
+
import { readTool, searchTool } from './trajectory'
|
|
15
|
+
|
|
16
|
+
const roots: string[] = []
|
|
17
|
+
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
18
|
+
|
|
19
|
+
function setup() {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-trajectory-'))
|
|
21
|
+
roots.push(root)
|
|
22
|
+
writeJson(join(root, 'projects.json'), [
|
|
23
|
+
{ id: 'p1', name: 'App', slug: 'app', rootPath: '/tmp/app', autoMaintenance: true, createdAt: 't' },
|
|
24
|
+
{ id: 'p2', name: 'Site', slug: 'site', rootPath: '/tmp/site', autoMaintenance: true, createdAt: 't' }
|
|
25
|
+
])
|
|
26
|
+
const env = createEnv(root, {
|
|
27
|
+
directory: {
|
|
28
|
+
agentName: (id) => ({ 'agent-a': 'Nora', 'agent-b': 'Kai', 'agent-hidden': 'Hidden' })[id] ?? null,
|
|
29
|
+
sessionAgent: (id) => id === 'hidden-session' ? 'agent-hidden' : null
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
return { root, env }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(id: string, overrides: Partial<RunRecord> = {}): RunRecord {
|
|
36
|
+
return runRecordSchema.parse({
|
|
37
|
+
id,
|
|
38
|
+
projectId: null,
|
|
39
|
+
mode: 'chat',
|
|
40
|
+
harness: 'pi',
|
|
41
|
+
sessionType: 'structured',
|
|
42
|
+
sessionId: `session-${id}`,
|
|
43
|
+
goal: `Goal for ${id}`,
|
|
44
|
+
startedAt: '2026-10-18T10:00:00.000Z',
|
|
45
|
+
endedAt: null,
|
|
46
|
+
status: 'running',
|
|
47
|
+
agentId: 'agent-a',
|
|
48
|
+
...overrides
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function event(runId: string, sequence: number, type: RunEvent['type'], payload: unknown, native?: unknown): RunEvent {
|
|
53
|
+
return {
|
|
54
|
+
id: `${runId}-event-${sequence}`,
|
|
55
|
+
runId,
|
|
56
|
+
sessionId: `session-${runId}`,
|
|
57
|
+
sequence,
|
|
58
|
+
timestamp: `2026-10-18T10:00:${String(sequence).padStart(2, '0')}.000Z`,
|
|
59
|
+
type,
|
|
60
|
+
payload,
|
|
61
|
+
...(native === undefined ? {} : { native })
|
|
62
|
+
} as RunEvent
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function save(root: string, record: RunRecord, events: RunEvent[] = []): void {
|
|
66
|
+
saveRun(root, record)
|
|
67
|
+
for (const entry of events) appendEvent(root, entry)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const agent: CallerContext = { agentId: 'agent-a', actor: 'Chat' }
|
|
71
|
+
|
|
72
|
+
test('trajectory search filters runs and paginates deterministically, including active runs', () => {
|
|
73
|
+
const { root, env } = setup()
|
|
74
|
+
save(root, run('z-run', { projectId: 'p2', agentId: 'agent-b' }))
|
|
75
|
+
save(root, run('b-run', { projectId: 'p2', agentId: 'agent-b' }))
|
|
76
|
+
save(root, run('a-run', { projectId: 'p1', agentId: 'agent-a', startedAt: '2026-10-17T10:00:00.000Z', status: 'completed', endedAt: '2026-10-17T10:01:00.000Z' }))
|
|
77
|
+
|
|
78
|
+
const first = JSON.parse(searchTool(env, agent, { project: 'site', agent: 'Kai', limit: 1 }))
|
|
79
|
+
expect(first.runs.map((item: { id: string }) => item.id)).toEqual(['z-run'])
|
|
80
|
+
expect(first.runs[0]).toMatchObject({ agentId: 'agent-b', agentName: 'Kai', project: { id: 'p2', name: 'Site', slug: 'site' }, status: 'running' })
|
|
81
|
+
expect(first.hasMore).toBe(true)
|
|
82
|
+
|
|
83
|
+
const second = JSON.parse(searchTool(env, agent, { project: 'p2', agent: 'agent-b', limit: 1, cursor: first.nextCursor }))
|
|
84
|
+
expect(second.runs.map((item: { id: string }) => item.id)).toEqual(['b-run'])
|
|
85
|
+
expect(second.hasMore).toBe(false)
|
|
86
|
+
|
|
87
|
+
const bounded = JSON.parse(searchTool(env, agent, { since: '2026-10-18', until: '2026-10-18', status: 'running' }))
|
|
88
|
+
expect(bounded.runs.map((item: { id: string }) => item.id)).toEqual(['z-run', 'b-run'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('trajectory search and read keep host-excluded agents hidden', () => {
|
|
92
|
+
const { root, env } = setup()
|
|
93
|
+
save(root, run('hidden-agent-run', { agentId: 'agent-hidden' }))
|
|
94
|
+
save(root, run('hidden-session-run', { agentId: null, sessionId: 'hidden-session' }))
|
|
95
|
+
save(root, run('visible-run'))
|
|
96
|
+
const caller = { ...agent, recallExcludeAgents: ['agent-hidden'] }
|
|
97
|
+
|
|
98
|
+
const results = JSON.parse(searchTool(env, caller, {}))
|
|
99
|
+
expect(results.runs.map((item: { id: string }) => item.id)).toEqual(['visible-run'])
|
|
100
|
+
expect(() => readTool(env, caller, { run: 'hidden-agent-run' })).toThrow()
|
|
101
|
+
expect(() => readTool(env, caller, { run: 'hidden-session-run' })).toThrow()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('trajectory_read resolves current and returns bounded event pages without native data', () => {
|
|
105
|
+
const { root, env } = setup()
|
|
106
|
+
const longInput = 'x'.repeat(20_000)
|
|
107
|
+
save(root, run('active-run'), [
|
|
108
|
+
event('active-run', 0, 'context.snapshot', { systemPrompt: 'Be useful', tools: [] }, { privateNative: true }),
|
|
109
|
+
event('active-run', 1, 'tool.started', { toolCallId: 't1', name: 'read', input: longInput }),
|
|
110
|
+
event('active-run', 2, 'tool.completed', { toolCallId: 't1', name: 'read', output: 'file contents' })
|
|
111
|
+
])
|
|
112
|
+
const caller = { ...agent, runId: 'active-run' }
|
|
113
|
+
|
|
114
|
+
const initial = JSON.parse(readTool(env, caller, { run: 'current', limit: 1 }))
|
|
115
|
+
expect(initial.events[0]).toMatchObject({ sequence: 0, type: 'context.snapshot', payload: { systemPrompt: 'Be useful', tools: [] } })
|
|
116
|
+
expect(JSON.stringify(initial)).not.toContain('privateNative')
|
|
117
|
+
|
|
118
|
+
const first = JSON.parse(readTool(env, caller, { run: 'current', afterSequence: 0, limit: 1 }))
|
|
119
|
+
expect(first).toMatchObject({ active: true, run: { id: 'active-run', status: 'running' }, hasMore: true, nextAfterSequence: 1 })
|
|
120
|
+
expect(first.events[0]).toMatchObject({ sequence: 1, type: 'tool.started', truncatedFields: ['payload.input'] })
|
|
121
|
+
expect(JSON.stringify(first)).not.toContain('privateNative')
|
|
122
|
+
expect(first.events[0].payload.input.length).toBeLessThan(longInput.length)
|
|
123
|
+
|
|
124
|
+
const second = JSON.parse(readTool(env, caller, { run: 'active-run', afterSequence: first.nextAfterSequence, limit: 1 }))
|
|
125
|
+
expect(second.events[0]).toMatchObject({ sequence: 2, type: 'tool.completed', payload: { output: 'file contents' } })
|
|
126
|
+
expect(second.hasMore).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('trajectory_read follows execution events through the first outcome only', () => {
|
|
130
|
+
const { root, env } = setup()
|
|
131
|
+
save(root, run('settled-run', { status: 'completed', endedAt: '2026-10-18T10:01:00.000Z' }), [
|
|
132
|
+
event('settled-run', 0, 'started', {}),
|
|
133
|
+
event('settled-run', 1, 'completed', {}),
|
|
134
|
+
event('settled-run', 2, 'tool.started', { toolCallId: 'late', name: 'ignored' }, { hostIgnored: 'run_already_ended' })
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
const result = JSON.parse(readTool(env, agent, { run: 'settled-run' }))
|
|
138
|
+
expect(result.active).toBe(false)
|
|
139
|
+
expect(result.events.map((item: { sequence: number }) => item.sequence)).toEqual([0, 1])
|
|
140
|
+
expect(JSON.stringify(result)).not.toContain('ignored')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('trajectory_read keeps account identifiers and credential fields out of event payloads', () => {
|
|
144
|
+
const { root, env } = setup()
|
|
145
|
+
save(root, run('safe-run'), [
|
|
146
|
+
event('safe-run', 0, 'account.status', { accountId: 'private-account-id', category: 'quota', message: 'Limit reached for sk-abcdefghijklmnopqrstuvwxyz012345' }),
|
|
147
|
+
event('safe-run', 1, 'tool.started', { toolCallId: 't1', name: 'login', input: '{"username":"ada","password":"private-password"}' }),
|
|
148
|
+
event('safe-run', 2, 'usage.updated', { inputTokens: 10, request: { index: 1, reasoningSummary: 'private reasoning summary' } })
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
const result = JSON.parse(readTool(env, agent, { run: 'safe-run' }))
|
|
152
|
+
expect(result.events[0].omittedFields).toContain('payload.accountId')
|
|
153
|
+
expect(JSON.stringify(result)).not.toContain('private-account-id')
|
|
154
|
+
expect(JSON.stringify(result)).not.toContain('sk-abcdefghijklmnopqrstuvwxyz012345')
|
|
155
|
+
expect(JSON.stringify(result)).not.toContain('private-password')
|
|
156
|
+
expect(result.events[1].payload.input).not.toContain('password')
|
|
157
|
+
expect(result.events[2].omittedFields).toContain('payload.request.reasoningSummary')
|
|
158
|
+
expect(JSON.stringify(result)).not.toContain('private reasoning summary')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('trajectory calls reject malformed filters and missing current runs', () => {
|
|
162
|
+
const { env } = setup()
|
|
163
|
+
expect(() => searchTool(env, agent, { status: 'unknown' })).toThrow()
|
|
164
|
+
expect(() => searchTool(env, agent, { since: 'not-a-date' })).toThrow()
|
|
165
|
+
expect(() => searchTool(env, agent, { cursor: 'not-a-cursor' })).toThrow()
|
|
166
|
+
expect(() => readTool(env, agent, { run: 'current' })).toThrow()
|
|
167
|
+
expect(() => readTool(env, agent, { run: 'missing' })).toThrow()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('the model tool registry exposes both trajectory calls through Wiki dispatch', () => {
|
|
171
|
+
const { root, env } = setup()
|
|
172
|
+
save(root, run('registered-run'))
|
|
173
|
+
const calls = WIKI_AGENT_TOOLS.map((tool) => tool.call)
|
|
174
|
+
expect(calls).toContain('trajectory.search')
|
|
175
|
+
expect(calls).toContain('trajectory.read')
|
|
176
|
+
expect(JSON.parse(dispatch(env, agent, 'trajectory.search', {})).runs[0].id).toBe('registered-run')
|
|
177
|
+
expect(JSON.parse(dispatch(env, agent, 'trajectory.read', { run: 'registered-run' })).run.id).toBe('registered-run')
|
|
178
|
+
})
|