@xingular/harness 0.1.5 → 0.1.7
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 +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -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 +130 -0
- 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 +95 -0
- 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 +27 -0
- 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 +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- 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 +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- 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 +57 -0
- 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 +58 -0
- 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 +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { colorToHex, stripTerminalSequences, visibleWidth, type TerminalColorMode } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { createHarnessLightTheme, createHarnessTheme } from './theme'
|
|
6
|
+
import { gradientLogo, HarnessWelcome, PI_LOGO } from './welcome'
|
|
7
|
+
|
|
8
|
+
function titanium(mode: TerminalColorMode = 'truecolor'): Theme {
|
|
9
|
+
return createHarnessTheme({ getColorMode: () => mode } as Theme)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('the Harness titanium presentation', () => {
|
|
13
|
+
it('uses the upstream titanium foreground and background colors with complete Pi fallbacks', () => {
|
|
14
|
+
const theme = titanium()
|
|
15
|
+
expect(theme.name).toBe('harness-titanium')
|
|
16
|
+
expect(theme.appearance).toBe('dark')
|
|
17
|
+
const expected = {
|
|
18
|
+
accent: '#00b4ff', border: '#2a3038', success: '#00ff88', error: '#ff4757',
|
|
19
|
+
customMessageLabel: '#d4c090', userMessageBg: '#0f1216', toolErrorBg: '#1a0f10',
|
|
20
|
+
thinkingMax: '#d4c090', scrollbarTrack: '#2a3038', scrollbarThumb: '#9ca3b0',
|
|
21
|
+
searchMatchBg: '#0082b3', searchMatchText: '#e8ecf4'
|
|
22
|
+
} as const
|
|
23
|
+
for (const [token, color] of Object.entries(expected)) {
|
|
24
|
+
expect(colorToHex(theme.colors[token as keyof typeof expected]), token).toBe(color)
|
|
25
|
+
}
|
|
26
|
+
expect(Object.keys(theme.colors)).toHaveLength(56)
|
|
27
|
+
expect(theme.getFgAnsi('text')).toBe('\x1b[39m')
|
|
28
|
+
expect(theme.getBgAnsi('userMessageBg')).toBe('\x1b[48;2;15;18;22m')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('offers the shipped light palette with complete native component fallbacks', () => {
|
|
32
|
+
const theme = createHarnessLightTheme(titanium())
|
|
33
|
+
expect(theme.name).toBe('light')
|
|
34
|
+
expect(theme.appearance).toBe('light')
|
|
35
|
+
expect(Object.keys(theme.colors)).toHaveLength(56)
|
|
36
|
+
expect(colorToHex(theme.colors.accent)).toBe('#5a8080')
|
|
37
|
+
expect(colorToHex(theme.colors.selectedBg)).toBe('#d0d0e0')
|
|
38
|
+
expect(colorToHex(theme.colors.toolSuccessBg)).toBe('#e8f0e8')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps the current terminal color mode and uses the exact upstream logo ramps', () => {
|
|
42
|
+
const truecolor = gradientLogo(PI_LOGO, 'truecolor')
|
|
43
|
+
expect(truecolor[0]).toMatch(/^\x1b\[38;2;248;79;204m█/)
|
|
44
|
+
expect(truecolor[0]).toContain('\x1b[38;2;147;98;244m█\x1b[0m')
|
|
45
|
+
expect(truecolor.map(stripTerminalSequences)).toEqual(PI_LOGO)
|
|
46
|
+
const theme = titanium('256color')
|
|
47
|
+
expect(theme.getColorMode()).toBe('256color')
|
|
48
|
+
const indexed = gradientLogo(PI_LOGO, theme.getColorMode())
|
|
49
|
+
expect(indexed[0]).toMatch(/^\x1b\[38;5;206m█/)
|
|
50
|
+
expect(indexed[0]).toContain('\x1b[38;5;99m█\x1b[0m')
|
|
51
|
+
expect(indexed.join('')).not.toContain('[38;2;')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps every rendered row within the terminal across narrow widths and Unicode product names', () => {
|
|
55
|
+
for (const name of ['Xingular Harness', 'Harness 知識 🧠', 'A'.repeat(120)]) {
|
|
56
|
+
const welcome = new HarnessWelcome(titanium(), name)
|
|
57
|
+
for (let width = 0; width <= 160; width++) {
|
|
58
|
+
const lines = welcome.render(width)
|
|
59
|
+
for (const line of lines) expect(visibleWidth(line), `width ${width}, ${name}`).toBeLessThanOrEqual(width)
|
|
60
|
+
if (width < 14) expect(lines).toEqual([])
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows a centered five-row logo alone in narrow terminals and centers the full lockup when it fits', () => {
|
|
66
|
+
const welcome = new HarnessWelcome(titanium(), 'Xingular Harness', '0.1.6')
|
|
67
|
+
const narrow = welcome.render(30).map(stripTerminalSequences)
|
|
68
|
+
expect(narrow).toEqual(PI_LOGO.map((line) => ' '.repeat(9) + line))
|
|
69
|
+
const wide = welcome.render(100).map(stripTerminalSequences)
|
|
70
|
+
expect(wide[1]).toContain('Xingular Harness')
|
|
71
|
+
expect(wide[4]).toContain('v0.1.6')
|
|
72
|
+
expect(wide[0].indexOf('█')).toBe(34)
|
|
73
|
+
expect(wide.slice(6).join(' ')).toContain('/accounts')
|
|
74
|
+
expect(wide.slice(6).join(' ')).toContain('/plugins')
|
|
75
|
+
expect(wide.slice(6).join(' ')).toContain('/hub')
|
|
76
|
+
expect(wide.join('')).not.toContain('omp')
|
|
77
|
+
expect(welcome.render(49)).toHaveLength(5)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { answerTerminalQuestion } from './questions'
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
const ui = { select: vi.fn(), input: vi.fn() }
|
|
7
|
+
return { ui, context: ui as unknown as ExtensionUIContext, signal: new AbortController().signal }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('terminal question answers', () => {
|
|
11
|
+
it('keeps action-like option labels distinct from dialog actions', async () => {
|
|
12
|
+
const { ui, context, signal } = fixture()
|
|
13
|
+
ui.select.mockResolvedValue('1. Write an answer — Actual choice')
|
|
14
|
+
expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'Write an answer', description: 'Actual choice' }] }, signal)).toEqual({ selected: ['Write an answer'] })
|
|
15
|
+
expect(ui.input).not.toHaveBeenCalled()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns to choices when text entry is cancelled and retains multiple selections', async () => {
|
|
19
|
+
const { ui, context, signal } = fixture()
|
|
20
|
+
ui.select.mockResolvedValueOnce('1. A').mockResolvedValueOnce('Write an answer').mockResolvedValueOnce('Submit choices')
|
|
21
|
+
ui.input.mockResolvedValue(undefined)
|
|
22
|
+
expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'A' }], multiSelect: true }, signal)).toEqual({ selected: ['A'] })
|
|
23
|
+
expect(ui.select).toHaveBeenCalledTimes(3)
|
|
24
|
+
expect(ui.select.mock.calls[2][1]).toContain('1. ✓ A')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows real batch progress and returns cancellation without inventing an answer', async () => {
|
|
28
|
+
const { ui, context, signal } = fixture()
|
|
29
|
+
ui.input.mockResolvedValue(undefined)
|
|
30
|
+
expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Why?', summary: 'Reason', batchId: 'batch', batchIndex: 2, batchCount: 3 }, signal)).toBeUndefined()
|
|
31
|
+
expect(ui.input).toHaveBeenCalledWith('2 of 3 · Reason: Why?', undefined, { signal })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('honors text restrictions and ignores answers after expiry', async () => {
|
|
35
|
+
const { ui, context } = fixture()
|
|
36
|
+
const controller = new AbortController()
|
|
37
|
+
ui.select.mockImplementation(async () => { controller.abort(); return '1. A' })
|
|
38
|
+
expect(await answerTerminalQuestion(context, { requestId: 'q', options: [{ label: 'A' }], allowText: false }, controller.signal)).toBeUndefined()
|
|
39
|
+
expect(ui.select.mock.calls[0][1]).toEqual(['1. A'])
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { QuestionAnswer } from '../../sessions/registry'
|
|
3
|
+
|
|
4
|
+
export interface TerminalQuestion {
|
|
5
|
+
requestId: string
|
|
6
|
+
question?: string
|
|
7
|
+
summary?: string
|
|
8
|
+
options?: { label: string; description?: string }[]
|
|
9
|
+
multiSelect?: boolean
|
|
10
|
+
allowText?: boolean
|
|
11
|
+
batchId?: string
|
|
12
|
+
batchIndex?: number
|
|
13
|
+
batchCount?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Answers stay local until the complete group can be submitted to the registry. */
|
|
17
|
+
export async function answerTerminalQuestion(ui: ExtensionUIContext, request: TerminalQuestion, signal: AbortSignal): Promise<QuestionAnswer | undefined> {
|
|
18
|
+
const choices = request.options ?? []
|
|
19
|
+
const selected: string[] = []
|
|
20
|
+
const progress = request.batchId ? `${request.batchIndex} of ${request.batchCount} · ` : ''
|
|
21
|
+
const title = `${progress}${request.summary ? request.summary + ': ' : ''}${request.question ?? 'Question'}`
|
|
22
|
+
if (!choices.length) {
|
|
23
|
+
if (request.allowText === false) return { selected }
|
|
24
|
+
const text = await ui.input(title, undefined, { signal })
|
|
25
|
+
return text === undefined || signal.aborted ? undefined : { selected, text }
|
|
26
|
+
}
|
|
27
|
+
while (!signal.aborted) {
|
|
28
|
+
const labels = choices.map((option, index) => `${index + 1}. ${selected.includes(option.label) ? '✓ ' : ''}${option.label}${option.description ? ' — ' + option.description : ''}`)
|
|
29
|
+
const menu = [...labels, ...(request.allowText !== false ? ['Write an answer'] : []), ...(request.multiSelect ? ['Submit choices'] : [])]
|
|
30
|
+
const choice = await ui.select(title, menu, { signal })
|
|
31
|
+
if (choice === undefined || signal.aborted) return undefined
|
|
32
|
+
if (choice === 'Submit choices') return { selected }
|
|
33
|
+
if (choice === 'Write an answer') {
|
|
34
|
+
const text = await ui.input(title, undefined, { signal })
|
|
35
|
+
if (signal.aborted) return undefined
|
|
36
|
+
if (text !== undefined) return { selected, text }
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const index = labels.indexOf(choice)
|
|
40
|
+
if (index < 0) throw new Error('Choose one of the displayed answers.')
|
|
41
|
+
const label = choices[index]!.label
|
|
42
|
+
if (selected.includes(label)) selected.splice(selected.indexOf(label), 1)
|
|
43
|
+
else selected.push(label)
|
|
44
|
+
if (!request.multiSelect) return { selected }
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { createHarness, type Harness } from '../../harness'
|
|
8
|
+
import { memorySecretStore } from '../../accounts/secrets'
|
|
9
|
+
import { connectionFromEnv, socketCall, terminalCall } from '../client'
|
|
10
|
+
import { controlCall } from '../control'
|
|
11
|
+
import { createTerminalRuntime, type TerminalRuntime } from './runtime'
|
|
12
|
+
|
|
13
|
+
const opened: Array<{ harness: Harness; runtime?: TerminalRuntime; root: string }> = []
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const entry of opened.splice(0)) {
|
|
16
|
+
await entry.runtime?.dispose()
|
|
17
|
+
await entry.harness.close()
|
|
18
|
+
rmSync(entry.root, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
vi.unstubAllEnvs()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
async function setup() {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-sdk-socket-'))
|
|
26
|
+
const cwd = join(root, 'project')
|
|
27
|
+
mkdirSync(cwd)
|
|
28
|
+
const harness = createHarness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), resolver: async () => [], piProgram: () => '/not-spawned/pi', assets: { node: process.execPath, cli: join(root, 'cli.js'), piExtension: join(root, 'terminal-pi.js') } })
|
|
29
|
+
const entry: { harness: Harness; runtime?: TerminalRuntime; root: string } = { harness, root }
|
|
30
|
+
opened.push(entry)
|
|
31
|
+
await harness.ready
|
|
32
|
+
const account = harness.accounts.create('Offline test', 'apiKey').accounts[0]
|
|
33
|
+
const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history') })
|
|
34
|
+
for (const [name, value] of Object.entries(launch.env)) vi.stubEnv(name, value === null ? undefined : value)
|
|
35
|
+
vi.spyOn(process, 'cwd').mockReturnValue(cwd)
|
|
36
|
+
const ui = { select: vi.fn(async () => undefined), confirm: vi.fn(async () => false), notify: vi.fn(), getEditorText: () => '' } as unknown as ExtensionUIContext
|
|
37
|
+
const runtime = await createTerminalRuntime(launch.args, { ui, notify: vi.fn(), changed: vi.fn(), shutdown: vi.fn() })
|
|
38
|
+
entry.runtime = runtime
|
|
39
|
+
return { harness, runtime, ui, account }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('a native SDK runtime on the actual Harness terminal socket', () => {
|
|
43
|
+
it('keeps the same account, command control and readiness after new/reload with no provider request', async () => {
|
|
44
|
+
const { harness, runtime, ui } = await setup()
|
|
45
|
+
expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
|
|
46
|
+
expect(runtime.session.extensionRunner.getExtensionPaths()).toEqual(['<inline:harness-terminal-bridge>'])
|
|
47
|
+
expect(runtime.services.agentDir).toContain(harness.accounts.dir)
|
|
48
|
+
for (const command of ['accounts', 'plugins', 'hub']) expect(runtime.session.extensionRunner.getCommand(command)).toBeDefined()
|
|
49
|
+
await runtime.session.prompt('/accounts')
|
|
50
|
+
expect(ui.select).toHaveBeenCalled()
|
|
51
|
+
await runtime.newSession()
|
|
52
|
+
await runtime.reload()
|
|
53
|
+
await runtime.session.prompt('/accounts')
|
|
54
|
+
expect(ui.select).toHaveBeenCalledTimes(2)
|
|
55
|
+
expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
|
|
56
|
+
await expect(controlCall('terminal.resources')).resolves.toEqual({})
|
|
57
|
+
await expect(socketCall(connectionFromEnv(), 'control.claim')).rejects.toThrow('already claimed')
|
|
58
|
+
const blocked = await runtime.session.extensionRunner.emitToolCall({ type: 'tool_call', toolName: 'bash', toolCallId: 'no-active-run', input: { command: 'echo must-not-execute' } })
|
|
59
|
+
expect(blocked?.block).toBe(true)
|
|
60
|
+
await runtime.dispose()
|
|
61
|
+
expect(harness.terminals.state('offline-sdk').ready).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('submits grouped terminal answers atomically through the protected real registry', async () => {
|
|
65
|
+
const { harness, runtime } = await setup()
|
|
66
|
+
const events: Array<{ type: string; payload: unknown }> = []
|
|
67
|
+
harness.runs.listen(event => { events.push(event) })
|
|
68
|
+
await terminalCall('begin', { text: 'Grouped terminal fixture' })
|
|
69
|
+
const tool = runtime.session.getToolDefinition('ask_user')!
|
|
70
|
+
const result = tool.execute('batch-tool', { questions: [
|
|
71
|
+
{ summary: 'First choice', question: 'Which?', options: [{ label: 'A', description: 'First option' }] },
|
|
72
|
+
{ summary: 'Second answer', question: 'Why?' }
|
|
73
|
+
] }, undefined, undefined, runtime.session.extensionRunner.createToolContext('batch-tool', undefined))
|
|
74
|
+
let questions: Array<{ requestId: string; batchId: string }> = []
|
|
75
|
+
await vi.waitFor(async () => {
|
|
76
|
+
questions = await runtime.pending() as typeof questions
|
|
77
|
+
expect(questions).toHaveLength(2)
|
|
78
|
+
})
|
|
79
|
+
expect(questions[0].batchId).toBe(questions[1].batchId)
|
|
80
|
+
const answers = [{ questionId: questions[0].requestId, answer: { selected: ['A'] } }, { questionId: questions[1].requestId, answer: { selected: [], text: 'Because' } }]
|
|
81
|
+
await expect(socketCall(connectionFromEnv(), 'terminal.answers', { batchId: questions[0].batchId, answers })).rejects.toThrow('Only a person')
|
|
82
|
+
await expect(controlCall('terminal.answers', { batchId: questions[0].batchId, answers: answers.slice(0, 1) })).rejects.toThrow('batch')
|
|
83
|
+
expect(events.filter(event => event.type === 'question.answered')).toHaveLength(0)
|
|
84
|
+
await runtime.answerBatch(questions[0].batchId, answers)
|
|
85
|
+
await expect(result).resolves.toMatchObject({ content: [{ text: 'First choice: The user chose: A.\nSecond answer: The user answered: Because' }] })
|
|
86
|
+
expect(events.filter(event => event.type === 'question.answered')).toHaveLength(2)
|
|
87
|
+
await expect(controlCall('terminal.pending')).resolves.toEqual({ requests: [] })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('settles an actual socket-backed pending question before disposing the native session', async () => {
|
|
91
|
+
const { harness, runtime } = await setup()
|
|
92
|
+
await terminalCall('begin', { text: 'Offline question bridge fixture' })
|
|
93
|
+
let questionReached!: () => void
|
|
94
|
+
const reached = new Promise<void>((resolve) => { questionReached = resolve })
|
|
95
|
+
harness.runs.listen((event) => { if (event.type === 'question.requested') questionReached() })
|
|
96
|
+
const tool = runtime.session.getToolDefinition('ask_user_question')!
|
|
97
|
+
const answer = tool.execute('offline-question', { question: 'Continue?', options: [] }, undefined, undefined, runtime.session.extensionRunner.createToolContext('offline-question', undefined))
|
|
98
|
+
await reached
|
|
99
|
+
await expect(controlCall<{ requests: unknown[] }>('terminal.pending')).resolves.toMatchObject({ requests: [expect.anything()] })
|
|
100
|
+
await runtime.dispose()
|
|
101
|
+
await expect(answer).resolves.toMatchObject({ content: [{ type: 'text', text: '{"selected":[]}' }] })
|
|
102
|
+
expect(harness.terminals.state('offline-sdk')).toEqual({ ready: false, dirty: false, active: false })
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import type { ExtensionAPI, ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
|
|
7
|
+
const transport = vi.hoisted(() => ({ claim: vi.fn(async () => 'private-control'), call: vi.fn(async (_call: string) => ({})) }))
|
|
8
|
+
vi.mock('../control', () => ({ claimTerminalControl: transport.claim, controlCall: transport.call }))
|
|
9
|
+
vi.mock('../piExtension', () => ({ default: (pi: ExtensionAPI) => {
|
|
10
|
+
pi.on('session_start', (_event, ctx) => { pi.appendEntry('bridge-start', { mode: ctx.mode }) })
|
|
11
|
+
} }))
|
|
12
|
+
|
|
13
|
+
import { assertTerminalSessionScope, createTerminalRuntime, parseTerminalArguments, type TerminalRuntime } from './runtime'
|
|
14
|
+
import { assertPiAdapterVersion, loadPiAdapter, withBuiltInRenderers } from './piAdapter'
|
|
15
|
+
|
|
16
|
+
const folders: string[] = []
|
|
17
|
+
const runtimes: TerminalRuntime[] = []
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const runtime of runtimes.splice(0)) await runtime.dispose()
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
vi.unstubAllEnvs()
|
|
22
|
+
transport.call.mockReset().mockResolvedValue({})
|
|
23
|
+
transport.claim.mockClear()
|
|
24
|
+
for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function fixture() {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-terminal-runtime-'))
|
|
29
|
+
folders.push(root)
|
|
30
|
+
const cwd = join(root, 'project')
|
|
31
|
+
const account = join(root, 'account')
|
|
32
|
+
const history = join(root, 'history')
|
|
33
|
+
mkdirSync(cwd)
|
|
34
|
+
mkdirSync(account)
|
|
35
|
+
mkdirSync(history)
|
|
36
|
+
vi.spyOn(process, 'cwd').mockReturnValue(cwd)
|
|
37
|
+
vi.stubEnv('PI_CODING_AGENT_DIR', account)
|
|
38
|
+
const plugin = join(root, 'fixture.mjs')
|
|
39
|
+
writeFileSync(plugin, `export default function (pi) {
|
|
40
|
+
pi.on('session_start', (_event, ctx) => pi.appendEntry('fixture-start', { mode: ctx.mode }))
|
|
41
|
+
pi.registerCommand('marker', { description: 'A local fixture command', handler: async (args) => { pi.appendEntry('marker', { text: args }) } })
|
|
42
|
+
}`)
|
|
43
|
+
const args = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', '--extension', plugin, '--extension', join(root, 'terminal-pi.js'), '--session-dir', history, '--append-system-prompt', 'Fixed Harness context.']
|
|
44
|
+
const changed = vi.fn()
|
|
45
|
+
const ui = { getEditorText: () => '', select: vi.fn(async () => undefined), notify: vi.fn() } as unknown as ExtensionUIContext
|
|
46
|
+
return { root, cwd, account, history, plugin, args, host: { ui, changed, shutdown: vi.fn(), notify: vi.fn() } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function openFixture() {
|
|
50
|
+
const f = fixture()
|
|
51
|
+
const runtime = await createTerminalRuntime(f.args, f.host)
|
|
52
|
+
runtimes.push(runtime)
|
|
53
|
+
return { ...f, runtime }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('the persistent terminal SDK adapter', () => {
|
|
57
|
+
it('parses only the prepared resource flags without discovering extra resources', () => {
|
|
58
|
+
const parsed = parseTerminalArguments(['--no-extensions', '--extension', 'builtin:mcp', '--extension', '/bridge.js', '--skill', '/skill', '--prompt-template', '/prompt', '--theme', '/theme', '--session-dir', '/history', '--session', '/history/old.jsonl', '--model', 'provider/model', '--append-system-prompt', 'context'])
|
|
59
|
+
expect(parsed).toEqual({ appendSystemPrompt: ['context'], extensions: ['builtin:mcp', '/bridge.js'], skills: ['/skill'], prompts: ['/prompt'], themes: ['/theme'], sessionDir: '/history', session: '/history/old.jsonl', model: 'provider/model' })
|
|
60
|
+
expect(() => parseTerminalArguments(['--serve'])).toThrow('Unsupported')
|
|
61
|
+
expect(() => parseTerminalArguments(['--session-dir'])).toThrow('needs a value')
|
|
62
|
+
expect(() => parseTerminalArguments(['--session-dir', '/history'])).toThrow('bridge extension')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('loads only explicit plugins and keeps events and commands alive across new, resume, fork and reload', async () => {
|
|
66
|
+
const { runtime, args, root, cwd } = await openFixture()
|
|
67
|
+
const first = runtime.session
|
|
68
|
+
const firstFile = first.sessionFile!
|
|
69
|
+
expect(first.systemPrompt).toContain('Fixed Harness context.')
|
|
70
|
+
expect(first.extensionRunner.getExtensionPaths()).toEqual([args[5], '<inline:harness-terminal-bridge>'])
|
|
71
|
+
expect(first.sessionManager.getEntries().filter((entry) => entry.type === 'custom').map((entry) => entry.data)).toEqual([{ mode: 'tui' }, { mode: 'tui' }])
|
|
72
|
+
const events: string[] = []
|
|
73
|
+
runtime.subscribe((event) => events.push(event.type))
|
|
74
|
+
const replacements: string[] = []
|
|
75
|
+
runtime.onSessionChange((session) => { replacements.push(session.sessionId) })
|
|
76
|
+
const before = vi.fn()
|
|
77
|
+
runtime.onBeforeSessionChange(before)
|
|
78
|
+
await runtime.session.prompt('/marker first')
|
|
79
|
+
// Native Pi keeps custom-entry-only sessions in memory until an assistant response. This
|
|
80
|
+
// fixture persists its actual native entries without making a provider request.
|
|
81
|
+
writeFileSync(firstFile, [first.sessionManager.getHeader(), ...first.sessionManager.getEntries()].map((entry) => JSON.stringify(entry)).join('\n') + '\n')
|
|
82
|
+
expect(events).toContain('entry_appended')
|
|
83
|
+
await runtime.newSession()
|
|
84
|
+
expect(runtime.session).not.toBe(first)
|
|
85
|
+
await runtime.session.prompt('/marker second')
|
|
86
|
+
await runtime.switchSession(firstFile)
|
|
87
|
+
expect(runtime.session.sessionFile).toBe(firstFile)
|
|
88
|
+
const marker = runtime.session.sessionManager.getEntries().find((entry) => entry.type === 'custom' && entry.customType === 'marker')!
|
|
89
|
+
await runtime.fork(marker.id, { position: 'at' })
|
|
90
|
+
expect(runtime.session.sessionFile).not.toBe(firstFile)
|
|
91
|
+
await runtime.reload()
|
|
92
|
+
await runtime.session.prompt('/marker reloaded')
|
|
93
|
+
expect(runtime.session.sessionManager.getEntries().some((entry) => entry.type === 'custom' && entry.customType === 'marker' && (entry.data as { text: string }).text === 'reloaded')).toBe(true)
|
|
94
|
+
expect(replacements).toHaveLength(4)
|
|
95
|
+
expect(before).toHaveBeenCalledTimes(4)
|
|
96
|
+
expect(transport.claim).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(transport.call.mock.calls.length).toBeGreaterThanOrEqual(6)
|
|
98
|
+
expect(transport.call.mock.calls.every(([call]) => ['terminal.resources', 'terminal.interrupt', 'accounts.session'].includes(call))).toBe(true)
|
|
99
|
+
expect(readFileSync(firstFile, 'utf8')).toContain(cwd)
|
|
100
|
+
expect(readdirSync(root)).not.toContain('.pi')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('ignores retained plugin UI callbacks after new, reload and disposal', async () => {
|
|
104
|
+
const { runtime, host } = await openFixture()
|
|
105
|
+
const first = runtime.session.extensionRunner.createContext().ui
|
|
106
|
+
const retainedNotify = first.notify
|
|
107
|
+
first.notify('current')
|
|
108
|
+
expect(host.ui.notify).toHaveBeenCalledWith('current')
|
|
109
|
+
await runtime.newSession()
|
|
110
|
+
retainedNotify('late from previous session')
|
|
111
|
+
expect(host.ui.notify).toHaveBeenCalledTimes(1)
|
|
112
|
+
expect(await first.select('Stale selector', ['One'])).toBeUndefined()
|
|
113
|
+
const second = runtime.session.extensionRunner.createContext().ui
|
|
114
|
+
second.notify('replacement')
|
|
115
|
+
expect(host.ui.notify).toHaveBeenCalledTimes(2)
|
|
116
|
+
await runtime.reload()
|
|
117
|
+
second.notify('late from old resources')
|
|
118
|
+
expect(host.ui.notify).toHaveBeenCalledTimes(2)
|
|
119
|
+
const reloaded = runtime.session.extensionRunner.createContext().ui
|
|
120
|
+
reloaded.notify('reloaded')
|
|
121
|
+
expect(host.ui.notify).toHaveBeenCalledTimes(3)
|
|
122
|
+
await runtime.dispose()
|
|
123
|
+
reloaded.notify('late after exit')
|
|
124
|
+
expect(host.ui.notify).toHaveBeenCalledTimes(3)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('returns immutable presentation state and routes submissions through the persistent session', async () => {
|
|
128
|
+
const { runtime } = await openFixture()
|
|
129
|
+
const first = runtime.snapshot()
|
|
130
|
+
expect(Object.isFrozen(first)).toBe(true)
|
|
131
|
+
expect(Object.isFrozen(first.queue.steering)).toBe(true)
|
|
132
|
+
expect(Object.isFrozen(first.context)).toBe(true)
|
|
133
|
+
expect(first.account).toBeNull()
|
|
134
|
+
await runtime.submit(' /marker from-runtime')
|
|
135
|
+
runtime.session.setSessionName('Updated title')
|
|
136
|
+
expect(first.name).toBeUndefined()
|
|
137
|
+
expect(runtime.snapshot().name).toBe('Updated title')
|
|
138
|
+
expect(runtime.session.sessionManager.getEntries().some(entry => entry.type === 'custom' && entry.customType === 'marker' && (entry.data as { text: string }).text === 'from-runtime')).toBe(true)
|
|
139
|
+
await runtime.dispose()
|
|
140
|
+
await expect(runtime.submit('late')).rejects.toThrow('closed')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('refuses changed resource trust before invalidating the existing session or loading new code', async () => {
|
|
144
|
+
const { runtime, plugin, root } = await openFixture()
|
|
145
|
+
const existing = runtime.session
|
|
146
|
+
const marker = join(root, 'untrusted-code-ran')
|
|
147
|
+
writeFileSync(plugin, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'yes'); export default function () {}`)
|
|
148
|
+
transport.call.mockRejectedValue(new Error('Plugin code changed; review and restart.'))
|
|
149
|
+
await expect(runtime.reload()).rejects.toThrow('Plugin code changed')
|
|
150
|
+
await expect(runtime.newSession()).rejects.toThrow('Plugin code changed')
|
|
151
|
+
expect(runtime.session).toBe(existing)
|
|
152
|
+
await runtime.session.prompt('/marker still-active')
|
|
153
|
+
expect(readdirSync(root)).not.toContain('untrusted-code-ran')
|
|
154
|
+
expect(runtime.session.extensionRunner.getCommand('marker')).toBeDefined()
|
|
155
|
+
await expect(runtime.session.resourceLoader.reload()).rejects.toThrow('Plugin code changed')
|
|
156
|
+
expect(readdirSync(root)).not.toContain('untrusted-code-ran')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('rejects cross-project resume/import before copying files or replacing the pinned session', async () => {
|
|
160
|
+
const { runtime, root, cwd, history } = await openFixture()
|
|
161
|
+
const other = join(root, 'other-project')
|
|
162
|
+
mkdirSync(other)
|
|
163
|
+
const imported = join(history, 'other.jsonl')
|
|
164
|
+
writeFileSync(imported, JSON.stringify({ type: 'session', version: 3, id: 'foreign', cwd: other, timestamp: new Date().toISOString() }) + '\n')
|
|
165
|
+
const current = runtime.session
|
|
166
|
+
const files = readdirSync(history)
|
|
167
|
+
await expect(runtime.switchSession(imported)).rejects.toThrow('another working folder')
|
|
168
|
+
await expect(runtime.importFromJsonl(imported, cwd)).rejects.toThrow('another working folder')
|
|
169
|
+
expect(runtime.session).toBe(current)
|
|
170
|
+
expect(readdirSync(history)).toEqual(files)
|
|
171
|
+
expect(() => assertTerminalSessionScope(imported, cwd, other)).toThrow('own working folder')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('rejects symlink escapes and same-project history outside the prepared history folder', async () => {
|
|
175
|
+
const { runtime, root, cwd, history, args, host } = await openFixture()
|
|
176
|
+
const external = join(root, 'external.jsonl')
|
|
177
|
+
writeFileSync(external, JSON.stringify({ type: 'session', version: 3, id: 'external', cwd, timestamp: new Date().toISOString() }) + '\n')
|
|
178
|
+
const symlink = join(history, 'escaped.jsonl')
|
|
179
|
+
symlinkSync(external, symlink)
|
|
180
|
+
const original = runtime.session
|
|
181
|
+
await expect(runtime.switchSession(external)).rejects.toThrow('terminal history folder')
|
|
182
|
+
await expect(runtime.switchSession(symlink)).rejects.toThrow('terminal history folder')
|
|
183
|
+
await expect(createTerminalRuntime([...args, '--session', symlink], host)).rejects.toThrow('terminal history folder')
|
|
184
|
+
expect(runtime.session).toBe(original)
|
|
185
|
+
await runtime.importFromJsonl(external)
|
|
186
|
+
expect(runtime.session.sessionManager.getHeader()?.id).toBe('external')
|
|
187
|
+
expect(runtime.session.sessionFile).toBe(join(realpathSync(history), 'external.jsonl'))
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('requires the prepared isolated account and refuses malformed session headers', async () => {
|
|
191
|
+
const f = fixture()
|
|
192
|
+
vi.stubEnv('PI_CODING_AGENT_DIR', '')
|
|
193
|
+
await expect(createTerminalRuntime(f.args, f.host)).rejects.toThrow('isolated account')
|
|
194
|
+
const invalid = join(f.root, 'invalid.jsonl')
|
|
195
|
+
writeFileSync(invalid, 'not a session\n')
|
|
196
|
+
expect(() => assertTerminalSessionScope(invalid, f.cwd)).toThrow('readable native session header')
|
|
197
|
+
writeFileSync(invalid, JSON.stringify({ type: 'session', cwd: 'relative' }))
|
|
198
|
+
expect(() => assertTerminalSessionScope(invalid, f.cwd)).toThrow('absolute working folder')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('loads the pinned rich renderers without opening a model or reading the clipboard', async () => {
|
|
202
|
+
expect(() => assertPiAdapterVersion('1.3.0')).toThrow('requires Pi 1.2.0')
|
|
203
|
+
const adapter = await loadPiAdapter()
|
|
204
|
+
expect(adapter.builtins.map((item) => typeof item === 'function' ? '' : item.name)).toEqual(['llama.cpp', 'codemode', 'tool-search', 'mcp'])
|
|
205
|
+
for (const name of ['read', 'bash', 'edit', 'write']) {
|
|
206
|
+
const renderers = withBuiltInRenderers(name, undefined)
|
|
207
|
+
expect(renderers?.renderCall).toBeTypeOf('function')
|
|
208
|
+
expect(renderers?.renderResult).toBeTypeOf('function')
|
|
209
|
+
}
|
|
210
|
+
const custom = { renderCall: vi.fn() }
|
|
211
|
+
expect(withBuiltInRenderers('read', custom)?.renderCall).toBe(custom.renderCall)
|
|
212
|
+
expect(adapter.readClipboardText).toBeTypeOf('function')
|
|
213
|
+
expect(adapter.readClipboardImage).toBeTypeOf('function')
|
|
214
|
+
})
|
|
215
|
+
})
|