Managed SSH Runtime events could complete successfully and then fail local conversation persistence when tool details exceeded a second, smaller renderer-to-Main schema limit. Conversation persistence now stores the complete tool details already accepted from the Runtime, so long remote results no longer leave the final assistant message stuck in a streaming state. Release note: 修复远程项目工具结果较长时会话持久化失败的问题;Runtime 已返回的工具详情现在会完整保存到本地会话。
246 lines
7.0 KiB
TypeScript
246 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AssistantProject } from './assistant-contracts'
|
|
import {
|
|
builtInDefaultProjectSeedDescription,
|
|
builtInDefaultProjectSeedName,
|
|
conversationBranchInputSchema,
|
|
conversationSnapshotSchema,
|
|
isUntouchedBuiltInDefaultProject,
|
|
localConversationSaveBatchSchema,
|
|
persistedProjectExecutionSpaceSchema,
|
|
projectCreateSchema,
|
|
projectExecutionSpaceSchema,
|
|
projectUpdateSchema
|
|
} from './assistant-contracts'
|
|
|
|
const untouchedProject: AssistantProject = {
|
|
id: '00000000-0000-4000-8000-000000000101',
|
|
name: builtInDefaultProjectSeedName,
|
|
description: builtInDefaultProjectSeedDescription,
|
|
rootPath: 'C:\\Workspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\Workspace'
|
|
},
|
|
defaultWorkMode: 'ask',
|
|
kind: 'user',
|
|
builtInDefault: true,
|
|
status: 'active',
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}
|
|
|
|
describe('isUntouchedBuiltInDefaultProject', () => {
|
|
it('requires the persistent marker and exact seed text', () => {
|
|
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
|
|
|
|
const changedProjects: AssistantProject[] = [
|
|
{ ...untouchedProject, name: '默认项目 ' },
|
|
{ ...untouchedProject, description: 'GoodBuddy 默认工作区 ' },
|
|
{ ...untouchedProject, builtInDefault: false },
|
|
{ ...untouchedProject, builtInDefault: undefined }
|
|
]
|
|
|
|
expect(
|
|
changedProjects.every(
|
|
(project) => !isUntouchedBuiltInDefaultProject(project)
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('does not infer identity for an independently created identical project', () => {
|
|
const independentProject = { ...untouchedProject }
|
|
delete independentProject.builtInDefault
|
|
expect(
|
|
isUntouchedBuiltInDefaultProject(independentProject)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('keeps identity across unrelated project setting changes', () => {
|
|
expect(
|
|
isUntouchedBuiltInDefaultProject({
|
|
...untouchedProject,
|
|
rootPath: 'D:\\Moved',
|
|
defaultWorkMode: 'execute',
|
|
runtimeSelection: { provider: 'continue' },
|
|
status: 'archived',
|
|
updatedAt: '2026-08-01T00:00:01.000Z'
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('does not accept the read-only marker in project inputs', () => {
|
|
const input = {
|
|
name: builtInDefaultProjectSeedName,
|
|
description: builtInDefaultProjectSeedDescription,
|
|
rootPath: 'C:\\Workspace',
|
|
defaultWorkMode: 'ask',
|
|
builtInDefault: true
|
|
}
|
|
expect(projectCreateSchema.safeParse(input).success).toBe(false)
|
|
expect(projectUpdateSchema.safeParse(input).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('project execution space contracts', () => {
|
|
it('parses strict local and SSH output without normalizing paths', () => {
|
|
expect(
|
|
projectExecutionSpaceSchema.parse({
|
|
kind: 'local',
|
|
rootPath: ''
|
|
})
|
|
).toEqual({ kind: 'local', rootPath: '' })
|
|
expect(
|
|
projectExecutionSpaceSchema.parse({
|
|
kind: 'local',
|
|
rootPath: ' '
|
|
})
|
|
).toEqual({ kind: 'local', rootPath: ' ' })
|
|
expect(
|
|
projectExecutionSpaceSchema.parse({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
})
|
|
).toEqual({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
})
|
|
|
|
expect(
|
|
projectExecutionSpaceSchema.safeParse({
|
|
kind: 'local',
|
|
rootPath: 'C:\\Workspace',
|
|
hostId: '00000000-0000-4000-8000-000000000301'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
projectExecutionSpaceSchema.safeParse({
|
|
kind: 'ssh',
|
|
hostId: 'not-a-host-id',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
projectExecutionSpaceSchema.safeParse({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: ' '
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('persists only the SSH Host and remote path', () => {
|
|
expect(
|
|
persistedProjectExecutionSpaceSchema.parse({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
})
|
|
).toEqual({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
})
|
|
expect(
|
|
persistedProjectExecutionSpaceSchema.safeParse({
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy',
|
|
validation: { hostRevision: 2 }
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('keeps renderer project create and update inputs local-only', () => {
|
|
const input = {
|
|
name: '远程草稿',
|
|
description: '',
|
|
rootPath: '/srv/goodbuddy',
|
|
defaultWorkMode: 'ask',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000301',
|
|
remoteRootPath: '/srv/goodbuddy'
|
|
}
|
|
}
|
|
expect(projectCreateSchema.safeParse(input).success).toBe(false)
|
|
expect(projectUpdateSchema.safeParse(input).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('conversation branch contracts', () => {
|
|
it('accepts bounded branch provenance and rejects renderer extras', () => {
|
|
const sourceConversationId =
|
|
'00000000-0000-4000-8000-000000000201'
|
|
expect(
|
|
conversationBranchInputSchema.parse({
|
|
sourceConversationId,
|
|
title: '方案讨论 · 分支'
|
|
})
|
|
).toEqual({
|
|
sourceConversationId,
|
|
title: '方案讨论 · 分支'
|
|
})
|
|
expect(
|
|
conversationBranchInputSchema.safeParse({
|
|
sourceConversationId,
|
|
title: '方案讨论 · 分支',
|
|
copyTasks: true
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
conversationSnapshotSchema.safeParse({
|
|
id: '00000000-0000-4000-8000-000000000202',
|
|
branch: {
|
|
sourceConversationId,
|
|
sourceTitle: '方案讨论'
|
|
},
|
|
title: '方案讨论 · 分支',
|
|
updatedAt: 1,
|
|
messages: []
|
|
}).success
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('conversation persistence contracts', () => {
|
|
it('preserves complete tool details without persistence length limits', () => {
|
|
const input = 'input'.repeat(10_000)
|
|
const output = 'output'.repeat(20_000)
|
|
const error = 'error'.repeat(10_000)
|
|
const batch = [
|
|
{
|
|
header: {
|
|
id: '00000000-0000-4000-8000-000000000301',
|
|
title: '远程工具会话',
|
|
updatedAt: 1
|
|
},
|
|
messages: [
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000302',
|
|
role: 'assistant',
|
|
content: '',
|
|
createdAt: 1,
|
|
state: 'complete',
|
|
tools: [
|
|
{
|
|
callId: 'remote-tool-call',
|
|
name: '远程工具',
|
|
state: 'completed',
|
|
summary: '完整远程工具结果',
|
|
input,
|
|
output,
|
|
error
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
|
|
})
|
|
})
|