@xingular/harness 0.0.0-stage → 0.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/README.md +269 -2
- package/dist/cli.js +11046 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.ts +204 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +289 -0
- package/src/index.ts +69 -0
- package/src/mcp/connections.ts +441 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +567 -0
- package/src/mcp/types.ts +105 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +287 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +128 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +250 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +44 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +184 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +130 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +308 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +394 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { mkdtempSync, realpathSync, utimesSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
7
|
+
import { createHarness, type Harness } from '../harness'
|
|
8
|
+
import type { RunEvent } from './contracts'
|
|
9
|
+
import { grant, type ApprovalFacts } from './approvals'
|
|
10
|
+
import { selectHistory } from './history'
|
|
11
|
+
import type { ToolPolicy } from './policy'
|
|
12
|
+
import type { PiEvent, PiSessionLike, SessionFactory } from './runner'
|
|
13
|
+
import { loadRun, readEvents } from './runStore'
|
|
14
|
+
import type { SessionRunInput } from './sessions'
|
|
15
|
+
|
|
16
|
+
const tick = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
17
|
+
const open: Harness[] = []
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const harness of open.splice(0)) await harness.close()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function setup(factory: SessionFactory, policy: ToolPolicy = {}) {
|
|
23
|
+
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-data-')), stateDir: mkdtempSync(join(tmpdir(), 'harness-state-')), secrets: memorySecretStore(), sessionFactory: factory, policy })
|
|
24
|
+
open.push(harness)
|
|
25
|
+
harness.accounts.create('Main', 'apiKey')
|
|
26
|
+
const events: RunEvent[] = []
|
|
27
|
+
harness.runs.listen((event) => void events.push(event))
|
|
28
|
+
const sessionDir = join(harness.env.root, 'sessions', 'agent-1')
|
|
29
|
+
const run = (id: string, extra: Partial<SessionRunInput> = {}) =>
|
|
30
|
+
harness.sessions.run({ run: { id, sessionId: 'agent-1' }, cwd: harness.env.root, sessionDir, text: 'hello', caller: { agentId: 'agent-1', agentName: 'Helper', actor: 'Chat' }, tools: { allow: ['read', 'bash'], gated: ['bash'] }, ...extra })
|
|
31
|
+
return { harness, events, run, sessionDir }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeSession(script: (emit: (event: PiEvent) => void, gateCheck: () => Promise<string | null>) => Promise<void>, gate: { check: (name: string, input: unknown) => Promise<string | null> }): PiSessionLike & { aborted: boolean } {
|
|
35
|
+
let listener: (event: PiEvent) => void = () => undefined
|
|
36
|
+
const session = {
|
|
37
|
+
sessionId: 'native-1',
|
|
38
|
+
aborted: false,
|
|
39
|
+
subscribe: (fn: (event: PiEvent) => void) => {
|
|
40
|
+
listener = fn
|
|
41
|
+
return () => undefined
|
|
42
|
+
},
|
|
43
|
+
prompt: () => script((event) => listener(event), () => gate.check('bash', { command: 'ls' })),
|
|
44
|
+
sendCustomMessage: async () => undefined,
|
|
45
|
+
abort: async () => {
|
|
46
|
+
session.aborted = true
|
|
47
|
+
},
|
|
48
|
+
dispose: () => undefined
|
|
49
|
+
}
|
|
50
|
+
return session
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('session turns on Pi', () => {
|
|
54
|
+
it('rejects image-required turns before any prompt reaches a text-only model', async () => {
|
|
55
|
+
let requested = false
|
|
56
|
+
let disposed = false
|
|
57
|
+
const { run, harness } = setup(async ({ gate }) => ({
|
|
58
|
+
...fakeSession(async () => { requested = true }, gate),
|
|
59
|
+
model: { input: ['text'] },
|
|
60
|
+
dispose: () => { disposed = true }
|
|
61
|
+
}))
|
|
62
|
+
expect(await run('image-unsupported', { requiresImageInput: true })).toEqual({ runId: 'image-unsupported', nativeSessionId: null })
|
|
63
|
+
expect(requested).toBe(false)
|
|
64
|
+
expect(disposed).toBe(true)
|
|
65
|
+
expect(loadRun(harness.env.root, 'image-unsupported')).toMatchObject({ status: 'failed', terminalReason: expect.stringContaining('supports images') })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('passes image pixels using Pi image content fields with the existing stable prefix', async () => {
|
|
69
|
+
let observed: unknown
|
|
70
|
+
const { run } = setup(async ({ gate }) => ({
|
|
71
|
+
...fakeSession(async () => undefined, gate),
|
|
72
|
+
model: { input: ['text', 'image'] },
|
|
73
|
+
prompt: async (_text, options) => { observed = options }
|
|
74
|
+
}))
|
|
75
|
+
await run('image-supported', { requiresImageInput: true, images: [{ mediaType: 'image/png', data: 'cGl4ZWxz' }] })
|
|
76
|
+
await tick()
|
|
77
|
+
expect(observed).toEqual({ images: [{ type: 'image', mimeType: 'image/png', data: 'cGl4ZWxz' }] })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('saves the context update in native history before the request, without another model turn', async () => {
|
|
81
|
+
const delivered: unknown[] = []
|
|
82
|
+
const { run, events, harness } = setup(async ({ gate, params }) => ({
|
|
83
|
+
...fakeSession(async () => undefined, gate),
|
|
84
|
+
sendCustomMessage: async (message, options) => void delivered.push({ type: message.customType, display: message.display, options, snapshot: Object.keys(message.details as object) }),
|
|
85
|
+
prompt: async (text) => void delivered.push({ text, system: params.system.startsWith('# Xingular Harness context\n') })
|
|
86
|
+
}))
|
|
87
|
+
const started = await run('context-run', { preamble: '[Group context]', text: 'The user request' })
|
|
88
|
+
await tick()
|
|
89
|
+
expect(started).toEqual({ runId: 'context-run', nativeSessionId: 'native-1' })
|
|
90
|
+
expect(delivered).toEqual([
|
|
91
|
+
{ type: 'xingular-context', display: false, options: { triggerTurn: false }, snapshot: ['xingularContext'] },
|
|
92
|
+
{ text: '[Group context]\n\nThe user request', system: true }
|
|
93
|
+
])
|
|
94
|
+
expect(events.filter((event) => event.type === 'usage.updated')).toHaveLength(0)
|
|
95
|
+
expect(events.at(-1)?.type).toBe('completed')
|
|
96
|
+
expect(loadRun(harness.env.root, 'context-run')).toMatchObject({ status: 'completed', nativeSessionId: 'native-1', mode: 'chat', harness: 'pi', goal: 'The user request', agentId: 'agent-1' })
|
|
97
|
+
expect(readEvents(harness.env.root, 'context-run').map((event) => event.sequence)).toEqual(events.map((_, index) => index))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('streams Pi events as contract events and completes', async () => {
|
|
101
|
+
const { events, run } = setup(async ({ gate }) =>
|
|
102
|
+
fakeSession(async (emit) => {
|
|
103
|
+
emit({ type: 'message_start', message: { role: 'assistant' } })
|
|
104
|
+
emit({ type: 'message_update', message: { role: 'assistant' }, assistantMessageEvent: { type: 'text_delta', delta: 'Hi' } })
|
|
105
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Hi' }], usage: { input: 3, output: 1 } } })
|
|
106
|
+
}, gate)
|
|
107
|
+
)
|
|
108
|
+
await run('run-1')
|
|
109
|
+
await tick()
|
|
110
|
+
expect(events.map((event) => event.type)).toEqual(['started', 'status.changed', 'context.snapshot', 'message.delta', 'message.completed', 'usage.updated', 'completed'])
|
|
111
|
+
expect(events.map((event) => event.sequence)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reports cache, reasoning, cost and context size across the requests of a reply', async () => {
|
|
115
|
+
const { events, run } = setup(async ({ gate }) => ({
|
|
116
|
+
...fakeSession(async (emit) => {
|
|
117
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [], model: 'sonnet', provider: 'anthropic', usage: { input: 10, output: 4, cacheRead: 100, cacheWrite: 20, reasoning: 1, cost: { total: 0.01 } } } })
|
|
118
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Done' }], model: 'sonnet', provider: 'anthropic', usage: { input: 5, output: 6, cacheRead: 130, cacheWrite: 0, reasoning: 2, cost: { total: 0.02 } } } })
|
|
119
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [], usage: { input: 1, output: 1 } } })
|
|
120
|
+
}, gate),
|
|
121
|
+
model: { input: ['text'], contextWindow: 200_000 }
|
|
122
|
+
}))
|
|
123
|
+
await run('run-1')
|
|
124
|
+
await tick()
|
|
125
|
+
const usage = events.filter((event) => event.type === 'usage.updated').map((event) => event.payload as Record<string, unknown>)
|
|
126
|
+
expect(usage[1]).toMatchObject({ inputTokens: 15, outputTokens: 10, cacheReadTokens: 230, cacheWriteTokens: 20, reasoningTokens: 3, requests: 2, contextTokens: 135, contextWindow: 200_000, model: 'sonnet', provider: 'anthropic' })
|
|
127
|
+
expect(usage[1].costUsd).toBeCloseTo(0.03)
|
|
128
|
+
// A request without the optional figures leaves the running totals intact.
|
|
129
|
+
expect(usage[2]).toMatchObject({ inputTokens: 16, cacheReadTokens: 230, reasoningTokens: 3, requests: 3, contextTokens: 1 })
|
|
130
|
+
expect(usage[2]).not.toHaveProperty('model')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('reports each model request on its own, with its model, cost and timing', async () => {
|
|
134
|
+
const { events, run } = setup(async ({ gate }) =>
|
|
135
|
+
fakeSession(async (emit) => {
|
|
136
|
+
emit({ type: 'turn_start' })
|
|
137
|
+
emit({ type: 'message_update', message: { role: 'assistant' }, assistantMessageEvent: { type: 'thinking_delta' } })
|
|
138
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [], model: 'sonnet', provider: 'anthropic', responseId: 'resp-1', stopReason: 'toolUse', usage: { input: 10, output: 4, cacheRead: 100, cacheWrite: 20, reasoning: 1, cost: { total: 0.01 } } } })
|
|
139
|
+
emit({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'read', args: { path: 'a.md' } })
|
|
140
|
+
emit({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'read', isError: false, result: { content: [{ type: 'text', text: 'x'.repeat(1200) }, { type: 'image' }] } })
|
|
141
|
+
emit({ type: 'turn_start' })
|
|
142
|
+
emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Done' }], model: 'haiku', provider: 'anthropic', usage: { input: 5, output: 6 } } })
|
|
143
|
+
}, gate)
|
|
144
|
+
)
|
|
145
|
+
await run('run-1')
|
|
146
|
+
await tick()
|
|
147
|
+
const requests = events.filter((event) => event.type === 'usage.updated').map((event) => (event.payload as { request: Record<string, unknown> }).request)
|
|
148
|
+
expect(requests[0]).toMatchObject({ index: 1, responseId: 'resp-1', model: 'sonnet', provider: 'anthropic', inputTokens: 10, cacheReadTokens: 100, cacheWriteTokens: 20, outputTokens: 4, reasoningTokens: 1, costUsd: 0.01, costSource: 'harness', stopReason: 'toolUse' })
|
|
149
|
+
expect(requests[0]).not.toHaveProperty('reasoningSummary')
|
|
150
|
+
expect(requests[0].durationMs).toBeGreaterThanOrEqual(0)
|
|
151
|
+
expect(requests[0].ttftMs).toBeGreaterThanOrEqual(0)
|
|
152
|
+
// The second request reported no cost and produced no streamed output.
|
|
153
|
+
expect(requests[1]).toMatchObject({ index: 2, model: 'haiku', inputTokens: 5, outputTokens: 6, cacheReadTokens: 0 })
|
|
154
|
+
for (const key of ['costUsd', 'costSource', 'ttftMs', 'reasoningTokens', 'responseId']) expect(requests[1]).not.toHaveProperty(key)
|
|
155
|
+
const tool = events.find((event) => event.type === 'tool.completed')!.payload as Record<string, unknown>
|
|
156
|
+
// Stored evidence is redacted, which rewrites JSON carried as text in its compact form.
|
|
157
|
+
expect(events.find((event) => event.type === 'tool.started')?.payload).toMatchObject({ toolCallId: 't1', input: '{"path":"a.md"}' })
|
|
158
|
+
expect(tool).toMatchObject({ toolCallId: 't1', ok: true, resultChars: 1200 })
|
|
159
|
+
expect(tool.output).toContain('x'.repeat(1200))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('persists only an explicitly captured provider summary without inventing usage figures', async () => {
|
|
163
|
+
const { events, run } = setup(async ({ gate }) =>
|
|
164
|
+
fakeSession(async (emit) => {
|
|
165
|
+
emit({ type: 'turn_start' })
|
|
166
|
+
emit({ type: 'message_end', message: { role: 'assistant', api: 'openai-codex-responses', content: [
|
|
167
|
+
{ type: 'thinking', thinkingSignature: JSON.stringify({ type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will check the saved state before changing the file.' }], content: [{ text: 'private reasoning' }], encrypted_content: 'private replay payload' }) },
|
|
168
|
+
{ type: 'text', text: 'Done.' }
|
|
169
|
+
], model: 'gpt-6', provider: 'openai', responseId: 'response-1' } })
|
|
170
|
+
}, gate)
|
|
171
|
+
)
|
|
172
|
+
await run('summary-run')
|
|
173
|
+
await tick()
|
|
174
|
+
const usage = events.find((event) => event.type === 'usage.updated')!.payload as Record<string, unknown>
|
|
175
|
+
expect(usage).toMatchObject({ requests: 1, model: 'gpt-6', provider: 'openai' })
|
|
176
|
+
expect(usage).not.toHaveProperty('inputTokens')
|
|
177
|
+
expect(usage.request).toMatchObject({ index: 1, responseId: 'response-1', reasoningSummary: 'I will check the saved state before changing the file.' })
|
|
178
|
+
expect(usage.request).not.toHaveProperty('outputTokens')
|
|
179
|
+
expect(JSON.stringify(events)).not.toContain('private reasoning')
|
|
180
|
+
expect(JSON.stringify(events)).not.toContain('private replay payload')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('records the compiled prompt and schemas for the tools actually declared to the model', async () => {
|
|
184
|
+
const { events, run } = setup(async ({ gate }) => ({
|
|
185
|
+
...fakeSession(async () => undefined, gate),
|
|
186
|
+
sendCustomMessage: async () => undefined,
|
|
187
|
+
getActiveToolNames: () => ['read'],
|
|
188
|
+
getAllTools: () => [
|
|
189
|
+
{ name: 'read', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
190
|
+
{ name: 'write', description: 'Write a file', parameters: { type: 'object' } }
|
|
191
|
+
]
|
|
192
|
+
}))
|
|
193
|
+
await run('run-1')
|
|
194
|
+
await tick()
|
|
195
|
+
const snapshot = events.find((event) => event.type === 'context.snapshot')!.payload as { systemPrompt: string; contextUpdate: string; tools: unknown[] }
|
|
196
|
+
expect(snapshot.systemPrompt).toContain('## Context updates')
|
|
197
|
+
expect(snapshot.contextUpdate).toContain('<block label="persona"')
|
|
198
|
+
expect(snapshot.tools).toEqual([{ name: 'read', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } } } }])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('caps saved tool inputs and outputs at 24,000 characters with truncation flags', async () => {
|
|
202
|
+
const large = 'x'.repeat(25_000)
|
|
203
|
+
const { events, run } = setup(async ({ gate }) =>
|
|
204
|
+
fakeSession(async (emit) => {
|
|
205
|
+
emit({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'computer_type_text', args: { app: 'Editor', text: large } })
|
|
206
|
+
emit({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'computer_type_text', isError: false, result: large })
|
|
207
|
+
}, gate)
|
|
208
|
+
)
|
|
209
|
+
await run('run-1')
|
|
210
|
+
await tick()
|
|
211
|
+
const started = events.find((event) => event.type === 'tool.started')!.payload as { input: string; inputTruncated: boolean }
|
|
212
|
+
const completed = events.find((event) => event.type === 'tool.completed')!.payload as { output: string; outputTruncated: boolean }
|
|
213
|
+
expect([started.inputTruncated, completed.outputTruncated]).toEqual([true, true])
|
|
214
|
+
expect(started.input.length).toBeLessThanOrEqual(24_000)
|
|
215
|
+
expect(completed.output.length).toBeLessThanOrEqual(24_000)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('records bounded retry and compaction events without exposing thinking text', async () => {
|
|
219
|
+
const { events, run } = setup(async ({ gate }) =>
|
|
220
|
+
fakeSession(async (emit) => {
|
|
221
|
+
emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 250, errorMessage: 'Temporary failure' } as unknown as PiEvent)
|
|
222
|
+
emit({ type: 'auto_retry_end', attempt: 1, success: true } as unknown as PiEvent)
|
|
223
|
+
emit({ type: 'compaction_start', reason: 'threshold' } as unknown as PiEvent)
|
|
224
|
+
emit({ type: 'compaction_end', reason: 'threshold', aborted: false, willRetry: true, result: { summary: 'kept the latest messages' } } as unknown as PiEvent)
|
|
225
|
+
}, gate)
|
|
226
|
+
)
|
|
227
|
+
await run('run-1')
|
|
228
|
+
await tick()
|
|
229
|
+
expect(events.filter((event) => event.type.startsWith('retry.') || event.type.startsWith('compaction.')).map((event) => event.type)).toEqual(['retry.started', 'retry.ended', 'compaction.started', 'compaction.ended'])
|
|
230
|
+
expect(events.find((event) => event.type === 'compaction.ended')?.payload).toMatchObject({ aborted: false, willRetry: true, summary: '{"summary":"kept the latest messages"}' })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('leaves out the cost and reasoning figures nobody reported', async () => {
|
|
234
|
+
const { events, run } = setup(async ({ gate }) => fakeSession(async (emit) => emit({ type: 'message_end', message: { role: 'assistant', content: [], usage: { input: 1, output: 1 } } }), gate))
|
|
235
|
+
await run('run-1')
|
|
236
|
+
await tick()
|
|
237
|
+
const usage = events.find((event) => event.type === 'usage.updated')!.payload as Record<string, unknown>
|
|
238
|
+
for (const key of ['costUsd', 'reasoningTokens', 'contextWindow']) expect(usage).not.toHaveProperty(key)
|
|
239
|
+
expect(usage.request).not.toHaveProperty('durationMs')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('pauses gated tools for approval and blocks on deny', async () => {
|
|
243
|
+
let blockReason: string | null | undefined
|
|
244
|
+
const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate))
|
|
245
|
+
await run('run-2', { text: 'list files' })
|
|
246
|
+
await tick()
|
|
247
|
+
const approval = events.find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
|
|
248
|
+
expect(approval).toMatchObject({ target: 'bash', action: 'ls', rememberable: true })
|
|
249
|
+
expect(loadRun(harness.env.root, 'run-2').status).toBe('waiting_for_approval')
|
|
250
|
+
harness.runs.resolveApproval('run-2', approval.approvalId, 'denied')
|
|
251
|
+
await tick()
|
|
252
|
+
expect(blockReason).toMatch(/denied/)
|
|
253
|
+
expect(events.some((event) => event.type === 'approval.resolved')).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("asks before a tool an extension brought, and relays the extension's question and notice as run events", async () => {
|
|
257
|
+
let blockReason: string | null | undefined
|
|
258
|
+
let answered: unknown
|
|
259
|
+
const { events, run, harness } = setup(async ({ gate, extensions }) => ({
|
|
260
|
+
...fakeSession(async () => undefined, gate),
|
|
261
|
+
prompt: async () => {
|
|
262
|
+
// What a loaded extension does: registers a tool nobody switched on by name, shows a line, and asks.
|
|
263
|
+
gate.ask?.(['kit_scan'])
|
|
264
|
+
extensions!.notice({ kind: 'status', key: 'kit', text: 'Scanning' })
|
|
265
|
+
answered = await extensions!.ask({ question: 'Scan the whole folder?', options: [{ label: 'Yes' }, { label: 'No' }], multiSelect: false, allowText: false })
|
|
266
|
+
blockReason = await gate.check('kit_scan', { path: '.' })
|
|
267
|
+
}
|
|
268
|
+
}))
|
|
269
|
+
await run('run-ext', { text: 'scan' })
|
|
270
|
+
await tick()
|
|
271
|
+
expect(events.find((event) => event.type === 'extension.notice')?.payload).toEqual({ kind: 'status', key: 'kit', text: 'Scanning' })
|
|
272
|
+
const question = events.find((event) => event.type === 'question.requested')!.payload as { questionId: string }
|
|
273
|
+
expect(question).toMatchObject({ question: 'Scan the whole folder?' })
|
|
274
|
+
harness.runs.answerQuestion('run-ext', question.questionId, { selected: ['Yes'] })
|
|
275
|
+
await tick()
|
|
276
|
+
expect(answered).toEqual({ selected: ['Yes'] })
|
|
277
|
+
const approval = events.find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
|
|
278
|
+
expect(approval).toMatchObject({ target: 'kit_scan' })
|
|
279
|
+
harness.runs.resolveApproval('run-ext', approval.approvalId, 'approved')
|
|
280
|
+
await tick()
|
|
281
|
+
expect(blockReason).toBeNull()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('gives an unattended run no extension interface', async () => {
|
|
285
|
+
let offered: unknown = 'unset'
|
|
286
|
+
const { run } = setup(async ({ gate, extensions }) => {
|
|
287
|
+
offered = extensions
|
|
288
|
+
return fakeSession(async () => undefined, gate)
|
|
289
|
+
})
|
|
290
|
+
await run('run-auto-ext', { text: 'tidy', automatic: true })
|
|
291
|
+
await tick()
|
|
292
|
+
expect(offered).toBeUndefined()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('skips the prompt for a tool the user already allowed in this session', async () => {
|
|
296
|
+
let blockReason: string | null | undefined
|
|
297
|
+
const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate))
|
|
298
|
+
harness.approvals.remember('agent-1', 'bash')
|
|
299
|
+
await run('run-auto', { text: 'list files' })
|
|
300
|
+
await tick()
|
|
301
|
+
expect(blockReason).toBeNull()
|
|
302
|
+
expect(events.find((event) => event.type === 'approval.requested')?.payload).toMatchObject({ target: 'bash', rememberable: true, automatic: 'remembered' })
|
|
303
|
+
expect(events.find((event) => event.type === 'approval.resolved')?.payload).toMatchObject({ decision: 'approved', automatic: 'remembered' })
|
|
304
|
+
expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(false)
|
|
305
|
+
expect(events.at(-1)?.type).toBe('completed')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('still asks when the application cannot say what is allowed', async () => {
|
|
309
|
+
const { events, run } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (await check()), gate), { approval: async () => { throw new Error('down') } })
|
|
310
|
+
await run('run-ask', { text: 'list files' })
|
|
311
|
+
await tick()
|
|
312
|
+
expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(true)
|
|
313
|
+
expect(events.some((event) => event.type === 'approval.resolved')).toBe(false)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('lets the application refuse a call that is outside an unattended run\'s scope, and switch a tool off', async () => {
|
|
317
|
+
const reasons: (string | null)[] = []
|
|
318
|
+
let authorized = 0
|
|
319
|
+
const policy: ToolPolicy = {
|
|
320
|
+
scope: async ({ tool }) => ({ automatic: true, allowed: tool !== 'bash', reason: 'Scheduled workers may use scoped file tools only.' }),
|
|
321
|
+
authorize: async () => {
|
|
322
|
+
authorized += 1
|
|
323
|
+
if (authorized > 1) throw new Error('Tool unavailable in the app settings.')
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const { run } = setup(async ({ gate }) => fakeSession(async () => {
|
|
327
|
+
reasons.push(await gate.check('bash', {}), await gate.check('read', {}), await gate.check('read', {}))
|
|
328
|
+
}, gate), policy)
|
|
329
|
+
await run('run-scope')
|
|
330
|
+
await tick()
|
|
331
|
+
expect(reasons).toEqual(['Scheduled workers may use scoped file tools only.', null, 'Tool unavailable in the app settings.'])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('aborts the Pi session on interrupt', async () => {
|
|
335
|
+
let session: (PiSessionLike & { aborted: boolean }) | null = null
|
|
336
|
+
const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
|
|
337
|
+
await run('run-3')
|
|
338
|
+
await tick()
|
|
339
|
+
await harness.runs.interrupt('run-3')
|
|
340
|
+
await tick(80)
|
|
341
|
+
expect(session!.aborted).toBe(true)
|
|
342
|
+
expect(events.at(-1)).toMatchObject({ type: 'status.changed', payload: { status: 'interrupted' } })
|
|
343
|
+
expect(loadRun(harness.env.root, 'run-3').status).toBe('interrupted')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('gives every session the memory, recall and Wiki tools, a question tool, and the application\'s tools in order', async () => {
|
|
347
|
+
let received: Parameters<SessionFactory>[0] | null = null
|
|
348
|
+
const { run, harness } = setup(async (input) => {
|
|
349
|
+
received = input
|
|
350
|
+
return fakeSession(async () => undefined, input.gate)
|
|
351
|
+
})
|
|
352
|
+
harness.sessions.provide(() => [{ name: 'react', label: 'React', description: 'React', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
|
|
353
|
+
await run('run-5', { tools: { allow: ['react'] } })
|
|
354
|
+
await tick()
|
|
355
|
+
expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
|
|
356
|
+
expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
|
|
357
|
+
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'ask_user', 'plugin_propose', 'react'])
|
|
358
|
+
expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
|
|
359
|
+
expect(received!.params.system).toContain('# Xingular Harness context')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('refuses missing continuation history before creating a run or opening a session', async () => {
|
|
363
|
+
let called = false
|
|
364
|
+
const { run, events, harness } = setup(async ({ gate }) => {
|
|
365
|
+
called = true
|
|
366
|
+
return fakeSession(async () => undefined, gate)
|
|
367
|
+
})
|
|
368
|
+
await expect(run('resume-run', { resume: { sessionFile: '/tmp/missing-history/saved.jsonl', nativeSessionId: 'native-1' } })).rejects.toMatchObject({ code: 'native_resume_unavailable' })
|
|
369
|
+
expect(called).toBe(false)
|
|
370
|
+
expect(events).toEqual([])
|
|
371
|
+
expect(() => loadRun(harness.env.root, 'resume-run')).toThrow()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('fails the run when the session cannot start', async () => {
|
|
375
|
+
const { events, run, harness } = setup(async () => {
|
|
376
|
+
throw new Error('No provider')
|
|
377
|
+
})
|
|
378
|
+
expect(await run('run-4')).toEqual({ runId: 'run-4', nativeSessionId: null })
|
|
379
|
+
expect(events.at(-1)).toMatchObject({ type: 'failed', payload: { message: 'No provider' } })
|
|
380
|
+
expect(loadRun(harness.env.root, 'run-4')).toMatchObject({ status: 'failed', terminalReason: 'No provider' })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('needs an account before it records anything', async () => {
|
|
384
|
+
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-data-')), stateDir: mkdtempSync(join(tmpdir(), 'harness-state-')), secrets: memorySecretStore(), sessionFactory: async () => { throw new Error('unreachable') } })
|
|
385
|
+
open.push(harness)
|
|
386
|
+
await expect(harness.sessions.run({ run: { id: 'r', sessionId: 's' }, cwd: '/tmp', sessionDir: join(harness.env.root, 's'), text: 'x', caller: { agentId: 'a' }, tools: { allow: [] } })).rejects.toMatchObject({ code: 'account_required' })
|
|
387
|
+
expect(() => loadRun(harness.env.root, 'r')).toThrow()
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('history selection', () => {
|
|
392
|
+
it('opens the newest history, or exactly the recorded one for a continuation', () => {
|
|
393
|
+
const dir = mkdtempSync(join(tmpdir(), 'context-history-'))
|
|
394
|
+
expect(selectHistory({ sessionDir: dir })).toBeNull()
|
|
395
|
+
expect(selectHistory({ sessionDir: join(dir, 'missing') })).toBeNull()
|
|
396
|
+
const first = join(dir, 'first.jsonl')
|
|
397
|
+
const newer = join(dir, 'newer.jsonl')
|
|
398
|
+
writeFileSync(first, `${JSON.stringify({ type: 'session', id: 'first' })}\n`)
|
|
399
|
+
writeFileSync(newer, `${JSON.stringify({ type: 'session', id: 'newer' })}\n`)
|
|
400
|
+
writeFileSync(join(dir, 'notes.txt'), '')
|
|
401
|
+
utimesSync(newer, new Date(Date.now() + 1000), new Date(Date.now() + 1000))
|
|
402
|
+
utimesSync(join(dir, 'notes.txt'), new Date(Date.now() + 5000), new Date(Date.now() + 5000))
|
|
403
|
+
expect(selectHistory({ sessionDir: dir })).toBe(realpathSync(newer))
|
|
404
|
+
expect(selectHistory({ sessionDir: dir, resume: { sessionFile: first, nativeSessionId: 'first' } })).toBe(realpathSync(first))
|
|
405
|
+
expect(() => selectHistory({ sessionDir: dir, resume: { sessionFile: first, nativeSessionId: 'newer' } })).toThrow(/saved conversation/)
|
|
406
|
+
expect(() => selectHistory({ sessionDir: dir, resume: { sessionFile: join(dir, 'missing'), nativeSessionId: 'first' } })).toThrow(/saved conversation/)
|
|
407
|
+
expect(() => selectHistory({ sessionDir: dir, resume: { sessionFile: first } })).toThrow(/saved conversation/)
|
|
408
|
+
const outside = join(mkdtempSync(join(tmpdir(), 'other-history-')), 'outside.jsonl')
|
|
409
|
+
writeFileSync(outside, `${JSON.stringify({ type: 'session', id: 'outside' })}\n`)
|
|
410
|
+
expect(() => selectHistory({ sessionDir: dir, resume: { sessionFile: outside, nativeSessionId: 'outside' } })).toThrow(/saved conversation/)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('never opens a newest file that is not a native history', () => {
|
|
414
|
+
const dir = mkdtempSync(join(tmpdir(), 'context-history-'))
|
|
415
|
+
writeFileSync(join(dir, 'broken.jsonl'), 'not json\n')
|
|
416
|
+
expect(() => selectHistory({ sessionDir: dir })).toThrow(/saved conversation/)
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('standing approvals', () => {
|
|
421
|
+
const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
|
|
422
|
+
const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
|
|
423
|
+
|
|
424
|
+
it('the switch reaches attended sessions but not unattended or read-only ones', () => {
|
|
425
|
+
expect(grant(policy, 'pi', 'pane-1', 'edit files', facts(false, false))).toBe('always')
|
|
426
|
+
expect(grant(policy, 'pi', 'pane-1', 'edit files', facts(true, false))).toBeNull()
|
|
427
|
+
expect(grant(policy, 'pi', 'pane-1', 'edit files', facts(false, true))).toBeNull()
|
|
428
|
+
expect(grant(policy, 'pi', 'pane-1', 'shell', facts(false, true))).toBe('remembered')
|
|
429
|
+
expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
|
|
430
|
+
expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
|
|
431
|
+
expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
|
|
432
|
+
expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
433
|
+
})
|
|
434
|
+
})
|