Files
goodbuddy/src/agent-daemon/semantic-prompt-store.test.ts
T
mesalogo 561de29f00 feat: keep managed SSH prompts running on Hosts
Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages.

Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4.

Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
2026-08-31 11:04:41 +08:00

436 lines
12 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS
} from '../shared/remote-agent-contracts'
import { SemanticPromptStore } from './semantic-prompt-store'
const temporary: string[] = []
function store(options: {
maximumEventsPerPrompt?: number
maximumTranscriptBytes?: number
maximumRetainedAcknowledgedOperations?: number
} = {}): SemanticPromptStore {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-semantic-prompt-'))
temporary.push(root)
mkdirSync(join(root, 'state'), { mode: 0o700 })
return new SemanticPromptStore(join(root, 'state', 'prompts.sqlite'), {
now: () => 1_000,
...options
})
}
afterEach(() => {
for (const path of temporary.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('SemanticPromptStore', () => {
it('replays unacknowledged output and prunes it after durable acknowledgement', () => {
const first = store()
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { update: { sessionUpdate: 'agent_message_chunk' } }
})
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: {
status: 'completed',
response: { stopReason: 'end_turn' }
},
terminalState: 'completed'
})
expect(
first.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '1',
limit: 10
}).events.map((event) => event.sequence)
).toEqual(['2'])
expect(
first.acknowledge({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
acknowledgedSequence: '2'
})
).toMatchObject({ acknowledgedSequence: '2' })
const path = (first as unknown as { close(): void })
path.close()
// Reopen the durable authority as a brand-new Desktop-facing owner.
const root = temporary[0]!
const second = new SemanticPromptStore(
join(root, 'state', 'prompts.sqlite')
)
expect(
second.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({
state: 'completed',
sessionId: 'session-1',
latestSemanticSequence: '2'
})
expect(
second.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
})
).toMatchObject({
events: [],
acknowledgedSequence: '2',
latestSequence: '2',
state: 'completed'
})
second.close()
})
it('is idempotent for one canonical operation and rejects divergent starts', () => {
const prompts = store()
const preparation = {
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
}
expect(prompts.prepare(preparation)).toEqual({ created: true })
expect(prompts.prepare(preparation)).toEqual({ created: false })
const start = {
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
}
expect(prompts.begin(start).created).toBe(true)
expect(prompts.begin(start).created).toBe(false)
expect(
prompts.findStarted({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
startDigest: start.startDigest
})
).toMatchObject({ state: 'running', sessionId: 'session-1' })
expect(() =>
prompts.begin({
...start,
startDigest: `sha256:${'c'.repeat(64)}`
})
).toThrow(/conflicts/u)
prompts.close()
})
it('rejects transcript events beyond the per-event bound', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(1024 * 1024) }
})
).toThrow(/byte limit/u)
prompts.close()
})
it('reserves event-count capacity for durable terminal evidence', () => {
const prompts = store({ maximumEventsPerPrompt: 3 })
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
for (let index = 0; index < 2; index += 1) {
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { index }
})
}
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { index: 3 }
})
).toThrow(/quota/u)
expect(
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'completed' },
terminalState: 'completed'
})
).toMatchObject({ sequence: '3', kind: 'prompt-terminal' })
prompts.close()
})
it('reserves byte capacity for terminal evidence across restart', () => {
const first = store({
maximumTranscriptBytes:
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes + 256
})
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
expect(() =>
first.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(512) }
})
).toThrow(/quota/u)
first.close()
const reopened = new SemanticPromptStore(
join(temporary[0]!, 'state', 'prompts.sqlite'),
{
maximumTranscriptBytes:
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventBytes + 256
}
)
expect(
reopened.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'outcome-unknown' })
reopened.close()
})
it('keeps the first terminal transition authoritative', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'cancelled' },
terminalState: 'cancelled'
})
expect(() =>
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'prompt-terminal',
payload: { status: 'completed' },
terminalState: 'completed'
})
).toThrow(/terminal evidence/u)
expect(
prompts.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'cancelled' })
prompts.close()
})
it('marks nonterminal operations outcome-unknown after Agent restart', () => {
const first = store()
first.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
first.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
first.close()
const second = new SemanticPromptStore(
join(temporary[0]!, 'state', 'prompts.sqlite')
)
expect(
second.attach('binding-1', 'operation-1', 'controller-1')
).toMatchObject({ state: 'outcome-unknown' })
expect(
second.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
}).events
).toEqual([
expect.objectContaining({
kind: 'prompt-terminal',
payload: expect.objectContaining({
status: 'outcome-unknown'
})
})
])
second.close()
})
it('bounds transcript pages by aggregate control-frame bytes', () => {
const prompts = store()
prompts.prepare({
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'operation-1',
controllerId: 'controller-1',
preparationDigest: `sha256:${'a'.repeat(64)}`,
promptSequence: 0
})
prompts.begin({
bindingId: 'binding-1',
operationId: 'operation-1',
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: 'session-1'
})
for (let index = 0; index < 2; index += 1) {
prompts.append({
bindingId: 'binding-1',
operationId: 'operation-1',
kind: 'session-update',
payload: { text: 'x'.repeat(400 * 1024), index }
})
}
const first = prompts.page({
bindingId: 'binding-1',
operationId: 'operation-1',
controllerId: 'controller-1',
afterSequence: '0',
limit: 10
})
expect(first.events).toHaveLength(1)
expect(first.hasMore).toBe(true)
expect(Buffer.byteLength(JSON.stringify(first), 'utf8')).toBeLessThan(
768 * 1024
)
prompts.close()
})
it('prunes acknowledged terminal operations while the daemon remains running', () => {
const prompts = store({
maximumRetainedAcknowledgedOperations: 2
})
for (let index = 1; index <= 3; index += 1) {
const operationId = `operation-${index}`
prompts.prepare({
bindingId: 'binding-1',
operationId,
requestId: operationId,
controllerId: 'controller-1',
preparationDigest: `sha256:${String(index).repeat(64)}`,
promptSequence: index
})
prompts.begin({
bindingId: 'binding-1',
operationId,
startDigest: `sha256:${'b'.repeat(64)}`,
sessionId: `session-${index}`
})
prompts.append({
bindingId: 'binding-1',
operationId,
kind: 'prompt-terminal',
payload: {
status: 'completed',
response: { stopReason: 'end_turn' }
},
terminalState: 'completed'
})
prompts.acknowledge({
bindingId: 'binding-1',
operationId,
controllerId: 'controller-1',
acknowledgedSequence: '1'
})
}
expect(() =>
prompts.attach('binding-1', 'operation-1', 'controller-1')
).toThrow(/does not exist/u)
expect(
prompts.attach('binding-1', 'operation-2', 'controller-1')
).toMatchObject({ state: 'completed' })
expect(
prompts.attach('binding-1', 'operation-3', 'controller-1')
).toMatchObject({ state: 'completed' })
prompts.close()
})
})