* fix(harness): start Pi sessions with MCP tools under Node The desktop app always gives Pi OpenPencil's MCP server, and the published companion failed every such session: - @ai-sdk/harness-pi imports pi-mcp-adapter, which publishes only TypeScript sources, and Node refuses to strip types under node_modules. A module load hook now strips them for TypeScript dependencies only; Bun runs them as is. (vercel/ai#22040) - pi-mcp-adapter imports @earendil-works/pi-tui but declares it as an optional peer, so npm leaves it out. The companion depends on it at the version pi-coding-agent uses, and a test keeps the two in step. (nicobailon/pi-mcp-adapter#805) - Pi reports live-process resume, yet the service handed it state saved by an earlier session, and the just-bash sandbox cannot resume, so every later session with that ID failed. Live-process backends now start fresh and drop saved state. Pi also installs the packages from a person's Pi settings with npm on every session, writing npm's output to the companion's stdout. The companion now keeps the real stdout for protocol messages, sends other writes to stderr, and quiets npm on success. The app logs companion stderr as diagnostics, since failures arrive as protocol errors. * fix(harness): reject another backend's state before dropping it loadResumeState removed saved state for a live-process backend before createSession checked which backend owned it, so state saved by another backend was lost instead of rejected. Ownership is checked first, and only state that belongs to the live-process backend is dropped.
183 lines
5.7 KiB
TypeScript
183 lines
5.7 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import type {
|
|
BackendEvent,
|
|
BackendSession,
|
|
HarnessBackend,
|
|
HarnessResumeState
|
|
} from '../src/backends/types'
|
|
import type { HarnessSessionConfiguration } from '../src/protocol'
|
|
import { HarnessSessionService } from '../src/service'
|
|
import type { ResumeStateStore } from '../src/session-store'
|
|
|
|
const configuration: HarnessSessionConfiguration = {
|
|
adapter: 'pi',
|
|
sandbox: 'just-bash',
|
|
model: 'test/model'
|
|
}
|
|
|
|
class MemoryStore implements ResumeStateStore {
|
|
readonly states = new Map<string, HarnessResumeState>()
|
|
|
|
async load(sessionId: string): Promise<HarnessResumeState | undefined> {
|
|
return this.states.get(sessionId)
|
|
}
|
|
|
|
async save(sessionId: string, state: HarnessResumeState): Promise<void> {
|
|
this.states.set(sessionId, state)
|
|
}
|
|
|
|
async remove(sessionId: string): Promise<void> {
|
|
this.states.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
class FakeSession implements BackendSession {
|
|
readonly isResume: boolean
|
|
destroyed = false
|
|
|
|
constructor(
|
|
readonly sessionId: string,
|
|
isResume: boolean
|
|
) {
|
|
this.isResume = isResume
|
|
}
|
|
|
|
async *runTurn(prompt: string): AsyncIterable<BackendEvent> {
|
|
yield { type: 'text-delta', text: `reply:${prompt}` }
|
|
yield { type: 'finish', finishReason: 'stop' }
|
|
}
|
|
|
|
async stop(): Promise<HarnessResumeState> {
|
|
return {
|
|
type: 'resume-session',
|
|
harnessId: 'pi',
|
|
specificationVersion: 'harness-v1',
|
|
data: { sessionId: this.sessionId }
|
|
}
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
this.destroyed = true
|
|
}
|
|
}
|
|
|
|
class FakeBackend implements HarnessBackend {
|
|
readonly id = 'pi' as const
|
|
readonly capabilities
|
|
sessions: FakeSession[] = []
|
|
|
|
constructor(sessionResume: 'live-process' | 'persistent' = 'live-process') {
|
|
this.capabilities = {
|
|
adapter: 'pi',
|
|
sandboxes: ['just-bash'],
|
|
structuredOutput: false,
|
|
sessionResume,
|
|
turnContinuation: true
|
|
} as const
|
|
}
|
|
|
|
async createSession(options: {
|
|
sessionId: string
|
|
resumeState?: HarnessResumeState
|
|
configuration?: HarnessSessionConfiguration
|
|
}): Promise<BackendSession> {
|
|
const session = new FakeSession(options.sessionId, options.resumeState !== undefined)
|
|
this.sessions.push(session)
|
|
return session
|
|
}
|
|
}
|
|
|
|
describe('HarnessSessionService', () => {
|
|
test('reports adapter and sandbox capabilities', () => {
|
|
const backend = new FakeBackend()
|
|
const service = new HarnessSessionService(new Map([[backend.id, backend]]), new MemoryStore())
|
|
expect(service.capabilities()).toEqual([
|
|
{
|
|
adapter: 'pi',
|
|
sandboxes: ['just-bash'],
|
|
structuredOutput: false,
|
|
sessionResume: 'live-process',
|
|
turnContinuation: true
|
|
}
|
|
])
|
|
})
|
|
|
|
test('streams turns and resumes from opaque persisted state', async () => {
|
|
const backend = new FakeBackend('persistent')
|
|
const store = new MemoryStore()
|
|
const service = new HarnessSessionService(new Map([[backend.id, backend]]), store)
|
|
|
|
expect(await service.createSession('session-1', configuration)).toEqual({ isResume: false })
|
|
const events: BackendEvent[] = []
|
|
for await (const event of service.runTurn('session-1', 'hello')) events.push(event)
|
|
expect(events).toEqual([
|
|
{ type: 'text-delta', text: 'reply:hello' },
|
|
{ type: 'finish', finishReason: 'stop' }
|
|
])
|
|
|
|
await service.stopSession('session-1')
|
|
expect(store.states.get('session-1')?.data).toEqual({ sessionId: 'session-1' })
|
|
expect(await service.createSession('session-1', configuration)).toEqual({ isResume: true })
|
|
})
|
|
|
|
test('destroys a session and removes resumability', async () => {
|
|
const backend = new FakeBackend('persistent')
|
|
const store = new MemoryStore()
|
|
const service = new HarnessSessionService(new Map([[backend.id, backend]]), store)
|
|
await service.createSession('session-1', configuration)
|
|
await service.stopSession('session-1')
|
|
await service.createSession('session-1', configuration)
|
|
await service.destroySession('session-1')
|
|
|
|
expect(store.states.has('session-1')).toBeFalse()
|
|
expect(backend.sessions.at(-1)?.destroyed).toBeTrue()
|
|
})
|
|
|
|
test('rejects persisted state from another backend', async () => {
|
|
const store = new MemoryStore()
|
|
store.states.set('session-1', {
|
|
type: 'resume-session',
|
|
harnessId: 'other',
|
|
specificationVersion: 'harness-v1',
|
|
data: {}
|
|
})
|
|
const service = new HarnessSessionService(
|
|
new Map([['pi', new FakeBackend('persistent')]]),
|
|
store
|
|
)
|
|
await expect(service.createSession('session-1', configuration)).rejects.toThrow(
|
|
'belongs to other'
|
|
)
|
|
})
|
|
|
|
test('starts a live-process backend fresh and drops state saved by an earlier session', async () => {
|
|
const backend = new FakeBackend('live-process')
|
|
const store = new MemoryStore()
|
|
const service = new HarnessSessionService(new Map([[backend.id, backend]]), store)
|
|
await service.createSession('session-1', configuration)
|
|
await service.stopSession('session-1')
|
|
|
|
expect(await service.createSession('session-1', configuration)).toEqual({ isResume: false })
|
|
expect(store.states.has('session-1')).toBeFalse()
|
|
})
|
|
|
|
test("rejects another backend's state without deleting it for a live-process backend", async () => {
|
|
const store = new MemoryStore()
|
|
store.states.set('session-1', {
|
|
type: 'resume-session',
|
|
harnessId: 'other',
|
|
specificationVersion: 'harness-v1',
|
|
data: {}
|
|
})
|
|
const service = new HarnessSessionService(
|
|
new Map([['pi', new FakeBackend('live-process')]]),
|
|
store
|
|
)
|
|
await expect(service.createSession('session-1', configuration)).rejects.toThrow(
|
|
'belongs to other'
|
|
)
|
|
expect(store.states.has('session-1')).toBeTrue()
|
|
})
|
|
})
|