@xingular/harness 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -2
- package/dist/cli.js +11510 -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.test.ts +95 -0
- package/src/accounts/env.ts +338 -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 +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -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 +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -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 +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -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 +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -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 +407 -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,149 @@
|
|
|
1
|
+
// What a person sees of everything that shapes a session. A plugin is a bundle with a title and
|
|
2
|
+
// a description; its contributions are the instructions, skills, commands, tools, connections,
|
|
3
|
+
// code and themes it brings.
|
|
4
|
+
import type { Attachment } from './attachments'
|
|
5
|
+
import type { Registered } from './plugins'
|
|
6
|
+
import type { ProjectCode } from './trust'
|
|
7
|
+
|
|
8
|
+
export const CONTRIBUTION_KINDS = ['instruction', 'skill', 'command', 'tool', 'connection', 'code', 'theme'] as const
|
|
9
|
+
export type ContributionKind = (typeof CONTRIBUTION_KINDS)[number]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a plugin comes from: the harness itself, the embedding application, Pi, a person's own
|
|
13
|
+
* registry, the folder every account shares, an installed package, the working folder, or the
|
|
14
|
+
* home folder.
|
|
15
|
+
*/
|
|
16
|
+
export const PLUGIN_SOURCES = ['builtin', 'application', 'pi', 'yours', 'shared', 'package', 'project', 'home'] as const
|
|
17
|
+
export type PluginSource = (typeof PLUGIN_SOURCES)[number]
|
|
18
|
+
|
|
19
|
+
/** `untrusted` is code that is found and does not load until a person trusts it. */
|
|
20
|
+
export type ContributionState = 'on' | 'off' | 'untrusted'
|
|
21
|
+
|
|
22
|
+
export interface Contribution {
|
|
23
|
+
/** `<plugin id>/<kind>/<name>`. */
|
|
24
|
+
id: string
|
|
25
|
+
kind: ContributionKind
|
|
26
|
+
name: string
|
|
27
|
+
description: string
|
|
28
|
+
path: string | null
|
|
29
|
+
state: ContributionState
|
|
30
|
+
/** Who it is switched on for, in words: "Everywhere", a project's name, "2 agents". */
|
|
31
|
+
scope: string | null
|
|
32
|
+
/** Where it is switched on and off, or `null` when it cannot be switched on its own. */
|
|
33
|
+
attachment: Attachment | null
|
|
34
|
+
/** What a code contribution registered the last time a session loaded it. */
|
|
35
|
+
registered: Registered | null
|
|
36
|
+
/** What went wrong the last time it ran, for code the harness runs itself. */
|
|
37
|
+
problem: string | null
|
|
38
|
+
/** Code the harness runs itself after a change, for everyone, outside any session. */
|
|
39
|
+
background: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where a person changes a plugin today. */
|
|
43
|
+
export type PluginManage = 'registry' | 'tools' | 'connections' | 'files'
|
|
44
|
+
|
|
45
|
+
export interface Plugin {
|
|
46
|
+
id: string
|
|
47
|
+
title: string
|
|
48
|
+
description: string
|
|
49
|
+
version: string | null
|
|
50
|
+
source: PluginSource
|
|
51
|
+
path: string | null
|
|
52
|
+
runsCode: boolean
|
|
53
|
+
trust: 'none' | 'trusted' | 'untrusted'
|
|
54
|
+
/** The code a person is asked to trust, for a working folder's own. */
|
|
55
|
+
code: ProjectCode | null
|
|
56
|
+
/**
|
|
57
|
+
* How a person installed it, when it can be removed here: named in the settings every account
|
|
58
|
+
* shares (`source` is the name there), or a folder kept with their data (`source` is its path).
|
|
59
|
+
*/
|
|
60
|
+
install: { kind: 'settings' | 'folder'; source: string } | null
|
|
61
|
+
/** The parts it adds to, by plugin id: a package that says it extends Memory, the Wiki or History. */
|
|
62
|
+
extends: string[]
|
|
63
|
+
/** Why it cannot be removed, or `null` when it can. */
|
|
64
|
+
required: string | null
|
|
65
|
+
manage: PluginManage | null
|
|
66
|
+
/** Numbers a person can set on it, each with its current value. */
|
|
67
|
+
settings: (PluginSetting & { value: number })[]
|
|
68
|
+
/** Declarative connector setup templates; listing one never creates or enables a connection. */
|
|
69
|
+
connectorTemplates: ConnectorTemplate[]
|
|
70
|
+
/** Where the whole plugin is switched on and off, or `null` when only its parts are, or nothing is. */
|
|
71
|
+
attachment: Attachment | null
|
|
72
|
+
contributions: Contribution[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A number a person can set on a plugin, such as a limit. */
|
|
76
|
+
export interface PluginSetting {
|
|
77
|
+
key: string
|
|
78
|
+
label: string
|
|
79
|
+
description: string
|
|
80
|
+
default: number
|
|
81
|
+
min: number
|
|
82
|
+
max: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A remote connector setup form. It contains no endpoint credentials or saved connection state. */
|
|
86
|
+
export interface ConnectorTemplate {
|
|
87
|
+
id: string
|
|
88
|
+
name: string
|
|
89
|
+
description: string
|
|
90
|
+
url: string
|
|
91
|
+
auth: 'none' | 'headers' | 'oauth'
|
|
92
|
+
oauthClient: 'dynamic' | 'user' | null
|
|
93
|
+
oauthScopes: string[]
|
|
94
|
+
docsUrl: string | null
|
|
95
|
+
fields: { header: string; label: string; prefix: string; help: string }[]
|
|
96
|
+
setup: string[]
|
|
97
|
+
notice: string | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What a part of the harness, or the embedding application, says about itself. Data only. */
|
|
101
|
+
export interface PluginDescriptor {
|
|
102
|
+
id: string
|
|
103
|
+
title: string
|
|
104
|
+
description: string
|
|
105
|
+
/** Why it cannot be removed. */
|
|
106
|
+
required?: string
|
|
107
|
+
manage?: PluginManage
|
|
108
|
+
tools?: { name: string; description?: string }[]
|
|
109
|
+
/** The prompt sections it writes. */
|
|
110
|
+
sections?: { id: string; name: string; description?: string }[]
|
|
111
|
+
/** The tool groups whose paragraph in the tools section it writes. */
|
|
112
|
+
toolGroups?: string[]
|
|
113
|
+
/** It ships with the harness and a person may switch it. `on` is its state until they do. */
|
|
114
|
+
switchable?: { on: boolean }
|
|
115
|
+
/** Numbers a person can set on it. */
|
|
116
|
+
settings?: PluginSetting[]
|
|
117
|
+
/** Remote connector setup templates; users must add, configure and enable each connection. */
|
|
118
|
+
connectorTemplates?: ConnectorTemplate[]
|
|
119
|
+
/** Code of its own that runs in a session. */
|
|
120
|
+
code?: { name: string; description: string }[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type Delivery = 'system' | 'context' | 'tool' | 'on-demand' | 'code' | 'background'
|
|
124
|
+
|
|
125
|
+
/** One thing that reaches a caller. */
|
|
126
|
+
export interface InventoryItem {
|
|
127
|
+
pluginId: string
|
|
128
|
+
contributionId: string | null
|
|
129
|
+
kind: ContributionKind
|
|
130
|
+
name: string
|
|
131
|
+
/** `system` is the stable prefix, `context` an update sent with a request, `on-demand` text a tool loads, `background` code the harness runs after a change. */
|
|
132
|
+
delivery: Delivery
|
|
133
|
+
/** Characters it adds to what the model is told, when that is known. */
|
|
134
|
+
chars: number | null
|
|
135
|
+
/** Its text was cut to fit a budget. */
|
|
136
|
+
cut: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface Inventory {
|
|
140
|
+
/** The plugins the items belong to. */
|
|
141
|
+
plugins: Plugin[]
|
|
142
|
+
/** Every other plugin, with nothing reaching this caller: what could still be switched on for it. */
|
|
143
|
+
others: Plugin[]
|
|
144
|
+
items: InventoryItem[]
|
|
145
|
+
/** Prompt sections, tool groups and tools that no plugin accounts for. */
|
|
146
|
+
unattributed: { kind: 'section' | 'toolGroup' | 'tool'; id: string }[]
|
|
147
|
+
systemChars: number
|
|
148
|
+
contextChars: number
|
|
149
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } 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 { dataRoot, writeFile } from '../../tests/helpers'
|
|
7
|
+
import { createEnv, type HarnessEnv } from '../core/env'
|
|
8
|
+
import { writeJson } from '../core/fs'
|
|
9
|
+
import { closeIndex } from '../core/index'
|
|
10
|
+
import * as blocks from '../memory/blocks'
|
|
11
|
+
import type { CallerContext } from '../memory/caller'
|
|
12
|
+
import * as references from '../memory/references'
|
|
13
|
+
import { readRunContext } from '../memory/runContext'
|
|
14
|
+
import { ensureProjectNamespace, wikiDir } from '../memory/store'
|
|
15
|
+
import { reindexWiki } from '../memory/wikiIndex'
|
|
16
|
+
import { compile, fit, launchText, load, memoryDelta, profileFor, profiles, record, REFERENCES_BUDGET, render, saveForSession, sessionFile, sessionLaunch, setProfile, type ContextPack, type ContextUpdate, type HostSection, type Launch } from './compile'
|
|
17
|
+
|
|
18
|
+
const roots: string[] = []
|
|
19
|
+
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
20
|
+
|
|
21
|
+
function setup(): HarnessEnv {
|
|
22
|
+
const root = dataRoot()
|
|
23
|
+
roots.push(root)
|
|
24
|
+
writeJson(join(root, 'projects.json'), [
|
|
25
|
+
{ id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' },
|
|
26
|
+
{ id: 'p2', name: 'Site', rootPath: '/src/site', autoMaintenance: true, createdAt: 't', slug: 'site' }
|
|
27
|
+
])
|
|
28
|
+
ensureProjectNamespace(root, 'xingular-agent', 'Xingular Agent')
|
|
29
|
+
const wiki = wikiDir(root)
|
|
30
|
+
writeFileSync(join(wiki, 'projects/xingular-agent/decisions.md'), '---\nid: decisions\ntitle: Decisions\ntype: decision\n---\n\nTauri 2 for the shell.\n')
|
|
31
|
+
writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
|
|
32
|
+
writeFileSync(join(wiki, 'facts/on-call.md'), '---\nid: on-call\ntitle: On call this week\nkind: fact\nnamespace: /\nconfidence: high\nvolatile: true\n---\n\nAstro team is on call.\n')
|
|
33
|
+
writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro renders the site.\n')
|
|
34
|
+
writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nAlways run the tests.\n')
|
|
35
|
+
writeFile(join(root, 'registry/skills/review/SKILL.md'), '---\nname: review\ndescription: Review a change\n---\n\nRead the diff first.\n')
|
|
36
|
+
writeJson(join(root, 'config/registry-enabled.json'), { global: ['rules/tests'], p1: ['skills/review'] })
|
|
37
|
+
mkdirSync(join(root, 'runs/run-1'), { recursive: true })
|
|
38
|
+
reindexWiki(root)
|
|
39
|
+
return createEnv(root)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const session = (project: string): CallerContext => ({ runId: 'run-1', sessionId: 's1', projectId: project, actor: 'Code' })
|
|
43
|
+
const agent = (extra: Partial<CallerContext> = {}): CallerContext => ({ agentId: 'nora', agentName: 'Nora', actor: 'Chat', ...extra })
|
|
44
|
+
const ids = (pack: ContextPack): string[] => pack.sections.map((entry) => entry.id)
|
|
45
|
+
|
|
46
|
+
// What an application supplies: who the model is, its role, and what it is working on.
|
|
47
|
+
const identity: HostSection = { id: 'identity', title: 'Identity', body: 'You are "Nora", an Xingular Agent agent.', budget: 6000, order: 10 }
|
|
48
|
+
const role: HostSection = { id: 'role', title: 'Role', body: 'You coordinate.', budget: 1500, order: 20 }
|
|
49
|
+
const work = (body: string | null, pinned?: string): HostSection => ({ id: 'work', title: 'Active work', body, budget: 1500, pointer: '(Call list_work for the full inventory.)', order: 70, dynamic: true, pinned: pinned ? { text: pinned, pointer: '(Call list_work for the full evidence.)' } : null })
|
|
50
|
+
const hosted = (extra: Partial<Launch> = {}): Launch => ({ sections: [identity, role, work('You have no unresolved work.\n')], ...extra })
|
|
51
|
+
|
|
52
|
+
test('cuts at a paragraph and points to the rest', () => {
|
|
53
|
+
expect(fit('short', 100, '(more)')).toEqual({ body: 'short', cut: false })
|
|
54
|
+
const cut = fit(`${'a'.repeat(40)}\n\n${'b'.repeat(40)}\n\n${'c'.repeat(40)}`, 100, '(more)')
|
|
55
|
+
expect(cut.cut).toBe(true)
|
|
56
|
+
expect(cut.body).toBe(`${'a'.repeat(40)}\n\n${'b'.repeat(40)}\n\n(more)`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('selected content is pinned and retained with automatic context off', () => {
|
|
60
|
+
const env = setup()
|
|
61
|
+
setProfile(env.root, 'nora', 'off')
|
|
62
|
+
const revision = references.pin(env.root, 'skill', 'skills/review')
|
|
63
|
+
writeFileSync(join(env.root, 'registry/skills/review/SKILL.md'), 'Changed instructions.')
|
|
64
|
+
const newer = references.pin(env.root, 'skill', 'skills/review')
|
|
65
|
+
const caller = agent({ runId: 'run-1' })
|
|
66
|
+
const pack = compile(env, caller, hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] }))
|
|
67
|
+
expect(ids(pack)).toEqual(['identity', 'role', 'references'])
|
|
68
|
+
const selected = pack.sections.find((entry) => entry.id === 'references')!
|
|
69
|
+
expect(selected.body).toContain('Read the diff first.')
|
|
70
|
+
expect(selected.body).not.toContain('Changed instructions.')
|
|
71
|
+
expect(revision).not.toBe(newer)
|
|
72
|
+
record(env.root, caller, pack)
|
|
73
|
+
expect(references.readPinned(env.root, 'run-1', 'skills/review', revision, 0)).toContain('Read the diff first.')
|
|
74
|
+
expect(() => references.readPinned(env.root, 'run-1', 'skills/review', newer, 0)).toThrow()
|
|
75
|
+
expect(readRunContext(env.root, 'run-1')[0].revisionId).toBe(revision)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('application lines sit with the references and large sources keep retrieval pointers', () => {
|
|
79
|
+
const env = setup()
|
|
80
|
+
writeFileSync(join(env.root, 'registry/skills/review/SKILL.md'), 'Review instructions.\n\n'.repeat(2000))
|
|
81
|
+
const revision = references.pin(env.root, 'skill', 'skills/review')
|
|
82
|
+
const pack = compile(env, agent(), { references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }, { kind: 'line', id: 'exact-agent-id', label: 'Duplicate name', text: '- Agent "Duplicate name": exact ID "exact-agent-id". Agent messaging is unavailable in this reply.' }] })
|
|
83
|
+
const selected = pack.sections.find((entry) => entry.id === 'references')!
|
|
84
|
+
expect(selected.body).toContain('Agent messaging is unavailable')
|
|
85
|
+
expect(selected.cut).toBe(true)
|
|
86
|
+
expect(selected.size).toBeLessThanOrEqual(REFERENCES_BUDGET)
|
|
87
|
+
expect(selected.body).toContain('wiki_read(id="skills/review", revision=')
|
|
88
|
+
expect(selected.sources[0].revision).toBe(revision)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a session gets blocks, rules, skills, knowledge and tools', () => {
|
|
92
|
+
const env = setup()
|
|
93
|
+
const pack = compile(env, session('p1'), { connections: 'Use get_capabilities.' })
|
|
94
|
+
expect(pack.profile).toBe('session')
|
|
95
|
+
expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
|
|
96
|
+
const text = render(env, pack)
|
|
97
|
+
for (const expected of ['<block label="user"', '<block label="project/xingular-agent"', 'Always run the tests.', 'review (skills/review)', '/projects/xingular-agent/decisions — Decisions', 'Use pnpm', 'App tools and connected apps: Use get_capabilities.']) expect(text).toContain(expected)
|
|
98
|
+
expect(text).not.toContain('Astro')
|
|
99
|
+
const cli = render(env, compile(env, { ...session('p1'), surface: 'cli' }))
|
|
100
|
+
expect(cli).toContain('agentx-wiki search')
|
|
101
|
+
expect(cli).toContain('agentx-wiki block append')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('long rules shorten only the rules section', () => {
|
|
105
|
+
const env = setup()
|
|
106
|
+
writeFileSync(join(env.root, 'registry/rules/tests.md'), `---\nname: Run tests\n---\n\n${'Always run the tests.\n\n'.repeat(600)}\n`)
|
|
107
|
+
const pack = compile(env, session('p1'))
|
|
108
|
+
const rules = pack.sections.find((entry) => entry.id === 'rules')!
|
|
109
|
+
expect(rules.cut && rules.size <= 3000 && rules.body.endsWith('wiki_list kind rule.)')).toBe(true)
|
|
110
|
+
expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
|
|
111
|
+
expect(pack.sections.filter((entry) => entry.id !== 'rules').every((entry) => !entry.cut)).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('an agent gets the application sections, its blocks and the tool groups in order', () => {
|
|
115
|
+
const env = setup()
|
|
116
|
+
const pack = compile(env, agent({ runId: 'run-1' }), hosted({
|
|
117
|
+
input: 'How does Astro render the site?',
|
|
118
|
+
tools: ['dispatch_work', 'list_agents', 'read'],
|
|
119
|
+
toolGroups: [
|
|
120
|
+
{ group: 'Work', tools: ['dispatch_work'], guidance: 'Work (dispatch_work): record and dispatch substantial tasks.', order: 30 },
|
|
121
|
+
{ group: 'Agents', tools: ['list_agents', 'get_agent'], guidance: 'Agents (list_agents, get_agent): see and message the other agents.', order: 40 },
|
|
122
|
+
{ group: 'Browser', tools: [], guidance: 'Browser: act in the managed browser.', order: 50 },
|
|
123
|
+
{ group: 'Files', tools: ['files_list', 'files_read'], guidance: 'Saved files (files_list, files_read): read the shared vault.', order: 80 }
|
|
124
|
+
]
|
|
125
|
+
}))
|
|
126
|
+
expect(pack.toolGroups.map((group) => group.group)).toEqual(['Memory', 'Knowledge', 'Work', 'Agents', 'Native', 'Files'])
|
|
127
|
+
expect(pack.toolGroups[3].tools).toEqual(['list_agents', 'get_agent'])
|
|
128
|
+
const tools = pack.sections.at(-1)!.body
|
|
129
|
+
expect(tools).toContain('Work (dispatch_work)')
|
|
130
|
+
expect(tools).toContain('Files (read)')
|
|
131
|
+
expect(tools).not.toContain('Browser')
|
|
132
|
+
expect(tools).not.toContain('connected apps')
|
|
133
|
+
expect(pack.profile).toBe('agent')
|
|
134
|
+
expect(ids(pack)).toEqual(['identity', 'role', 'blocks', 'rules', 'skills', 'work', 'knowledge', 'tools'])
|
|
135
|
+
const text = render(env, pack)
|
|
136
|
+
for (const expected of ['# Xingular Harness context\n\nYou are "Nora", an Xingular Agent agent.\n\n## Role', '<block label="persona"', '<block label="notes"', 'You have no unresolved work.', '- /projects/xingular-agent', 'Site uses Astro']) expect(text).toContain(expected)
|
|
137
|
+
// A volatile fact is never carried, even when it matches.
|
|
138
|
+
expect(text).not.toContain('On call this week')
|
|
139
|
+
// A skill enabled for one project is not listed for an agent, only pointed to.
|
|
140
|
+
expect(text).not.toContain('review (skills/review)')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a running session is told once when a block changes', () => {
|
|
144
|
+
const env = setup()
|
|
145
|
+
const directory = mkdtempSync(join(tmpdir(), 'harness-session-'))
|
|
146
|
+
saveForSession(directory, compile(env, session('p1')))
|
|
147
|
+
expect(memoryDelta(env, directory, session('p1'))).toBeNull()
|
|
148
|
+
blocks.edit(env.root, { kind: 'user' }, 'project/xingular-agent', { kind: 'append', text: 'Uses pnpm.' })
|
|
149
|
+
const note = memoryDelta(env, directory, session('p1'))!
|
|
150
|
+
expect(note.startsWith('[Memory updated since')).toBe(true)
|
|
151
|
+
expect(note).toContain('<block label="project/xingular-agent"')
|
|
152
|
+
expect(note).toContain('Uses pnpm.')
|
|
153
|
+
expect(note).not.toContain('<block label="user"')
|
|
154
|
+
expect(memoryDelta(env, directory, session('p1'))).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('a profile limits the sections and is recorded', () => {
|
|
158
|
+
const env = setup()
|
|
159
|
+
expect(profileFor(env.root, session('p1'))).toBe('full')
|
|
160
|
+
setProfile(env.root, 's1', 'minimal')
|
|
161
|
+
const pack = compile(env, session('p1'))
|
|
162
|
+
expect([pack.contextProfile, ids(pack)]).toEqual(['minimal', ['rules', 'tools']])
|
|
163
|
+
setProfile(env.root, 's1', 'off')
|
|
164
|
+
const off = compile(env, session('p1'))
|
|
165
|
+
expect([off.sections, off.toolGroups]).toEqual([[], []])
|
|
166
|
+
setProfile(env.root, 's1', 'full')
|
|
167
|
+
expect(profiles(env.root)).toEqual({})
|
|
168
|
+
expect(() => setProfile(env.root, '../x', 'off')).toThrow()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the pack is recorded with the run', () => {
|
|
172
|
+
const env = setup()
|
|
173
|
+
const text = launchText(env, session('p1'))
|
|
174
|
+
const saved = load(env.root, 'run-1')!
|
|
175
|
+
expect(render(env, saved)).toBe(text)
|
|
176
|
+
expect(saved.sections.some((entry) => entry.id === 'blocks' && entry.sources.some((source) => source.id === 'blocks/shared/user'))).toBe(true)
|
|
177
|
+
expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'rules/tests' && entry.via === 'entrypoint')).toBe(true)
|
|
178
|
+
expect(load(env.root, 'missing')).toBeNull()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
function delivered(path: string, update: ContextUpdate): void {
|
|
182
|
+
writeFileSync(path, `${JSON.stringify({ type: 'session', id: 'native-chat' })}\n${JSON.stringify({ type: 'custom_message', id: 'context-1', parentId: null, customType: 'xingular-context', content: update.content, details: { xingularContext: update.snapshot } })}\n`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test('state stays out of the system prompt and only delivered changes are resent', () => {
|
|
186
|
+
const env = setup()
|
|
187
|
+
const caller = agent({ runId: 'run-1' })
|
|
188
|
+
const first = sessionLaunch(env, caller, hosted(), null)
|
|
189
|
+
const initial = first.update!
|
|
190
|
+
expect(first.system).not.toContain('<block')
|
|
191
|
+
expect(first.system).not.toContain('You have no unresolved work.')
|
|
192
|
+
expect(first.system.endsWith('through tools when needed.\n')).toBe(true)
|
|
193
|
+
expect(initial.content).toContain('<block')
|
|
194
|
+
expect(initial.content).toContain('You have no unresolved work.')
|
|
195
|
+
// Recording a prepared launch is not evidence that the session received its context.
|
|
196
|
+
expect(sessionLaunch(env, caller, hosted(), null).update).not.toBeNull()
|
|
197
|
+
const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
|
|
198
|
+
delivered(history, initial)
|
|
199
|
+
const unchanged = sessionLaunch(env, caller, hosted(), history)
|
|
200
|
+
expect(unchanged.system).toBe(first.system)
|
|
201
|
+
expect(unchanged.update).toBeNull()
|
|
202
|
+
blocks.edit(env.root, { kind: 'user' }, 'user', { kind: 'append', text: 'New shared preference.' })
|
|
203
|
+
const changed = sessionLaunch(env, caller, hosted(), history)
|
|
204
|
+
expect(changed.system).toBe(first.system)
|
|
205
|
+
expect(changed.update!.content).toContain('New shared preference.')
|
|
206
|
+
expect(changed.update!.content).not.toContain('## Active work')
|
|
207
|
+
expect(changed.update!.content).not.toContain('## Knowledge')
|
|
208
|
+
expect(load(env.root, 'run-1')!.delivery!.updatedSectionIds).toEqual(['blocks'])
|
|
209
|
+
writeFileSync(join(env.root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nFollow the updated rule.\n')
|
|
210
|
+
const updated = sessionLaunch(env, caller, hosted(), history).system
|
|
211
|
+
expect(updated).not.toBe(first.system)
|
|
212
|
+
expect(updated).toContain('Follow the updated rule.')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('pinned application text reaches native history with context off without changing the system prefix', () => {
|
|
216
|
+
const env = setup()
|
|
217
|
+
setProfile(env.root, 'nora', 'off')
|
|
218
|
+
const before = sessionLaunch(env, agent(), hosted(), null)
|
|
219
|
+
const after = sessionLaunch(env, agent(), { sections: [identity, role, work('You have no unresolved work.\n', 'Recent task reports:\n\nTask t1: Reviewed evidence shown to the user\n')] }, null)
|
|
220
|
+
expect(after.system).toBe(before.system)
|
|
221
|
+
expect(after.update!.content).toContain('Reviewed evidence shown to the user')
|
|
222
|
+
expect(after.update!.content).not.toContain('You have no unresolved work.')
|
|
223
|
+
expect(before.update!.content).not.toContain('Reviewed evidence')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('references apply to each request and profile changes withdraw old state', () => {
|
|
227
|
+
const env = setup()
|
|
228
|
+
const revision = references.pin(env.root, 'skill', 'skills/review')
|
|
229
|
+
const launch = hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] })
|
|
230
|
+
const first = sessionLaunch(env, agent(), launch, null)
|
|
231
|
+
expect(first.system).not.toContain('Read the diff first.')
|
|
232
|
+
const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
|
|
233
|
+
delivered(history, first.update!)
|
|
234
|
+
const repeated = sessionLaunch(env, agent(), launch, history).update!
|
|
235
|
+
expect(repeated.content).toContain('Read the diff first.')
|
|
236
|
+
expect(repeated.content).not.toContain('## Memory blocks')
|
|
237
|
+
const removed = sessionLaunch(env, agent(), hosted(), history).update!
|
|
238
|
+
expect(removed.content).toContain('## references')
|
|
239
|
+
expect(removed.content).not.toContain('Read the diff first.')
|
|
240
|
+
setProfile(env.root, 'nora', 'off')
|
|
241
|
+
const withdrawn = sessionLaunch(env, agent(), hosted(), history).update!
|
|
242
|
+
expect(withdrawn.snapshot).toEqual([])
|
|
243
|
+
for (const id of ['blocks', 'references', 'work', 'knowledge']) expect(withdrawn.content).toContain(`## ${id}`)
|
|
244
|
+
expect(sessionLaunch(env, agent(), launch, history).update!.content).toContain('Read the diff first.')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('the snapshot follows the active branch and refreshes after compaction', () => {
|
|
248
|
+
const env = setup()
|
|
249
|
+
const initial = sessionLaunch(env, agent(), hosted(), null).update!
|
|
250
|
+
const directory = mkdtempSync(join(tmpdir(), 'harness-history-'))
|
|
251
|
+
const history = join(directory, 'chat.jsonl')
|
|
252
|
+
delivered(history, initial)
|
|
253
|
+
const original = readFileSync(history, 'utf8')
|
|
254
|
+
// An unrelated branch's snapshot must not decide what this branch already received.
|
|
255
|
+
writeFileSync(history, `${original}${JSON.stringify({ type: 'custom_message', id: 'other', parentId: null, customType: 'xingular-context', details: { xingularContext: [] } })}\n${JSON.stringify({ type: 'message', id: 'leaf', parentId: 'context-1' })}\n`)
|
|
256
|
+
expect(sessionLaunch(env, agent(), hosted(), history).update).toBeNull()
|
|
257
|
+
writeFileSync(history, `${original}${JSON.stringify({ type: 'compaction', id: 'summary', parentId: 'context-1' })}\n`)
|
|
258
|
+
expect(sessionLaunch(env, agent(), hosted(), history).update!.content).toContain('## Memory blocks')
|
|
259
|
+
expect(sessionFile(directory, join(directory, 'older.jsonl'))).toBe(join(directory, 'older.jsonl'))
|
|
260
|
+
expect(sessionFile(directory)).toBe(history)
|
|
261
|
+
})
|